/* ============================ 版本检测 / 一键更新 ============================ 概述页的「版本与更新」卡片 + 更新弹窗(含进度条、可中断)。 刻意不塞进 modal():modal 的按钮回调是一次性的 await, 而更新是**长任务 + 持续轮询**,塞进去会导致「点了没反应、 进度不动」。这里自己搭一层遮罩,语义更清楚。 数据来源:/api/update/*(后端 drouter-update.py) ------------------------------------------------------------------------ 一条重要约定(别改):**检测走多源、下载只走 GitHub 官方**。 前端不要因为「代理快」就改去第三方拉大文件 —— 后端已经保证了, 这里只负责把「用的是哪个源」如实显示出来。 */ (function () { 'use strict'; if (window.__drouterUpdate) return; window.__drouterUpdate = true; const $ = s => document.querySelector(s); /* ⛔ 转义函数必须来自 app.js,**绝不能静默降级**。 1.0.10 修的是一个真实 XSS:这里的写法是 `const esc = w => (window.esc || 兜底)(w)` 而 app.js 里 `const esc = s => {...}` 是**顶层 const** —— 它只进全局 词法环境,**不会**成为 window 的属性(只有 function 声明和 var 才会)。 于是 `window.esc` 永远是 undefined → 走兜底 `String(x)` → **零转义**, 后端返回的版本号 / 文件名直接进 innerHTML。 (`api` 能取到是因为它是 `function` 声明 —— 这个区别很容易误判。) 现在 app.js 显式 `window.__drouterEsc = esc`,这里直接取它。 ⚠️ 兜底改成**抛错**而不是 String():拿不到转义函数就必须让问题暴露, 悄悄用零转义兜底等于把 XSS 藏起来 —— 那比白屏危险得多。 */ const esc = (window.__drouterEsc = window.__drouterEsc || ((w) => { if (w == null) return ''; if (typeof w === 'object') { try { w = JSON.stringify(w); } catch (e) { /* 循环引用 */ } } return String(w).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); })); /* ---- 后端 update 模块文案的英文对照(后端零改动)------------------- drouter-update.py 是独立模块,没有 helper 的 MSG_EN 表: source / errors / msg_cn / verified / install_hint / assets[].desc 全是中文原文下发。这里按「整串正则 → 子串」两级翻,中文界面原样返回。 ⚠️ 动态部分(路径 / 版本 / 源名)保留原文,别硬套。 */ const updIsEn = () => !!(window.i18n && window.i18n.getLang && window.i18n.getLang() === 'en-US'); // 源名 / 附件说明走 i18n.js 的 bt 表(bt4),中文键不落在本文件里 —— // 否则 t-i18n-cover 会把它们判成「未包 t() 的文案」。 const updSrc = (x) => (!updIsEn() ? x : (typeof bt4 === 'function' ? bt4('UPD_SOURCES', x, 'en', x) : x)); const updAsset = (a) => { const d = (a && a.desc) || ''; if (!updIsEn() || typeof bt4 !== 'function') return d; return bt4('UPD_ASSETS', a.key, 'd', d); }; function updText(x) { let s = String(x == null ? '' : x); if (!updIsEn() || !/[\u4e00-\u9fff]/.test(s)) return s; const pats = [ [/^无法访问 GitHub,请检查本机外网连通性$/, 'Cannot reach GitHub; check this machine\u2019s Internet connectivity'], [/^校验不通过:文件与官方 SHA256 不一致$/, 'Verification failed: the file does not match the official SHA256'], [/^官方 SHA256(下载源:(.+?))$/, (m, p1) => 'Official SHA256 (source: ' + updSrc(p1) + ')'], [/^未获取到 SHA256 清单,本次下载\*\*未做完整性校验\*\*(下载源:(.+?))$/, (m, p1) => 'No SHA256 manifest was obtained; this download was **not integrity-checked** (source: ' + updSrc(p1) + ')'], [/^安装:sudo apt install \.\/(.+?)(或 dpkg -i 后 apt --fix-broken install)$/, 'Install: sudo apt install ./$1 (or dpkg -i then apt --fix-broken install)'], [/^离线安装:解包后进入目录执行 \.\/install-offline\.sh,全程不需要外网$/, 'Offline install: unpack, cd into the directory and run ./install-offline.sh; no Internet needed'], [/^Docker:docker load -i (.+?) 然后 docker compose up -d$/, 'Docker: docker load -i $1 then docker compose up -d'], [/^下载完成并已保存到 (.+)$/, (m, p1) => 'Download complete, saved to ' + p1], [/^正在从 (.+?) 下载…$/, (m, p1) => 'Downloading from ' + updSrc(p1) + '…'], [/^下载失败:([\s\S]+)$/, (m, p1) => 'Download failed: ' + updText(p1)], [/^备份失败,已中止更新:([\s\S]+)$/, (m, p1) => 'Backup failed, update aborted: ' + updText(p1)], [/^更新过程出错:([\s\S]+)$/, (m, p1) => 'Update error: ' + updText(p1)], [/^未知的附件类型:(.+)$/, (m, p1) => 'Unknown asset type: ' + p1], // 内层原因(backend/drouter-update.py 的 _why() / _do_backup() 原文) [/^连接超时,外网可能不通$/, 'connection timed out; the Internet may be unreachable'], [/^HTTPS 证书校验失败(可能被劫持或需要安装 CA)$/, 'HTTPS certificate validation failed (possibly hijacked, or a CA needs installing)'], [/^附件不存在(该版本可能没发布这个文件)$/, 'the asset does not exist (this release may not publish that file)'], [/^连接被拒绝$/, 'connection refused'], [/^所有下载源均不可用$/, 'all download sources are unavailable'], [/^备份程序不存在:(.+)$/, (m, p1) => 'backup program not found: ' + p1], [/^调用备份程序失败:([\s\S]+)$/, (m, p1) => 'failed to run the backup program: ' + updText(p1)], [/^备份程序未产出备份包(可能未开启自动备份)$/, 'the backup program produced no pack (automatic backup may be off)'], [/^当前没有进行中的任务$/, 'there is no task in progress'], [/^返回内容里没有可识别的版本号$/, 'the response contained no recognisable version number'], [/^连接超时$/, 'connection timed out'], [/^版本号格式不对$/, 'malformed version string'], [/^已有更新任务在进行中$/, 'an update task is already running'], [/^已开始更新$/, 'Update started'], ]; for (const q of pats) { if (q[0].test(s)) return s.replace(q[0], q[1]); } const subs = [ [/正在备份当前配置…/g, 'Backing up the current configuration…'], [/备份完成,开始下载…/g, 'Backup complete, starting download…'], [/准备下载…/g, 'Preparing download…'], [/正在校验文件完整性…/g, 'Verifying file integrity…'], [/准备开始/g, 'Starting'], [/下载中/g, 'Downloading'], [/已取消,未做任何改动/g, 'Cancelled; nothing was changed'], [/下载源:/g, 'source: '], ]; let out = s; for (const q of subs) out = out.replace(q[0], q[1]); return out; } // errors 是 "<源名>:<原因>" 拼出来的 function updErr(x) { const s = String(x == null ? '' : x); if (!updIsEn()) return s; const m = s.match(/^(.+?):([\s\S]+)$/); if (m) return updSrc(m[1]) + ': ' + updText(m[2]); return updText(s); } let POLL = null; // 发布页地址。⚠️ 这是**展示**用的常量,不参与任何下载逻辑 —— // 真正的下载 URL 由后端 DL_SOURCES 决定(那里才有降级链)。 // 前端拼不出也不该拼下载 URL(判据里有这条)。 const REPO_URL = 'https://github.com/liuzhuohua/drouter'; /* ---------------- 本地版本 ---------------- ⚠️ 必须从页脚 `#app-ver` 读,**不能在 app.js 里写 __APP_VERSION__**。 后端只对 index.html 做字符串替换(`_inject_asset_version` 里的 `.replace('__APP_VERSION__', app_version())`),app.js 是独立静态文件、 **不经过注入** —— 在 app.js 里写这个变量会直接 ReferenceError。 离线渲染检查器(check-render.js)就是这么抓到的。 页脚那个元素在 index.html 里,服务端已把占位符换成真实版本号。 */ function localVersion() { const el = document.getElementById('app-ver'); const s = el ? String(el.textContent || '').trim() : ''; return s.replace(/^v/, '') || ''; } /* ---------------- 版本卡片 ---------------- */ async function renderCard() { const host = $('#upd-card'); if (!host) return; const lv = localVersion(); const el = document.getElementById('upd-local'); if (el) el.textContent = lv || '—'; let d = null; try { const r = await api('/api/update/check'); if (r && r.ok) d = r.data; } catch (e) { /* 网络异常走下面的失败分支 */ } if (!d) { host.innerHTML = `
${t('版本')}${esc(lv || '—')}
${t('无法检测新版本')} ${t('请检查本机外网连通性')}
`; bindRetry(); return; } if (!d.ok) { host.innerHTML = `
${t('版本')}${esc(d.local)}
${t('检测失败')} ${esc(updText(d.msg_cn || ''))}
${(d.errors || []).length ? `
${ d.errors.map(e => esc(updErr(e))).join(' · ')}
` : ''}`; bindRetry(); return; } if (d.has_update) { host.innerHTML = `
${t('版本')} ${esc(d.local)} → ${esc(d.latest)} ${t('有可用更新')}
`; const g = $('#upd-go'); if (g) g.onclick = () => openUpdate(d); bindForce(host, d); } else { // ⚠️ 「已是最新」这一支原来点「重新检查」后**什么提示都没有** —— // 按钮文字变回「重新检查」、卡片内容和上次一模一样,用户完全 // 分不清「点了没生效」还是「重查了结果还是最新」。 // → 补三样东西:① 检查中的明确状态 ② 完成后 toast // ③ 卡片上常驻显示「上次检查:时间 · 探测源」。 const w = d.ts ? new Date(d.ts * 1000) : null; const whenTxt = w ? `${w.getHours()}:${String(w.getMinutes()).padStart(2, '0')}:${ String(w.getSeconds()).padStart(2, '0')}` : '—'; // ⚠️ 「查看更新内容并升级」原来**只在 has_update 为真时**才渲染。 // 于是「当前已是最新」的用户只能看到「重新检查」, // 合理地怀疑「升级按钮是不是坏了 / 藏哪了」—— // 2026-10-04 用户就反馈过这个(他自己是 1.0.9,线上也是 1.0.9)。 // 「看看新版本发布了什么」这件事**本来就不该依赖有没有更新**。 // → 最新版也保留入口,只是按钮改成「查看版本发布说明」, // 点开是只读的信息窗(发布内容 + 下载地址 + 安装命令), // 不提供下载进度条 —— 没有可下的东西就别给升级流程。 host.innerHTML = `
${t('版本')} ${esc(d.local)} ${t('已是最新')}
${t('上次检查:')}${esc(whenTxt)} ${t('· 探测源:')}${esc(updSrc(d.source) || '—')}
`; const ib = $('#upd-info'); if (ib) ib.onclick = () => openReleaseNotes(d); bindForce(host, d); } } /* 「重新检查」的统一处理:进行中反馈 + 完成后 toast。 ⚠️ 完成后**必须**给用户一个明确的说法,否则重查与不重查看起来一样。 */ function bindForce(host, prev) { const b = $('#upd-force'); if (!b) return; b.onclick = async () => { // ① 进行中:按钮禁用 + 卡片上换行提示(不要只改按钮文字, // 按钮文字会随 innerHTML 重建消失) b.disabled = true; b.textContent = t('检查中…'); const w = $('#upd-when'); if (w) w.textContent = t('正在连接 GitHub 查询最新版本…'); let ok = false, msg = ''; try { const r = await api('/api/update/check?force=1'); ok = !!(r && r.ok); if (ok) { const d = r.data || {}; msg = d.has_update ? `${t('发现新版本 ')}${d.latest}${t('(当前 ')}${d.local}${t(')')}` : `${t('已是最新版本 ')}${d.local}`; } else { msg = (r && r.msg_cn) || t('检测失败'); } } catch (e) { msg = t('检测失败:') + ((e && e.message) || t('网络异常')); } // ② 完成后重绘卡片 await renderCard(); // ③ toast 让用户确知结果 —— 这一条是这次改进的关键 toast(ok ? msg : msg, ok ? 'ok' : 'err', 4200); }; } function bindRetry() { const b = $('#upd-retry'); if (!b) return; b.onclick = async () => { b.disabled = true; b.textContent = t('检测中…'); const w = $('#upd-when'); if (w) w.textContent = t('正在连接 GitHub 查询最新版本…'); let ok = false, msg = ''; try { const r = await api('/api/update/check?force=1'); ok = !!(r && r.ok); msg = ok ? ((r.data && r.data.has_update) ? `${t('发现新版本 ')}${r.data.latest}` : t('已是最新版本')) : ((r && r.msg_cn) || t('检测失败')) } catch (e) { msg = t('检测失败:') + ((e && e.message) || t('网络异常')); } await renderCard(); toast(msg, ok ? 'ok' : 'err', 4200); }; } /* ---------------- 版本发布说明(只读,2026-10-10)------------------ 「已是最新」时也能打开这个窗口。它**不是升级流程**(没有可下的东西 就不给进度条),而是回答「这版是什么、从哪拿、怎么装」—— 用户看到别人提到某个版本时,可以直接复制安装命令。 */ function openReleaseNotes(d) { const assets = d.assets || []; const ver = d.latest || d.local || ''; const rows = assets.map(a => ` ${esc(updAsset(a))} ${esc(a.name)} ${esc(REPO_URL + '/releases/download/v' + ver + '/' + a.name)} `).join(''); const html = `
${t('当前安装:')}${esc(d.local)} ${t('· 最新发布:')}${esc(d.latest || '—')} ${d.has_update ? `${t('有可用更新')}` : `${t('已是最新')}`}

${t('怎么装')}

${t('下载地址')}

${rows ? `${rows}
${t('类型')}${t('文件名')} ${t('链接')}
` : `

${t('(未取得附件列表,可能探测源暂时不可用)')}

`}

${t('全部下载在')} GitHub Release ${t('页:')} ${esc(REPO_URL + '/releases/tag/v' + ver)}

${t('安全说明')}

`; const m = document.createElement('div'); m.className = 'upd-mask'; m.id = 'upd-note'; m.innerHTML = `

${t('版本发布说明')}

${html}
`; document.body.appendChild(m); const close = () => m.remove(); m.querySelector('#un-close').onclick = close; m.addEventListener('click', e => { if (e.target === m) close(); }); } /* ---------------- 更新弹窗 ---------------- */ function openUpdate(d) { const assets = d.assets || []; const rows = assets.map((a, i) => ` `).join(''); const html = `
${t('检测到新版本 ')}${esc(d.latest)} ${t('(当前 ')}${esc(d.local)}${t(')。')}

${t('更新前会做什么')}

${t('安全与可中断性')}

${t('选择要下载的安装包')}

${rows || `
${t('未能获取附件列表')}
`}
`; const m = document.createElement('div'); m.className = 'upd-mask'; m.id = 'upd-mask'; m.innerHTML = `

${t('更新到 ')}${esc(d.latest)}

${html}
`; document.body.appendChild(m); const close = () => { m.remove(); if (POLL) { clearInterval(POLL); POLL = null; } }; m.querySelector('#upd-close').onclick = close; m.addEventListener('click', e => { if (e.target === m) close(); }); m.querySelector('#upd-start').onclick = async () => { const sel = m.querySelector('input[name="upd-asset"]:checked'); if (!sel) { alert(t('请选择一个安装包')); return; } const btn = m.querySelector('#upd-start'); btn.disabled = true; btn.textContent = t('正在启动…'); const r = await api('/api/update/apply', { method: 'POST', body: { version: d.latest, asset: sel.value, backup: true } }); if (!r || !r.ok) { alert((r && r.msg_cn) || t('启动更新失败')); btn.disabled = false; btn.textContent = t('开始更新'); return; } m.querySelector('#upd-prog').classList.remove('pw-hidden'); btn.style.display = 'none'; const ct = m.querySelector('#upd-cancel-task'); ct.style.display = ''; ct.onclick = async () => { const x = await api('/api/update/cancel', { method: 'POST' }); m.querySelector('#upd-msg').textContent = (x && x.msg_cn) || t('操作失败'); }; POLL = setInterval(() => tickUpdate(m), 900); tickUpdate(m); }; } async function tickUpdate(m) { const r = await api('/api/update/state'); if (!r || !r.ok) return; const s = r.data || {}; const fill = m.querySelector('#upd-fill'); const bar = fill.parentElement; const pct = Math.max(0, Math.min(100, s.pct || 0)); fill.style.width = pct + '%'; bar.classList.toggle('warn', s.phase === 'failed' || s.phase === 'canceled'); let msg = updText(s.msg_cn || ''); if (s.phase === 'download' && s.speed) { msg += ` (${(s.speed / 1024).toFixed(0)} KB/s)`; } m.querySelector('#upd-msg').textContent = msg; // ⚠️ 实际用的下载源要**全程显示**(用户明确要求)。 // 后端在大陆网络下会自动从 GitHub 官方降级到 gh-proxy 镜像 // (实测 api.github.com / github.com 直连 100% 丢包)。 // 这不是异常情况,但用户有权知道自己的安装包经过了谁。 // 走镜像时额外给一句说明 —— 降级本身要显眼,不能藏在 meta 里。 let meta = ''; if (s.src) { const mirror = /镜像|proxy/i.test(s.src); meta += (mirror ? `${t('下载源:')}${esc(updSrc(s.src))}` + `${t(' 官方直连不通,已自动改用镜像;')}` + t('文件完整性仍会按官方 SHA256 校验') : `${t('下载源:')}${esc(updSrc(s.src))}`); } if (s.total) { meta += (meta ? '
' : '') + `${(s.got / 1048576).toFixed(1)} / ${(s.total / 1048576).toFixed(1)} MB`; } if (s.sha256) { meta += (meta ? ' · ' : '') + 'SHA256 ' + String(s.sha256).slice(0, 16) + '…'; } if (s.verified) meta += (meta ? '
' : '') + esc(updText(s.verified)); if (s.path) meta += (meta ? '
' : '') + t('已保存:') + esc(s.path) + ''; if (s.install_hint) meta += (meta ? '
' : '') + esc(updText(s.install_hint)); m.querySelector('#upd-meta').innerHTML = meta; if (!s.running) { if (POLL) { clearInterval(POLL); POLL = null; } m.querySelector('#upd-cancel-task').style.display = 'none'; const b = m.querySelector('#upd-close'); if (b) b.textContent = t('完成'); } } /* ---------------- 挂载 ---------------- */ // 概述页每次重新渲染都会替换 innerHTML,所以挂到 MutationObserver 之外的 // 稳定时机:路由切换后由 init 调一次(见下),且内部有 host 存在性检查。 // 测试钩子:离线英文回归要能直接喂后端文案(卡片/进度弹窗不都能靠一次渲染覆盖) window.__drouterUpdI18n = { text: updText, src: updSrc, asset: updAsset, err: updErr }; window.drouterRenderUpdateCard = renderCard; if (window.i18n) window.i18n.register('update', renderCard); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', renderCard); } else { setTimeout(renderCard, 0); } })();