Files
HomeDash/extension/firefox/background.js
JianFeeeee ff0e6418f8 feat(nav): auto-detect browser extension and offer one-click install
导航页现在会自动检测浏览历史插件是否已安装:

- 扩展在用户配置的服务地址上动态注册 content script
  (Chrome: chrome.scripting.registerContentScripts,
   Firefox: browser.contentScripts.register),
  于 document_start 给 <html> 打上 data-navi-ext 标记,
  不在其他站点运行。
- history-server 始终把「安装浏览历史插件」放在「历史访问」第一条
  (入口 url=/extension/install.html),启动时也会重建一次,
  不再需要等下一次同步。
- web/navi.js 读到标记后隐藏该入口(连 .item-wrapper 一起隐藏,
  避免留下空网格位)。
- 新增 /extension/install.html:自动识别浏览器,给出对应安装包与步骤;
  支持在 data 卷放 extension-store.json 配置商店链接,
  有商店链接时显示真正的「一键安装」。
- 段落名匹配同时兼容中英文(常用服务/Favorites、历史访问/History),
  修掉了公开示例 conf.yml 用英文名时重建逻辑完全失效的问题。
- 扩展升到 1.1.0(新增 scripting 权限与 detect.js)。

Note: Chrome/Edge 不允许从网站直接安装非商店扩展,
因此自托管场景下只能下载后手动加载;发布到商店后才会出现一键安装。
2026-09-11 10:41:10 +08:00

236 lines
7.6 KiB
JavaScript
Raw 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: 'not configured' };
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'; }
}