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 不允许从网站直接安装非商店扩展,
因此自托管场景下只能下载后手动加载;发布到商店后才会出现一键安装。
This commit is contained in:
JianFeeeee
2026-09-11 10:41:10 +08:00
parent 88e23861fe
commit ff0e6418f8
14 changed files with 440 additions and 45 deletions

4
.gitignore vendored
View File

@ -16,3 +16,7 @@ gateway/navi-gateway
.DS_Store
Thumbs.db
.codegraph/
# Go build output
history-server/history-server
admin-server/admin-server

View File

@ -35,7 +35,19 @@ RUN git clone --depth 1 https://github.com/searxng/searxng.git /src/searxng \
&& /opt/searxng-venv/bin/pip install --no-cache-dir --no-build-isolation . \
&& rm -rf /src/searxng
# ── Stage 3: Runtime ──
# ── Stage 3: browser extension packages ──
# 供 /extension/install.html 下载(Chromium 用 zip,Firefox 用 xpi)
FROM debian:bookworm-slim AS extpack
RUN apt-get update && apt-get install -y --no-install-recommends zip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY extension/chrome /src/chrome
COPY extension/firefox /src/firefox
RUN cd /src/chrome && zip -qr /out/navi-chrome.zip . \
&& cd /src/firefox && zip -qr /out/navi-firefox.xpi .
# ── Stage 4: Runtime ──
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
@ -64,6 +76,11 @@ COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
# 浏览器扩展安装页与安装包(通过 /extension/ 访问)
COPY web/extension/install.html /app/dashy/extension/install.html
COPY --from=extpack /out/navi-chrome.zip /app/dashy/extension/navi-chrome.zip
COPY --from=extpack /out/navi-firefox.xpi /app/dashy/extension/navi-firefox.xpi
# Inject search script into Dashy
COPY web/navi.js /app/dashy/navi.js
RUN sed -i 's#</body>#<script src="/navi.js"></script></body>#' /app/dashy/index.html

View File

@ -136,7 +136,32 @@ icon: fas fa-star # Font Awesome
## 浏览器扩展
### 安装
### 安装(两种方式)
**方式 1:导航页内一键引导(推荐)**
导航页会自动检测插件是否已安装:
- 未安装时,「历史访问」**第一条**固定显示「安装浏览历史插件」入口;
- 点击后打开 `/extension/install.html`,**自动识别浏览器**并给出对应安装步骤与安装包;
- 已安装时,`/navi.js` 读到插件写入的 `data-navi-ext` 标记,把该入口自动隐藏。
检测原理:插件设置里填了服务地址后,会向**该站点的域名**动态注册一个 content script
(Chrome 用 `chrome.scripting.registerContentScripts`,Firefox 用 `browser.contentScripts.register`),
在 `document_start` 给 `<html>` 打上 `data-navi-ext`。不会在其他站点上运行。
> Chrome / Edge **不允许**从网站直接安装非商店扩展,所以这一步只能下载后手动加载;
> 若你已把扩展发布到商店,把链接写进 `.env` 或 data 卷的 `extension-store.json`,
> 安装页就会出现真正的「从商店一键安装」按钮:
>
> ```json
> { "chrome": "https://chrome.google.com/webstore/detail/<id>",
> "firefox": "https://addons.mozilla.org/firefox/addon/<slug>/" }
> ```
>
> 放到 `data/extension-store.json` 即可,无需重建镜像。
**方式 2:手动加载源码目录**
**Chrome / Edge**:`chrome://extensions/` → 开发者模式 → 加载 `extension/chrome/`
@ -472,7 +497,7 @@ data/
├── history-server/ # Go 历史同步 API (:3004)
├── admin-server/ # Go 管理后台 API (:3005)
├── admin/ # 管理后台前端
├── web/ # Dashy 注入脚本
├── web/ # Dashy 注入脚本 + 扩展安装引导页
├── extension/ # 浏览器扩展 (Chrome/Firefox)
├── nginx.conf # 容器内统一反代
├── supervisord.conf # 进程管理
@ -501,6 +526,14 @@ data/
**历史不显示**:检查扩展配置中的服务地址和同步令牌。
**「安装浏览历史插件」入口不消失**:该入口由插件写入的 `data-navi-ext` 标记驱动隐藏。
- 确认扩展已启用,且设置里的服务地址**主机名**与当前访问地址一致
(例如填 `https://nav.example.com`、却用 `http://192.168.1.100:3003` 访问,就检测不到)。
- 改完设置请刷新页面。Chrome 若因新增 `scripting` 权限把扩展置为待确认,
需在 `chrome://extensions` 重新启用。
- 检测失败只会多显示一个入口,不影响同步功能。
**内存占用**:正常约 100MB。
## 许可

