Single-container navigation service combining: - Dashy dashboard + SearXNG metasearch + history sync API + admin WebUI - Go services for history/admin APIs, nginx as unified entry - Browser extensions (Chrome MV3 / Firefox MV2) with 30s batched sync - Production guide: portal-nginx unified auth reverse proxy + webui4frpc/frp tunnel - Fully sanitized (no internal IPs/domains/credentials)
133 lines
4.0 KiB
JavaScript
133 lines
4.0 KiB
JavaScript
// JianF Nav Sync — Chrome/Edge MV3 service worker
|
|
'use strict';
|
|
|
|
const ALARM_NAME = 'navi-sync';
|
|
const SYNC_INTERVAL_MINUTES = 0.5; // 30 seconds
|
|
const SEARCH_DOMAINS = new Set([
|
|
'google.com', 'www.google.com', 'baidu.com', 'www.baidu.com',
|
|
'bing.com', 'www.bing.com', 'duckduckgo.com', 'www.duckduckgo.com',
|
|
'sogou.com', 'www.sogou.com', 'so.com', 'www.so.com',
|
|
'yahoo.com', 'www.yahoo.com', 'ask.com', 'www.ask.com',
|
|
'ecosia.org', 'www.ecosia.org', 'qwant.com', 'www.qwant.com',
|
|
]);
|
|
|
|
let pendingItems = [];
|
|
|
|
// Load pending from storage on startup
|
|
chrome.storage.local.get(['pending', 'serverUrl', 'syncToken'], (data) => {
|
|
pendingItems = data.pending || [];
|
|
});
|
|
|
|
// Listen to history visits
|
|
chrome.history.onVisited.addListener((historyItem) => {
|
|
try {
|
|
const url = new URL(historyItem.url);
|
|
// Skip non-http(s)
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
// Skip search engine URLs
|
|
const domain = url.hostname.replace(/^www\./, '');
|
|
if (SEARCH_DOMAINS.has(domain)) return;
|
|
|
|
pendingItems.push({
|
|
url: historyItem.url,
|
|
title: historyItem.title || '',
|
|
timestamp: historyItem.lastVisitTime / 1000, // ms -> seconds
|
|
visit_count: historyItem.visitCount || 1,
|
|
});
|
|
chrome.storage.local.set({ pending: pendingItems });
|
|
} catch (e) {
|
|
// invalid URL, skip
|
|
}
|
|
});
|
|
|
|
// Periodic sync alarm
|
|
chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_INTERVAL_MINUTES });
|
|
|
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name !== ALARM_NAME) return;
|
|
syncNow();
|
|
});
|
|
|
|
// Manual sync from options page
|
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
if (msg.action === 'sync') {
|
|
syncNow().then((result) => sendResponse(result)).catch((e) => sendResponse({ error: e.message }));
|
|
return true; // async response
|
|
}
|
|
if (msg.action === 'getConfig') {
|
|
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (data) => {
|
|
sendResponse(data);
|
|
});
|
|
return true;
|
|
}
|
|
if (msg.action === 'setConfig') {
|
|
chrome.storage.local.set(msg.data, () => sendResponse({ ok: true }));
|
|
return true;
|
|
}
|
|
if (msg.action === 'clearPending') {
|
|
pendingItems = [];
|
|
chrome.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
|
|
return true;
|
|
}
|
|
});
|
|
|
|
function detectDevice() {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
if (/mobile|android|iphone|ipad/.test(ua)) {
|
|
if (/firefox/.test(ua)) return 'mobile-firefox';
|
|
if (/edg/.test(ua)) return 'mobile-edge';
|
|
return 'mobile-chrome';
|
|
}
|
|
if (/firefox/.test(ua)) return 'desktop-firefox';
|
|
if (/edg/.test(ua)) return 'desktop-edge';
|
|
return 'desktop-chrome';
|
|
}
|
|
|
|
async function syncNow() {
|
|
if (pendingItems.length === 0) return { ok: true, skipped: true, reason: 'no pending' };
|
|
|
|
const { serverUrl, syncToken, deviceName } = await new Promise((resolve) => {
|
|
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], resolve);
|
|
});
|
|
|
|
if (!serverUrl) return { ok: false, reason: 'server not configured' };
|
|
|
|
// Snapshot and clear pending
|
|
const toSend = [...pendingItems];
|
|
pendingItems = [];
|
|
chrome.storage.local.set({ pending: [] });
|
|
|
|
const body = JSON.stringify({
|
|
items: toSend.map((item) => ({
|
|
...item,
|
|
device: deviceName || detectDevice(),
|
|
})),
|
|
});
|
|
|
|
try {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (syncToken) headers['Authorization'] = `Bearer ${syncToken}`;
|
|
|
|
const resp = await fetch(`${serverUrl}/api/history/sync`, {
|
|
method: 'POST',
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
// On failure, put items back
|
|
pendingItems = [...toSend, ...pendingItems];
|
|
chrome.storage.local.set({ pending: pendingItems });
|
|
return { ok: false, status: resp.status, error: text };
|
|
}
|
|
|
|
return await resp.json();
|
|
} catch (e) {
|
|
// Network error — put items back
|
|
pendingItems = [...toSend, ...pendingItems];
|
|
chrome.storage.local.set({ pending: pendingItems });
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|