haixunMaster/extension/haixun-threads-sync/service-worker.js

253 lines
7.6 KiB
JavaScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { buildStorageState } from "./storage-state.js";
const CONTENT_SCRIPT_ID = "haixun-bridge";
2026-06-23 16:55:10 +00:00
const GO_API_SUCCESS_CODE = 102000;
const DEV_WEB_PORTS = new Set(["3000", "4173", "5173"]);
2026-06-21 12:50:31 +00:00
2026-06-23 16:55:10 +00:00
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()];
2026-06-21 12:50:31 +00:00
}
async function ensureContentScript(serverOrigin) {
2026-06-23 16:55:10 +00:00
const patterns = equivalentOrigins(serverOrigin).map((item) => `${item}/*`);
2026-06-21 12:50:31 +00:00
const existing = await chrome.scripting.getRegisteredContentScripts();
if (existing.some((script) => script.id === CONTENT_SCRIPT_ID)) {
return;
}
await chrome.scripting.registerContentScripts([
{
id: CONTENT_SCRIPT_ID,
2026-06-23 16:55:10 +00:00
matches: patterns,
2026-06-21 12:50:31 +00:00
js: ["content-haixun.js"],
2026-06-23 16:55:10 +00:00
runAt: "document_start",
2026-06-21 12:50:31 +00:00
},
]);
}
2026-06-23 16:55:10 +00:00
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.
}
}
}
2026-06-21 12:50:31 +00:00
2026-06-23 16:55:10 +00:00
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;
2026-06-21 12:50:31 +00:00
}
2026-06-23 16:55:10 +00:00
export async function syncThreadsSessionToGo(serverUrl, accountId, accessToken) {
2026-06-21 12:50:31 +00:00
const origin = new URL(serverUrl).origin;
2026-06-23 16:55:10 +00:00
const apiBase = resolveApiBase(serverUrl);
2026-06-21 12:50:31 +00:00
2026-06-23 16:55:10 +00:00
if (!accountId) {
throw new Error(
"缺少經營帳號 ID。請在巡樓頂部切換帳號或開啟 /threads/:id/connections 後再同步"
);
}
if (!accessToken) {
const hint = equivalentOrigins(origin).join(" 或 ");
throw new Error(
`找不到巡樓登入狀態。請在 Chrome 開啟並登入 ${hint}(需為新版巡樓 :5173不是舊版 :3000`
);
2026-06-21 12:50:31 +00:00
}
2026-06-23 16:55:10 +00:00
let storageState;
try {
storageState = await buildStorageState();
} catch (error) {
throw error;
}
2026-06-21 12:50:31 +00:00
2026-06-23 16:55:10 +00:00
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;
}
2026-06-21 12:50:31 +00:00
2026-06-23 16:55:10 +00:00
const raw = await res.json().catch(() => ({}));
2026-06-21 12:50:31 +00:00
if (!res.ok) {
2026-06-23 16:55:10 +00:00
throw new Error(parseApiError(raw, res.status));
2026-06-21 12:50:31 +00:00
}
2026-06-23 16:55:10 +00:00
const data = unwrapGoApiResponse(raw);
if (data.valid === false) {
throw new Error(data.message || "Session 驗證失敗");
}
2026-06-21 12:50:31 +00:00
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;
2026-06-23 16:55:10 +00:00
if (activeUrl) {
2026-06-21 12:50:31 +00:00
try {
2026-06-23 16:55:10 +00:00
const origin = new URL(activeUrl).origin;
const port = new URL(activeUrl).port;
if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/threads/")) {
return origin;
}
2026-06-21 12:50:31 +00:00
} catch {
// ignore
}
}
2026-06-23 16:55:10 +00:00
throw new Error("請在擴充功能選項設定巡樓網址(例如 http://localhost:5173或先開啟巡樓分頁");
2026-06-21 12:50:31 +00:00
}
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);
2026-06-23 16:55:10 +00:00
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 已同步",
});
2026-06-21 12:50:31 +00:00
} 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) {
2026-06-23 16:55:10 +00:00
await chrome.storage.sync.set({ serverUrl: "http://localhost:5173" });
2026-06-21 12:50:31 +00:00
}
});