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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -16,3 +16,7 @@ gateway/navi-gateway
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.codegraph/
|
||||
|
||||
# Go build output
|
||||
history-server/history-server
|
||||
admin-server/admin-server
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@ -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
|
||||
|
||||
37
README.md
37
README.md
@ -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。
|
||||
|
||||
## 许可
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') {
|
||||
|
||||
20
extension/chrome/detect.js
Normal file
20
extension/chrome/detect.js
Normal 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 一定存在;出错也只是少一个标记 */
|
||||
}
|
||||
@ -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>"
|
||||
|
||||
@ -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') {
|
||||
|
||||
20
extension/firefox/detect.js
Normal file
20
extension/firefox/detect.js
Normal 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 一定存在;出错也只是少一个标记 */
|
||||
}
|
||||
@ -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 设置",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
150
web/extension/install.html
Normal 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>
|
||||
31
web/navi.js
31
web/navi.js
@ -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 });
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user