Compare commits

..
2 Commits
Author SHA1 Message Date
Bill 244199c512 chore: release v1.0.17
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-09-24 22:16:49 +08:00
Bill 35583b2c15 feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window):
- scrypt password verifier in <userData>/lock.json (per-write salt,
  timingSafeEqual); salt/hash/password never leave the main process
- lock now / idle auto-lock / lock at startup, growing failure cooldown,
  lock flags persisted so a quit-and-relaunch cannot bypass the lock
- locked shell and body portals go inert while sessions keep running;
  menu accelerators (reload, DevTools, zoom) are swallowed while locked
- settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja

Security and stability:
- packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv)
- renderer preload runs with sandbox: true
- unreadable known_hosts store fails closed instead of being overwritten
- connect-time secrets gated by the bookmark's auth method (connectPromptFor)
- ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once
- sysinfo polling is refcounted for split panes (forceStopPolling on close)
- session-log index entries are path-contained; settings store writes
  atomically with EPERM/EBUSY retry
- sync-changelog tolerates CRLF checkouts (was a silent no-op)
- retry ssh2 host-key generation (flaky malformed key, ~1/500)

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
2026-09-24 22:16:43 +08:00
52 changed files with 3150 additions and 107 deletions

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: typecheck + test + build (windows)
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 10 个离线测试
- name: Test
run: npm test
# 仅验证构建通过;不做 electron-builder 打包、不发布、不传 artifact
- name: Build
run: npm run build
+3
View File
@@ -24,6 +24,9 @@ research/
.sftp-dl-*
tests/.commands-store.cjs
tests/.known-hosts.cjs
tests/.lock-store.cjs
tests/.lock-controller.cjs
tests/.settings-store.cjs
tests/.session-e2e.cjs
tests/.hl-split-smoke.cjs
+16 -2
View File
@@ -7,8 +7,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
- 测试:`npm test`(先 `node tests/build-bundles.cjs` 重建 esbuild bundle,再依次跑可离线运行的 7 个测试;真实服务器测试需 JD_* 凭据,不在此列)
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 10 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 10 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次),产物在 `release/`(msi + exe + latest.yml + blockmap)
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
## 仓库与远程
@@ -52,6 +53,19 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
## 锁屏
- 只使用**主窗口内的不透明遮罩**(`src/renderer/src/lock/LockScreen.tsx` + `lock.css` 的 `.lock-screen`,z-index 4000),**不创建第二个 Electron 窗口**。锁定时 `App.tsx` 把 `.app-root` 设为 `inert` 并**保持挂载**——卸载会杀掉遮罩后面的本地/SSH 会话与传输列表;antd portal(Modal/Dropdown/Tooltip)挂在 `document.body` 上、不在 `#root` 内,锁定时**要把 body 下 `#root` 以外的子节点也设为 `inert`**,否则键盘 Tab 仍能走进遮罩后面的浮层
- 密码 verifier 在 `<userData>/lock.json`(`src/main/lockStore.ts` 的 `LockStore`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,**不存明文**;缺 `version: 1`、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 `version` 不认识会**每进程 warn 一次**——将来改格式不许静默失锁
- 锁状态由主进程独占(`src/main/lockController.ts`):`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,**salt/hash/密码永不出主进程**,渲染层从不自行判定锁定
- 锁标志落盘在 `<userData>/lock-state.json`(`LockStateStore`),**locked/failures/cooldownUntil 每次变化立即写**,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——`lockAtStartup` 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 `cooldownUntil` 夹紧到 `now+30s`,防系统时间回拨导致永久锁死
- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层另有一道 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
## 关键词高亮
- 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过**
+22
View File
@@ -1,5 +1,27 @@
# OpenTerminal Changelog
## v1.0.17 - 2026-09-24
- **New Settings → Lock tab**: set, change or remove a lock password (stored on this machine only, scrypt one-way hash — no plaintext, no account system)
- **Three ways to lock**: lock now, auto-lock after the system is idle for a chosen time (1/5/15/30/60 minutes, off by default), and lock at startup
- The mask is fully opaque; terminal sessions keep running behind it and stay connected — unlocking restores everything
- Wrong attempts enter a growing cooldown (1s→30s) to stop guessing at the keyboard
- **The lock survives quitting and relaunching**: tray exit, task-manager kill or a crash do not get around it
- While locked, reload (Ctrl+R / F5), DevTools and zoom shortcuts are disabled, so the sessions behind the mask cannot be killed by accident
- A forgotten password cannot be recovered; the only way back in is deleting this machine's `%APPDATA%\OpenTerminal\lock.json` and setting a new one
- Packaged builds no longer honour `ELECTRON_RENDERER_URL` / `OT_UPDATE_URL`, closing an injection path that could point the app or its update feed at a hostile address
- The renderer preload runs sandboxed again
- An unreadable or corrupt known-hosts store now refuses connections and asks for the file to be repaired or deleted, instead of silently overwriting every pinned host fingerprint
- Connect-time password/passphrase prompts now match the bookmark's auth method (a key/agent bookmark no longer pops a password dialog or offers a leftover stored password)
- Fixed missing or duplicate session-exit broadcasts when an SSH connection dies — the terminal no longer hangs on "connecting"
- Closing one split-pane monitor no longer stops the sibling pane's polling
- Session-log index entries are path-validated, so a tampered index cannot write outside the log directory
- Settings are saved atomically, with a retry for Windows file-lock errors
- Added CI (GitHub Actions): typecheck + 10 offline tests + build
- The freeze-detection log no longer reports timer throttling as a ~1-minute "stall" while the window is hidden in the tray
## v1.0.16 - 2026-09-23
- **Built-in rules 11 → 22**: new dangerous/destructive commands (`rm -rf`, `mkfs`, `dd if=`, `chmod 777`, `drop database`, piping into `sh`, …), suspected secret/token reminders, delete/move/overwrite operations, create operations, log levels, exit codes, HTTP status codes, durations (ms), percentage progress, network & IP addresses, timestamps, `root@` prompts, shell keywords, quoted strings, environment variables and more
+22
View File
@@ -1,5 +1,27 @@
# OpenTerminal 更新履歴
## v1.0.17 - 2026-09-24
- **設定 → ロック を新設**:ロック画面のパスワードを設定・変更・削除できます(このマシンにのみ保存、scrypt 一方向ハッシュ。平文なし・アカウント体系なし)
- **3 つのロック方法**:今すぐロック、システムが一定時間アイドルで自動ロック(1/5/15/30/60 分、既定はオフ)、起動時にロック
- ロック中は完全に不透明なマスクでウィンドウを覆い、裏で動くターミナルセッションは切断されません。ロック解除すると元の状態に戻ります
- 誤入力には段階的なクールダウン(1 秒→30 秒)で総当たりを防止します
- **アプリを終了して再起動してもロックは維持**されます。トレイ終了・タスクマネージャー強制終了・クラッシュでも回避できません
- ロック中は Ctrl+R / F5 の再読み込み・開発者ツール・ズームのショートカットを無効化し、マスクの裏のセッションを誤って終了させないようにします
- パスワードを忘れた場合の復元はできません。このマシンの `%APPDATA%\OpenTerminal\lock.json` を削除して再設定する以外に方法はありません
- パッケージ版は `ELECTRON_RENDERER_URL` / `OT_UPDATE_URL` 環境変数を無視するようにしました(アプリや更新チャネルを悪意あるアドレスへ向ける注入経路を遮断)
- レンダラーの preload を再びサンドボックス化
- known_hosts が読み取れない/壊れている場合は接続を拒否し、ファイルの修復または削除を求めます(固定済みフィンガープリントの全消去を防ぐため)
- 接続時のパスワード/パスフレーズ入力をブックマークの認証方式と統一(鍵/エージェント認証のブックマークでパスワードダイアログが出たり、残った保存済みパスワードが送られたりしない)
- SSH 接続が異常切断されたときのセッション終了通知の欠落・重複を修正(ターミナルが「接続中」のまま固まるのを防ぐ)
- 分割ペインのシステムモニターで、片方を閉じてももう片方の監視が止まらないようにしました
- セッションログのインデックスにパス検証を追加し、改ざされたインデックスがログディレクトリの外へ書き込むのを防止
- 設定の保存をアトミック書き込みに変更し、Windows でファイルが占有されている場合にリトライします
- CI(GitHub Actions)を追加:型チェック + 10 個のオフラインテスト + ビルド
- ウィンドウをトレイに隠している間、凍結検出ログがタイマー絞り込みを約 1 分の「フリーズ」と誤報告しないようにしました
## v1.0.16 - 2026-09-23
- **内蔵ルール 11 件 → 22 件**:危険/破壊的なコマンド(`rm -rf`、`mkfs`、`dd if=`、`chmod 777`、`drop database`、`sh` へのパイプなど)、疑わしいシークレット/トークンの警告、削除/移動/上書き操作、作成操作、ログレベル、終了コード、HTTP ステータスコード、処理時間(ミリ秒)、パーセント進捗、ネットワークと IP アドレス、日時、`root@` プロンプト、Shell キーワード、引用文字列、環境変数などを追加
+22
View File
@@ -1,5 +1,27 @@
# OpenTerminal 更新日志
## v1.0.17 - 2026-09-24
- **新增 设置 → 锁屏 选项卡**:可设置、修改、移除锁屏密码(仅存本机,scrypt 单向哈希,不存明文、无账号体系)
- **三种锁定方式**:立即锁屏、系统闲置指定时长后自动锁定(1/5/15/30/60 分钟,默认关闭)、启动时锁屏
- 锁定后以完全不透明的遮罩覆盖窗口,背后的终端会话保持运行、不会断开,解锁后恢复原状
- 密码输错进入渐进冷却(1 秒→30 秒),防止在键盘前暴力猜测
- **退出并重新启动应用后锁定仍然生效**:托盘退出、任务管理器强杀、崩溃都绕不过锁屏
- 锁定期间停用 Ctrl+R / F5 重新加载、开发者工具与缩放快捷键,避免误触终止遮罩背后的会话
- 忘记密码无法找回,只能删除本机 `%APPDATA%\OpenTerminal\lock.json` 后重新设置
- 打包版本不再读取 `ELECTRON_RENDERER_URL` / `OT_UPDATE_URL` 环境变量,阻断把应用或更新通道指向恶意地址的注入路径
- 渲染进程 preload 重新启用沙箱隔离
- known_hosts 储存无法读取或损坏时改为拒绝连接、提示修复或删除文件,不再静默覆盖全部已固定的主机指纹
- 连接时的密码/口令输入与书签的认证方式统一(密钥/agent 认证的书签不再弹出密码框、不再送出残留的已存密码)
- 修复 SSH 连接异常中断时会话退出通知漏发或重发的问题,终端不再卡在「连接中」
- 分割面板的系统监控在一侧关闭后不再误停另一侧的监控
- 会话日志索引加入路径校验,防止被篡改的索引把日志写到目录之外
- 设置保存改为原子写入,Windows 下文件被占用时自动重试,修复偶发的设置保存失败
- 新增 CI(GitHub Actions):类型检查 + 10 个离线测试 + 构建
- 窗口隐藏到托盘时,冻结检测日志不再把定时器节流误报为约 1 分钟的「卡死」
## v1.0.16 - 2026-09-23
- **内置规则 11 条 → 22 条**:新增危险/破坏性命令(`rm -rf`、`mkfs`、`dd if=`、`chmod 777`、`drop database`、管道给 `sh` 等)、疑似密钥/令牌提醒、删除/移动/覆盖操作、新建/创建操作、日志级别、退出码、HTTP 状态码、耗时(毫秒)、百分比进度、网络与 IP 地址、时间日期、`root@` 提示符、Shell 关键字、字符串、环境变量等
+22
View File
@@ -1,5 +1,27 @@
# OpenTerminal 更新日誌
## v1.0.17 - 2026-09-24
- **新增 設定 → 鎖定 分頁**:可設定、修改、移除鎖定畫面密碼(僅存於本機、scrypt 單向雜湊,不存明文、無帳號體系)
- **三種鎖定方式**:立即鎖定、系統閒置指定時長後自動鎖定(1/5/15/30/60 分鐘,預設關閉)、啟動時鎖定
- 鎖定後以完全不透明的遮罩覆蓋視窗,背後的終端機工作階段保持執行、不會中斷,解鎖後恢復原狀
- 密碼輸錯進入漸進冷卻(1 秒→30 秒),防止在鍵盤前暴力猜測
- **結束並重新啟動應用後鎖定仍然生效**:系統匣退出、工作管理員強制結束、當機都繞不過鎖定畫面
- 鎖定期間停用 Ctrl+R / F5 重新載入、開發者工具與縮放快捷鍵,避免誤觸終止遮罩背後的工作階段
- 忘記密碼無法找回,只能刪除本機 `%APPDATA%\OpenTerminal\lock.json` 後重新設定
- 打包版本不再讀取 `ELECTRON_RENDERER_URL` / `OT_UPDATE_URL` 環境變數,阻斷把應用或更新通道指向惡意位址的注入路徑
- 繪製程序 preload 重新啟用沙箱隔離
- known_hosts 儲存無法讀取或損壞時改為拒絕連線、提示修復或刪除檔案,不再靜默覆蓋全部已固定的主機指紋
- 連線時的密碼/通關密語輸入與書籤的認證方式統一(金鑰/agent 認證的書籤不再彈出密碼框、不再送出殘留的已存密碼)
- 修正 SSH 連線異常中斷時工作階段結束通知漏發或重發的問題,終端機不再卡在「連線中」
- 分割面板的系統監控在一側關閉後不再誤停另一側的監控
- 工作階段日誌索引加入路徑校驗,防止被竄改的索引把日誌寫到目錄之外
- 設定儲存改為原子寫入,Windows 下檔案被佔用時自動重試,修正偶發的設定儲存失敗
- 新增 CI(GitHub Actions):型別檢查 + 10 個離線測試 + 建置
- 視窗隱藏到系統匣時,凍結偵測日誌不再把計時器節流誤報為約 1 分鐘的「當機」
## v1.0.16 - 2026-09-23
- **內建規則 11 條 → 22 條**:新增危險/破壞性命令(`rm -rf`、`mkfs`、`dd if=`、`chmod 777`、`drop database`、管線給 `sh` 等)、疑似金鑰/權杖提醒、刪除/移動/覆蓋操作、新建/建立操作、日誌級別、結束代碼、HTTP 狀態碼、耗時(毫秒)、百分比進度、網路與 IP 位址、時間日期、`root@` 提示字元、Shell 關鍵字、字串、環境變數等
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.16",
"version": "1.0.17",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
@@ -12,7 +12,9 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
"pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
"predist": "npm test",
"dist": "electron-vite build && electron-builder --win msi nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
},
+17 -3
View File
@@ -51,13 +51,27 @@ const sync = ({ notes, changelog, header, required }) => {
return
}
const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${body}\n`
// The header pattern must match the file's own newlines: a checkout with
// core.autocrlf=true leaves these files CRLF, and an `\n`-only pattern then
// matches nothing — the write below becomes a silent no-op that still logs
// success (exactly what bit the v1.0.17 sync). The inserted section follows
// the file's dominant ending so it does not create mixed line endings.
const nl = existing.includes('\r\n') ? '\r\n' : '\n'
const section =
`## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}${nl}${nl}` +
`${body.split('\n').join(nl)}${nl}`
// Function replacement, not a string: a notes body containing `$&`, `$1`, `` $` ``
// or `$'` would otherwise be expanded by String.replace and corrupt the merge.
const updated = existing
? existing.replace(new RegExp(`^(${header}\\n\\n)`), (_m, head) => `${head}${section}\n`)
: `${header}\n\n${section}`
? existing.replace(new RegExp(`^(${header}\\r?\\n\\r?\\n)`), (_m, head) => `${head}${section}${nl}`)
: `${header}${nl}${nl}${section}`
fs.writeFileSync(changelogPath, updated, 'utf8')
// Belt and braces: the header replace is the only thing that places the
// section, so prove it landed instead of trusting the pattern.
if (!fs.readFileSync(changelogPath, 'utf8').includes(`## v${pkgVersion} `)) {
console.error(`${changelog}: header pattern did not match — section was NOT inserted`)
process.exit(1)
}
console.log(`${changelog}: added v${pkgVersion} (${body.split('\n').length} lines)`)
}
+68 -2
View File
@@ -20,9 +20,9 @@
import { app, shell } from 'electron'
import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync } from 'fs'
import { appendFile } from 'fs/promises'
import { join } from 'path'
import { basename, dirname, join, resolve, sep } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands'
import { DEFAULT_SETTINGS } from '../shared/settings'
import { loadSettings } from './settingsStore'
@@ -236,6 +236,9 @@ export class CommandsStore {
typeof (x as SessionLogMeta).file === 'string'
) {
const meta = x as SessionLogMeta
// index.json is data, not trust: a tampered or hand-edited `file`
// must never turn logWrite into an arbitrary-path append.
if (!this.isLoggableFile(meta.file)) continue
this.metasByFile.set(meta.file, meta)
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
}
@@ -245,6 +248,69 @@ export class CommandsStore {
}
}
/**
* True when `file` resolves to a regular file inside the logs directory.
*
* Applied to every entry hydrated from index.json before it can ever reach
* logWrite. `..` segments collapse via resolve(); containment is compared
* case-insensitively on Windows so drive-letter or name-case spelling cannot
* sneak a path past it. Symlinks are followed (realpathSync): an entry that
* resolves outside the logs dir is dropped, and a path that exists but is
* not a regular file (a directory, a device) is dropped too. A path that is
* simply not on disk yet stays eligible — appendFile creates it lazily, and
* the directory it would land in is still resolved.
*/
private isLoggableFile(file: string): boolean {
if (file.length === 0) return false
const dirReal = this.logsDirReal()
let target: string
let exists = true
try {
target = realpathSync(file)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
// Not on disk yet: appendFile would create it, so the directory it would
// land in is what decides containment. Judging the literal path alone
// would let a symlinked subdirectory point the append outside the dir.
target = this.pendingPathReal(file)
exists = false
}
if (exists) {
try {
if (!statSync(target).isFile()) return false
} catch {
// Vanished between realpath and stat: appends would recreate it, and
// the containment check below already passed for this path.
}
}
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
const dirN = norm(dirReal)
return norm(target).startsWith(dirN + sep)
}
/** Real path of the logs dir; falls back to resolve() when it does not exist yet. */
private logsDirReal(): string {
try {
return realpathSync(this.logsDir)
} catch {
return resolve(this.logsDir)
}
}
/**
* Where a log file that is not on disk yet would actually be written: its
* parent resolved through any symlinks, the leaf kept as written (it does not
* exist, so it has nothing to resolve). Falls back to the literal resolve()
* when the parent is missing as well — the containment check then decides.
*/
private pendingPathReal(file: string): string {
try {
return join(realpathSync(dirname(file)), basename(file))
} catch {
return resolve(file)
}
}
/** Write the full log index so listSessionLogs survives restart. */
private persistIndex(): void {
try {
+28
View File
@@ -0,0 +1,28 @@
import { app } from 'electron'
/**
* Dev-only environment overrides. A packaged build must ignore these variables
* even when they are present in its environment: whoever can inject env vars
* into a launch (a wrapper script, a shortcut, malware with user rights) could
* otherwise point the renderer — and with it the IPC trust check — at a remote
* origin, or redirect the update feed to a hostile server.
*/
/**
* Vite dev server URL, or undefined when the packaged renderer file must be
* loaded. Packaged builds never honor ELECTRON_RENDERER_URL.
*/
export function devRendererUrl(): string | undefined {
if (app.isPackaged) return undefined
return process.env['ELECTRON_RENDERER_URL'] || undefined
}
/**
* Custom update feed URL for development, or undefined to use the production
* Gitea feed. Packaged builds never honor OT_UPDATE_URL (OT_UPDATE_TOKEN is
* unrelated and still read from the environment in every build).
*/
export function devUpdateFeedUrl(): string | undefined {
if (app.isPackaged) return undefined
return process.env['OT_UPDATE_URL'] || undefined
}
+50 -2
View File
@@ -2,9 +2,11 @@ import { app, BrowserWindow, globalShortcut, net, nativeImage, protocol, shell }
import { existsSync } from 'fs'
import { join } from 'path'
import { pathToFileURL } from 'url'
import { devRendererUrl } from './devEnv'
import { isTrustedRendererUrl, registerIpc } from './ipc'
import { killAllPtys, killPtysByOwner } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { getLockController, initLockController } from './lockController'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyWindowChrome } from './windowChrome'
@@ -87,6 +89,11 @@ if (!gotSingleInstanceLock) {
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
// The lock state has to exist before the window loads, so a lockAtStartup
// lock is already in place when the renderer asks for it. It also starts the
// idle watcher, which is why it belongs after ready: powerMonitor cannot be
// touched before that.
initLockController()
createWindow()
initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has
@@ -99,6 +106,31 @@ if (!gotSingleInstanceLock) {
})
}
/**
* Accelerators that must not reach the page while the lock screen is up.
*
* The overlay is a DOM layer inside the window, so everything the browser
* process handles on its own passes straight through it: reloading the renderer
* runs Workspace's beforeunload and kills every local/SSH session behind the
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
* resized or read. These come from Electron's default application menu, whose
* keys are matched before any renderer code runs.
*
* Matching is on `input.key` rather than `input.code`: the key is what the
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
* while the code depends on the physical key.
*/
function isLockBlockedShortcut(input: Electron.Input): boolean {
const key = input.key.toLowerCase()
if (key === 'f5') return true
if (!input.control) return false
if (key === 'r') return true
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
return input.shift && (key === 'i' || key === 'j' || key === 'c')
}
function createWindow(): void {
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
// (electron-builder embeds build/icon.png), so undefined is fine there.
@@ -128,7 +160,21 @@ function createWindow(): void {
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
// Keep the default renderer sandbox (preload only touches the electron
// IPC bridge, so it does not need Node access).
sandbox: true
}
})
// Swallow the menu accelerators that would otherwise act behind the lock
// overlay (see isLockBlockedShortcut). A throw in here would break typing
// altogether, so the whole guard is defensive.
win.webContents.on('before-input-event', (event, input) => {
try {
if (input.type !== 'keyDown' || !getLockController().isLocked()) return
if (isLockBlockedShortcut(input)) event.preventDefault()
} catch {
/* an input guard must never take the window down with it */
}
})
@@ -174,7 +220,9 @@ function createWindow(): void {
if (!isTrustedRendererUrl(url)) event.preventDefault()
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
// ELECTRON_RENDERER_URL is honored in dev builds only — a packaged build must
// always load the bundled renderer file, no matter what the environment says.
const devUrl = devRendererUrl()
if (devUrl) {
win.loadURL(devUrl)
} else {
+11 -5
View File
@@ -8,9 +8,11 @@ import { pathToFileURL } from 'url'
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
import { devRendererUrl } from './devEnv'
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { startPolling, stopPolling } from './sysinfo'
import { registerSettingsIpc } from './settingsStore'
import { registerLockIpc } from './lockController'
import { registerSessionStateIpc } from './sessionState'
import { normalizeReportedCwd, resolveCwd } from './cwd'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
@@ -41,15 +43,17 @@ const RENDERER_FILE = join(__dirname, '../renderer/index.html')
/**
* True only for a document the app itself loaded: the bundled renderer file, or
* in dev anything served by the vite dev server. Used both to refuse a
* navigation away from the app page and to refuse IPC from a frame that is not
* it — a window that navigated elsewhere would still hold this preload bridge,
* which is the whole main-process API (`createPty` included).
* in dev anything served by the vite dev server (ELECTRON_RENDERER_URL is read
* in dev builds only — a packaged build always trusts the production file URL,
* never a remote origin). Used both to refuse a navigation away from the app
* page and to refuse IPC from a frame that is not it — a window that navigated
* elsewhere would still hold this preload bridge, which is the whole
* main-process API (`createPty` included).
*/
export function isTrustedRendererUrl(raw: string): boolean {
const devUrl = process.env['ELECTRON_RENDERER_URL']
try {
const target = new URL(raw)
const devUrl = devRendererUrl()
if (devUrl) return target.origin === new URL(devUrl).origin
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
} catch {
@@ -240,6 +244,8 @@ export function registerIpc(): void {
registerSettingsIpc()
registerSessionStateIpc()
// ---- lock screen (main owns the state; the renderer only draws the overlay) ----
registerLockIpc()
// Resolve a cd-style argument against the current cwd (platform-aware; only
// the main process has node's `path`).
+69 -18
View File
@@ -29,6 +29,22 @@ export type HostKeyCheckResult =
| { status: 'match'; entry: KnownHostEntry }
| { status: 'new' }
| { status: 'changed'; stored: KnownHostEntry }
/**
* The store file exists but cannot be trusted (unreadable, corrupt JSON or
* not the expected shape). Callers must fail closed: accepting here would
* rewrite the store from an empty table and destroy every pinned fingerprint.
*/
| { status: 'unreadable' }
/**
* Load outcome, so "the file was never written" (TOFU from scratch) stays
* distinguishable from "the file is there but we cannot read it" (data loss
* in progress — never pretend the store is empty).
*/
type LoadResult =
| { kind: 'ok'; shape: KnownHostsStoreShape }
| { kind: 'missing' }
| { kind: 'unreadable' }
/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */
export function fingerprintOf(key: Buffer): string {
@@ -38,28 +54,42 @@ export function fingerprintOf(key: Buffer): string {
export class KnownHostsStore {
constructor(private readonly filePath: string) {}
private load(): KnownHostsStoreShape {
private load(): LoadResult {
let raw: string
try {
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
if (raw !== null && typeof raw === 'object') {
const shape = raw as Partial<KnownHostsStoreShape>
raw = readFileSync(this.filePath, 'utf8')
} catch (err) {
// A file that was never created is the normal first-connect case; any
// other read failure (EACCES, EISDIR, ...) means data we cannot see.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }
return { kind: 'unreadable' }
}
try {
const parsed: unknown = JSON.parse(raw)
if (parsed !== null && typeof parsed === 'object') {
const shape = parsed as Partial<KnownHostsStoreShape>
if (Array.isArray(shape.entries)) {
return {
version: 1,
entries: shape.entries.filter(
(e): e is KnownHostEntry =>
e !== null &&
typeof e === 'object' &&
typeof (e as KnownHostEntry).host === 'string' &&
typeof (e as KnownHostEntry).keyBase64 === 'string'
)
kind: 'ok',
shape: {
version: 1,
entries: shape.entries.filter(
(e): e is KnownHostEntry =>
e !== null &&
typeof e === 'object' &&
typeof (e as KnownHostEntry).host === 'string' &&
typeof (e as KnownHostEntry).keyBase64 === 'string'
)
}
}
}
}
} catch {
// missing / corrupted file -> start fresh
// fall through: JSON.parse failure
}
return { version: 1, entries: [] }
// Parses-but-wrong-shape is treated like corrupt: a file at this path that
// is not the store we wrote is not evidence that nothing was pinned.
return { kind: 'unreadable' }
}
private save(shape: KnownHostsStoreShape): void {
@@ -68,9 +98,18 @@ export class KnownHostsStore {
/**
* Compare the live host key against the stored entry for (host, port).
* Returns `unreadable` when the store exists but cannot be read — never a
* `new` verdict, which would invite overwriting the pin data on accept.
*/
check(host: string, port: number, key: Buffer): HostKeyCheckResult {
const entries = this.load().entries.filter((e) => e.host === host && e.port === port)
const loaded = this.load()
if (loaded.kind === 'unreadable') return { status: 'unreadable' }
// A file that was never written is the genuine TOFU case; an unreadable
// one was already handled above and must never degrade to 'new'.
const entries =
loaded.kind === 'ok'
? loaded.shape.entries.filter((e) => e.host === host && e.port === port)
: []
if (entries.length === 0) return { status: 'new' }
const fingerprint = fingerprintOf(key)
@@ -82,9 +121,19 @@ export class KnownHostsStore {
return { status: 'changed', stored }
}
/** Record a new host key (accept of a 'new' or 'changed' prompt). */
/**
* Record a new host key (accept of a 'new' or 'changed' prompt).
*
* Throws when the store is currently unreadable: rewriting the file from a
* table we failed to load would wipe every other pinned fingerprint.
*/
accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry {
const shape = this.load()
const loaded = this.load()
if (loaded.kind === 'unreadable') {
throw new Error(`known hosts store unreadable, refusing to overwrite: ${this.filePath}`)
}
const shape: KnownHostsStoreShape =
loaded.kind === 'ok' ? loaded.shape : { version: 1, entries: [] }
const entry: KnownHostEntry = {
id: randomUUID(),
host,
@@ -99,8 +148,10 @@ export class KnownHostsStore {
return entry
}
/** Empty when the store is unreadable: callers must not treat that as "no pins". */
list(): KnownHostEntry[] {
return [...this.load().entries]
const loaded = this.load()
return loaded.kind === 'ok' ? [...loaded.shape.entries] : []
}
}
+401
View File
@@ -0,0 +1,401 @@
/**
* Screen-lock controller.
*
* The main process owns the lock: it holds the verifier (lockStore) and the one
* piece of live state that matters — whether the screen is currently locked.
* Nothing here creates a window; the renderer draws the overlay for whatever
* window it is and asks these channels for the truth, so a renderer reload (or a
* second window, later) can never disagree about being locked.
*
* Every state change is published on LOCK_STATE_CHANGED, so the overlay appears
* the moment the idle watcher fires rather than when something happens to ask.
*/
import { app, ipcMain, powerMonitor } from 'electron'
import {
Ipc,
type LockOperationError,
type LockOperationResult,
type LockPasswordInput,
type LockSettingsState
} from '../shared/ipc'
import type { LockSettings } from '../shared/settings'
import { broadcast } from './broadcast'
import {
LockStateStore,
LockStore,
defaultLockPath,
defaultLockStatePath,
isValidPassword
} from './lockStore'
import { loadSettings, mutateSettings } from './settingsStore'
/**
* Backoff after each failed verification: the nth failure refuses further
* attempts for COOLDOWN_STEPS_MS[n-1], with the last step repeating. A wrong
* password therefore costs 1s, 2s, 5s, 10s and then 30s every time.
*/
const COOLDOWN_STEPS_MS = [1000, 2000, 5000, 10000, 30000]
/** How often the idle watcher asks the OS how long the user has been away. */
const IDLE_POLL_MS = 15_000
/**
* Upper bound applied to a cooldown restored from disk. The longest backoff step
* is 30s, so a legitimately stored deadline can never sit further out than that;
* anything beyond it means the clock moved backwards, and trusting the file
* verbatim would lock the user out until the old deadline came around again.
*/
const MAX_RESTORED_COOLDOWN_MS = 30_000
export interface LockControllerOptions {
/** injected by tests; defaults to <userData>/lock.json */
store?: LockStore
/** injected by tests; defaults to <userData>/lock-state.json */
stateStore?: LockStateStore
/** where the preferences are read from — read per call, so a settings change
* takes effect without restarting anything */
getLockSettings?: () => LockSettings
publish?: (state: LockSettingsState) => void
now?: () => number
/** system idle time in seconds; injected so tests need no powerMonitor */
idleSeconds?: () => number
/** turns the lock preferences off once the password is gone; the default
* writes them through settingsStore, which broadcasts the change itself */
clearLockPreferences?: () => void | Promise<void>
}
export class LockController {
private readonly store: LockStore
private readonly stateStore: LockStateStore
private readonly getLockSettings: () => LockSettings
private readonly publish: (state: LockSettingsState) => void
private readonly now: () => number
private readonly idleSeconds: () => number
private readonly clearLockPreferences: () => void | Promise<void>
/** true only while a verifier exists and a lock was requested */
private locked = false
/** consecutive failed verifications; any success clears them */
private failures = 0
/** epoch ms until which attempts are refused; 0 = no cooldown */
private cooldownUntil = 0
private idleTimer?: ReturnType<typeof setInterval>
/** tail of the operation queue; see serialize() */
private queue: Promise<void> = Promise.resolve()
constructor(options: LockControllerOptions = {}) {
this.store = options.store ?? new LockStore(defaultLockPath(app.getPath('userData')))
this.stateStore =
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
this.publish =
options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
this.now = options.now ?? ((): number => Date.now())
this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
this.clearLockPreferences =
options.clearLockPreferences ??
((): Promise<void> =>
mutateSettings((s) => ({
...s,
lock: { ...s.lock, enabled: false, lockAtStartup: false }
})).then(() => undefined))
}
// ---- state -----------------------------------------------------------------
/**
* Write the live flags through to disk. Called after every change rather than
* once at quit, because the exits that matter here are the abrupt ones: a
* tray exit, a task-manager kill or a crash must not hand back an unlocked
* app, and the failure count has to survive with it. The state is three
* fields, so a synchronous write per change is not worth debouncing.
*
* A failed write is a warning and nothing more: losing the flags costs the
* user one restart's worth of protection, while failing the operation they
* just asked for would be a visible bug.
*/
private persist(): void {
try {
this.stateStore.save({
locked: this.locked,
failures: this.failures,
cooldownUntil: this.cooldownUntil
})
} catch {
console.warn('[lock] could not persist the lock state')
}
}
/**
* Run the operations one at a time. The gate reads the backoff, then awaits a
* ~100ms scrypt verification; two calls arriving together would both clear the
* gate and only raise the cooldown once they had both failed, so firing
* attempts in parallel would step around the backoff entirely. Serializing
* also means only one scrypt runs at a time in the main process.
*/
private serialize<T>(op: () => Promise<T>): Promise<T> {
const run = this.queue.then(op, op)
this.queue = run.then(
() => undefined,
() => undefined
)
return run
}
private remainingCooldown(): number {
return Math.max(0, this.cooldownUntil - this.now())
}
/**
* What the renderer sees: the stored preferences plus the live lock flags.
* Never the verifier — no salt, no hash, no password.
*/
getState(): LockSettingsState {
const settings = this.getLockSettings()
const cooldownMs = this.remainingCooldown()
return {
configured: this.store.isConfigured(),
enabled: settings.enabled,
autoLockMinutes: settings.autoLockMinutes,
lockAtStartup: settings.lockAtStartup,
locked: this.locked,
...(cooldownMs > 0 ? { cooldownMs } : {})
}
}
private result(error?: LockOperationError): LockOperationResult {
const state = this.getState()
return error === undefined ? { ok: true, state } : { ok: false, state, error }
}
/** Change the lock flag and publish, so every renderer follows immediately. */
private applyLocked(locked: boolean): void {
if (this.locked === locked) return
this.locked = locked
this.persist()
this.publish(this.getState())
}
/** Record a failed verification and (re)start the backoff. */
private noteFailure(): void {
const step = COOLDOWN_STEPS_MS[Math.min(this.failures, COOLDOWN_STEPS_MS.length - 1)]
this.failures += 1
this.cooldownUntil = this.now() + step
this.persist()
}
private resetFailures(): void {
this.failures = 0
this.cooldownUntil = 0
this.persist()
}
/** Refuse an attempt while the backoff is running. */
private gate(): LockOperationResult | null {
return this.remainingCooldown() > 0 ? this.result('cooldown') : null
}
/** Passwords only ever travel in; a missing one is simply a mismatch. */
private static passwordOf(value: unknown): string {
return typeof value === 'string' ? value : ''
}
// ---- operations ------------------------------------------------------------
/**
* Set or replace the password. Replacing requires the current one: without
* that check, anyone who walked up to an unlocked machine could install their
* own password and keep the real user out afterwards.
*/
async setPassword(input: LockPasswordInput): Promise<LockOperationResult> {
return this.serialize(async (): Promise<LockOperationResult> => {
const next = input?.newPassword
if (!isValidPassword(next)) return this.result('invalid-password')
if (this.store.isConfigured()) {
const blocked = this.gate()
if (blocked) return blocked
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
this.noteFailure()
return this.result('wrong-password')
}
}
try {
await this.store.setPassword(next)
} catch {
// Deliberately not logging the error: it can quote the input, and the
// password must not reach a log file.
console.error('[lock] could not write the lock verifier')
return this.result('save-failed')
}
this.resetFailures()
// Whoever set the password knows it, so a freshly configured lock does not
// slam shut on them; `locked` is left exactly as it was.
this.publish(this.getState())
return this.result()
})
}
/**
* Drop the password. Verifying first is the whole point: otherwise the lock
* could be removed by anyone at the keyboard. Clearing also unlocks, because
* an unconfigured lock has no way to ask for anything.
*/
async clearPassword(input: { currentPassword?: string }): Promise<LockOperationResult> {
return this.serialize(async (): Promise<LockOperationResult> => {
if (!this.store.isConfigured()) {
this.applyLocked(false)
return this.result()
}
const blocked = this.gate()
if (blocked) return blocked
if (!(await this.store.verify(LockController.passwordOf(input?.currentPassword)))) {
this.noteFailure()
return this.result('wrong-password')
}
try {
this.store.clear()
} catch {
console.error('[lock] could not remove the lock verifier')
return this.result('save-failed')
}
this.locked = false
this.resetFailures()
// The preferences go with the password: the settings UI promises that
// clearing turns the lock off, and leaving `enabled`/`lockAtStartup` set
// would silently re-arm the screen the moment a new password was typed.
try {
await this.clearLockPreferences()
} catch {
console.warn('[lock] could not turn the lock preferences off')
}
this.publish(this.getState())
return this.result()
})
}
/** Answer the lock screen. */
async unlock(input: { password?: string }): Promise<LockOperationResult> {
return this.serialize(async (): Promise<LockOperationResult> => {
if (!this.store.isConfigured()) {
// The verifier is gone (deleted while running): nothing could ever open
// the screen again, so it must not stay shut.
this.applyLocked(false)
return this.result()
}
if (!this.locked) return this.result()
const blocked = this.gate()
if (blocked) return blocked
if (!(await this.store.verify(LockController.passwordOf(input?.password)))) {
this.noteFailure()
return this.result('wrong-password')
}
this.locked = false
this.resetFailures()
this.publish(this.getState())
return this.result()
})
}
/** Whether the screen is currently shut. Read by the main-process guards that
* have to stop input reaching a locked window (see before-input-event). */
isLocked(): boolean {
return this.locked
}
/** Lock the screen now. Only a configured password can lock — with no
* verifier there would be no way back in. */
lockNow(): LockSettingsState {
if (this.store.isConfigured()) this.applyLocked(true)
return this.getState()
}
// ---- lifecycle -------------------------------------------------------------
/**
* Apply the startup lock and start watching for idleness. Call once the app is
* ready: powerMonitor cannot be touched before that.
*
* The lock flags come back from disk, so a relaunch is not a way out of a lock
* that was already up — an idle auto-lock or an explicit lock survives the
* quit, exactly like `lockAtStartup` does. `locked` is assigned directly here
* (no publish): nothing is listening yet, and the renderer asks for the state
* as soon as it loads.
*/
start(): void {
const restored = this.stateStore.load()
this.failures = restored.failures
// Clamped: see MAX_RESTORED_COOLDOWN_MS.
this.cooldownUntil = Math.min(restored.cooldownUntil, this.now() + MAX_RESTORED_COOLDOWN_MS)
if (this.store.isConfigured()) {
if (this.getLockSettings().lockAtStartup || restored.locked) this.locked = true
} else {
// No verifier means nothing could ever open the screen again, so the
// stored flags must not outlive it (a later password would re-arm a lock
// nobody asked for).
try {
this.stateStore.clear()
} catch {
console.warn('[lock] could not clear the persisted lock state')
}
}
if (this.idleTimer === undefined) {
this.idleTimer = setInterval(() => this.checkIdle(), IDLE_POLL_MS)
// Polling for idleness must never hold the process open.
this.idleTimer.unref?.()
}
}
/**
* Idle auto-lock. Only a configured, enabled lock with a real delay may fire,
* and never while the screen is already locked — otherwise every poll would
* republish the same state.
*/
private checkIdle(): void {
const settings = this.getLockSettings()
if (!settings.enabled || settings.autoLockMinutes <= 0) return
if (this.locked || !this.store.isConfigured()) return
let idleSeconds: number
try {
idleSeconds = this.idleSeconds()
} catch {
// powerMonitor refuses without a session (or on a locked workstation);
// "unknown" must read as "not idle" rather than locking the app.
return
}
if (idleSeconds >= settings.autoLockMinutes * 60) this.applyLocked(true)
}
}
let controller: LockController | undefined
export function getLockController(): LockController {
if (!controller) controller = new LockController()
return controller
}
/** Start the idle watcher and apply the startup lock (call after app ready). */
export function initLockController(): LockController {
const instance = getLockController()
instance.start()
return instance
}
/**
* Register the lock channels. Goes through `ipcMain.handle` like every other
* channel, so the sender guard installed by registerIpc covers these too.
*/
export function registerLockIpc(): void {
const lock = getLockController()
ipcMain.handle(Ipc.LOCK_STATE_GET, () => lock.getState())
ipcMain.handle(Ipc.LOCK_SET_PASSWORD, (_event, input: LockPasswordInput) =>
lock.setPassword(input ?? {})
)
ipcMain.handle(Ipc.LOCK_CLEAR_PASSWORD, (_event, input: { currentPassword?: string }) =>
lock.clearPassword(input ?? {})
)
ipcMain.handle(Ipc.LOCK_UNLOCK, (_event, input: { password?: string }) =>
lock.unlock(input ?? {})
)
ipcMain.handle(Ipc.LOCK_NOW, () => lock.lockNow())
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Screen-lock stores. The verifier persists to <userData>/lock.json, the live
* lock flags (locked / failures / cooldownUntil) to <userData>/lock-state.json.
*
* No Electron imports here: both file locations are injected, the same way the
* known-hosts store does it, so the module can be driven by a plain-Node test.
*
* What lands on disk is a scrypt verifier, never the password: a random 16-byte
* salt plus scrypt(password, salt, 64), both base64. The comparison goes through
* timingSafeEqual so a wrong password cannot be narrowed down by response time,
* and the password is never logged, echoed back or put in an error message.
*
* A missing, truncated, wrong-shaped or wrongly-sized verifier file reads as
* "not configured": losing the lock is a nuisance, while throwing out of a
* startup path would make the app unstartable. The state store is just as
* forgiving, for the same reason.
*/
import { randomBytes, scrypt, timingSafeEqual } from 'crypto'
import { unlinkSync } from 'fs'
import { readJson, writeJson } from './store'
/** Shape written to disk; `version` gates any future migration. */
export interface LockStoreShape {
version: 1
/** random per-install salt, base64 */
salt: string
/** scrypt(password, salt, KEY_LENGTH), base64 */
hash: string
createdAt: number
}
const SALT_BYTES = 16
const KEY_LENGTH = 64
/**
* scrypt cost parameters, spelled out rather than left to node's defaults so the
* verifier cannot be silently re-tuned by a runtime upgrade (an existing hash
* has to stay checkable).
*/
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
export const MIN_PASSWORD_LENGTH = 4
export const MAX_PASSWORD_LENGTH = 128
/** An empty or absurdly long password is refused rather than hashed. */
export function isValidPassword(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length >= MIN_PASSWORD_LENGTH &&
value.length <= MAX_PASSWORD_LENGTH
)
}
/** Async on purpose: scryptSync would block the main process (which streams PTY
* output) for ~100ms on every attempt. */
function derive(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, KEY_LENGTH, SCRYPT_OPTIONS, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
}
export class LockStore {
constructor(private readonly filePath: string) {}
/** The load path runs on every state query, so the unknown-version warning
* must fire once per process rather than once per call. */
private static warnedUnknownVersion = false
/** The stored verifier, or null when unconfigured or unusable. */
private load(): LockStoreShape | null {
const parsed = readJson<Partial<LockStoreShape>>(this.filePath)
if (parsed === null || typeof parsed !== 'object') return null
if (parsed.version !== 1) {
// Fail-open is deliberate (a lock file nobody can read must not make the
// app unstartable), but a future format must not disable the lock
// silently: the file exists, says it holds a verifier, and is being
// ignored. One warning per process; the message quotes nothing from it.
if (!LockStore.warnedUnknownVersion) {
LockStore.warnedUnknownVersion = true
console.warn(
'[lock] lock.json has a version this build does not know; reading it as not configured — the password must be set again'
)
}
return null
}
if (typeof parsed.salt !== 'string' || typeof parsed.hash !== 'string') return null
if (typeof parsed.createdAt !== 'number') return null
const salt = Buffer.from(parsed.salt, 'base64')
const hash = Buffer.from(parsed.hash, 'base64')
// A verifier of the wrong size is a corrupt file, not a weak password: it
// can never match, so it must not count as "a password is set".
if (salt.length !== SALT_BYTES || hash.length !== KEY_LENGTH) return null
return { version: 1, salt: parsed.salt, hash: parsed.hash, createdAt: parsed.createdAt }
}
isConfigured(): boolean {
return this.load() !== null
}
/**
* Write a fresh verifier — first set and replace alike, because the salt is
* regenerated so the previous hash (and anyone who saw it) becomes worthless.
* Throws on an unusable password; the caller maps that to `invalid-password`.
*/
async setPassword(password: string): Promise<void> {
if (!isValidPassword(password)) {
throw new Error(
`lock password must be ${MIN_PASSWORD_LENGTH}..${MAX_PASSWORD_LENGTH} characters`
)
}
const salt = randomBytes(SALT_BYTES)
const hash = await derive(password, salt)
const shape: LockStoreShape = {
version: 1,
salt: salt.toString('base64'),
hash: hash.toString('base64'),
createdAt: Date.now()
}
writeJson(this.filePath, shape)
}
/** Constant-time check. False when unconfigured; never throws. */
async verify(password: string): Promise<boolean> {
const stored = this.load()
if (stored === null || typeof password !== 'string') return false
const expected = Buffer.from(stored.hash, 'base64')
const actual = await derive(password, Buffer.from(stored.salt, 'base64'))
// Lengths are equal by construction (load() enforces it); the guard keeps
// timingSafeEqual from throwing on a file that changed underneath us.
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
/** Forget the password: the file is deleted, so "not configured" is one state
* rather than two (an empty file vs. no file). */
clear(): void {
try {
unlinkSync(this.filePath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
}
/** Default location: <userData>/lock.json */
export function defaultLockPath(userDataPath: string): string {
return `${userDataPath}/lock.json`
}
/** The live lock flags, as persisted and as held in memory by the controller. */
export interface LockState {
locked: boolean
failures: number
cooldownUntil: number
}
/** Shape written to disk; `version` gates any future migration. */
interface LockStateShape extends LockState {
version: 1
}
/**
* Persistence for the lock flags themselves, so quitting and relaunching is not
* a way out of a lock that was already up (nor a way to reset the backoff that
* was already earned). Only flags live here — never a password, never a hash.
*
* Like the verifier store, this reads a missing/corrupt/wrong-shaped file as
* "nothing was ever locked": the load happens on the startup path, where
* throwing would leave the app without a window but still holding the
* single-instance lock.
*/
export class LockStateStore {
constructor(private readonly filePath: string) {}
/** The stored flags, or "unlocked with no failures" for anything unusable. */
load(): LockState {
const fallback: LockState = { locked: false, failures: 0, cooldownUntil: 0 }
const parsed = readJson<Partial<LockStateShape>>(this.filePath)
if (parsed === null || typeof parsed !== 'object') return fallback
if (parsed.version !== 1) return fallback
const { failures, cooldownUntil } = parsed
return {
locked: parsed.locked === true,
failures:
typeof failures === 'number' && Number.isInteger(failures) && failures > 0 ? failures : 0,
cooldownUntil:
typeof cooldownUntil === 'number' && Number.isFinite(cooldownUntil) && cooldownUntil > 0
? cooldownUntil
: 0
}
}
/** Atomic write (temp file + rename), so a crash mid-write cannot leave a
* half-written file that would read back as "never locked". */
save(state: LockState): void {
const shape: LockStateShape = { version: 1, ...state }
writeJson(this.filePath, shape)
}
/** Forget the flags: the file is deleted, so "not locked" is one state rather
* than two (an empty file vs. no file). */
clear(): void {
try {
unlinkSync(this.filePath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
}
/** Default location: <userData>/lock-state.json */
export function defaultLockStatePath(userDataPath: string): string {
return `${userDataPath}/lock-state.json`
}
+33 -7
View File
@@ -8,7 +8,7 @@ import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../s
import { broadcast } from './broadcast'
import { connectSsh, type SshSessionHandle } from './ssh'
import type { HostKeyCheckResult } from './knownHosts'
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
import { configureSysinfo, registerSysinfoClient, forceStopPolling } from './sysinfo'
import { registerSftpClientProvider, closeSftp } from './sftp'
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
import { pickAdapter } from './shellIntegration'
@@ -17,7 +17,7 @@ import { statSync } from 'fs'
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
// M3 polling engine without importing src/main/sysinfo.ts separately.
export { startPolling, stopPolling } from './sysinfo'
export { startPolling, stopPolling, forceStopPolling } from './sysinfo'
/**
* M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the
@@ -308,16 +308,40 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
if (typeof code === 'number') handle.exitCode = code
})
handle.stream.on('close', () => {
// One teardown for both terminal events. ssh2 emits 'close' after an 'error'
// (and killSession closes the stream directly), so the path must be
// idempotent: the flag guarantees PTY_EXIT is broadcast exactly once and the
// polling / SFTP / ZMODEM / log cleanups run at most once per session.
let sshClosed = false
const teardownSshStream = (exitCode: number): void => {
if (sshClosed) return
sshClosed = true
try {
stopPolling(handle.id)
// Session is gone: no shared poll may survive any remaining panel refs.
forceStopPolling(handle.id)
closeSftp(handle.id)
detachZmodem(handle.id)
safeStopLog(handle.id)
sessions.delete(handle.id)
replayBuffers.delete(handle.id)
sshDecoders.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: handle.exitCode })
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
} catch {
// never crash the event loop
}
}
handle.stream.on('close', () => {
teardownSshStream(handle.exitCode)
})
handle.stream.on('error', (err: Error) => {
// 'close' follows an 'error', but rely on the idempotent teardown instead
// of waiting for it: a dead stream must release its session slot at once.
try {
console.warn(`[pty] ssh stream error (${handle.id}): ${err.message}`)
if (handle.exitCode === 0) handle.exitCode = 1
teardownSshStream(handle.exitCode)
} catch {
// never crash the event loop
}
@@ -353,7 +377,9 @@ export function resizePty(id: string, cols: number, rows: number): void {
}
function killSession(id: string): void {
stopPolling(id)
// Forced: the session is being destroyed, so the shared poll must stop even
// if split panels still hold references.
forceStopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
@@ -398,7 +424,7 @@ export function killPtysByOwner(owner: number): void {
export function killAllPtys(): void {
for (const id of sessions.keys()) {
stopPolling(id)
forceStopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
+38 -14
View File
@@ -1,13 +1,16 @@
import { app, ipcMain, powerSaveBlocker } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
isHighlightCategory,
isLockAutoDelay,
lockAutoDelayOf,
type AppSettings,
type HighlightRule,
type LockSettings,
type SystemSettings,
type TerminalSettings
} from '../shared/settings'
@@ -15,6 +18,7 @@ import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/th
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { sanitizeProfiles } from '../shared/highlightProfiles'
import { broadcast } from './broadcast'
import { writeJson } from './store'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
@@ -267,8 +271,31 @@ function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
return themes
}
/**
* Sanitize the lock block. Every field is forced to its type, so a partial
* patch, a hand-edited file or a config written before the block existed all
* land on the defaults; the delay must be one of the offered steps, because an
* arbitrary number here would silently change the idle-lock schedule.
*/
function sanitizeLock(value: unknown, errors: string[]): LockSettings {
const candidate =
value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
if (candidate.autoLockMinutes !== undefined && !isLockAutoDelay(candidate.autoLockMinutes)) {
errors.push('lock.autoLockMinutes')
}
return {
enabled: candidate.enabled === true,
autoLockMinutes: lockAutoDelayOf(candidate.autoLockMinutes),
lockAtStartup: candidate.lockAtStartup === true
}
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
const lock = sanitizeLock(
raw !== null && typeof raw === 'object' ? (raw as { lock?: unknown }).lock : undefined,
errors
)
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
@@ -345,7 +372,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
new Set(rules.map((rule) => rule.id)),
(message) => errors.push(message)
),
system: system as SystemSettings
system: system as SystemSettings,
lock
},
errors
}
@@ -419,7 +447,8 @@ export function loadSettings(): AppSettings {
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM }
system: { ...DEFAULT_SYSTEM },
lock: { ...DEFAULT_SETTINGS.lock }
}
}
}
@@ -440,11 +469,7 @@ function migrateDefaultsOnce(): void {
if (current.terminal.suggestEnabled || current.terminal.historyEnabled) {
current.terminal.suggestEnabled = false
current.terminal.historyEnabled = false
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf8')
renameSync(tmp, path)
writeJson(settingsPath(), current)
}
writeFileSync(flag, '', 'utf8')
} catch {
@@ -464,11 +489,9 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
applySystemSettings(merged.system)
applyWindowChrome(merged)
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
renameSync(tmp, path)
// writeJson gives the same atomic write as every other store, including
// short EPERM/EBUSY retries when Windows holds the destination open.
writeJson(settingsPath(), merged)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
@@ -492,7 +515,8 @@ export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
...current,
...next,
terminal: { ...current.terminal, ...next.terminal },
system: { ...current.system, ...next.system }
system: { ...current.system, ...next.system },
lock: { ...current.lock, ...next.lock }
}))
}
+30 -13
View File
@@ -15,7 +15,6 @@
import type { SshConnection, SshSecretOverride } from '../shared/connections'
import { t } from '../shared/i18n'
import { Ipc } from '../shared/ipc'
import { randomUUID } from 'crypto'
import { readFileSync } from 'fs'
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
@@ -112,14 +111,16 @@ export async function connectSsh(
// Called by ssh2 during kex; return undefined => async verdict via verify().
const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => {
let fingerprint: string
let status: 'new' | 'changed' | 'match'
let status: HostKeyCheckResult['status']
try {
fingerprint = fingerprintOf(hostKey)
status = deps.knownHosts.check(conn.host, conn.port, hostKey).status
} catch (err) {
console.error(`[ssh] knownHosts.check threw: ${(err as Error).message}`)
fingerprint = fingerprintOf(hostKey)
status = 'new'
// A throwing store is as untrustworthy as an unreadable one: falling back
// to 'new' would let the subsequent accept() rewrite the whole store.
status = 'unreadable'
}
if (status === 'match') {
@@ -127,6 +128,17 @@ export async function connectSsh(
return
}
if (status === 'unreadable') {
// known_hosts exists but cannot be read (corrupt JSON, access error...).
// Accepting would persist the new key into a table we failed to load and
// destroy every pinned fingerprint, so fail closed instead of prompting:
// no accept offer, the user repairs or deletes the file and retries.
verifierErr = t('main.ssh.knownHostsUnreadable')
console.error(`[ssh] ${verifierErr}`)
verify(false)
return
}
// Pause the connect timeout; the user's decision owns this wait.
if (connectTimer) clearTimeout(connectTimer)
@@ -168,13 +180,11 @@ export async function connectSsh(
}
// Session already established: surface as a session exit and clean up.
// Record the failure code on the handle so the stream's later 'close'
// (pty.ts) reports the same exit code instead of a bogus 0.
// (pty.ts teardown) reports the same exit code instead of a bogus 0 —
// the broadcast itself must come only from pty.ts's idempotent teardown;
// emitting it here as well would fire PTY_EXIT twice (destroy() closes
// the stream, and the stream 'close' handler broadcasts on its own).
if (sessionHandle) sessionHandle.exitCode = 1
try {
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
} catch {
// never crash the event loop
}
try {
handshake.destroy()
} catch {
@@ -228,11 +238,14 @@ export async function connectSsh(
// Password auth. A stored password is offered only when the bookmark is
// configured for password auth: connectionsStore keeps password_enc when a
// bookmark is switched to key/agent auth, and silently falling back to it
// would authenticate a weaker method than the user chose. An explicitly
// typed connect-time password (secretOverride) is always honoured.
// would authenticate a weaker method than the user chose. The same gate
// applies to a typed connect-time password (secretOverride): it belongs to
// the password flow and must never upgrade a key/agent bookmark into
// password auth (a stale ask flag used to route one here via ConnectFlow).
const password =
secretOverride?.password ??
(conn.auth === 'password' ? deps.connections.getSecret(conn, 'password') : undefined)
conn.auth === 'password'
? (secretOverride?.password ?? deps.connections.getSecret(conn, 'password'))
: undefined
if (password !== undefined) cfg.password = password
// Private key auth (keyPath takes precedence over stored keyContent)
@@ -246,6 +259,10 @@ export async function connectSsh(
: undefined
if (privateKey !== undefined) {
cfg.privateKey = privateKey
// A typed connect-time passphrase (secretOverride) is honoured only
// inside this private-key branch — the gate above keeps the password
// override out of key auth and this branch keeps the passphrase out of
// password auth.
const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase')
if (passphrase !== undefined) cfg.passphrase = passphrase
}
+42 -5
View File
@@ -75,16 +75,29 @@ interface PollState {
prevAt: number
metaSent: boolean
timer: NodeJS.Timeout
/**
* Live renderer subscriptions on this poll. Two MonitorPanels can share one
* sessionId (split view); each start/stop pair adjusts the count and only a
* count of zero (or a forced stop) tears the poll down.
*/
refs: number
}
const polls = new Map<string, PollState>()
/**
* Start polling a session. Calling again for the same id stops the previous
* poll first (re-entrancy safe).
* Start polling a session on behalf of one renderer subscriber.
*
* The first call creates the poll; further calls for an already-polling id
* only bump the reference count (the existing interval keeps running) so a
* second panel joining a split view cannot restart or reset the shared poll.
*/
export function startPolling(id: string, intervalMs = 3000): void {
stopPolling(id)
const existing = polls.get(id)
if (existing) {
existing.refs += 1
return
}
const state: PollState = {
id,
intervalMs,
@@ -94,13 +107,34 @@ export function startPolling(id: string, intervalMs = 3000): void {
lastSample: undefined,
prevAt: 0,
metaSent: false,
refs: 1,
timer: setTimeout(() => pollOnce(id, state), 0)
}
polls.set(id, state)
}
/** Stop polling a session (no-op when not polling). Also run on session close. */
/**
* Drop one renderer subscription. The poll itself stops only when the last
* reference is gone — any other panel sharing the sessionId keeps it alive.
* No-op when nothing is polling (e.g. after a forced stop).
*/
export function stopPolling(id: string): void {
const state = polls.get(id)
if (!state) return
state.refs -= 1
if (state.refs <= 0) haltPolling(id)
}
/**
* Stop unconditionally, discarding the reference count. For session
* close/kill: after the underlying ssh client is gone nothing must keep
* polling, regardless of how many panels still hold references.
*/
export function forceStopPolling(id: string): void {
haltPolling(id)
}
function haltPolling(id: string): void {
const state = polls.get(id)
if (!state) return
state.stopped = true
@@ -199,7 +233,10 @@ function handleError(id: string, state: PollState, message: string): void {
if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`)
stopPolling(id)
// Engine-side decision: halt the poll outright (not a refcount decrement —
// that would leave sibling panels pointing at a dead loop with no timer).
// A later renderer stop is then a no-op and a fresh start can re-create it.
forceStopPolling(id)
return
}
armNext(id, state)
+6 -1
View File
@@ -3,6 +3,7 @@ import { autoUpdater } from 'electron-updater'
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'
@@ -32,11 +33,15 @@ function useFeed(feed: 'gitea' | 'github'): void {
activeFeed = feed
if (feed === 'gitea') {
// Domestic feed: always direct — a system proxy only breaks it.
// OT_UPDATE_URL overrides the feed in dev builds only; OT_UPDATE_TOKEN is
// read from the environment in every build, which is only safe because the
// packaged URL is the hardcoded GITEA_FEED — making it configurable again
// would turn the token into a credential sent to whatever host it names.
void autoUpdater.netSession.setProxy({ mode: 'direct' })
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
url: process.env.OT_UPDATE_URL || GITEA_FEED,
url: devUpdateFeedUrl() ?? GITEA_FEED,
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
} else {
+13
View File
@@ -3,6 +3,7 @@ import { Ipc } from '../shared/ipc'
import type { AppSettings } from '../shared/settings'
import type { AppApi } from '../shared/api'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type { LockOperationResult, LockPasswordInput, LockSettingsState } from '../shared/ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -113,6 +114,18 @@ const api: AppApi = {
return () => ipcRenderer.removeListener(Ipc.SETTINGS_CHANGED, listener)
},
getLockState: () => ipcRenderer.invoke(Ipc.LOCK_STATE_GET),
setLockPassword: (input: LockPasswordInput) => ipcRenderer.invoke(Ipc.LOCK_SET_PASSWORD, input),
clearLockPassword: (input: { currentPassword?: string }) =>
ipcRenderer.invoke(Ipc.LOCK_CLEAR_PASSWORD, input),
unlockLock: (input: { password?: string }) => ipcRenderer.invoke(Ipc.LOCK_UNLOCK, input),
lockNow: () => ipcRenderer.invoke(Ipc.LOCK_NOW),
onLockStateChanged: (cb: (state: LockSettingsState) => void) => {
const listener = (_: unknown, state: LockSettingsState): void => cb(state)
ipcRenderer.on(Ipc.LOCK_STATE_CHANGED, listener)
return () => ipcRenderer.removeListener(Ipc.LOCK_STATE_CHANGED, listener)
},
listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST),
listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST),
+92 -6
View File
@@ -7,9 +7,11 @@ import jaJP from 'antd/locale/ja_JP'
import Workspace from '@renderer/workspace/Workspace'
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { LockScreen } from '@renderer/lock/LockScreen'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { DEFAULT_LANGUAGE, syncLanguage, type Language } from '@shared/i18n'
import type { LockSettingsState } from '@shared/ipc'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
@@ -44,11 +46,81 @@ export default function App(): React.JSX.Element {
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const [settingsOpen, setSettingsOpen] = useState(false)
/** Secure default: main may already hold a startup lock before this IPC
* resolves, so the shell must not become interactive while unknown. `null`
* means "not answered yet" and is kept distinct from "locked": the overlay
* is drawn only once main has spoken, otherwise every start would flash a
* lock panel — even for users who never configured a password. */
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
useEffect(() => {
void hydrate()
}, [hydrate])
// Lock state: pulled once (it may already be locked at startup) and then kept
// in sync from main, which owns the state — the renderer never decides.
useEffect(() => {
let alive = true
void window.api.getLockState().then(
(state: LockSettingsState) => {
if (alive) setLockState(state)
},
(err: unknown) => {
console.error('[lock] getLockState failed', err)
// Stay recoverable: fall back to "locked with no verifier" so the panel
// (and its input) is reachable. Main unlocks an unconfigured app on the
// first attempt, so the user is never stuck on the boot layer.
if (alive) {
setLockState({
configured: false,
enabled: false,
autoLockMinutes: 0,
lockAtStartup: false,
locked: true
})
}
}
)
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
return () => {
alive = false
off()
}
}, [])
// Unknown counts as locked: main owns the state and may already be locked.
const locked = lockState === null || lockState.locked
// Locking is app-wide: close antd portals that live outside `.app-root`
// (the settings modal otherwise stays focusable behind the opaque mask),
// and expose the state to window-level hotkey listeners — they see 'true'
// during the unknown window as well, which is the point.
useEffect(() => {
document.documentElement.dataset.locked = locked ? 'true' : 'false'
if (locked) setSettingsOpen(false)
// Portals (antd modals, dropdowns, tooltips) attach to document.body,
// outside the inert `#root` subtree, so a keyboard user could still Tab
// into whatever happened to be open when the lock engaged. Inert every
// other body child while locked; the overlay itself lives inside #root,
// above the inert `.app-root`, and stays reachable.
const appRoot = document.getElementById('root')
const inerted: Element[] = []
if (locked) {
for (const el of Array.from(document.body.children)) {
if (el === appRoot) continue
try {
el.setAttribute('inert', '')
inerted.push(el)
} catch {
// a node that refuses the attribute must not break the lock
}
}
}
return () => {
for (const el of inerted) el.removeAttribute('inert')
}
}, [locked])
// Interface language: t() reads the module-level language at render time, so
// sync it before the tree renders — an effect would paint one frame late.
// Silent on purpose: notifying subscribers during a render is what React
@@ -71,12 +143,26 @@ export default function App(): React.JSX.Element {
return (
<ConfigProvider locale={ANTD_LOCALES[language]}>
<div className="app-root">
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
<>
{/* Locked: the shell goes inert (no focus, no pointer, hidden from
assistive tech) but stays mounted — unmounting it would kill the
local/SSH sessions and the transfer list behind the overlay.
`inert` is listed before aria-hidden so focus leaves the subtree
before the browser checks for a focused descendant. */}
<div className="app-root" inert={locked || undefined} aria-hidden={locked || undefined}>
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
{/* Unknown state paints the opaque layer alone: the shell stays hidden
and no panel is shown, so startup cannot flash a lock screen. */}
{lockState === null ? (
<div className="lock-screen-boot" />
) : locked ? (
<LockScreen state={lockState} onStateChange={setLockState} />
) : null}
</>
</ConfigProvider>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from 'react'
import { LockOutlined } from '@ant-design/icons'
import { Button, Input } from 'antd'
import type { InputRef } from 'antd'
import { t } from '@shared/i18n'
import type { LockOperationError, LockSettingsState } from '@shared/ipc'
import './lock.css'
/** Message key per failure, so every LockOperationError reads as its own line. */
const ERROR_KEYS: Record<LockOperationError, string> = {
'invalid-password': 'settings.lock.error.invalidPassword',
'wrong-password': 'settings.lock.error.wrongPassword',
cooldown: 'settings.lock.error.cooldown',
'save-failed': 'settings.lock.error.saveFailed'
}
/**
* Failure text for a `LockOperationResult.error`. A `cooldown` error is shown
* with the remaining seconds when main reports them, and with the generic
* wording otherwise — never as "retry in 0 seconds".
*/
export function lockErrorText(error: LockOperationError, cooldownMs?: number): string {
if (error === 'cooldown') {
const seconds = cooldownMs !== undefined && cooldownMs > 0 ? Math.ceil(cooldownMs / 1000) : 0
return seconds > 0
? t('settings.lock.error.cooldown', { n: seconds })
: t('settings.lock.error.cooldownGeneric')
}
return t(ERROR_KEYS[error])
}
export interface LockScreenProps {
state: LockSettingsState
/** publish the state main returned, so App drops the overlay once unlocked */
onStateChange: (state: LockSettingsState) => void
}
/**
* Lock overlay for the main window.
*
* App.tsx renders it as a *sibling* of the app shell rather than inside it: the
* shell stays mounted (and inert) underneath, so PTY/SSH sessions keep running
* and the workspace is not torn down by a lock. The layer is opaque, so no
* terminal output is visible through it.
*
* Deliberately minimal — no session content, no bypass button, and the password
* never leaves this component: it is cleared after every attempt and is not
* logged anywhere.
*/
export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX.Element {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<LockOperationError | null>(null)
/** local deadline, so the countdown keeps ticking between main's broadcasts */
const [cooldownUntil, setCooldownUntil] = useState(0)
const [now, setNow] = useState(() => Date.now())
const inputRef = useRef<InputRef>(null)
const cooldownMs = state.cooldownMs ?? 0
useEffect(() => {
setCooldownUntil(cooldownMs > 0 ? Date.now() + cooldownMs : 0)
setNow(Date.now())
}, [cooldownMs])
useEffect(() => {
if (cooldownUntil <= 0) return
const id = window.setInterval(() => setNow(Date.now()), 250)
return () => window.clearInterval(id)
}, [cooldownUntil])
const remainingMs = Math.max(0, cooldownUntil - now)
const cooling = remainingMs > 0
// Focus follows usability: on mount and again when a cooldown runs out, so
// the lock can be answered by typing without reaching for the mouse.
useEffect(() => {
if (!cooling) inputRef.current?.focus()
}, [cooling])
const submit = async (): Promise<void> => {
if (busy || cooling || password.length === 0) return
const attempt = password
setBusy(true)
setError(null)
try {
const result = await window.api.unlockLock({ password: attempt })
setPassword('')
onStateChange(result.state)
if (!result.ok) setError(result.error ?? 'wrong-password')
} catch (err) {
console.error('[lock] unlock request failed', err)
setError('save-failed')
} finally {
setBusy(false)
}
}
const message = cooling
? lockErrorText('cooldown', remainingMs)
: error
? lockErrorText(error, cooldownMs)
: ''
return (
<div
className="lock-screen"
role="dialog"
aria-modal="true"
aria-label={t('settings.lock.title')}
// Modal focus trap: antd popovers/modals render outside the inert app
// root, so Tab must not be allowed to walk into content hidden behind
// this opaque overlay.
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault()
inputRef.current?.focus()
}
}}
>
<form
className="lock-screen-panel"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<LockOutlined className="lock-screen-icon" />
<div className="lock-screen-title">{t('settings.lock.title')}</div>
<div className="lock-screen-desc">{t('settings.lock.screenDesc')}</div>
<Input.Password
ref={inputRef}
className="lock-screen-input"
size="large"
value={password}
disabled={cooling}
visibilityToggle={false}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.passwordPlaceholder')}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError(null)
}}
/>
<Button
className="lock-screen-button"
type="primary"
size="large"
htmlType="submit"
loading={busy}
disabled={cooling || password.length === 0}
>
{busy ? t('settings.lock.unlocking') : t('settings.lock.unlock')}
</Button>
<div className="lock-screen-message" role="status">
{message}
</div>
<div className="lock-screen-hint">{t('settings.lock.screenForgot')}</div>
</form>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
/* ============================================================
Lock overlay (lock/LockScreen.tsx)
Opaque on purpose: nothing of the workspace underneath may
show through. Colors come from the chrome variables, so the
overlay follows the active terminal theme.
============================================================ */
/* Opaque layer shared by the lock screen and the boot layer that App.tsx paints
while the lock state is still unknown (first IPC round trip). Keeping one rule
means the boot layer cannot drift away from the overlay's stacking level. */
.lock-screen,
.lock-screen-boot {
position: fixed;
inset: 0;
/* above the workspace rail (960) and every antd layer: modal 1000,
message 1010, notification 1050 */
z-index: 4000;
background: var(--chrome-bg-deep, #101418);
color: var(--chrome-fg, #cccccc);
}
/* Only the panel-bearing overlay centres anything; the boot layer is empty. */
.lock-screen {
display: flex;
align-items: center;
justify-content: center;
}
.lock-screen-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 340px;
max-width: 80vw;
padding: 30px 28px 22px;
border: 1px solid var(--chrome-border, #2d2d2d);
border-radius: 10px;
background: var(--chrome-bg, #181818);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
text-align: center;
}
.lock-screen-icon {
font-size: 32px;
line-height: 1;
color: var(--tab-accent, #3fb950);
}
.lock-screen-title {
font-size: 17px;
font-weight: 600;
}
.lock-screen-desc {
font-size: 12px;
margin-bottom: 4px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
}
.lock-screen-input,
.lock-screen-button {
width: 100%;
}
/* Reserved height: the message appears and disappears without moving the
button, so a failed attempt does not shift the input out from under the
pointer. */
.lock-screen-message {
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #f85149;
}
.lock-screen-hint {
font-size: 11px;
line-height: 1.5;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 38%, transparent);
}
+38 -2
View File
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client'
import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import type { LockSettingsState } from '@shared/ipc'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
@@ -19,7 +20,14 @@ import './global.css'
setInterval(() => {
const now = performance.now()
const lag = now - lastTick - 2000
if (lag > 3000) console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
// Chromium throttles timers in a hidden page down to roughly one per
// minute (intensive throttling), which this watchdog would otherwise
// report as a ~58s "stall" on every tick while the window sits in the
// tray. A hidden window also has nothing user-visible to freeze, so the
// report is skipped until the page is visible again.
if (lag > 3000 && !document.hidden) {
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
}
lastTick = now
}, 2000)
}
@@ -29,6 +37,16 @@ import './global.css'
if (typeof window !== 'undefined' && !window.api) {
const noop = (): void => undefined
const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}`
/** Browser stub: never configured and never locked, so the lock overlay
* stays out of the way of layout work done in a plain vite page. */
const stubLockState = (over?: Partial<LockSettingsState>): LockSettingsState => ({
configured: false,
enabled: false,
autoLockMinutes: 0,
lockAtStartup: false,
locked: false,
...over
})
const api: AppApi = {
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
@@ -111,7 +129,16 @@ if (typeof window !== 'undefined' && !window.api) {
getSessionState: async () => null,
saveSessionState: async () => null,
resolveCwd: async () => null,
reportCwd: async () => null
reportCwd: async () => null,
getLockState: async () => stubLockState(),
setLockPassword: async (input) => ({
ok: true,
state: stubLockState({ configured: (input.newPassword ?? '').length > 0 })
}),
clearLockPassword: async () => ({ ok: true, state: stubLockState() }),
unlockLock: async () => ({ ok: true, state: stubLockState() }),
lockNow: async () => stubLockState(),
onLockStateChanged: () => noop
}
;(window as unknown as { api: AppApi }).api = api
}
@@ -130,6 +157,15 @@ function FontHotkeyListener(): null {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
// The lock overlay leaves Workspace mounted; window-capture hotkeys must
// not mutate font size on a shell hidden behind it. preventDefault matters
// here: returning alone lets the chord reach Electron's default menu
// accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI
// behind the opaque mask and make Chromium persist that zoom per origin.
if (document.documentElement.dataset.locked === 'true') {
e.preventDefault()
return
}
if (!e.ctrlKey || e.metaKey) return
const target = e.target as HTMLElement | null
const isXtermHelper =
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react'
import { Button, Input, Popconfirm, Select, Switch, Tag } from 'antd'
import { t } from '@shared/i18n'
import type { LockOperationResult, LockSettingsState } from '@shared/ipc'
import { LOCK_AUTO_DELAYS, type LockAutoDelay } from '@shared/settings'
import { lockErrorText } from '@renderer/lock/LockScreen'
import { SettingRow } from './fields'
import { useSettingsStore } from './store'
type Feedback = { kind: 'ok' | 'error'; text: string } | null
/**
* 锁屏设置页。
*
* Two sources, on purpose: the switches live in `settings.lock` (persisted
* through the settings store, so they follow the normal save/rollback path),
* while "is a password configured / is the screen locked right now" comes from
* main (`lock.json` holds the verifier, never settings). The password fields
* are write-only: they are sent once and cleared, main never echoes them back.
*/
export function LockSettingsTab(): React.JSX.Element {
const lock = useSettingsStore((s) => s.settings.lock)
const updateLock = useSettingsStore((s) => s.updateLock)
const [lockState, setLockState] = useState<LockSettingsState | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [busy, setBusy] = useState(false)
const [feedback, setFeedback] = useState<Feedback>(null)
useEffect(() => {
let alive = true
void window.api.getLockState().then(
(state: LockSettingsState) => {
if (alive) setLockState(state)
},
(err: unknown) => console.error('[lock] getLockState failed', err)
)
// Keeps `configured` honest when the lock engages or main clears the
// verifier (e.g. an unlock attempt failed and a cooldown started).
const off = window.api.onLockStateChanged((state: LockSettingsState) => setLockState(state))
return () => {
alive = false
off()
}
}, [])
const configured = lockState?.configured === true
const usable = configured && lock.enabled
const clearFields = (): void => {
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
}
/** Run a lock operation and fold its result into the local state + feedback. */
const run = async (op: () => Promise<LockOperationResult>, okText: string): Promise<void> => {
setBusy(true)
setFeedback(null)
try {
const result = await op()
setLockState(result.state)
if (result.ok) {
clearFields()
setFeedback({ kind: 'ok', text: okText })
} else {
setFeedback({
kind: 'error',
text: lockErrorText(result.error ?? 'save-failed', result.state.cooldownMs)
})
}
} catch (err) {
console.error('[lock] operation failed', err)
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
} finally {
setBusy(false)
}
}
const savePassword = (): void => {
if (newPassword.length === 0) {
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
return
}
if (newPassword !== confirmPassword) {
setFeedback({ kind: 'error', text: t('settings.lock.password.mismatch') })
return
}
void run(
() =>
window.api.setLockPassword(configured ? { currentPassword, newPassword } : { newPassword }),
t('settings.lock.password.saved')
)
}
const clearPassword = (): void => {
if (currentPassword.length === 0) {
setFeedback({ kind: 'error', text: t('settings.lock.password.empty') })
return
}
void run(
() => window.api.clearLockPassword({ currentPassword }),
t('settings.lock.password.cleared')
)
}
/** lockNow answers with the state directly, not with an operation result. */
const lockNow = async (): Promise<void> => {
setBusy(true)
setFeedback(null)
try {
setLockState(await window.api.lockNow())
} catch (err) {
console.error('[lock] lockNow failed', err)
setFeedback({ kind: 'error', text: lockErrorText('save-failed') })
} finally {
setBusy(false)
}
}
const autoLockOptions = LOCK_AUTO_DELAYS.map((minutes: LockAutoDelay) => ({
value: minutes,
label:
minutes === 0
? t('settings.lock.autoLockOff')
: t('settings.lock.autoLockMinutes', { n: minutes })
}))
return (
<div className="settings-pane">
<div className="settings-block-label">
{t('settings.lock.password.title')}
<span className="settings-block-hint">{t('settings.lock.password.desc')}</span>
<Tag color={configured ? 'green' : 'default'}>
{configured
? t('settings.lock.password.configured')
: t('settings.lock.password.notConfigured')}
</Tag>
</div>
{configured && (
<SettingRow
label={t('settings.lock.password.current')}
control={
<Input.Password
className="settings-lock-input"
value={currentPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.currentPlaceholder')}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
}
/>
)}
<SettingRow
label={t('settings.lock.password.new')}
control={
<Input.Password
className="settings-lock-input"
value={newPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.newPlaceholder')}
onChange={(e) => setNewPassword(e.target.value)}
/>
}
/>
<SettingRow
label={t('settings.lock.password.confirm')}
control={
<Input.Password
className="settings-lock-input"
value={confirmPassword}
autoComplete="off"
spellCheck={false}
placeholder={t('settings.lock.password.confirmPlaceholder')}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
}
/>
<div className="settings-lock-actions">
<Button
type="primary"
loading={busy}
disabled={newPassword.length === 0 || confirmPassword.length === 0}
onClick={savePassword}
>
{configured ? t('settings.lock.password.change') : t('settings.lock.password.set')}
</Button>
{configured && (
<Popconfirm
title={t('settings.lock.password.clearTitle')}
description={t('settings.lock.password.clearDesc')}
okText={t('settings.lock.password.clear')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={clearPassword}
>
<Button danger disabled={busy}>
{t('settings.lock.password.clear')}
</Button>
</Popconfirm>
)}
</div>
<div
className={'settings-lock-feedback' + (feedback?.kind === 'error' ? ' is-error' : ' is-ok')}
role="status"
>
{feedback?.text ?? ''}
</div>
<div className="settings-lock-note">{t('settings.lock.password.forgot')}</div>
<SettingRow
label={t('settings.lock.enabled')}
desc={configured ? t('settings.lock.enabledDesc') : t('settings.lock.needPassword')}
control={
<Switch
checked={lock.enabled}
disabled={!configured}
onChange={(checked) => void updateLock({ enabled: checked })}
/>
}
/>
<SettingRow
label={t('settings.lock.autoLock')}
desc={t('settings.lock.autoLockDesc')}
control={
<Select
className="settings-select"
value={lock.autoLockMinutes}
disabled={!usable}
onChange={(value) => void updateLock({ autoLockMinutes: value })}
options={autoLockOptions}
/>
}
/>
<SettingRow
label={t('settings.lock.lockAtStartup')}
desc={t('settings.lock.lockAtStartupDesc')}
control={
<Switch
checked={lock.lockAtStartup}
disabled={!usable}
onChange={(checked) => void updateLock({ lockAtStartup: checked })}
/>
}
/>
<SettingRow
label={t('settings.lock.lockNow')}
desc={t('settings.lock.lockNowDesc')}
control={
<Button disabled={!configured || busy} onClick={() => void lockNow()}>
{t('settings.lock.lockNow')}
</Button>
}
/>
</div>
)
}
@@ -7,6 +7,7 @@ import { useSettingsStore } from './store'
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
import { ThemeSettingsTab } from './ThemeSettingsTab'
import { HighlightTab } from './HighlightTab'
import { LockSettingsTab } from './LockSettingsTab'
import { AboutTab } from './AboutTab'
import { ThemeEditor } from '../theme/editor/ThemeEditor'
import './settings.css'
@@ -91,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
)
},
{ key: 'system', label: t('settings.tabs.system'), children: <SystemSettingsTab /> },
{ key: 'lock', label: t('settings.tabs.lock'), children: <LockSettingsTab /> },
{ key: 'about', label: t('settings.tabs.about'), children: <AboutTab /> }
]
+37
View File
@@ -571,6 +571,43 @@
line-height: 1.6;
}
/* ---------- lock tab (settings/LockSettingsTab.tsx) ---------- */
.settings-lock-input {
width: 220px;
}
.settings-lock-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 8px 8px 0;
}
/* Reserved height, so a feedback line appearing does not push the rows below
it around. */
.settings-lock-feedback {
min-height: 18px;
margin: 6px 8px 0;
font-size: 12px;
line-height: 18px;
}
.settings-lock-feedback.is-ok {
color: #3fb950;
}
.settings-lock-feedback.is-error {
color: #f85149;
}
.settings-lock-note {
margin: 4px 8px 16px;
font-size: 12px;
line-height: 1.6;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
/* ---------- shortcut recorder (press-to-record input) ---------- */
.shortcut-input {
+11 -1
View File
@@ -1,4 +1,4 @@
import type { AppSettings, SystemSettings, TerminalSettings } from '@shared/settings'
import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme } from '@shared/theme'
import { getThemeById } from '@shared/theme'
@@ -19,6 +19,8 @@ export interface SettingsState {
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
/** shallow-merge into settings.system and persist */
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
/** shallow-merge into settings.lock and persist */
updateLock: (partial: Partial<LockSettings>) => Promise<void>
}
/** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */
@@ -68,6 +70,8 @@ async function persist(
if (terminal) patch.terminal = terminal as TerminalSettings
const system = diffGroup(prev.system, written.system)
if (system) patch.system = system as SystemSettings
const lock = diffGroup(prev.lock, written.lock)
if (lock) patch.lock = lock as LockSettings
await window.api.saveSettings(patch)
} catch (err) {
console.error(`[settings] ${label} failed, rolling back`, err)
@@ -131,6 +135,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const prev = get().settings
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
await persist(get, set, next, prev, 'updateSystem')
},
updateLock: async (partial) => {
const prev = get().settings
const next: AppSettings = { ...prev, lock: { ...prev.lock, ...partial } }
await persist(get, set, next, prev, 'updateLock')
}
}))
+8 -6
View File
@@ -1,16 +1,17 @@
import { useState } from 'react'
import { Button, Input, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import type { SshConnection, SshSecretOverride } from '@shared/connections'
import { connectPromptFor, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Connect-time gateways for one SSH connection attempt.
*
* `phase` drives which dialog shows:
* - 'secret' → secret prompt modal (password when `askPasswordAtConnect`,
* passphrase when `askPassphraseAtConnect`). This is the only
* connect-time dialog that can be cancelled, because
* - 'secret' → secret prompt modal (kind chosen by the shared
* `connectPromptFor`: password for ask-at-connect password
* auth, passphrase for ask-at-connect key auth). This is the
* only connect-time dialog that can be cancelled, because
* openSession cannot be aborted before it resolves.
* - 'connecting' → an uncancellable "连接中…" modal while openSession flies.
*
@@ -34,14 +35,15 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
const [password, setPassword] = useState('')
const [passphrase, setPassphrase] = useState('')
const prompt = conn != null && phase !== 'connecting' ? connectPromptFor(conn) : null
const stage: ConnectStage =
conn == null
? 'idle'
: phase === 'connecting'
? 'connecting'
: conn.askPasswordAtConnect
: prompt === 'password'
? 'password'
: conn.askPassphraseAtConnect
: prompt === 'passphrase'
? 'passphrase'
: 'connecting'
+12 -7
View File
@@ -25,7 +25,7 @@ import type {
} from 'dockview-react'
import 'dockview-react/dist/styles/dockview.css'
import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections'
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
@@ -660,6 +660,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
*/
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
// Tab cycling must not move an invisible workspace while the lock
// overlay covers the main window.
if (document.documentElement.dataset.locked === 'true') return
const ctrl = e.ctrlKey
const code = e.code
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return
@@ -764,12 +767,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const handleConnectRequest = useCallback(
(conn: SshConnection): void => {
if (connectInFlightRef.current > 0) return
// Ask-at-connect connections go through the secret prompt first; saved
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
// would only ever *render* a "connecting" spinner without firing openSession.
const needsPassword = conn.auth === 'password' && conn.askPasswordAtConnect
const needsPassphrase = conn.auth === 'privateKey' && conn.askPassphraseAtConnect
if (needsPassword || needsPassphrase) {
// Ask-at-connect connections go through the secret prompt first (the
// prompt kind comes from the shared `connectPromptFor`, so a stale ask
// flag from a switched auth method can never pop the wrong dialog);
// saved credentials must connect IMMEDIATELY — routing them through
// ConnectFlow would only ever *render* a "connecting" spinner without
// firing openSession.
const prompt = connectPromptFor(conn)
if (prompt !== null) {
setRequestedConn(conn)
return
}
+12
View File
@@ -10,6 +10,7 @@ import type {
} from './ipc'
import type { AppSettings } from './settings'
import type { ReleaseNote, UpdateState } from './ipc'
import type { LockOperationResult, LockPasswordInput, LockSettingsState } from './ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -100,6 +101,17 @@ export interface AppApi {
saveSettings(next: Partial<AppSettings>): Promise<AppSettings>
onSettingsChanged(cb: (s: AppSettings) => void): () => void
// ---- lock screen (main-window overlay; main owns the lock state) ----
getLockState(): Promise<LockSettingsState>
/** set or replace the password; verifies currentPassword when one exists */
setLockPassword(input: LockPasswordInput): Promise<LockOperationResult>
/** remove the password; verifies currentPassword when one exists */
clearLockPassword(input: { currentPassword?: string }): Promise<LockOperationResult>
/** answer the lock screen; resets the failure cooldown on success */
unlockLock(input: { password?: string }): Promise<LockOperationResult>
lockNow(): Promise<LockSettingsState>
onLockStateChanged(cb: (state: LockSettingsState) => void): () => void
// ---- fonts ----
listFonts(): Promise<string[]>
+22
View File
@@ -67,6 +67,28 @@ export interface SshSecretOverride {
passphrase?: string
}
/** The secret kind a connection must prompt for before connecting. */
export type ConnectPromptKind = 'password' | 'passphrase'
/**
* Which secret dialog (if any) a connect attempt for `conn` must show first.
*
* Single source of truth for Workspace's `handleConnectRequest` and
* ConnectFlow's stage selection: both used to derive it independently and the
* renderer-only check on ConnectFlow's side ignored `auth`, so a key-auth
* bookmark with a stale `askPasswordAtConnect` flag (left over from an edit
* that switched auth methods) popped a password dialog.
*
* Each ask* flag only counts for the auth method it belongs to; `null` means
* saved credentials exist and the connection must start immediately (existing
* UX — such connections must never route through the secret prompt).
*/
export function connectPromptFor(conn: SshConnection): ConnectPromptKind | null {
if (conn.auth === 'password' && conn.askPasswordAtConnect) return 'password'
if (conn.auth === 'privateKey' && conn.askPassphraseAtConnect) return 'passphrase'
return null
}
export interface SessionOpenOptions {
kind: 'local' | 'ssh'
/** ssh only */
+1
View File
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})',
'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}',
'main.ssh.knownHostsUnreadable': 'The known-hosts file (ssh_known_hosts.json) exists but could not be read. The connection was refused to protect the pinned fingerprints. Repair or delete the file and try again.',
'main.ssh.hostKeyRejected': 'The user rejected the host key',
'main.ssh.connectFailed': 'Connection failed {host}:{port}: {detail}',
'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}',
+47
View File
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
'settings.tabs.highlight': 'Highlighting',
'settings.tabs.theme': 'Themes',
'settings.tabs.system': 'System',
'settings.tabs.lock': 'Lock',
'settings.tabs.about': 'About',
'settings.resizeHandle': 'Drag to resize',
'settings.preview': 'Preview',
@@ -100,6 +101,52 @@ const settings: Record<string, string> = {
'settings.system.shortcutPlaceholder': 'Click and press a shortcut, leave empty to disable',
'settings.system.shortcutConflict':
'This shortcut is already used by the app (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn). Please choose another.',
'settings.lock.password.title': 'Lock password',
'settings.lock.password.desc': 'A password is required before the lock can be enabled',
'settings.lock.password.configured': 'Set',
'settings.lock.password.notConfigured': 'Not set',
'settings.lock.password.current': 'Current password',
'settings.lock.password.currentPlaceholder': 'Enter the current password',
'settings.lock.password.new': 'New password',
'settings.lock.password.newPlaceholder': 'Enter a new password',
'settings.lock.password.confirm': 'Confirm new password',
'settings.lock.password.confirmPlaceholder': 'Repeat the new password',
'settings.lock.password.set': 'Set password',
'settings.lock.password.change': 'Change password',
'settings.lock.password.clear': 'Remove password',
'settings.lock.password.clearTitle': 'Remove the lock password?',
'settings.lock.password.clearDesc':
'The lock is turned off along with it. The current password is required to confirm.',
'settings.lock.password.mismatch': 'The two new passwords do not match',
'settings.lock.password.empty': 'Enter a password',
'settings.lock.password.saved': 'Password saved',
'settings.lock.password.cleared': 'Lock password removed',
'settings.lock.password.forgot':
'A forgotten lock password cannot be recovered from any cloud or backdoor: the only way back in is deleting this machine\u2019s lock data and setting a new one.',
'settings.lock.enabled': 'Enable lock',
'settings.lock.enabledDesc':
'Lock the main window when idle or at startup; unlocking needs the password above',
'settings.lock.needPassword': 'Set a lock password first',
'settings.lock.autoLock': 'Lock when idle',
'settings.lock.autoLockDesc': 'Lock once the system has been idle this long (0 = never)',
'settings.lock.autoLockOff': 'Off',
'settings.lock.autoLockMinutes': '{n} min',
'settings.lock.lockAtStartup': 'Lock at startup',
'settings.lock.lockAtStartupDesc': 'Ask for the password right after every launch',
'settings.lock.lockNow': 'Lock now',
'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running)',
'settings.lock.title': 'Locked',
'settings.lock.screenDesc': 'Enter the lock password to unlock',
'settings.lock.passwordPlaceholder': 'Password',
'settings.lock.unlock': 'Unlock',
'settings.lock.unlocking': 'Unlocking…',
'settings.lock.screenForgot':
'Forgot it? The lock password cannot be recovered \u2014 the only way out is deleting this machine\u2019s lock data.',
'settings.lock.error.invalidPassword': 'Password is invalid (empty or too short)',
'settings.lock.error.wrongPassword': 'Wrong password',
'settings.lock.error.cooldown': 'Too many attempts \u2014 try again in {n}s',
'settings.lock.error.cooldownGeneric': 'Too many attempts \u2014 try again later',
'settings.lock.error.saveFailed': 'Save failed, please retry',
'settings.resetTerminal': 'Reset terminal settings',
'settings.resetTerminalTitle': 'Reset terminal settings?',
'settings.resetTerminalDesc': 'All terminal settings — font, cursor, rendering and theme — will be restored to defaults.',
+1
View File
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})',
'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}',
'main.ssh.knownHostsUnreadable': '既知ホストファイル (ssh_known_hosts.json) が存在しますが読み取れないため、保存済みフィンガープリントを保護するために接続を拒否しました。ファイルを修復または削除してから再試行してください。',
'main.ssh.hostKeyRejected': 'ユーザーがホスト鍵を拒否しました',
'main.ssh.connectFailed': '接続に失敗しました {host}:{port}: {detail}',
'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}',
+47
View File
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
'settings.tabs.highlight': 'ハイライト',
'settings.tabs.theme': 'テーマ',
'settings.tabs.system': 'システム',
'settings.tabs.lock': 'ロック',
'settings.tabs.about': 'このアプリについて',
'settings.resizeHandle': 'ドラッグしてサイズ変更',
'settings.preview': 'プレビュー',
@@ -97,6 +98,52 @@ const settings: Record<string, string> = {
'settings.system.shortcutPlaceholder': 'クリックしてショートカットを押す。空欄で無効',
'settings.system.shortcutConflict':
'このショートカットはアプリ内で既に使用されています(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn)。別のものを選んでください。',
'settings.lock.password.title': 'ロックパスワード',
'settings.lock.password.desc': 'パスワードを設定するとロックを有効にできます',
'settings.lock.password.configured': '設定済み',
'settings.lock.password.notConfigured': '未設定',
'settings.lock.password.current': '現在のパスワード',
'settings.lock.password.currentPlaceholder': '現在のパスワードを入力',
'settings.lock.password.new': '新しいパスワード',
'settings.lock.password.newPlaceholder': '新しいパスワードを入力',
'settings.lock.password.confirm': '新しいパスワード(確認)',
'settings.lock.password.confirmPlaceholder': 'もう一度入力してください',
'settings.lock.password.set': 'パスワードを設定',
'settings.lock.password.change': 'パスワードを変更',
'settings.lock.password.clear': 'パスワードを削除',
'settings.lock.password.clearTitle': 'ロックパスワードを削除しますか?',
'settings.lock.password.clearDesc':
'削除するとロックも無効になります。確認のため現在のパスワードが必要です。',
'settings.lock.password.mismatch': '新しいパスワードが一致しません',
'settings.lock.password.empty': 'パスワードを入力してください',
'settings.lock.password.saved': 'パスワードを保存しました',
'settings.lock.password.cleared': 'ロックパスワードを削除しました',
'settings.lock.password.forgot':
'パスワードを忘れてもクラウドから復元する方法はなく、回避手段もありません。本機のロックデータを削除して設定し直すしかありません。',
'settings.lock.enabled': 'ロックを有効にする',
'settings.lock.enabledDesc':
'アイドル時と起動時にメインウィンドウをロックします。解除には上のパスワードが必要です',
'settings.lock.needPassword': '先にロックパスワードを設定してください',
'settings.lock.autoLock': 'アイドル時に自動ロック',
'settings.lock.autoLockDesc': 'システムがこの時間アイドル状態になったらロックします(0 は無効)',
'settings.lock.autoLockOff': '無効',
'settings.lock.autoLockMinutes': '{n} 分',
'settings.lock.lockAtStartup': '起動時にロック',
'settings.lock.lockAtStartupDesc': '起動直後にパスワードの入力を求めます',
'settings.lock.lockNow': '今すぐロック',
'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます)',
'settings.lock.title': 'ロック中',
'settings.lock.screenDesc': 'ロックパスワードを入力して解除します',
'settings.lock.passwordPlaceholder': 'パスワード',
'settings.lock.unlock': '解除',
'settings.lock.unlocking': '解除中…',
'settings.lock.screenForgot':
'パスワードを忘れた場合、復元する方法はありません。本機のロックデータを削除するしかありません。',
'settings.lock.error.invalidPassword': 'パスワードが無効です(空または短すぎます)',
'settings.lock.error.wrongPassword': 'パスワードが違います',
'settings.lock.error.cooldown': '試行回数が多すぎます。{n} 秒後にもう一度お試しください',
'settings.lock.error.cooldownGeneric': '試行回数が多すぎます。しばらくしてからお試しください',
'settings.lock.error.saveFailed': '保存に失敗しました。もう一度お試しください',
'settings.resetTerminal': 'ターミナル設定をリセット',
'settings.resetTerminalTitle': 'ターミナル設定をリセットしますか?',
'settings.resetTerminalDesc': 'フォント、カーソル、レンダリング、テーマなどすべてのターミナル設定がデフォルトに戻ります。',
+1
View File
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
'main.ssh.connectTimeout': '连接超时 ({host}:{port})',
'main.ssh.saveFingerprintFailed': '保存主机指纹失败: {detail}',
'main.ssh.knownHostsUnreadable': '已知主机文件 (ssh_known_hosts.json) 存在但无法读取,为避免覆盖已保存的指纹,本次连接被拒绝。请修复或删除该文件后重试。',
'main.ssh.hostKeyRejected': '用户拒绝了主机指纹',
'main.ssh.connectFailed': '连接失败 {host}:{port}: {detail}',
'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}',
+44
View File
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
'settings.tabs.highlight': '高亮',
'settings.tabs.theme': '主题',
'settings.tabs.system': '系统',
'settings.tabs.lock': '锁屏',
'settings.tabs.about': '关于',
'settings.resizeHandle': '拖拽调整大小',
'settings.preview': '预览',
@@ -94,6 +95,49 @@ const settings: Record<string, string> = {
'settings.system.shortcutPlaceholder': '点击后按下快捷键,留空禁用',
'settings.system.shortcutConflict':
'该组合键已被应用内快捷键占用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),请换一个。',
'settings.lock.password.title': '锁屏密码',
'settings.lock.password.desc': '设置密码后才能启用锁屏',
'settings.lock.password.configured': '已配置',
'settings.lock.password.notConfigured': '未配置',
'settings.lock.password.current': '当前密码',
'settings.lock.password.currentPlaceholder': '请输入当前密码',
'settings.lock.password.new': '新密码',
'settings.lock.password.newPlaceholder': '请输入新密码',
'settings.lock.password.confirm': '确认新密码',
'settings.lock.password.confirmPlaceholder': '再次输入新密码',
'settings.lock.password.set': '设置密码',
'settings.lock.password.change': '修改密码',
'settings.lock.password.clear': '清除密码',
'settings.lock.password.clearTitle': '清除锁屏密码?',
'settings.lock.password.clearDesc': '清除后锁屏会一并关闭,需要当前密码确认。',
'settings.lock.password.mismatch': '两次输入的新密码不一致',
'settings.lock.password.empty': '请填写密码',
'settings.lock.password.saved': '密码已保存',
'settings.lock.password.cleared': '锁屏密码已清除',
'settings.lock.password.forgot':
'忘记锁屏密码无法通过云端找回,也没有后门:只能删除本机锁屏数据后重新设置。',
'settings.lock.enabled': '启用锁屏',
'settings.lock.enabledDesc': '闲置或启动时锁定主窗口,解锁需要上面的密码',
'settings.lock.needPassword': '请先设置锁屏密码',
'settings.lock.autoLock': '闲置自动锁屏',
'settings.lock.autoLockDesc': '系统闲置超过该时长后自动锁定(0 表示从不)',
'settings.lock.autoLockOff': '关闭',
'settings.lock.autoLockMinutes': '{n} 分钟',
'settings.lock.lockAtStartup': '启动时锁屏',
'settings.lock.lockAtStartupDesc': '每次启动后先要求输入密码',
'settings.lock.lockNow': '立即锁屏',
'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行)',
'settings.lock.title': '已锁定',
'settings.lock.screenDesc': '输入锁屏密码解锁',
'settings.lock.passwordPlaceholder': '密码',
'settings.lock.unlock': '解锁',
'settings.lock.unlocking': '解锁中…',
'settings.lock.screenForgot': '忘记密码?锁屏密码无法找回,只能删除本机锁屏数据。',
'settings.lock.error.invalidPassword': '密码无效(不能为空或过短)',
'settings.lock.error.wrongPassword': '密码错误',
'settings.lock.error.cooldown': '尝试次数过多,请等待 {n} 秒后重试',
'settings.lock.error.cooldownGeneric': '尝试次数过多,请稍后再试',
'settings.lock.error.saveFailed': '保存失败,请重试',
'settings.resetTerminal': '恢复默认终端设置',
'settings.resetTerminalTitle': '恢复默认终端设置?',
'settings.resetTerminalDesc': '字体、光标、渲染与主题等全部终端设置将恢复为默认值。',
+1
View File
@@ -17,6 +17,7 @@ const main: Record<string, string> = {
'main.ssh.connectTimeout': '連線逾時 ({host}:{port})',
'main.ssh.saveFingerprintFailed': '儲存主機指紋失敗: {detail}',
'main.ssh.knownHostsUnreadable': '已知主機檔案 (ssh_known_hosts.json) 存在但無法讀取,為避免覆寫已儲存的指紋,本次連線被拒絕。請修復或刪除該檔案後重試。',
'main.ssh.hostKeyRejected': '使用者拒絕了主機指紋',
'main.ssh.connectFailed': '連線失敗 {host}:{port}: {detail}',
'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}',
+44
View File
@@ -7,6 +7,7 @@ const settings: Record<string, string> = {
'settings.tabs.highlight': '高亮',
'settings.tabs.theme': '主題',
'settings.tabs.system': '系統',
'settings.tabs.lock': '鎖定畫面',
'settings.tabs.about': '關於',
'settings.resizeHandle': '拖曳調整大小',
'settings.preview': '預覽',
@@ -94,6 +95,49 @@ const settings: Record<string, string> = {
'settings.system.shortcutPlaceholder': '點擊後按下快速鍵,留空為停用',
'settings.system.shortcutConflict':
'此組合鍵已被應用內快捷鍵佔用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),請換一個。',
'settings.lock.password.title': '鎖定密碼',
'settings.lock.password.desc': '設定密碼後才能啟用鎖定',
'settings.lock.password.configured': '已設定',
'settings.lock.password.notConfigured': '未設定',
'settings.lock.password.current': '目前密碼',
'settings.lock.password.currentPlaceholder': '請輸入目前密碼',
'settings.lock.password.new': '新密碼',
'settings.lock.password.newPlaceholder': '請輸入新密碼',
'settings.lock.password.confirm': '確認新密碼',
'settings.lock.password.confirmPlaceholder': '再次輸入新密碼',
'settings.lock.password.set': '設定密碼',
'settings.lock.password.change': '變更密碼',
'settings.lock.password.clear': '清除密碼',
'settings.lock.password.clearTitle': '清除鎖定密碼?',
'settings.lock.password.clearDesc': '清除後鎖定會一併關閉,需要目前密碼確認。',
'settings.lock.password.mismatch': '兩次輸入的新密碼不一致',
'settings.lock.password.empty': '請填寫密碼',
'settings.lock.password.saved': '密碼已儲存',
'settings.lock.password.cleared': '鎖定密碼已清除',
'settings.lock.password.forgot':
'忘記鎖定密碼無法透過雲端找回,也沒有後門:只能刪除本機鎖定資料後重新設定。',
'settings.lock.enabled': '啟用鎖定',
'settings.lock.enabledDesc': '閒置或啟動時鎖定主視窗,解鎖需要上面的密碼',
'settings.lock.needPassword': '請先設定鎖定密碼',
'settings.lock.autoLock': '閒置自動鎖定',
'settings.lock.autoLockDesc': '系統閒置超過該時間後自動鎖定(0 表示從不)',
'settings.lock.autoLockOff': '關閉',
'settings.lock.autoLockMinutes': '{n} 分鐘',
'settings.lock.lockAtStartup': '啟動時鎖定',
'settings.lock.lockAtStartupDesc': '每次啟動後先要求輸入密碼',
'settings.lock.lockNow': '立即鎖定',
'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作)',
'settings.lock.title': '已鎖定',
'settings.lock.screenDesc': '輸入鎖定密碼以解鎖',
'settings.lock.passwordPlaceholder': '密碼',
'settings.lock.unlock': '解鎖',
'settings.lock.unlocking': '解鎖中…',
'settings.lock.screenForgot': '忘記密碼?鎖定密碼無法找回,只能刪除本機鎖定資料。',
'settings.lock.error.invalidPassword': '密碼無效(不能為空或過短)',
'settings.lock.error.wrongPassword': '密碼錯誤',
'settings.lock.error.cooldown': '嘗試次數過多,請等待 {n} 秒後重試',
'settings.lock.error.cooldownGeneric': '嘗試次數過多,請稍後再試',
'settings.lock.error.saveFailed': '儲存失敗,請重試',
'settings.resetTerminal': '恢復預設終端設定',
'settings.resetTerminalTitle': '恢復預設終端設定?',
'settings.resetTerminalDesc': '字體、游標、渲染與主題等全部終端設定將恢復為預設值。',
+52 -1
View File
@@ -110,9 +110,60 @@ export const Ipc = {
/** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */
CWD_REPORT: 'session:cwdReport',
/** open a local directory in the OS file manager (terminal toolbar) */
CWD_OPEN: 'session:cwdOpen'
CWD_OPEN: 'session:cwdOpen',
// ---- lock screen (main-window overlay; the main process owns the state) ----
/** renderer pulls the current lock state without changing it */
LOCK_STATE_GET: 'lock:stateGet',
/** set or replace the password; an existing one must be verified first */
LOCK_SET_PASSWORD: 'lock:setPassword',
/** remove the password; the existing one must be verified first */
LOCK_CLEAR_PASSWORD: 'lock:clearPassword',
LOCK_UNLOCK: 'lock:unlock',
LOCK_NOW: 'lock:now',
/** main -> renderer broadcast: LockSettingsState */
LOCK_STATE_CHANGED: 'lock:state'
} as const
/**
* The lock state the renderer sees. Deliberately free of salt, hash and
* password: the stored verifier never leaves the main process.
*/
export interface LockSettingsState {
/** a password is set, so the lock can engage at all */
configured: boolean
enabled: boolean
autoLockMinutes: number
lockAtStartup: boolean
locked: boolean
/** remaining lockout in ms; absent while no cooldown is running */
cooldownMs?: number
}
/** Passwords travel one way only: in. Neither field is ever echoed back. */
export interface LockPasswordInput {
/** required when a password is already set (replace / clear) */
currentPassword?: string
/** required when setting or replacing */
newPassword?: string
}
export type LockOperationError =
/** the offered new password is unusable (empty, too short, too long) */
| 'invalid-password'
/** the offered current password does not match */
| 'wrong-password'
/** too many failed attempts: retry after state.cooldownMs */
| 'cooldown'
/** the verifier could not be written to disk */
| 'save-failed'
export interface LockOperationResult {
ok: boolean
state: LockSettingsState
error?: LockOperationError
}
export interface PtyCreateOptions {
cwd?: string
/** executable; omit for platform default shell */
+41 -1
View File
@@ -125,6 +125,41 @@ export function highlightModeOf(value: unknown): HighlightMode {
return value === 'basic' || value === 'off' ? value : 'all'
}
/**
* The delays offered for the idle auto-lock, in minutes. A closed set rather
* than a free number: `0` means "never", and the settings UI, the sanitizer and
* the idle watcher all read this one list so they cannot disagree.
*/
export type LockAutoDelay = 0 | 1 | 5 | 15 | 30 | 60
export const LOCK_AUTO_DELAYS: LockAutoDelay[] = [0, 1, 5, 15, 30, 60]
/**
* Read a stored auto-lock delay, tolerating a hand-edited settings.json: only a
* whitelisted value survives, anything else falls back to 0 (never).
*/
export function lockAutoDelayOf(value: unknown): LockAutoDelay {
return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay) ? (value as LockAutoDelay) : 0
}
export function isLockAutoDelay(value: unknown): value is LockAutoDelay {
return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay)
}
/**
* Screen-lock preferences. The password itself is never stored here — the
* verifier lives in `<userData>/lock.json` (src/main/lockStore.ts), so settings
* can be copied around, synced or logged without leaking it.
*/
export interface LockSettings {
/** master switch: without it nothing ever locks, idle watcher included */
enabled: boolean
/** minutes of system idle before the screen locks; 0 = never */
autoLockMinutes: LockAutoDelay
/** start each run locked (asks for the password before the app is usable) */
lockAtStartup: boolean
}
export interface SystemSettings {
/** register the app to launch at OS login */
launchAtLogin: boolean
@@ -171,6 +206,8 @@ export interface AppSettings {
/** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */
highlightProfiles: HighlightProfile[]
system: SystemSettings
/** screen lock; the password verifier lives outside settings (lock.json) */
lock: LockSettings
}
const RULE = (
@@ -429,5 +466,8 @@ 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' }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, 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 }
}
+8
View File
@@ -19,7 +19,15 @@ const BUNDLES = [
// ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`.
{ entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] },
{ entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' },
// Known-hosts store: TOFU / changed / unreadable fail-closed behavior.
{ entry: 'src/main/knownHosts.ts', out: 'tests/.known-hosts.cjs' },
{ entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' },
// Lock-password store: scrypt verifier, round trip, damaged-file handling.
{ entry: 'src/main/lockStore.ts', out: 'tests/.lock-store.cjs' },
// Lock controller: cooldown ladder, serialized attempts, persisted flags.
// Pulls in settingsStore + broadcast, which is why the electron stub needs
// powerMonitor as well.
{ entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' },
// zmodem.js stays bundled (NOT external) — the test drives a second in-process
// Sentry from the same library.
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
+108 -5
View File
@@ -10,8 +10,8 @@
* --alias:@shared=./src/shared
* Run: node tests/commands-store.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { basename, join, resolve, sep } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
@@ -39,7 +39,13 @@ let commandsMod
try {
commandsMod = require_('./.commands-store.cjs')
} catch {
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
fail('bundle not found — run: node tests/build-bundles.cjs (or the esbuild line in the header)')
}
let knownHostsMod
try {
knownHostsMod = require_('./.known-hosts.cjs')
} catch {
fail('bundle not found — run: node tests/build-bundles.cjs (builds tests/.known-hosts.cjs)')
}
// ---- 2. Store over the temp userData; capture openDir target -------------------
@@ -227,8 +233,105 @@ const expected =
'partial-tail'
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
// The store wrote into a temp userData dir; drop it so repeated runs do not
// litter %TEMP%.
// ---- 7. index.json path containment (hydrateIndex) -----------------------------
// index.json is data, not trust: a tampered `file` value must never turn
// logWrite into an arbitrary-path append. Only entries that resolve inside
// logsDir and point at a regular file may hydrate.
const logsDir = join(userData, 'logs')
mkdirSync(join(logsDir, 'subdir'), { recursive: true })
const escapeFile = join(userData, 'escaped.log')
const fwdSlashEntry = `${userData.split(sep).join('/')}/logs/${basename(start.file)}`
const driveCaseEntry =
process.platform === 'win32'
? start.file.replace(/^[A-Z]:/, (m) => m.toLowerCase())
: start.file
writeFileSync(
join(logsDir, 'index.json'),
JSON.stringify([
{ sessionId: 'escape-abs', file: escapeFile, startedAt: 1 }, // outside logsDir
{ sessionId: 'escape-dotdot', file: join(logsDir, '..', 'escaped2.log'), startedAt: 2 },
{ sessionId: 'escape-dir', file: join(logsDir, 'subdir'), startedAt: 3 }, // not a regular file
{ sessionId: 'ok-legit', file: start.file, startedAt: 4, endedAt: 5 }, // real hydrated log
{ sessionId: 'ok-fwdslash', file: fwdSlashEntry, startedAt: 6, endedAt: 7 }, // same file, '/' separators
{ sessionId: 'ok-drivecase', file: driveCaseEntry, startedAt: 8, endedAt: 9 } // same file, 'C:' recased
]),
'utf8'
)
const store3 = new commandsMod.CommandsStore(userData)
let hydrated = store3.listSessionLogs()
const ids = hydrated.map((m) => m.sessionId).sort()
ok(!ids.includes('escape-abs'), 'absolute path outside logsDir is dropped')
ok(!ids.includes('escape-dotdot'), '`..` escape out of logsDir is dropped')
ok(!ids.includes('escape-dir'), 'entry pointing at a directory is dropped')
ok(ids.includes('ok-legit') && ids.includes('ok-fwdslash'), 'legit entry survives, also spelled with / separators')
if (process.platform === 'win32') {
ok(ids.includes('ok-drivecase'), 'drive-letter case does not break containment')
}
// The dropped entries must not come back either: a later index persist only
// ever writes the contained subset.
store3.logStart('persist-1')
const persisted = JSON.parse(readFileSync(join(logsDir, 'index.json'), 'utf8'))
ok(
!persisted.some((m) => resolve(m.file) === resolve(escapeFile)) &&
!persisted.some((m) => resolve(m.file) === resolve(join(userData, 'escaped2.log'))),
're-persisted index keeps out-of-logsDir entries out'
)
ok(
!existsSync(escapeFile) && !existsSync(join(userData, 'escaped2.log')),
'no log file was created outside logsDir'
)
// ---- 8. KnownHostsStore: TOFU, change detect, unreadable fail-closed -----------
const khDir = mkdtempSync(join(tmpdir(), 'm5-kh-'))
const khFile = join(khDir, 'ssh_known_hosts.json')
const kh = new knownHostsMod.KnownHostsStore(khFile)
const keyA = Buffer.from('host-key-a')
const keyB = Buffer.from('host-key-b')
const fpA = knownHostsMod.fingerprintOf(keyA)
const fpB = knownHostsMod.fingerprintOf(keyB)
ok(kh.check('h1', 22, keyA).status === 'new', 'knownHosts: missing file is TOFU new')
kh.accept('h1', 22, keyA, fpA)
ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: accepted key matches')
const changed = kh.check('h1', 22, keyB)
ok(changed.status === 'changed' && changed.stored.fingerprint === fpA, 'knownHosts: other key reports changed + stored fingerprint')
// Truncated JSON: the file exists but cannot be trusted.
writeFileSync(khFile, '{"version":1,"entries":[{"id":"x"', 'utf8')
ok(kh.check('h1', 22, keyB).status === 'unreadable', 'knownHosts: corrupt store checks as unreadable, never new')
let threw = false
try {
kh.accept('h1', 22, keyB, fpB)
} catch {
threw = true
}
ok(threw, 'knownHosts: accept refuses to overwrite an unreadable store')
// Valid JSON but not the store shape counts as unreadable too.
writeFileSync(khFile, '{"nope":true}', 'utf8')
ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: shapeless JSON checks as unreadable')
// A directory at the store path (EISDIR) is unreadable, not "no pins yet".
writeFileSync(
khFile,
JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] })
)
rmSync(khFile)
mkdirSync(khFile)
ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: a directory at the store path is unreadable, not new')
rmSync(khFile, { recursive: true, force: true })
// Restore the good store: a transient unreadable episode lost nothing.
writeFileSync(
khFile,
JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] })
)
ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: pins survive an unreadable episode')
kh.accept('h1', 22, keyB, fpB)
ok(kh.check('h1', 22, keyB).status === 'match', 'knownHosts: accept works again once the store is readable')
rmSync(khDir, { recursive: true, force: true })
// All stores wrote into temp dirs; drop them so repeated runs do not litter.
rmSync(userData, { recursive: true, force: true })
console.log('\n[commands] ALL CHECKS PASSED')
+3
View File
@@ -28,6 +28,9 @@ module.exports = {
isRegistered: () => false
},
webContents: {},
powerMonitor: {
getSystemIdleTime: () => 0
},
powerSaveBlocker: {
start: () => 1,
stop: () => {},
+420
View File
@@ -0,0 +1,420 @@
/**
* Lock-controller self-test (lock-controller.mjs).
*
* Offline and Electron-free: every input the controller has — the two stores,
* the settings, the clock, the idle time, the publisher — is injectable, so the
* whole state machine runs under plain Node without a window or a real 15s poll.
* Guards the contract the lock screen depends on:
* - the backoff ladder (1s / 2s / 5s / 10s / 30s, capped) and that an attempt
* inside the window is refused as `cooldown`, not answered as `wrong-password`
* - a success clears the failure count and the backoff with it
* - concurrent attempts are serialized, so firing two at once cannot step
* around the backoff (the regression this file exists for)
* - `locked` / `failures` / `cooldownUntil` survive a restart, and `start()`
* restores them silently (nothing is listening yet)
* - a stored lock without a verifier is discarded, never applied
* - a cooldown restored from the future is clamped (clock moved backwards)
* - idle auto-lock fires exactly once, and never on a broken or disabled input
* - clearing the password turns the preferences off and unlocks
* - lockNow()/unlock() are no-ops in the directions that would trap the user
*
* Build: node tests/build-bundles.cjs
* Run: node tests/lock-controller.mjs (must exit 0)
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const dir = mkdtempSync(join(tmpdir(), 'ot-lockctl-'))
// The controller bundle re-exports only the controller; the stores come from the
// lock-store bundle, so the controller is tested against the real scrypt store
// rather than a hand-written double.
const { LockController } = require('./.lock-controller.cjs')
const { LockStore, LockStateStore, defaultLockStatePath } = require('./.lock-store.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed++
}
const PASSWORD = 'correct horse'
/** Controllable clock: the whole ladder is driven without ever waiting on it. */
let clockMs = 1_700_000_000_000
const now = () => clockMs
const advance = (ms) => {
clockMs += ms
}
/** Idle auto-lock off, so the real 15s poll `start()` installs is a no-op in
* every test that is not about idle time (no stray publish mid-assertion). */
const idleOff = () => ({ enabled: false, autoLockMinutes: 0, lockAtStartup: false })
let seq = 0
const freshStore = async () => {
const store = new LockStore(join(dir, `lock-${seq++}.json`))
await store.setPassword(PASSWORD)
return store
}
const freshState = () => new LockStateStore(join(dir, `state-${seq++}.json`))
/** A controller with every input pinned; returns the publishes it produced. */
const makeController = (opts = {}) => {
const publishes = []
const controller = new LockController({
store: opts.store,
stateStore: opts.stateStore ?? freshState(),
getLockSettings: opts.getLockSettings ?? idleOff,
publish: (state) => publishes.push(state),
now,
idleSeconds: opts.idleSeconds ?? (() => 0),
clearLockPreferences: opts.clearLockPreferences ?? (() => {})
})
return { controller, publishes }
}
/** A real store whose verify() takes a couple of turns of the event loop, so two
* attempts fired without awaiting genuinely overlap unless they are serialized. */
const slowStore = (store, delayMs = 20) => ({
isConfigured: () => store.isConfigured(),
setPassword: (password) => store.setPassword(password),
clear: () => store.clear(),
verify: async (password) => {
await new Promise((resolve) => setTimeout(resolve, delayMs))
return store.verify(password)
}
})
// ---- 1. backoff ladder ------------------------------------------------------
console.log('[cooldown ladder]')
{
const store = await freshStore()
const stateStore = freshState()
const { controller } = makeController({ store, stateStore })
controller.lockNow()
ok(controller.isLocked() === true, 'a configured lock can engage')
const steps = [1000, 2000, 5000, 10000, 30000, 30000]
for (let i = 0; i < steps.length; i++) {
const attempt = await controller.unlock({ password: 'wrong' })
ok(attempt.ok === false && attempt.error === 'wrong-password', `failure ${i + 1} reports wrong-password`)
ok(attempt.state.cooldownMs === steps[i], `failure ${i + 1} backs off for ${steps[i]}ms`)
ok(stateStore.load().failures === i + 1, `failure ${i + 1} is on disk`)
// A second guess inside the window must be answered `cooldown`. Answering
// `wrong-password` would mean the password was verified again, so a caller
// could keep guessing (and keep escalating the ladder) with no waiting.
const blocked = await controller.unlock({ password: 'wrong' })
ok(blocked.ok === false && blocked.error === 'cooldown', `failure ${i + 1}: an immediate retry is refused as cooldown`)
ok(blocked.state.cooldownMs === steps[i], `failure ${i + 1}: a refused retry does not restart the backoff`)
ok(stateStore.load().failures === i + 1, `failure ${i + 1}: a refused retry is not counted as a failure`)
// Advance by exactly the step: the remaining cooldown reaches 0, so the next
// iteration is allowed through the gate again.
advance(steps[i])
}
const opened = await controller.unlock({ password: PASSWORD })
ok(opened.ok === true && opened.state.locked === false, 'the correct password still opens the screen after the full ladder')
}
// ---- 2. success clears the backoff ------------------------------------------
console.log('[success clears]')
{
const store = await freshStore()
const stateStore = freshState()
const { controller } = makeController({ store, stateStore })
controller.lockNow()
const first = await controller.unlock({ password: 'wrong' })
advance(first.state.cooldownMs)
const second = await controller.unlock({ password: 'wrong' })
ok(stateStore.load().failures === 2, 'two failures are recorded')
advance(second.state.cooldownMs)
const opened = await controller.unlock({ password: PASSWORD })
ok(opened.ok === true, 'the correct password opens the screen')
ok(opened.state.cooldownMs === undefined, 'a success reports no cooldown')
ok(
stateStore.load().failures === 0 && stateStore.load().cooldownUntil === 0,
'a success clears the failure count and the backoff on disk'
)
// Indirect proof that the ladder really restarted: the next failure waits 1s,
// which it could not do if the two earlier failures were still counted.
controller.lockNow()
const after = await controller.unlock({ password: 'wrong' })
ok(after.state.cooldownMs === 1000, 'the failure after a success starts the ladder over at 1s')
}
// ---- 3. concurrent attempts are serialized ----------------------------------
console.log('[serialized attempts]')
{
const store = await freshStore()
const stateStore = freshState()
const { controller } = makeController({ store: slowStore(store), stateStore })
controller.lockNow()
// Fired without awaiting: both calls are already inside the controller before
// either verification resolves.
const [first, second] = await Promise.all([
controller.unlock({ password: 'wrong' }),
controller.unlock({ password: 'wrong' })
])
ok(first.error === 'wrong-password', 'the first of two concurrent attempts is verified and fails')
ok(second.error === 'cooldown', 'the second is refused by the backoff the first just raised')
ok(stateStore.load().failures === 1, 'two concurrent attempts count as one failure')
advance(1000)
const after = await controller.unlock({ password: PASSWORD })
ok(after.ok === true, 'the correct password still works once the cooldown has passed')
}
// ---- 4. persistence round trip ----------------------------------------------
console.log('[persistence]')
{
const lockFile = join(dir, 'lock-persist.json')
const stateFile = join(dir, 'state-persist.json')
const store = new LockStore(lockFile)
await store.setPassword(PASSWORD)
const first = makeController({ store, stateStore: new LockStateStore(stateFile) })
first.controller.lockNow()
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'locking writes locked:true to the state file')
// A relaunch is not a way out of a lock that was already up.
const second = makeController({
store: new LockStore(lockFile),
stateStore: new LockStateStore(stateFile)
})
second.controller.start()
ok(second.controller.isLocked() === true, 'a new instance over the same files starts locked')
ok(second.publishes.length === 0, 'start() does not publish: nothing is listening yet')
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'and the restored lock is not written back as unlocked')
const opened = await second.controller.unlock({ password: PASSWORD })
ok(opened.ok === true && opened.state.locked === false, 'the correct password opens the restored lock')
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === false, 'unlocking writes locked:false to the state file')
const third = makeController({
store: new LockStore(lockFile),
stateStore: new LockStateStore(stateFile)
})
third.controller.start()
ok(third.controller.isLocked() === false, 'a fresh instance after an unlock does not lock')
}
// ---- 5. a stored lock with no verifier is discarded -------------------------
console.log('[start without a verifier]')
{
const stateFile = join(dir, 'state-orphan.json')
writeFileSync(stateFile, JSON.stringify({ version: 1, locked: true, failures: 2, cooldownUntil: 0 }), 'utf8')
const { controller, publishes } = makeController({
store: new LockStore(join(dir, 'lock-missing.json')),
stateStore: new LockStateStore(stateFile)
})
controller.start()
ok(existsSync(stateFile) === false, 'the stored flags are deleted: no password could ever open that lock again')
ok(controller.isLocked() === false, 'and the screen is not locked')
ok(publishes.length === 0, 'start() does not publish')
}
// ---- 6. a cooldown restored from the future is clamped ----------------------
console.log('[clock skew]')
{
const store = await freshStore()
const stateFile = join(dir, 'state-skew.json')
writeFileSync(
stateFile,
JSON.stringify({ version: 1, locked: false, failures: 3, cooldownUntil: now() + 3_600_000 }),
'utf8'
)
const { controller, publishes } = makeController({ store, stateStore: new LockStateStore(stateFile) })
controller.start()
ok(controller.getState().cooldownMs === 30000, 'an hour-long restored cooldown is clamped to the longest step')
ok(publishes.length === 0, 'start() does not publish')
// The restored failure count still drives the ladder: the 4th failure waits 10s.
advance(30000)
controller.lockNow()
const attempt = await controller.unlock({ password: 'wrong' })
ok(attempt.state.cooldownMs === 10000, 'the restored failure count still picks the matching step')
}
{
const store = await freshStore()
const stateFile = join(dir, 'state-keep.json')
writeFileSync(
stateFile,
JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: now() + 5000 }),
'utf8'
)
const { controller } = makeController({ store, stateStore: new LockStateStore(stateFile) })
controller.start()
ok(controller.getState().cooldownMs === 5000, 'a legitimately stored cooldown is kept exactly as it is')
}
// ---- 7. idle auto-lock ------------------------------------------------------
console.log('[idle auto-lock]')
{
// checkIdle() is only `private` to TypeScript; the modifier is erased at
// runtime, so the poll can be driven directly instead of waiting 15 seconds.
const store = await freshStore()
const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false })
const idle = makeController({ store, getLockSettings: enabled, idleSeconds: () => 60 })
idle.controller.checkIdle()
ok(idle.controller.isLocked() === true, 'one minute of idleness locks the screen')
ok(
idle.publishes.length === 1 && idle.publishes[0].locked === true,
'the lock is published once, so the overlay appears without being asked'
)
idle.controller.checkIdle()
ok(idle.publishes.length === 1, 'a poll while already locked publishes nothing')
}
{
const store = await freshStore()
const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false })
const justUnder = makeController({ store, getLockSettings: enabled, idleSeconds: () => 59 })
justUnder.controller.checkIdle()
ok(justUnder.controller.isLocked() === false, '59 seconds is not yet a minute of idleness')
ok(justUnder.publishes.length === 0, 'and nothing is published')
const disabled = makeController({
store,
getLockSettings: () => ({ ...enabled(), enabled: false }),
idleSeconds: () => 3600
})
disabled.controller.checkIdle()
ok(disabled.controller.isLocked() === false, 'the master switch off means idle never locks')
const never = makeController({
store,
getLockSettings: () => ({ ...enabled(), autoLockMinutes: 0 }),
idleSeconds: () => 3600
})
never.controller.checkIdle()
ok(never.controller.isLocked() === false, 'autoLockMinutes 0 means never')
}
{
const store = await freshStore()
const broken = makeController({
store,
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
idleSeconds: () => {
throw new Error('powerMonitor is unavailable without a session')
}
})
let threw = false
try {
broken.controller.checkIdle()
} catch {
threw = true
}
ok(!threw, 'a failing idle reading does not throw out of the poll')
ok(broken.controller.isLocked() === false, 'and unknown idleness reads as not idle rather than locking the app')
}
{
const unconfigured = makeController({
store: new LockStore(join(dir, 'lock-noverifier-idle.json')),
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
idleSeconds: () => 3600
})
unconfigured.controller.checkIdle()
ok(unconfigured.controller.isLocked() === false, 'an unconfigured lock never engages on idle')
}
// ---- 8. clearing the password -----------------------------------------------
console.log('[clear password]')
{
const store = await freshStore()
let cleared = 0
const { controller } = makeController({ store, clearLockPreferences: () => void cleared++ })
controller.lockNow()
ok(controller.isLocked() === true, 'the screen is locked before clearing')
const res = await controller.clearPassword({ currentPassword: PASSWORD })
ok(res.ok === true, 'clearing with the correct password succeeds')
ok(cleared === 1, 'the lock preferences are turned off exactly once')
ok(controller.isLocked() === false, 'clearing also unlocks: an unconfigured lock can never be answered')
ok(controller.getState().configured === false, 'the state reports unconfigured')
ok(store.isConfigured() === false, 'the verifier file is gone')
// Wrong current password: removing the lock must not be possible from the
// keyboard alone, so nothing is cleared and the screen stays shut.
await store.setPassword(PASSWORD)
controller.lockNow()
ok(controller.isLocked() === true, 'the screen locks again once a password exists')
const bad = await controller.clearPassword({ currentPassword: 'wrong' })
ok(bad.ok === false && bad.error === 'wrong-password', 'clearing with the wrong password is refused')
ok(cleared === 1, 'and the preferences are left alone')
ok(store.isConfigured() === true, 'and the password is still set')
ok(controller.isLocked() === true, 'and the screen stays locked')
}
// ---- 9. the paths that must not trap the user -------------------------------
console.log('[unconfigured paths]')
{
const { controller, publishes } = makeController({ store: new LockStore(join(dir, 'lock-none.json')) })
const state = controller.lockNow()
ok(state.locked === false && controller.isLocked() === false, 'lockNow() cannot lock without a verifier')
ok(publishes.length === 0, 'and it publishes nothing')
}
{
// Verifier deleted while running: nothing could ever open the screen again, so
// it has to open rather than stay shut forever.
const store = await freshStore()
const { controller } = makeController({ store })
controller.lockNow()
ok(controller.isLocked() === true, 'a configured lock does engage')
store.clear()
const res = await controller.unlock({ password: 'anything' })
ok(res.ok === true && controller.isLocked() === false, 'unlock() opens a screen whose verifier disappeared')
ok(res.state.configured === false, 'and reports it as unconfigured')
}
// ---- 10. applyLocked is idempotent ------------------------------------------
console.log('[idempotent lock changes]')
{
const store = await freshStore()
const { controller, publishes } = makeController({
store,
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
idleSeconds: () => 3600
})
controller.lockNow()
ok(publishes.length === 1, 'the first lock publishes once')
controller.lockNow()
ok(publishes.length === 1, 'locking an already locked screen publishes nothing')
controller.checkIdle()
ok(publishes.length === 1, 'the idle watcher does not republish an existing lock')
await controller.unlock({ password: PASSWORD })
ok(publishes.length === 2, 'unlocking publishes once')
await controller.unlock({ password: PASSWORD })
ok(publishes.length === 2, 'unlocking an unlocked screen publishes nothing')
store.clear()
await controller.clearPassword({})
ok(publishes.length === 2, 'clearing an unconfigured lock publishes nothing new')
ok(controller.isLocked() === false, 'and leaves the screen unlocked')
}
// ---- 11. path helper --------------------------------------------------------
console.log('[paths]')
{
const statePath = join(dir, 'lock-state.json')
ok(
resolve(defaultLockStatePath(dir)) === resolve(statePath),
'defaultLockStatePath resolves to <userData>/lock-state.json'
)
}
// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%.
rmSync(dir, { recursive: true, force: true })
console.log(failed === 0 ? '\n[lock-ctl] ALL CHECKS PASSED' : `\n[lock-ctl] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+309
View File
@@ -0,0 +1,309 @@
/**
* Lock-store self-test (lock-store.mjs).
*
* Offline and Electron-free: the store takes its file path by injection, so it
* runs under plain Node. Guards the contract the lock controller relies on:
* - a fresh install is "not configured", and looking does not create a file
* - setting a password writes a scrypt verifier and never the password
* - correct / incorrect verification, case sensitivity, salt regenerated per write
* - the verifier survives a restart (a new store over the same file)
* - clearing removes the file and returns to "not configured"
* - missing / truncated / wrongly-shaped / wrongly-sized files read as
* unconfigured instead of throwing (a corrupt lock must not break startup)
* - the lock flags (locked / failures / cooldownUntil) round-trip through the
* state store, and every unusable shape of that file reads back as "unlocked
* with no failures" instead of throwing on the startup path
*
* Build: node tests/build-bundles.cjs
* Run: node tests/lock-store.mjs (must exit 0)
*/
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const dir = mkdtempSync(join(tmpdir(), 'ot-lock-'))
const lockFile = join(dir, 'lock.json')
const lock = require('./.lock-store.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed++
}
const PASSWORD = 'correct horse'
const fresh = () => new lock.LockStore(lockFile)
const readRaw = () => readFileSync(lockFile, 'utf8')
/** Real base64 for the expected verifier sizes, so a damaged-file case can
* break exactly one field. */
const SALT16 = Buffer.alloc(16).toString('base64')
const HASH64 = Buffer.alloc(64).toString('base64')
// ---- 1. path helper ---------------------------------------------------------
console.log('[paths]')
// resolve() so the assertion holds whether the helper joins with '/' or '\'.
ok(resolve(lock.defaultLockPath(dir)) === resolve(lockFile), 'defaultLockPath resolves to <userData>/lock.json')
// ---- 2. unconfigured --------------------------------------------------------
console.log('[unconfigured]')
{
const s = fresh()
ok(s.isConfigured() === false, 'a missing file is not configured')
ok((await s.verify(PASSWORD)) === false, 'verify() on an unconfigured store is false')
ok(existsSync(lockFile) === false, 'verifying does not create the file')
}
// ---- 3. set + verify --------------------------------------------------------
console.log('[set + verify]')
{
const s = fresh()
await s.setPassword(PASSWORD)
ok(s.isConfigured(), 'after setPassword the store is configured')
ok(existsSync(lockFile), 'the verifier file exists')
const raw = JSON.parse(readRaw())
ok(raw.version === 1, 'stored version is 1')
ok(typeof raw.salt === 'string' && typeof raw.hash === 'string', 'salt + hash are strings')
ok(typeof raw.createdAt === 'number', 'createdAt is a number')
ok(!readRaw().includes(PASSWORD), 'the password itself is not on disk')
ok(!readRaw().includes('correct'), 'no fragment of the password is on disk')
ok((await s.verify(PASSWORD)) === true, 'the correct password verifies')
ok((await s.verify('correct hors')) === false, 'a near miss does not verify')
ok((await s.verify('correct horse ')) === false, 'a trailing space does not verify')
ok((await s.verify('')) === false, 'the empty string does not verify')
ok((await s.verify('CORRECT HORSE')) === false, 'verification is case sensitive')
ok(s.isConfigured(), 'a failed attempt does not unconfigure the store')
}
{
// Replacing regenerates the salt, so the previous hash is worthless even when
// the new password is the same one.
const s = fresh()
await s.setPassword(PASSWORD)
const first = JSON.parse(readRaw())
await s.setPassword(PASSWORD)
const second = JSON.parse(readRaw())
ok(first.salt !== second.salt, 'each write generates a fresh salt')
ok(first.hash !== second.hash, 'and therefore a different hash')
ok((await s.verify(PASSWORD)) === true, 'the replaced verifier still matches the same password')
}
// ---- 4. persistence round trip ----------------------------------------------
console.log('[persistence]')
{
const s = fresh()
await s.setPassword(PASSWORD)
const reopened = new lock.LockStore(lockFile) // "restart"
ok(reopened.isConfigured(), 'a new store over the same file is configured')
ok((await reopened.verify(PASSWORD)) === true, 'the password survives a restart')
ok((await reopened.verify('nope')) === false, 'a wrong password still fails after a restart')
}
// ---- 5. password length policy ----------------------------------------------
console.log('[length policy]')
{
ok(lock.isValidPassword('abcd') === true, '4 characters is accepted')
ok(lock.isValidPassword('a'.repeat(128)) === true, '128 characters is accepted')
ok(lock.isValidPassword('abc') === false, '3 characters is refused')
ok(lock.isValidPassword('') === false, 'the empty password is refused')
ok(lock.isValidPassword('a'.repeat(129)) === false, '129 characters is refused')
ok(lock.isValidPassword(undefined) === false, 'a missing password is refused')
ok(lock.isValidPassword(1234) === false, 'a non-string password is refused')
}
{
const path = join(dir, 'unset.json')
const s = new lock.LockStore(path)
let threw = false
try {
await s.setPassword('abc')
} catch {
threw = true
}
ok(threw, 'setPassword refuses a too-short password')
threw = false
try {
await s.setPassword('')
} catch {
threw = true
}
ok(threw, 'setPassword refuses an empty password')
ok(s.isConfigured() === false, 'a refused password leaves the store unconfigured')
ok(existsSync(path) === false, 'nothing was written for a refused password')
}
// ---- 6. clear ---------------------------------------------------------------
console.log('[clear]')
{
const s = fresh()
await s.setPassword(PASSWORD)
s.clear()
ok(s.isConfigured() === false, 'clearing returns the store to unconfigured')
ok(existsSync(lockFile) === false, 'clearing removes the file')
ok((await s.verify(PASSWORD)) === false, 'a cleared store verifies nothing')
}
{
let threw = false
try {
fresh().clear()
} catch {
threw = true
}
ok(!threw, 'clearing an unconfigured store does not throw')
}
// ---- 7. damaged files -------------------------------------------------------
console.log('[damaged files]')
const damaged = [
['truncated JSON', '{"version":1,"salt":"AAAA"'],
['an empty file', ''],
['JSON null', 'null'],
['a JSON array', '[]'],
['a bare string', '"nope"'],
['an unknown version', JSON.stringify({ version: 2, salt: SALT16, hash: HASH64, createdAt: 1 })],
['a missing hash', JSON.stringify({ version: 1, salt: SALT16, createdAt: 1 })],
['a non-string salt', JSON.stringify({ version: 1, salt: 42, hash: HASH64, createdAt: 1 })],
[
'a salt of the wrong size',
JSON.stringify({ version: 1, salt: Buffer.alloc(8).toString('base64'), hash: HASH64, createdAt: 1 })
],
['a hash of the wrong size', JSON.stringify({ version: 1, salt: SALT16, hash: 'AAAA', createdAt: 1 })],
['a missing createdAt', JSON.stringify({ version: 1, salt: SALT16, hash: HASH64 })]
]
for (const [name, content] of damaged) {
writeFileSync(lockFile, content, 'utf8')
const s = fresh()
let threw = false
let configured = true
let verified = true
try {
configured = s.isConfigured()
verified = await s.verify(PASSWORD)
} catch {
threw = true
}
ok(
!threw && configured === false && verified === false,
`${name} reads as unconfigured without throwing`
)
}
{
// A directory at the store path (EISDIR) is unreadable, not "a password is set".
const asDir = join(dir, 'as-dir')
mkdirSync(asDir, { recursive: true })
const s = new lock.LockStore(asDir)
let threw = false
let configured = true
try {
configured = s.isConfigured()
} catch {
threw = true
}
ok(!threw && configured === false, 'a directory at the store path reads as unconfigured')
}
{
// Recovery: a corrupt file is overwritten by the next set, not left blocking.
writeFileSync(lockFile, 'not json at all', 'utf8')
const s = fresh()
await s.setPassword(PASSWORD)
ok(s.isConfigured() === true && (await s.verify(PASSWORD)) === true, 'a corrupt file can be replaced')
}
// ---- 8. lock state store ----------------------------------------------------
console.log('[lock state store]')
const stateFile = join(dir, 'lock-state.json')
const stateStore = () => new lock.LockStateStore(stateFile)
{
ok(
resolve(lock.defaultLockStatePath(dir)) === resolve(stateFile),
'defaultLockStatePath resolves to <userData>/lock-state.json'
)
const s = stateStore()
ok(s.load().locked === false, 'a missing state file reads as unlocked')
ok(existsSync(stateFile) === false, 'and reading it does not create the file')
}
{
// The controller writes every flag change through; a lost round trip would hand
// back an unlocked app (or a reset backoff) after a restart.
const s = stateStore()
s.save({ locked: true, failures: 2, cooldownUntil: 1234 })
const raw = JSON.parse(readFileSync(stateFile, 'utf8'))
ok(raw.version === 1, 'the state file records version 1')
const loaded = s.load()
ok(
loaded.locked === true && loaded.failures === 2 && loaded.cooldownUntil === 1234,
'load() returns exactly what save() wrote'
)
ok(new lock.LockStateStore(stateFile).load().failures === 2, 'the flags survive a restart (a new store over the same file)')
}
{
const s = stateStore()
s.save({ locked: true, failures: 1, cooldownUntil: 5000 })
s.clear()
ok(existsSync(stateFile) === false, 'clear() removes the state file')
ok(s.load().locked === false, 'and the flags read back as unlocked')
let threw = false
try {
s.clear()
} catch {
threw = true
}
ok(!threw, 'clearing an absent state file does not throw')
}
{
// Every one of these must land on the same fallback: the load runs on the
// startup path, where throwing would leave the app without a window while it
// still holds the single-instance lock.
const fallback = (state) =>
state.locked === false && state.failures === 0 && state.cooldownUntil === 0
const damagedStates = [
['an empty file', ''],
['truncated JSON', '{"version":1,"locked":true'],
['JSON null', 'null'],
['a JSON array', '[]'],
['a bare string', '"locked"'],
['an unknown version', JSON.stringify({ version: 2, locked: true, failures: 3, cooldownUntil: 9 })],
['a missing version', JSON.stringify({ locked: true, failures: 3, cooldownUntil: 9 })],
['a non-boolean locked', JSON.stringify({ version: 1, locked: 'true', failures: 0, cooldownUntil: 0 })],
['locked: 1', JSON.stringify({ version: 1, locked: 1, failures: 0, cooldownUntil: 0 })],
['a fractional failure count', JSON.stringify({ version: 1, locked: false, failures: 2.5, cooldownUntil: 0 })],
['a negative failure count', JSON.stringify({ version: 1, locked: false, failures: -1, cooldownUntil: 0 })],
['a stringified failure count', JSON.stringify({ version: 1, locked: false, failures: '2', cooldownUntil: 0 })],
['a missing failure count', JSON.stringify({ version: 1, locked: false, cooldownUntil: 0 })],
// JSON has no NaN and no Infinity: both arrive as null, or as text that is
// not JSON at all. Either way the cooldown must not become a live deadline.
['a nulled cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: null })],
['a literal NaN cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":NaN}'],
['a literal Infinity cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":Infinity}'],
['a stringified cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: '1234' })],
['a negative cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: -5000 })]
]
for (const [name, content] of damagedStates) {
writeFileSync(stateFile, content, 'utf8')
let threw = false
let state = null
try {
state = stateStore().load()
} catch {
threw = true
}
ok(!threw && fallback(state), `${name} reads as unlocked with no failures, without throwing`)
}
}
{
// A damaged file must not block the next save (the controller writes after
// every change, so it has to be able to recover on its own).
writeFileSync(stateFile, 'not json at all', 'utf8')
const s = stateStore()
s.save({ locked: true, failures: 0, cooldownUntil: 0 })
ok(s.load().locked === true, 'a damaged state file can be replaced')
}
// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%.
rmSync(dir, { recursive: true, force: true })
console.log(failed === 0 ? '\n[lock] ALL CHECKS PASSED' : `\n[lock] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+16 -1
View File
@@ -25,7 +25,22 @@ const fail = (msg) => {
}
// ---- 1. Boot the loopback server -------------------------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
// key"), which is enough to flake a release build's pretest. The Server
// constructor parses hostKeys eagerly, so generate until one is accepted.
function newHostKey() {
for (let attempt = 0; ; attempt++) {
const pair = utils.generateKeyPairSync('ed25519')
try {
new Server({ hostKeys: [pair.private] }, () => {})
return pair
} catch (err) {
if (attempt >= 9) throw err
}
}
}
const serverKey = newHostKey()
let serverPort = 0
let seenWindowChange = { cols: 0, rows: 0 }
+50 -2
View File
@@ -26,11 +26,31 @@ const fail = (msg) => {
}
// ---- 1. Loopback ssh server --------------------------------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
// key"), which is enough to flake a release build's pretest. The Server
// constructor parses hostKeys eagerly, so generate until one is accepted.
function newHostKey() {
for (let attempt = 0; ; attempt++) {
const pair = utils.generateKeyPairSync('ed25519')
try {
new Server({ hostKeys: [pair.private] }, () => {})
return pair
} catch (err) {
if (attempt >= 9) throw err
}
}
}
const serverKey = newHostKey()
let serverPort = 0
let seenWindowChange = { cols: 0, rows: 0 }
// Latest server-side connection, so a test can hang up on the transport under
// an established session (see the PTY_EXIT guard near the end).
let serverSideClient = null
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
serverSideClient = client
client.on('authentication', (ctx) => {
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
ctx.accept()
@@ -108,7 +128,7 @@ sessionLayer.configureSessionRuntime({
})
// ---- 3. Drive the pipeline ----------------------------------------------------
const timer = setTimeout(() => fail('e2e timed out'), 15000)
const timer = setTimeout(() => fail('e2e timed out'), 25000)
const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
if (!openResult?.id) fail('openSession did not resolve with an id')
console.log(`[e2e] session open: ${openResult.id}`)
@@ -149,6 +169,34 @@ console.log('[e2e] kill -> PTY_EXIT ok')
if (!events.some((e) => e.channel === 'touched' && e.payload === 'conn-1')) fail('lastConnectedAt touch not recorded')
console.log('[e2e] connection touch recorded')
// ---- 4. Transport death under an established session -------------------------
// pty.ts's teardown is now the ONLY PTY_EXIT broadcaster (ssh.ts dropped its own
// emit), so this is the guard for both failure modes: a teardown that never
// fires leaves the renderer's panel stuck on "connecting" forever, and a broken
// idempotence guard would broadcast the exit twice.
const open2 = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
if (!open2?.id) fail('second openSession did not resolve with an id')
for (let i = 0; i < 50 && !(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER'); i++) {
await wait(100)
}
if (!(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER')) {
fail('second session never became established')
}
console.log('[e2e] second session established')
const exitCount = (id) =>
events.filter((e) => e.channel === 'pty:exit' && e.payload?.id === id).length
if (exitCount(open2.id) !== 0) fail('PTY_EXIT arrived before the transport died')
// The server hangs up. The client stream must land in the teardown path, which
// owns the single broadcast; the waits below give 'close' a moment to arrive.
serverSideClient.end()
for (let i = 0; i < 50 && exitCount(open2.id) === 0; i++) await wait(100)
if (exitCount(open2.id) !== 1) {
fail(`transport death must broadcast PTY_EXIT exactly once, got ${exitCount(open2.id)}`)
}
console.log('[e2e] transport death -> PTY_EXIT exactly once')
clearTimeout(timer)
srv.close()
console.log('[e2e] ALL CHECKS PASSED')
+50 -1
View File
@@ -28,7 +28,22 @@ const fail = (msg) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
// ---- 1. Loopback ssh server with canned /proc exec -----------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
// key"), which is enough to flake a release build's pretest. The Server
// constructor parses hostKeys eagerly, so generate until one is accepted.
function newHostKey() {
for (let attempt = 0; ; attempt++) {
const pair = utils.generateKeyPairSync('ed25519')
try {
new Server({ hostKeys: [pair.private] }, () => {})
return pair
} catch (err) {
if (attempt >= 9) throw err
}
}
}
const serverKey = newHostKey()
let serverPort = 0
// Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute.
@@ -198,6 +213,40 @@ const after = samples(openResult.id).length
if (after !== before) fail(`stopPolling did not halt: got ${after - before} new samples`)
console.log('[sysinfo] stopPolling halts the sample stream')
// ---- 5. Reference counting: split panels share one sessionId ----------------------
// Two panels start on the same session; the poll must survive one stop and
// only halt on the last release. Counts grow at ~5 samples/s (200ms interval),
// so the waits below must show growth while a reference is held.
sessionLayer.startPolling(openResult.id, 200)
sessionLayer.startPolling(openResult.id, 200)
const refCounted = samples(openResult.id).length
await wait(600)
if (samples(openResult.id).length <= refCounted) fail('shared poll (refs=2) stopped sampling')
sessionLayer.stopPolling(openResult.id) // first panel unmounts
const oneRef = samples(openResult.id).length
await wait(600)
if (samples(openResult.id).length <= oneRef) fail('poll stopped while a sibling panel still held a reference')
console.log('[sysinfo] shared poll survives one sibling stop')
sessionLayer.stopPolling(openResult.id) // last panel unmounts
const zeroRefs = samples(openResult.id).length
await wait(600)
if (samples(openResult.id).length !== zeroRefs) fail('poll must stop only once the last reference is released')
console.log('[sysinfo] last release stops the poll')
// Restart after a full release must work on fresh state (no stale refs/timer).
sessionLayer.startPolling(openResult.id, 200)
await wait(600)
if (samples(openResult.id).length <= zeroRefs) fail('poll did not restart cleanly after a full release')
sessionLayer.stopPolling(openResult.id)
console.log('[sysinfo] restart after full release works')
// A killed session must force-stop regardless of outstanding references.
sessionLayer.startPolling(openResult.id, 200)
sessionLayer.startPolling(openResult.id, 200)
sessionLayer.forceStopPolling(openResult.id)
const forced = samples(openResult.id).length
await wait(600)
if (samples(openResult.id).length !== forced) fail('forceStopPolling must halt even with outstanding references')
console.log('[sysinfo] forceStopPolling overrides outstanding references')
clearTimeout(timer)
srv.close()
console.log('[sysinfo] ALL CHECKS PASSED')