diff --git a/browser-extension/deer-web-clipper/background.js b/browser-extension/deer-web-clipper/background.js index 104380f7..686b9660 100644 --- a/browser-extension/deer-web-clipper/background.js +++ b/browser-extension/deer-web-clipper/background.js @@ -5,6 +5,9 @@ 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)); @@ -12,6 +15,14 @@ function delay(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; @@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { } }; - add(currentApiBase); add(webBase); - add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); - add(LEGACY_LOCAL_API_BASE_URL); try { const url = new URL(normalizeServiceBase(webBase)); @@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { // Ignore invalid candidates. } + add(currentApiBase); + add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); + add(LEGACY_LOCAL_API_BASE_URL); + return candidates; } @@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) { 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; } @@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) { settings.webBaseUrl !== next.webBaseUrl || settings.lastServiceSyncSource !== next.lastServiceSyncSource ) { - await chrome.storage.sync.set({ - apiBaseUrl: next.apiBaseUrl, - webBaseUrl: next.webBaseUrl, + // 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, }); @@ -826,19 +847,50 @@ async function pollOnce() { } } -async function startup() { +async function initializePolling() { let settings = await getSettings(); - settings = await discoverServiceBases(settings); - settings = await syncAuthToken(settings); + 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); } -chrome.runtime.onInstalled.addListener(() => void startup()); -chrome.runtime.onStartup.addListener(() => void startup()); +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") { - void startup(); + 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) => { @@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; }); -void startup(); +void startup().catch((error) => console.warn("zncm startup failed", error)); diff --git a/browser-extension/deer-web-clipper/background.test.cjs b/browser-extension/deer-web-clipper/background.test.cjs new file mode 100644 index 00000000..e1462ff8 --- /dev/null +++ b/browser-extension/deer-web-clipper/background.test.cjs @@ -0,0 +1,130 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const BACKGROUND_PATH = path.join(__dirname, "background.js"); + +function createHarness() { + let storageChangedListener; + let alarmCreateCount = 0; + const settings = { + apiBaseUrl: "http://localhost:8001", + webBaseUrl: "http://localhost:5174", + autoDiscoverServiceUrl: false, + accessToken: "", + tokenSyncEnabled: false, + taskPollingEnabled: true, + workerId: "test-worker", + taskPollIntervalSeconds: 5, + }; + + const chrome = { + alarms: { + clear: async () => {}, + create: async () => { + alarmCreateCount += 1; + }, + onAlarm: { addListener: () => {} }, + }, + cookies: { get: async () => null }, + runtime: { + onInstalled: { addListener: () => {} }, + onMessage: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + }, + storage: { + local: { + get: async (defaults) => ({ ...defaults }), + set: async () => {}, + }, + session: { get: async () => ({ actSessions: {} }), set: async () => {} }, + sync: { + get: async () => ({ ...settings }), + set: async (updates) => Object.assign(settings, updates), + }, + onChanged: { + addListener: (listener) => { + storageChangedListener = listener; + }, + }, + }, + tabs: { query: async () => [] }, + }; + + const context = { + chrome, + console, + fetch: async () => { + throw new Error("offline test"); + }, + importScripts: () => {}, + setTimeout: () => 1, + clearTimeout: () => {}, + URL, + DEER_CLIPPER_DEFAULTS: settings, + deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""), + deerMakeWorkerId: () => "test-worker", + deerAuthHeaders: () => ({}), + deerExtractAuthTokenFromPage: () => ({ token: "" }), + }; + context.globalThis = context; + vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, { + filename: BACKGROUND_PATH, + }); + + return { + emitStorageChange(changes) { + storageChangedListener(changes, "sync"); + }, + getAlarmCreateCount: () => alarmCreateCount, + resetAlarmCreateCount: () => { + alarmCreateCount = 0; + }, + getApiCandidates: (webBase, currentApiBase) => + vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context), + }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test("self-written sync metadata does not restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + lastTokenSyncAt: { oldValue: "before", newValue: "after" }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 0); +}); + +test("polling configuration changes restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + taskPollingEnabled: { oldValue: false, newValue: true }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 1); +}); + +test("service discovery prefers the API paired with the open zncm tab", async () => { + const harness = createHarness(); + + assert.deepEqual( + Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")), + [ + "http://localhost:5174", + "http://localhost:8001", + "http://47.94.209.59:2026", + "http://localhost:8001", + ].filter((value, index, values) => values.indexOf(value) === index), + ); +}); diff --git a/browser-extension/deer-web-clipper/content.js b/browser-extension/deer-web-clipper/content.js index 1c387461..abb388bc 100644 --- a/browser-extension/deer-web-clipper/content.js +++ b/browser-extension/deer-web-clipper/content.js @@ -7,6 +7,8 @@ const HOST_ID = "zncm-page-assistant-host"; const state = { enabled: true, + taskPollingEnabled: true, + webBaseUrl: "", open: false, busy: false, threadId: "", @@ -709,20 +711,58 @@ async function loadSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); state.enabled = Boolean(settings.pageAssistantEnabled); + state.taskPollingEnabled = Boolean(settings.taskPollingEnabled); + state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); ensureWidget(); } chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "sync" || !changes.pageAssistantEnabled) { + if (areaName !== "sync") { return; } - state.enabled = Boolean(changes.pageAssistantEnabled.newValue); - ensureWidget(); - if (state.enabled) { - setStatus("页面问答框已开启。"); + if (changes.pageAssistantEnabled) { + state.enabled = Boolean(changes.pageAssistantEnabled.newValue); + ensureWidget(); + if (state.enabled) { + setStatus("页面问答框已开启。"); + } + } + if (changes.taskPollingEnabled) { + state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue); + } + if (changes.webBaseUrl) { + state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl); } }); + function isZncmPage() { + const appPath = `${location.pathname}${location.hash}`.toLowerCase(); + if ( + appPath.includes("/page/workspace") || + appPath.includes("/chats/") || + appPath.includes("#/login") + ) { + return true; + } + try { + const configured = new URL(state.webBaseUrl); + return configured.origin === location.origin; + } catch { + return false; + } + } + + // MV3 service workers may be suspended between their short polling timers, + // while chrome.alarms cannot reliably run more often than every 30 seconds. + // A visible zncm tab provides a cheap wake-up signal so browser_act requests + // are normally claimed within the backend/model timeout window. + setInterval(() => { + if (!state.taskPollingEnabled || !isZncmPage()) { + return; + } + chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {}); + }, 3000); + setInterval(() => { if (!state.enabled || !ui.pageTitle) { return;