import { buildStorageState } from "./storage-state.js"; const CONTENT_SCRIPT_ID = "haixun-bridge"; const GO_API_SUCCESS_CODE = 102000; const DEV_WEB_PORTS = new Set(["3000", "4173", "5173"]); function unwrapGoApiResponse(raw) { if (raw && typeof raw === "object" && "code" in raw) { if (raw.code !== GO_API_SUCCESS_CODE) { throw new Error(raw.message || `API error ${raw.code}`); } return raw.data && typeof raw.data === "object" ? raw.data : {}; } return raw && typeof raw === "object" ? raw : {}; } function parseApiError(raw, status) { if (raw && typeof raw === "object") { if (typeof raw.message === "string" && raw.message.trim()) return raw.message; if (typeof raw.error === "string" && raw.error.trim()) return raw.error; } return `匯入失敗(HTTP ${status})`; } function equivalentOrigins(origin) { const url = new URL(origin); const port = url.port ? `:${url.port}` : ""; const origins = new Set([url.origin]); if (url.hostname === "localhost") { origins.add(`${url.protocol}//127.0.0.1${port}`); } else if (url.hostname === "127.0.0.1") { origins.add(`${url.protocol}//localhost${port}`); } return [...origins]; } function resolveApiBase(serverUrl) { const url = new URL(serverUrl); if (DEV_WEB_PORTS.has(url.port)) { // Prefer 127.0.0.1 to match backend default bind address. const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname; return `${url.protocol}//${host}:8890`; } return url.origin; } async function requestHostPermission(serverOrigin) { const origins = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); const granted = await chrome.permissions.contains({ origins }); if (granted) return true; return chrome.permissions.request({ origins }); } async function queryHaixunTabs(serverOrigin) { const tabsById = new Map(); for (const origin of equivalentOrigins(serverOrigin)) { const tabs = await chrome.tabs.query({ url: `${origin}/*` }); for (const tab of tabs) { if (tab.id != null) tabsById.set(tab.id, tab); } } return [...tabsById.values()]; } async function ensureContentScript(serverOrigin) { const patterns = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); const existing = await chrome.scripting.getRegisteredContentScripts(); if (existing.some((script) => script.id === CONTENT_SCRIPT_ID)) { return; } await chrome.scripting.registerContentScripts([ { id: CONTENT_SCRIPT_ID, matches: patterns, js: ["content-haixun.js"], runAt: "document_start", }, ]); } async function injectBridgeIntoTabs(serverOrigin) { const tabs = await queryHaixunTabs(serverOrigin); for (const tab of tabs) { if (!tab.id) continue; try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content-haixun.js"], }); } catch { // Ignore duplicate injection / restricted pages. } } } async function readAuthFromHaixunTab(serverOrigin) { const tabs = await queryHaixunTabs(serverOrigin); for (const tab of tabs) { if (!tab.id) continue; const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { const pathname = location.pathname ?? ""; const fromPath = pathname.match(/\/threads\/([^/]+)/)?.[1] ?? ""; return { accessToken: localStorage.getItem("haixun.access_token") ?? "", accountId: fromPath || localStorage.getItem("haixun.active_threads_account_id") || "", origin: location.origin, }; }, }); const payload = result?.result; if (payload?.accessToken) { return payload; } } return null; } export async function syncThreadsSessionToGo(serverUrl, accountId, accessToken) { const origin = new URL(serverUrl).origin; const apiBase = resolveApiBase(serverUrl); if (!accountId) { throw new Error( "缺少經營帳號 ID。請在巡樓頂部切換帳號,或開啟 /threads/:id/connections 後再同步" ); } if (!accessToken) { const hint = equivalentOrigins(origin).join(" 或 "); throw new Error( `找不到巡樓登入狀態。請在 Chrome 開啟並登入 ${hint}(需為新版巡樓 :5173,不是舊版 :3000)` ); } let storageState; try { storageState = await buildStorageState(); } catch (error) { throw error; } let res; try { res = await fetch( `${apiBase}/api/v1/threads-accounts/${encodeURIComponent(accountId)}/session/import`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ storageState }), } ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (/fetch|network|failed/i.test(msg)) { throw new Error( `無法連線後端 ${apiBase}。請確認已執行 make run(或 backend:8d),且 :8890 有在跑` ); } throw error; } const raw = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(parseApiError(raw, res.status)); } const data = unwrapGoApiResponse(raw); if (data.valid === false) { throw new Error(data.message || "Session 驗證失敗"); } return data; } async function resolveServerUrl(partial) { if (partial) return new URL(partial).origin; const stored = await chrome.storage.sync.get(["serverUrl"]); if (stored.serverUrl) return new URL(stored.serverUrl).origin; const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const activeUrl = tabs[0]?.url; if (activeUrl) { try { const origin = new URL(activeUrl).origin; const port = new URL(activeUrl).port; if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/threads/")) { return origin; } } catch { // ignore } } throw new Error("請在擴充功能選項設定巡樓網址(例如 http://localhost:5173),或先開啟巡樓分頁"); } chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.action !== "sync") return undefined; (async () => { try { const serverUrl = await resolveServerUrl(message.serverUrl); const allowed = await requestHostPermission(serverUrl); if (!allowed) { throw new Error("需要授權存取巡樓網站才能同步"); } await ensureContentScript(serverUrl); await injectBridgeIntoTabs(serverUrl); let accountId = message.accountId ?? ""; let accessToken = message.accessToken ?? ""; if (!accountId || !accessToken) { const auth = await readAuthFromHaixunTab(serverUrl); if (auth) { accountId = accountId || auth.accountId; accessToken = accessToken || auth.accessToken; } } const result = await syncThreadsSessionToGo(serverUrl, accountId, accessToken); sendResponse({ success: true, valid: result.valid !== false, synced: result.synced ?? true, account_id: result.account_id ?? result.accountId, username: result.username, message: result.message || "Chrome session 已同步", }); } catch (error) { sendResponse({ success: false, valid: false, message: error instanceof Error ? error.message : "同步失敗", }); } })(); return true; }); chrome.runtime.onInstalled.addListener(async () => { const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]); if (!serverUrl) { await chrome.storage.sync.set({ serverUrl: "http://localhost:5173" }); } });