View File

@ -22,8 +22,9 @@ pageInfo:
footer: ""
sections:
- name: Code Hosting
icon: fas fa-code-branch
# 名称必须是 Favorites / 常用服务 —— 服务端会把访问量最高的 4 个导航写到这里
- name: Favorites
icon: fas fa-star
collapsed: false
items:
- title: Gitea
@ -32,14 +33,17 @@ sections:
url: http://192.168.1.100:3000
statusCheck: true
# 名称必须是 History / 历史访问 ——
# 服务端会把插件安装入口固定放在第一条,其余按访问频次填充。
# 插件已安装的浏览器由 /navi.js 自动隐藏该入口。
- name: History
icon: fas fa-history
collapsed: false
items:
- title: Install browser extension...
description: History fills automatically after sync
icon: fas fa-info-circle
url: /admin/
- title: 安装浏览历史插件
description: 一键安装,自动按访问频次排序
icon: fas fa-download
url: /extension/install.html
- name: Tools
icon: fas fa-tools

View File

@ -29,15 +29,59 @@ const SEARCH_HOSTS = new Set([
let lastSyncTime = 0;
let pendingItems = [];
// ── 检测标记:只向用户配置的导航站注入 content script ──
// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。
const DETECT_SCRIPT_ID = 'navi-detect';
const DETECT_FILE = 'detect.js';
function hostMatchPattern(serverUrl) {
try {
const host = new URL(serverUrl).hostname;
return host ? '*://' + host + '/*' : null;
} catch (e) {
return null;
}
}
async function syncDetectScript(serverUrl) {
if (!chrome.scripting || !chrome.scripting.registerContentScripts) return;
try {
const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] });
if (existing && existing.length) {
await chrome.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] });
}
} catch (e) { /* 从未注册过 */ }
const pattern = hostMatchPattern(serverUrl);
if (!pattern) return;
try {
await chrome.scripting.registerContentScripts([{
id: DETECT_SCRIPT_ID,
matches: [pattern],
js: [DETECT_FILE],
runAt: 'document_start',
allFrames: false,
persistAcrossSessions: true,
}]);
} catch (e) {
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
}
}
// ── 初始化 ──
chrome.runtime.onInstalled.addListener(() => {
ensureAlarm();
chrome.storage.local.get(['pending', 'lastSyncTime'], (data) => {
chrome.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => {
pendingItems = data.pending || [];
lastSyncTime = data.lastSyncTime || 0;
syncDetectScript(data.serverUrl);
});
});
chrome.runtime.onStartup.addListener(() => {
chrome.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl));
});
function ensureAlarm() {
chrome.alarms.get(ALARM_NAME, (alarm) => {
if (!alarm) chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES });
@ -115,7 +159,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
return true;
}
if (msg && msg.action === 'setConfig') {
chrome.storage.local.set(msg.data || {}, () => sendResponse({ ok: true }));
const data = msg.data || {};
chrome.storage.local.set(data, () => {
syncDetectScript(data.serverUrl);
sendResponse({ ok: true });
});
return true;
}
if (msg && msg.action === 'clearPending') {

View File

@ -0,0 +1,20 @@
// JianF Nav Sync — 检测标记(content script)
//
// 由 background 动态注册到「用户在设置里填写的导航站」上,
// 不会在其他网站上运行。
//
// 唯一职责:在 document_start 阶段给 <html> 打上 data-navi-ext 属性。
// 导航站的 /navi.js 读到该属性就隐藏「安装浏览历史插件」入口。
'use strict';
try {
let version = '1';
try {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getManifest) {
version = chrome.runtime.getManifest().version || version;
}
} catch (e) { /* 忽略:版本号只用于展示 */ }
document.documentElement.setAttribute('data-navi-ext', version);
} catch (e) {
/* document_start 下 documentElement 一定存在;出错也只是少一个标记 */
}

