// 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'; } }