diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 748b825..ec55cda 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -164,6 +164,10 @@ - [x] ~~首启动空屏~~(v0.2.1:启动自动打开一个终端) - [ ] zh-TW 用语统一(i18n 质量专项):`命令/指令` 全文件混用(panels/ssh/settings/terminal,台湾惯用「指令」)、`口令`→`密碼`(ssh.secret.passphrase)、顺带通读 服务器→伺服器 / 默认→預設 / 网络→網路 / 软件→軟體。需要一次全局连贯的专项,零散改会制造新的不一致 - [ ] ja 片假名长音符号统一:`フォルダ`(6) / `フォルダー`(7) 混用,二选一全局统一 +- [ ] 周期检查可被高频设置写入推迟(updater:每次 mutateSettings 重排 4h 定时器,字体快捷键连按/ColorPicker 拖动会重置倒计时;改为「仅 interval 值变化时重排」或记录上次触发时间) +- [ ] 「启动时自动检查」运行中途拨开会在 ~5s 后立即检查(startupArmed 语义应为下次启动生效) +- [ ] 下载无整体超时:检查有 withTimeout,下载没有——慢滴流可让 downloading 卡死且自动下载不再重试(需设计,不能简单套超时杀死慢速下载) +- [ ] release.cjs `--channel-only` 与 `--skip-gitea` 组合矛盾(channel-only 先 return,警告永不打印,Gitea 通道仍会上传) - [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制) - [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令) - [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等) diff --git a/src/main/updater.ts b/src/main/updater.ts index 7802a3a..b2ce8d0 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -323,11 +323,17 @@ async function performCheck(source: CheckSource): Promise { */ export function runCheck(source: CheckSource = 'manual'): Promise { // A manual check is an explicit act, so whatever it finds counts as news even - // if this run has already dealt with that version: the suppression record is - // dropped here — before the in-flight guard, so a click that joins a check - // already running gets the same treatment. (A manual DOWNLOAD needs no - // equivalent: its entry point never consults the record.) - if (source === 'manual') actedRelease = undefined + // if this run has already announced that version: the announcement half of the + // record is dropped here — before the in-flight guard, so a click that joins a + // check already running gets the same treatment. The DOWNLOADED half is kept: + // for a release whose package sits on disk, "downloaded" *is* the truthful + // answer, and dropping it made update-available report `available` again — + // folding the About tab's install button back into "download" for a manual + // check on an already-fetched release. (A manual DOWNLOAD needs no equivalent: + // its entry point never consults the record.) + if (source === 'manual') { + actedRelease = actedRelease?.downloaded ? actedRelease : undefined + } if (checkInFlight) return checkInFlight const task = performCheck(source) checkInFlight = task diff --git a/src/renderer/src/workspace/ZmodemOffers.tsx b/src/renderer/src/workspace/ZmodemOffers.tsx index 8f31b7e..5436269 100644 --- a/src/renderer/src/workspace/ZmodemOffers.tsx +++ b/src/renderer/src/workspace/ZmodemOffers.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { App as AntdApp, Button, Modal, Space, Typography } from 'antd' import type { ZmodemOfferEvent, ZmodemResponse } from '@shared/ipc' -import { t } from '@shared/i18n' +import { DEFAULT_LANGUAGE, t, tFor } from '@shared/i18n' +import { useSettingsStore } from '../settings/store' /** * M6 ZMODEM offer/done UX: renders one dialog per active zmodem offer. @@ -15,6 +16,10 @@ import { t } from '@shared/i18n' */ export default function ZmodemOffers(): null | React.JSX.Element { const { message } = AntdApp.useApp() + // Rendered as App's sibling: a language switch re-renders App, but the parent's + // children element is identical, so this subtree bails out and would keep the + // old language. Subscribe here and translate with the explicit language. + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const [offers, setOffers] = useState>(new Map()) const busyRef = useRef>(new Set()) @@ -117,21 +122,21 @@ export default function ZmodemOffers(): null | React.JSX.Element { closable={false} maskClosable={false} keyboard={false} - title={isReceive ? t('ssh.zmodem.titleReceive') : t('ssh.zmodem.titleSend')} + title={isReceive ? tFor(language, 'ssh.zmodem.titleReceive') : tFor(language, 'ssh.zmodem.titleSend')} footer={ - + } > - {isReceive ? t('ssh.zmodem.receiveDesc') : t('ssh.zmodem.sendDesc')} + {isReceive ? tFor(language, 'ssh.zmodem.receiveDesc') : tFor(language, 'ssh.zmodem.sendDesc')} ) diff --git a/tests/updater-fallback.mjs b/tests/updater-fallback.mjs index c56a2ed..56259d8 100644 --- a/tests/updater-fallback.mjs +++ b/tests/updater-fallback.mjs @@ -986,6 +986,28 @@ console.log('a finished download stays finished') ok(s.status === 'available', `a different release starts from 'available' (got '${s.status}')`) ok(s.version === '4.4.2', `and is named (${s.version})`) } + + // A manual check after a finished download must ALSO keep the downloaded + // state — the package is on disk, so "downloaded" is the truthful answer, and + // folding the install button back into "download" would make the user fetch + // what they already have. The manual record drop only discards announcement + // bookkeeping, never the on-disk fact. + live.emit('update-downloaded', { version: '4.4.2' }) + await tick() + ok(call(Ipc.UPDATE_STATE_GET).status === 'downloaded', 'the new release finishes too') + ctl.checkForUpdates = () => { + live.emit('checking-for-update') + live.emit('update-available', { version: '4.4.2' }) + return Promise.resolve(null) + } + { + const state = await call(Ipc.UPDATE_CHECK) + ok( + state.status === 'downloaded' && state.version === '4.4.2', + `a manual check keeps a finished download finished (got '${state.status}')` + ) + ok(state.status !== 'checking', 'and is not parked on the spinner') + } } console.log('balloons respect the visible-window guard') diff --git a/website/assets/i18n-apply.js b/website/assets/i18n-apply.js index baff421..910cb1c 100644 --- a/website/assets/i18n-apply.js +++ b/website/assets/i18n-apply.js @@ -27,6 +27,8 @@ if (!tag) return null; var t = String(tag).replace(/_/g, '-'); if (/^zh-?(tw|hk|mo)/i.test(t)) return 'zh-TW'; + // zh-Hant-* is Traditional by definition (kept in sync with the head script). + if (/^zh-hant/i.test(t)) return 'zh-TW'; if (/^zh/i.test(t)) return 'zh-CN'; if (/^ja/i.test(t)) return 'ja'; if (/^en/i.test(t)) return 'en'; diff --git a/website/index.html b/website/index.html index 9dc1f1e..bb467f0 100644 --- a/website/index.html +++ b/website/index.html @@ -27,6 +27,9 @@ if (!tag) return null; var t = String(tag).replace(/_/g, '-'); if (/^zh-?(tw|hk|mo)/i.test(t)) return 'zh-TW'; + // BCP-47 can carry a script subtag (zh-Hant-TW, zh-Hant-HK, zh-Hant): + // some browsers report those forms, and they are Traditional by definition. + if (/^zh-hant/i.test(t)) return 'zh-TW'; if (/^zh/i.test(t)) return 'zh-CN'; if (/^ja/i.test(t)) return 'ja'; if (/^en/i.test(t)) return 'en'; @@ -35,7 +38,12 @@ function pickLang() { var m = /[?&]lang=([^&]+)/.exec(location.search); if (m) { - var fromUrl = normalize(decodeURIComponent(m[1])); + // A truncated % escape in a shared link must not throw inside this + // first-frame IIFE: a throw here would skip setting html[lang] AND the + // pending flag, leaving the page stuck on the default language. + var raw = null; + try { raw = decodeURIComponent(m[1]); } catch (e) {} + var fromUrl = normalize(raw); if (fromUrl) return fromUrl; } try {