View File

@ -1,12 +1,13 @@
{
"manifest_version": 3,
"name": "JianF Nav Sync",
"version": "1.0.0",
"version": "1.1.0",
"description": "同步浏览历史到自建导航门户,按访问频次自动排序显示常用服务",
"permissions": [
"history",
"storage",
"alarms"
"alarms",
"scripting"
],
"host_permissions": [
"<all_urls>"

View File

@ -29,6 +29,39 @@ const SEARCH_HOSTS = new Set([
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 });
@ -37,12 +70,17 @@ function ensureAlarm() {
browser.runtime.onInstalled.addListener(() => {
ensureAlarm();
browser.storage.local.get(['pending', 'lastSyncTime'], (data) => {
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);
@ -102,7 +140,11 @@ browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
return true;
}
if (msg && msg.action === 'setConfig') {
browser.storage.local.set(msg.data || {}, () => sendResponse({ ok: true }));
const data = msg.data || {};
browser.storage.local.set(data, () => {
syncDetectScript(data.serverUrl);
sendResponse({ ok: true });
});
return true;
}
if (msg && msg.action === 'clearPending') {

View File

@ -0,0 +1,20 @@
// JianF Nav Sync — 检测标记(content script)
//
// 由 background 通过 browser.contentScripts.register 动态注册到
// 「用户在设置里填写的导航站」上,不会在其他网站上运行。
//
// 唯一职责:在 document_start 阶段给 <html> 打上 data-navi-ext 属性。
// 导航站的 /navi.js 读到该属性就隐藏「安装浏览历史插件」入口。
'use strict';
try {
let version = '1';
try {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getManifest) {
version = chrome.runtime.getManifest().version || version;
}
} catch (e) { /* 忽略:版本号只用于展示 */ }
document.documentElement.setAttribute('data-navi-ext', version);
} catch (e) {
/* document_start 下 documentElement 一定存在;出错也只是少一个标记 */
}

View File

@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "JianF Nav Sync",
"version": "1.0.0",
"version": "1.1.0",
"description": "同步浏览历史到自建导航门户,按访问频次自动排序显示常用服务",
"permissions": [
"history",
@ -10,8 +10,10 @@
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": false
"scripts": [
"background.js"
],
"persistent": true
},
"browser_action": {
"default_title": "JianF Nav Sync 设置",

View File

@ -255,6 +255,36 @@ func hostVisits() map[string]int {
return out
}
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
const (
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
favSectionEn = "Favorites"
histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
histSectionEn = "History"
)
// extensionInstallURL 是「一键安装浏览历史插件」入口。
// 服务端始终把它放在「历史访问」第一条;已装插件的浏览器由 navi.js 隐藏。
const extensionInstallURL = "/extension/install.html"
func isFavSection(name string) bool {
return name == favSectionZh || strings.EqualFold(name, favSectionEn)
}
func isHistSection(name string) bool {
return name == histSectionZh || strings.EqualFold(name, histSectionEn)
}
// extensionInstallItem 返回「历史访问」首条的插件安装入口。
func extensionInstallItem() map[string]any {
return map[string]any{
"title": "\u5b89\u88c5\u6d4f\u89c8\u5386\u53f2\u63d2\u4ef6",
"description": "\u4e00\u952e\u5b89\u88c5\uff0c\u81ea\u52a8\u6309\u8bbf\u95ee\u9891\u6b21\u6392\u5e8f",
"icon": "fas fa-download",
"url": extensionInstallURL,
}
}
// rebuildDashySections 读 conf.yml:
// 1) 收集除「历史访问」外所有导航项
// 2) 按各导航 host 在 history 的累计访问统计
@ -277,9 +307,7 @@ func rebuildDashySections() {
return
}
const favName = "\u5e38\u7528\u670d\u52a1" // 常用服务
const histName = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
// 段落名匹配见 isFavSection / isHistSection(兼容中英文)
type secInfo struct {
raw map[string]any
name string
@ -330,10 +358,9 @@ func rebuildDashySections() {
if host != "" {
v = hv[host]
}
if si.name == histName {
if isHistSection(si.name) {
continue
}
// 所有导航项(含常用服务原本手动配置的)都进候选,
} // 所有导航项(含常用服务原本手动配置的)都进候选,
// 由访问量 + code-host 置顶决定最终 Top4
candidates = append(candidates, cand{
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
@ -357,13 +384,11 @@ func rebuildDashySections() {
}
}
if !anyNav {
// 尚无导航访问数据:保持「常用服务」的手工配置不动,
// 「历史访问」只放插件安装入口(装好后由 navi.js 隐藏)。
for _, si := range secs {
if si.name == histName {
si.raw["items"] = []any{map[string]any{
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
"icon": "fas fa-info-circle", "url": "/admin/",
}}
if isHistSection(si.name) {
si.raw["items"] = []any{extensionInstallItem()}
}
}
out, err := yaml.Marshal(cfg)
@ -423,7 +448,7 @@ func rebuildDashySections() {
// 更新各 section
for _, si := range secs {
if si.name == favName {
if isFavSection(si.name) {
newItems := make([]any, 0, len(finalNavs))
for _, n := range finalNavs {
it := map[string]any{
@ -461,7 +486,10 @@ func rebuildDashySections() {
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
histItems := make([]any, 0, 20)
histItems := make([]any, 0, 21)
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
// 已装的浏览器由 navi.js 隐藏。
histItems = append(histItems, extensionInstallItem())
for _, e := range entries {
if navHosts[strings.ToLower(hostOf(e.URL))] {
continue // 已是导航项,不重复进历史
@ -475,19 +503,12 @@ func rebuildDashySections() {
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": "fas fa-globe", "url": e.URL,
})
if len(histItems) >= 20 {
if len(histItems) >= 21 {
break
}
}
if len(histItems) == 0 {
histItems = append(histItems, map[string]any{
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
"icon": "fas fa-info-circle", "url": "/admin/",
})
}
for _, si := range secs {
if si.name == histName {
if isHistSection(si.name) {
si.raw["items"] = histItems
}
}
@ -531,6 +552,9 @@ func visitCount(rawURL string) int {
func main() {
os.MkdirAll(dataDir, 0755)
load()
// 启动时重建一次:让「历史访问」首条的插件安装入口立即生效,
// 而不必等到下一次浏览器插件同步。
rebuildDashySections()
http.HandleFunc("/sync", cors(handleSync))
http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) {

View File

@ -74,6 +74,13 @@ http {
alias /app/data/conf.yml;
}
# 可选的扩展商店链接:部署方把 extension-store.json 放进 data 卷即可,
# /extension/install.html 会读取它并显示「一键安装」按钮。
location = /extension/store.json {
alias /app/data/extension-store.json;
add_header Cache-Control "no-store";
}
# item-icons 从 data volume 提供
location /item-icons/ {
alias /app/data/item-icons/;

150
web/extension/install.html Normal file
View File

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>安装浏览历史插件</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
background:#0f172a;color:#e2e8f0;line-height:1.6;padding:32px 20px}
.wrap{max-width:720px;margin:0 auto}
h1{font-size:24px;font-weight:700;margin-bottom:8px}
.sub{color:#94a3b8;font-size:14px;margin-bottom:24px}
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px 22px;margin-bottom:16px}
.card h2{font-size:17px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.badge{font-size:12px;font-weight:600;padding:2px 8px;border-radius:999px;
background:#1e3a5f;color:#93c5fd;border:1px solid #2563eb}
.card.current{border-color:#2563eb;box-shadow:0 0 0 1px #2563eb inset}
ol{margin:0 0 0 20px;font-size:14px;color:#cbd5e1}
ol li{margin-bottom:6px}
a{color:#60a5fa}
code{background:#0f172a;border:1px solid #334155;border-radius:4px;padding:1px 6px;font-size:13px}
.note{font-size:13px;color:#94a3b8;margin-top:12px;border-top:1px solid #334155;padding-top:12px}
.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}
.btn{display:inline-block;padding:9px 18px;border-radius:8px;font-size:14px;font-weight:600;
text-decoration:none;border:1px solid #334155;background:#0f172a;color:#e2e8f0;cursor:pointer}
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
.btn.primary:hover{background:#1d4ed8}
.btn:hover{border-color:#475569}
.site{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}
.site input{flex:1;min-width:260px;background:#0f172a;border:1px solid #334155;border-radius:8px;
padding:9px 12px;color:#e2e8f0;font-size:14px}
.hint{font-size:13px;color:#94a3b8;margin-top:10px}
.steps-done{font-size:13px;color:#86efac;margin-top:10px}
</style>
</head>
<body>
<div class="wrap">
<h1>安装浏览历史插件</h1>
<p class="sub">安装后浏览记录会自动同步到本站,并据此把最常访问的服务排到「常用服务」最前面。</p>
<div class="card">
<h2>第一步:填写本站地址</h2>
<p class="hint">在插件设置里粘贴下面这个地址(点「复制」)。同步令牌从部署方的 <code>NAVI_SYNC_TOKEN</code> 环境变量获取。</p>
<div class="site">
<input id="site-url" readonly>
<button class="btn" id="copy-btn" type="button">复制</button>
</div>
<div class="steps-done" id="copy-done" hidden>已复制</div>
</div>
<div class="card" id="card-chromium" hidden>
<h2>Chrome / Edge <span class="badge">已识别你的浏览器</span></h2>
<div class="actions">
<a class="btn primary" id="store-chromium" hidden>从商店一键安装</a>
<a class="btn" href="/extension/navi-chrome.zip" download>下载扩展包(zip)</a>
</div>
<ol>
<li>解压下载的 zip 到一个固定目录(不要删除)</li>
<li>地址栏打开 <code>chrome://extensions</code>,Edge 用 <code>edge://extensions</code></li>
<li>打开右上角「开发者模式」</li>
<li>点「加载已解压的扩展程序」,选择解压出来的目录</li>
<li>点扩展图标,填入上面的本站地址与同步令牌,保存</li>
</ol>
<p class="note">Chrome 不允许从网站直接安装非商店扩展,所以这里只能下载后手动加载。若已发布到商店,上面的「一键安装」按钮会自动出现。</p>
</div>
<div class="card" id="card-firefox" hidden>
<h2>Firefox <span class="badge">已识别你的浏览器</span></h2>
<div class="actions">
<a class="btn primary" id="store-firefox" hidden>从商店一键安装</a>
<a class="btn" href="/extension/navi-firefox.xpi" download>下载扩展包(xpi)</a>
</div>
<ol>
<li>地址栏打开 <code>about:debugging#/runtime/this-firefox</code></li>
<li>点「临时加载附加组件」,选择下载的 xpi</li>
<li>点扩展图标,填入上面的本站地址与同步令牌,保存</li>
</ol>
<p class="note">Firefox 正式版只接受签名附加组件;未签名的 xpi 只能在开发者版 / ESR 上加载,或在 <code>about:config</code> 关闭 <code>xpinstall.signatures.required</code>。发布到 AMO 后可用上面的「一键安装」。</p>
</div>
<div class="card" id="card-other" hidden>
<h2>其他浏览器</h2>
<p class="hint">未能识别具体浏览器。Chromium 内核(Brave、Vivaldi、Opera、Kiwi 等)请按上面 Chrome 的步骤操作;Firefox 请按 Firefox 的步骤操作。</p>
<div class="actions">
<a class="btn" href="/extension/navi-chrome.zip" download>下载 Chromium 版(zip)</a>
<a class="btn" href="/extension/navi-firefox.xpi" download>下载 Firefox 版(xpi)</a>
</div>
</div>
<div class="card">
<h2>装好后</h2>
<ol>
<li>回到导航页并刷新,本次会话浏览几条页面</li>
<li>约 30 秒后历史会自动上报,「历史访问」开始按访问频次排序</li>
<li>检测到插件已安装后,本入口会自动隐藏</li>
</ol>
</div>
</div>
<script>
(function () {
'use strict';
document.getElementById('site-url').value = location.origin;
var copyBtn = document.getElementById('copy-btn');
copyBtn.addEventListener('click', function () {
var input = document.getElementById('site-url');
input.select();
input.setSelectionRange(0, 999);
var done = document.getElementById('copy-done');
function ok() { done.hidden = false; setTimeout(function () { done.hidden = true; }, 2000); }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(input.value).then(ok, function () { document.execCommand('copy'); ok(); });
} else {
document.execCommand('copy'); ok();
}
});
// 浏览器识别:Firefox 用 UA 判断,其余按 Chromium 处理。
var ua = navigator.userAgent;
var isFirefox = /firefox\/|fxios\//i.test(ua);
var isChromium = /chrome\/|chromium\/|edg\//i.test(ua);
var card = document.getElementById(isFirefox ? 'card-firefox' : (isChromium ? 'card-chromium' : 'card-other'));
card.hidden = false;
card.classList.add('current');
// 可选:部署方在 /app/data/extension-store.json 放商店链接后,
// 对应平台的「一键安装」按钮会自动出现(无需重建镜像)。
fetch('/extension/store.json', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (s) {
if (!s) return;
if (s.chrome) showStore('store-chromium', s.chrome);
if (s.firefox) showStore('store-firefox', s.firefox);
})
.catch(function () { /* 未配置商店链接,保持手动安装步骤 */ });
function showStore(id, url) {
var el = document.getElementById(id);
if (!el) return;
el.href = url;
el.hidden = false;
}
})();
</script>
</body>
</html>

View File

@ -1,6 +1,24 @@
(() => {
'use strict';
// 浏览器插件在 document_start 阶段写入这个属性(见 extension/*/detect.js)。
const EXT_ATTR = 'data-navi-ext';
const INSTALL_SELECTOR = 'a[href*="/extension/install"]';
function extensionInstalled() {
return document.documentElement.hasAttribute(EXT_ATTR);
}
// 已装插件的浏览器不需要安装入口:连外层网格单元一起隐藏,
// 否则 .item-wrapper 会留下一个空位。
function hideInstallEntry() {
if (!extensionInstalled()) return;
document.querySelectorAll(INSTALL_SELECTOR).forEach((link) => {
const cell = link.closest('.item-wrapper') || link.parentElement || link;
if (cell.style.display !== 'none') cell.style.display = 'none';
});
}
function mountSearch() {
if (document.querySelector('.navi-search-bar')) return;
const form = document.createElement('form');
@ -30,10 +48,15 @@
document.body.appendChild(form);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountSearch, { once: true });
} else {
function run() {
mountSearch();
hideInstallEntry();
}
new MutationObserver(mountSearch).observe(document.documentElement, { childList: true, subtree: true });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true });
} else {
run();
}
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
})();