sync update from LAN repo (source dhk-1 @ 3b1ec5b4)

This commit is contained in:
gozeros committed 2026-11-05 10:56:34 +08:00
1 parent da6521b6cb
commit 264e2200d6
995 files changed
+148105 -13399

No files matched your search

+123 -39
View File
@@ -1,6 +1,13 @@
importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
const STATUS_CACHE_TTL_MS = 60 * 1000;
const STATUS_RETRY_MS = 5 * 60 * 1000;
const STATUS_MANUAL_MIN_INTERVAL_MS = 30 * 1000;
const statusCache = new Map();
const statusRequests = new Map();
const discoveryRequests = new Map();
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
@@ -18,6 +25,9 @@ async function getSettings() {
const discovered = await chrome.storage.local.get({
discoveredApiBaseUrl: "",
discoveredWebBaseUrl: "",
lastServiceSyncAt: "",
lastServiceSyncSource: "",
lastServiceSyncContext: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
@@ -33,6 +43,9 @@ async function getSettings() {
if (!settings.workerId) {
updates.workerId = deerMakeWorkerId();
}
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
settings.lastServiceSyncContext = discovered.lastServiceSyncContext || "";
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
@@ -100,28 +113,77 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
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;
async function serviceCacheKey(settings, apiBaseUrl, webBaseUrl = "") {
const cookie = await getCookieToken({ ...settings, apiBaseUrl });
const auth = JSON.stringify([settings.accessToken || "", cookie?.token || ""]);
// Persist only a credential fingerprint with the discovery timestamp. A new
// login or cookie must not reuse another account's cached status/backoff.
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth));
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
return JSON.stringify([normalizeServiceBase(apiBaseUrl), normalizeServiceBase(webBaseUrl), fingerprint]);
}
function statusRetryMs(response) {
const value = response.headers?.get("Retry-After");
if (!value) return STATUS_RETRY_MS;
const seconds = Number(value);
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
return Number.isFinite(delay) ? Math.max(STATUS_RETRY_MS, Math.min(delay, 60 * 60 * 1000)) : STATUS_RETRY_MS;
}
async function browserContextStatus(apiBaseUrl, settings = {}, force = false) {
const base = normalizeServiceBase(apiBaseUrl);
const key = await serviceCacheKey(settings, base);
const cached = statusCache.get(key);
const now = Date.now();
if (cached && now < (force ? cached.manualRetryAt : cached.expiresAt)) {
return cached.value;
}
if (statusRequests.has(key)) return statusRequests.get(key);
const request = (async () => {
let value = null;
let ttl = STATUS_RETRY_MS;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${base}/api/browser-context/status`, {
credentials: "include",
signal: controller.signal,
});
const data = await response.json().catch(() => null);
ttl = statusRetryMs(response);
if (response.status === 403 && data?.detail?.code === "browser_status_disabled") {
value = { enabled: false, statusAccessDisabled: true };
} else if (response.status === 401 || response.status === 403) {
// Authentication still establishes a candidate's API identity. Do not
// mistake the explicit administrative status switch for a login error.
value = { enabled: true, authenticationRequired: true };
} else if (response.ok && data && typeof data.enabled === "boolean") {
value = data;
if (data.enabled) ttl = STATUS_CACHE_TTL_MS;
}
} catch {
// Failed candidates are negative-cached too: an unreachable deployment
// must not cause every open tab's three-second wake-up to probe it again.
} finally {
clearTimeout(timeout);
}
const checkedAt = Date.now();
statusCache.set(key, {
value,
expiresAt: checkedAt + ttl,
manualRetryAt: checkedAt + (ttl === STATUS_CACHE_TTL_MS ? STATUS_MANUAL_MIN_INTERVAL_MS : ttl),
});
if (statusCache.size > 100) statusCache.delete(statusCache.keys().next().value);
return value;
})();
statusRequests.set(key, request);
try {
return await request;
} finally {
statusRequests.delete(key);
}
return null;
}
function tabLooksLikeZncm(tab) {
@@ -173,9 +235,37 @@ async function findOpenZncmTabs(settings) {
}
async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) return settings;
const key = await serviceCacheKey(settings, settings.apiBaseUrl, settings.webBaseUrl);
if (discoveryRequests.has(key)) return discoveryRequests.get(key);
const request = discoverServiceBasesOnce(settings, force, key);
discoveryRequests.set(key, request);
try {
return await request;
} finally {
discoveryRequests.delete(key);
}
}
async function discoverServiceBasesOnce(settings, force, contextKey) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
const discoveryAge = Date.now() - lastSync;
const discoveryTtl = /browser_context_disabled|api_not_verified/.test(settings.lastServiceSyncSource || "")
? STATUS_RETRY_MS
: SERVICE_DISCOVERY_TTL_MS;
if (
!force &&
Number.isFinite(lastSync) &&
settings.lastServiceSyncSource &&
settings.lastServiceSyncContext === contextKey &&
discoveryAge >= 0 &&
discoveryAge < discoveryTtl
) {
return settings;
}
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
let discoveredWebBaseUrl = webBaseUrl;
@@ -192,7 +282,7 @@ async function discoverServiceBases(settings, force = false) {
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
let verified = false;
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
const status = await browserContextStatus(candidate);
const status = await browserContextStatus(candidate, settings, force);
if (status) {
apiBaseUrl = candidate;
webBaseUrl = discoveredWebBaseUrl;
@@ -201,7 +291,7 @@ async function discoverServiceBases(settings, force = false) {
break;
}
}
if (!verified && force) {
if (!verified) {
source = `${source}:api_not_verified`;
}
@@ -213,22 +303,16 @@ async function discoverServiceBases(settings, force = false) {
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,
});
}
next.lastServiceSyncContext = await serviceCacheKey(next, next.apiBaseUrl, next.webBaseUrl);
// Refresh the timestamp even when the address/source did not change. Without
// this write the first expired TTL causes status probes on every later poll.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
lastServiceSyncContext: next.lastServiceSyncContext,
});
return next;
}