Files
HomeDash/extension/firefox/background.js
JianFeeeee 63c588145b fix(extension): 同步失败时真实原因被吞成「失败: undefined」
background 的「未配置站点」分支只返回 {ok:false, reason:'not configured'},
而 options.js 只读 result.error || result.status —— 两个字段都没有,
于是模板插值出 undefined,用户看到「失败: undefined」,真实原因看不到。
实测服务端日志里没有任何 /api/history/sync 请求,确认请求根本没发出。

修:两版 options.js 改为 error || reason || status || '未知错误',
两版 background.js 的 reason 换成中文可操作提示
「未配置站点地址,请先填写并保存」。
firefox 版本升到 1.1.4(已签名的 1.1.3 内容已冻结,须重签)。
2026-10-03 08:28:26 +08:00

236 lines
7.7 KiB
JavaScript
Raw Permalink 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.

// JianF Nav Sync — Firefox WebExtension (MV2 background page)
// 与 Chrome MV3 共用逻辑;MV2 常驻后台,navigator 可用,device 检测更准。
'use strict';
const ALARM_NAME = 'navi-sync';
const SYNC_MINUTES = 0.5;
const FETCH_WINDOW = 2 * 60 * 60 * 1000;
const MAX_BATCH = 200;
const DEDUP_SECONDS = 600;
const SEARCH_HOSTS = new Set([
'google.com', 'www.google.com', 'googleusercontent.com',
'bing.com', 'www.bing.com', 'cn.bing.com',
'baidu.com', 'www.baidu.com', 'm.baidu.com',
'duckduckgo.com', 'www.duckduckgo.com', 'lite.duckduckgo.com',
'sogou.com', 'www.sogou.com',
'so.com', 'www.so.com',
'yahoo.com', 'www.yahoo.com', 'search.yahoo.com',
'ecosia.org', 'www.ecosia.org',
'qwant.com', 'www.qwant.com',
'startpage.com', 'www.startpage.com',
'brave.com', 'search.brave.com',
'yandex.com', 'yandex.ru',
'360.cn', 'www.360.cn',
'sm.cn', 'quark.sm.cn',
'searx.be', 'searxng.site', 'paulgo.io',
]);
let lastSyncTime = 0;
let pendingItems = [];
// ── 检测标记:只向用户配置的导航站注册 content script ──
// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。
let detectRegistration = null;
function hostMatchPattern(serverUrl) {
try {
const host = new URL(serverUrl).hostname;
return host ? '*://' + host + '/*' : null;
} catch (e) {
return null;
}
}
async function syncDetectScript(serverUrl) {
if (detectRegistration) {
try { await detectRegistration.unregister(); } catch (e) { /* 已失效 */ }
detectRegistration = null;
}
const pattern = hostMatchPattern(serverUrl);
if (!pattern) return;
if (!browser.contentScripts || !browser.contentScripts.register) return;
try {
detectRegistration = await browser.contentScripts.register({
matches: [pattern],
js: [{ file: 'detect.js' }],
runAt: 'document_start',
allFrames: false,
});
} catch (e) {
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
}
}
function ensureAlarm() {
browser.alarms.get(ALARM_NAME, (alarm) => {
if (!alarm) browser.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES });
});
}
browser.runtime.onInstalled.addListener(() => {
ensureAlarm();
browser.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => {
pendingItems = data.pending || [];
lastSyncTime = data.lastSyncTime || 0;
syncDetectScript(data.serverUrl);
});
});
browser.runtime.onStartup.addListener(() => {
browser.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl));
});
function isJunk(url) {
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
const host = u.hostname.toLowerCase().replace(/^www\./, '');
if (SEARCH_HOSTS.has(host)) return true;
const q = u.searchParams.get('q');
const path = u.pathname.toLowerCase();
if (q && (path === '/search' || path === '/s' || path === '/results' || path === '/web')) return true;
if (url.startsWith('about:') || url.startsWith('moz-extension://')) return true;
return false;
} catch (e) { return true; }
}
function hostFrom(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
}
async function collectRecent(previousSyncTime = 0) {
const initialCutoff = Date.now() - DEDUP_SECONDS * 1000;
const cutoff = previousSyncTime > 0 ? previousSyncTime : initialCutoff;
const since = Math.max(0, Date.now() - FETCH_WINDOW, cutoff);
const items = await new Promise((resolve) => {
browser.history.search({ text: '', startTime: since, maxResults: 10000 }, resolve);
});
const seen = new Set();
const collected = [];
for (const item of items) {
if (isJunk(item.url)) continue;
const key = hostFrom(item.url) + '|' + (item.url.split('?')[0] || '');
if (seen.has(key)) continue;
seen.add(key);
const ts = item.lastVisitTime || Date.now();
if (ts <= cutoff) continue;
collected.push({
url: item.url,
title: item.title || '',
timestamp: Math.floor(ts / 1000),
visit_count: item.visitCount || 1,
});
if (collected.length >= MAX_BATCH) break;
}
return collected;
}
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) syncNow();
});
browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg && msg.action === 'sync') {
syncNow().then((r) => sendResponse(r)).catch((e) => sendResponse({ ok: false, error: e.message }));
return true;
}
if (msg && msg.action === 'getConfig') {
browser.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (d) => sendResponse(d));
return true;
}
if (msg && msg.action === 'setConfig') {
const data = msg.data || {};
browser.storage.local.set(data, () => {
syncDetectScript(data.serverUrl);
sendResponse({ ok: true });
});
return true;
}
if (msg && msg.action === 'clearPending') {
pendingItems = [];
browser.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
return true;
}
});
async function syncNow() {
ensureAlarm();
const syncStartedAt = Date.now();
const stored = await new Promise((resolve) => {
browser.storage.local.get(
['serverUrl', 'syncToken', 'deviceName', 'pending', 'lastSyncTime'],
resolve,
);
});
const { serverUrl, syncToken, deviceName } = stored;
if (!serverUrl) return { ok: false, reason: '未配置站点地址,请先填写并保存' };
pendingItems = Array.isArray(stored.pending) ? stored.pending : [];
lastSyncTime = Number(stored.lastSyncTime) || 0;
let recent;
try { recent = await collectRecent(lastSyncTime); } catch (e) { recent = []; }
const merged = dedup(recent.concat(pendingItems));
if (merged.length === 0) {
lastSyncTime = syncStartedAt;
await new Promise((res) => browser.storage.local.set({ lastSyncTime }, res));
return { ok: true, skipped: true, reason: 'nothing new' };
}
const body = JSON.stringify({
items: merged.map((it) => ({
url: it.url,
title: it.title || '',
device: deviceName || defaultDevice(),
timestamp: it.timestamp,
visit_count: it.visit_count || 1,
})),
});
const headers = { 'Content-Type': 'application/json' };
if (syncToken) headers['Authorization'] = 'Bearer ' + syncToken;
try {
const resp = await fetch(serverUrl.replace(/\/$/, '') + '/api/history/sync', {
method: 'POST', headers, body,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
pendingItems = merged;
await new Promise((res) => browser.storage.local.set({ pending: merged }, res));
return { ok: false, status: resp.status, error: text.slice(0, 200) };
}
const data = await resp.json().catch(() => ({}));
pendingItems = [];
lastSyncTime = syncStartedAt;
await new Promise((res) => browser.storage.local.set({ pending: [], lastSyncTime }, res));
return { ok: true, count: merged.length, total: data.total || 0 };
} catch (e) {
await new Promise((res) => browser.storage.local.set({ pending: merged }, res));
return { ok: false, error: e.message };
}
}
function dedup(list) {
const seen = new Set();
const out = [];
for (const it of list) {
if (seen.has(it.url)) continue;
seen.add(it.url);
out.push(it);
}
return out;
}
function defaultDevice() {
try {
const ua = navigator.userAgent.toLowerCase();
const mobile = /mobile|android|iphone|ipad/.test(ua);
const ff = /firefox/.test(ua);
const edg = /edg\//.test(ua);
const base = mobile ? 'mobile' : 'desktop';
const browser = ff ? 'firefox' : (edg ? 'edge' : 'chrome');
return base + '-' + browser;
} catch (e) { return 'desktop-browser'; }
}