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

253 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" });
}
});