This commit is contained in:
Rocky Kwok committed 2026-09-04 11:16:38 +08:00
1 parent 49d9ced04f
commit a723a9ca02
3 files changed
+235 -13

No files matched your search

@@ -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();
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));
@@ -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),
);
});
+41 -1
View File
@@ -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;
}
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;