Compare commits

...
9 Commits
Author SHA1 Message Date
Bill 2027fa3310 fix(updater,i18n): 对抗性审查修复(1中 + 3低)
CI / typecheck + test + build (windows) (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
- [中] 手动检查不再丢弃「已下载」事实:runCheck('manual') 原来无条件清
  抑制记录,导致下载完成后点「检查更新」会把关于页从「重启安装」退回
  「下载」(包已在磁盘)。改为保留 downloaded 记录,只清 announcement
  记账;测试 §19 补 manual-check 保持 downloaded 的口径(153 断言)
- website 首帧脚本:?lang= 的 decodeURIComponent 包 try/catch(畸形转义
  会在 IIFE 内抛 URIError,跳过 html[lang] 与防闪标记,指定语言被静默
  忽略);zh-Hant-TW/zh-Hant-HK/zh-Hant 按 BCP-47 语义判为繁体
  (normalize 与 i18n-apply.js 双处同步)
- ZmodemOffers 是 App 兄弟节点,语言切换时父级 children bailout 不重渲染:
  订阅 language 并以 tFor 显式翻译(事件回调内的 t() 保留)
- ROADMAP 补录对抗性审查推迟项(周期检查可被设置写入推迟、启动开关
  即时生效语义、下载无整体超时、--channel-only/--skip-gitea 矛盾)
2026-10-10 16:51:10 +08:00
Bill 7c2044fdf4 fix(i18n): 崩溃兜底页不再停留旧语言
CI / typecheck + test + build (windows) (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
ErrorBoundary / PanelErrorBoundary 是 class 组件(拥有错误生命周期),
不能用 hook 订阅 settings store;根边界还是 App 的父级,语言切换时
App 的重渲染传导不到它——崩溃兜底页因此停留在启动时的语言,恰好是
用户最需要读得懂的一屏。

修法:导出改为一层薄函数包装(订阅 language 传 prop),class 内部经
tFor(language, …) 翻译。错误边界行为不变(边界仍是 class、仍在
children 之上)。使用方(main.tsx / TerminalPanel)无需改动。

ROADMAP 已知待办补录两项 i18n 质量专项:zh-TW 用语统一(命令/指令、
口令/密碼 等)、ja 片假名长音符号统一(フォルダ/フォルダー)。
2026-10-10 15:35:56 +08:00
Bill b344af4691 chore(release): v1.0.25
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-10 15:21:05 +08:00
Bill b89f9c6c95 website: v1.0.25 — 四语言站点 + 更新下载与日志
- 单页 + 语言层:assets/i18n.js(147 键 × zh-CN/zh-TW/en/ja)+
  i18n-apply.js(data-i18n / data-i18n-html / data-i18n-attr 三种挂键方式,
  首帧前定语言,?lang= → localStorage → navigator → zh-CN)
- 导航栏新增语言切换;html[lang]、title、meta description / og:* 同步
- 版本号/下载直链 → v1.0.25;近期更新补 1.0.25 / 1.0.24,裁到 5 条
- style.css 仅追加(语言选择器样式 + 首帧防闪),既有规则零改动
- README 补语言层说明与发版清单
2026-10-10 15:18:50 +08:00
Bill 7a1b11dad7 fix(i18n): 日文布局菜单的分割标签仍是中文
ja 的 workspace.layout.splitRight/splitBelow 值为「水平分割/垂直分割」
(中文句式),改为日语 UI 惯用的「左右に分割/上下に分割」。zh-TW 的
「水平分割/垂直分割」是通顺繁体且与 zh-TW 全文件用词(分割)一致,保留。
2026-10-10 15:18:49 +08:00
Bill 402bf368fc fix(i18n): 语言切换后 dockview/memo 组件不再停留旧语言
dockview 渲染的组件(面板内容/标签/header actions)不在 App→Workspace 的
重渲染路径上,React.memo 且 prop 恒定的组件也被挡住——它们的渲染期 t()
读模块级 current,不重渲染就停留旧值。日文界面下「+」按钮 tooltip、
SFTP 表头、搜索条等因此停留在中文。

修法沿用仓库既有惯例:组件自己订阅 settings.system.language,渲染文案改
tFor(language, …)(显式语言,不依赖模块级状态)。共 8 文件 145 处:

- TerminalTab / SshHostBadge / TerminalPanel / SshBottomPanel /
  TabActions(+按钮)/ BroadcastToggleButton / BroadcastPopover
- FilePanel(memo,60处) / PermissionModal / 传输面板行 / MonitorPanel
- TerminalView:搜索条、右键菜单、工具栏 tooltip、粘贴确认、结束遮罩;
  搜索命中提示改为「存标签、渲染期翻译」(消除 effect 闭包过期风险)

刻意不改:事件回调动作时构造的字符串(t() 在调用时刻已是新语言);
不给 DockviewReact 加 key={language}(会重挂载杀掉全部会话);
retitleAutoTitles 的存储标题机制保持不变。
2026-10-10 15:18:48 +08:00
Bill c94e3358ea test(updater): 覆盖调度、自动下载、同版本抑制与气泡
updater-fallback 从 82 扩到 150 断言(新增 11 节):周期节拍与关闭、重排、
启动检查只武装一次、并发检查共用同一 promise、自动下载开关、同版本去重、
换版本恢复、手动检查不被抑制、已下载状态保持(含不卡 checking 与
fetchHandled/downloaded 区分)、气泡的窗口可见性守卫。electron-stub 的
Tray 补 displayBalloon 记录、BrowserWindow.getAllWindows 可注入假窗口。

AGENTS.md 更新机制一节补录三条来源、注入式调度、自动下载、同版本抑制与
气泡守卫。
2026-10-10 13:36:55 +08:00
Bill bf79036599 feat(settings): 更新开关的界面与四语言文案
- 系统设置页加「定时检查更新」下拉(选项由 UPDATE_CHECK_INTERVALS 白名单
  生成,标签用 Record<UpdateCheckInterval, string> 保证漏项即编译错误)与
  「自动下载更新」开关
- 关于页:后台自动下载完成时用 statusDownloadedAuto 文案,与用户手动下载
  区分
- 四语言补齐 13 个键(含托盘气泡文案),已用 i18n parity 校验 617 键对齐
2026-10-10 13:36:54 +08:00
Bill 9cc856fc8f feat(updater): 定时检查更新与后台自动下载
设置 → 系统新增两个开关:定时检查更新(从不/4/12/24 小时,默认 4)与
自动下载更新(默认关)。安装时机仍交给用户——绝不因下载完成自动退出,
autoInstallOnAppQuit 只覆盖用户自己退出时装上。

- runCheck(source) 统一三条来源(启动/周期/手动),UpdateState.scheduled
  标记来源;已有在途检查返回同一 promise,避免 electron-updater 的去重把
  来源标记记到错的调用方
- 周期调度器经 settingsStore.setUpdateScheduleApplier 注入(updater 已
  import settingsStore,反向 import 成环);幂等重排,启动检查由
  startupArmed 保证每进程只武装一次;所有 timer 带 unref
- 自动下载由 settings.autoDownloadUpdate 驱动,autoUpdater.autoDownload
  保持 false(单一事实源在 settings);失败不重试,等下一周期
- 同版本抑制(actedRelease 单槽,actedFor 版本不同即替换):否则长开机
  的应用每 4 小时重复弹气泡、重复走一遍 downloadUpdate;手动检查无条件
  清空记录
- downloaded 与 fetchHandled 分开:前者仅在 update-downloaded 置位,用于
  阻止后续 update-available 把状态从 downloaded 翻回 available(否则「关于」
  页按钮会从「重启安装」退回「下载」)
- tray.notifyUpdate 弹 Windows 托盘气泡;非 win32 / 无 tray / 有可见窗口
  都不弹
2026-10-10 13:36:53 +08:00
43 changed files with 2695 additions and 365 deletions

No files matched your search

+7
View File
@@ -47,6 +47,13 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 更新机制
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- **三条检查来源**(`runCheck(source)`,共用一条路径,`UpdateState.scheduled` 标记来源):启动后 5s 一次(`autoCheckUpdate`)、周期定时(`settings.system.updateCheckIntervalHours`,白名单 `{0,4,12,24}` 默认 4,`0`=从不;4/12/24 建 `setInterval(runCheck('scheduled'))`)、用户手动。**所有 timer 必须 `.unref()`**,否则会阻止进程退出。已有在途检查时返回同一 promise(`checkInFlight`),避免 electron-updater 的去重把来源标记记到错的调用方头上
- **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查
- **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期
- **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」
- **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`
- `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」
- **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- 自动更新只走 NSIS exe;自 v1.0.22 起不再构建 MSI 安装包(electron-updater 本就不支持 MSI 自动更新)
- 每次 checkForUpdates 都被 **30s 整体超时**包住(`withTimeout`):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但**被放弃的检查取消不掉**,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
+28
View File
@@ -1,5 +1,33 @@
# OpenTerminal Changelog
## v1.0.25 - 2026-10-10
## v1.0.23
### SFTP file management
- **The list is now a real table**: Name / Size / Modified / Permissions / Owner / Group are aligned in six columns, with a sticky header that stays visible while scrolling long listings.
- **Click-to-sort headers**: all six columns sort, with arrow indicators for ascending/descending; directories always come before files.
- **Adjustable font size**: use the A- / A+ toolbar buttons or Ctrl+mouse wheel to scale between 0.8x and 1.8x. Row height follows the font size, and the setting is remembered locally, so it survives reopening the panel.
- **Real owner / group names**: previously only numeric UID / GID were shown; the client now parses the real names (`eveuser`, `root`, ...) out of the data returned by the SSH server.
- **Context menu on empty space**: right-click anywhere in the blank area of the list to get New Folder, Upload, Refresh and friends without having to select a row first.
- The modified column now reads `2026/10/08 15:23`, and sizes read `30.7 KB`.
### Fixes
- Fixed the file panel's six columns not actually aligning to the grid (the sort icon's style class was being cloned onto the row element, overriding the row's grid layout).
## v1.0.23
### SFTP file management
- **The list is now a real table**: Name / Size / Modified / Permissions / Owner / Group are aligned in six columns, with a sticky header that stays visible while scrolling long listings.
- **Click-to-sort headers**: all six columns sort, with arrow indicators for ascending/descending; directories always come before files.
- **Adjustable font size**: use the A- / A+ toolbar buttons or Ctrl+mouse wheel to scale between 0.8x and 1.8x. Row height follows the font size, and the setting is remembered locally, so it survives reopening the panel.
- **Real owner / group names**: previously only numeric UID / GID were shown; the client now parses the real names (`eveuser`, `root`, ...) out of the data returned by the SSH server.
- **Context menu on empty space**: right-click anywhere in the blank area of the list to get New Folder, Upload, Refresh and friends without having to select a row first.
- The modified column now reads `2026/10/08 15:23`, and sizes read `30.7 KB`.
### Fixes
- Fixed the file panel's six columns not actually aligning to the grid (the sort icon's style class was being cloned onto the row element, overriding the row's grid layout).
## v1.0.24 - 2026-10-09
## v1.0.23
+28
View File
@@ -1,5 +1,33 @@
# OpenTerminal 更新履歴
## v1.0.25 - 2026-10-10
## v1.0.23
### SFTP ファイル管理
- **一覧が本物の表に**:名前 / サイズ / 更新日時 / パーミッション / 所有者 / グループの 6 列で整列表示。ヘッダーは固定され、長い一覧をスクロールしても常に見えます。
- **ヘッダーをクリックで並べ替え**:6 列すべてが並べ替え可能。昇順・降順は矢印で表示され、ディレクトリは常にファイルより前に来ます。
- **文字サイズの変更**:ツールバーの A- / A+ または Ctrl+ホイールで 0.8〜1.8 倍に調整。行の高さも文字サイズに追従し、設定はローカルに保存されるためパネルを開き直しても維持されます。
- **所有者 / グループを実名表示**:従来は数字の UID / GID のみでしたが、SSH サーバーから返る情報を解析して `eveuser` や `root` といった実際の名前を表示します。
- **空白部分の右クリックメニュー**:一覧の何もない場所を右クリックすると、新規フォルダー・アップロード・更新などを呼び出せます。行を先に選択する必要はありません。
- 更新日時は `2026/10/08 15:23`、サイズは `30.7 KB` といった読みやすい形式になりました。
### 修正
- ファイルパネルの 6 列が実際にはグリッド整列していなかった問題を修正(並べ替えアイコンのスタイルクラスが行要素に複製され、行のグリッドレイアウトを上書きしていました)。
## v1.0.23
### SFTP ファイル管理
- **一覧が本物の表に**:名前 / サイズ / 更新日時 / パーミッション / 所有者 / グループの 6 列で整列表示。ヘッダーは固定され、長い一覧をスクロールしても常に見えます。
- **ヘッダーをクリックで並べ替え**:6 列すべてが並べ替え可能。昇順・降順は矢印で表示され、ディレクトリは常にファイルより前に来ます。
- **文字サイズの変更**:ツールバーの A- / A+ または Ctrl+ホイールで 0.8〜1.8 倍に調整。行の高さも文字サイズに追従し、設定はローカルに保存されるためパネルを開き直しても維持されます。
- **所有者 / グループを実名表示**:従来は数字の UID / GID のみでしたが、SSH サーバーから返る情報を解析して `eveuser` や `root` といった実際の名前を表示します。
- **空白部分の右クリックメニュー**:一覧の何もない場所を右クリックすると、新規フォルダー・アップロード・更新などを呼び出せます。行を先に選択する必要はありません。
- 更新日時は `2026/10/08 15:23`、サイズは `30.7 KB` といった読みやすい形式になりました。
### 修正
- ファイルパネルの 6 列が実際にはグリッド整列していなかった問題を修正(並べ替えアイコンのスタイルクラスが行要素に複製され、行のグリッドレイアウトを上書きしていました)。
## v1.0.24 - 2026-10-09
## v1.0.23
+27
View File
@@ -1,5 +1,32 @@
# OpenTerminal 更新日志
## v1.0.25 - 2026-10-10
新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。
### 新增:定时检查更新
- 设置 → 系统新增「定时检查更新」,可选 **从不 / 每 4 小时 / 每 12 小时 / 每 24 小时**(默认每 4 小时)
- 启动时的自动检查与手动「检查更新」始终保留,不受该设置影响
- 选「从不」即只保留启动检查与手动检查
### 新增:自动下载更新
- 设置 → 系统新增「自动下载更新」开关(默认关闭)
- 开启后,发现新版本会在后台**静默下载**,不打扰你的操作
- **安装时机始终由你决定**:下载完成后应用不会自行退出,你可以在「关于」页随时点「重启安装」,或在下次正常退出应用时自动完成安装
### 通知
- 发现新版本或后台下载完成时,若窗口正最小化在托盘,会弹出一条来自托盘的提示
- 窗口可见时只更新「关于」页状态,不弹通知打扰
### 改进
- 同一个版本不会重复提示或重复下载:长期不关机的用户不会再每 4 小时收到一次相同的新版本提示
- 后台下载完成、你尚未安装期间,应用不会再把这个版本退回「待下载」状态(「关于」页会稳定显示「重启安装」)
- 手动点「检查更新」不受上述去重影响,始终如实反映最新状态
### 界面语言
- 修复切换界面语言后,部分界面仍停留在旧语言的问题:终端标签的工具提示与右键菜单、布局菜单、分屏「+」按钮提示、SFTP 文件面板(表头/工具栏/右键菜单/权限对话框)、传输面板、服务器监控面板、终端搜索条与粘贴确认等,现在都会即时跟随界面语言
- 修正日文界面布局菜单中「水平分割 / 垂直分割」仍为中文的问题(现为「左右に分割 / 上下に分割」)
## v1.0.24 - 2026-10-09
一轮全面的审查修复:两个数据安全级问题、多处稳定性与内存问题,以及一批界面细节修补。
+28
View File
@@ -1,5 +1,33 @@
# OpenTerminal 更新日誌
## v1.0.25 - 2026-10-10
## v1.0.23
### SFTP 檔案管理
- **清單改為真正的表格**:名稱 / 大小 / 修改時間 / 權限 / 使用者 / 群組六欄對齊顯示,表頭吸頂,長清單捲動時始終可見
- **點擊表頭排序**:六欄皆可排序,升降序切換有箭頭指示,目錄始終排在檔案之前
- **字型可縮放**:工具列 A- / A+ 或 Ctrl+滾輪在 0.8–1.8 倍之間調整,行高與字號聯動,設定記入本機,重開面板保持
- **使用者 / 群組顯示真實名稱**:先前只顯示數字 UID / GID,現在從 SSH 伺服器回傳的資訊中解析出 `eveuser` / `root` 這樣的真實名稱
- **空白處右鍵選單**:在清單空白區域右鍵即可喚出新增資料夾、上傳、重新整理等操作,不必先點中某一列
- 時間欄改為 `2026/10/08 15:23` 格式,大小欄改為 `30.7 KB` 這類易讀寫法
### 修復
- 修復檔案面板六欄先前並未真正按網格對齊的問題(排序圖示的樣式類別被誤加到列元素上,頂掉了列的網格佈局)
## v1.0.23
### SFTP 檔案管理
- **清單改為真正的表格**:名稱 / 大小 / 修改時間 / 權限 / 使用者 / 群組六欄對齊顯示,表頭吸頂,長清單捲動時始終可見
- **點擊表頭排序**:六欄皆可排序,升降序切換有箭頭指示,目錄始終排在檔案之前
- **字型可縮放**:工具列 A- / A+ 或 Ctrl+滾輪在 0.8–1.8 倍之間調整,行高與字號聯動,設定記入本機,重開面板保持
- **使用者 / 群組顯示真實名稱**:先前只顯示數字 UID / GID,現在從 SSH 伺服器回傳的資訊中解析出 `eveuser` / `root` 這樣的真實名稱
- **空白處右鍵選單**:在清單空白區域右鍵即可喚出新增資料夾、上傳、重新整理等操作,不必先點中某一列
- 時間欄改為 `2026/10/08 15:23` 格式,大小欄改為 `30.7 KB` 這類易讀寫法
### 修復
- 修復檔案面板六欄先前並未真正按網格對齊的問題(排序圖示的樣式類別被誤加到列元素上,頂掉了列的網格佈局)
## v1.0.24 - 2026-10-09
## v1.0.23
+6
View File
@@ -162,6 +162,12 @@
- [x] ~~布局塌陷~~(v0.2.1 修复:antd `<App>` 包裹层 `.ant-app` 无高度导致百分比高度链断裂)
- [x] ~~关闭标签页整窗崩溃~~(v0.2.1 修复:xterm IDisposable 对象被当函数调用;并新增 ErrorBoundary 兜底 + 渲染层报错转发主进程日志)
- [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 等)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "1.0.24",
"version": "1.0.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "1.0.24",
"version": "1.0.25",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.24",
"version": "1.0.25",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
+7 -2
View File
@@ -5,12 +5,12 @@ import { pathToFileURL } from 'url'
import { devRendererUrl } from './devEnv'
import { isTrustedRendererUrl, registerIpc } from './ipc'
import { killAllPtys, killPtysByOwner } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { applyStartupSystemSettings, loadSettings, setUpdateScheduleApplier } from './settingsStore'
import { getLockController, initLockController } from './lockController'
import { applyMenuLockState } from './lockMenu'
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyWindowChrome } from './windowChrome'
import { onLanguageChange, t } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
@@ -90,6 +90,11 @@ if (!gotSingleInstanceLock) {
// Before the window exists: a renderer-triggered check must not run against
// the updater's defaults (feed, proxy, autoDownload are set in here).
configureAutoUpdater()
// Arms the startup + periodic update checks and re-arms them on every
// settings save. Injected rather than imported by settingsStore because
// updater.ts already imports settingsStore: the reverse import would be a
// cycle, arming the timer against a half-initialised updater.
setUpdateScheduleApplier(applyUpdateSchedule)
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
+32
View File
@@ -7,6 +7,7 @@ import {
DEFAULT_SETTINGS,
isHighlightCategory,
isLockAutoDelay,
isUpdateCheckInterval,
lockAutoDelayOf,
type AppSettings,
type HighlightRule,
@@ -363,6 +364,12 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
globalShowHide,
closeAction,
autoCheckUpdate: candidate.autoCheckUpdate !== false,
// The interval is a whitelist (UPDATE_CHECK_INTERVALS): an arbitrary
// number here would silently rewrite how often we hit the update feed.
updateCheckIntervalHours: isUpdateCheckInterval(candidate.updateCheckIntervalHours)
? candidate.updateCheckIntervalHours
: DEFAULT_SYSTEM.updateCheckIntervalHours,
autoDownloadUpdate: candidate.autoDownloadUpdate === true,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false,
shellIntegration: candidate.shellIntegration === true,
@@ -395,6 +402,28 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
let sleepBlockerId: number | undefined
/**
* The updater's scheduler, injected rather than imported: `updater.ts` already
* imports this module, so importing it back would be a cycle — the timer would be
* armed against a half-initialised updater (and, depending on load order, on an
* `undefined` binding). `index.ts` wires this once with the updater's scheduler.
*/
let updateScheduleApplier: ((system: SystemSettings) => void) | null = null
/**
* Wire the updater's scheduler. The current settings are handed over immediately
* as well, so the schedule is armed no matter where in startup the caller wires
* this: `index.ts` wires the updater around `applyStartupSystemSettings`, and the
* order of the two is not part of the contract. `loadSettings` is a pure read — it
* never calls `applySystemSettings` — so applying here cannot recurse, and the
* extra call is idempotent: the applier is a plain "set the timer for this
* interval" step that already re-runs on every settings save.
*/
export function setUpdateScheduleApplier(fn: (system: SystemSettings) => void): void {
updateScheduleApplier = fn
fn(loadSettings().system)
}
/** Apply OS-level effects of the system settings (login item, sleep blocker). */
function applySystemSettings(system: SystemSettings): void {
// The main process renders its own strings (tray menu, dialogs), so it tracks
@@ -413,6 +442,9 @@ function applySystemSettings(system: SystemSettings): void {
}
// Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide)
// Re-arm (or disarm) the periodic update check the same way. Routed through the
// injected applier instead of importing the updater, which would be a cycle.
updateScheduleApplier?.(system)
}
/** Apply system side effects for the settings loaded at startup. */
+15
View File
@@ -103,6 +103,21 @@ function hideToTray(win: BrowserWindow): void {
}
}
/**
* Balloon for background events (update available / downloaded). Windows only;
* silent elsewhere, and only when no window is visible — a user looking at the
* app already sees the state in 设置 → 关于.
*
* Deliberately not gated on `balloonShown`: that flag is the one-shot
* "minimized to tray" hint, while an update event may legitimately notify once
* per version.
*/
export function notifyUpdate(title: string, content: string): void {
if (process.platform !== 'win32' || !tray) return
if (BrowserWindow.getAllWindows()[0]?.isVisible()) return
tray.displayBalloon({ iconType: 'info', title, content })
}
/**
* Window close interception. Must be called synchronously from the 'close'
* event: preventDefault happens before any await on every branch.
+275 -16
View File
@@ -1,11 +1,13 @@
import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
import type { SystemSettings, UpdateCheckInterval } from '@shared/settings'
import { isUpdateCheckInterval } from '@shared/settings'
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
import { t } from '../shared/i18n'
import { broadcast } from './broadcast'
import { devUpdateFeedUrl } from './devEnv'
import { loadSettings } from './settingsStore'
import { markQuitting } from './tray'
import { markQuitting, notifyUpdate } from './tray'
/**
* Update feeds: GitHub releases is tried first (system proxy, gated by a
@@ -37,20 +39,94 @@ const GITHUB_PROBE_URL =
* silence, so a slow trickling response can hold an attempt open forever.
* - `fetch`: changelog / releases-API fetches; a stalled response must not
* hang the About tab.
* - `startupDelay`: how long after launch the startup check runs.
* - `hour`: what one settings "hour" is worth in milliseconds. The periodic
* check multiplies the configured interval (4/12/24) by it, so the harness can
* drive a whole cadence in milliseconds instead of waiting out real hours.
*/
const budgets = {
probe: 20_000,
check: 30_000,
fetch: 15_000
fetch: 15_000,
startupDelay: 5_000,
hour: 3_600_000
}
export function setUpdateTimeouts(patch: Partial<typeof budgets>): void {
Object.assign(budgets, patch)
}
/** Interval used when settings carry no whitelisted value (a hand-built object). */
const FALLBACK_CHECK_INTERVAL_HOURS: UpdateCheckInterval = 4
/** Which entry point asked for a check; surfaced as `UpdateState.scheduled`. */
export type CheckSource = 'manual' | 'startup' | 'scheduled'
/** Which entry point asked for a download; surfaced as `UpdateState.auto`. */
type DownloadSource = 'manual' | 'auto'
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
let eventsWired = false
/**
* In-flight check / download. Both electron-updater calls de-dupe concurrent
* callers internally by handing back the same promise, which would make the
* second caller silently inherit the first one's source marks — guarding here
* keeps `scheduled` / `auto` honest, and keeps a background tick from hijacking
* a check the user just started by hand.
*/
let checkInFlight: Promise<UpdateState> | undefined
let downloadInFlight: Promise<void> | undefined
/** The periodic check timer; reassigned on every settings save. */
let intervalTimer: NodeJS.Timeout | undefined
/** Once per run: the startup check is armed the first time the schedule is
* applied, and a later settings save must not schedule a second one. */
let startupArmed = false
/**
* What has already been done for ONE release, so an app left running for days
* does not deal with the same version over and over: every 4 hours the periodic
* check re-reports the same availability, and without this record each cycle
* walked electron-updater through another `downloadUpdate()` — its cache makes
* that cheap, but the "update downloaded" balloon fired again every time — or,
* with auto-download off, repeated the "an update is available" balloon.
*
* One record at a time, deliberately: suppression is about the release the last
* check reported, and any DIFFERENT version replaces the whole record. A new
* release must be announced and downloaded as if this were a fresh launch, so
* the record must not outlive the release it describes (which also means a feed
* flapping between two versions cannot leave the older one suppressed, and that
* the memory here is bounded by construction).
*/
type ActedRelease = {
version: string
/** The "an update is available" balloon was already considered for this version. */
announced: boolean
/** A download of this version has been started (and not failed) or already
* finished — no later cycle needs that transfer again. Says nothing about
* whether the transfer ENDED: it is set when a download starts. */
fetchHandled: boolean
/** The package for this version is on disk (`update-downloaded` was seen).
* Separate from `fetchHandled` on purpose — a download in flight must not be
* mistaken for a finished one, or `downloading` would be skipped over. */
downloaded: boolean
/** The "the update has been downloaded" balloon was already considered. */
downloadedAnnounced: boolean
}
let actedRelease: ActedRelease | undefined
/** The record for `version`, replaced wholesale when it names a different release. */
function actedFor(version: string): ActedRelease {
if (actedRelease === undefined || actedRelease.version !== version) {
actedRelease = {
version,
announced: false,
fetchHandled: false,
downloaded: false,
downloadedAnnounced: false
}
}
return actedRelease
}
function setState(patch: Partial<UpdateState>): void {
state = { ...state, ...patch, currentVersion: app.getVersion() }
@@ -83,16 +159,77 @@ function wireEvents(): void {
if (eventsWired) return
eventsWired = true
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
autoUpdater.on('update-available', (info) =>
autoUpdater.on('update-available', (info) => {
const seen = actedFor(info.version)
// A release whose package already sits on disk keeps the `downloaded` state.
// Reporting `available` again would fold the About tab's "install" button back
// into "download" — for a user who never installed, every 4-hour cycle, for
// as long as the app stays open. The record is dropped before a manual check
// looks at it, so a hand-started check reports the truth again.
//
// The state cannot simply be left alone here: the check that found this
// release put it on 'checking', so an early return with no write would park
// the About tab on a spinner forever. Rebuild the equivalent 'downloaded'
// state instead — same fields `update-downloaded` writes, and `setState`
// merges, so `auto` (which the About tab reads for the automatic wording) and
// `scheduled` survive untouched.
if (seen.downloaded) {
setState({ status: 'downloaded', version: info.version, feed: activeFeed, percent: undefined })
return
}
setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined })
// The write above reports what the check just found; the suppression below is
// about what has already been done for that release, so a 4-hour cycle that
// keeps finding the same one does not restart its download or repeat its
// balloon. (A release that already finished downloading never gets here — it
// was answered above.)
// With auto-download on there is no "an update is available" balloon: the
// balloon that matters is the one saying the download finished. Either way
// nothing installs itself — installing stays a user action, and
// autoInstallOnAppQuit covers quitting.
if (loadSettings().system.autoDownloadUpdate === true) {
if (seen.fetchHandled) return
void handleDownload('auto')
return
}
if (seen.announced) return
seen.announced = true
notifyUpdate(
t('main.updater.notifyAvailableTitle'),
t('main.updater.notifyAvailableContent', { version: info.version })
)
})
// The suppression record survives this on purpose: a check that reports
// nothing (or fails) must not make the next cycle fetch a release it has
// already dealt with.
autoUpdater.on('update-not-available', () => setState({ status: 'latest', version: undefined }))
autoUpdater.on('download-progress', (p) =>
setState({ status: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) =>
autoUpdater.on('update-downloaded', (info) => {
// Read before setState: `auto` says who started this download, and the state
// keeps it (the About tab shows the automatic variant for it).
const wasAuto = state.auto === true
setState({ status: 'downloaded', version: info.version, percent: undefined })
const seen = actedFor(info.version)
// The package is on disk now, whoever fetched it (the user from the About
// tab, or the auto-download above): the next cycle has nothing left to fetch
// for this version.
seen.fetchHandled = true
// …and the release has reached its final state, which is what keeps a later
// `update-available` for it from downgrading the UI back to 'available'.
seen.downloaded = true
// Only the silent background download has to be announced; a download the
// user started from the About tab is already on screen. And only once per
// version — the balloon is not a progress log.
if (wasAuto && !seen.downloadedAnnounced) {
seen.downloadedAnnounced = true
notifyUpdate(
t('main.updater.notifyDownloadedTitle'),
t('main.updater.notifyDownloadedContent', { version: info.version })
)
}
})
autoUpdater.on('error', (err) =>
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
)
@@ -153,28 +290,102 @@ async function checkWithFallback(): Promise<void> {
}
}
async function handleCheck(): Promise<UpdateState> {
async function performCheck(source: CheckSource): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev' })
setState({ status: 'dev', scheduled: source === 'scheduled' })
return state
}
setState({ status: 'checking', error: undefined, percent: undefined })
setState({
status: 'checking',
error: undefined,
percent: undefined,
scheduled: source === 'scheduled'
})
try {
await checkWithFallback()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
setState({
status: 'error',
error: err instanceof Error ? err.message : String(err),
scheduled: source === 'scheduled'
})
}
return state
}
async function handleDownload(): Promise<void> {
/**
* The one check entry point: the IPC handler (manual), the startup timer and the
* periodic timer all come through here, so the `scheduled` mark cannot be
* attributed to the wrong caller. A check already running is shared instead of
* started twice — electron-updater would de-dupe the two anyway, but it returns
* its in-flight promise *to whoever asks first*, which is exactly how the source
* mark would end up on the wrong attempt.
*/
export function runCheck(source: CheckSource = 'manual'): Promise<UpdateState> {
// A manual check is an explicit act, so whatever it finds counts as news even
// 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
const release = (): void => {
if (checkInFlight === task) checkInFlight = undefined
}
// Released on rejection too: performCheck reports failures as state rather
// than throwing, but a stuck slot would silently stop every later check.
task.then(release, release)
return task
}
/** IPC entry point (设置 → 关于 "check now"): always a manual check. */
function handleCheck(): Promise<UpdateState> {
return runCheck('manual')
}
/**
* Download the update the last check found. `auto` marks a download started by
* the `autoDownloadUpdate` setting; it is what the balloon and the About tab key
* off. A failure lands on `status: 'error'` and is never retried here — the next
* check (or the user) starts the next attempt, which is exactly why the claim
* below is rolled back: a permanent claim would turn one failure into "auto
* update never happens again for this release".
*/
async function handleDownload(source: DownloadSource = 'manual'): Promise<void> {
// One transfer at a time: a second call would restart the same download.
if (downloadInFlight) return downloadInFlight
if (!app.isPackaged) return
setState({ status: 'downloading', percent: 0 })
// Claim the version before the transfer starts, so another `update-available`
// for it (the 4-hour cycle) cannot start a second one — and so a manual
// download of that version already on disk is not re-fetched in the
// background later. Rolled back on failure: a download that never finished
// must be retried by the next check, and claiming it permanently would leave
// auto-download silently dead.
const seen = state.version === undefined ? undefined : actedFor(state.version)
if (seen) seen.fetchHandled = true
const task = (async (): Promise<void> => {
setState({ status: 'downloading', percent: 0, auto: source === 'auto' })
try {
await autoUpdater.downloadUpdate()
} catch (err) {
if (seen) seen.fetchHandled = false
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
})()
downloadInFlight = task
const release = (): void => {
if (downloadInFlight === task) downloadInFlight = undefined
}
task.then(release, release)
return task
}
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
@@ -259,7 +470,9 @@ async function fetchChangelog(): Promise<ReleaseNote[]> {
export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
// Wrapped, not passed directly: an ipcMain handler receives the event as its
// first argument, which would land in `source`.
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, () => handleDownload('manual'))
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
if (!app.isPackaged) return
// The close interceptor (tray flow) would swallow this quit — mark first.
@@ -280,13 +493,59 @@ export function configureAutoUpdater(): void {
useFeed('gitea')
wireEvents()
autoUpdater.logger = console
// Both halves of the update stay under our control: `autoDownload` is driven
// by the `autoDownloadUpdate` setting (one source of truth, read in the
// update-available handler) rather than by electron-updater, and nothing here
// ever installs on its own — autoInstallOnAppQuit only covers quitting.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Startup check only when the user left it enabled (设置 → 关于).
if (loadSettings().system.autoCheckUpdate === false) return
const timer = setTimeout(() => {
void handleCheck()
}, 5000)
// The startup check and the periodic one are armed by applyUpdateSchedule
// (wired in index.ts through settingsStore's injected applier) instead of
// here: a settings save must re-arm the schedule, not add a second timer.
}
/**
* Arm (or re-arm) the update schedule from the current settings. Called by
* settingsStore's injected applier on startup and after every settings save, so
* it must be idempotent: the previous interval timer is dropped first, and the
* startup check is armed at most once per run — a settings save two seconds into
* the run must not schedule a second "startup" check.
*
* Dev builds schedule nothing (there is no feed), which is also why the startup
* flag is not set on the way out.
*/
export function applyUpdateSchedule(system: SystemSettings): void {
if (intervalTimer) {
clearInterval(intervalTimer)
intervalTimer = undefined
}
if (!app.isPackaged) return
// Startup check only when the user left it enabled (设置 → 关于). Left alone by
// later calls, and unref'd like every other timer here: a pending check must
// never keep the app (or a test process) alive.
if (!startupArmed && system.autoCheckUpdate !== false) {
startupArmed = true
const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay)
startup.unref?.()
}
// 0 = never: the startup and the manual check are all that is left. Any other
// value is a whitelisted interval (settingsStore sanitizes it); the fallback
// covers a hand-built SystemSettings.
const hours = isUpdateCheckInterval(system.updateCheckIntervalHours)
? system.updateCheckIntervalHours
: FALLBACK_CHECK_INTERVAL_HOURS
if (hours > 0) {
const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour)
timer.unref?.()
intervalTimer = timer
}
// Auto-download switched on while an update was already found: start now, the
// next tick could be hours away.
if (system.autoDownloadUpdate === true && state.status === 'available') {
void handleDownload('auto')
}
}
+38 -9
View File
@@ -1,6 +1,7 @@
import { Component } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { useSettingsStore } from './settings/store'
interface ErrorBoundaryProps {
children: ReactNode
@@ -11,10 +12,29 @@ interface ErrorBoundaryState {
}
/**
* Last-resort boundary: a render/effect crash must never take the whole app
* down to a black window. Shows the error and a reload escape instead.
* Error boundaries must stay class components (they own the error lifecycle),
* and class components cannot subscribe to the settings store the way function
* components do — nor do they re-render when App re-renders on a language
* switch (the root boundary is App's *parent*). So the exported boundaries are
* thin function wrappers that subscribe to the language and hand it down as a
* prop; the classes translate through `tFor(language, …)`. Without this, a
* crashed panel showed its fallback screen in whatever language the app was
* launched in — the one screen where reading it matters most.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
function useBoundaryLanguage(): Language {
return useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
}
/** Last-resort boundary: a render/effect crash must never take the whole app
* down to a black window. Shows the error and a reload escape instead. */
export function ErrorBoundary(props: ErrorBoundaryProps): ReactNode {
const language = useBoundaryLanguage()
return <ErrorBoundaryClass {...props} language={language} />
}
type ErrorBoundaryClassProps = ErrorBoundaryProps & { language: Language }
class ErrorBoundaryClass extends Component<ErrorBoundaryClassProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
@@ -39,7 +59,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
color: '#cccccc'
}}
>
<div>{t('workspace.error.title')}</div>
<div>{tFor(this.props.language, 'workspace.error.title')}</div>
<pre
style={{
maxWidth: 720,
@@ -57,7 +77,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
onClick={() => window.location.reload()}
style={{ padding: '6px 18px', cursor: 'pointer' }}
>
{t('workspace.error.reload')}
{tFor(this.props.language, 'workspace.error.reload')}
</button>
</div>
)
@@ -78,7 +98,14 @@ interface PanelErrorBoundaryProps {
* other live session. A crashed pane is replaced in place and can be remounted
* on retry; the rest of the workspace is untouched.
*/
export class PanelErrorBoundary extends Component<PanelErrorBoundaryProps, ErrorBoundaryState> {
export function PanelErrorBoundary(props: PanelErrorBoundaryProps): ReactNode {
const language = useBoundaryLanguage()
return <PanelErrorBoundaryClass {...props} language={language} />
}
type PanelErrorBoundaryClassProps = PanelErrorBoundaryProps & { language: Language }
class PanelErrorBoundaryClass extends Component<PanelErrorBoundaryClassProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
@@ -98,10 +125,12 @@ export class PanelErrorBoundary extends Component<PanelErrorBoundaryProps, Error
if (!this.state.error) return this.props.children
return (
<div className="panel-error">
<div className="panel-error-title">{this.props.label ?? t('workspace.error.panelTitle')}</div>
<div className="panel-error-title">
{this.props.label ?? tFor(this.props.language, 'workspace.error.panelTitle')}
</div>
<pre className="panel-error-message">{this.state.error.message}</pre>
<button type="button" className="panel-error-retry" onClick={this.retry}>
{t('workspace.error.panelRetry')}
{tFor(this.props.language, 'workspace.error.panelRetry')}
</button>
</div>
)
+23 -18
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './monitor.css'
@@ -119,14 +120,14 @@ function formatSize(mb: number): string {
}
/** human-readable uptime */
function humanizeUptime(sec: number): string {
function humanizeUptime(lang: Language, sec: number): string {
const s = Math.floor(sec)
if (s < 60) return t('ssh.monitor.uptimeSeconds', { n: s })
if (s < 60) return tFor(lang, 'ssh.monitor.uptimeSeconds', { n: s })
const mins = Math.floor(s / 60)
if (s < 3600) return t('ssh.monitor.uptimeMinutes', { n: mins })
if (s < 3600) return tFor(lang, 'ssh.monitor.uptimeMinutes', { n: mins })
const hours = Math.floor(s / 3600)
if (s < 86400) return t('ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) })
return t('ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) })
if (s < 86400) return tFor(lang, 'ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) })
return tFor(lang, 'ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) })
}
function Bar({ pct, color }: { pct: number; color: string }) {
@@ -142,6 +143,10 @@ function Bar({ pct, color }: { pct: number; color: string }) {
}
export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Element {
// Rendered inside a dockview panel, which is never re-rendered as part of the
// App→Workspace tree, so the language is subscribed here and every string is
// resolved with `tFor` instead of the module-level language `t()` reads.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const [meta, setMeta] = useState<SysinfoMeta | null>(null)
const [sample, setSample] = useState<SysinfoSample | null>(null)
const sampleRef = useRef<SysinfoSample | null>(null)
@@ -188,14 +193,14 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
if (sample?.error) {
return (
<div className="mm-root mm-empty mm-failed">
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error })}</div>
<div className="mm-error">{tFor(language, 'ssh.monitor.interrupted', { error: sample.error })}</div>
</div>
)
}
return (
<div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span>
{t('ssh.monitor.collecting')}
{tFor(language, 'ssh.monitor.collecting')}
</div>
)
}
@@ -210,7 +215,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
{failed && (
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
<div className="mm-error">{tFor(language, 'ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
)}
<header className="mm-header">
@@ -222,19 +227,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {t('ssh.monitor.cores', { n: cpu.cores })}</span>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {tFor(language, 'ssh.monitor.cores', { n: cpu.cores })}</span>
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub mm-load">
{t('ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
{tFor(language, 'ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
</div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
</section>
<section className="mm-block" aria-label={t('ssh.monitor.mem')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.mem')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.mem')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.mem')}</span>
<span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span>
@@ -248,9 +253,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
{disks.length > 0 && (
<section className="mm-block" aria-label={t('ssh.monitor.disks')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.disks')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.disks')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.disks')}</span>
</div>
<div className="mm-disks">
{disks.slice(0, 6).map((d) => {
@@ -273,9 +278,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
)}
<section className="mm-block" aria-label={t('ssh.monitor.net')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.net')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.net')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.net')}</span>
<div className="mm-net">
<span className="mm-net-item mm-rx">
<span className="mm-arrow mm-up">↓</span>
@@ -296,7 +301,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
<footer className="mm-footer">
{t('ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
{tFor(language, 'ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(language, sample.uptimeSec)}</span>
</footer>
</div>
)
+7 -1
View File
@@ -84,7 +84,13 @@ export function AboutTab(): React.JSX.Element {
}, [])
const status = state?.status ?? 'idle'
const statusText = t(STATUS_TEXT[status]) + (state?.version ? ` v${state.version}` : '')
// A download the app started on its own reads differently from one the user
// pressed the button for — same state, different expectation about install.
const statusKey =
status === 'downloaded' && state?.auto
? 'settings.about.statusDownloadedAuto'
: STATUS_TEXT[status]
const statusText = t(statusKey) + (state?.version ? ` v${state.version}` : '')
return (
<div className="about-tab">
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
import { UPDATE_CHECK_INTERVALS, type UpdateCheckInterval } from '@shared/settings'
import type { ThemeColors } from '@shared/theme'
import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n'
import { isReservedAccelerator } from '@shared/reservedAccelerators'
@@ -345,6 +346,18 @@ export function RenderSettingsTab(): React.JSX.Element {
)
}
/**
* Label for each offered interval. Keyed by the whitelist union rather than
* built ad hoc, so a new entry in UPDATE_CHECK_INTERVALS fails to type-check
* here until it has a label (and `0` keeps meaning "never").
*/
const UPDATE_INTERVAL_LABELS: Record<UpdateCheckInterval, string> = {
0: 'settings.system.updateCheckNever',
4: 'settings.system.updateCheck4h',
12: 'settings.system.updateCheck12h',
24: 'settings.system.updateCheck24h'
}
export function SystemSettingsTab(): React.JSX.Element {
const settings = useSettingsStore((s) => s.settings)
const updateSystem = useSettingsStore((s) => s.updateSystem)
@@ -403,6 +416,31 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label={t('settings.system.updateCheckInterval')}
desc={t('settings.system.updateCheckIntervalDesc')}
control={
<Select
className="settings-select"
value={settings.system.updateCheckIntervalHours ?? 4}
onChange={(value) => void updateSystem({ updateCheckIntervalHours: value })}
options={UPDATE_CHECK_INTERVALS.map((hours) => ({
value: hours,
label: t(UPDATE_INTERVAL_LABELS[hours])
}))}
/>
}
/>
<SettingRow
label={t('settings.system.autoDownload')}
desc={t('settings.system.autoDownloadDesc')}
control={
<Switch
checked={settings.system.autoDownloadUpdate === true}
onChange={(checked) => void updateSystem({ autoDownloadUpdate: checked })}
/>
}
/>
<SettingRow
label={t('settings.system.closeAction')}
desc={t('settings.system.closeActionDesc')}
+87 -71
View File
@@ -19,7 +19,8 @@ import {
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import type { SftpEntry, TransferProgressEvent } from '@shared/sftp'
import { getLanguage, t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import './sftp.css'
/** Remote file browser for one SSH session (M4). */
@@ -89,9 +90,9 @@ function bitsToOctal(bits: PermBits): string {
return special > 0 ? `${special}${perms}` : perms
}
/** t() falls back to the key itself when a translation is missing. */
function tOr(key: string, fallback: string): string {
const value = t(key)
/** tFor() falls back to the key itself when a translation is missing. */
function tOr(lang: Language, key: string, fallback: string): string {
const value = tFor(lang, key)
return value === key ? fallback : value
}
@@ -126,12 +127,20 @@ function ownerLabel(e: SftpEntry): string {
}
/** Permission-grid labels are resolved per render so they follow the language. */
function permRowLabels(): string[] {
return [t('ssh.file.permRowOwner'), t('ssh.file.permRowGroup'), t('ssh.file.permRowOther')]
function permRowLabels(lang: Language): string[] {
return [
tFor(lang, 'ssh.file.permRowOwner'),
tFor(lang, 'ssh.file.permRowGroup'),
tFor(lang, 'ssh.file.permRowOther')
]
}
function permColLabels(): string[] {
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')]
function permColLabels(lang: Language): string[] {
return [
tFor(lang, 'ssh.file.permColRead'),
tFor(lang, 'ssh.file.permColWrite'),
tFor(lang, 'ssh.file.permColExec')
]
}
/**
@@ -196,9 +205,11 @@ interface PermissionModalProps {
entry: SftpEntry | null
onClose: () => void
onSaved: () => void
/** from FilePanel, which owns the language subscription — see there */
language: Language
}
function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element {
function PermissionModal({ open, sessionId, entry, onClose, onSaved, language }: PermissionModalProps): React.JSX.Element {
const { message } = App.useApp()
const [bits, setBits] = useState<PermBits>(emptyBits)
const [octal, setOctal] = useState('')
@@ -208,8 +219,8 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
/** lstat failed while listing → no mode to compare against or preserve. */
const modeKnown = Boolean(entry?.mode)
const rowLabels = permRowLabels()
const colLabels = permColLabels()
const rowLabels = permRowLabels(language)
const colLabels = permColLabels(language)
const modeStr = useMemo(() => {
if (!entry) return { type: '-', str: '---------' }
@@ -302,7 +313,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
await window.api.chownRemote(sessionId, entry.path, u, g)
}
if (modeChanged || ownerChanged) {
message.success(t('ssh.file.permApplied'))
message.success(tFor(language, 'ssh.file.permApplied'))
}
onClose()
onSaved()
@@ -316,13 +327,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
return (
<Modal
title={t('ssh.file.permTitle', { name: entry?.name ?? '' })}
title={tFor(language, 'ssh.file.permTitle', { name: entry?.name ?? '' })}
open={open}
onOk={onSave}
onCancel={onClose}
confirmLoading={saving}
okText={t('common.save')}
cancelText={t('common.cancel')}
okText={tFor(language, 'common.save')}
cancelText={tFor(language, 'common.cancel')}
okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }}
width={380}
>
@@ -355,11 +366,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
</div>
<div className="sftp-perm-octal-input">
<span className="sftp-perm-label">{t('ssh.file.octal')}</span>
<span className="sftp-perm-label">{tFor(language, 'ssh.file.octal')}</span>
<Input
value={octal}
onChange={(e) => applyOctal(e.target.value)}
placeholder={t('ssh.file.octalPlaceholder')}
placeholder={tFor(language, 'ssh.file.octalPlaceholder')}
addonAfter={bitsToModeStr(modeStr.type, bits)}
/>
</div>
@@ -370,7 +381,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input
value={uid}
onChange={(e) => setUid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/>
</div>
<div className="sftp-perm-own-field">
@@ -378,14 +389,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/>
</div>
</div>
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
{entry && !modeKnown && <div className="sftp-perm-hint">{t('ssh.file.modeUnavailable')}</div>}
<div className="sftp-perm-hint">{tFor(language, 'ssh.file.ownerHint')}</div>
{entry && !modeKnown && <div className="sftp-perm-hint">{tFor(language, 'ssh.file.modeUnavailable')}</div>}
{(idInvalid(uid) || idInvalid(gid)) && (
<div className="sftp-perm-hint">{t('main.sftp.invalidUidGid')}</div>
<div className="sftp-perm-hint">{tFor(language, 'main.sftp.invalidUidGid')}</div>
)}
</div>
</Modal>
@@ -397,8 +408,15 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
* lifetime, so dragging the SSH divider (which re-renders SshBottomPanel on
* every pointermove) no longer re-renders the whole file browser. Switching to
* a different directory still works — it is internal state.
*
* The stable prop is also why the language has to be subscribed here: memo
* blocks the parent's re-render, and the panel lives in dockview's own render
* slot on top of that, so nothing else would ever re-render it. Every string
* below therefore goes through `tFor(language, …)` — `t()` would read the
* module-level language of whatever render last touched it.
*/
export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const { message, modal } = App.useApp()
const [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([])
@@ -640,9 +658,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
startUpload(files)
return
}
const desc = tOr('ssh.file.overwriteDesc', '')
const desc = tOr(language, 'ssh.file.overwriteDesc', '')
modal.confirm({
title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')),
title: tOr(language, 'ssh.file.overwriteTitle', tFor(language, 'ssh.file.uploadFile')),
content: (
<div>
{existing.map((name) => (
@@ -651,9 +669,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{desc && <div>{desc}</div>}
</div>
),
okText: t('common.ok'),
okText: tFor(language, 'common.ok'),
okButtonProps: { danger: true },
cancelText: t('common.cancel'),
cancelText: tFor(language, 'common.cancel'),
onOk: () => startUpload(files)
})
} catch (err) {
@@ -721,16 +739,13 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
void (async () => {
try {
await navigator.clipboard.writeText(entry.path)
message.success(t('ssh.file.pathCopied'))
message.success(tFor(language, 'ssh.file.pathCopied'))
} catch {
message.error(t('ssh.file.copyPathFailed'))
message.error(tFor(language, 'ssh.file.copyPathFailed'))
}
})()
}
/** Active language; a dep of the menu below so translated labels refresh. */
const language = getLanguage()
/** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */
const menu: MenuProps = useMemo(() => {
const target = menuTarget
@@ -738,51 +753,51 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
const canAction = target != null
return {
items: [
{ key: 'refresh', label: t('ssh.file.refresh'), icon: <ReloadOutlined /> },
{ key: 'refresh', label: tFor(language, 'ssh.file.refresh'), icon: <ReloadOutlined /> },
{ type: 'divider' },
{
key: 'open',
label: isDir ? t('ssh.file.open') : t('ssh.file.download'),
label: isDir ? tFor(language, 'ssh.file.open') : tFor(language, 'ssh.file.download'),
icon: isDir ? <FolderOpenOutlined /> : <DownloadOutlined />,
disabled: !canAction
},
{
key: 'download',
label: t('ssh.file.download'),
label: tFor(language, 'ssh.file.download'),
icon: <DownloadOutlined />,
disabled: !canAction || Boolean(isDir)
},
{ key: 'upload', label: t('ssh.file.upload'), icon: <UploadOutlined /> },
{ key: 'upload', label: tFor(language, 'ssh.file.upload'), icon: <UploadOutlined /> },
{
key: 'rename',
label: t('ssh.file.rename'),
label: tFor(language, 'ssh.file.rename'),
icon: <FormOutlined />,
disabled: !canAction
},
{
type: 'submenu',
key: 'new',
label: t('ssh.file.new'),
label: tFor(language, 'ssh.file.new'),
icon: <FileAddOutlined />,
children: [
{ key: 'mkdir', label: t('ssh.file.newFolder'), icon: <FolderAddOutlined /> }
{ key: 'mkdir', label: tFor(language, 'ssh.file.newFolder'), icon: <FolderAddOutlined /> }
// 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
]
},
{ type: 'divider' },
{
key: 'copyPath',
label: t('ssh.file.copyPath'),
label: tFor(language, 'ssh.file.copyPath'),
icon: <CopyOutlined />,
disabled: !canAction
},
{
key: 'permission',
label: t('ssh.file.permission'),
label: tFor(language, 'ssh.file.permission'),
icon: <SafetyOutlined />,
disabled: !canAction
},
{ key: 'delete', label: t('common.delete'), icon: <DeleteOutlined />, danger: true, disabled: !canAction }
{ key: 'delete', label: tFor(language, 'common.delete'), icon: <DeleteOutlined />, danger: true, disabled: !canAction }
],
onClick: ({ key }) => {
const entry = menuTarget
@@ -808,11 +823,11 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
setPermOpen(true)
} else if (key === 'delete') {
modal.confirm({
title: t('ssh.file.deleteTitle'),
title: tFor(language, 'ssh.file.deleteTitle'),
content: entry ? `${entry.name}(${entry.path})` : undefined,
okText: t('common.delete'),
okText: tFor(language, 'common.delete'),
okButtonProps: { danger: true },
cancelText: t('common.cancel'),
cancelText: tFor(language, 'common.cancel'),
onOk: () =>
new Promise<void>((resolve, reject) => {
if (!entry) {
@@ -864,7 +879,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{e.isDir ? '' : fmtSize(e.size)}
</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
<span className="sftp-mode" title={e.mode ?? tFor(language, 'ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
@@ -888,7 +903,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
return (
<div className="sftp-panel" style={scaleVars}>
<div className="sftp-toolbar">
<Tooltip title={t('ssh.file.up')}>
<Tooltip title={tFor(language, 'ssh.file.up')}>
<Button
type="text"
size="small"
@@ -897,10 +912,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => void refresh(parent)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.refresh')}>
<Tooltip title={tFor(language, 'ssh.file.refresh')}>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={() => void refresh(dir)} />
</Tooltip>
<Tooltip title={t('ssh.file.newFolder')}>
<Tooltip title={tFor(language, 'ssh.file.newFolder')}>
<Button
type="text"
size="small"
@@ -911,10 +926,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}}
/>
</Tooltip>
<Tooltip title={t('ssh.file.uploadFile')}>
<Tooltip title={tFor(language, 'ssh.file.uploadFile')}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} />
</Tooltip>
<Tooltip title={t('ssh.file.downloadSelected')}>
<Tooltip title={tFor(language, 'ssh.file.downloadSelected')}>
<Button
type="text"
size="small"
@@ -923,7 +938,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => selected && doDownload(selected)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.rename')}>
<Tooltip title={tFor(language, 'ssh.file.rename')}>
<Button
type="text"
size="small"
@@ -935,7 +950,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}}
/>
</Tooltip>
<Tooltip title={t('ssh.file.permOwner')}>
<Tooltip title={tFor(language, 'ssh.file.permOwner')}>
<Button
type="text"
size="small"
@@ -945,15 +960,15 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
/>
</Tooltip>
<Popconfirm
title={t('ssh.file.deleteTitle')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
title={tFor(language, 'ssh.file.deleteTitle')}
okText={tFor(language, 'common.delete')}
cancelText={tFor(language, 'common.cancel')}
onConfirm={doDelete}
>
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm>
<span className="sftp-toolbar-gap" />
<Tooltip title={t('ssh.file.zoomOut')}>
<Tooltip title={tFor(language, 'ssh.file.zoomOut')}>
<Button
type="text"
size="small"
@@ -962,7 +977,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => adjustScale(-FONT_SCALE_STEP)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.zoomIn')}>
<Tooltip title={tFor(language, 'ssh.file.zoomIn')}>
<Button
type="text"
size="small"
@@ -1024,16 +1039,16 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
>
<div className="sftp-head">
<span className="sftp-head-cell" />
{headCell('name', 'sftp-col-name', t('ssh.file.colName'))}
{headCell('size', 'sftp-col-size', t('ssh.file.colSize'))}
{headCell('mtime', 'sftp-col-time', t('ssh.file.colModified'))}
<span className="sftp-head-cell sftp-col-mode">{t('ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{t('ssh.file.colOwner')}</span>
{headCell('name', 'sftp-col-name', tFor(language, 'ssh.file.colName'))}
{headCell('size', 'sftp-col-size', tFor(language, 'ssh.file.colSize'))}
{headCell('mtime', 'sftp-col-time', tFor(language, 'ssh.file.colModified'))}
<span className="sftp-head-cell sftp-col-mode">{tFor(language, 'ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{tFor(language, 'ssh.file.colOwner')}</span>
</div>
{loading && entriesSorted.length === 0 && (
<div className="sftp-empty">{t('ssh.file.loading')}</div>
<div className="sftp-empty">{tFor(language, 'ssh.file.loading')}</div>
)}
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{tFor(language, 'ssh.file.empty')}</div>}
{virtualized ? (
<div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
<div
@@ -1056,31 +1071,32 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
entry={selected}
onClose={() => setPermOpen(false)}
onSaved={() => void refresh(dir)}
language={language}
/>
<Modal
title={t('ssh.file.newFolder')}
title={tFor(language, 'ssh.file.newFolder')}
open={mkdirOpen}
onOk={doMkdir}
onCancel={() => setMkdirOpen(false)}
okText={t('ssh.file.create')}
cancelText={t('common.cancel')}
okText={tFor(language, 'ssh.file.create')}
cancelText={tFor(language, 'common.cancel')}
width={360}
>
<Input
autoFocus
placeholder={t('ssh.file.folderName')}
placeholder={tFor(language, 'ssh.file.folderName')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={doMkdir}
/>
</Modal>
<Modal
title={t('ssh.file.renameTitle', { name: selected?.name ?? '' })}
title={tFor(language, 'ssh.file.renameTitle', { name: selected?.name ?? '' })}
open={renameOpen}
onOk={doRename}
onCancel={() => setRenameOpen(false)}
okText={t('common.ok')}
cancelText={t('common.cancel')}
okText={tFor(language, 'common.ok')}
cancelText={tFor(language, 'common.cancel')}
width={360}
>
<Input
+18 -9
View File
@@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons'
import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
/** One aggregated transfer row in the overlay (the per-event state string is
* the shared `TransferState`). */
@@ -24,8 +25,16 @@ interface TransferRowProps {
* Memoized on the entry object: the progress channel replaces only the entry a
* transfer owns, so a busy multi-transfer overlay re-renders the advancing row
* and leaves the others alone.
*
* That memo is also why the row subscribes to the language itself: a language
* switch changes no prop here, so nothing else would ever re-render it, and
* `t()` would keep serving whatever the module-level language was. A row count
* is bounded by the concurrent transfers, so one subscription per row is
* cheaper than threading the language down as a prop (which would defeat the
* memo and re-render every row on each progress tick).
*/
const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: TransferRowProps): React.JSX.Element {
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const pct =
entry.totalBytes > 0
? Math.min(100, Math.round((entry.bytes / entry.totalBytes) * 100))
@@ -37,18 +46,18 @@ const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: Transfer
<div className="sftp-transfer-head">
<span className="sftp-transfer-title">
{entry.state === 'error'
? t('panels.transfer.failed')
? tFor(language, 'panels.transfer.failed')
: entry.state === 'cancelled'
? t('panels.transfer.cancelled')
? tFor(language, 'panels.transfer.cancelled')
: entry.state === 'done'
? t('panels.transfer.done')
? tFor(language, 'panels.transfer.done')
: entry.kind === 'upload'
? t('panels.transfer.uploading')
? tFor(language, 'panels.transfer.uploading')
: entry.kind === 'download'
? t('panels.transfer.downloading')
? tFor(language, 'panels.transfer.downloading')
: entry.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')}
? tFor(language, 'panels.transfer.zmodemUploading')
: tFor(language, 'panels.transfer.zmodemDownloading')}
</span>
{/* Only the SFTP engine's own transfers can be cancelled; zmodem
runs in a separate engine that ignores cancelTransfer. */}
@@ -58,7 +67,7 @@ const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: Transfer
className="sftp-transfer-cancel"
onClick={() => window.api.cancelTransfer(id)}
>
{t('common.cancel')}
{tFor(language, 'common.cancel')}
</button>
)}
{/* Cancelled rows dismiss themselves after 3s, but one that also
+66 -34
View File
@@ -7,7 +7,7 @@ import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
@@ -113,7 +113,25 @@ interface SearchBarProps {
onNext: () => void
onClose: () => void
enabled: boolean
result: string
result: SearchResult
/** Subscribed in TerminalView; passed down so the bar's own labels follow a
* language switch (a module-level `t()` would be read at the wrong moment). */
language: Language
}
/**
* Result slot of the search bar: the addon's match counter, or a manual find's
* verdict. Kept as a tag rather than as a rendered string because both
* producers run outside the render (a click handler and an addon event), so a
* stored string would keep the language it was built in for as long as it is
* on screen.
*/
type SearchResult = { index: number; count: number } | 'found' | 'notFound' | null
function searchResultText(result: SearchResult, language: Language): string {
if (result === null) return ''
if (typeof result === 'object') return `${result.index}/${result.count}`
return tFor(language, result === 'found' ? 'terminal.search.found' : 'terminal.search.notFound')
}
/** M5: cap for the floating completion popup height in px. */
@@ -125,14 +143,14 @@ function estimatePopupHeight(itemCount: number): number {
return Math.min(SUGGEST_POPUP_H, Math.max(70, itemCount * 22 + 28))
}
function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element {
function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result, language }: SearchBarProps): React.JSX.Element {
return (
<div className="term-searchbar">
<input
className="term-searchbar-input"
type="text"
value={value}
placeholder={t('terminal.search.placeholder')}
placeholder={tFor(language, 'terminal.search.placeholder')}
spellCheck={false}
autoFocus
onChange={(e) => onChange(e.target.value)}
@@ -142,10 +160,10 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
else if (e.key === 'Escape') onClose()
}}
/>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>{t('terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{t('terminal.search.next')}</button>
<span className="term-searchbar-count">{result}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{t('common.close')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>{tFor(language, 'terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{tFor(language, 'terminal.search.next')}</button>
<span className="term-searchbar-count">{searchResultText(result, language)}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{tFor(language, 'common.close')}</button>
</div>
)
}
@@ -299,6 +317,12 @@ export function TerminalView({
}))
)
const rendererMode = useSettingsStore((s) => s.settings.terminal.rendererMode)
// dockview owns this component's render slot, so an App→Workspace re-render
// never reaches it: the language must be subscribed here and passed to
// `tFor` explicitly, or every label would keep the language it was first
// rendered with. One single-field selector — kept out of the five shallow
// groups below so a language write cannot re-run their work.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const highlightOpts = useSettingsStore(
useShallow((s) => ({
mode: highlightModeOf(s.settings.terminal.highlightMode),
@@ -441,7 +465,7 @@ export function TerminalView({
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
const [searchResult, setSearchResult] = useState<SearchResult>(null)
// Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
@@ -573,7 +597,7 @@ export function TerminalView({
searchOpenRef.current = false
searchRef.current?.clearDecorations()
setSearchOpen(false)
setResultInfo('')
setSearchResult(null)
termRef.current?.focus()
}, [])
@@ -585,7 +609,9 @@ export function TerminalView({
dir === 1
? addon.findNext(q, { decorations: SEARCH_DECORATIONS })
: addon.findPrevious(q, { decorations: SEARCH_DECORATIONS })
setResultInfo(found ? t('terminal.search.found') : t('terminal.search.notFound'))
// Store the verdict, not the string: this runs from a click handler, and a
// stored translation would stay in the language it was produced in.
setSearchResult(found ? 'found' : 'notFound')
}, [])
/** Record a submitted command line; cd-style lines also update cwd memory. */
@@ -837,7 +863,12 @@ export function TerminalView({
const searchAddon = new SearchAddon()
searchAddon.onDidChangeResults((ev) => {
if (searchOpenRef.current && searchTermRef.current.trim()) {
setResultInfo(`${Math.max(ev.resultIndex + 1, 0)}/${ev.resultCount}`)
// Keep the identity of an unchanged counter: this fires on every search
// step in a busy pane, and React bails out only on an unchanged value.
const next = { index: Math.max(ev.resultIndex + 1, 0), count: ev.resultCount }
setSearchResult((prev) =>
typeof prev === 'object' && prev?.index === next.index && prev.count === next.count ? prev : next
)
}
})
term.loadAddon(searchAddon)
@@ -1300,7 +1331,7 @@ export function TerminalView({
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">{t('common.copy')}</span>
<span className="term-menu-label">{tFor(language, 'common.copy')}</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
@@ -1310,23 +1341,23 @@ export function TerminalView({
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">{t('common.paste')}</span>
<span className="term-menu-label">{tFor(language, 'common.paste')}</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">{t('terminal.menu.find')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.find')}</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">{t('terminal.menu.selectAll')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.selectAll')}</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">{t('terminal.menu.clear')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.clear')}</span>
<kbd />
</button>
</div>
@@ -1340,7 +1371,8 @@ export function TerminalView({
onNext={() => search(1)}
onClose={closeSearch}
enabled={searchTerm.trim().length > 0}
result={resultInfo}
result={searchResult}
language={language}
/>
)}
<div className="term-recbar">
@@ -1348,8 +1380,8 @@ export function TerminalView({
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? t('terminal.rec.stopLog') : t('terminal.rec.startLog')}
aria-label={recording ? t('terminal.rec.stop') : t('terminal.rec.start')}
title={recording ? tFor(language, 'terminal.rec.stopLog') : tFor(language, 'terminal.rec.startLog')}
aria-label={recording ? tFor(language, 'terminal.rec.stop') : tFor(language, 'terminal.rec.start')}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
@@ -1362,8 +1394,8 @@ export function TerminalView({
<button
type="button"
className="term-rec-btn"
title={t('terminal.rec.openLogs')}
aria-label={t('terminal.rec.openLogs')}
title={tFor(language, 'terminal.rec.openLogs')}
aria-label={tFor(language, 'terminal.rec.openLogs')}
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
@@ -1373,8 +1405,8 @@ export function TerminalView({
<button
type="button"
className="term-rec-btn"
title={t('terminal.rec.openCwd')}
aria-label={t('terminal.rec.openCwd')}
title={tFor(language, 'terminal.rec.openCwd')}
aria-label={tFor(language, 'terminal.rec.openCwd')}
onClick={() => {
const dir = getSessionCwd(sessionId)
if (dir) void window.api.openDirectory(dir)
@@ -1386,29 +1418,29 @@ export function TerminalView({
</div>
<Modal
open={paste !== null}
title={t('terminal.paste.title')}
okText={t('common.paste')}
cancelText={t('common.cancel')}
title={tFor(language, 'terminal.paste.title')}
okText={tFor(language, 'common.paste')}
cancelText={tFor(language, 'common.cancel')}
width={420}
maskClosable={false}
onOk={acceptPaste}
onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>{t('terminal.paste.message')}</p>
<p>{tFor(language, 'terminal.paste.message')}</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
{t('terminal.paste.noPrompt')}
{tFor(language, 'terminal.paste.noPrompt')}
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
{t('terminal.paste.disableDetection')}
{tFor(language, 'terminal.paste.disableDetection')}
</Checkbox>
<p className="term-paste-confirm-hint">{t('terminal.paste.hint')}</p>
<p className="term-paste-confirm-hint">{tFor(language, 'terminal.paste.hint')}</p>
</div>
</Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion
@@ -1436,7 +1468,7 @@ export function TerminalView({
</button>
))}
</div>
<div className="term-suggest-hint">{t('terminal.suggest.hint')}</div>
<div className="term-suggest-hint">{tFor(language, 'terminal.suggest.hint')}</div>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
@@ -1459,8 +1491,8 @@ export function TerminalView({
)}
{dead && (
<div className="term-dead-mask">
<div>{t('terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{t('common.close')}</button>
<div>{tFor(language, 'terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{tFor(language, 'common.close')}</button>
</div>
)}
</div>
@@ -1,18 +1,21 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { t } from '@shared/i18n'
import { tFor, type Language } from '@shared/i18n'
import { FilePanel } from '../sftp/FilePanel'
export interface SshBottomPanelProps {
sessionId: string
/** The terminal surface rendered in the flexible top region. */
terminal: ReactNode
/** From TerminalPanel, the dockview-rendered parent that owns the language
* subscription — this panel only re-renders along with it. */
language: Language
}
/** File browser height in px — clamped to [120, 60% of container height]. */
const MIN_HEIGHT = 120
export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
export function SshBottomPanel({ sessionId, terminal, language }: SshBottomPanelProps): React.JSX.Element {
/** Current file-browser height in px. */
const [filesHeight, setFilesHeight] = useState<number>(220)
/** Divider drag in flight — drives the accent highlight. */
@@ -82,7 +85,7 @@ export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): Re
className={'ssh-bottom-divider' + (resizing ? ' is-resizing' : '')}
role="separator"
aria-orientation="horizontal"
aria-label={t('ssh.bottomPanel.resize')}
aria-label={tFor(language, 'ssh.bottomPanel.resize')}
onPointerDown={onDividerPointerDown}
onPointerMove={onDividerPointerMove}
onPointerUp={endDrag}
+8 -3
View File
@@ -1,17 +1,22 @@
import { t } from '@shared/i18n'
import { tFor, type Language } from '@shared/i18n'
/**
* Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`)
* on an SSH terminal tab. Host labels are often long, so the display string is
* truncated with an ellipsis rather than wrapped; the full label is always
* available via the native `title` tooltip and the tab-level tooltip.
*
* `language` comes from the tab component instead of a subscription of its own:
* the badge lives in dockview's tab render slot, so it follows TerminalTab's
* re-renders — but `t()` would then read the module-level language, which is
* only as fresh as the last App render.
*/
export function SshHostBadge({ label }: { label: string }): React.JSX.Element {
export function SshHostBadge({ label, language }: { label: string; language: Language }): React.JSX.Element {
return (
<span
className="ssh-host-badge"
title={label}
aria-label={t('ssh.hostBadge.aria', { label })}
aria-label={tFor(language, 'ssh.hostBadge.aria', { label })}
>
{label}
</span>
+12 -5
View File
@@ -3,7 +3,8 @@ import type { IDockviewPanelProps } from 'dockview-react'
import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons'
import { t } from '@shared/i18n'
import { tFor, DEFAULT_LANGUAGE } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { PanelErrorBoundary } from '../ErrorBoundary'
import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
@@ -42,6 +43,12 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
/** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */
const [sideOpen, setSideOpen] = useState(true)
// dockview renders panel content from its own portal slot, so the panel never
// re-renders as part of the App→Workspace tree; the language is subscribed
// here and handed to `tFor` explicitly rather than read from the module-level
// language that `t()` uses.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
// M6 broadcast: keep the session in the broadcast registry while mounted so
// it can be picked as a target / receive fan-out writes. Registrations are
// keyed by panel id — an SSH split shows one session in two panes, and
@@ -84,20 +91,20 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
// is remounted on retry; the shell (tab, broadcast registration) survives.
return (
<div className="workspace-terminal-panel">
<PanelErrorBoundary label={t('workspace.error.panelTitle')}>
<PanelErrorBoundary label={tFor(language, 'workspace.error.panelTitle')}>
{isSsh && (
<Button
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
icon={<AreaChartOutlined />}
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
aria-label={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
title={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal}
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} language={language} /> : terminal}
{isSsh && sideOpen && (
<div className="workspace-terminal-side">
<MonitorPanel sessionId={sessionId} />
+17 -10
View File
@@ -6,7 +6,8 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge'
import { t } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { DEFAULT_LANGUAGE, t, tFor } from '@shared/i18n'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -30,13 +31,19 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const [title, setTitle] = useState<string | undefined>(api.title)
const selfIndex = panel.panels.findIndex((p) => p.api === api)
// dockview keeps the tab component in its own render slot — a re-render of
// App/Workspace does not reach it — so the language has to be subscribed here
// and passed to `tFor` explicitly; `t()` would read the module-level language
// of whatever render last touched it.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const isSsh = params?.sessionKind === 'ssh'
const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined
const tooltip = isSsh
? hostLabel && hostLabel !== title
? `${title} — ${hostLabel}`
: (hostLabel || title)
: t('workspace.tab.localTerminal')
: tFor(language, 'workspace.tab.localTerminal')
// M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
@@ -55,9 +62,9 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const menu: MenuProps = {
items: [
{ key: 'close', label: t('common.close') },
{ key: 'closeOthers', label: t('workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: t('workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
{ key: 'close', label: tFor(language, 'common.close') },
{ key: 'closeOthers', label: tFor(language, 'workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
],
onClick: ({ key }) => {
if (key === 'close') {
@@ -85,20 +92,20 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} />}
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={t('workspace.tab.broadcastTarget')}
aria-label={t('workspace.tab.broadcastTarget')}
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{t('workspace.tab.broadcastBadge')}
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={t('common.close')}
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
+26 -18
View File
@@ -27,7 +27,7 @@ import type {
import 'dockview-react/dist/styles/dockview.css'
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, t, tFor, type Language } from '@shared/i18n'
import { nextTerminalTitle, retitleAutoTitles } from '@shared/terminalTitle'
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
@@ -248,12 +248,16 @@ function apiOfMode(mode: WorkspaceMode, terminalApi: DockviewApi | undefined, ss
* terminal dockview and hints from the server list in the ssh dockview). */
function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element {
function TabActions({ containerApi, group }: IDockviewHeaderActionsProps): React.JSX.Element {
// dockview renders the header actions from its own slot, outside the
// App→Workspace re-render path, so the language is subscribed here (and
// passed down to the buttons) and every string goes through `tFor`.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
// The per-group "+" creates a local terminal — SSH sessions can only be
// opened from the server list, so in ssh mode the button just hints.
const { message } = App.useApp()
const handleAdd = (): void => {
if (mode === 'ssh') {
message.info(t('workspace.hint.sshFromList'))
message.info(tFor(language, 'workspace.hint.sshFromList'))
return
}
void (async (): Promise<void> => {
@@ -272,8 +276,8 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
return (
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<AddTerminalGroupButton language={language} onClick={handleAdd} />
<BroadcastToggleButton language={language} />
</div>
)
}
@@ -1368,13 +1372,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}
/** "+" renders the new-terminal button (per-group tab addition). */
function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element {
function AddTerminalGroupButton({ onClick, language }: { onClick: () => void; language: Language }): React.JSX.Element {
return (
<button
type="button"
className="workspace-tabbar-add"
title={t('workspace.tab.addInSplit')}
aria-label={t('workspace.tab.addInSplit')}
title={tFor(language, 'workspace.tab.addInSplit')}
aria-label={tFor(language, 'workspace.tab.addInSplit')}
onClick={onClick}
>
<PlusOutlined />
@@ -1383,7 +1387,7 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX
}
/** 广播 target picker: toggle + Popover with per-session checkboxes. */
function BroadcastToggleButton(): React.JSX.Element {
function BroadcastToggleButton({ language }: { language: Language }): React.JSX.Element {
const enabled = useBroadcastStore((s) => s.enabled)
const targets = useBroadcastStore((s) => s.targets)
const sessions = useBroadcastStore((s) => s.sessions)
@@ -1409,14 +1413,15 @@ function BroadcastToggleButton(): React.JSX.Element {
sessions={sessions}
onToggleTarget={toggleTarget}
onSetEnabled={setEnabled}
language={language}
/>
}
>
<button
type="button"
className={cls}
title={enabled ? t('workspace.broadcast.activeTitle', { count: targets.size }) : t('workspace.broadcast.toggle')}
aria-label={t('workspace.broadcast.toggle')}
title={enabled ? tFor(language, 'workspace.broadcast.activeTitle', { count: targets.size }) : tFor(language, 'workspace.broadcast.toggle')}
aria-label={tFor(language, 'workspace.broadcast.toggle')}
aria-pressed={enabled}
>
<ShareAltOutlined />
@@ -1435,7 +1440,8 @@ function WorkspaceBroadcastPopover({
targets,
sessions,
onToggleTarget,
onSetEnabled
onSetEnabled,
language
}: {
mode: WorkspaceMode
enabled: boolean
@@ -1443,6 +1449,8 @@ function WorkspaceBroadcastPopover({
sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void
/** from the dockview header-actions component — see TabActions */
language: Language
}): React.JSX.Element {
const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh
const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s)))
@@ -1451,26 +1459,26 @@ function WorkspaceBroadcastPopover({
return (
<div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode">
{mode === 'ssh' ? t('workspace.broadcast.popoverSsh') : t('workspace.broadcast.popoverTerminal')}
{mode === 'ssh' ? tFor(language, 'workspace.broadcast.popoverSsh') : tFor(language, 'workspace.broadcast.popoverTerminal')}
</div>
<div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">{t('workspace.broadcast.switch')}</span>
<span className="workspace-bcast-pop-label">{tFor(language, 'workspace.broadcast.switch')}</span>
<Checkbox
checked={enabled}
disabled={targets.size < 2}
onChange={(e) => onSetEnabled(e.target.checked)}
>
{enabled
? t('workspace.broadcast.enabled', { count: targets.size })
? tFor(language, 'workspace.broadcast.enabled', { count: targets.size })
: targets.size < 2
? t('workspace.broadcast.needTwo')
: t('workspace.broadcast.disabled')}
? tFor(language, 'workspace.broadcast.needTwo')
: tFor(language, 'workspace.broadcast.disabled')}
</Checkbox>
</div>
<div className="workspace-bcast-pop-list">
{groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">
{mode === 'ssh' ? t('workspace.broadcast.emptySsh') : t('workspace.broadcast.emptyTerminal')}
{mode === 'ssh' ? tFor(language, 'workspace.broadcast.emptySsh') : tFor(language, 'workspace.broadcast.emptyTerminal')}
</div>
) : (
groupSessions.map((s) => (
@@ -1482,7 +1490,7 @@ function WorkspaceBroadcastPopover({
))
)}
</div>
<div className="workspace-bcast-pop-hint">{t('workspace.broadcast.hint')}</div>
<div className="workspace-bcast-pop-hint">{tFor(language, 'workspace.broadcast.hint')}</div>
</div>
)
}
+10 -5
View File
@@ -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<Map<string, ZmodemOfferEvent>>(new Map())
const busyRef = useRef<Set<string>>(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={
<Space>
<Button
type="primary"
onClick={() => (isReceive ? void handlePickDir(first.id) : void handlePickFiles(first.id))}
>
{isReceive ? t('ssh.zmodem.pickDir') : t('ssh.zmodem.pickFiles')}
{isReceive ? tFor(language, 'ssh.zmodem.pickDir') : tFor(language, 'ssh.zmodem.pickFiles')}
</Button>
<Button onClick={() => handleCancel(first.id)}>{t('common.cancel')}</Button>
<Button onClick={() => handleCancel(first.id)}>{tFor(language, 'common.cancel')}</Button>
</Space>
}
>
<Typography.Text>
{isReceive ? t('ssh.zmodem.receiveDesc') : t('ssh.zmodem.sendDesc')}
{isReceive ? tFor(language, 'ssh.zmodem.receiveDesc') : tFor(language, 'ssh.zmodem.sendDesc')}
</Typography.Text>
</Modal>
)
+4
View File
@@ -17,6 +17,10 @@ const main: Record<string, string> = {
'main.updater.feedFailed': 'Gitea feed: {gitea}; GitHub: {github}',
'main.updater.checkTimeout': 'Update check timed out',
'main.updater.notifyAvailableTitle': 'Update available',
'main.updater.notifyAvailableContent': 'OpenTerminal v{version} is available',
'main.updater.notifyDownloadedTitle': 'Update downloaded',
'main.updater.notifyDownloadedContent': 'v{version} downloaded — it installs when you quit the app',
'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})',
'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}',
+11
View File
@@ -94,6 +94,16 @@ const settings: Record<string, string> = {
'settings.system.preventSleep': 'Prevent system sleep',
'settings.system.preventSleepDesc':
'While on, OpenTerminal keeps the system from sleeping automatically; when off, the system follows its own power settings',
'settings.system.updateCheckInterval': 'Periodic update check',
'settings.system.updateCheckIntervalDesc':
'Check for new versions periodically in the background (startup and manual checks always work)',
'settings.system.updateCheckNever': 'Never',
'settings.system.updateCheck4h': 'Every 4 hours',
'settings.system.updateCheck12h': 'Every 12 hours',
'settings.system.updateCheck24h': 'Every 24 hours',
'settings.system.autoDownload': 'Download updates automatically',
'settings.system.autoDownloadDesc':
'Silently download new versions in the background; installing stays your call (it installs when you quit the app)',
'settings.system.restoreSession': 'Restore last session on startup',
'settings.system.restoreSessionDesc':
'Restore the split layout on the next launch and return each terminal to its last directory; when off, OpenTerminal always starts with a single terminal',
@@ -308,6 +318,7 @@ const settings: Record<string, string> = {
'settings.about.statusLatest': 'Already up to date',
'settings.about.statusDownloading': 'Downloading…',
'settings.about.statusDownloaded': 'Download complete — restart to install',
'settings.about.statusDownloadedAuto': 'Downloaded in the background — installs when you quit',
'settings.about.statusError': 'Check failed',
'settings.about.statusDev': 'Update checks are unavailable in development mode',
'settings.about.autoCheck': 'Check automatically at startup',
+4
View File
@@ -17,6 +17,10 @@ const main: Record<string, string> = {
'main.updater.feedFailed': 'Gitea フィード: {gitea};GitHub: {github}',
'main.updater.checkTimeout': '更新の確認がタイムアウトしました',
'main.updater.notifyAvailableTitle': '新しいバージョンがあります',
'main.updater.notifyAvailableContent': 'OpenTerminal v{version} が利用可能です',
'main.updater.notifyDownloadedTitle': 'アップデートをダウンロードしました',
'main.updater.notifyDownloadedContent': 'v{version} をダウンロードしました — 終了時にインストールされます',
'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})',
'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}',
+11
View File
@@ -91,6 +91,16 @@ const settings: Record<string, string> = {
'settings.system.preventSleep': 'スリープを防止',
'settings.system.preventSleepDesc':
'オンにすると OpenTerminal がシステムの自動スリープを防ぎます。オフにすると OS の設定どおりにスリープします',
'settings.system.updateCheckInterval': '定期アップデート確認',
'settings.system.updateCheckIntervalDesc':
'バックグラウンドで定期的に新バージョンを確認します(起動時と手動確認は常に有効)',
'settings.system.updateCheckNever': 'なし',
'settings.system.updateCheck4h': '4 時間ごと',
'settings.system.updateCheck12h': '12 時間ごと',
'settings.system.updateCheck24h': '24 時間ごと',
'settings.system.autoDownload': 'アップデートを自動ダウンロード',
'settings.system.autoDownloadDesc':
'新バージョンをバックグラウンドで自動ダウンロードします(インストールは終了時に実行されます)',
'settings.system.restoreSession': '起動時に前回のセッションを復元',
'settings.system.restoreSessionDesc':
'次回の起動で分割レイアウトを復元し、各ターミナルを前回のディレクトリに戻します。オフにすると毎回 1 つのターミナルで起動します',
@@ -303,6 +313,7 @@ const settings: Record<string, string> = {
'settings.about.statusLatest': '最新バージョンです',
'settings.about.statusDownloading': 'ダウンロードしています…',
'settings.about.statusDownloaded': 'ダウンロード完了。再起動してインストールできます',
'settings.about.statusDownloadedAuto': 'バックグラウンドでダウンロード完了 — 終了時にインストールされます',
'settings.about.statusError': '確認に失敗しました',
'settings.about.statusDev': '開発モードでは更新を確認できません',
'settings.about.autoCheck': '起動時に自動で確認',
+2 -2
View File
@@ -20,8 +20,8 @@ const workspace: Record<string, string> = {
'workspace.rail.sshWorkspace': 'SSH ワークスペース',
'workspace.panel.hideSidebar': 'サイドバーを折りたたむ',
'workspace.panel.showSidebar': 'サイドバーを展開',
'workspace.layout.splitRight': '水平分割',
'workspace.layout.splitBelow': '垂直分割',
'workspace.layout.splitRight': '左右に分割',
'workspace.layout.splitBelow': '上下に分割',
'workspace.layout.saveTemplate': 'テンプレートとして保存',
'workspace.layout.applyTemplate': 'テンプレートを適用',
'workspace.template.saveTitle': 'レイアウトテンプレートを保存',
+4
View File
@@ -17,6 +17,10 @@ const main: Record<string, string> = {
'main.updater.feedFailed': '国内源: {gitea};GitHub: {github}',
'main.updater.checkTimeout': '检查更新超时',
'main.updater.notifyAvailableTitle': '发现新版本',
'main.updater.notifyAvailableContent': 'OpenTerminal v{version} 已可用',
'main.updater.notifyDownloadedTitle': '更新已下载',
'main.updater.notifyDownloadedContent': 'v{version} 已下载完成,退出应用时将自动安装',
'main.ssh.connectTimeout': '连接超时 ({host}:{port})',
'main.ssh.saveFingerprintFailed': '保存主机指纹失败: {detail}',
+11
View File
@@ -89,6 +89,16 @@ const settings: Record<string, string> = {
'settings.system.preventSleep': '阻止系统休眠',
'settings.system.preventSleepDesc':
'开启后 OpenTerminal 将阻止系统自动休眠;关闭则允许系统按系统设置休眠',
'settings.system.updateCheckInterval': '定时检查更新',
'settings.system.updateCheckIntervalDesc':
'后台按周期自动检查新版本(启动时与手动检查始终可用)',
'settings.system.updateCheckNever': '从不',
'settings.system.updateCheck4h': '每 4 小时',
'settings.system.updateCheck12h': '每 12 小时',
'settings.system.updateCheck24h': '每 24 小时',
'settings.system.autoDownload': '自动下载更新',
'settings.system.autoDownloadDesc':
'发现新版本时在后台静默下载;安装时机仍由你决定(退出应用时会自动安装)',
'settings.system.restoreSession': '启动时恢复上次会话',
'settings.system.restoreSessionDesc':
'下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动',
@@ -292,6 +302,7 @@ const settings: Record<string, string> = {
'settings.about.statusLatest': '已经是最新版本',
'settings.about.statusDownloading': '正在下载…',
'settings.about.statusDownloaded': '下载完成,可重启安装',
'settings.about.statusDownloadedAuto': '已在后台下载完成,退出时自动安装',
'settings.about.statusError': '检查失败',
'settings.about.statusDev': '开发模式不支持更新检查',
'settings.about.autoCheck': '启动时自动检查',
+4
View File
@@ -17,6 +17,10 @@ const main: Record<string, string> = {
'main.updater.feedFailed': '國內來源: {gitea};GitHub: {github}',
'main.updater.checkTimeout': '檢查更新逾時',
'main.updater.notifyAvailableTitle': '發現新版本',
'main.updater.notifyAvailableContent': 'OpenTerminal v{version} 已可用',
'main.updater.notifyDownloadedTitle': '更新已下載',
'main.updater.notifyDownloadedContent': 'v{version} 已下載完成,結束應用程式時將自動安裝',
'main.ssh.connectTimeout': '連線逾時 ({host}:{port})',
'main.ssh.saveFingerprintFailed': '儲存主機指紋失敗: {detail}',
+11
View File
@@ -89,6 +89,16 @@ const settings: Record<string, string> = {
'settings.system.preventSleep': '防止系統休眠',
'settings.system.preventSleepDesc':
'開啟後 OpenTerminal 會防止系統自動休眠;關閉則允許系統依本身的設定休眠',
'settings.system.updateCheckInterval': '定時檢查更新',
'settings.system.updateCheckIntervalDesc':
'背景依週期自動檢查新版本(啟動時與手動檢查始終可用)',
'settings.system.updateCheckNever': '永不',
'settings.system.updateCheck4h': '每 4 小時',
'settings.system.updateCheck12h': '每 12 小時',
'settings.system.updateCheck24h': '每 24 小時',
'settings.system.autoDownload': '自動下載更新',
'settings.system.autoDownloadDesc':
'發現新版本時在背景靜默下載;安裝時機仍由你決定(結束應用程式時會自動安裝)',
'settings.system.restoreSession': '啟動時還原上次工作階段',
'settings.system.restoreSessionDesc':
'下次啟動時還原分割版面,並讓每個終端回到上次所在的目錄;關閉則每次都以單一終端啟動',
@@ -292,6 +302,7 @@ const settings: Record<string, string> = {
'settings.about.statusLatest': '目前已是最新版本',
'settings.about.statusDownloading': '正在下載…',
'settings.about.statusDownloaded': '下載完成,可重新啟動安裝',
'settings.about.statusDownloadedAuto': '已在背景下載完成,結束時自動安裝',
'settings.about.statusError': '檢查失敗',
'settings.about.statusDev': '開發模式不支援檢查更新',
'settings.about.autoCheck': '啟動時自動檢查',
+4
View File
@@ -262,6 +262,10 @@ export interface UpdateState {
error?: string
/** which feed served the last check: domestic Gitea first, GitHub fallback */
feed?: 'gitea' | 'github'
/** the check that produced this state was started by the periodic timer */
scheduled?: boolean
/** the in-flight/completed download was started by the auto-download setting */
auto?: boolean
}
export interface ReleaseNote {
+36 -1
View File
@@ -161,6 +161,24 @@ export interface LockSettings {
lockAtStartup: boolean
}
/**
* The intervals offered for the periodic update check, in hours. A closed set
* rather than a free number, for the same reason as LOCK_AUTO_DELAYS: `0` means
* "never" — the startup and the manual check still work — and the updater's
* timer, the sanitizer and the settings UI all read this one list, so they cannot
* disagree. Without the whitelist a hand-edited settings.json could put the app
* on an arbitrary schedule (a check every minute, or a fractional interval the
* timer would round unpredictably).
*/
export type UpdateCheckInterval = 0 | 4 | 12 | 24
export const UPDATE_CHECK_INTERVALS: UpdateCheckInterval[] = [0, 4, 12, 24]
/** Only a whitelisted interval survives a stored value; callers fall back themselves. */
export function isUpdateCheckInterval(value: unknown): value is UpdateCheckInterval {
return UPDATE_CHECK_INTERVALS.includes(value as UpdateCheckInterval)
}
export interface SystemSettings {
/** register the app to launch at OS login */
launchAtLogin: boolean
@@ -172,6 +190,10 @@ export interface SystemSettings {
closeAction?: 'ask' | 'tray' | 'exit'
/** check for updates shortly after startup (manual check always available) */
autoCheckUpdate?: boolean
/** periodic background update check; 0 = never (startup + manual always work) */
updateCheckIntervalHours?: UpdateCheckInterval
/** silently download a found update in the background (installing stays manual) */
autoDownloadUpdate?: boolean
/**
* Restore the previous layout and each pane's working directory on launch
* (see the session snapshot). On by default — that is the point of it.
@@ -467,7 +489,20 @@ export const DEFAULT_SETTINGS: AppSettings = {
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
highlightProfiles: [],
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' },
// Every 4 hours by default: often enough for a terminal app that runs all day,
// and the download stays manual (autoDownloadUpdate) until the user opts in.
system: {
launchAtLogin: false,
preventSleep: false,
globalShowHide: '',
closeAction: 'tray',
autoCheckUpdate: true,
updateCheckIntervalHours: 4,
autoDownloadUpdate: false,
restoreSession: true,
shellIntegration: false,
language: 'zh-CN'
},
// Off until the user sets a password and turns it on: an app that locks
// itself out of the box would be a support ticket, not a feature.
lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false }
+18 -1
View File
@@ -46,7 +46,10 @@ const fakeSession = (name) => {
module.exports = {
BrowserWindow: {
getAllWindows: () => []
// Configurable so a test can drive the "is a window visible?" guards (the
// update balloons, the session snapshot). Like the rest of the stub state
// this lives on the shared global, and the default is "no window open".
getAllWindows: () => state.windows ?? []
},
app: {
getPath: (name) => {
@@ -110,6 +113,16 @@ module.exports = {
setContextMenu() {}
on() {}
destroy() {}
/**
* Balloons (`notifyUpdate`, the minimize hint) have no other observable
* effect, so they are recorded instead of dropped. The sink is a global for
* the same reason as the rest of the state: the bundle inlines this file, so
* a test's require of this path is a different module instance.
*/
displayBalloon(options) {
const sink = (globalThis.__otBalloons ??= [])
sink.push(options)
}
static getBounds() {
return { x: 0, y: 0, width: 0, height: 0 }
}
@@ -123,6 +136,10 @@ module.exports = {
__setPackaged: (v) => {
state.isPackaged = v
},
/** Fake window list `BrowserWindow.getAllWindows()` hands the bundled code. */
__setWindows: (wins) => {
state.windows = wins
},
/** channel -> listener (invoke), `on:<channel>` -> listener (send). */
get __handlers() {
return state.handlers
+524 -5
View File
@@ -26,11 +26,22 @@
* of the two-feed split)
* - dev builds never check (state 'dev'), and a packaged build's changelog
* fetch falls back through the three sources
* - the schedule: the periodic timer follows the configured interval, 0 means
* never, re-applying replaces the cadence, and the startup check is armed at
* most once per run (sections 10-13)
* - one check at a time (a second caller shares the in-flight promise), and the
* `scheduled` / `auto` marks belong to the caller that started it
* - one release is fetched and announced once: a repeating cycle does not
* restart the download, does not repeat the balloon, and does not downgrade a
* finished download back to 'available'; a different release is news again,
* and a manual check is never suppressed
* - balloons respect their guards (Windows only, tray present, no visible
* window)
*
* Build: node tests/build-bundles.cjs
* Run: node tests/updater-fallback.mjs (must exit 0)
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
@@ -61,9 +72,47 @@ globalThis.__otFetch = (url, init) => {
const stub = require('./electron-stub.cjs')
stub.__setPackaged(false)
const updater = require('./.updater.cjs')
/**
* Load the bundle with its inlined tray slot exposed, and hand the test's own
* `Tray` stub to it.
*
* Why this is needed: `src/main/tray.ts` keeps the tray in a module-level slot
* that only `initTray()` fills, and `initTray` is not something updater.ts
* imports — esbuild drops it, so the bundle has no way to create a tray at all
* and `notifyUpdate` (the sole balloon producer, and what the balloon assertions
* are about) always returns at its `!tray` guard. The bundle text is therefore
* re-emitted with ONE appended line that reads/writes that slot, and that copy —
* byte-identical otherwise — is the module under test. Nothing under src/ is
* touched, and `require('./.updater.cjs')` is still what is exercised, just via a
* copy built from the freshly compiled file.
*
* `slot` stays undefined if a future build renames or drops the slot; the
* balloon checks then skip (with a printed reason) rather than fail.
*/
let bundleTmpDir
const requireUpdater = () => {
const source = readFileSync(join(__dirname, '.updater.cjs'), 'utf8')
if (!/^var tray = null;$/m.test(source)) return { module: require('./.updater.cjs'), slot: undefined }
const dir = mkdtempSync(join(tmpdir(), 'ot-updater-bundle-'))
bundleTmpDir = dir
const patched = join(dir, 'updater.cjs')
writeFileSync(
patched,
`${source}
// harness: expose the inlined tray slot (see tests/updater-fallback.mjs)
module.exports.__traySlot = { get: () => tray, set: (value) => { tray = value } }
`,
'utf8'
)
return { module: require(patched), slot: require(patched).__traySlot }
}
const { module: updater, slot: traySlot } = requireUpdater()
const { Ipc } = require('./.ipc-channels.cjs')
/** Stand a tray up exactly as `initTray` would, using the stub's own class. */
if (traySlot) traySlot.set(new stub.Tray({}))
let failed = 0
let passed = 0
const ok = (cond, msg) => {
@@ -378,9 +427,9 @@ console.log('updater events drive the state the renderer reads')
stub.__setUserData(userData)
stub.__setPackaged(true)
// configureAutoUpdater wires the event listeners and (unless the user turned
// startup checks off) schedules a check 5s after launch — unref'd, so it does
// not hold the test open.
// configureAutoUpdater wires the event listeners. It arms NO timer: the startup
// check and the periodic one belong to applyUpdateSchedule, which
// settingsStore's injected applier calls on startup and on every settings save.
updater.configureAutoUpdater()
// `on()` publishes the instance the bundle actually subscribed, which is NOT
@@ -527,6 +576,476 @@ console.log('install')
ok(ctl.quitAndInstallCalls.length === 0, 'a dev build does not call quitAndInstall')
}
// ---- 10. the schedule, auto-download, suppression, balloons ------------------
/**
* Everything below shares module-level state that no test can reset, so the
* scenarios are chosen around it:
* - the suppression record (src/main/updater.ts `ActedRelease`) holds ONE
* release at a time, is dropped by a manual check and is replaced wholesale
* when a different version shows up — so each scenario uses its own version
* number (7.x and 9.9.9 are spent by section 7), or starts from a manual check;
* - the "startup check armed" flag is per run too: the first `applyUpdateSchedule`
* that allows startup checks is the only one that can arm it, which is why
* sections 10-13 run in this order.
* `reset()` re-points the feeds and the check/download doubles, so those do not
* leak between sections.
*/
const scheduleDir = mkdtempSync(join(tmpdir(), 'ot-updater-schedule-'))
stub.__setUserData(scheduleDir)
/** The settings the update handlers read (autoDownloadUpdate lives here). */
const writeSystem = (system) => writeFileSync(join(scheduleDir, 'settings.json'), JSON.stringify({ system }))
/** Every check the harness observed, with the source mark published for it. */
const checkLog = []
const recordCheck = () => {
// Read synchronously: the handler returns the module state, and the mark is
// written before the check is handed to the updater.
checkLog.push(call(Ipc.UPDATE_STATE_GET).scheduled === true)
return Promise.resolve(null)
}
const startupChecks = () => checkLog.filter((scheduled) => scheduled === false).length
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** Let a listener the module started with `void` settle. */
const tick = () => sleep(5)
/**
* A window fake rich enough for the two things the bundled code does with
* `BrowserWindow.getAllWindows()`: `broadcast` sends the new state to each window,
* and `notifyUpdate` asks the first one whether it is visible.
*/
const fakeWindow = (visible) => ({
isVisible: () => visible,
isDestroyed: () => false,
webContents: { send: () => {} }
})
/** Windows-only side effect, and it needs a tray the bundle cannot create itself. */
const balloons = () => (globalThis.__otBalloons ??= [])
const balloonCount = () => balloons().length
const balloonChecksWork = process.platform === 'win32' && traySlot !== undefined
const whyNoBalloons =
traySlot === undefined ? 'the bundle exposes no tray slot' : `process.platform is ${process.platform}`
const okBalloon = (cond, msg) => {
if (!balloonChecksWork) {
console.log(` skip: ${msg} (${whyNoBalloons})`)
return
}
ok(cond, msg)
}
// The events were wired by section 7; the call is idempotent. `live` is the
// instance the bundle subscribed — emitting on any other one is a no-op.
updater.configureAutoUpdater()
const live = ctl.live
ok(live !== undefined, 'the updater instance under test is the one the bundle wired')
console.log('the periodic check follows the configured interval')
{
reset()
stub.__setPackaged(true)
updater.setUpdateTimeouts({ hour: 5, startupDelay: 20 }) // one "hour" = 5ms
// Startup checks off here on purpose: this section counts the periodic ticks,
// and the timer that arms the startup one is the subject of the next section.
writeSystem({ autoCheckUpdate: false, updateCheckIntervalHours: 4, autoDownloadUpdate: false })
ctl.checkForUpdates = recordCheck
fetchImpl = () => Promise.resolve(errResponse(503)) // probe fails -> one attempt per check
const from = checkLog.length
updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 4 })
await sleep(200)
const ticks = checkLog.slice(from)
ok(ticks.length >= 2, `a 4-hour interval ticks repeatedly (${ticks.length} checks in 200ms)`)
ok(
ticks.every((scheduled) => scheduled === true),
'every one of them is marked as a scheduled check'
)
ok(startupChecks() === 0, 'and none of them was mistaken for the startup check')
}
console.log('interval 0: the periodic check is off, the startup check still runs once')
{
reset()
stub.__setPackaged(true)
updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 })
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
ctl.checkForUpdates = recordCheck
fetchImpl = () => Promise.resolve(errResponse(503))
const from = checkLog.length
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
await sleep(150)
const ran = checkLog.slice(from)
ok(ran.length === 1, `exactly one check ran — the startup one (${ran.length})`)
ok(ran[0] === false, "'never' means the startup check is the only one, and it is not marked scheduled")
ok(startupChecks() === 1, 'the run has seen its startup check exactly once')
}
console.log('re-applying the schedule replaces the cadence (and 0 stops it)')
{
reset()
stub.__setPackaged(true)
updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 })
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 24, autoDownloadUpdate: false })
ctl.checkForUpdates = recordCheck
fetchImpl = () => Promise.resolve(errResponse(503))
const from = checkLog.length
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 24 }) // one hour = 5ms -> 120ms
await sleep(150)
const slow = checkLog.slice(from)
ok(slow.length <= 2, `a 24-hour interval is slow (${slow.length} checks in 150ms)`)
const fastFrom = checkLog.length
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 4 }) // -> 20ms
await sleep(150)
const fast = checkLog.slice(fastFrom)
ok(fast.length >= 3, `switching to 4 hours speeds the beat up (${fast.length} checks in 150ms)`)
ok(fast.length > slow.length, 'the new cadence replaced the old one')
// Back to the slow interval: if the 20ms timer had merely been left running, it
// would keep ticking ~7 times here instead of at most once.
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 24 })
await sleep(30)
const settled = checkLog.length
await sleep(150)
ok(
checkLog.length - settled <= 1,
`the 20ms timer is gone, only the slow beat is left (${checkLog.length - settled} checks)`
)
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
const stopped = checkLog.length
await sleep(150)
ok(checkLog.length === stopped, `switching to 0 stops the timer (${checkLog.length - stopped} checks after)`)
}
console.log('the startup check is armed at most once per run')
{
reset()
stub.__setPackaged(true)
updater.setUpdateTimeouts({ startupDelay: 15, hour: 5 })
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
ctl.checkForUpdates = recordCheck
fetchImpl = () => Promise.resolve(errResponse(503))
// A settings save re-runs this applier, so three more applications stand in for
// three saves two seconds into the run: none of them may add a startup check on
// top of the one the previous section saw.
const before = checkLog.length
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
await sleep(150)
ok(checkLog.length === before, `repeated applies scheduled no extra check (${checkLog.length - before})`)
ok(startupChecks() === 1, 'and the whole run still has exactly one startup check')
}
console.log('a check already running is shared, never started twice')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
fetchImpl = () => Promise.resolve(errResponse(503))
let calls = 0
let settle
ctl.checkForUpdates = () => {
calls++
return new Promise((resolve) => {
settle = resolve
})
}
const manual = updater.runCheck('manual')
const scheduled = updater.runCheck('scheduled')
ok(manual === scheduled, 'the second caller gets the very same promise')
// The updater is only called after the GitHub probe resolves, so give the
// started check a turn before counting.
await tick()
ok(calls === 1, `the updater was asked to check once (${calls})`)
ok(call(Ipc.UPDATE_STATE_GET).scheduled === false, 'the mark belongs to the check that actually started (manual)')
settle(null)
const [a, b] = await Promise.all([manual, scheduled])
ok(a === b, 'both callers resolve to the same state')
ok(calls === 1, `still one upstream check after it settled (${calls})`)
}
console.log('auto-download follows the setting')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: true })
let downloads = 0
ctl.downloadUpdate = () => {
downloads++
return Promise.resolve()
}
live.emit('update-available', { version: '8.8.8' })
await tick()
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(downloads === 1, `the transfer starts on its own (${downloads})`)
ok(s.status === 'downloading', `the state moves to 'downloading' (got '${s.status}')`)
ok(s.version === '8.8.8', 'the downloading state names the version')
ok(s.auto === true, 'and is marked automatic (the About tab keys its wording off this)')
}
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
live.emit('update-available', { version: '8.8.7' })
await tick()
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(downloads === 1, `with the setting off nothing is fetched (${downloads})`)
ok(s.status === 'available', `the state stays 'available' for the UI button (got '${s.status}')`)
}
}
console.log('the same release is fetched and announced once')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: true })
let downloads = 0
ctl.downloadUpdate = () => {
downloads++
return Promise.resolve()
}
const base = balloonCount()
/** One 4-hour cycle reporting the same release again. */
const cycle = (version) => {
live.emit('checking-for-update')
live.emit('update-available', { version })
}
cycle('7.7.7')
await tick()
live.emit('download-progress', { percent: 50 })
live.emit('update-downloaded', { version: '7.7.7' })
await tick()
ok(downloads === 1, `one fetch for one release (${downloads})`)
okBalloon(balloonCount() === base + 1, `the finished download is announced once (${balloonCount() - base})`)
const announced = balloonCount()
cycle('7.7.7')
await tick()
ok(downloads === 1, `the next cycle does not restart the transfer (${downloads})`)
okBalloon(balloonCount() === announced, `and does not repeat the balloon (${balloonCount() - announced} more)`)
// Auto-download off: now the availability balloon is the one that must not repeat.
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
const quiet = balloonCount()
cycle('7.7.6')
await tick()
const s = call(Ipc.UPDATE_STATE_GET)
ok(s.status === 'available' && s.version === '7.7.6', `the release is still reported to the UI (${s.status})`)
okBalloon(balloonCount() === quiet + 1, `a fresh release is announced once (${balloonCount() - quiet})`)
const told = balloonCount()
cycle('7.7.6')
await tick()
okBalloon(balloonCount() === told, `the same release is not announced again (${balloonCount() - told} more)`)
ok(downloads === 1, 'and nothing was fetched behind the user (the setting is off)')
}
console.log('a different release is news again')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: true })
let downloads = 0
ctl.downloadUpdate = () => {
downloads++
return Promise.resolve()
}
const base = balloonCount()
live.emit('update-available', { version: '6.6.6' })
await tick()
live.emit('update-downloaded', { version: '6.6.6' })
await tick()
ok(downloads === 1, `the first release is fetched (${downloads})`)
okBalloon(balloonCount() === base + 1, `and announced once (${balloonCount() - base})`)
live.emit('update-available', { version: '6.6.5' })
await tick()
ok(downloads === 2, `a new release is fetched as if the app had just started (${downloads})`)
live.emit('update-downloaded', { version: '6.6.5' })
await tick()
okBalloon(balloonCount() === base + 2, `and announced once of its own (${balloonCount() - base})`)
}
console.log('a manual check is never suppressed')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
fetchImpl = () => Promise.resolve(errResponse(503))
const base = balloonCount()
ctl.checkForUpdates = () => {
live.emit('update-available', { version: '5.5.5' })
return Promise.resolve(null)
}
// A background cycle announces the release the first time…
await updater.runCheck('scheduled')
ok(call(Ipc.UPDATE_STATE_GET).status === 'available', 'a background check reports the release')
const first = balloonCount()
okBalloon(first === base + 1, `and announces it (${first - base})`)
// …and stays quiet about it afterwards — that is the suppression.
await updater.runCheck('scheduled')
okBalloon(balloonCount() === first, 'a later background check for the same release stays quiet')
// A click on "check now" is an explicit act: the record is dropped, so the same
// release is reported and announced again.
const state = await call(Ipc.UPDATE_CHECK)
ok(state.status === 'available' && state.version === '5.5.5', `the manual check reports it again (${state.status})`)
okBalloon(balloonCount() === first + 1, `and announces it again (${balloonCount() - first})`)
}
console.log('a finished download stays finished')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: true })
let downloads = 0
ctl.downloadUpdate = () => {
downloads++
return Promise.resolve()
}
const version = '4.4.4'
live.emit('update-available', { version })
await tick()
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(s.status === 'downloading' && downloads === 1, `the automatic download started (${s.status}, ${downloads})`)
ok(s.auto === true, 'marked automatic')
}
live.emit('update-downloaded', { version })
await tick()
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(s.status === 'downloaded', `the package is on disk (got '${s.status}')`)
ok(s.percent === undefined, 'the progress bar is cleared')
ok(s.auto === true, 'the automatic mark survives (the panel keys its wording off it)')
}
// The 4-hour cycle reports the same release again: the UI must not fold "install"
// back into "download" — and must not be parked on a spinner either.
live.emit('checking-for-update')
ok(call(Ipc.UPDATE_STATE_GET).status === 'checking', 'the cycle starts by reporting a check in progress')
live.emit('update-available', { version })
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(s.status === 'downloaded', `the finished download is not downgraded to 'available' (got '${s.status}')`)
ok(s.status !== 'checking', 'and the state is not left on the spinner')
ok(s.version === version, `the state still names the release (${s.version})`)
ok(s.percent === undefined, 'with no leftover percent')
ok(s.auto === true, 'and the automatic mark intact')
}
ok(downloads === 1, `the repeat cycle fetched nothing (${downloads})`)
// `downloaded` is not `fetchHandled`: a transfer that only started must not be
// mistaken for a finished one.
live.emit('update-available', { version: '4.4.3' })
await tick()
ok(downloads === 2, `a new release is fetched (${downloads})`)
live.emit('download-progress', { percent: 10 })
ok(call(Ipc.UPDATE_STATE_GET).status === 'downloading', 'the transfer is in progress')
live.emit('update-available', { version: '4.4.3' })
{
const s = call(Ipc.UPDATE_STATE_GET)
ok(s.status !== 'downloaded', `a started-but-unfinished transfer is not 'downloaded' (got '${s.status}')`)
ok(downloads === 2, `and the fetched-once claim keeps the second cycle from restarting it (${downloads})`)
}
// Failures and "nothing to report" cycles must not forget what is on disk.
live.emit('update-downloaded', { version: '4.4.3' })
await tick()
ok(call(Ipc.UPDATE_STATE_GET).status === 'downloaded', 'the second release finishes too')
live.emit('error', new Error('boom'))
ok(call(Ipc.UPDATE_STATE_GET).status === 'error', 'an error is reported as usual')
live.emit('update-available', { version: '4.4.3' })
ok(
call(Ipc.UPDATE_STATE_GET).status === 'downloaded',
'but the finished package is still known after the error'
)
live.emit('update-not-available')
ok(call(Ipc.UPDATE_STATE_GET).status === 'latest', "'nothing to update' is reported as usual")
live.emit('update-available', { version: '4.4.3' })
ok(
call(Ipc.UPDATE_STATE_GET).status === 'downloaded',
'and still survives a cycle that found nothing'
)
// A NEW release replaces the record instead of inheriting it.
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
live.emit('update-available', { version: '4.4.2' })
{
const s = call(Ipc.UPDATE_STATE_GET)
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')
{
reset()
stub.__setPackaged(true)
writeSystem({ autoCheckUpdate: true, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
if (!balloonChecksWork) {
console.log(` skip: the balloon guard checks (${whyNoBalloons})`)
} else {
stub.__setWindows([fakeWindow(true)])
const visible = balloonCount()
live.emit('update-available', { version: '3.3.3' })
ok(balloonCount() === visible, 'a visible window suppresses the balloon (the About tab already shows the state)')
stub.__setWindows([fakeWindow(false)])
const hidden = balloonCount()
live.emit('update-available', { version: '3.3.2' })
ok(balloonCount() === hidden + 1, 'with every window hidden the balloon is shown')
stub.__setWindows([])
const closed = balloonCount()
live.emit('update-available', { version: '3.3.1' })
ok(balloonCount() === closed + 1, 'and it is shown when no window has been created at all')
}
}
stub.__setWindows([])
stub.__setUserData('')
rmSync(scheduleDir, { recursive: true, force: true })
if (bundleTmpDir) {
try {
rmSync(bundleTmpDir, { recursive: true, force: true })
} catch {
// best effort: the copy only has to live as long as the run needs the module
}
}
stub.__setPackaged(false)
clearInterval(keepAlive)
+120 -7
View File
@@ -1,7 +1,8 @@
# OpenTerminal 官网
OpenTerminal 的产品介绍站点,部署在 GitHub Pages。
纯静态 HTML + CSS,**无构建步骤、无第三方依赖**,改完即可发布。
纯静态 HTML + CSS + 原生 JS,**无构建步骤、无第三方依赖**,改完即可发布。
支持四语言:简体中文(源码基准)、繁体中文、English、日本語。
## 线上地址
@@ -11,10 +12,12 @@ OpenTerminal 的产品介绍站点,部署在 GitHub Pages。
```
website/
├── index.html # 单页站点:功能介绍 / 下载 / 更新日志
├── index.html # 单页站点:功能介绍 / 下载 / 更新日志(文案挂 data-i18n 键)
├── .nojekyll # 让 GitHub Pages 原样托管(跳过 Jekyll 处理)
└── assets/
├── style.css # 全部样式(深色默认 / 浅色可切换,主题令牌在 :root 与 html[data-theme='light'])
├── i18n.js # 四语言文案字典 window.OPENTERMINAL_I18N
├── i18n-apply.js # 语言层:选定语言 → 写 DOM / <html lang> / title / meta,并绑定语言切换控件
├── fonts/ # 自托管 woff2:Space Grotesk 400/500/600/700、JetBrains Mono 400/500/600
├── shot-terminal.jpg # 主界面截图(三路分屏并行 AI 编程 Agent,含背景图个性化)
├── shot-agents.png # 多路 AI Agent 分屏截图(无背景图版)
@@ -25,9 +28,80 @@ website/
└── icon.png # 应用图标(来自仓库 build/icon.png)
```
## 语言层(i18n)
**单文件 + 语言层**,不是复制四份 HTML:HTML 只有一份(文案是简体中文原文,同时作为缺键时的兜底),
所有可翻译文本通过 data 属性挂键,`assets/i18n.js` 提供四语言字典,`assets/i18n-apply.js` 在运行时写入。
### 三种挂键方式
| 属性 | 行为 | 例子 |
| --- | --- | --- |
| `data-i18n="k"` | 写 `textContent`(纯文本) | `<h2 data-i18n="dl.title">下载</h2>` |
| `data-i18n-html="k"` | 写 `innerHTML`(需要内联标签时) | `<h1 data-i18n-html="hero.title">…<br /><span class="accent">同一个</span>…</h1>` |
| `data-i18n-attr="attr:k"` | 写属性,多组用 `;` 分隔 | `<img data-i18n-attr="alt:hero.shot.alt" />` |
`<title>` 和三个 meta 也走同一套机制(`<title data-i18n="meta.title">`、`<meta … data-i18n-attr="content:meta.description">`),
所以「HTML 里出现的键」和「字典里的键」可以一次性机器比对,不存在漏网的特殊分支。
### 键命名
扁平命名,与 HTML 结构对应:
- `meta.*`、`a11y.skip`、`brand.*`
- `nav.*`(含 `nav.themeToLight` / `nav.themeToDark`,由页尾脚本在主题切换时取用)
- `hero.*`、`spec.*`
- `features.*`、`cap.NN.title` / `cap.NN.li.N`(8 张能力卡)、`cap.hint`
- 四个 feature-row:`fw.*`(Workbench / AI Agent)、`fs.*`(SFTP)、`fc.*`(Customize)、`fl.*`(Security)
- `dl.*`(下载表格与自动更新说明)、`cl.*`(更新日志小节)、`changelog.<版本>.li.N`(每条更新一条键)
- `footer.*`
产品名 `OpenTerminal`、版本号、命令、URL、快捷键(`Ctrl+L` / `Ctrl+F`)、技术名词(SSH / SFTP / WebGL / xterm.js / ZMODEM / MIT…)一律不译。
各节拉丁 eyebrow(`Capabilities` / `Workbench` / `SFTP` / `Customize` / `Security` / `Download` / `Changelog`)是刻意的排版元素,四语言保持一致。
### 加一条文案
1. 在 `index.html` 的对应元素上加 `data-i18n="新键"`,元素里保留简体中文原文(作为兜底)
2. 在 `assets/i18n.js` 的**四个语言块里都加上同名键**——键集合必须完全一致
3. 跑下面的「改完必跑」三条命令
漏加某个语言的键不会显示空白:`i18n-apply.js` 会回落到简体中文。但键集合不一致会让「键一致性」检查失败,属于必须修的。
### 语言选择与首帧防闪
选定语言优先级(`index.html` 头部内联脚本与 `i18n-apply.js` 用的是同一套判定):
1. URL 参数 `?lang=xx`(一次性,不写 localStorage,便于分享指定语言的链接)
2. `localStorage['openterminal.lang']`
3. `navigator.languages` 前缀匹配(`zh-TW`/`zh-HK`/`zh-MO` → 繁中,其余 `zh*` → 简中,`ja*` → 日文,`en*` → 英文)
4. 默认 `zh-CN`
**首帧不闪中文**:HTML 源码是简体中文,若等脚本跑完再翻译,非中文用户会先看到一帧中文。
所以头部内联脚本在设置 `document.documentElement.lang` 的同时,非 `zh-CN` 时给 `<html>` 挂上 `data-i18n-pending`,
`style.css` 末尾的 `html[data-i18n-pending] body { visibility: hidden }` 把 body 先藏住;
页尾的 `i18n-apply.js` 应用完文案后立刻摘掉该属性(`finally` 里摘,异常也不会把页面藏死)。
另有一条 2.5s 兜底定时器(头部脚本里),保证脚本加载失败时页面最终一定可见。
导航栏 `.nav-right` 里的 `<select class="lang-select" id="lang-select">`(在主题按钮左侧)负责切换:
换语言 → 写 `localStorage` → 重写全部文案与 `<html lang>` → 派发 `openterminal:langchange` 事件,
页尾脚本收到事件后重刷主题按钮的 `aria-label` / `title`。全程不刷新页面。
## 发新版本时需要更新的地方
`index.html` 中搜索当前版本号,共 2 处下载链接(hero 主按钮、下载表格 exe)。
版本号与链接都是**硬编码在 `index.html`**(版本号本身不翻译,不进字典),需要改的地方:
1. `index.html` 中搜索旧版本号,替换为 `v<新版本>` / `<新版本>`:
- hero 主按钮 `id="dl-exe"` 的直链(1 处)
- hero-note 里的 `<span class="mono">`(1 处)
- `spec-strip` 里的 `<strong class="mono">`(1 处)
- 下载表格的 exe 文件名单元格与「下载」链接(2 处)
2. 下载表格里的文件大小(`.mono` 单元格,如 `109 MB`)
3. 「近期更新」小节:新增一条 `<article>`(最新的在最前面,结构保持
`<h3><span class="mono">版本</span><span class="rel-date">日期</span></h3>` + `<ul><li>`),
并删掉最旧的一条以控制长度(当前保留最近 5 条:1.0.25 / 1.0.24 / 1.0.23 / 1.0.22 / 1.0.21)
4. `assets/i18n.js`:给新版本加 `changelog.<新版本>.li.N` 键(**四个语言块都要加**),
并删掉被移除那条的 `changelog.<旧版本>.li.*` 键(同样是四个语言块)
自 v1.0.22 起不再提供 MSI,只有 NSIS exe 一条直链:
```
@@ -38,7 +112,44 @@ https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTermi
(`HTTPS_PROXY=http://127.0.0.1:7897 node scripts/release.cjs <版本> --skip-gitea`),
否则直链 404。
同时更新「近期更新」小节(与 RELEASE_NOTES.md 内容保持一致)和下载表格里的文件大小。
### 改完必跑
```bash
# 1) 语法(在仓库根执行)
node --check website/assets/i18n.js
node --check website/assets/i18n-apply.js
# 2) 四语言键集合一致性(缺失 / 多余 / 同一语言内重复键)
node -e '
const fs=require("fs"); global.window={};
require("./website/assets/i18n.js");
const D=window.OPENTERMINAL_I18N, langs=Object.keys(D), base=new Set(Object.keys(D[langs[0]]));
let bad=false;
for (const l of langs) console.log(l, Object.keys(D[l]).length);
for (const l of langs.slice(1)) {
const s=new Set(Object.keys(D[l]));
const miss=[...base].filter(k=>!s.has(k)), extra=[...s].filter(k=>!base.has(k));
if (miss.length||extra.length) { bad=true; console.log(l, "missing:", miss, "extra:", extra); }
}
console.log("键集合一致:", !bad);'
# 3) index.html 引用的键在字典里都存在(防漏键显示空白)
node -e '
const fs=require("fs"); global.window={};
require("./website/assets/i18n.js");
const D=window.OPENTERMINAL_I18N, langs=Object.keys(D);
const html=fs.readFileSync("website/index.html","utf8");
const keys=new Set();
for (const m of html.matchAll(/data-i18n(?:-html)?="([^"]+)"/g)) keys.add(m[1]);
for (const m of html.matchAll(/data-i18n-attr="([^"]+)"/g))
m[1].split(";").forEach(p=>{const i=p.indexOf(":"); if(i>0) keys.add(p.slice(i+1).trim());});
["nav.themeToLight","nav.themeToDark"].forEach(k=>keys.add(k));
const dict=new Set(); langs.forEach(l=>Object.keys(D[l]).forEach(k=>dict.add(k)));
const missing=[...keys].filter(k=>!dict.has(k));
const unused=[...dict].filter(k=>!keys.has(k));
console.log("HTML 引用键:", keys.size, "字典键:", dict.size);
console.log("缺失:", missing.length?missing:"无", "| 未引用:", unused.length?unused:"无");'
```
## 部署方式
@@ -61,14 +172,16 @@ python -m http.server 8080 --directory website
npx serve website
```
然后访问 `http://localhost:8080`。直接双击 `index.html` 也可以(无跨域资源)。
然后访问 `http://localhost:8080`。直接双击 `index.html` 也可以(无跨域资源);
想看指定语言加参数即可,例如 `index.html?lang=ja`。
## 设计约定
- **深色为主**:默认深色(`#0b0e12` 底 + 终端绿强调色),右上角按钮可切浅色;两套主题共用同一份令牌,新增样式一律走 CSS 变量,不要写死颜色
- 主题选择存 `localStorage['openterminal.theme']`,首帧前由 `index.html` 头部的内联脚本定好,避免闪白/闪黑
- 主题选择存 `localStorage['openterminal.theme']`,语言选择存 `localStorage['openterminal.lang']`,两者都在首帧前由 `index.html` 头部的内联脚本定好,避免闪白/闪黑与闪中文
- 新增样式**只追加在 `style.css` 末尾**,不重排已有规则;语言切换控件(`.lang-select`)复用既有令牌,不改 `.theme-toggle` 的规则
- 全站只用**一个强调色**:深底 `#3fb950`、浅底 `#1a7f37`(对比度 ≥ 4.5:1)
- 字体自托管(`assets/fonts/`),不依赖 Google Fonts:展示字体 Space Grotesk、等宽 JetBrains Mono;中文回落到系统栈
- 「核心能力」是横向滚动卡片轨道(箭头翻页 + 拖拽 + 键盘方向键),不再用三栏等高卡片
- 截图来自真实运行界面,与应用内设置一致;涉及 IP / 主机名等敏感信息的截图必须打码后再放入 `assets/`
- 中文为主要语言;文案保持具体、平实,避免营销套话
- 文案保持具体、平实,避免营销套话;四语言都按各语言习惯写,不做逐字直译
+130
View File
@@ -0,0 +1,130 @@
/* OpenTerminal 官网语言层
选定语言的优先级(与 index.html 头部内联脚本完全一致):
1. URL 参数 ?lang=xx
2. localStorage['openterminal.lang']
3. navigator.language(s) 前缀匹配
4. 默认 zh-CN
index.html 头部内联脚本先按同一优先级把 <html lang> 定好(首帧防闪),
本文件直接读 document.documentElement.lang 作为唯一事实源,不重复判定。
应用规则:
- [data-i18n="k"] → textContent
- [data-i18n-html="k"] → innerHTML(字典是本地静态文件,非用户输入)
- [data-i18n-attr="alt:k;title:k2"] → 逐对写属性,分号分隔
缺键回落到 zh-CN;仍缺则保持 HTML 里的原文,不会把页面写空。
语言切换后派发 document 事件 'openterminal:langchange'(detail.lang),
页面尾部的内联脚本据此重刷主题按钮文案。 */
(function () {
'use strict';
var DICT = window.OPENTERMINAL_I18N || {};
var STORAGE_KEY = 'openterminal.lang';
var FALLBACK = 'zh-CN';
var SUPPORTED = ['zh-CN', 'zh-TW', 'en', 'ja'];
function normalize(tag) {
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';
return null;
}
function tableOf(lang) {
return DICT[lang] || DICT[FALLBACK] || {};
}
function lookup(lang, key) {
var table = tableOf(lang);
if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
var fb = DICT[FALLBACK] || {};
if (Object.prototype.hasOwnProperty.call(fb, key)) return fb[key];
return null;
}
function applyAttrs(lang) {
var nodes = document.querySelectorAll('[data-i18n-attr]');
for (var i = 0; i < nodes.length; i++) {
var pairs = nodes[i].getAttribute('data-i18n-attr').split(';');
for (var j = 0; j < pairs.length; j++) {
var parts = pairs[j].split(':');
if (parts.length < 2) continue;
var attr = parts[0].trim();
var key = parts.slice(1).join(':').trim();
var val = lookup(lang, key);
if (attr && val !== null) nodes[i].setAttribute(attr, val);
}
}
}
function applyText(lang) {
var nodes = document.querySelectorAll('[data-i18n]');
for (var i = 0; i < nodes.length; i++) {
var val = lookup(lang, nodes[i].getAttribute('data-i18n'));
if (val !== null) nodes[i].textContent = val;
}
}
function applyHtml(lang) {
var nodes = document.querySelectorAll('[data-i18n-html]');
for (var i = 0; i < nodes.length; i++) {
var val = lookup(lang, nodes[i].getAttribute('data-i18n-html'));
if (val !== null) nodes[i].innerHTML = val;
}
}
function clearPending() {
document.documentElement.removeAttribute('data-i18n-pending');
}
function apply(lang, announce) {
var target = normalize(lang) || FALLBACK;
try {
document.documentElement.lang = target;
applyText(target);
applyHtml(target);
applyAttrs(target);
} finally {
// 任何异常都不能让 body 一直藏在 visibility:hidden 后面
clearPending();
}
var sel = document.getElementById('lang-select');
if (sel && sel.value !== target) sel.value = target;
if (announce) {
try {
document.dispatchEvent(new CustomEvent('openterminal:langchange', { detail: { lang: target } }));
} catch (e) {
var ev = document.createEvent('Event');
ev.initEvent('openterminal:langchange', true, true);
ev.detail = { lang: target };
document.dispatchEvent(ev);
}
}
return target;
}
// 头部内联脚本已经定好语言;这里只是把它落到 DOM 上
var current = apply(document.documentElement.lang || FALLBACK, false);
var select = document.getElementById('lang-select');
if (select) {
select.value = current;
select.addEventListener('change', function () {
var next = normalize(select.value) || FALLBACK;
try { localStorage.setItem(STORAGE_KEY, next); } catch (e) {}
current = apply(next, true);
});
}
window.OPENTERMINAL_SET_LANG = function (lang) {
current = apply(lang, true);
try { localStorage.setItem(STORAGE_KEY, current); } catch (e) {}
return current;
};
window.OPENTERMINAL_I18N_SUPPORTED = SUPPORTED.slice();
})();
+719
View File
@@ -0,0 +1,719 @@
/* OpenTerminal 官网文案字典
四语言键集合必须完全一致(zh-CN / zh-TW / en / ja)。
扁平命名:nav.* / hero.* / spec.* / cap.NN.* / fw|fs|fc|fl.* / dl.* / changelog.* / cl.* / footer.*
- 键名以 HTML 里的 data-i18n / data-i18n-html / data-i18n-attr 为准,改键名要两边一起改
- data-i18n-html 的值允许内联标签(目前只有 hero.title 用到 <br /> 与 <span class="accent">)
- 产品名 OpenTerminal、版本号、命令、URL、快捷键(Ctrl+L 等)、技术名词(SSH/SFTP/WebGL/xterm.js/ZMODEM/MIT…)一律不译
- 各节拉丁 eyebrow(Capabilities / Workbench / SFTP / Customize / Security / Download / Changelog)是刻意的排版元素,四语言保持一致
新增键后请跑:node --check website/assets/i18n.js,并比对四语言键集合 */
window.OPENTERMINAL_I18N = {
'zh-CN': {
'meta.title': 'OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具',
'meta.description': 'OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。',
'meta.ogTitle': 'OpenTerminal - 本地终端 / SSH / SFTP 桌面终端',
'meta.ogDescription': '开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入。MIT 协议,支持自动更新。',
'a11y.skip': '跳到主要内容',
'brand.iconAlt': 'OpenTerminal 图标',
'nav.aria': '站内导航',
'nav.features': '功能',
'nav.download': '下载',
'nav.changelog': '更新日志',
'nav.langAria': '界面语言',
'nav.themeToggle': '切换明暗主题',
'nav.themeToLight': '切换到浅色主题',
'nav.themeToDark': '切换到深色主题',
'hero.kicker': '开源桌面终端 · MIT 协议',
'hero.title': '本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用',
'hero.sub': 'OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。',
'hero.download': '下载 Windows 安装包',
'hero.source': '查看源码',
'hero.notePre': '当前版本',
'hero.notePost': '· NSIS exe 安装包,支持应用内自动更新。',
'hero.shot.alt': 'OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex',
'hero.shot.caption': '三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义',
'spec.version': '当前版本',
'spec.os': 'Windows 10 / 11 · x64',
'spec.license': 'MIT 开源协议',
'spec.update': 'exe 支持应用内自动更新',
'features.eyebrow': 'Capabilities',
'features.title': '核心能力',
'features.sub': '按使用场景划分的能力组,左右拖动或用箭头浏览;完整清单见仓库 README。',
'features.prev': '向左浏览能力卡片',
'features.next': '向右浏览能力卡片',
'features.trackAria': '核心能力,可横向滚动',
'cap.01.title': '终端核心',
'cap.01.li.1': 'node-pty 本地会话,自动探测 PowerShell / zsh / bash',
'cap.01.li.2': 'xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级',
'cap.01.li.3': '内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认',
'cap.01.li.4': '关键词高亮 22 条内置规则,支持自定义正则与三档强度',
'cap.01.li.5': '字体、字重、字距、行高、光标样式实时生效',
'cap.02.title': 'SSH 会话',
'cap.02.li.1': 'ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent',
'cap.02.li.2': '多路分屏与广播输入,一次操作下发到多台主机',
'cap.02.li.3': '主机指纹校验、首次连接确认、变更告警',
'cap.02.li.4': 'ZMODEM 传输支持(sz / rz)',
'cap.02.li.5': 'OSC 7 上报工作目录,新会话按上次位置恢复',
'cap.03.title': 'SFTP 文件面板',
'cap.03.li.1': '列表头列分明:名称、大小、修改时间、权限、用户/组',
'cap.03.li.2': '点击列头排序;文件名解析为真实用户与组名',
'cap.03.li.3': '字体缩放 80%–180%,Ctrl+滚轮或工具栏按钮',
'cap.03.li.4': '上传下载、同名覆盖确认、chmod / chown 编辑',
'cap.03.li.5': '大目录虚拟滚动,上万条目不卡窗口',
'cap.04.title': '工作区与布局',
'cap.04.li.1': 'dockview 分屏:任意拆分、拖拽、合并与浮动',
'cap.04.li.2': '布局模板保存与一键套用',
'cap.04.li.3': '启动恢复上次会话布局',
'cap.04.li.4': '界面缩放 50% 至 150%',
'cap.05.title': '主题与外观',
'cap.05.li.1': '主题编辑器:22 个颜色位逐项调整,实时预览',
'cap.05.li.2': '内置 11 套主题,跟随系统明暗自动切换',
'cap.05.li.3': '自定义背景图片、不透明度与压暗层',
'cap.05.li.4': '标签强调色、高亮配色跟随主题',
'cap.06.title': '效率工具',
'cap.06.li.1': '命令库:保存常用命令片段,跨会话发送到指定终端',
'cap.06.li.2': '高亮规则集按主机绑定,不同服务器不同配色',
'cap.06.li.3': '规则导入导出、实时预览与命中统计',
'cap.06.li.4': '会话快照与恢复、终端标签自动命名',
'cap.07.title': '系统监控',
'cap.07.li.1': 'CPU、负载、内存、磁盘、网络实时面板',
'cap.07.li.2': '每核占用与磁盘分区明细',
'cap.07.li.3': '运行时长与网络速率曲线',
'cap.07.li.4': '随会话打开,不额外占用通道',
'cap.08.title': '锁屏与安全',
'cap.08.li.1': 'Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁',
'cap.08.li.2': '密码经 scrypt 加盐哈希存储,不落盘明文',
'cap.08.li.3': '输错触发递增冷却;锁定期间禁用菜单与刷新快捷键',
'cap.08.li.4': '锁屏只是遮罩:背后会话与传输不中断',
'cap.hint': '← 拖动浏览 · 共 8 组能力',
'fw.eyebrow': 'Workbench',
'fw.title': 'AI Agent 工作台',
'fw.li.1': '多路分屏并行运行 Kimi Code、Claude Code、Codex 等 AI 编程工具',
'fw.li.2': '为高频刷新的 TUI 界面优化:终端尺寸去抖同步,避免反复重绘',
'fw.li.3': '中文输入法候选窗紧贴光标,TUI 内输入中文不再漂移',
'fw.li.4': '关键词高亮自动标出错误、警告、HTTP 状态码与百分比',
'fw.shot.alt': 'OpenTerminal 多路分屏运行 Kimi Code、Claude Code、Codex 等 AI 编程 Agent',
'fs.eyebrow': 'SFTP',
'fs.title': '远程文件面板',
'fs.li.1': '列表头列分明:名称、大小、修改时间、权限、用户/组,点击列头排序',
'fs.li.2': '文件名解析为真实用户与组名(root/root、eveuser/eveuser),不再只有数字',
'fs.li.3': '字体缩放 80%–180%:Ctrl+滚轮或工具栏按钮,行高与字号联动并记忆',
'fs.li.4': '上传 / 下载、同名覆盖确认、chmod 权限与 chown 属主编辑',
'fs.li.5': '大目录虚拟滚动,上万条目不卡窗口',
'fs.shot.alt': 'OpenTerminal SFTP 文件面板:列表头、可排序列、完整修改时间与用户/组',
'fs.shot.alt2': 'OpenTerminal 在文件列表空白处右键弹出同款文件菜单',
'fs.caption': '列表空白处右键同样是完整文件菜单,行内右键则作用于所选条目',
'fc.eyebrow': 'Customize',
'fc.title': '定制与效率',
'fc.li.1': '主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层',
'fc.li.2': '内置 11 套主题,跟随系统明暗自动切换',
'fc.li.3': '命令库:保存常用命令片段,跨会话发送到指定终端',
'fc.li.4': '启动恢复上次会话布局;界面缩放 50% 至 150%',
'fc.li.5': '四语言界面:简体中文、繁体中文、English、日本語',
'fc.shot.alt': 'OpenTerminal 主题设置:背景图片、不透明度、压暗层与主题画廊',
'fl.eyebrow': 'Security',
'fl.title': '锁屏与安全',
'fl.li.1': 'Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁',
'fl.li.2': '锁定期间禁用应用菜单、页面刷新与开发者工具快捷键',
'fl.li.3': '密码经 scrypt 加盐哈希存储,不落盘明文;输错触发递增冷却',
'fl.li.4': '锁屏只是遮罩:背后的终端会话与文件传输保持运行不中断',
'fl.shot.alt': 'OpenTerminal 锁屏界面:不透明遮罩与密码解锁',
'dl.eyebrow': 'Download',
'dl.title': '下载',
'dl.sub': '从 GitHub Releases 下载;全部历史版本在同一页面。',
'dl.th.pkg': '安装包',
'dl.th.scene': '适用场景',
'dl.th.size': '大小',
'dl.th.link': '链接',
'dl.table.exe': 'NSIS 安装包,支持应用内自动更新(推荐)',
'dl.table.download': '下载',
'dl.table.history': '历史版本',
'dl.table.historyScene': '全部发布记录与更新说明',
'dl.table.releases': 'releases 列表',
'dl.note.title': '自动更新说明',
'dl.note.body': '应用内检查更新优先访问 GitHub Releases(跟随系统代理),失败时自动回退国内 git.codingplan.site 更新通道(直连)。更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。自 v1.0.22 起不再提供 MSI 安装包,统一使用 NSIS exe。',
'cl.eyebrow': 'Changelog',
'cl.title': '近期更新',
'changelog.1.0.25.li.1': '定时检查更新(从不 / 4 / 12 / 24 小时,默认 4)',
'changelog.1.0.25.li.2': '自动下载更新开关(后台静默下载,安装时机仍由用户决定,绝不自动退出)',
'changelog.1.0.25.li.3': '下载完成 / 发现新版本时窗口最小化到托盘则弹提示',
'changelog.1.0.25.li.4': '同版本不重复提示或重复下载',
'changelog.1.0.24.li.1': '修复应用旧版布局模板会误杀全部 SSH 会话',
'changelog.1.0.24.li.2': '配置文件损坏时先备份再重置(不再静默销毁命令库 / 书签 / 设置)',
'changelog.1.0.24.li.3': '会话日志退出不再丢尾部',
'changelog.1.0.24.li.4': 'SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试',
'changelog.1.0.23.li.1': 'SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序',
'changelog.1.0.23.li.2': '字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置',
'changelog.1.0.23.li.3': '用户 / 组列显示真实名称,不再只有数字 UID / GID',
'changelog.1.0.23.li.4': '列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题',
'changelog.1.0.22.li.1': '修复中文输入法候选窗不跟随光标(v1.0.21 回归)',
'changelog.1.0.22.li.2': '不再提供 MSI 安装包,统一使用 NSIS exe',
'changelog.1.0.22.li.3': '打包流程新增依赖一致性校验,防止旧依赖混入安装包',
'changelog.1.0.21.li.1': '背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆',
'changelog.1.0.21.li.2': '性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界',
'changelog.1.0.21.li.3': '安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固',
'cl.morePre': '完整日志见仓库',
'cl.morePost': '。',
'footer.copyright': '© 2026 CodingPlan.Site · OpenTerminal 以 MIT 协议开源',
'footer.channel': '更新通道:git.codingplan.site'
},
'zh-TW': {
'meta.title': 'OpenTerminal - 本機終端機 / SSH / SFTP 桌面終端機,開源 Windows 終端機工具',
'meta.description': 'OpenTerminal 是開源的 Windows 桌面終端機:本機終端機、SSH 遠端連線、SFTP 檔案管理、分割視窗與廣播輸入、主題自訂。MIT 授權,支援自動更新。',
'meta.ogTitle': 'OpenTerminal - 本機終端機 / SSH / SFTP 桌面終端機',
'meta.ogDescription': '開源的 Windows 桌面終端機:本機終端機、SSH 遠端連線、SFTP 檔案管理、分割視窗與廣播輸入。MIT 授權,支援自動更新。',
'a11y.skip': '跳至主要內容',
'brand.iconAlt': 'OpenTerminal 圖示',
'nav.aria': '站內導覽',
'nav.features': '功能',
'nav.download': '下載',
'nav.changelog': '更新日誌',
'nav.langAria': '介面語言',
'nav.themeToggle': '切換明暗主題',
'nav.themeToLight': '切換到淺色主題',
'nav.themeToDark': '切換到深色主題',
'hero.kicker': '開源桌面終端機 · MIT 授權',
'hero.title': '本機終端機、SSH 與 SFTP<br />裝進<span class="accent">同一個</span>桌面應用程式',
'hero.sub': 'OpenTerminal 面向開發與維運場景:WebGL 加速的終端機渲染、可分割視窗的 SSH 連線、內建 SFTP 檔案管理與廣播輸入。安裝即用,支援自動更新。',
'hero.download': '下載 Windows 安裝程式',
'hero.source': '檢視原始碼',
'hero.notePre': '目前版本',
'hero.notePost': '· NSIS exe 安裝程式,支援應用程式內自動更新。',
'hero.shot.alt': 'OpenTerminal 主畫面:三路分割視窗同時執行 Kimi Code、Claude Code 與 Codex',
'hero.shot.caption': '三路分割視窗同時執行 AI 編程 Agent,背景圖片與壓暗層可自訂',
'spec.version': '目前版本',
'spec.os': 'Windows 10 / 11 · x64',
'spec.license': 'MIT 開源授權',
'spec.update': 'exe 支援應用程式內自動更新',
'features.eyebrow': 'Capabilities',
'features.title': '核心能力',
'features.sub': '依使用情境劃分的能力群組,可左右拖曳或用箭頭瀏覽;完整清單請見倉庫 README。',
'features.prev': '向左瀏覽能力卡片',
'features.next': '向右瀏覽能力卡片',
'features.trackAria': '核心能力,可橫向捲動',
'cap.01.title': '終端機核心',
'cap.01.li.1': 'node-pty 本機工作階段,自動偵測 PowerShell / zsh / bash',
'cap.01.li.2': 'xterm.js WebGL 渲染,宣告 256 色與全彩,異常時自動降級',
'cap.01.li.3': '內嵌搜尋(Ctrl+F)、指令歷史、貼上風險確認',
'cap.01.li.4': '關鍵字標亮 22 條內建規則,支援自訂正規表達式與三種強度',
'cap.01.li.5': '字型、字重、字距、行高、游標樣式即時生效',
'cap.02.title': 'SSH 工作階段',
'cap.02.li.1': 'ssh2 實作:密碼、私密金鑰(檔案或貼上內容)、SSH Agent',
'cap.02.li.2': '多路分割視窗與廣播輸入,一次操作下發到多台主機',
'cap.02.li.3': '主機指紋驗證、首次連線確認、變更告警',
'cap.02.li.4': 'ZMODEM 傳輸支援(sz / rz)',
'cap.02.li.5': 'OSC 7 回報工作目錄,新工作階段依上次位置還原',
'cap.03.title': 'SFTP 檔案面板',
'cap.03.li.1': '表頭欄位分明:名稱、大小、修改時間、權限、使用者 / 群組',
'cap.03.li.2': '點擊表頭排序;檔名解析為真實使用者與群組名稱',
'cap.03.li.3': '字型縮放 80%–180%,Ctrl+滾輪或工具列按鈕',
'cap.03.li.4': '上傳下載、同名覆寫確認、chmod / chown 編輯',
'cap.03.li.5': '大目錄虛擬捲動,上萬項目不卡視窗',
'cap.04.title': '工作區與版面配置',
'cap.04.li.1': 'dockview 分割視窗:任意分割、拖曳、合併與浮動',
'cap.04.li.2': '版面配置範本儲存與一鍵套用',
'cap.04.li.3': '啟動時還原上次工作階段版面',
'cap.04.li.4': '介面縮放 50% 至 150%',
'cap.05.title': '主題與外觀',
'cap.05.li.1': '主題編輯器:22 個顏色欄位逐項調整,即時預覽',
'cap.05.li.2': '內建 11 套主題,跟隨系統明暗自動切換',
'cap.05.li.3': '自訂背景圖片、不透明度與壓暗層',
'cap.05.li.4': '分頁強調色、標亮配色跟隨主題',
'cap.06.title': '效率工具',
'cap.06.li.1': '指令庫:儲存常用指令片段,跨工作階段傳送到指定終端機',
'cap.06.li.2': '標亮規則集依主機綁定,不同伺服器不同配色',
'cap.06.li.3': '規則匯入匯出、即時預覽與命中統計',
'cap.06.li.4': '工作階段快照與還原、終端機分頁自動命名',
'cap.07.title': '系統監控',
'cap.07.li.1': 'CPU、負載、記憶體、磁碟、網路即時面板',
'cap.07.li.2': '每核心使用率與磁碟分割區明細',
'cap.07.li.3': '運行時間與網路速率曲線',
'cap.07.li.4': '隨工作階段開啟,不額外佔用通道',
'cap.08.title': '鎖定畫面與安全',
'cap.08.li.1': 'Ctrl+L 一鍵鎖定,支援閒置自動鎖定與啟動即鎖定',
'cap.08.li.2': '密碼以 scrypt 加鹽雜湊儲存,不寫入明文',
'cap.08.li.3': '輸入錯誤觸發遞增冷卻;鎖定期間停用選單與重新整理快速鍵',
'cap.08.li.4': '鎖定畫面只是遮罩:背後的工作階段與傳輸不中斷',
'cap.hint': '← 拖曳瀏覽 · 共 8 組能力',
'fw.eyebrow': 'Workbench',
'fw.title': 'AI Agent 工作台',
'fw.li.1': '多路分割視窗同時執行 Kimi Code、Claude Code、Codex 等 AI 編程工具',
'fw.li.2': '為高頻更新的 TUI 介面最佳化:終端機尺寸去抖同步,避免反覆重繪',
'fw.li.3': '中文輸入法候選窗緊貼游標,在 TUI 內輸入中文不再漂移',
'fw.li.4': '關鍵字標亮自動標出錯誤、警告、HTTP 狀態碼與百分比',
'fw.shot.alt': 'OpenTerminal 多路分割視窗執行 Kimi Code、Claude Code、Codex 等 AI 編程 Agent',
'fs.eyebrow': 'SFTP',
'fs.title': '遠端檔案面板',
'fs.li.1': '表頭欄位分明:名稱、大小、修改時間、權限、使用者 / 群組,點擊表頭排序',
'fs.li.2': '檔名解析為真實使用者與群組名稱(root/root、eveuser/eveuser),不再只有數字',
'fs.li.3': '字型縮放 80%–180%:Ctrl+滾輪或工具列按鈕,行高與字級連動並記憶',
'fs.li.4': '上傳 / 下載、同名覆寫確認、chmod 權限與 chown 擁有者編輯',
'fs.li.5': '大目錄虛擬捲動,上萬項目不卡視窗',
'fs.shot.alt': 'OpenTerminal SFTP 檔案面板:表頭、可排序欄位、完整修改時間與使用者 / 群組',
'fs.shot.alt2': 'OpenTerminal 在檔案清單空白處按右鍵彈出同款檔案選單',
'fs.caption': '清單空白處按右鍵同樣是完整檔案選單,行內按右鍵則作用於所選項目',
'fc.eyebrow': 'Customize',
'fc.title': '自訂與效率',
'fc.li.1': '主題編輯器:22 個顏色欄位逐項調整,即時預覽;支援自訂背景圖片、不透明度與壓暗層',
'fc.li.2': '內建 11 套主題,跟隨系統明暗自動切換',
'fc.li.3': '指令庫:儲存常用指令片段,跨工作階段傳送到指定終端機',
'fc.li.4': '啟動時還原上次工作階段版面;介面縮放 50% 至 150%',
'fc.li.5': '四語言介面:簡體中文、繁體中文、English、日本語',
'fc.shot.alt': 'OpenTerminal 主題設定:背景圖片、不透明度、壓暗層與主題畫廊',
'fl.eyebrow': 'Security',
'fl.title': '鎖定畫面與安全',
'fl.li.1': 'Ctrl+L 一鍵鎖定,支援閒置自動鎖定與啟動即鎖定',
'fl.li.2': '鎖定期間停用應用程式選單、頁面重新整理與開發者工具快速鍵',
'fl.li.3': '密碼以 scrypt 加鹽雜湊儲存,不寫入明文;輸入錯誤觸發遞增冷卻',
'fl.li.4': '鎖定畫面只是遮罩:背後的終端機工作階段與檔案傳輸保持運行不中斷',
'fl.shot.alt': 'OpenTerminal 鎖定畫面:不透明遮罩與密碼解鎖',
'dl.eyebrow': 'Download',
'dl.title': '下載',
'dl.sub': '從 GitHub Releases 下載;所有歷史版本都在同一頁面。',
'dl.th.pkg': '安裝程式',
'dl.th.scene': '適用情境',
'dl.th.size': '大小',
'dl.th.link': '連結',
'dl.table.exe': 'NSIS 安裝程式,支援應用程式內自動更新(推薦)',
'dl.table.download': '下載',
'dl.table.history': '歷史版本',
'dl.table.historyScene': '所有發佈記錄與更新說明',
'dl.table.releases': 'releases 清單',
'dl.note.title': '自動更新說明',
'dl.note.body': '應用程式內檢查更新會優先存取 GitHub Releases(跟隨系統代理設定),失敗時自動回退到國內 git.codingplan.site 更新通道(直連)。更新檔經 SHA-512 校驗後安裝。更新說明文字從發佈通道的 release-notes.md 讀取,與每次發佈同步更新。自 v1.0.22 起不再提供 MSI 安裝程式,統一使用 NSIS exe。',
'cl.eyebrow': 'Changelog',
'cl.title': '近期更新',
'changelog.1.0.25.li.1': '定時檢查更新(從不 / 4 / 12 / 24 小時,預設 4)',
'changelog.1.0.25.li.2': '自動下載更新開關(背景靜默下載,安裝時機仍由使用者決定,絕不自動結束)',
'changelog.1.0.25.li.3': '下載完成 / 發現新版本時,視窗若最小化到系統匣會彈出提示',
'changelog.1.0.25.li.4': '同一版本不重複提示、不重複下載',
'changelog.1.0.24.li.1': '修復套用舊版版面配置範本會誤殺全部 SSH 工作階段的問題',
'changelog.1.0.24.li.2': '設定檔損毀時先備份再重置(不再靜默銷毀指令庫 / 書籤 / 設定)',
'changelog.1.0.24.li.3': '工作階段日誌結束時不再遺失尾段',
'changelog.1.0.24.li.4': 'SFTP 刪除不再誤報失敗;ZMODEM 接收大檔案的記憶體用量可控;新增 3 組渲染層測試',
'changelog.1.0.23.li.1': 'SFTP 檔案面板改為真正的表格:六欄對齊、表頭固定、點擊表頭排序',
'changelog.1.0.23.li.2': '字型可縮放 0.8–1.8 倍(A- / A+ 或 Ctrl+滾輪),行高連動並記憶設定',
'changelog.1.0.23.li.3': '使用者 / 群組欄顯示真實名稱,不再只有數字 UID / GID',
'changelog.1.0.23.li.4': '清單空白處按右鍵即可喚出檔案選單;修復六欄先前未真正依格線對齊的問題',
'changelog.1.0.22.li.1': '修復中文輸入法候選窗不跟隨游標(v1.0.21 回歸)',
'changelog.1.0.22.li.2': '不再提供 MSI 安裝程式,統一使用 NSIS exe',
'changelog.1.0.22.li.3': '打包流程新增相依性一致性校驗,防止舊相依性混入安裝程式',
'changelog.1.0.21.li.1': '背景圖片體驗:任何主題下調低不透明度都是壓暗、文字自動提亮至 WCAG AA、新增背景壓暗滑桿',
'changelog.1.0.21.li.2': '效能:設定熱更新不再觸發全部窗格重排,SFTP 大目錄虛擬捲動,面板獨立錯誤邊界',
'changelog.1.0.21.li.3': '安裝程式精簡約 90MB;SFTP/ZMODEM 本機路徑准入與多處安全加固',
'cl.morePre': '完整日誌請見倉庫',
'cl.morePost': '。',
'footer.copyright': '© 2026 CodingPlan.Site · OpenTerminal 以 MIT 授權開源',
'footer.channel': '更新通道:git.codingplan.site'
},
en: {
'meta.title': 'OpenTerminal - Local terminal, SSH & SFTP desktop terminal for Windows',
'meta.description': 'OpenTerminal is an open-source desktop terminal for Windows: local shells, SSH sessions, SFTP file management, split panes with broadcast input, and deep theming. MIT licensed, with built-in auto-update.',
'meta.ogTitle': 'OpenTerminal - Local terminal, SSH & SFTP desktop terminal',
'meta.ogDescription': 'An open-source desktop terminal for Windows: local shells, SSH sessions, SFTP file management, and split panes with broadcast input. MIT licensed, with built-in auto-update.',
'a11y.skip': 'Skip to main content',
'brand.iconAlt': 'OpenTerminal icon',
'nav.aria': 'Site navigation',
'nav.features': 'Features',
'nav.download': 'Download',
'nav.changelog': 'Changelog',
'nav.langAria': 'Interface language',
'nav.themeToggle': 'Toggle light and dark theme',
'nav.themeToLight': 'Switch to light theme',
'nav.themeToDark': 'Switch to dark theme',
'hero.kicker': 'Open-source desktop terminal · MIT licensed',
'hero.title': 'Local shells, SSH and SFTP<br />in <span class="accent">one</span> desktop app',
'hero.sub': 'OpenTerminal is built for developers and sysadmins: WebGL-accelerated terminal rendering, SSH sessions you can split into panes, built-in SFTP file management and broadcast input. Install and go, with auto-update built in.',
'hero.download': 'Download for Windows',
'hero.source': 'View source',
'hero.notePre': 'Current version',
'hero.notePost': '· NSIS installer with in-app auto-update.',
'hero.shot.alt': 'OpenTerminal main window: three split panes running Kimi Code, Claude Code and Codex side by side',
'hero.shot.caption': 'Three AI coding agents running side by side — background image and dim layer are customizable',
'spec.version': 'Current version',
'spec.os': 'Windows 10 / 11 · x64',
'spec.license': 'MIT open-source license',
'spec.update': 'In-app auto-update via exe',
'features.eyebrow': 'Capabilities',
'features.title': 'Core capabilities',
'features.sub': 'Capability groups organized by workflow — drag sideways or use the arrows to browse. See the repository README for the full list.',
'features.prev': 'Scroll capability cards left',
'features.next': 'Scroll capability cards right',
'features.trackAria': 'Core capabilities, horizontally scrollable',
'cap.01.title': 'Terminal core',
'cap.01.li.1': 'node-pty local sessions with automatic PowerShell / zsh / bash detection',
'cap.01.li.2': 'xterm.js WebGL rendering, 256-color and true-color advertised, automatic fallback on failure',
'cap.01.li.3': 'Built-in search (Ctrl+F), command history, paste-risk confirmation',
'cap.01.li.4': 'Keyword highlighting with 22 built-in rules, custom regex and three intensity levels',
'cap.01.li.5': 'Font, weight, letter spacing, line height and cursor style apply live',
'cap.02.title': 'SSH sessions',
'cap.02.li.1': 'Built on ssh2: password, private key (file or pasted) and SSH Agent auth',
'cap.02.li.2': 'Multi-pane split view with broadcast input — send one command to many hosts at once',
'cap.02.li.3': 'Host key verification, first-connection confirmation and change warnings',
'cap.02.li.4': 'ZMODEM transfer support (sz / rz)',
'cap.02.li.5': 'OSC 7 working-directory reporting — new sessions resume where you left off',
'cap.03.title': 'SFTP file panel',
'cap.03.li.1': 'Clear column headers: name, size, modified time, permissions, user / group',
'cap.03.li.2': 'Click a header to sort; user and group IDs resolve to real names',
'cap.03.li.3': 'Font scaling from 80% to 180% via Ctrl+wheel or toolbar buttons',
'cap.03.li.4': 'Upload and download, overwrite confirmation, chmod / chown editing',
'cap.03.li.5': 'Virtual scrolling for huge directories — tens of thousands of entries stay smooth',
'cap.04.title': 'Workspace and layout',
'cap.04.li.1': 'dockview panes: split, drag, merge and float freely',
'cap.04.li.2': 'Save layout templates and apply them in one click',
'cap.04.li.3': 'Restore your last session layout on startup',
'cap.04.li.4': 'Interface zoom from 50% to 150%',
'cap.05.title': 'Themes and appearance',
'cap.05.li.1': 'Theme editor: tune all 22 color slots with live preview',
'cap.05.li.2': '11 built-in themes that follow the system light / dark setting',
'cap.05.li.3': 'Custom background image, opacity and dim layer',
'cap.05.li.4': 'Tab accent color; highlight colors follow the theme',
'cap.06.title': 'Productivity tools',
'cap.06.li.1': 'Command library: save snippets and send them to any terminal from any session',
'cap.06.li.2': 'Bind highlight rule sets per host — different colors for different servers',
'cap.06.li.3': 'Import and export rules, live preview and match statistics',
'cap.06.li.4': 'Session snapshots and restore, automatic terminal tab naming',
'cap.07.title': 'System monitoring',
'cap.07.li.1': 'Live CPU, load, memory, disk and network panel',
'cap.07.li.2': 'Per-core usage and per-partition disk details',
'cap.07.li.3': 'Uptime and network throughput graphs',
'cap.07.li.4': 'Opens with the session — no extra channel needed',
'cap.08.title': 'Lock screen and security',
'cap.08.li.1': 'Ctrl+L locks instantly; idle auto-lock and lock-on-startup available',
'cap.08.li.2': 'Passwords stored as salted scrypt hashes — never in plain text',
'cap.08.li.3': 'Wrong attempts trigger escalating cooldowns; menus and reload shortcuts are disabled while locked',
'cap.08.li.4': 'The lock screen is just an overlay — sessions and transfers keep running behind it',
'cap.hint': '← Drag to browse · 8 capability groups',
'fw.eyebrow': 'Workbench',
'fw.title': 'AI agent workbench',
'fw.li.1': 'Run Kimi Code, Claude Code, Codex and other AI coding tools side by side',
'fw.li.2': 'Tuned for fast-refreshing TUIs: terminal size updates are debounced to avoid redundant repaints',
'fw.li.3': 'The IME candidate window sticks to the cursor — no more drift when typing Chinese in a TUI',
'fw.li.4': 'Highlighting flags errors, warnings, HTTP status codes and percentages automatically',
'fw.shot.alt': 'OpenTerminal running Kimi Code, Claude Code, Codex and other AI coding agents in multiple panes',
'fs.eyebrow': 'SFTP',
'fs.title': 'Remote file panel',
'fs.li.1': 'Clear column headers — name, size, modified time, permissions, user / group — click any header to sort',
'fs.li.2': 'User and group IDs resolve to real names (root/root, eveuser/eveuser) instead of bare numbers',
'fs.li.3': 'Font scaling 80%–180% via Ctrl+wheel or toolbar buttons; row height follows and the setting is remembered',
'fs.li.4': 'Upload / download, overwrite confirmation, chmod permissions and chown ownership editing',
'fs.li.5': 'Virtual scrolling keeps directories with tens of thousands of entries responsive',
'fs.shot.alt': 'OpenTerminal SFTP file panel: column headers, sortable columns, full timestamps and user / group names',
'fs.shot.alt2': 'OpenTerminal showing the same file menu from a right-click on empty list space',
'fs.caption': 'Right-click empty space for the full file menu; right-click a row to act on that entry',
'fc.eyebrow': 'Customize',
'fc.title': 'Customization and efficiency',
'fc.li.1': 'Theme editor with live preview for all 22 color slots, plus custom background image, opacity and dim layer',
'fc.li.2': '11 built-in themes that follow the system light / dark setting',
'fc.li.3': 'Command library: save snippets and send them to a chosen terminal from any session',
'fc.li.4': 'Restore your last session layout on startup; interface zoom from 50% to 150%',
'fc.li.5': 'Four interface languages: Simplified Chinese, Traditional Chinese, English and Japanese',
'fc.shot.alt': 'OpenTerminal theme settings: background image, opacity, dim layer and theme gallery',
'fl.eyebrow': 'Security',
'fl.title': 'Lock screen and security',
'fl.li.1': 'Ctrl+L locks instantly; idle auto-lock and lock-on-startup available',
'fl.li.2': 'While locked, the app menu, page reload and devtools shortcuts are disabled',
'fl.li.3': 'Passwords stored as salted scrypt hashes, never plain text; wrong attempts trigger escalating cooldowns',
'fl.li.4': 'The lock screen is only an overlay: terminal sessions and file transfers keep running behind it',
'fl.shot.alt': 'OpenTerminal lock screen: opaque overlay with password unlock',
'dl.eyebrow': 'Download',
'dl.title': 'Download',
'dl.sub': 'Download from GitHub Releases — every past version lives on the same page.',
'dl.th.pkg': 'Package',
'dl.th.scene': 'Best for',
'dl.th.size': 'Size',
'dl.th.link': 'Link',
'dl.table.exe': 'NSIS installer with in-app auto-update (recommended)',
'dl.table.download': 'Download',
'dl.table.history': 'All versions',
'dl.table.historyScene': 'Every release and its notes',
'dl.table.releases': 'Release list',
'dl.note.title': 'About auto-update',
'dl.note.body': 'In-app update checks try GitHub Releases first (following the system proxy) and fall back to the China-based git.codingplan.site channel (direct connection) on failure. Packages are verified with SHA-512 before installation. Release notes are read from release-notes.md on the update channel and stay in sync with each release. Since v1.0.22 no MSI installer is provided; NSIS exe is the only option.',
'cl.eyebrow': 'Changelog',
'cl.title': 'Recent updates',
'changelog.1.0.25.li.1': 'Scheduled update checks (never / 4 / 12 / 24 hours, default 4)',
'changelog.1.0.25.li.2': 'Auto-download toggle: silent background download, install timing still up to you, never quits on its own',
'changelog.1.0.25.li.3': 'Tray notification when a new version is found or a download finishes while the window is minimized',
'changelog.1.0.25.li.4': 'No repeated prompts or repeated downloads for the same version',
'changelog.1.0.24.li.1': 'Fixed: applying an older layout template could kill every SSH session',
'changelog.1.0.24.li.2': 'Corrupted config files are now backed up before resetting — no more silent loss of the command library, bookmarks or settings',
'changelog.1.0.24.li.3': 'Session logs no longer lose their tail when the app exits',
'changelog.1.0.24.li.4': 'SFTP delete no longer reports false failures; ZMODEM downloads of large files keep memory bounded; 3 new renderer test suites',
'changelog.1.0.23.li.1': 'SFTP file panel is now a real table: six aligned columns, sticky header, click-to-sort',
'changelog.1.0.23.li.2': 'Font scaling from 0.8x to 1.8x (A- / A+ or Ctrl+wheel); row height follows and the setting is remembered',
'changelog.1.0.23.li.3': 'User / group columns show real names instead of bare UID / GID numbers',
'changelog.1.0.23.li.4': 'Right-click empty list space for the file menu; fixed columns that were not actually aligned to the grid',
'changelog.1.0.22.li.1': 'Fixed the IME candidate window not following the cursor (regression in v1.0.21)',
'changelog.1.0.22.li.2': 'MSI installer dropped; NSIS exe is now the only package',
'changelog.1.0.22.li.3': 'Build pipeline now verifies dependency consistency to keep stale dependencies out of the installer',
'changelog.1.0.21.li.1': 'Background images: lowering opacity now always dims the image in any theme, text is auto-brightened to WCAG AA, and a new dim slider was added',
'changelog.1.0.21.li.2': 'Performance: settings hot-updates no longer reflow every pane, SFTP virtual scrolling for large directories, per-pane error boundaries',
'changelog.1.0.21.li.3': 'Installer slimmed by about 90MB; local path admission for SFTP / ZMODEM plus several security hardening fixes',
'cl.morePre': 'Full changelog in',
'cl.morePost': '.',
'footer.copyright': '© 2026 CodingPlan.Site · OpenTerminal is open source under the MIT license',
'footer.channel': 'Update channel: git.codingplan.site'
},
ja: {
'meta.title': 'OpenTerminal - ローカルターミナル / SSH / SFTP 対応の Windows デスクトップターミナル',
'meta.description': 'OpenTerminal はオープンソースの Windows デスクトップターミナル。ローカルシェル、SSH セッション、SFTP ファイル管理、分割ペインとブロードキャスト入力、テーマのカスタマイズ。MIT ライセンス、自動更新対応。',
'meta.ogTitle': 'OpenTerminal - ローカルターミナル / SSH / SFTP デスクトップターミナル',
'meta.ogDescription': 'オープンソースの Windows デスクトップターミナル。ローカルシェル、SSH セッション、SFTP ファイル管理、分割ペインとブロードキャスト入力。MIT ライセンス、自動更新対応。',
'a11y.skip': 'メインコンテンツへスキップ',
'brand.iconAlt': 'OpenTerminal のアイコン',
'nav.aria': 'サイト内ナビゲーション',
'nav.features': '機能',
'nav.download': 'ダウンロード',
'nav.changelog': '更新履歴',
'nav.langAria': '表示言語',
'nav.themeToggle': 'ライト/ダークテーマの切り替え',
'nav.themeToLight': 'ライトテーマに切り替え',
'nav.themeToDark': 'ダークテーマに切り替え',
'hero.kicker': 'オープンソースのデスクトップターミナル · MIT ライセンス',
'hero.title': 'ローカルシェルも SSH も SFTP も<br /><span class="accent">ひとつ</span>のデスクトップアプリに',
'hero.sub': 'OpenTerminal は開発者と運用担当者のためのターミナルです。WebGL による高速な描画、分割できる SSH セッション、SFTP ファイル管理、ブロードキャスト入力。インストールすればすぐ使え、自動更新にも対応します。',
'hero.download': 'Windows 版をダウンロード',
'hero.source': 'ソースコードを見る',
'hero.notePre': '現在のバージョン',
'hero.notePost': '· NSIS インストーラー、アプリ内自動更新に対応。',
'hero.shot.alt': 'OpenTerminal のメイン画面。3 つの分割ペインで Kimi Code、Claude Code、Codex を同時に実行',
'hero.shot.caption': '3 つの AI コーディングエージェントを並行実行。背景画像と減光レイヤーは自由に設定できます',
'spec.version': '現在のバージョン',
'spec.os': 'Windows 10 / 11 · x64',
'spec.license': 'MIT オープンソースライセンス',
'spec.update': 'exe はアプリ内自動更新に対応',
'features.eyebrow': 'Capabilities',
'features.title': '主な機能',
'features.sub': '用途ごとにまとめた機能グループです。左右にドラッグするか矢印ボタンでご覧ください。全機能の一覧はリポジトリの README にあります。',
'features.prev': '機能カードを左へスクロール',
'features.next': '機能カードを右へスクロール',
'features.trackAria': '主な機能(横スクロール可能)',
'cap.01.title': 'ターミナルの中核',
'cap.01.li.1': 'node-pty によるローカルセッション。PowerShell / zsh / bash を自動検出',
'cap.01.li.2': 'xterm.js の WebGL レンダリング。256 色とトゥルーカラーに対応し、異常時は自動でフォールバック',
'cap.01.li.3': '組み込み検索(Ctrl+F)、コマンド履歴、貼り付け時のリスク確認',
'cap.01.li.4': 'キーワードハイライトは組み込み 22 ルール。独自の正規表現と 3 段階の強度に対応',
'cap.01.li.5': 'フォント、太さ、字間、行間、カーソル形状を即時反映',
'cap.02.title': 'SSH セッション',
'cap.02.li.1': 'ssh2 による実装。パスワード、秘密鍵(ファイルまたは貼り付け)、SSH Agent に対応',
'cap.02.li.2': '複数ペインの分割表示とブロードキャスト入力。1 回の操作で複数のホストへ送信',
'cap.02.li.3': 'ホスト鍵の検証、初回接続の確認、鍵変更時の警告',
'cap.02.li.4': 'ZMODEM 転送に対応(sz / rz)',
'cap.02.li.5': 'OSC 7 による作業ディレクトリの通知。新しいセッションは前回の場所から再開',
'cap.03.title': 'SFTP ファイルパネル',
'cap.03.li.1': '名前、サイズ、更新日時、パーミッション、ユーザー / グループの列を明示',
'cap.03.li.2': 'ヘッダーをクリックして並べ替え。UID / GID は実際のユーザー名・グループ名に解決',
'cap.03.li.3': '文字サイズは 80%〜180%。Ctrl+ホイールまたはツールバーのボタンで変更',
'cap.03.li.4': 'アップロードとダウンロード、同名ファイルの上書き確認、chmod / chown の編集',
'cap.03.li.5': '大量のファイルがあるディレクトリも仮想スクロールで快適。数万件でも固まりません',
'cap.04.title': 'ワークスペースとレイアウト',
'cap.04.li.1': 'dockview によるペイン分割。自由な分割、ドラッグ、統合、フローティング',
'cap.04.li.2': 'レイアウトテンプレートの保存とワンクリック適用',
'cap.04.li.3': '起動時に前回のセッションレイアウトを復元',
'cap.04.li.4': 'UI のズームは 50%〜150%',
'cap.05.title': 'テーマと外観',
'cap.05.li.1': 'テーマエディター:22 個のカラースロットを個別に調整し、リアルタイムでプレビュー',
'cap.05.li.2': '組み込みテーマ 11 種。システムのライト/ダーク設定に自動追従',
'cap.05.li.3': '背景画像、不透明度、減光レイヤーをカスタマイズ',
'cap.05.li.4': 'タブのアクセントカラー。ハイライトの配色はテーマに追従',
'cap.06.title': '生産性ツール',
'cap.06.li.1': 'コマンドライブラリ:よく使うコマンドを保存し、任意のセッションから指定したターミナルへ送信',
'cap.06.li.2': 'ハイライトルールセットはホストごとに割り当て。サーバーによって配色を変えられます',
'cap.06.li.3': 'ルールのインポート/エクスポート、リアルタイムプレビュー、一致回数の統計',
'cap.06.li.4': 'セッションのスナップショットと復元、ターミナルタブの自動命名',
'cap.07.title': 'システム監視',
'cap.07.li.1': 'CPU、ロードアベレージ、メモリ、ディスク、ネットワークをリアルタイム表示',
'cap.07.li.2': 'コアごとの使用率とディスクパーティションの内訳',
'cap.07.li.3': '稼働時間とネットワーク速度のグラフ',
'cap.07.li.4': 'セッションと一緒に開き、追加のチャンネルは使いません',
'cap.08.title': 'ロック画面とセキュリティ',
'cap.08.li.1': 'Ctrl+L で即座にロック。アイドル時の自動ロックと起動時ロックにも対応',
'cap.08.li.2': 'パスワードは scrypt のソルト付きハッシュで保存。平文はディスクに残りません',
'cap.08.li.3': '誤入力時はクールダウンが段階的に延長。ロック中はメニューと再読み込みショートカットを無効化',
'cap.08.li.4': 'ロック画面はオーバーレイにすぎません。背後のセッションと転送は動き続けます',
'cap.hint': '← ドラッグで閲覧 · 全 8 グループ',
'fw.eyebrow': 'Workbench',
'fw.title': 'AI エージェント作業台',
'fw.li.1': 'Kimi Code、Claude Code、Codex などの AI コーディングツールを並行して実行',
'fw.li.2': '高頻度で更新される TUI 向けに最適化。ターミナルサイズの同期をデバウンスし、無駄な再描画を抑制',
'fw.li.3': 'IME の変換候補ウィンドウがカーソルに追従。TUI 内での日本語入力でもずれません',
'fw.li.4': 'キーワードハイライトがエラー、警告、HTTP ステータスコード、パーセントを自動で色分け',
'fw.shot.alt': 'OpenTerminal で Kimi Code、Claude Code、Codex などの AI コーディングエージェントを複数ペインで実行',
'fs.eyebrow': 'SFTP',
'fs.title': 'リモートファイルパネル',
'fs.li.1': '名前、サイズ、更新日時、パーミッション、ユーザー / グループの列を明示。ヘッダーをクリックで並べ替え',
'fs.li.2': 'UID / GID は実際の名前(root/root、eveuser/eveuser)で表示。数字だけではありません',
'fs.li.3': '文字サイズは 80%〜180%。Ctrl+ホイールまたはツールバーのボタンで変更でき、行の高さも連動して記憶されます',
'fs.li.4': 'アップロード/ダウンロード、同名ファイルの上書き確認、chmod 権限と chown 所有者の編集',
'fs.li.5': '数万件のファイルがあるディレクトリも仮想スクロールで快適に操作',
'fs.shot.alt': 'OpenTerminal の SFTP ファイルパネル。列ヘッダー、並べ替え可能な列、完全な更新日時、ユーザー / グループ',
'fs.shot.alt2': 'OpenTerminal でファイル一覧の空白部分を右クリックし、同じファイルメニューを表示',
'fs.caption': '空白部分の右クリックでも同じファイルメニューが開きます。行の右クリックはその項目だけが対象です',
'fc.eyebrow': 'Customize',
'fc.title': 'カスタマイズと効率化',
'fc.li.1': 'テーマエディターで 22 個のカラースロットを個別に調整し、リアルタイムでプレビュー。背景画像、不透明度、減光レイヤーにも対応',
'fc.li.2': '組み込みテーマ 11 種。システムのライト/ダーク設定に自動追従',
'fc.li.3': 'コマンドライブラリ:よく使うコマンドを保存し、任意のセッションから指定したターミナルへ送信',
'fc.li.4': '起動時に前回のセッションレイアウトを復元。UI のズームは 50%〜150%',
'fc.li.5': '4 つの表示言語:簡体字中国語、繁体字中国語、英語、日本語',
'fc.shot.alt': 'OpenTerminal のテーマ設定。背景画像、不透明度、減光レイヤー、テーマギャラリー',
'fl.eyebrow': 'Security',
'fl.title': 'ロック画面とセキュリティ',
'fl.li.1': 'Ctrl+L で即座にロック。アイドル時の自動ロックと起動時ロックにも対応',
'fl.li.2': 'ロック中はアプリのメニュー、再読み込み、開発者ツールのショートカットを無効化',
'fl.li.3': 'パスワードは scrypt のソルト付きハッシュで保存し、平文は残しません。誤入力時はクールダウンが段階的に延長',
'fl.li.4': 'ロック画面はオーバーレイにすぎません。背後のターミナルセッションとファイル転送は動き続けます',
'fl.shot.alt': 'OpenTerminal のロック画面。不透明なオーバーレイとパスワードによる解除',
'dl.eyebrow': 'Download',
'dl.title': 'ダウンロード',
'dl.sub': 'GitHub Releases からダウンロードできます。過去のバージョンも同じページに揃っています。',
'dl.th.pkg': 'パッケージ',
'dl.th.scene': '用途',
'dl.th.size': 'サイズ',
'dl.th.link': 'リンク',
'dl.table.exe': 'NSIS インストーラー。アプリ内自動更新に対応(推奨)',
'dl.table.download': 'ダウンロード',
'dl.table.history': '過去のバージョン',
'dl.table.historyScene': 'すべてのリリースと変更内容',
'dl.table.releases': 'リリース一覧',
'dl.note.title': '自動更新について',
'dl.note.body': 'アプリ内の更新確認はまず GitHub Releases(システムプロキシに追従)へアクセスし、失敗した場合は国内の git.codingplan.site 更新チャンネル(直接接続)へ自動的に切り替わります。更新パッケージは SHA-512 で検証してからインストールします。更新内容は配信チャンネルの release-notes.md から読み込み、リリースごとに同期されます。v1.0.22 以降 MSI インストーラーは提供せず、NSIS exe に統一されています。',
'cl.eyebrow': 'Changelog',
'cl.title': '最近の更新',
'changelog.1.0.25.li.1': '定期的な更新チェック(しない / 4 / 12 / 24 時間、既定は 4 時間)',
'changelog.1.0.25.li.2': '自動ダウンロードのオン/オフ(バックグラウンドで静かにダウンロード。インストールのタイミングは従来どおりユーザーが決定し、自動で終了することはありません)',
'changelog.1.0.25.li.3': '新バージョンの検出時やダウンロード完了時に、ウィンドウがトレイに最小化されていれば通知を表示',
'changelog.1.0.25.li.4': '同じバージョンで通知やダウンロードを繰り返しません',
'changelog.1.0.24.li.1': '古いレイアウトテンプレートを適用すると SSH セッションがすべて切断される問題を修正',
'changelog.1.0.24.li.2': '設定ファイルが壊れている場合は先にバックアップしてから初期化(コマンドライブラリ、ブックマーク、設定が黙って失われることはなくなりました)',
'changelog.1.0.24.li.3': '終了時にセッションログの末尾が欠けなくなりました',
'changelog.1.0.24.li.4': 'SFTP の削除が誤って失敗扱いにならなくなりました。ZMODEM の大容量受信もメモリ使用量を抑制。レンダラー層のテストを 3 組追加',
'changelog.1.0.23.li.1': 'SFTP ファイルパネルを本格的なテーブルに変更:6 列の整列、ヘッダーの固定表示、ヘッダークリックで並べ替え',
'changelog.1.0.23.li.2': '文字サイズを 0.8〜1.8 倍に変更可能(A- / A+ または Ctrl+ホイール)。行の高さも連動し、設定は記憶されます',
'changelog.1.0.23.li.3': 'ユーザー / グループ列に実際の名前を表示。数字だけの UID / GID ではなくなりました',
'changelog.1.0.23.li.4': '一覧の空白部分を右クリックでファイルメニューを表示。6 列が実際にはグリッドに揃っていなかった問題を修正',
'changelog.1.0.22.li.1': 'IME の変換候補ウィンドウがカーソルに追従しない問題を修正(v1.0.21 のリグレッション)',
'changelog.1.0.22.li.2': 'MSI インストーラーの提供を終了し、NSIS exe に統一',
'changelog.1.0.22.li.3': 'ビルド手順に依存関係の整合性チェックを追加し、古い依存関係が混入しないようにしました',
'changelog.1.0.21.li.1': '背景画像の扱いを改善。どのテーマでも不透明度を下げると画像が暗くなり、文字は WCAG AA まで自動的に明るく調整。減光スライダーも追加',
'changelog.1.0.21.li.2': 'パフォーマンス:設定のホットアップデートで全ペインが再レイアウトされなくなり、SFTP の大規模ディレクトリは仮想スクロール、ペインごとにエラーバウンダリを追加',
'changelog.1.0.21.li.3': 'インストーラーを約 90MB 削減。SFTP / ZMODEM のローカルパス検証と複数のセキュリティ強化',
'cl.morePre': '完全な変更履歴は',
'cl.morePost': ' をご覧ください。',
'footer.copyright': '© 2026 CodingPlan.Site · OpenTerminal は MIT ライセンスで公開されています',
'footer.channel': '更新チャンネル:git.codingplan.site'
}
};
+44
View File
@@ -770,3 +770,47 @@ section { position: relative; }
html { scroll-behavior: auto; }
*, *::before, *::after { transition: none !important; }
}
/* ---- 语言切换(i18n 层新增,只消费既有令牌,不改动上面任何规则) ---- */
.lang-select {
appearance: none;
-webkit-appearance: none;
height: 38px;
padding: 0 30px 0 13px;
border-radius: 19px;
border: 1px solid var(--border-strong);
background-color: var(--surface);
color: var(--text-2);
font-family: var(--mono);
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
/* 下拉箭头:两个主题各一份 data URI(内联 SVG 用不了 currentColor),色值取自 --text-2 */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2399a49c' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 11px center;
background-size: 10px 6px;
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, transform 0.15s ease;
}
.lang-select:hover { color: var(--accent-text); border-color: var(--accent); transform: translateY(-1px); }
.lang-select:active { transform: translateY(0) scale(0.98); }
.lang-select option { background: var(--surface); color: var(--text); }
html[data-theme='light'] .lang-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2359615a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
/* 非中文用户首帧先藏住 body,等 i18n 应用完由 assets/i18n-apply.js 摘掉该属性(另有 2.5s 兜底定时器) */
html[data-i18n-pending] body { visibility: hidden; }
@media (max-width: 640px) {
.lang-select {
height: 32px;
padding: 0 24px 0 10px;
font-size: 11.5px;
background-position: right 8px center;
background-size: 9px 5px;
}
}
+226 -142
View File
@@ -3,45 +3,97 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title>
<meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" />
<title data-i18n="meta.title">OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title>
<meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" data-i18n-attr="content:meta.description" />
<link rel="icon" type="image/png" href="assets/icon.png" />
<link rel="preload" href="assets/fonts/space-grotesk-600.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="assets/fonts/jetbrains-mono-500.woff2" as="font" type="font/woff2" crossorigin />
<meta property="og:title" content="OpenTerminal - 本地终端 / SSH / SFTP 桌面终端" />
<meta property="og:description" content="开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入。MIT 协议,支持自动更新。" />
<meta property="og:title" content="OpenTerminal - 本地终端 / SSH / SFTP 桌面终端" data-i18n-attr="content:meta.ogTitle" />
<meta property="og:description" content="开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入。MIT 协议,支持自动更新。" data-i18n-attr="content:meta.ogDescription" />
<meta property="og:image" content="assets/shot-terminal.jpg" />
<meta name="theme-color" content="#0b0e12" />
<link rel="stylesheet" href="assets/style.css" />
<script>
// 主题必须在首帧前定好,避免闪白/闪黑;默认深色,只有用户显式切过才用保存值
// 主题与界面语言都必须在首帧前定好:前者避免闪白/闪黑,后者避免闪一帧中文。
// 语言优先级(与 assets/i18n-apply.js 一致):?lang= > localStorage > navigator > zh-CN。
(function () {
var theme = 'dark';
try {
if (localStorage.getItem('openterminal.theme') === 'light') theme = 'light';
} catch (e) {}
document.documentElement.dataset.theme = theme;
function normalize(tag) {
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';
return null;
}
function pickLang() {
var m = /[?&]lang=([^&]+)/.exec(location.search);
if (m) {
// 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 {
var fromStore = normalize(localStorage.getItem('openterminal.lang'));
if (fromStore) return fromStore;
} catch (e) {}
var list = (navigator.languages && navigator.languages.length) ? navigator.languages : [navigator.language];
for (var i = 0; i < list.length; i++) {
var hit = normalize(list[i]);
if (hit) return hit;
}
return 'zh-CN';
}
var lang = pickLang();
document.documentElement.lang = lang;
// HTML 源码文案是简体中文:非中文用户先把 body 藏住,等 i18n 应用完再显示。
// 定时器是兜底,保证 i18n 脚本出错时页面也不会一直空着。
if (lang !== 'zh-CN') {
document.documentElement.setAttribute('data-i18n-pending', '');
setTimeout(function () {
document.documentElement.removeAttribute('data-i18n-pending');
}, 2500);
}
})();
</script>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<a class="skip-link" href="#main" data-i18n="a11y.skip">跳到主要内容</a>
<header class="site-nav">
<div class="container nav-inner">
<a class="brand" href="#top">
<img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" />
<img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" data-i18n-attr="alt:brand.iconAlt" />
<span>OpenTerminal</span>
</a>
<div class="nav-right">
<nav class="nav-links" aria-label="站内导航">
<a href="#features">功能</a>
<a href="#download">下载</a>
<a href="#changelog">更新日志</a>
<nav class="nav-links" aria-label="站内导航" data-i18n-attr="aria-label:nav.aria">
<a href="#features" data-i18n="nav.features">功能</a>
<a href="#download" data-i18n="nav.download">下载</a>
<a href="#changelog" data-i18n="nav.changelog">更新日志</a>
<a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a>
</nav>
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题">
<select class="lang-select" id="lang-select" aria-label="界面语言" data-i18n-attr="aria-label:nav.langAria">
<option value="zh-CN">简体中文</option>
<option value="zh-TW">繁體中文</option>
<option value="en">English</option>
<option value="ja">日本語</option>
</select>
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题" data-i18n-attr="aria-label:nav.themeToggle;title:nav.themeToggle">
<svg class="icon-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
@@ -59,32 +111,32 @@
<section class="hero" id="top">
<div class="container hero-grid">
<div class="hero-copy">
<p class="hero-kicker">开源桌面终端 · MIT 协议</p>
<h1>本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用</h1>
<p class="hero-sub">
<p class="hero-kicker" data-i18n="hero.kicker">开源桌面终端 · MIT 协议</p>
<h1 data-i18n-html="hero.title">本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用</h1>
<p class="hero-sub" data-i18n="hero.sub">
OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
</p>
<div class="hero-actions">
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.23/OpenTerminal-1.0.23-setup.exe">
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="hero.download">
下载 Windows 安装包
</a>
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener" data-i18n="hero.source">
查看源码
</a>
</div>
<p class="hero-note">当前版本 <span class="mono">v1.0.23</span> · NSIS exe 安装包,支持应用内自动更新。</p>
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.25</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
</div>
<figure class="hero-shot">
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" />
<figcaption>三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义</figcaption>
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" data-i18n-attr="alt:hero.shot.alt" />
<figcaption data-i18n="hero.shot.caption">三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义</figcaption>
</figure>
</div>
<div class="container spec-strip">
<span>当前版本 <strong class="mono">v1.0.23</strong></span>
<span>Windows 10 / 11 · x64</span>
<span>MIT 开源协议</span>
<span>exe 支持应用内自动更新</span>
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.25</strong></span>
<span data-i18n="spec.os">Windows 10 / 11 · x64</span>
<span data-i18n="spec.license">MIT 开源协议</span>
<span data-i18n="spec.update">exe 支持应用内自动更新</span>
</div>
</section>
@@ -92,178 +144,178 @@
<div class="container">
<div class="section-head">
<div>
<p class="section-eyebrow">Capabilities</p>
<h2>核心能力</h2>
<p class="section-sub">按使用场景划分的能力组,左右拖动或用箭头浏览;完整清单见仓库 README。</p>
<p class="section-eyebrow" data-i18n="features.eyebrow">Capabilities</p>
<h2 data-i18n="features.title">核心能力</h2>
<p class="section-sub" data-i18n="features.sub">按使用场景划分的能力组,左右拖动或用箭头浏览;完整清单见仓库 README。</p>
</div>
<div class="cap-controls">
<button class="track-btn" type="button" data-dir="-1" aria-label="向左浏览能力卡片">
<button class="track-btn" type="button" data-dir="-1" aria-label="向左浏览能力卡片" data-i18n-attr="aria-label:features.prev">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
</button>
<button class="track-btn" type="button" data-dir="1" aria-label="向右浏览能力卡片">
<button class="track-btn" type="button" data-dir="1" aria-label="向右浏览能力卡片" data-i18n-attr="aria-label:features.next">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
</button>
</div>
</div>
<div class="cap-track" id="cap-track" tabindex="0" aria-label="核心能力,可横向滚动">
<div class="cap-track" id="cap-track" tabindex="0" aria-label="核心能力,可横向滚动" data-i18n-attr="aria-label:features.trackAria">
<article class="cap-card">
<p class="cap-num">01</p>
<h3>终端核心</h3>
<h3 data-i18n="cap.01.title">终端核心</h3>
<ul>
<li>node-pty 本地会话,自动探测 PowerShell / zsh / bash</li>
<li>xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级</li>
<li>内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li>
<li>关键词高亮 22 条内置规则,支持自定义正则与三档强度</li>
<li>字体、字重、字距、行高、光标样式实时生效</li>
<li data-i18n="cap.01.li.1">node-pty 本地会话,自动探测 PowerShell / zsh / bash</li>
<li data-i18n="cap.01.li.2">xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级</li>
<li data-i18n="cap.01.li.3">内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li>
<li data-i18n="cap.01.li.4">关键词高亮 22 条内置规则,支持自定义正则与三档强度</li>
<li data-i18n="cap.01.li.5">字体、字重、字距、行高、光标样式实时生效</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">02</p>
<h3>SSH 会话</h3>
<h3 data-i18n="cap.02.title">SSH 会话</h3>
<ul>
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent</li>
<li>多路分屏与广播输入,一次操作下发到多台主机</li>
<li>主机指纹校验、首次连接确认、变更告警</li>
<li>ZMODEM 传输支持(sz / rz)</li>
<li>OSC 7 上报工作目录,新会话按上次位置恢复</li>
<li data-i18n="cap.02.li.1">ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent</li>
<li data-i18n="cap.02.li.2">多路分屏与广播输入,一次操作下发到多台主机</li>
<li data-i18n="cap.02.li.3">主机指纹校验、首次连接确认、变更告警</li>
<li data-i18n="cap.02.li.4">ZMODEM 传输支持(sz / rz)</li>
<li data-i18n="cap.02.li.5">OSC 7 上报工作目录,新会话按上次位置恢复</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">03</p>
<h3>SFTP 文件面板</h3>
<h3 data-i18n="cap.03.title">SFTP 文件面板</h3>
<ul>
<li>列表头列分明:名称、大小、修改时间、权限、用户/组</li>
<li>点击列头排序;文件名解析为真实用户与组名</li>
<li>字体缩放 80%–180%,Ctrl+滚轮或工具栏按钮</li>
<li>上传下载、同名覆盖确认、chmod / chown 编辑</li>
<li>大目录虚拟滚动,上万条目不卡窗口</li>
<li data-i18n="cap.03.li.1">列表头列分明:名称、大小、修改时间、权限、用户/组</li>
<li data-i18n="cap.03.li.2">点击列头排序;文件名解析为真实用户与组名</li>
<li data-i18n="cap.03.li.3">字体缩放 80%–180%,Ctrl+滚轮或工具栏按钮</li>
<li data-i18n="cap.03.li.4">上传下载、同名覆盖确认、chmod / chown 编辑</li>
<li data-i18n="cap.03.li.5">大目录虚拟滚动,上万条目不卡窗口</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">04</p>
<h3>工作区与布局</h3>
<h3 data-i18n="cap.04.title">工作区与布局</h3>
<ul>
<li>dockview 分屏:任意拆分、拖拽、合并与浮动</li>
<li>布局模板保存与一键套用</li>
<li>启动恢复上次会话布局</li>
<li>界面缩放 50% 至 150%</li>
<li data-i18n="cap.04.li.1">dockview 分屏:任意拆分、拖拽、合并与浮动</li>
<li data-i18n="cap.04.li.2">布局模板保存与一键套用</li>
<li data-i18n="cap.04.li.3">启动恢复上次会话布局</li>
<li data-i18n="cap.04.li.4">界面缩放 50% 至 150%</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">05</p>
<h3>主题与外观</h3>
<h3 data-i18n="cap.05.title">主题与外观</h3>
<ul>
<li>主题编辑器:22 个颜色位逐项调整,实时预览</li>
<li>内置 11 套主题,跟随系统明暗自动切换</li>
<li>自定义背景图片、不透明度与压暗层</li>
<li>标签强调色、高亮配色跟随主题</li>
<li data-i18n="cap.05.li.1">主题编辑器:22 个颜色位逐项调整,实时预览</li>
<li data-i18n="cap.05.li.2">内置 11 套主题,跟随系统明暗自动切换</li>
<li data-i18n="cap.05.li.3">自定义背景图片、不透明度与压暗层</li>
<li data-i18n="cap.05.li.4">标签强调色、高亮配色跟随主题</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">06</p>
<h3>效率工具</h3>
<h3 data-i18n="cap.06.title">效率工具</h3>
<ul>
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li>高亮规则集按主机绑定,不同服务器不同配色</li>
<li>规则导入导出、实时预览与命中统计</li>
<li>会话快照与恢复、终端标签自动命名</li>
<li data-i18n="cap.06.li.1">命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li data-i18n="cap.06.li.2">高亮规则集按主机绑定,不同服务器不同配色</li>
<li data-i18n="cap.06.li.3">规则导入导出、实时预览与命中统计</li>
<li data-i18n="cap.06.li.4">会话快照与恢复、终端标签自动命名</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">07</p>
<h3>系统监控</h3>
<h3 data-i18n="cap.07.title">系统监控</h3>
<ul>
<li>CPU、负载、内存、磁盘、网络实时面板</li>
<li>每核占用与磁盘分区明细</li>
<li>运行时长与网络速率曲线</li>
<li>随会话打开,不额外占用通道</li>
<li data-i18n="cap.07.li.1">CPU、负载、内存、磁盘、网络实时面板</li>
<li data-i18n="cap.07.li.2">每核占用与磁盘分区明细</li>
<li data-i18n="cap.07.li.3">运行时长与网络速率曲线</li>
<li data-i18n="cap.07.li.4">随会话打开,不额外占用通道</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">08</p>
<h3>锁屏与安全</h3>
<h3 data-i18n="cap.08.title">锁屏与安全</h3>
<ul>
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li>密码经 scrypt 加盐哈希存储,不落盘明文</li>
<li>输错触发递增冷却;锁定期间禁用菜单与刷新快捷键</li>
<li>锁屏只是遮罩:背后会话与传输不中断</li>
<li data-i18n="cap.08.li.1">Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li data-i18n="cap.08.li.2">密码经 scrypt 加盐哈希存储,不落盘明文</li>
<li data-i18n="cap.08.li.3">输错触发递增冷却;锁定期间禁用菜单与刷新快捷键</li>
<li data-i18n="cap.08.li.4">锁屏只是遮罩:背后会话与传输不中断</li>
</ul>
</article>
</div>
<p class="cap-hint">← 拖动浏览 · 共 8 组能力</p>
<p class="cap-hint" data-i18n="cap.hint">← 拖动浏览 · 共 8 组能力</p>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-agents.png" alt="OpenTerminal 多路分屏运行 Kimi Code、Claude Code、Codex 等 AI 编程 Agent" width="1296" height="870" />
<img src="assets/shot-agents.png" alt="OpenTerminal 多路分屏运行 Kimi Code、Claude Code、Codex 等 AI 编程 Agent" width="1296" height="870" data-i18n-attr="alt:fw.shot.alt" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Workbench</p>
<h3>AI Agent 工作台</h3>
<p class="eyebrow" data-i18n="fw.eyebrow">Workbench</p>
<h3 data-i18n="fw.title">AI Agent 工作台</h3>
<ul>
<li>多路分屏并行运行 Kimi Code、Claude Code、Codex 等 AI 编程工具</li>
<li>为高频刷新的 TUI 界面优化:终端尺寸去抖同步,避免反复重绘</li>
<li>中文输入法候选窗紧贴光标,TUI 内输入中文不再漂移</li>
<li>关键词高亮自动标出错误、警告、HTTP 状态码与百分比</li>
<li data-i18n="fw.li.1">多路分屏并行运行 Kimi Code、Claude Code、Codex 等 AI 编程工具</li>
<li data-i18n="fw.li.2">为高频刷新的 TUI 界面优化:终端尺寸去抖同步,避免反复重绘</li>
<li data-i18n="fw.li.3">中文输入法候选窗紧贴光标,TUI 内输入中文不再漂移</li>
<li data-i18n="fw.li.4">关键词高亮自动标出错误、警告、HTTP 状态码与百分比</li>
</ul>
</div>
</div>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-sftp.png" alt="OpenTerminal SFTP 文件面板:列表头、可排序列、完整修改时间与用户/组" width="1282" height="801" />
<img class="narrow" src="assets/shot-sftp-menu.png" alt="OpenTerminal 在文件列表空白处右键弹出同款文件菜单" width="1282" height="801" />
<figcaption>列表空白处右键同样是完整文件菜单,行内右键则作用于所选条目</figcaption>
<img src="assets/shot-sftp.png" alt="OpenTerminal SFTP 文件面板:列表头、可排序列、完整修改时间与用户/组" width="1282" height="801" data-i18n-attr="alt:fs.shot.alt" />
<img class="narrow" src="assets/shot-sftp-menu.png" alt="OpenTerminal 在文件列表空白处右键弹出同款文件菜单" width="1282" height="801" data-i18n-attr="alt:fs.shot.alt2" />
<figcaption data-i18n="fs.caption">列表空白处右键同样是完整文件菜单,行内右键则作用于所选条目</figcaption>
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">SFTP</p>
<h3>远程文件面板</h3>
<p class="eyebrow" data-i18n="fs.eyebrow">SFTP</p>
<h3 data-i18n="fs.title">远程文件面板</h3>
<ul>
<li>列表头列分明:名称、大小、修改时间、权限、用户/组,点击列头排序</li>
<li>文件名解析为真实用户与组名(root/root、eveuser/eveuser),不再只有数字</li>
<li>字体缩放 80%–180%:Ctrl+滚轮或工具栏按钮,行高与字号联动并记忆</li>
<li>上传 / 下载、同名覆盖确认、chmod 权限与 chown 属主编辑</li>
<li>大目录虚拟滚动,上万条目不卡窗口</li>
<li data-i18n="fs.li.1">列表头列分明:名称、大小、修改时间、权限、用户/组,点击列头排序</li>
<li data-i18n="fs.li.2">文件名解析为真实用户与组名(root/root、eveuser/eveuser),不再只有数字</li>
<li data-i18n="fs.li.3">字体缩放 80%–180%:Ctrl+滚轮或工具栏按钮,行高与字号联动并记忆</li>
<li data-i18n="fs.li.4">上传 / 下载、同名覆盖确认、chmod 权限与 chown 属主编辑</li>
<li data-i18n="fs.li.5">大目录虚拟滚动,上万条目不卡窗口</li>
</ul>
</div>
</div>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-themes.jpg" alt="OpenTerminal 主题设置:背景图片、不透明度、压暗层与主题画廊" width="1739" height="870" />
<img src="assets/shot-themes.jpg" alt="OpenTerminal 主题设置:背景图片、不透明度、压暗层与主题画廊" width="1739" height="870" data-i18n-attr="alt:fc.shot.alt" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Customize</p>
<h3>定制与效率</h3>
<p class="eyebrow" data-i18n="fc.eyebrow">Customize</p>
<h3 data-i18n="fc.title">定制与效率</h3>
<ul>
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层</li>
<li>内置 11 套主题,跟随系统明暗自动切换</li>
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li>启动恢复上次会话布局;界面缩放 50% 至 150%</li>
<li>四语言界面:简体中文、繁体中文、English、日本語</li>
<li data-i18n="fc.li.1">主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层</li>
<li data-i18n="fc.li.2">内置 11 套主题,跟随系统明暗自动切换</li>
<li data-i18n="fc.li.3">命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li data-i18n="fc.li.4">启动恢复上次会话布局;界面缩放 50% 至 150%</li>
<li data-i18n="fc.li.5">四语言界面:简体中文、繁体中文、English、日本語</li>
</ul>
</div>
</div>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-lock.png" alt="OpenTerminal 锁屏界面:不透明遮罩与密码解锁" width="1739" height="870" />
<img src="assets/shot-lock.png" alt="OpenTerminal 锁屏界面:不透明遮罩与密码解锁" width="1739" height="870" data-i18n-attr="alt:fl.shot.alt" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Security</p>
<h3>锁屏与安全</h3>
<p class="eyebrow" data-i18n="fl.eyebrow">Security</p>
<h3 data-i18n="fl.title">锁屏与安全</h3>
<ul>
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li>锁定期间禁用应用菜单、页面刷新与开发者工具快捷键</li>
<li>密码经 scrypt 加盐哈希存储,不落盘明文;输错触发递增冷却</li>
<li>锁屏只是遮罩:背后的终端会话与文件传输保持运行不中断</li>
<li data-i18n="fl.li.1">Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li data-i18n="fl.li.2">锁定期间禁用应用菜单、页面刷新与开发者工具快捷键</li>
<li data-i18n="fl.li.3">密码经 scrypt 加盐哈希存储,不落盘明文;输错触发递增冷却</li>
<li data-i18n="fl.li.4">锁屏只是遮罩:背后的终端会话与文件传输保持运行不中断</li>
</ul>
</div>
</div>
@@ -272,39 +324,44 @@
<section class="download" id="download">
<div class="container">
<p class="section-eyebrow">Download</p>
<h2>下载</h2>
<p class="section-sub">
从 GitHub Releases 下载;全部历史版本在同一页面。
<p class="section-eyebrow" data-i18n="dl.eyebrow">Download</p>
<h2 data-i18n="dl.title">下载</h2>
<!-- 发新版本时更新下方 exe 直链与 hero 的 id=dl-exe 链接 -->
<p class="section-sub" data-i18n="dl.sub">
从 GitHub Releases 下载;全部历史版本在同一页面。
</p>
<table class="dl-table">
<thead>
<tr><th>安装包</th><th>适用场景</th><th>大小</th><th>链接</th></tr>
<tr>
<th data-i18n="dl.th.pkg">安装包</th>
<th data-i18n="dl.th.scene">适用场景</th>
<th data-i18n="dl.th.size">大小</th>
<th data-i18n="dl.th.link">链接</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">OpenTerminal-1.0.23-setup.exe</td>
<td>NSIS 安装包,支持应用内自动更新(推荐)</td>
<td class="mono">OpenTerminal-1.0.25-setup.exe</td>
<td data-i18n="dl.table.exe">NSIS 安装包,支持应用内自动更新(推荐)</td>
<td class="mono">109 MB</td>
<td>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.23/OpenTerminal-1.0.23-setup.exe">下载</a>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="dl.table.download">下载</a>
</td>
</tr>
<tr>
<td class="mono">历史版本</td>
<td>全部发布记录与更新说明</td>
<td class="mono" data-i18n="dl.table.history">历史版本</td>
<td data-i18n="dl.table.historyScene">全部发布记录与更新说明</td>
<td class="mono">-</td>
<td>
<a href="https://github.com/billowliu2/OpenTerminal/releases" target="_blank" rel="noopener">releases 列表</a>
<a href="https://github.com/billowliu2/OpenTerminal/releases" target="_blank" rel="noopener" data-i18n="dl.table.releases">releases 列表</a>
</td>
</tr>
</tbody>
</table>
<div class="update-note">
<h3>自动更新说明</h3>
<p>
<h3 data-i18n="dl.note.title">自动更新说明</h3>
<p data-i18n="dl.note.body">
应用内检查更新优先访问 GitHub Releases(跟随系统代理),失败时自动回退国内 git.codingplan.site 更新通道(直连)。
更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。
自 v1.0.22 起不再提供 MSI 安装包,统一使用 NSIS exe。
@@ -315,44 +372,56 @@
<section class="changelog" id="changelog">
<div class="container">
<p class="section-eyebrow">Changelog</p>
<h2>近期更新</h2>
<p class="section-eyebrow" data-i18n="cl.eyebrow">Changelog</p>
<h2 data-i18n="cl.title">近期更新</h2>
<div class="changelog-list">
<article>
<h3><span class="mono">1.0.25</span><span class="rel-date">2026-10-10</span></h3>
<ul>
<li data-i18n="changelog.1.0.25.li.1">定时检查更新(从不 / 4 / 12 / 24 小时,默认 4)</li>
<li data-i18n="changelog.1.0.25.li.2">自动下载更新开关(后台静默下载,安装时机仍由用户决定,绝不自动退出)</li>
<li data-i18n="changelog.1.0.25.li.3">下载完成 / 发现新版本时窗口最小化到托盘则弹提示</li>
<li data-i18n="changelog.1.0.25.li.4">同版本不重复提示或重复下载</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.24</span><span class="rel-date">2026-10-09</span></h3>
<ul>
<li data-i18n="changelog.1.0.24.li.1">修复应用旧版布局模板会误杀全部 SSH 会话</li>
<li data-i18n="changelog.1.0.24.li.2">配置文件损坏时先备份再重置(不再静默销毁命令库 / 书签 / 设置)</li>
<li data-i18n="changelog.1.0.24.li.3">会话日志退出不再丢尾部</li>
<li data-i18n="changelog.1.0.24.li.4">SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.23</span><span class="rel-date">2026-10-09</span></h3>
<ul>
<li>SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
<li>字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
<li>用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
<li>列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
<li data-i18n="changelog.1.0.23.li.1">SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
<li data-i18n="changelog.1.0.23.li.2">字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
<li data-i18n="changelog.1.0.23.li.3">用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
<li data-i18n="changelog.1.0.23.li.4">列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.22</span><span class="rel-date">2026-10-08</span></h3>
<ul>
<li>修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
<li>不再提供 MSI 安装包,统一使用 NSIS exe</li>
<li>打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
<li data-i18n="changelog.1.0.22.li.1">修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
<li data-i18n="changelog.1.0.22.li.2">不再提供 MSI 安装包,统一使用 NSIS exe</li>
<li data-i18n="changelog.1.0.22.li.3">打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.21</span><span class="rel-date">2026-10-07</span></h3>
<ul>
<li>背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
<li>性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
<li>安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.20</span><span class="rel-date">2026-10-01</span></h3>
<ul>
<li>修复中文输入法候选窗不跟随光标</li>
<li>锁屏加固:锁定期间移除应用菜单,Ctrl+L 按键判定重构并补测试</li>
<li data-i18n="changelog.1.0.21.li.1">背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
<li data-i18n="changelog.1.0.21.li.2">性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
<li data-i18n="changelog.1.0.21.li.3">安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
</ul>
</article>
</div>
<p class="changelog-more">
完整日志见仓库 <a href="https://github.com/billowliu2/OpenTerminal/blob/main/CHANGELOG.md" target="_blank" rel="noopener">CHANGELOG.md</a>。
<span data-i18n="cl.morePre">完整日志见仓库</span>
<a href="https://github.com/billowliu2/OpenTerminal/blob/main/CHANGELOG.md" target="_blank" rel="noopener">CHANGELOG.md</a><span data-i18n="cl.morePost">。</span>
</p>
</div>
</section>
@@ -361,11 +430,13 @@
<footer class="site-footer">
<div class="container footer-inner">
<span>© 2026 CodingPlan.Site · OpenTerminal 以 MIT 协议开源</span>
<span class="mono">更新通道:git.codingplan.site</span>
<span data-i18n="footer.copyright">© 2026 CodingPlan.Site · OpenTerminal 以 MIT 协议开源</span>
<span class="mono" data-i18n="footer.channel">更新通道:git.codingplan.site</span>
</div>
</footer>
<script src="assets/i18n.js"></script>
<script src="assets/i18n-apply.js"></script>
<script>
(function () {
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -461,10 +532,20 @@
// 明暗主题切换
var toggle = document.getElementById('theme-toggle');
var meta = document.querySelector('meta[name="theme-color"]');
function themeLabel(key, fallback) {
var dict = (window.OPENTERMINAL_I18N || {})[document.documentElement.lang];
return (dict && dict[key]) || fallback;
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
if (meta) meta.setAttribute('content', theme === 'light' ? '#fafbf9' : '#0b0e12');
if (toggle) toggle.setAttribute('aria-label', theme === 'light' ? '切换到深色主题' : '切换到浅色主题');
if (toggle) {
var label = theme === 'light'
? themeLabel('nav.themeToDark', '切换到深色主题')
: themeLabel('nav.themeToLight', '切换到浅色主题');
toggle.setAttribute('aria-label', label);
toggle.setAttribute('title', label);
}
}
if (toggle) {
toggle.addEventListener('click', function () {
@@ -474,6 +555,9 @@
});
}
applyTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
document.addEventListener('openterminal:langchange', function () {
applyTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
});
})();
</script>