importScripts("config.js", "shared.js"); const ALARM_NAME = "deer-browser-task-poll"; const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001"; const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174"; let pollTimer = null; let isPolling = false; let startupPromise = null; const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function getSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); const discovered = await chrome.storage.local.get({ discoveredApiBaseUrl: "", discoveredWebBaseUrl: "", }); if (settings.autoDiscoverServiceUrl) { settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl; settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl; } const updates = {}; if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) { updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl; } if (!settings.webBaseUrl || settings.webBaseUrl === LEGACY_LOCAL_WEB_BASE_URL) { updates.webBaseUrl = DEER_CLIPPER_DEFAULTS.webBaseUrl; } if (!settings.workerId) { updates.workerId = deerMakeWorkerId(); } if (Object.keys(updates).length > 0) { await chrome.storage.sync.set(updates); Object.assign(settings, updates); } return settings; } function pollIntervalMs(settings) { const seconds = Number(settings.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds); return Math.max(3, seconds) * 1000; } function originMatchPattern(value) { try { const url = new URL(deerNormalizeBaseUrl(value)); return `${url.protocol}//${url.host}/*`; } catch { return ""; } } function normalizeServiceBase(value) { return deerNormalizeBaseUrl(value || ""); } function serviceBaseFromTabUrl(value) { try { const url = new URL(value); const path = url.pathname.replace(/\/+$/, ""); if (path === "/deerflow" || path.startsWith("/deerflow/")) { return `${url.origin}/deerflow`; } return url.origin; } catch { return ""; } } function apiCandidatesForWebBase(webBase, currentApiBase) { const candidates = []; const add = (value) => { const normalized = normalizeServiceBase(value); if (normalized && !candidates.includes(normalized)) { candidates.push(normalized); } }; add(webBase); try { const url = new URL(normalizeServiceBase(webBase)); if (["5173", "5174", "3000"].includes(url.port)) { const apiUrl = new URL(url.href); apiUrl.port = "8001"; add(apiUrl.href); } } catch { // Ignore invalid candidates. } add(currentApiBase); add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); add(LEGACY_LOCAL_API_BASE_URL); return candidates; } async function browserContextStatus(apiBaseUrl) { try { const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, { credentials: "include", }); // The gateway may enforce authentication globally before this otherwise // public discovery endpoint is reached. A 401/403 still proves that this // candidate is the zncm API paired with the open frontend tab. if (response.status === 401 || response.status === 403) { return { enabled: true, authenticationRequired: true }; } if (!response.ok) { return null; } const data = await response.json().catch(() => null); if (data && typeof data.enabled === "boolean") { return data; } } catch { return null; } return null; } function tabLooksLikeZncm(tab) { const value = `${tab.url || ""} ${tab.title || ""}`.toLowerCase(); return ( value.includes("deerflow") || value.includes("deer") || value.includes("zncm") || value.includes("/page/workspace") || value.includes("/chats/") || value.includes("#/login") ); } async function findOpenZncmTabs(settings) { const tabs = []; const seen = new Set(); const addTabs = (items) => { for (const tab of items || []) { if (tab?.id && !seen.has(tab.id)) { seen.add(tab.id); tabs.push(tab); } } }; for (const pattern of [ originMatchPattern(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl), originMatchPattern(DEER_CLIPPER_DEFAULTS.webBaseUrl), ]) { if (!pattern) { continue; } try { addTabs(await chrome.tabs.query({ url: pattern })); } catch { // Continue with broader discovery below. } } try { const allTabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] }); addTabs(allTabs.filter(tabLooksLikeZncm)); } catch { // Ignore tab discovery failures. } return tabs; } async function discoverServiceBases(settings, force = false) { if (!settings.autoDiscoverServiceUrl && !force) { return settings; } let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); let discoveredWebBaseUrl = webBaseUrl; let source = "configured"; const tabs = await findOpenZncmTabs(settings); if (tabs.length > 0) { const tabBase = serviceBaseFromTabUrl(tabs[0].url); if (tabBase) { discoveredWebBaseUrl = tabBase; source = `tab:${tabs[0].url || tabBase}`; } } let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); let verified = false; for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) { const status = await browserContextStatus(candidate); if (status) { apiBaseUrl = candidate; webBaseUrl = discoveredWebBaseUrl; verified = true; source = status.enabled ? `${source}:browser_context_enabled` : `${source}:browser_context_disabled`; break; } } if (!verified && force) { source = `${source}:api_not_verified`; } const next = { ...settings, apiBaseUrl, webBaseUrl, lastServiceSyncAt: new Date().toISOString(), lastServiceSyncSource: source, }; if ( force || settings.apiBaseUrl !== next.apiBaseUrl || settings.webBaseUrl !== next.webBaseUrl || settings.lastServiceSyncSource !== next.lastServiceSyncSource ) { // Discovered endpoints are runtime state, not user-authored preferences. // Keep them in storage.local so Chrome sync write quotas cannot break task // polling. The options page may still persist explicit user choices. await chrome.storage.local.set({ discoveredApiBaseUrl: next.apiBaseUrl, discoveredWebBaseUrl: next.webBaseUrl, lastServiceSyncAt: next.lastServiceSyncAt, lastServiceSyncSource: next.lastServiceSyncSource, }); } return next; } async function getCookieToken(settings) { if (!chrome.cookies?.get) { return null; } const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); try { const cookie = await chrome.cookies.get({ url: apiBaseUrl, name: "access_token" }); if (cookie?.value) { return { token: cookie.value, source: `cookie:${new URL(apiBaseUrl).host}:access_token` }; } } catch (error) { console.warn("Failed to read zncm access_token cookie", error); } return null; } async function getTokenFromOpenZncmTab(settings) { const tabs = await findOpenZncmTabs(settings); let sawZncmTab = false; for (const tab of tabs) { if (!tab.id) { continue; } sawZncmTab = true; try { const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: deerExtractAuthTokenFromPage, }); const tokenInfo = result?.result; if (tokenInfo?.token) { const tabBase = serviceBaseFromTabUrl(tab.url); if (tabBase && settings.autoDiscoverServiceUrl && settings.webBaseUrl !== tabBase) { await chrome.storage.sync.set({ webBaseUrl: tabBase, lastServiceSyncAt: new Date().toISOString(), lastServiceSyncSource: `token-tab:${tab.url || tabBase}`, }); } return { token: tokenInfo.token, source: tokenInfo.source || `tab:${tab.url || "open-deer-tab"}`, }; } } catch (error) { console.warn("Failed to read zncm token from tab", tab.id, error); } } if (sawZncmTab) { return { token: "", source: "open-deer-tab", clearStale: true }; } return null; } async function syncAuthToken(settings = null, forceStatus = false) { let current = settings || (await getSettings()); current = await discoverServiceBases(current); if (!current.tokenSyncEnabled && !forceStatus) { return current; } const tokenInfo = (await getTokenFromOpenZncmTab(current)) || (await getCookieToken(current)); if (!tokenInfo?.token) { if (tokenInfo?.clearStale && current.accessToken) { const next = { ...current, accessToken: "", lastTokenSyncSource: "cleared:open-deer-tab-no-token", lastTokenSyncAt: new Date().toISOString(), }; await chrome.storage.sync.set({ accessToken: "", lastTokenSyncSource: next.lastTokenSyncSource, lastTokenSyncAt: next.lastTokenSyncAt, }); return next; } return current; } const next = { ...current, accessToken: tokenInfo.token, lastTokenSyncSource: tokenInfo.source, lastTokenSyncAt: new Date().toISOString(), }; if ( forceStatus || current.accessToken !== next.accessToken || current.lastTokenSyncSource !== next.lastTokenSyncSource ) { await chrome.storage.sync.set({ accessToken: next.accessToken, lastTokenSyncSource: next.lastTokenSyncSource, lastTokenSyncAt: next.lastTokenSyncAt, }); } return next; } async function refreshAlarm(settings) { await chrome.alarms.clear(ALARM_NAME); if (!settings.taskPollingEnabled) { return; } const minutes = Math.max(0.5, pollIntervalMs(settings) / 60000); await chrome.alarms.create(ALARM_NAME, { delayInMinutes: minutes, periodInMinutes: minutes }); } async function scheduleNextPoll(delayMs = null) { if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } const settings = await getSettings(); if (!settings.taskPollingEnabled) { return; } const ms = delayMs ?? pollIntervalMs(settings); pollTimer = setTimeout(() => { void pollOnce().catch((error) => console.warn("zncm task poll failed", error)); }, ms); } async function waitForTabLoad(tabId, timeoutSeconds) { const timeoutMs = Math.max(5, Number(timeoutSeconds || 30)) * 1000; try { const tab = await chrome.tabs.get(tabId); if (tab.status === "complete") { return true; } } catch { return false; } return new Promise((resolve) => { const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(false); }, timeoutMs); function listener(updatedTabId, changeInfo) { if (updatedTabId === tabId && changeInfo.status === "complete") { clearTimeout(timer); chrome.tabs.onUpdated.removeListener(listener); resolve(true); } } chrome.tabs.onUpdated.addListener(listener); }); } async function captureFromTab(tabId, maxContentChars) { const [result] = await chrome.scripting.executeScript({ target: { tabId }, func: deerExtractCurrentPage, }); const page = result?.result; if (!page?.content) { throw new Error("当前页面没有可读取的文本。"); } const limit = Math.max(1000, Number(maxContentChars || 200000)); if (page.content.length > limit) { page.content = `${page.content.slice(0, limit)}\n\n[Truncated by zncm browser task limit]`; } return page; } async function postTaskResult(taskId, settings, payload) { const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); const response = await fetch(`${apiBaseUrl}/api/browser-context/tasks/${encodeURIComponent(taskId)}/result`, { method: "POST", headers: { "Content-Type": "application/json", ...deerAuthHeaders(settings), }, body: JSON.stringify(payload), }); const data = await response.json().catch(() => ({})); if (!response.ok) { const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data); throw new Error(detail || `HTTP ${response.status}`); } return data; } function zncmApiBaseCandidates(settings) { const candidates = []; const add = (value) => { const normalized = deerNormalizeBaseUrl(value || ""); if (normalized && !candidates.includes(normalized)) { candidates.push(normalized); } }; add(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); try { const url = new URL(deerNormalizeBaseUrl(settings.apiBaseUrl || "")); if (url.pathname && url.pathname !== "/") { add(url.origin); } } catch { // Ignore invalid configured API URL. } add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); return candidates; } async function zncmFetchJson(settings, path, options) { let lastError = null; for (const apiBaseUrl of zncmApiBaseCandidates(settings)) { try { const response = await fetch(`${apiBaseUrl}${path}`, options); const contentType = response.headers.get("content-type") || ""; const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : { detail: await response.text().catch(() => "") }; if (response.ok) { return { data, apiBaseUrl, status: response.status }; } const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data); lastError = new Error(detail || `HTTP ${response.status}`); lastError.status = response.status; lastError.detail = detail; lastError.apiBaseUrl = apiBaseUrl; if (response.status !== 404) { throw lastError; } } catch (error) { lastError = error; if (error?.status && error.status !== 404) { throw error; } } } throw lastError || new Error("zncm 服务请求失败。"); } function zncmMakeThreadId() { if (globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); } return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function zncmBuildPagePrompt(page, content, truncated, followUp) { const title = page.title || "未知标题"; const url = page.url || "未知 URL"; const capturedAt = new Date().toISOString(); if (followUp) { return [ "你是 zncm 页面问答助手。请继续基于本会话里已经读取的网页内容回答用户问题。", "如果已有网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。", `当前页面标题:${title}`, `当前页面 URL:${url}`, `本轮提问时间:${capturedAt}`, ].join("\n\n"); } return [ "你是 zncm 页面问答助手。请直接基于当前网页内容回答用户问题。", "如果网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。", `页面标题:${title}`, `页面 URL:${url}`, `读取时间:${capturedAt}`, truncated ? "说明:页面内容较长,本次已按插件限制截断。" : "说明:页面内容如下。", "当前网页内容:", page.contentType === "text" ? `\`\`\`text\n${content}\n\`\`\`` : content, ].join("\n\n"); } function zncmExtractLatestAnswer(values) { const messages = Array.isArray(values?.messages) ? values.messages : []; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index] || {}; const type = String(message.type || message.role || "").toLowerCase(); if (type !== "ai" && type !== "assistant") { continue; } const content = message.content; if (typeof content === "string" && content.trim()) { return content.trim(); } if (Array.isArray(content)) { const text = content .map((part) => { if (typeof part === "string") return part; if (part && typeof part.text === "string") return part.text; return ""; }) .filter(Boolean) .join("\n") .trim(); if (text) { return text; } } } return ""; } async function askZncmViaRunsWait(settings, payload, page, question) { const contentLimit = 60000; const rawContent = String(page.content || ""); const truncated = rawContent.length > contentLimit; const content = truncated ? `${rawContent.slice(0, contentLimit)}\n\n[Truncated locally by zncm Web Clipper]` : rawContent; const threadId = payload.threadId || zncmMakeThreadId(); const promptPrefix = zncmBuildPagePrompt(page, content, truncated, Boolean(payload.threadId)); const { data } = await zncmFetchJson(settings, "/api/runs/wait", { method: "POST", headers: { "Content-Type": "application/json", ...deerAuthHeaders(settings), }, body: JSON.stringify({ assistant_id: "lead_agent", input: { messages: [ { type: "human", content: question, additional_kwargs: { prompt_prefix: promptPrefix, }, }, ], }, config: { configurable: { thread_id: threadId, }, }, metadata: { source: "zncm_page_assistant", browser_context_url: page.url || "", browser_context_title: page.title || "", browser_context_fallback: true, }, stream_mode: ["values"], on_disconnect: "continue", multitask_strategy: "reject", }), }); return { success: true, thread_id: threadId, run_id: "", answer: zncmExtractLatestAnswer(data) || "zncm 没有返回可显示的答案。", truncated, message: "zncm 页面问答已完成。", }; } async function askZncmAboutPage(payload) { let settings = await getSettings(); settings = await discoverServiceBases(settings); settings = await syncAuthToken(settings); if (!settings.pageAssistantEnabled) { throw new Error("页面问答框已关闭。"); } const page = payload?.page || {}; const question = String(payload?.question || "").trim(); if (!question) { throw new Error("请输入要询问的问题。"); } if (!page.content) { throw new Error("当前页面没有可读取的正文。"); } try { const { data } = await zncmFetchJson(settings, "/api/browser-context/ask", { method: "POST", headers: { "Content-Type": "application/json", ...deerAuthHeaders(settings), }, body: JSON.stringify({ thread_id: payload.threadId || null, title: page.title || "", url: page.url || "", content: page.content || "", content_type: page.contentType || "markdown", question, }), }); return data; } catch (error) { const detail = String(error?.detail || error?.message || ""); if (error?.status === 404 && !/browser context integration is disabled/i.test(detail)) { return askZncmViaRunsWait(settings, payload, page, question); } throw error; } } async function getActSessionMap() { try { const stored = await chrome.storage.session.get({ actSessions: {} }); return stored.actSessions && typeof stored.actSessions === "object" ? stored.actSessions : {}; } catch { return {}; } } async function setActSessionTab(sessionId, tabId) { if (!sessionId) return; const map = await getActSessionMap(); map[sessionId] = tabId; await chrome.storage.session.set({ actSessions: map }).catch(() => {}); } async function removeActSession(sessionId) { if (!sessionId) return; const map = await getActSessionMap(); if (sessionId in map) { delete map[sessionId]; await chrome.storage.session.set({ actSessions: map }).catch(() => {}); } } async function resolveSessionTabId(sessionId) { const map = await getActSessionMap(); const tabId = map[sessionId]; if (!tabId) return null; try { await chrome.tabs.get(tabId); return tabId; } catch { await removeActSession(sessionId); return null; } } async function performActionInTab(tabId, action) { const [result] = await chrome.scripting.executeScript({ target: { tabId }, func: deerPerformAction, args: [action], }); return result?.result || { status: "ok", message: "" }; } async function observeTab(tabId, maxElements) { const [result] = await chrome.scripting.executeScript({ target: { tabId }, func: deerObservePage, args: [maxElements], }); const observation = result?.result; if (!observation) { throw new Error("当前页面无法读取交互元素。"); } return observation; } async function runActTask(task, settings) { const sessionId = task.session_id || ""; const action = task.action || {}; const actionType = String(action.type || "").toLowerCase(); const maxElements = 150; try { if (!sessionId) { throw new Error("act 任务缺少 session_id。"); } if (actionType === "close") { const tabId = await resolveSessionTabId(sessionId); if (tabId) { await chrome.tabs.remove(tabId).catch(() => {}); } await removeActSession(sessionId); await postTaskResult(task.id, settings, { kind: "act", observation: { action_status: "ok", message: "Tab closed.", url: "", title: "", elements: [], text: "" }, metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId }, }); return; } let tabId = await resolveSessionTabId(sessionId); if (actionType === "navigate") { const url = String(action.url || task.url || ""); if (!url) { throw new Error("navigate 动作缺少 url。"); } if (tabId) { await chrome.tabs.update(tabId, { url }); } else { const tab = await chrome.tabs.create({ url, active: false }); tabId = tab.id; await setActSessionTab(sessionId, tabId); } await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds); await delay(800); } else { if (!tabId) { throw new Error("会话标签页不存在,请先用 navigate 打开页面。"); } const outcome = await performActionInTab(tabId, action); // Give the page a moment to react / navigate, then settle. await delay(400); await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds); await delay(500); if (outcome.status === "error") { const observation = await observeTab(tabId, maxElements).catch(() => ({ elements: [], text: "", url: "", title: "" })); await postTaskResult(task.id, settings, { kind: "act", observation: { ...observation, action_status: outcome.status, message: outcome.message }, metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId }, }); return; } } const observation = await observeTab(tabId, maxElements); await postTaskResult(task.id, settings, { kind: "act", title: observation.title || "", url: observation.url || "", observation: { ...observation, action_status: "ok", message: "" }, metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId }, }); } catch (error) { await postTaskResult(task.id, settings, { kind: "act", error: error instanceof Error ? error.message : String(error), metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId }, }).catch((submitError) => console.warn("Failed to submit zncm act task error", submitError)); } } async function runFetchTask(task, settings) { let tabId = null; try { const tab = await chrome.tabs.create({ url: task.url, active: false }); tabId = tab.id; await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds); await delay(750); const page = await captureFromTab(tabId, task.max_content_chars); await postTaskResult(task.id, settings, { kind: "fetch", title: page.title, url: page.url, content: page.content, content_type: page.contentType || "markdown", metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, instruction: task.instruction || "", }, }); } catch (error) { await postTaskResult(task.id, settings, { kind: "fetch", error: error instanceof Error ? error.message : String(error), metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, }, }).catch((submitError) => console.warn("Failed to submit zncm task error", submitError)); } finally { if (tabId && settings.closeTaskTabs) { await chrome.tabs.remove(tabId).catch(() => {}); } } } async function runTask(task, settings) { if ((task.kind || "fetch") === "act") { await runActTask(task, settings); return; } await runFetchTask(task, settings); } async function pollOnce() { if (isPolling) { return { ok: true, message: "任务轮询正在运行。" }; } isPolling = true; try { let settings = await getSettings(); settings = await discoverServiceBases(settings); settings = await syncAuthToken(settings); if (!settings.taskPollingEnabled) { return { ok: false, message: "任务轮询已关闭。" }; } const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); const url = `${apiBaseUrl}/api/browser-context/tasks/next?worker_id=${encodeURIComponent(settings.workerId)}`; const response = await fetch(url, { headers: deerAuthHeaders(settings) }); const data = await response.json().catch(() => ({})); if (!response.ok) { const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data); throw new Error(detail || `HTTP ${response.status}`); } if (!data.task) { return { ok: true, message: "暂无待处理的 zncm 浏览器任务。" }; } await runTask(data.task, settings); return { ok: true, message: `已完成 zncm 浏览器任务 ${data.task.id}。` }; } finally { isPolling = false; await scheduleNextPoll(); } } async function initializePolling() { let settings = await getSettings(); try { settings = await discoverServiceBases(settings); } catch (error) { // Service discovery persists diagnostic metadata in storage.sync. Chrome can // temporarily reject those writes after its quota is reached; polling must // still start with the last known settings. console.warn("zncm service discovery failed during startup", error); } try { settings = await syncAuthToken(settings); } catch (error) { // A failed status/token write must not prevent alarms from being installed. // pollOnce() will retry token synchronization before claiming each task. console.warn("zncm token sync failed during startup", error); } await refreshAlarm(settings); await scheduleNextPoll(1000); } function startup() { if (!startupPromise) { startupPromise = initializePolling().finally(() => { startupPromise = null; }); } return startupPromise; } async function restartPollingSchedule() { const settings = await getSettings(); await refreshAlarm(settings); await scheduleNextPoll(0); } chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); chrome.storage.onChanged.addListener((changes, areaName) => { if ( areaName === "sync" && Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key)) ) { void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error)); } }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM_NAME) { void pollOnce().catch((error) => console.warn("zncm alarm poll failed", error)); } }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type === "zncm-discover-service") { getSettings() .then((settings) => discoverServiceBases(settings, true)) .then((settings) => sendResponse({ ok: Boolean(settings.apiBaseUrl && settings.webBaseUrl), message: `服务地址:${settings.webBaseUrl};API:${settings.apiBaseUrl}`, apiBaseUrl: settings.apiBaseUrl, webBaseUrl: settings.webBaseUrl, source: settings.lastServiceSyncSource || "", }), ) .catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) })); return true; } if (message?.type === "zncm-sync-token") { syncAuthToken(null, true) .then((settings) => { const hasToken = Boolean(settings.accessToken); sendResponse({ ok: hasToken, message: hasToken ? `Token 已从 ${settings.lastTokenSyncSource || "zncm"} 同步。` : "没有找到 zncm token。请打开已登录的 zncm 页面后重试。", source: settings.lastTokenSyncSource || "", }); }) .catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) })); return true; } if (message?.type === "zncm-page-agent-ask") { askZncmAboutPage(message) .then((result) => sendResponse({ ok: true, ...result })) .catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) })); return true; } if (message?.type === "zncm-open-options") { chrome.runtime.openOptionsPage() .then(() => sendResponse({ ok: true })) .catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) })); return true; } if (message?.type !== "zncm-poll-now") { return false; } pollOnce() .then((result) => sendResponse(result)) .catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) })); return true; }); void startup().catch((error) => console.warn("zncm startup failed", error));