docs+chore: refresh README/STATE, cache electron in CI, lock chord by keycode, misc P3
- README: add lock-screen section, refresh test list (13 offline tests),
updater fallback order, data locations, architecture tree
- STATE.md: v1.0.20, current release.cjs flow (no --skip-github), M11-M13
- ci.yml: actions/cache for the ~100MB Electron binary keyed on lockfile
- .gitignore: ignore .env.* but keep committed templates
- scripts/archive-releases.cjs moved in from tmp-test; KEEP_TAG is now a
required CLI arg (a hardcoded default is how the wrong version gets wiped)
- lockShortcuts: isPanicLockChord matches input.code ('KeyL') so Dvorak and
non-Latin layouts trigger Ctrl+L lock correctly
- settings: drop the dead single-option update-channel Select from About tab
- Workspace/App: memo(IconRail/LayoutFlyout), useMemo layoutMenu keyed on
language, useCallback onOpenSettings; drop unused IconRail onSplit prop
This commit is contained in:
1 parent
d22923aedd
commit
5ff311ea0f
14 files changed
+231
-61
No files matched your search
@@ -24,10 +24,20 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
# `npm ci` triggers electron's postinstall, which downloads the ~100MB
|
||||||
|
# binary into %LOCALAPPDATA%\electron\Cache. Keyed on the lockfile so any
|
||||||
|
# dependency bump — electron included — refetches instead of restoring a
|
||||||
|
# binary that no longer matches the pinned version.
|
||||||
|
- name: Cache Electron binary
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/AppData/Local/electron/Cache
|
||||||
|
key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 10 个离线测试
|
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 13 个离线测试
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ research/
|
|||||||
|
|
||||||
# credentials (do not commit)
|
# credentials (do not commit)
|
||||||
.env
|
.env
|
||||||
|
# .env.* too, but never the committed templates
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.sample
|
||||||
|
!.env.template
|
||||||
|
|
||||||
# ts build info
|
# ts build info
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -30,6 +35,7 @@ tests/.lock-store.cjs
|
|||||||
tests/.lock-controller.cjs
|
tests/.lock-controller.cjs
|
||||||
tests/.lock-shortcuts.cjs
|
tests/.lock-shortcuts.cjs
|
||||||
tests/.settings-store.cjs
|
tests/.settings-store.cjs
|
||||||
|
tests/.local-path-grants.cjs
|
||||||
tests/.session-e2e.cjs
|
tests/.session-e2e.cjs
|
||||||
tests/.hl-split-smoke.cjs
|
tests/.hl-split-smoke.cjs
|
||||||
tests/.hl-rules.cjs
|
tests/.hl-rules.cjs
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ npm install # 已安装可跳过
|
|||||||
npm run dev # 开发模式(热更新)
|
npm run dev # 开发模式(热更新)
|
||||||
npm run build # 生产构建到 out/
|
npm run build # 生产构建到 out/
|
||||||
npm run typecheck # 全量类型检查
|
npm run typecheck # 全量类型检查
|
||||||
npm test # 重建测试 bundle 后依次跑离线测试(详见「测试」)
|
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12 个离线测试(详见「测试」)
|
||||||
```
|
```
|
||||||
|
|
||||||
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||||
@@ -48,7 +48,16 @@ npm test # 重建测试 bundle 后依次跑离线测试(详见「测
|
|||||||
- 命令面板:侧边栏「命令」标签分「历史」/「命令库」两页,历史按会话去重,命令库支持分组与一键运行
|
- 命令面板:侧边栏「命令」标签分「历史」/「命令库」两页,历史按会话去重,命令库支持分组与一键运行
|
||||||
- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭)
|
- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭)
|
||||||
- 会话日志:手动启停、纯文本落盘
|
- 会话日志:手动启停、纯文本落盘
|
||||||
- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、全局唤起/隐藏(可配)
|
- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、Ctrl+L 立即锁屏(已设密码时生效)、全局唤起/隐藏(可配)
|
||||||
|
|
||||||
|
**锁屏与隐私**
|
||||||
|
- 主窗口内不透明遮罩锁定(不另开窗口),遮罩下的本地/SSH 会话与传输列表保持挂载,解锁即恢复
|
||||||
|
- 密码只存 scrypt 派生值(`lock.json`,每次写入重新生成随机盐 + 定时安全比较),明文永不落盘;未设密码则不启用
|
||||||
|
- 锁定状态落盘(`lock-state.json`),托盘退出 / 任务管理器强杀 / 崩溃重启后仍然是锁的
|
||||||
|
- **Ctrl+L = 立即锁屏**,终端里也生效;未设密码的应用保留 Ctrl+L 给 shell 的清屏
|
||||||
|
- 密码错误有冷却阶梯(1s → 2s → 5s → 10s → 30s),失败计数与冷却同样落盘
|
||||||
|
- 可选「闲置自动锁屏」(1/5/15/30/60 分钟)与「启动即锁」,冷却中连输也逐级退避
|
||||||
|
- 锁定期间吞掉 F5 / Ctrl+R / Ctrl+±0 / Ctrl+Shift+I/J/C,并整块摘除应用菜单,防止菜单键绕过遮罩
|
||||||
|
|
||||||
**外观与窗口**
|
**外观与窗口**
|
||||||
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
|
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
|
||||||
@@ -56,6 +65,7 @@ npm test # 重建测试 bundle 后依次跑离线测试(详见「测
|
|||||||
- 窗口标题栏与背景跟随终端主题
|
- 窗口标题栏与背景跟随终端主题
|
||||||
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
|
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
|
||||||
- 单实例运行:重复启动唤出已有窗口
|
- 单实例运行:重复启动唤出已有窗口
|
||||||
|
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时,MSI 仅分发、自动更新走 NSIS 安装包
|
||||||
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
|
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
|
||||||
|
|
||||||
**界面与语言**
|
**界面与语言**
|
||||||
@@ -64,15 +74,22 @@ npm test # 重建测试 bundle 后依次跑离线测试(详见「测
|
|||||||
|
|
||||||
**测试**
|
**测试**
|
||||||
|
|
||||||
`npm test` 会先重建 esbuild bundle,再依次跑下列测试(无需服务器与凭据):
|
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 12 个测试(全部离线,无需服务器与凭据):
|
||||||
|
|
||||||
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
|
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
|
||||||
|
- `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储
|
||||||
|
- `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份
|
||||||
|
- `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
||||||
|
- `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理)
|
||||||
|
- `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
|
||||||
|
- `node tests/lock-shortcuts.mjs` — 锁屏快捷键分类器(表驱动:Ctrl+L 恐慌锁 / 锁定时禁用的组合键)
|
||||||
|
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
|
||||||
|
- `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令)
|
||||||
|
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
|
||||||
- `node tests/ssh-session-e2e.mjs` — 会话路由层端到端(SSH 数据面、replay、resize、kill)
|
- `node tests/ssh-session-e2e.mjs` — 会话路由层端到端(SSH 数据面、replay、resize、kill)
|
||||||
- `node tests/sysinfo-e2e.mjs` — 服务器监控轮询端到端(META、采样速率、stopPolling)
|
- `node tests/sysinfo-e2e.mjs` — 服务器监控轮询端到端(META、采样速率、stopPolling)
|
||||||
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
|
|
||||||
- `node tests/commands-store.mjs` / `node tests/settings-store.mjs` — 命令库/历史/会话日志与设置清洗器
|
- 真实服务器测试(需 `JD_HOST/JD_USER/JD_PASS`,**不在** `npm test` 内,需联网与真实凭据):`tests/sftp-real.mjs`、`tests/sftp-chmod.mjs`
|
||||||
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
|
|
||||||
- 真实服务器测试(需 `JD_HOST/JD_USER/JD_PASS`,不在 `npm test` 内):`tests/sftp-real.mjs`、`tests/sftp-chmod.mjs`
|
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
@@ -86,6 +103,8 @@ src/
|
|||||||
├── main/ # 主进程
|
├── main/ # 主进程
|
||||||
│ ├── pty.ts # PTY 会话池(node-pty)→ 广播 PTY_DATA/PTY_EXIT
|
│ ├── pty.ts # PTY 会话池(node-pty)→ 广播 PTY_DATA/PTY_EXIT
|
||||||
│ ├── settingsStore.ts # JSON 持久化 + 深合并兜底
|
│ ├── settingsStore.ts # JSON 持久化 + 深合并兜底
|
||||||
|
│ ├── lockController.ts # 锁屏状态机(冷却阶梯、闲置触发、串行化解锁)
|
||||||
|
│ ├── lockStore.ts # 锁屏密码校验值(scrypt,不存明文)
|
||||||
│ ├── layouts.ts # 布局模板存储(userData/layouts/*.json)
|
│ ├── layouts.ts # 布局模板存储(userData/layouts/*.json)
|
||||||
│ ├── tray.ts # 系统托盘 + 关闭行为
|
│ ├── tray.ts # 系统托盘 + 关闭行为
|
||||||
│ └── ipc.ts # 全部 ipcMain 注册
|
│ └── ipc.ts # 全部 ipcMain 注册
|
||||||
@@ -105,6 +124,8 @@ src/
|
|||||||
- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json`
|
- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json`
|
||||||
- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密)
|
- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密)
|
||||||
- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json`
|
- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json`
|
||||||
|
- 锁屏密码校验值:`%APPDATA%/OpenTerminal/lock.json`(scrypt 派生值,无明文)
|
||||||
|
- 锁屏状态与冷却:`%APPDATA%/OpenTerminal/lock-state.json`
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+31
-12
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 当前版本与仓库
|
## 当前版本与仓库
|
||||||
|
|
||||||
- v1.0.19,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
- v1.0.20,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||||
- 开源协议:MIT(LICENSE)
|
- 开源协议:MIT(LICENSE)
|
||||||
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
|
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
|
||||||
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
|
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
|
||||||
@@ -25,6 +25,18 @@
|
|||||||
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
|
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
|
||||||
| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ |
|
| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ |
|
||||||
| M10 多语言界面(zh-CN/zh-TW/en/ja)+离线多语言更新日志+按键录制+可配工具条+设置健壮性 | ✅(v1.0.13) |
|
| M10 多语言界面(zh-CN/zh-TW/en/ja)+离线多语言更新日志+按键录制+可配工具条+设置健壮性 | ✅(v1.0.13) |
|
||||||
|
| M11 终端自定义(背景图/压暗层+对比度护栏)、高亮预设扩到 22 条、传输健壮性修复 | ✅(v1.0.14–1.0.16) |
|
||||||
|
| M12 锁屏(主窗口遮罩+scrypt 密码+Ctrl+L 立即锁屏+冷却阶梯+落盘状态) | ✅(v1.0.17,v1.0.18 修复锁屏输入回归,v1.0.20 摘除菜单加固) |
|
||||||
|
| M13 更新源改为 GitHub 优先 + Gitea 通道兜底,高亮引擎性能优化 | ✅(v1.0.19–1.0.20) |
|
||||||
|
|
||||||
|
## 锁屏实现要点(v1.0.17+,已落地)
|
||||||
|
|
||||||
|
- **只在主窗口内加不透明遮罩**(`lock/LockScreen.tsx`,z-index 4000),不另开 Electron 窗口。锁定时 `.app-root` 置 `inert` 但**保持挂载**(卸载会杀掉遮罩后的本地/SSH 会话与传输列表);antd portal 挂在 `document.body` 上不在 `#root` 内,所以还要给 `body` 下非 `#root` 子节点补 `inert`,并用 `MutationObserver` 覆盖锁定之后才挂上的浮层(SSH 发出后闲置锁屏、主机密钥弹窗恰好在此刻弹出的场景)
|
||||||
|
- **密码永不进渲染层**:`lockStore.ts` 用 scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,存 `<userData>/lock.json`;锁状态机独占在 `lockController.ts`(`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs)
|
||||||
|
- **锁标志落盘** `<userData>/lock-state.json`(locked/failures/cooldownUntil 每次变化立即写)→ 托盘退出/强杀/崩溃重启后仍是锁的;`lockAtStartup` 只是额外一层。磁盘恢复的 `cooldownUntil` 夹到 `now+30s` 防系统时间回拨
|
||||||
|
- **Ctrl+L = 立即锁屏**:`before-input-event` 捕获,未设密码时不 `preventDefault`(保留给 shell 清屏),已锁定时不拦截,跳过 `isAutoRepeat`。判定函数 `isPanicLockChord` 判 **`input.code === 'KeyL'`**(物理键位,非布局相关的 `key`),Shift/Alt/Meta 任一存在即放行(IME 切输入法的 Ctrl+Shift+L、AltGr 的 Ctrl+Alt)
|
||||||
|
- **键盘之外还有鼠标路径**:默认菜单 Alt 就能唤出,菜单点击不走 `before-input-event`,所以锁定期间 `lockMenu.ts` 把整个应用菜单置 null。渲染层的 `document.documentElement.dataset.locked` 守卫**只允许 return,绝不能 preventDefault**(keydown 默认动作就是往聚焦输入框插字符,v1.0.17 的回归就死在这里,v1.0.18 修)
|
||||||
|
- **Ctrl+L 是保留键**:快捷键录制器(`RESERVED_EXACT_ACCELERATORS`)与主进程 `applyGlobalShortcut` 双重拒绝注册它——globalShortcut 在 OS 层拦截会让紧急锁屏静默失效;老版本存下的 `Control+L` 只能靠主进程那道拒绝兜底
|
||||||
|
|
||||||
## M8 实现要点(v0.8.0,已落地)
|
## M8 实现要点(v0.8.0,已落地)
|
||||||
|
|
||||||
@@ -48,28 +60,35 @@
|
|||||||
|
|
||||||
## 发布流程(下一版本照抄)
|
## 发布流程(下一版本照抄)
|
||||||
|
|
||||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 末尾会 `npm install --package-lock-only` 自动同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
|
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 会先跑 `npm test`(typecheck + 12 个离线测试)再 `npm install --package-lock-only` 同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
|
||||||
2. `node scripts/release.cjs <版本号> --skip-github`:脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap
|
2. `node scripts/release.cjs <版本号>`(**不带 skip 参数**,Gitea 与 GitHub 一起发):脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);再把 exe/exe.blockmap/latest.yml 作为 release 资产同步发到 GitHub(electron-updater 标准 GitHub provider 直接吃 release 资产)。不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap。上传前 `assertNoDowngrade()` 会读通道 latest.yml,线上版本更高时直接拒绝
|
||||||
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(同一版本可重复运行:release 复用、已传资产跳过)
|
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub;同一版本可重复运行:release 复用、已传资产跳过)
|
||||||
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||||
5. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像只推代码/tag,不随版本发 release 资产)
|
5. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像推代码/tag + release 资产,与 Gitea 保持同步)
|
||||||
|
|
||||||
|
> GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub);国内通道全程直连,不设代理
|
||||||
|
|
||||||
## 测试(全部通过,改动后必跑)
|
## 测试(全部通过,改动后必跑)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck # tsconfig.node.json + tsconfig.web.json
|
npm run typecheck # tsconfig.node.json + tsconfig.web.json
|
||||||
npm run build
|
npm run build
|
||||||
npm test # = node tests/build-bundles.cjs && 下面 8 个测试(依次,全部离线可跑)
|
npm test # = pretest(typecheck) + node tests/build-bundles.cjs + 下面 12 个测试(依次,全部离线可跑)
|
||||||
node tests/ssh-loopback.mjs
|
node tests/ssh-loopback.mjs
|
||||||
node tests/commands-store.mjs
|
node tests/commands-store.mjs
|
||||||
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
node tests/connections-store.mjs # SSH 书签 CRUD / 公开-密文切分 / 损坏文件备份
|
||||||
|
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
||||||
|
node tests/lock-store.mjs # 锁屏密码校验值(scrypt 往返、损坏文件)
|
||||||
|
node tests/lock-controller.mjs # 冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动
|
||||||
|
node tests/lock-shortcuts.mjs # 锁屏快捷键分类器(表驱动)
|
||||||
node tests/.hl-split-smoke.cjs
|
node tests/.hl-split-smoke.cjs
|
||||||
node tests/.hl-rules.cjs # 内置高亮预设(词边界、大小写、负向词、危险命令)
|
node tests/.hl-rules.cjs # 内置高亮预设(词边界、大小写、负向词、危险命令)
|
||||||
node tests/zmodem-e2e.mjs
|
node tests/zmodem-e2e.mjs
|
||||||
node tests/ssh-session-e2e.mjs
|
node tests/ssh-session-e2e.mjs
|
||||||
node tests/sysinfo-e2e.mjs
|
node tests/sysinfo-e2e.mjs
|
||||||
# `node tests/build-bundles.cjs` 单独重建全部 7 个 esbuild bundle(别名只存在于该脚本):
|
# `node tests/build-bundles.cjs` 单独重建全部 esbuild bundle(别名只存在于该脚本):
|
||||||
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .settings-store.cjs
|
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .connections-store.cjs
|
||||||
|
# .known-hosts.cjs .settings-store.cjs .lock-store.cjs .lock-controller.cjs .lock-shortcuts.cjs
|
||||||
# .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败
|
# .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败
|
||||||
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
|
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
|
||||||
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
|
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
|
||||||
@@ -100,5 +119,5 @@ node tests/sysinfo-e2e.mjs
|
|||||||
|
|
||||||
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
|
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
|
||||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键、中文 IME 候选窗跟随光标(每次发版前手测一遍,CI 无法覆盖)
|
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键、中文 IME 候选窗跟随光标(每次发版前手测一遍,CI 无法覆盖)
|
||||||
3. `@xterm/xterm` 6.1.0 stable 发布后把精确锁定的 `6.1.0-beta.304` 改回 `^` 范围并回归验证 IME(背景见已踩坑 11)
|
3. `@xterm/xterm` 6.1.0 stable 发布后把精确锁定的 `6.1.0-beta.304` 改回 `^` 范围并回归验证 IME(背景见已踩坑 11;该 pin 随 v1.0.20 的中文输入法候选窗修复上线)
|
||||||
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
4. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* Archive old Gitea releases: backup assets locally, then delete remote
|
||||||
|
releases, keeping only the one you name. Tags are untouched.
|
||||||
|
|
||||||
|
WARNING: `delete` removes every release except <keep-tag> from the Gitea
|
||||||
|
repository. The kept tag is a REQUIRED argument precisely because a
|
||||||
|
hardcoded default is how the wrong version gets wiped — the script refuses
|
||||||
|
to run without it. `delete` is safe by construction: it aborts unless every
|
||||||
|
asset of every old release already has a size-matching local backup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node scripts/archive-releases.cjs <keep-tag> backup # download assets only
|
||||||
|
node scripts/archive-releases.cjs <keep-tag> delete # delete releases whose
|
||||||
|
# assets are all backed
|
||||||
|
# up (size match)
|
||||||
|
|
||||||
|
Reads GIT_TOKEN from .env (same format as scripts/release.cjs). */
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const ROOT = path.join(__dirname, '..')
|
||||||
|
const KEEP_TAG = process.argv[2]
|
||||||
|
if (!KEEP_TAG) {
|
||||||
|
console.error('usage: node scripts/archive-releases.cjs <keep-tag> backup|delete')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const ARCHIVE = path.join(ROOT, 'release', 'archive')
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
|
||||||
|
.filter((l) => l.includes('=') && !l.startsWith('#'))
|
||||||
|
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
|
||||||
|
)
|
||||||
|
const API = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
|
||||||
|
const H = { Authorization: `token ${env.GIT_TOKEN}` }
|
||||||
|
|
||||||
|
const listReleases = async () => {
|
||||||
|
const out = []
|
||||||
|
let page = 1
|
||||||
|
for (;;) {
|
||||||
|
const res = await fetch(`${API}/releases?limit=50&page=${page}`, { headers: H })
|
||||||
|
if (!res.ok) throw new Error(`list HTTP ${res.status}`)
|
||||||
|
const batch = await res.json()
|
||||||
|
if (!batch.length) break
|
||||||
|
out.push(...batch)
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = async () => {
|
||||||
|
const releases = await listReleases()
|
||||||
|
const old = releases.filter((r) => r.tag_name !== KEEP_TAG)
|
||||||
|
let ok = true
|
||||||
|
for (const r of old) {
|
||||||
|
for (const a of r.assets ?? []) {
|
||||||
|
const dir = path.join(ARCHIVE, r.tag_name)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
const dest = path.join(dir, a.name)
|
||||||
|
if (fs.existsSync(dest) && fs.statSync(dest).size === a.size) {
|
||||||
|
console.log(`skip (have) ${r.tag_name}/${a.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
process.stdout.write(`get ${r.tag_name}/${a.name} (${(a.size / 1e6).toFixed(1)} MB) ... `)
|
||||||
|
const res = await fetch(a.browser_download_url, { headers: H })
|
||||||
|
if (!res.ok) { console.log(`HTTP ${res.status}`); ok = false; continue }
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
|
if (buf.length !== a.size) { console.log(`size mismatch ${buf.length} != ${a.size}`); ok = false; continue }
|
||||||
|
fs.writeFileSync(dest, buf)
|
||||||
|
console.log('ok')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(ok ? 'BACKUP DONE' : 'BACKUP INCOMPLETE — do not delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = async () => {
|
||||||
|
const releases = await listReleases()
|
||||||
|
const old = releases.filter((r) => r.tag_name !== KEEP_TAG)
|
||||||
|
let ok = true
|
||||||
|
for (const r of old) {
|
||||||
|
for (const a of r.assets ?? []) {
|
||||||
|
const dest = path.join(ARCHIVE, r.tag_name, a.name)
|
||||||
|
const have = fs.existsSync(dest) && fs.statSync(dest).size === a.size
|
||||||
|
if (!have) { console.log(`MISSING backup ${r.tag_name}/${a.name} — abort this release`); ok = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) { console.log('DELETE ABORTED'); return }
|
||||||
|
for (const r of old) {
|
||||||
|
const res = await fetch(`${API}/releases/${r.id}`, { method: 'DELETE', headers: H })
|
||||||
|
console.log(`delete ${r.tag_name}: HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const left = await listReleases()
|
||||||
|
console.log(`remaining: ${left.map((r) => r.tag_name).join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const mode = process.argv[3]
|
||||||
|
if (mode === 'backup') await backup()
|
||||||
|
else if (mode === 'delete') await del()
|
||||||
|
else console.log('usage: node scripts/archive-releases.cjs <keep-tag> backup|delete')
|
||||||
|
})().catch((e) => { console.error('failed:', e.message); process.exit(1) })
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
export interface LockInputEvent {
|
export interface LockInputEvent {
|
||||||
key: string
|
key: string
|
||||||
|
/** Physical key position (`KeyL`, `F5`, …) — layout-independent, unlike `key`. */
|
||||||
|
code: string
|
||||||
control: boolean
|
control: boolean
|
||||||
shift: boolean
|
shift: boolean
|
||||||
alt: boolean
|
alt: boolean
|
||||||
@@ -45,6 +47,12 @@ export function isLockBlockedShortcut(input: LockInputEvent): boolean {
|
|||||||
* because Ctrl+Shift+L is the switch-to-English chord on Chinese and Japanese
|
* because Ctrl+Shift+L is the switch-to-English chord on Chinese and Japanese
|
||||||
* IMEs; Alt is excluded because AltGr arrives as Ctrl+Alt on most European
|
* IMEs; Alt is excluded because AltGr arrives as Ctrl+Alt on most European
|
||||||
* layouts. A chord that isn't exactly this must keep its original meaning.
|
* layouts. A chord that isn't exactly this must keep its original meaning.
|
||||||
|
*
|
||||||
|
* Matching is on `input.code` (the physical key) rather than `input.key`: the
|
||||||
|
* key character depends on the active keyboard layout, so on Dvorak or a
|
||||||
|
* non-Latin layout the physical L key no longer arrives as 'l' and matching
|
||||||
|
* `key === 'l'` would silently stop locking. The code names the key position
|
||||||
|
* and is identical on every layout.
|
||||||
*/
|
*/
|
||||||
export function isPanicLockChord(input: LockInputEvent): boolean {
|
export function isPanicLockChord(input: LockInputEvent): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -52,6 +60,6 @@ export function isPanicLockChord(input: LockInputEvent): boolean {
|
|||||||
!input.shift &&
|
!input.shift &&
|
||||||
!input.alt &&
|
!input.alt &&
|
||||||
!input.meta &&
|
!input.meta &&
|
||||||
input.key.toLowerCase() === 'l'
|
input.code === 'KeyL'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import zhTW from 'antd/locale/zh_TW'
|
import zhTW from 'antd/locale/zh_TW'
|
||||||
@@ -46,6 +46,9 @@ export default function App(): React.JSX.Element {
|
|||||||
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
||||||
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
// Stable so the memoized icon rail inside Workspace can bail out of the
|
||||||
|
// re-renders Workspace does on every session/panel change.
|
||||||
|
const openSettings = useCallback((): void => setSettingsOpen(true), [])
|
||||||
/** Secure default: main may already hold a startup lock before this IPC
|
/** Secure default: main may already hold a startup lock before this IPC
|
||||||
* resolves, so the shell must not become interactive while unknown. `null`
|
* resolves, so the shell must not become interactive while unknown. `null`
|
||||||
* means "not answered yet" and is kept distinct from "locked": the overlay
|
* means "not answered yet" and is kept distinct from "locked": the overlay
|
||||||
@@ -171,7 +174,7 @@ export default function App(): React.JSX.Element {
|
|||||||
before the browser checks for a focused descendant. */}
|
before the browser checks for a focused descendant. */}
|
||||||
<div className="app-root" inert={locked || undefined} aria-hidden={locked || undefined}>
|
<div className="app-root" inert={locked || undefined} aria-hidden={locked || undefined}>
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
<Workspace onOpenSettings={openSettings} />
|
||||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
<TransferPanel />
|
<TransferPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Progress, Select, Switch, Tag } from 'antd'
|
import { Button, Progress, Switch, Tag } from 'antd'
|
||||||
import { DEFAULT_LANGUAGE, t, type Language } from '@shared/i18n'
|
import { DEFAULT_LANGUAGE, t, type Language } from '@shared/i18n'
|
||||||
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
|
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
|
||||||
import { useSettingsStore } from './store'
|
import { useSettingsStore } from './store'
|
||||||
@@ -103,11 +103,6 @@ export function AboutTab(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="about-controls">
|
<div className="about-controls">
|
||||||
<Select
|
|
||||||
value="stable"
|
|
||||||
options={[{ value: 'stable', label: t('settings.about.channelStable') }]}
|
|
||||||
style={{ width: 150 }}
|
|
||||||
/>
|
|
||||||
<label className="about-autocheck">
|
<label className="about-autocheck">
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { App, Button, Checkbox, Popover, Tooltip } from 'antd'
|
import { App, Button, Checkbox, Popover, Tooltip } from 'antd'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
@@ -1152,28 +1152,34 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
[message]
|
[message]
|
||||||
)
|
)
|
||||||
|
|
||||||
const layoutMenu: MenuProps = {
|
// Memoized so the memoized IconRail below can actually bail out: the labels
|
||||||
items: [
|
// are translated at build time, so `language` has to be a dependency or a
|
||||||
{ key: 'split-right', label: t('workspace.layout.splitRight') },
|
// language switch would leave stale menu text behind.
|
||||||
{ key: 'split-below', label: t('workspace.layout.splitBelow') },
|
const language = useSettingsStore((s) => s.settings.system.language)
|
||||||
{ type: 'divider' },
|
const layoutMenu = useMemo<MenuProps>(
|
||||||
{ key: 'save', label: t('workspace.layout.saveTemplate') },
|
() => ({
|
||||||
{ key: 'apply', label: t('workspace.layout.applyTemplate') }
|
items: [
|
||||||
],
|
{ key: 'split-right', label: t('workspace.layout.splitRight') },
|
||||||
onClick: ({ key }) => {
|
{ key: 'split-below', label: t('workspace.layout.splitBelow') },
|
||||||
if (key === 'split-right') handleSplit('right')
|
{ type: 'divider' },
|
||||||
else if (key === 'split-below') handleSplit('below')
|
{ key: 'save', label: t('workspace.layout.saveTemplate') },
|
||||||
else if (key === 'save') setSaveOpen(true)
|
{ key: 'apply', label: t('workspace.layout.applyTemplate') }
|
||||||
else if (key === 'apply') setApplyOpen(true)
|
],
|
||||||
}
|
onClick: ({ key }) => {
|
||||||
}
|
if (key === 'split-right') handleSplit('right')
|
||||||
|
else if (key === 'split-below') handleSplit('below')
|
||||||
|
else if (key === 'save') setSaveOpen(true)
|
||||||
|
else if (key === 'apply') setApplyOpen(true)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[language, handleSplit]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-root">
|
<div className="workspace-root">
|
||||||
<IconRail
|
<IconRail
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onNewTerminal={handleRailNewTerminal}
|
onNewTerminal={handleRailNewTerminal}
|
||||||
onSplit={handleSplit}
|
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
connecting={connecting}
|
connecting={connecting}
|
||||||
@@ -1394,11 +1400,14 @@ function WorkspaceBroadcastPopover({
|
|||||||
/**
|
/**
|
||||||
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
||||||
* Top: workspace actions; bottom: settings.
|
* Top: workspace actions; bottom: settings.
|
||||||
|
*
|
||||||
|
* Memoized: the workspace re-renders on every session/panel change, and the
|
||||||
|
* rail only depends on sidebar visibility, the connecting spinner and the
|
||||||
|
* (memoized) layout menu, so the shallow compare lets it skip those renders.
|
||||||
*/
|
*/
|
||||||
function IconRail({
|
const IconRail = memo(function IconRail({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onNewTerminal,
|
onNewTerminal,
|
||||||
onSplit,
|
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
connecting,
|
connecting,
|
||||||
@@ -1406,7 +1415,6 @@ function IconRail({
|
|||||||
}: {
|
}: {
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onNewTerminal: () => void
|
onNewTerminal: () => void
|
||||||
onSplit: (direction: 'right' | 'below') => void
|
|
||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
sidebarOpen: boolean
|
sidebarOpen: boolean
|
||||||
connecting: boolean
|
connecting: boolean
|
||||||
@@ -1433,7 +1441,7 @@ function IconRail({
|
|||||||
<RailButton title={t('workspace.rail.settings')} icon={<SettingOutlined />} onClick={onOpenSettings} />
|
<RailButton title={t('workspace.rail.settings')} icon={<SettingOutlined />} onClick={onOpenSettings} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
/** M6.1: terminal / SSH workspace entries in the icon rail. */
|
/** M6.1: terminal / SSH workspace entries in the icon rail. */
|
||||||
function ModeRailButtons(): React.JSX.Element {
|
function ModeRailButtons(): React.JSX.Element {
|
||||||
@@ -1464,7 +1472,7 @@ function ModeRailButtons(): React.JSX.Element {
|
|||||||
* rail's own stacking context, opens to the right of the rail, and closes only
|
* rail's own stacking context, opens to the right of the rail, and closes only
|
||||||
* on item pick, outside pointerdown, or Escape.
|
* on item pick, outside pointerdown, or Escape.
|
||||||
*/
|
*/
|
||||||
function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
|
const LayoutFlyout = memo(function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -1527,7 +1535,7 @@ function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function RailButton({
|
function RailButton({
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -307,7 +307,6 @@ const settings: Record<string, string> = {
|
|||||||
'settings.about.statusDownloaded': 'Download complete — restart to install',
|
'settings.about.statusDownloaded': 'Download complete — restart to install',
|
||||||
'settings.about.statusError': 'Check failed',
|
'settings.about.statusError': 'Check failed',
|
||||||
'settings.about.statusDev': 'Update checks are unavailable in development mode',
|
'settings.about.statusDev': 'Update checks are unavailable in development mode',
|
||||||
'settings.about.channelStable': 'Stable channel',
|
|
||||||
'settings.about.autoCheck': 'Check automatically at startup',
|
'settings.about.autoCheck': 'Check automatically at startup',
|
||||||
'settings.about.download': 'Download update',
|
'settings.about.download': 'Download update',
|
||||||
'settings.about.downloadGithub': ' (GitHub source)',
|
'settings.about.downloadGithub': ' (GitHub source)',
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ const settings: Record<string, string> = {
|
|||||||
'settings.about.statusDownloaded': 'ダウンロード完了。再起動してインストールできます',
|
'settings.about.statusDownloaded': 'ダウンロード完了。再起動してインストールできます',
|
||||||
'settings.about.statusError': '確認に失敗しました',
|
'settings.about.statusError': '確認に失敗しました',
|
||||||
'settings.about.statusDev': '開発モードでは更新を確認できません',
|
'settings.about.statusDev': '開発モードでは更新を確認できません',
|
||||||
'settings.about.channelStable': '安定版チャンネル',
|
|
||||||
'settings.about.autoCheck': '起動時に自動で確認',
|
'settings.about.autoCheck': '起動時に自動で確認',
|
||||||
'settings.about.download': '更新をダウンロード',
|
'settings.about.download': '更新をダウンロード',
|
||||||
'settings.about.downloadGithub': '(GitHub ソース)',
|
'settings.about.downloadGithub': '(GitHub ソース)',
|
||||||
|
|||||||
@@ -291,7 +291,6 @@ const settings: Record<string, string> = {
|
|||||||
'settings.about.statusDownloaded': '下载完成,可重启安装',
|
'settings.about.statusDownloaded': '下载完成,可重启安装',
|
||||||
'settings.about.statusError': '检查失败',
|
'settings.about.statusError': '检查失败',
|
||||||
'settings.about.statusDev': '开发模式不支持更新检查',
|
'settings.about.statusDev': '开发模式不支持更新检查',
|
||||||
'settings.about.channelStable': '稳定更新通道',
|
|
||||||
'settings.about.autoCheck': '启动时自动检查',
|
'settings.about.autoCheck': '启动时自动检查',
|
||||||
'settings.about.download': '下载更新',
|
'settings.about.download': '下载更新',
|
||||||
'settings.about.downloadGithub': '(GitHub 源)',
|
'settings.about.downloadGithub': '(GitHub 源)',
|
||||||
|
|||||||
@@ -291,7 +291,6 @@ const settings: Record<string, string> = {
|
|||||||
'settings.about.statusDownloaded': '下載完成,可重新啟動安裝',
|
'settings.about.statusDownloaded': '下載完成,可重新啟動安裝',
|
||||||
'settings.about.statusError': '檢查失敗',
|
'settings.about.statusError': '檢查失敗',
|
||||||
'settings.about.statusDev': '開發模式不支援檢查更新',
|
'settings.about.statusDev': '開發模式不支援檢查更新',
|
||||||
'settings.about.channelStable': '穩定更新通道',
|
|
||||||
'settings.about.autoCheck': '啟動時自動檢查',
|
'settings.about.autoCheck': '啟動時自動檢查',
|
||||||
'settings.about.download': '下載更新',
|
'settings.about.download': '下載更新',
|
||||||
'settings.about.downloadGithub': '(GitHub 來源)',
|
'settings.about.downloadGithub': '(GitHub 來源)',
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
* in src/main/index.ts is built from. This is the decision v1.0.17 got wrong
|
* in src/main/index.ts is built from. This is the decision v1.0.17 got wrong
|
||||||
* from inside an untestable closure, so every chord spelling that matters is
|
* from inside an untestable closure, so every chord spelling that matters is
|
||||||
* pinned here:
|
* pinned here:
|
||||||
* - the panic lock is exactly Ctrl+L: Shift (IME switch on zh/ja) and Alt
|
* - the panic lock is exactly Ctrl+L, matched on the physical key: Shift (IME
|
||||||
* (AltGr on European layouts) must pass through, as must Caps-Lock 'L'
|
* switch on zh/ja) and Alt (AltGr on European layouts) must pass through,
|
||||||
* variants only when they really are Ctrl+L
|
* Caps-Lock 'L' still locks, and a layout whose 'l' key sits elsewhere
|
||||||
|
* (Dvorak) neither locks on a different physical key nor misses its own
|
||||||
* - the locked-window blocklist: reload (F5, Ctrl+R, Ctrl+Shift+R), zoom in
|
* - the locked-window blocklist: reload (F5, Ctrl+R, Ctrl+Shift+R), zoom in
|
||||||
* both plain and Shift-shifted spellings ('='/'+' and '-'/'_'), reset ('0'),
|
* both plain and Shift-shifted spellings ('='/'+' and '-'/'_'), reset ('0'),
|
||||||
* and DevTools (Ctrl+Shift+I/J/C)
|
* and DevTools (Ctrl+Shift+I/J/C)
|
||||||
@@ -28,9 +29,12 @@ const ok = (cond, msg) => {
|
|||||||
if (!cond) failed += 1
|
if (!cond) failed += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a classifier input; modifiers default to off. */
|
/** Build a classifier input; modifiers default to off. `code` defaults to the
|
||||||
|
* physical key for `k` ('l'/'L' → KeyL) — the panic chord matches on code,
|
||||||
|
* not on the layout-dependent key character. */
|
||||||
const key = (k, mods = {}) => ({
|
const key = (k, mods = {}) => ({
|
||||||
key: k,
|
key: k,
|
||||||
|
code: `Key${k.toUpperCase()}`,
|
||||||
control: false,
|
control: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
alt: false,
|
alt: false,
|
||||||
@@ -42,7 +46,9 @@ const ctrl = (k, mods = {}) => key(k, { control: true, ...mods })
|
|||||||
// ---- panic lock chord (exactly Ctrl+L) --------------------------------------
|
// ---- panic lock chord (exactly Ctrl+L) --------------------------------------
|
||||||
console.log('panic lock chord')
|
console.log('panic lock chord')
|
||||||
ok(isPanicLockChord(ctrl('l')), 'Ctrl+L is the panic chord')
|
ok(isPanicLockChord(ctrl('l')), 'Ctrl+L is the panic chord')
|
||||||
ok(isPanicLockChord(ctrl('L')), 'Ctrl+L with Caps Lock still matches (key is case-folded)')
|
ok(isPanicLockChord(ctrl('L')), 'Ctrl+L with Caps Lock still matches (code is case-independent)')
|
||||||
|
ok(isPanicLockChord(key('s', { code: 'KeyL', control: true })), 'Ctrl+physical-L matches on code when the layout produces a different key (Dvorak)')
|
||||||
|
ok(!isPanicLockChord(key('l', { code: 'KeyI', control: true })), "key 'l' from a non-L physical key is not the panic chord (Dvorak: 'l' sits on KeyI)")
|
||||||
ok(!isPanicLockChord(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes (IME input-mode switch)')
|
ok(!isPanicLockChord(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes (IME input-mode switch)')
|
||||||
ok(!isPanicLockChord(ctrl('l', { alt: true })), 'Ctrl+Alt+L passes (AltGr on European layouts)')
|
ok(!isPanicLockChord(ctrl('l', { alt: true })), 'Ctrl+Alt+L passes (AltGr on European layouts)')
|
||||||
ok(!isPanicLockChord(ctrl('l', { meta: true })), 'Ctrl+Meta+L passes')
|
ok(!isPanicLockChord(ctrl('l', { meta: true })), 'Ctrl+Meta+L passes')
|
||||||
|
|||||||
Reference in new issue
Block a user