From a9acdbe50010e7ccaa15af32c2ed882dccb2e376 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 7 Sep 2026 16:15:50 +0800 Subject: [PATCH] Initial commit: OpenTerminal v0.1.0 Open-source terminal app (local + SSH): split panes, themes, SFTP, server monitoring, broadcast input, ZMODEM, system tray, single instance. Electron + React + TypeScript. MIT License. --- .gitignore | 34 + LICENSE | 21 + README.md | 96 + build/icon-aurora.png | Bin 0 -> 58065 bytes build/icon-legacy.png | Bin 0 -> 27512 bytes build/icon-o-prompt.png | Bin 0 -> 58904 bytes build/icon-open-o.png | Bin 0 -> 36546 bytes build/icon.png | Bin 0 -> 27512 bytes docs/ROADMAP.md | 116 + docs/STATE.md | 97 + electron-builder.yml | 50 + electron.vite.config.ts | 25 + package-lock.json | 7286 +++++++++++++++++ package.json | 46 + src/main/assets/tray.png | Bin 0 -> 131 bytes src/main/broadcast.ts | 8 + src/main/commands.ts | 288 + src/main/connectionsStore.ts | 219 + src/main/globalShortcuts.ts | 37 + src/main/index.ts | 122 + src/main/ipc.ts | 174 + src/main/knownHosts.ts | 111 + src/main/layouts.ts | 61 + src/main/pty.ts | 346 + src/main/settingsStore.ts | 177 + src/main/sftp.ts | 424 + src/main/ssh.ts | 310 + src/main/ssh2.d.ts | 137 + src/main/sysinfo.ts | 415 + src/main/tray.ts | 118 + src/main/updater.ts | 54 + src/main/windowChrome.ts | 16 + src/main/zmodem-js.d.ts | 125 + src/main/zmodem.ts | 476 ++ src/preload/index.ts | 123 + src/renderer/index.html | 15 + src/renderer/src/App.tsx | 39 + src/renderer/src/ErrorBoundary.tsx | 66 + src/renderer/src/commands/CommandsPanel.tsx | 261 + src/renderer/src/commands/commands.css | 164 + src/renderer/src/connections/Common.tsx | 84 + .../src/connections/ConnectionEditDialog.tsx | 211 + .../src/connections/ConnectionSidebar.tsx | 320 + src/renderer/src/connections/Fields.tsx | 89 + src/renderer/src/connections/connections.css | 439 + src/renderer/src/env.d.ts | 9 + src/renderer/src/global.css | 86 + src/renderer/src/main.tsx | 161 + src/renderer/src/monitor/MonitorPanel.tsx | 272 + src/renderer/src/monitor/monitor.css | 290 + src/renderer/src/settings/HighlightTab.tsx | 451 + src/renderer/src/settings/SettingsDialog.tsx | 70 + src/renderer/src/settings/SettingsTabs.tsx | 399 + .../src/settings/ThemeSettingsTab.tsx | 121 + src/renderer/src/settings/fields.tsx | 57 + src/renderer/src/settings/highlight.css | 221 + src/renderer/src/settings/settings.css | 346 + src/renderer/src/settings/store.ts | 114 + src/renderer/src/sftp/FilePanel.tsx | 638 ++ src/renderer/src/sftp/TransferPanel.tsx | 126 + src/renderer/src/sftp/sftp.css | 313 + src/renderer/src/terminal/TerminalView.tsx | 916 +++ src/renderer/src/terminal/highlightEngine.ts | 294 + src/renderer/src/terminal/terminal.css | 333 + src/renderer/src/theme/editor/ThemeEditor.tsx | 201 + src/renderer/src/workspace/ConnectFlow.tsx | 175 + src/renderer/src/workspace/HostKeyModal.tsx | 60 + .../src/workspace/QuickInputPanel.tsx | 173 + src/renderer/src/workspace/SshBottomPanel.tsx | 66 + src/renderer/src/workspace/SshHostBadge.tsx | 17 + src/renderer/src/workspace/TerminalPanel.tsx | 95 + src/renderer/src/workspace/TerminalTab.tsx | 251 + src/renderer/src/workspace/Workspace.tsx | 1084 +++ src/renderer/src/workspace/ZmodemOffers.tsx | 126 + src/renderer/src/workspace/broadcastStore.ts | 100 + src/renderer/src/workspace/workspace.css | 880 ++ src/renderer/src/workspace/workspaceMode.ts | 30 + .../src/workspace/workspaceModeStore.ts | 36 + src/shared/api.ts | 108 + src/shared/commands.ts | 26 + src/shared/connections.ts | 86 + src/shared/ipc.ts | 161 + src/shared/settings.ts | 106 + src/shared/sftp.ts | 31 + src/shared/sysinfo.ts | 34 + src/shared/theme.ts | 382 + tests/.exact-inline.mjs | 66 + tests/commands-store.mjs | 139 + tests/electron-stub.cjs | 12 + tests/hl-split-smoke.mjs | 64 + tests/sftp-chmod.mjs | 78 + tests/sftp-real.mjs | 109 + tests/ssh-loopback.mjs | 163 + tests/ssh-session-e2e.mjs | 151 + tests/sysinfo-e2e.mjs | 200 + tests/zmodem-e2e.mjs | 257 + tsconfig.json | 7 + tsconfig.node.json | 26 + tsconfig.web.json | 23 + 99 files changed, 23940 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build/icon-aurora.png create mode 100644 build/icon-legacy.png create mode 100644 build/icon-o-prompt.png create mode 100644 build/icon-open-o.png create mode 100644 build/icon.png create mode 100644 docs/ROADMAP.md create mode 100644 docs/STATE.md create mode 100644 electron-builder.yml create mode 100644 electron.vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/main/assets/tray.png create mode 100644 src/main/broadcast.ts create mode 100644 src/main/commands.ts create mode 100644 src/main/connectionsStore.ts create mode 100644 src/main/globalShortcuts.ts create mode 100644 src/main/index.ts create mode 100644 src/main/ipc.ts create mode 100644 src/main/knownHosts.ts create mode 100644 src/main/layouts.ts create mode 100644 src/main/pty.ts create mode 100644 src/main/settingsStore.ts create mode 100644 src/main/sftp.ts create mode 100644 src/main/ssh.ts create mode 100644 src/main/ssh2.d.ts create mode 100644 src/main/sysinfo.ts create mode 100644 src/main/tray.ts create mode 100644 src/main/updater.ts create mode 100644 src/main/windowChrome.ts create mode 100644 src/main/zmodem-js.d.ts create mode 100644 src/main/zmodem.ts create mode 100644 src/preload/index.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/src/App.tsx create mode 100644 src/renderer/src/ErrorBoundary.tsx create mode 100644 src/renderer/src/commands/CommandsPanel.tsx create mode 100644 src/renderer/src/commands/commands.css create mode 100644 src/renderer/src/connections/Common.tsx create mode 100644 src/renderer/src/connections/ConnectionEditDialog.tsx create mode 100644 src/renderer/src/connections/ConnectionSidebar.tsx create mode 100644 src/renderer/src/connections/Fields.tsx create mode 100644 src/renderer/src/connections/connections.css create mode 100644 src/renderer/src/env.d.ts create mode 100644 src/renderer/src/global.css create mode 100644 src/renderer/src/main.tsx create mode 100644 src/renderer/src/monitor/MonitorPanel.tsx create mode 100644 src/renderer/src/monitor/monitor.css create mode 100644 src/renderer/src/settings/HighlightTab.tsx create mode 100644 src/renderer/src/settings/SettingsDialog.tsx create mode 100644 src/renderer/src/settings/SettingsTabs.tsx create mode 100644 src/renderer/src/settings/ThemeSettingsTab.tsx create mode 100644 src/renderer/src/settings/fields.tsx create mode 100644 src/renderer/src/settings/highlight.css create mode 100644 src/renderer/src/settings/settings.css create mode 100644 src/renderer/src/settings/store.ts create mode 100644 src/renderer/src/sftp/FilePanel.tsx create mode 100644 src/renderer/src/sftp/TransferPanel.tsx create mode 100644 src/renderer/src/sftp/sftp.css create mode 100644 src/renderer/src/terminal/TerminalView.tsx create mode 100644 src/renderer/src/terminal/highlightEngine.ts create mode 100644 src/renderer/src/terminal/terminal.css create mode 100644 src/renderer/src/theme/editor/ThemeEditor.tsx create mode 100644 src/renderer/src/workspace/ConnectFlow.tsx create mode 100644 src/renderer/src/workspace/HostKeyModal.tsx create mode 100644 src/renderer/src/workspace/QuickInputPanel.tsx create mode 100644 src/renderer/src/workspace/SshBottomPanel.tsx create mode 100644 src/renderer/src/workspace/SshHostBadge.tsx create mode 100644 src/renderer/src/workspace/TerminalPanel.tsx create mode 100644 src/renderer/src/workspace/TerminalTab.tsx create mode 100644 src/renderer/src/workspace/Workspace.tsx create mode 100644 src/renderer/src/workspace/ZmodemOffers.tsx create mode 100644 src/renderer/src/workspace/broadcastStore.ts create mode 100644 src/renderer/src/workspace/workspace.css create mode 100644 src/renderer/src/workspace/workspaceMode.ts create mode 100644 src/renderer/src/workspace/workspaceModeStore.ts create mode 100644 src/shared/api.ts create mode 100644 src/shared/commands.ts create mode 100644 src/shared/connections.ts create mode 100644 src/shared/ipc.ts create mode 100644 src/shared/settings.ts create mode 100644 src/shared/sftp.ts create mode 100644 src/shared/sysinfo.ts create mode 100644 src/shared/theme.ts create mode 100644 tests/.exact-inline.mjs create mode 100644 tests/commands-store.mjs create mode 100644 tests/electron-stub.cjs create mode 100644 tests/hl-split-smoke.mjs create mode 100644 tests/sftp-chmod.mjs create mode 100644 tests/sftp-real.mjs create mode 100644 tests/ssh-loopback.mjs create mode 100644 tests/ssh-session-e2e.mjs create mode 100644 tests/sysinfo-e2e.mjs create mode 100644 tests/zmodem-e2e.mjs create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsconfig.web.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b427c64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies +node_modules/ + +# build output +out/ +dist/ +tests/.session-e2e.cjs +tests/.sftp-svc.* +tests/.hl-smoke.* + +# research artifacts (XTerminal reverse-engineering notes, not part of the app) +research/ + +# logs / misc +*.log +.DS_Store + +# credentials (do not commit) +.env + +# ts build info +*.tsbuildinfo +.sftp-test-*.bin +.sftp-dl-* + +tests/.commands-store.cjs +tests/.session-e2e.cjs +tests/.hl-split-smoke.cjs +tests/.zmodem-e2e.cjs +release/ + +# stray local test artifacts +.min-test.bin +.min2-test.bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41cacac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7199a73 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# OpenTerminal + +开源的现代化终端应用:本地终端 + SSH 远程会话 + 分屏管理 + 主题/字体定制。Electron + React + TypeScript,从 0 到 1 独立开发。 + +## 快速开始 + +```bash +npm install # 已安装可跳过 +npm run dev # 开发模式(热更新) +npm run build # 生产构建到 out/ +npm run typecheck # 全量类型检查 +``` + +> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js` + +## 功能 + +**终端核心** +- 本地终端(node-pty,Windows PowerShell / macOS zsh / Linux bash 自动探测),自动声明 256 色与真彩能力 +- xterm.js 渲染:WebGL 高性能模式,加载失败/上下文丢失自动降级 DOM +- 会话生命周期:退出遮罩显示退出码;窗口关闭时回收全部会话 +- 搜索(Ctrl+F 内嵌搜索条、全部高亮、n/N 计数) +- 剪贴板(Ctrl+Shift+C/V、可选选中即复制) +- 粘贴风险确认(多行/长文本需手动确认,可关闭) +- 字体/字号/字重/字距/行高/回滚行数/光标样式实时生效 +- 关键词高亮:11 条内置规则(权限、路径、错误状态、IP、URL 等),支持自定义正则规则 + +**SSH 远程会话** +- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证 +- 连接管理器:左侧书签栏(分组手风琴)、双击连接、新建/编辑/删除对话框 +- 凭据安全:密码/私钥/口令经 Electron safeStorage(DPAPI)加密后落盘,渲染层永远接触不到明文密钥;支持「连接时询问密码」模式 +- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、30s 无响应自动拒绝 +- 服务器硬件监控:CPU / 内存 / 磁盘 / 网络实时面板(无需在服务器部署任何脚本) +- SFTP 文件管理:双栏浏览、拖拽传输、队列/进度/取消、权限与属主修改 +- ZMODEM(rz/sz)文件传输 +- 会话数据面与本地终端完全统一(写/缩放/关闭/退出事件同通道) + +**分屏与布局** +- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单 +- 布局模板:把当前分屏保存为模板、一键应用(自动重建会话)、可删除 +- Terminal / SSH 双工作区,互不混排 + +**效率工具** +- 广播输入:勾选 ≥2 个终端后同步键入,标签带广播标记 +- 快捷输入面板:右下角浮层,命令历史 + 命令库(支持 `{{param}}` 变量)+ 一键执行 +- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭) +- 会话日志:手动启停、纯文本落盘 +- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、全局唤起/隐藏(可配) + +**外观与窗口** +- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等) +- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览 +- 窗口标题栏与背景跟随终端主题 +- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换 +- 单实例运行:重复启动唤出已有窗口 +- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步 + +**测试** +- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹) +- `node tests/ssh-session-e2e.mjs` — 真实会话路由层端到端(需先跑 esbuild 打包命令,见脚本头注释) + +## 架构 + +``` +src/ +├── shared/ # 主/渲染进程共享契约(IPC 通道、设置、主题模型) +│ ├── ipc.ts # 通道名 + 负载类型 +│ ├── api.ts # window.api 接口(preload 暴露面) +│ ├── settings.ts # AppSettings / 默认值 +│ └── theme.ts # TerminalTheme / 内置主题 +├── main/ # 主进程 +│ ├── pty.ts # PTY 会话池(node-pty)→ 广播 PTY_DATA/PTY_EXIT +│ ├── settingsStore.ts # JSON 持久化 + 深合并兜底 +│ ├── layouts.ts # 布局模板存储(userData/layouts/*.json) +│ ├── tray.ts # 系统托盘 + 关闭行为 +│ └── ipc.ts # 全部 ipcMain 注册 +├── preload/index.ts # contextBridge → window.api +└── renderer/src/ + ├── terminal/ # TerminalView:xterm 封装(渲染器/搜索/剪贴板/粘贴确认) + ├── workspace/ # dockview 工作区:工具栏/分屏/会话生命周期/布局模板 + ├── settings/ # zustand store + 设置对话框 + └── theme/editor/ # 自定义主题编辑器 +``` + +关键设计:**契约先行** —— `src/shared/` 是唯一契约源,主/预载/渲染三端各自实现,模块间零横向依赖(terminal 不感知 workspace,settings 不感知 terminal)。 + +## 数据位置 + +- 设置:`%APPDATA%/OpenTerminal/settings.json` +- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json` +- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密) +- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json` + +## License + +[MIT](LICENSE) diff --git a/build/icon-aurora.png b/build/icon-aurora.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed8412036d5af51117d951ddde2475cc6bc1454 GIT binary patch literal 58065 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%0D!!4Dhaa>MoYh%Nehxky=EcJl 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 z z96_Flr;Ru1)p5WC<6=a(7JZ*veQeMoIPDq~E}VltMI#-Kw@#VROHKKX5$ahAhY)PQYT- z*An?lPIWBZFIq39#aeQ1lOEq5jwoSor@7$I#x5~VyJ;R{&I7)ygc7y4S2mmbvAsEs zU2OrgcrE;VB+`5*;^v6o7Q#P4Q;2ecKDNk<%SJG-BSn}4Gs=F6McnA(L@eQ?#SVuS zr*%AJjqa_qknT`bB}S@|kLy@}q5Z6a-TPfh`QK6BKOp*>j7m55;|L@G=Mp5AVu8z? zb?FB+xV|$SN1H5F?^$-2iPX8qhPl9tfNZv26S=U%m4~KAeBuzTAqX))=YH&a10yU< z-D)j9oIrDtAms3J<{14WVq`-lHj+0UvWASoRBXYqoENW?q>~==^4n+Hta=l8$n75K z|G4tTtLWRfaKnDl19j&6K;}wvkMV4k@Y~=6m*mf9xGJis=O6I76M2FNY(@h3f5c5` z-m?ND3D8{;n3fq7H;Q!O-g`Dx={I#U;#_$W56}5T4;fDk5L=4_`u-af+5ZuhTNA-0 zmGe{Uowgg)$UZ;nYF!A_+s2=Slnc46HSGOkPWFSV!5s0HF?n2gECAkY zcKU}7p*wQXnG-d(4UEO2Ury98hlu0+;P19#*4PZ4_@R@{X*i~5O1$Xd`)l|dFhyB= zbqGzirnfrpmp<9Q?aLIs&~Z97ZaFPBTJfnItW}xUT1vf}*<32M4rCmp7ZXF(;rG0; zC8$Dk3E3g6og)s$q4hH^fp8sMJUj~|v)T2?By?N7M*2yG4i(rnx!u36)THoRb^~5J z7-(eb57C8ZdBLV$vSLGQ-q2opfe-kA!Xs6-B_b_zYSpx>?Mb00;}1ktinA}AA;wG~ z{W~80N9)B?^PSjtLYopp2ln3gvbcxnBQ~tAy|Tp03s&ioKz|=0^O=p(bX}Np1b&o_ zAp1k$8+i8A5Q++pfZb^DZ*&36N-8L>+o`rQ%L>l%steIw^a6dNcPat5nu20#%5vHL4M${Y|~i@{V_i6Tp{rk|5io@ z?{aObOQW>mir?*-!r#h0nVmgY#YnZ;n2P#zd$uR{eQ9ivszY=K#Uj8ZbvR_eT0N4W z+sy@rwYU#Qrpl7t!30iy=;xLkblahvL#K66JAuwZd5Q&V9IVQY+fq1>S~nI(bn zwKclSF@*95H%uUa_^Gvp9+t=6M+w@|e00!>?q_(PW-@vM{MY{0`8Z7v zKnHcm5wFvSen$Qpz+`*$VaEXg4^o+4Sdk`#9b&WIQ=7|`$O+N+BJ&MHR>DeRFa&B{L#v_m}Mty zum(DwUNWz@tM9ZcBp4ma3d~3*yus`MA9L#$ws5r#a zT7B^ExVm3KUk-knfX@EYSSy)Ag!bBOj3GKM(;jn-y`PaPk{&Xk_m`SWo>b$eSO|?Q z49NkK_pBdy2{mOIYyswP2IS%m8u=p-xN6RWkEW+4Nb_#2s~fs-Di+ikj3v5&OqBKY z(g46 zM!5w_`{(^={rq*N>Q?5m5c;yrTzm>-wfOHD=pttZGX*0r9*wGEeLWzm6fL7&TC?t* zXu+@D1eoKYCM3GbJQL%+4d!i0+C%@F*Rv=u8igy9A1pt}%-pUJElm~Xb?M~3O>BT#+c=OqLTQn;X-B0?sZ;ML!WCXkW&09k|X2qD!317^FCtqBZ zql5p80{SM`ZZzND#zw-SPvh{$8T1`6!7mQ!cpEqz6f9lD{0~`z0f1X2W$r)?M@Nq_ zL2S6n#5!MhC+J{#CddTHjIFRI>w0?*om9EwwBc_hb$78k8uecN!B|G5YTx(obLPxA7#XsR6hkUx zt5n+FIY`k)+APIXih3)eq=h+DDk(y=$&{@HyC5O~wi3?xpYm(<)dtNyQ z@k4kRpY!fL6!tAKn>kBZCJ(PPVC1Td&T6UViT>vZ$B6qG`qG{{od8hIwr18zT#Q2UKjo<8&^)_6(3{y0N|=En&1Gce$Ggog2dmgeMu}u8(141Ue)Mv;y$5 zmYUIOlbEj7z>K;=1M#4_Z}Ni={BvrBECqKV)~?{84`K-D3#evXV-${G4mqj|rg!C{ zlW?X20W}#2u+RX3sRo4XgEoZ`B7h(M#1e&;1*+d+K6)Bd+jj;rcE6&N(GC)|eA|?N zDe^+uXE)Jq7FsAgv&;tSa~ zU{#e7H5}okG%i4t?ORPi&&t7N2ZwC!C|a%d3j+tw8NX{`!s9jb%u>v_ zR#Uk&&*!TC;UVVu^=+n@T7G$Ob#UCbEyYTKZ{OG!U9@zGO})NxKMNBo1|Cf^-GkPJ zNl(F3uRv^u6W}y8vt0~7T!e4a`(87?V7;Fuc==mw1c)quMO^mUh8YAWE)2q+`%$}|Nc3z}~B4*#7cKIE%r&r|$P64r-g{FC2zHSC_FYI(4& zBzf<2Qx56EqbdBr?^oCKk#0w@`Fn47CL`Y&-)0{dt(Z98Ks<5F4M!sF1{l6EqNBJn zHZ@@d%+Umq<@>AZAOAvW7IT_IS1E+ze)GH4Ia;*81FA%G_JR|!7~}bUrbKUZfXL*N zF7FS_Ad=(0oZBz}_XbN2O9T7WP2pz^#hC`Ge4(BHpDUEB8E-zBQR^h|adl6mw>!!> z^n(ASa?dg^2#yL~HYIv!Zb@WL;`zy4 z=_GpM7!)q38M3ze%W22^f4|t(6s|WbXTI`n;?l0r+PwyMrnhV7&+*PYp%)gp>M2h~ zK>}r1q;qBruhv1;vgiHoltf`q^5z>>NjOv_LYx4beGZ~!cg{< zh+r3O3)po5p1Jw3xNm`2qRi?%V<^pfVxP4ilRN;rb`0$R&G%TcUz@_g>D?Y^%am$C z)ji;!OpZ|HSX^X6+o7{ypjbTiIntl?>s0fGS&HwDaT7~4Zh#wD|KY%WJxuV;p4Nxx?cgF@0m)zQv1K7|APjLo zW{_~8b>fFw9xbBxf&;Pmu_pCj{Nxas31kdPo;>V<+_AG-=%-AU?=cWR+Ar!V^wPZ2 zc|AWwCsgp9#vF*MehZ&R0+|QxsTe&+H9O`Fk|O8zh3h_$vd{2%J_Q2F^pA+6Rv87U zi-5AlA$is5pLM^N!H-{~ps!kJ3#Bc>C$Tsbsl5-?GsD9aQT5EOb|$!^9hW_xJQ(XS z=93~flvECH2+`}D%t1)l4qhiq)a`az+4`m>2a!|`&R4%3d8*Geqi&C}@Mhx)(2t4`36 zOuCPn?WSn8X>nYn)S1;4RV7@u`E+65q?YU?wBw1po<;`*gxr9PxPfUpj&LOrWaAj; z_&^?9WxJLuOdr!;Y^LMtvOM4Ce2}QhyDD(^ENZLNp9z+#q!dV?Jo3N{HW4 z?l~;JGfgEd`JA|$ZZ#oe;^S#!8?y2LWk4c~beZiz&rzfxYIF6`{&A?vy0meYxsTGu zan-|&H~YqLlso}Vk@%{{39?)H&)lfP2kF_&p3=k(w9}X#$g&* z$6Y_0vrN6MUU{u4Jp1R2A%N)9d&ydiKH&m+H{do-E#Sb-N^rV1`o(Zq7z_NwOHBGM zxwl@G@Zt@1?_6dMd0~OO2b$cT2l7IMpy&yWxH7x{1`l>*hUO}-CZi{+68_XZ)3>1f zL1|~U!CS8vrw?Kih*=5mQN@3TJVOqbbbssdQy|Zcr`Ac#g{=23H(z;u&78fn0t^XQ zb!a89A%q4`S1k1~K|S(XSOE?Y8H>{&C%HmKKMVT0%>mKxC#~xjN9US4AXAi>lko#* z?SJX)D2B#)|BwiTE%Q;N6ropT&2Y~vM6<$bMI<%YGl7#g0`Co;VMD$#gS}0BYA~GkXDT#cJizp4Okw`O zbaw)rSl4OMi7G~t^9`MDw3Vyk|4R>CQUEbJ_ROT4@|v+#Ipcc@AGci2?@Ld8kk^?j zi#H&Y0{&x4j}`%rp2U_*EYu?q97|dOi&9}u*2IsmrvhjiC zK~>PvLC6zItM`tX9WymZ{f9m4uDph-LZX%NGX{c=$?7iSFGq`e(Xu428JF0Sv?U9+T7!HO)vaR3}Dp30m;~7i=ME@GI8+j`C_+ zOU~B}@vNf0^dM(rCL{Z_->#}#5~0oFP3{tFPW%qdz4TZO%n(iL3e;C)D*u-_3~l2A z6y7g-F!xd{cSrp`(x5OXs1`WA=Po_m>8h zUS%eOM%#GOJdveL+W;F8OP(S0oTA%>ecH?g<15JDq`WWOB5`4VSk+~v&oJ1{$3;mR z5UJ3e)C)~q`Z20c1NLe_0qX3&;jmsMH1=aOs&dG9oo(oS4V*W$KtBK5QKmQ__hq6B z#r?((jL`+hhc}j|uDHysM`=u=4Tb_(n_Y=X*o4f^ z5ZXIF{yYBUyO80Mo$)$xVPp(K30eoDQ34v|Eq38WR7)dzO@GL1Q1qN_!z9NjkTJ-D zRigH+74!St1V@szaQ1R?lb!&>M&eZw#KMmmL8_YzyC%BZ#3|~+pU+~rHrum9kn&s? znotYk3K(p4Mb zR#;8Tq6S*INHo_4sWS8>ish%2=?N{u{L6iNR`~RbxN~;zjdy0TkYI_;^Qk)sH{~hk z`fPTKIXV~HcIKzdWJB|IV2g3GZ3K*63N97!-U$AD5f23}BEBY(kRjK=;JA7p5|Ri@ zDu(>s^Y3G5Tp_w|&f|~+>jFGtF{-Cexrw@SQfI2=d~mZ9i@U~E32l7T3;L6!3p&&D z3>xPV+MzYV^#q?pVb11ez`8Mq)8a1J!;|>%Ygo{M~;f^1S$=!!#LDr zV**hp{Oy5%4-*Lo@x37-G%fRF{#9QM1@^H3Z&dF&pCEubq1H@k}se znI`Zh0(x)!c)@>9h8!Rw>hDB%xmSxP`>9Q4DGG1%s)R@q9%8syxgd`;V)Bk#tk$v^ z+3+;uWa=1t=0rjZr8H}NRs@mp7KH-&g4JCUuNz92bmqxs=LD&@$KQPH)q*UtSP~ql zE9O{gJSO{xL!J%ZvI%@LS(N1*khC&r1$)>KPz7OAZ@zlAdg49)8{RHinJ&J0B=I`I zVuk21E8ftYIMJTtuO62y4`1HW@N$9LOQi6AoGC8pROAP9FSo7AWwE>#ZxTpEI-y!f z>xt|6=+C6suJl!QpY8R&<74y1oSKWtXN;tN?O#qI(H}?%g1RFHr|B%&vyd?Gd|(K7 zev7k#pT(x;@?b6p3U(U9xF0>@{#(74NYN`uj@OCyc# z2=e5*M&bj$$`kS|8?~3?{CR&BZTeaSk5}m?a8HC`n1>E&9i^9gME+W=|ZAP zoMzzJ#i|x3WbANCSKN`&2E>k>FAh8D1i+KrNY6&n`UMK7kC$BhdUVQ(=}VLoqfTG# z4!2sZOp|`cC3T~rxe23-eqj%cptUd$oSakP&C2}Cx3iFLc!Gi#leQCiBUzIJuhVfM z_X5Yabfx(zWhkqK=@}gd4slbrDF2L!R*b@0gbzm}gAFl$tUO>>_0&;%E*#69R3gGK zaQRTez@M~UfS`x--Bl^$4W$9?+cnR3KS!@~FPDzomq7Ed)Ww9%z!cnhuxq2P;08oI zV@6<3Mu;_Y6vD=W(*eZH!@yVVzfQ&Mv7adZkl;#bu}qtt#!#*lu>QTrYn#WY<=%Z& zjCgjIuuP}4v$$hl7L!3Kywg~=>U{|!xy7i8XL$v4wmye=1Dn&NHR3#ntV8swj9mk3 z#1N=fm}VOniyRtsUUxU?EIxfs6Si&y?r#{-0L;*WQy*A+N5hBT;z_{Wjne@&$RLiH z292LBD1zl3Dd;p{Vcz3-0&e`G(LI;nJd_Tdpb8DWhQyIr3F<_IY&*tlF{%>8_hw(- zFDx7tAmB|$QwU!!Nm@uG#@GlxUNDVX4 z7^@OKgEK23Y!^XKTikzad;35HU7*i0T}Mv!aHWk$a$Z-u@?@jM+xzSN)nN|h-M7}Z z2>mlhtM>&Niye-I?WRq8*3-R-mrXH`jYNhXA)1MOHe!O-9?6&PZJi<7&^YpH)+SV8 z{I`cS;Ajz~YKD+nbQd2Jp>VY68Kc1ld(@dJpf+Yj^;2`=z8lgTH}TGVv2YU|(29TA zd#GJl&!$S0p}QQEUh7#0PfQn3wsCV?gs!s|KFs+Zd(B<8UkjI`AXGVA@@6i+m+pR= zHOoM{w7tZrH{iv*4fyon_AS3nM@>h=b)O%^Ai7+LLGucNzNQTydy%|!&I1li8Mg_N z?}5_;TjnG#8iL8VIPGrEN?> zJS0xXB!A6l3^ZYp2f%A)DqO2!vU`}pt!*Taa~Bzgiks1pFn+QQ?md`SJMY`yEhW|B zgJu2FFLy^-vglhnF3Xq2Jigv6&&kVTctPsH$Ew6johQY%&m^r(c!xc+f7VBG*BN`1i#s8jqz@@X2MgE45g`iRJKNZVg1FIy$J)R8uJkbbF(q+-!m;8 zj&Zx?<}(LpHPuwjG-Rj0jj(_j)X8Mp?jW7ujDPUW#bh4T>W&e~byEK`-GRRO<{LhL zvA`uILv>sd=l9wIjSs>|z;G3A!z)fa}{s{2SWCx6Y`GVa(u z4l6xolZ9Z^LAWJYsORJ3POoh*aT^`mbFU)(?ca-kq+9lJY2QN$z=23RXomk@N-i1% z`;KABwvHiWeu%rd49?8-Z#~YgDD2In21&e>M;ocHZybHvjKaFMw>&o6 zdhl;A4dS`GKzk<`Vv}5nW_MSrBb#lIwJ zBCCLT);sdrwrEWrj%`q+AV<$DL&u%+?)N<&e=UwX2Nwtq&?koC#X|`MIUS_s)yyDU z)#B#-lne!KC2bl=cbhSvcwACj0iG`~iNcIF0%d*yfu&{vbv6Yk7pCT!fBX4g*N3E> z9PCa35!l%&5O5Q8c${@a-kt3exA)idVIuLz@SzxOrcG1@U+=3Zha^UscSD*|_GKL# zdGwvVfa8(h$VyECO%ABNj@~vNH&Ekgoma7a_!}w0A)Y32EjcMU95*|!+qhiGInt@!@U$m; zVNib@4_ZoDg5enLEM^srn?wV-6kcl37J4veFG%#zXsot6cnA@w_S`8-H!~uv`JT(R zdmDqly1QfAJd^sBv}{7-otY1+#K}gpx;lPcZrbB27`!V!ng|)8m%4K}z15)6P=7sw zOk_y+wp=?x?{=T@2Cz!5TQYikz!9*Xk3y*~ByPe)_0bx#Fh4j`hdG((z$3N#*5WU{ z!Fw$rIl(@17gh9FR~sieGo;&ecJzDa<`mZc(1uO_OldrQ{!NRcpYl`7oj7~7tRc3w zSd?hqs^dEfFMf`!c@Xw-kS18zvsJR)rrC2GKe+hpP6<^uNq1wCN}LMPgSV*a_;d&O z@3QN)=_`nor3)fIZK(at{9Ov0A}^V)7ra@au`7P=#Cw7WJ)iP+`~L2nQ{HIy_WovP zvH0g`V(8QzAA;2@%eh}))=b8m!&#n#(+Q%WlE-tW21OW%h6}uBQYYhS%LFv$n5C|! z9aZ5v`R3Al9bdENjomIii%d(LuvASQC_^O}iW&D=vQz_Di$mC2jOwcI5P^WAMr++L za4oXg9shECuL$8p8N%)TThfa4I#qYv=57N(haA+eo zjGF^4tq2J6JcQ1@){U}5_}!7ye##v`y$NDMTahWxQ#D3jg5!WSw%WdEtick z6AvP!g_rtSKx@GS9K{~rzx+lGJ;S)tRohGG6iHAjUs2v7kJRm=lm8*m8)OY-Bed-!ZeSBgryai8{vOXakN(W~I{etmwA z*4;wK^kA2x*bEf=hd|^|2#JSE5*K`of*;=|!H*6E<%0Vd4)x`;W80y47-Dbz#+SHT zu&Dg=&uB%>^FroYWp@fDQEk;R4wBziPcB zdP|mi(dq6hV{=+PF19^?;+d#L*G<4>VdpB~2!*Dx3zIG@4`Y-&y#bsX!E+7J&fI6u zhaX6=_ajNqWd|-Y;{fskki)0fNwq5Y{nGE%>tz)=W>@e0IWYS$6Qtw2l*oIf$BP-9 z=}H-K5RZ2$G*1Ennr<}UAshM^7d5taEp_C@u5w(Wtk=8Gq3#Z-Bod%%<4`*hciqjc1}C3OrJ0DkDi3$6 z7RQ|Xwz~LT)_X9;3zS}@=kGCsUG$t7va%V?E~mDP6(rJ@8rb2q@zGrkwi&9PQyJ4P zbYC7^)%Q?rkI~3r;pRUqYV8z^-i_jYDS4(1lVzAeo-``wRZN?9aCt@W0Mv1IM zU4a(G$W0-b}~t?5NY%F z6c=WxHwUEMY|ptkJ^#!wxW5oW+<`i#LwBvmpA+ggGZ6p2 z!3Wa;_!p)eiJPBkE!6hj;|i(7D(GgXs3QboS4W!I`X*& zUA4cVeqo<9HR;*uEx&(Le!y=xfw9O!j~y%fy?9Z1@12pn4rl~4Th zzc*YNq8q!fS^IkB3fX=mgFd~~vTs2blzNaC+V?_Sd>3GX%DWB|F+sr*UDuzL{OK_z zHr)DN!odVYo}pf<-gL^Sh+Vny25fz7PwCy%=zLmP6}L@YHt(MGwg6klpck#+IU5(W zXwWJvnG6?|jD+Mvn3KUsNb^&$ptkR0R&yI_k-5(f4cEa#&}b5)TDxn2`rErL?DUVk z;1Z1KJ}~>nMEplCm;%~$BT{f{(o>(8iVBRK7IYcA5;6NkEJ+lXx8be#Odol9D&`{r z0s21~FFK6yj)C;x}NDIV1#vE0LiJgzLr9gU5mlZVQFq zt$qRjG%m2$q+d4TqM5-Uc}B>JILHFOQUu;%PA(d{zUqg;!@{zJ9NKxgs>Yt-#P;We zu;_*Ad5>nlW*aYCIc(|&G4IW@_wd(Rq(Q)!Hq#5117s*W(f{5!;)GfU(kuR&$+jG> zHe1}UwJPpdMo#<}7PYk&OC2IYmG^V3Xv$LHUI8b)VNkc^L+E&Ot(vO$^1fo3$zMB`i}90(W<3v-BCpp%hJnG=W#T$2WI!|vam zG|>Q()gQ0F=@!mDqTUIeaqDBzI2c3Hv7Hc6f-UK7nIM`Hr2pZ{lo9*rYgYJJnLW+N z6Air1Nn?3ec7v5HUu^zGwXH5av2X(j%V5ffMZwOVox){)IU?|5E!GTZnF8s;lf& zcPd~g_z-zlkv@oD0d{DmE5KvMXXb;~2knztomeUxb#?&p@*Giycq%GQbYRLZ@bTfu zQmtVm+Bv=~s@=FF;AunL+qrx`*{nUy8a_8JZEYBj@`a#9sADb0VdMw^Yq7JcROijS zEaJI67-NHv@lvf&d5XelF(2Ox13MZKk;_kKIVwLLo?>;__|gQZ)uPMnVB~!^{c;SN zVx*pC$(E=spC`soAUnenT)XA$QPm~?6 z-X4vOH~JHK>=H0RiJh#h)uV)*%HJj6X|Xt{v|#24=0)Xc4!v~Z5X@;hK?ST()i{bb z!<;{SXdST+-aIcv5gKg|7PzcYE*?i(#iy1v1tAaRmN|@lo^ldM<}!m9QHuSTtomo~ zKkE*%kt?<54ZYN+;j|`wtk32iH;a zzf;S@QgKYCj#6i7<#5ihLA>>3RRt8Dfj6f%>C$NHPKLxVIN}48FM#fjw{ws1B_774 zwTtJnjX8WsL-I$&DPIGrwqhat5N<*C>-9BkPetxvYz~Cb=t#Blk$uT9M6Tr3Couam z!!T;`IPh#Sw}j{)zAgz%*fP(B@MhRpc(h%I7GD3I?{^Q!_?k}KWVLRyChdGf?Tz&A zX*m6-qk%Vw-Z%&z&63DFMs`pVbEzoIrv!5aPfJWdf_bEpW#4OgU1af*8`?r^=! zSk+askSKa5mTG}hVf}@;fQ?hFHNEr3Lo)IzSb{qEF^D053Kzt_MXm*&3SaL{JL#ld z0@z^;TTvq|#OcSAuPPNU#7BM9Lm`=ii9##FKJ}OCmS8($ylUgzt%eKok^BQ-EUNJ) zLFE`oEgZ@+*wNagTKem*(k1oDfnZ%+vXd^ig5n_vuVBNJO}Gf*jK0e;I-X9&oUG87 zpqa~9F5fRKY!)h#xNz2Fcf0hGiz+L=CAc|Lnsqt$d)1v?GHRIi3E^YpNJH{c^`}#f zd~ijin5|l%l>oJVb%oRAD_cS~lozQwO!c5sUO$Ll*zq5RXK4vPI6&_k__`5V^q){z z8ZCWQ&Ui=e8^Y1uv1WX28;4$?<^%OYtaDtAoI97iEMVr3h3LjL%+}jKKka!k;;zdu zRwoZQQ~-*T7%r%8zyYJfu?JfI;mc<_<3RN4Q zS)wCsI%74EEwd*s&xP6F?{GzqJNOZk<|S<^s@PgpTnVd z0ty5M2mnALL=+EPBpw+1Z1u1E1&lwcHa;rG|Hec~Kzor8(-J%~LyALmIwq?4$EdAg zcEaFTlcs54hVQxopN>!I;Db9Dg8#EI)WIw4$^|HNfFN$fC4EasLf8k64lE9#svN5f zX5ro!Vht!3FWH8qXG-MRXR(7@I|@p>u-<71*XE}yZBSLx4pQpJLQr3_Xh7* z(Pz;`3n?J;pk_7lX&OetSlXUijMTG66d#!9S&u)(U>GP;Vc<2}lXM0SX}1Y#zBxpD zko1@dkht(aW1W+kVuA*wGPEyMUml12l@_ZBNZtreS_HCyX}B`(%ipMml=pO;J9L>L zor}#rdum7oxAveuL^?r+4|Ka9WjrTGK(i%EZ9K5yK{N_UFHwQVpcDslfnSJR*Z@T# z4~Xc6#HVEc^ z!V#N67ElW)a}TnolW57DDYh|;2h!&PM^lXH9d*ee@nRu7rd>2TAn=|K~R+^ zO04oipo)}Rp{-*%8;L_(#R|s4#R;WRsEmZvcot$? zco$G0@Ma$SfRmXcbe6!BQe=~$OMB@+X)EI-9~IrN12zB!Xs9k3sK8e3ZVyJlUCzI4 z4SV8lTD=h(W9dz#WM?+Me1~krN79f+ajoTEu zMiBiNnL@Cwwar)}4;Cg{sO^eabN4E9PHPhIGdSn73xKnVg%!;n1gc`4B8K@c1QCz- z4tL@?J)DUwdrclqqodD2C5Cn0kDrI4t`U2D5yrE3-UUt` zAhSZ=&GLcw5vBIVkO>o-V197a{8K#!e(IzXXd4SgppZNg1SC>8&BUlrAQ%gI!*MF| zI5O#U;d8&Wqjt=xpU{Y*IycTGW*>5(CEuO;^mh|?3;ds>Dc<`(%M*L z&vQilSriF>IiS!^^9SIlZ_UXEAzoe1Ecbk6D=St5bAU{dJQm_ zed3{U?Kt4?^uc^sAVFE((tLotgoM~=%IEGz~3GA zS^`O9xEwK^!~aCv;2vXcZGDxhY%@wOt({lhL2p{P&R05eCAhnxGa{ zut^9Rl0&NM?zORp#>_0BprY z8ouiHS6Uh4N$5C0$aPGTPtaDu_y<(oKH8{DjRI8uwUtFoTn%mK0BsQZQ;#@01j3Ss zK>tCk_%xvI=!wlTqI}z4s7i<9+VFW4jLe6kYz%s|X@6UI5YANNjp|)OPMiAodJ+uU z0m)FP4HvM~4~5V~P!1CSsv2;F3rF39N}H*gMH{ish7U`-D#hc1ap$w0gXj|uV35wD zrK%5E6-^%07auiB=jjRPH*5a)o72Y{gAPj&n3ZNeQcjn0)4-@cbLFF0^Y7dJ(@cXsU z0k*BiQqyrN8_4SHFk$%f-T(CMRCJL1~ADI z!h*F3wk&5D?>~z5!^bO#dIJidL!(8L~RXTobtD$WV6|yLG*G3;>3pn_*=qSBk)DKrBH2I|Jp`fKTo#Bf!*39{h`!D_tv?3J|4 zLjROpYIn^1k&!M$r9s$%LEvB6owEs^gaNhs+@qOv(->;;?`Z?N6*rQ{mhIIVno-ey zmYIgr91e|)+iF?3WH@JF%vQhjZx6cnF8mvF%Q0oWV$U;XA`7#lyi>%`4cZc<%uc51T;{wHH zSH$TkKH>*Ib~K;8vb6W_0`Zz3gV8ueg9;o1O)WWG0hMbGPk9iTIuKEcjnJg^S~3XE zb7>DYEZvB^hnZa5D7P7%75fkiZNzM$S|Go6LEGq8pJk@aI z_CTOXKBOKnh4o`$Bm&^JU=f4UOjkZ3f!eAeLy{csC8izD_@k}@qWJLjE>!NrCCFWD zr1mmNv;Qepqpc9N$!S0%ZRr<3rFw+4E-|TAAvJ&@2q=%lQlqMo!OpcOWv4;Z^5_bY z;04XjEZKu@#JS$|g@u~lWdeLUhU_unvl(?DPFJKqh2!Jc$JFKIe6JL5v)R!d7~8qy z!O?ZeJjj=9~!Kl+Ke8dTSQW&wcLJ!~lXVGB&JGFKDyJO~-R zx`0`C>RU*`W{`~5q)iqeD%u#7ak$u`zG>)X&oR1rC5`dkc9JYq>k7xQXe}MYgKEVj zqy`v5|jKI(x!m2>xj?rRuL+SU96XzY7Gdah(&5!g7bm>VGP&<#d0S24; zy|rL93UQ0fpnd(}H(2T`6tlIUdpkU~?PqqiYDPwj(D6W`rgJF9j)k^+;M3+Z#r%4f z@=?!-GYmv2!tC8!%# z$cd$AluKWWa3p-IYh@zPp$Ntx{XrHe;E1^8IKYE_3CFoWs|i`26+{lHXZ%-wXTI^k z_TzqBGQkZ(nvgu&BoGd52g7?t60K4Vp9;k6hc;E;wUFmb;p-oyrC;ZRsU`>iB3d*x z{4Q9hVIoJxfzRF2eON?K%kjU8Lj@l)tcO04Mo#;UH|907sqygA9zw8GJ)8c8nYUWhXwFJ7i02CORC*lLaM)HB*_IA%V=2iF{an zEP`mp0wI3oz}*oBM26u(%o0jb2bR(Ywf;XEiy7UUQ*Dlkx5&*^BQaQlR2JtrwB3+F z@S&;XySnSBngt;X3z-KX(P6=7rL^xM^AJrFh4QfuP7)S9L3PyuCQJg}6s86}+46J{ zhSS3C)Q_?*A?f5xA|JB`t@!sNA8ZgCt`@4Cn zl`bk%Hk?vkHKY^^uv#rlFzSjrCoc$;C!$`pnD<6qb&E6b^7WwzSktd@iHVvU`(5k+ z&T|=47AzP)YTRfVUah1p*K13nb?F$c&zGSr=fnzV@?gy4PSy^6o>pk^&&glwM?JuN zZs9g!4%i~`nF&~XU{047Fa>sTRoy}g&MU`XWUej}&VsNc932kI_$Kt(6A;s#fNuVg zgJc>e(1E$FXy?G*Olj3{`W>XAH)WCyjq6Z>R9Q8Y#e>wrXz)tN#I?Vt58cglbpFtP zEN#aqL!53y{R$>iv{e5|YvBLW>uoDmK^RJ|Hs$IRT%yB~Sz*YOwq6J4YdxR^BztG}ThpxjaX|;x5_o=DlpM(r%Q7jg<41>e$ zuSPV7&Q6)O5@p*X>sPH+Y)5ViZ=kkLmqw6rV87zZ^d>gO)wF`?HO#23#$eNm?tY-^ zyK@l0^z3C1MiE%KJxu^Azy!ls3pLS{S5(Sw`xNQB@-1^KXViEipgW^(=7PWP+?(JG z!bu0r#4ai7CRRj#Ye>#gPG8KpD*k0t$PMy>14KANbSZS!FyW^{3gFJgY2xoM1fXq%<{xyMZOJDJd| zJFoZ87cML{G=)c-zcJmbbNVe>Qb6EtD}MSNCAd!cC|u&frVcJvukY^`?z_me{t+L? zU=cT&B(m;pY>3>;68WxDT_)%<{{esC+fZt+hYDw%APkT}!srcH(y<#zpV(XqrM6nM zIOa%j=OZnq;lX{Izdk3;MsDLq7%tZqXwq6H@K(M<{Ed0-7q>CRJ}&;1P#5y~7&1^9 zhtrJN9`2%TQQ z=;tf%LfDZ1rEY-Tj_MIO9XOMYH(&YilKy1Ss#n!?b~|oW=e&qe=Zr5?7iKZ2NYuu@ zreMV*P*fsm4_k<=T}eNR(3TW-GWJ94cQYDsC%?7?XTPgA{}ztyw$9{2sT&s#JAqk@ z(*M@42D=t> z^rYIbp7}c9p`ynUjp5Q>*Fy@vQ>brris7cN!s#e34T4cIT**TDWG!(P^>*hhCOG?N z*h$5b!tE7TtYJt9H2c6y63#gImOzd|UXoq%C<0w|SLrZn-22X&-jTB>q_1ts*|)qu z3#ZaC8WsPyt>Nn*am7u!d_9Y3)Vn7e5fFEQHx21roNTVBYvPeVv-*6}ckv(yYsoo! z@eFfY3lnYJ1fJfDcHaP#{FSr=3H~}2FzN0}=s+{I#_NV^bLG??jh4EGgIUvj`(y4w z=`k){HWeO4ehUuTGFDNrMD1#aBk~3tuE{l65)(F(^)+-?3rCGXYWU7-^O}oKmTuO3 zYOlY~nCMeT$AT3~NiSZA51}g^2E*~eNYE04{DYd2#{NAnOTi-bVCiFw-dD%CyLg{H z{Ll3tCi%iA9og@CbSI}jJ+B!Eg9fT20onIt$(?DLSxWVvvdfB=j{N0%$hKF5L|x*7 z*Nnf;cd;S7?C8hY;OX#Dt18T?bLDcFGwTG?8j#!`+^9mi6Wzh{M7{;-HZ-XD!{LLp z%^E=$bn@AiwEik>YBI+Mmo0kZAq%%QQk%COvWwmDHflE0`+4f$?S)aTX~@I11i~*W z*D7DDQhvE5N!RF!&L|%a*Z^F`YMVgv6i|JMXy2nWzB3(@mjvAzHhRb64ThePPIM5Q zXg18OVmI&f0H6Fb4beN$U?k$fX=1IpH(s^WY6*D;O*N7cY!`!`qjW{ zJghcv)U*DJhSi2aedtCeGn?3{W^fF#y2;Xek6&p5x_+$l=;a-ur~&zW7HwC-aXrkk zCAcw5{OC=0gB6R4ROO@}Q4c)S`|Qzy~t-59P z_gBzccS<#@X$-wZmn!PM>EXDj zU%DqtJ?_N4j;w!9DuaW)-{3NEn%5$Jx;c2u*xi|C(EUXn`_`$YBkp|goso-?B)My4 zOAUu|-`a03pqBU7c+FvUe^=|M!OT*kD_nem!Y{$@^a;K)h!ILq%$ zdvP=v4AzABYI-?=w4r(|N)M;mb5VTXp-cPpz2Cz2aP;}pSjWV=+{5G#bg z3lrQ@*VtTQuHJ()nwVhtWL3B)th28+XVH2B6yt^is#10fXo5bU{cC-N>Rv<7<)&3N z_Ns>T;m$vqgXV6Z_sGl5UylJD_)_#s`gMQss4NDsZ@>>EOPLFzs02s@zdK zL#PQ!FHqte*4fV_&E7J3$)L!J#QPMlS1I;gp~~3_)TaLWQguxwhyraip{slZ@45_5;0tVbjj-bJEqt`#ggE2nR-%-fq`uGX;?yBHkg_wk4;~{V4{s1?E zZd`?gp;}ym9LkD0G@0`z{zF)(IjdP1eS3NrV`digHdoJ9-H`Tss=ajMWZ@)SgPL~p zySO7Q_zJr71!c4X^SRKupx44_(eppOzTB{`oGA4 zIGHXOLl7G+8s~kwl6B`+0oS4TtdZ~7-9a3}DZ-LAbtIMR3mr6Go>rWsO z9+Vi??vMAXR0xaJHUFKUdS-lWzc3f4k5)ZmLl5$OErAic?cs|VY}BshTS}t-URnND zC^Oz6SyhHknK-Hsr4mZD=S#?{&heeq$E( zdff}+d9aN?V9|;MN9fu8B^N7rCNC2Mb%2T1WUh{{R45QNo+FblIOj}qK_$+KbaFv z{le;F>hrnT>Tz3dzrRJWCM;pbDVql3 ze1DI|=~a#k`fETdCr~LhNnF%lGj3LnuX4ZRosNeMxDRS>$(Y7{tuu>$F&tkt3A1pp zw#7xMd_5n&>!wWcSv^}O`R*$r!c%Iyb66STpU)g8FIif4Jb{i8L_ExYf19=_a!89% z)ORSSF7F@35)>AzE2@J@_)#r>A~#0p^*IC}&Z)m0ZiuS;aj1kqtteV1aHZC33_}G@ zc?EVji76?n6C3&T*No8;`zXz8-ECqW9d*V5mCmJIQn2WM)ZE3&`9#QcJ)f7mzhwm5aF$n+kMNIdI4^C7?p`yqAg?J=E zpR4P>JPguDT)v;qhK>U?x>G}dm{l6s6MM>=F+!vK(fu{I(o0~)L+1VRyrhAhTs(_W z=)QT#;Q8HGnsj|tM#XHjtHT~daUo741{@o0ocy%1|}Qq(-?&UrpowImVV zES@z4*1p4dcQiyBv{T1KAmp}`>oOhst(P)%GWm;*aeRqz%C={aA z6rt@!rL@nav``UV?L}&eHcZ5fG3R?t@89S5M~`~+M;-Tl-PiiOp3iyLj}Hshre84a zJucUk^~0_y@}Ex`{63Ab#Dya<`s_SZT)O(xWi@+!ZqV~8wMW1X+EZ=p93Sv8G=oWs zDX3xYgmtT*iT{4b3IVF7UNP8L894a5_E?TnJ=BKY2t&4>h-+1GS5I&jb^!|YoPeQc z{*R8RCB1xt%pWtzxF;evwt-c}v-C;CDISl7z%R}$X9gs(0(pH53z$jkbDzQrs}9nS z)jLG{j+i_cb#}0H;Cb)1fCj2Ne@KeWc zZD!`)k>pd{VJMNudDQMht`FnHjJVu$UD875gP_zXNgzQU9Xq%+xr9+{wydcw9Fkyu ziW6Zc2_Sn^kFdK}RNGSs{Z@TRXCX7(q|z=!3atAY^rNY0V zWZ(2K)gk^BTwt!uW}YSoGI_v)b(kgI)QGsZ)p>yKssfxq**aB=A{;~@R@`2S1RwbW z!=ta*n#i;aFT8s4bK6+U%@#L0>QhP>voi|yCT42HeQw3l z!F9zwGiO>5(sp^F>)=;N-BFWh?{DTli#PS3v7mD6$`vlw%9gCfR+-@6dYn*ImuC%g z*R>wVH3vR(GnlFTcWqSue1+$mzB_M#tk+&p@beifaEkAJI#NEFQl8Jb;M_Ad%SsS1 z3^V-z9X8MwpvN@04vmycP3YOo#M&CsuRERk2+`t64*YJnA>7u5%A!Mj!Jw`)bJk0i zZ!d8f3l^5crqzCqie1y1K@_d}IZglvc8|b59^MXNJr_ zkh$XLX5B;upI6K|&iG;#*`t9S;ZQ$v1)QP}YDj?yna_rzQ-6rpQ!9->pg<;@Mbjyhhtux};u+Re| zo)#`10@cojNn0(f3>ftvU9&7h6fUl_ASQRpKjt#{d_}S}>eX+@(%mhX(n#ou$m2lm z3K;$6z<0+nLBe->-lW4w!teeektd-IejRpFH}wsQ!Q!X6;V(K*`j}(E+U+DV!*B0y zIlV|(XS+sT-%g;@s-syAm|VX{edqy@h337--BURQgWt=4YZXQ+JJTSO>w_Kja?cu3 z#nyt?fxs2#LkOW16lCtFHKVm!t%ala&=`BR6={ZFJ^xe<7$g?UTjD#Wxv+@yIZG_? z$xBj6NaG0bqbED~b!y~74l*@D$)(f|*}zvORX(m8z51fW_thB*GHpvzws944d~NT zh5^M2m&sZK&Xa!*==xv{BaqPZ70$5|WYV++2DJHHE98SXtQ9)dHo3aZ9|sk<-jyTn`?JFr&-UE9Y13_r{%Q0ECtGwDm#_#XS1Rp|?v zhw<7CF3h~Xchqh>7^4tt0!{FClU5sHRp-w8zRY-_e)x!sBbnGONT{llO#nYwT!nta z+1d!l_=tZ*QFAK#TXO7`TLbcqhM0`5J zmUD1|*LwPlS>&@Z7Ob8Hs7HQ|87@a$PcavN>&e+;j0bZ8N-O%~awUM7HxwjVm?!oi z^H}ql#aliM{o=Q8I-W#tgKk%z6PZGz(@tWm0Ga5a?uDL-HKMMer+c=B&MFL7?4)Z% zosB~1C%uvZ1dc#Y&9w<0DhowoZ*p^{4hkl6b5c;`ll3^@e<$1W;>vl%Y%5wP0b{@T zrG5Hl-_B%deu47nJ|Htvj$MGldIOouhM#U93Bk#e=9Z{rl@BTgdq6h8=2*7jD%vbJ z63X`vK#o|S71u2qu5+PhUJY>jD4s|~PaGJ3xDILUjj;od%W-bS?gIHAS74wna{vjZ zJv47Vx8V{p(ML}f^}T%dr&nG6JdOT2>n(k=cP;Bhy9Nrn`RNS|Ae%cxPme1vA1P(_ zK`V2ivV-^Q6SW=_HUxN}cRLY;_4xxvG;G(TBMbCpL=|uc^heU39a{O!&7SO8%;~6s zkB6A@Q&xwV#h5@QNm@9LQu4!*i3O`cFMGv0jL<``a1d}h_!=Lr zjksRqoK0G=pRv`m#?oQ;B3V;9v)rpg{p2CW76?y$YpD-UU0&=)m7GYViCd#GmwqdZ zPNPQwm-nRw58=sJuIejf~#+?7_X(FPKK0HakAA%l)x_E z)*iMT_O}UfSl)HDRvT5(qyHN3f%^t)Gx=fQ5Z=6!G?XM8f^Io?#`fkgj#t#S zO&B)6L${WB`6GK4M3?->cm4UCD~gxi8itJH>I2fg4T7gd@NTZ70u#=Z&y!qV2aEDI9jjmYH zDB%jPk9#kT9)_>ly5MWRsJf@{KaauaeRG)S(!i#nU*(h;mi;E&=nY=H6nt0iRh9mW z!Bupi+ILprT!l;UEMSMo$0QJEb|<&BWEt`=D&~4iIwk)YV^Mdx6-j%5uYhPqoGy5# zT+W+|-mOOhonKuVpB-ENE)yfwu*5$6#jn$!E!f19H7sk13;gfn0vOYi9Z{DQTC_B4 z;~@VKB4K%mQ_#!urr$sYQmIxDy#}gUm@V%5y=X&6QY|yLr>$j%2o+zW7IwS+c9;eaLtrI*=1K0~U_jdlN9{!}KNl~d>FM-dDRs?q3^BUaTA-8V zfnO~jY|d~dv`d(OTYIt&iN4i{&d!!?MB;ga8|(PbdPNo}>=Q6nnxHKPYGY1j*xTD&y#^kJ_Rzyt ztFH7cRFWhw=;K#7mN(KDikzE(no%ZgBea~4AX z{DTbI2GeF_VMHdLtAY+&VvUg?)C=2Fx1`K}uJ77Jvorm=J8g{_#Wb((PGEjwM%`BE zp66n&RGg`|IFo~4f)~o-(j>hXS+m`}cn1%K{iq>dYf({~jb2hObai2$K){XA+Q7G3 z!6hG};n=U9!r{F=meDd}TG9!0*)XD2&c(AVQP)y*w+Qsw++(pl9D5cwXyGy&Xj%*N zrj;pNSanRJYqK;e%ZPzb0VB-|RysbB>Yhrb*(_``!dHzzL6AUsK6E8MyF#SrFM-9h z$Mv6F;_uFjpzhBz$rY_2&R>7sv3y{dF6PFiZ3H(FzTtd_$h^L%@Gd6oZG-IdmQ)uL zn(fR2Xw;)fG>?F85eJ$jWs$V@XM1!_jkXp_pYCiwT|b(sizg+NMvyYMt;N?q{(?kg z_RNYuwovY7h`mPGoS8&32I_u1;TzKj_QdB2tdn8mPP1p;MlZuQ%&% zBz>L~al>;*{Eqk)<%37#WqdbMoalx(wE14nK?xpQGF7~Vjkh-OixfwX)U`lwB^4nj z=2|Ip5r!PWOSBrG_4D*S-ab+Dk>6S@0`wFrSeTfLh1BSdV*F`olrH$Ti;9;X645Xx zCQ6{9I{KaVVf~jOxAmvPT0EvR1pfwP24jGWz*+Onj*zwA9XG8#-SQ_iwy92Z*OAG5 zAngpUWrDR)IwwL)ifkuqaIaA#eDPpy(0&e3?|BtvG-+_7#Xwz{VQrK^ZtO(qqR0$L zM$fELFbz+ZqKi(X362QAbr@`#p-g<0JKE>pkzj!8vkI^cf~WKg=N`};!DDN6_g z`ss!9m5%$QQJIIByNkPbPW%!5rOBOGqiZ9)?&nJ;CDD_(n3eV>i4{5fZBW~&!7Uz} zcL*e~XSXQ1jsb=KY_a3+Ne;-FX0I+f{o(HPr1z5`7%+h z14W>XX)e~BS9-G!CDSE1&fz|4;I>YmfY0_KarIjV?M@G|r&e`ueN+<&f-pkG&im5B z>wi8)$eOB?f0X_r<;W6hO|Q#B<=ga6 zYP8o-1g#6G@}C&dTMUA#^+4bvLWo0rzH3B>$(yAjckL?0#kLlIhxiq;@(s0=?R=$r zMO#)bMANj?8vdgL83D4w&gLYVjj+vT7~&Am*$D+>ffTVNixePYJJb@ zwMnJb%6q!oeZ_4YJ?>%UWJLxwZ%z|L$?J$4Iha((_iW;8(un|x@tv8_Y9#z1LkNKOl|Dab)IJv>7`yTaR7m>7ndeCb6){co$Krrq{5HCmhqMjuK`c z2dP=Yx0Ag_ZG}cXMs$^$00*c41xl@UBNS)A^+H$G%65G(jgUp1N`utd zzDATEqPQ=O>IF$f5OOP$w5W^C;Hjk6F>#fO=TCOYL5Hi-gcGv?j?Y@0;mi@31kE}) zOEi{qt^X)qla)su3Re8u&M4&d$7P-}{<9YR{M1BAbgEv3elk7%I444UQH9V3&bv0O$cZv6uljsD=v>)0NwBt8wM~ z-&)a&`)m`Wo%!)xxLvH4$^$xcg%rvdr}*dKN?X@PuX|koq$U3G<14{1ZjJ#`+0Q&) zGByNn7{SI5x|Y?I=#Sd9N|s?nX@x5)qnf$xM%d zWOx2x^rkt?DM=L9h3b=C^ZP+mHlTVBDOvA zPk^jEu9iAHU%7Fc%+e>F??(X8%@?(n$X@xcQJrBBO{S`0)|xkpYRH8fhbiLxMKc|C zH#E0=Iz+8*S^G>O*XJPU4*2R-q^`Kdt?*-szwM6bxUAQ@P@(Tc)pS5kXW09n=wGR2 zZ;$9C%C=s}Zr)%(dXteDHgzJw#VSl*Xl%+=lgGdMj8CK zw)xDHD}^M;rlUmq%EOD&G(c?8DX;Y6M3=7(%bjKcS<5+Qh&k%c6`lAGUh#6|T3tsC z*F_(ei;p~q1o0yz#yhl-553i(OEW~dudN06+rW)vM#(JqAEL59T&z0Q*caQU*HB^w ztA8VPvE7^=n>3_Jh-WVPxO1iaq%oDhNdDxE$as!1i=NP1KT$II->qL>7W&i{((p~* zdcNX%k`n<9*rC|D;&;9Yz^HY6YKwZg^0B04X#5v`(Jgto8sC@uWm_-^Y?k)m(}PYe=A z>v^qKx8YfA;M?@2RC|kmT(LBCp;9{dHaF$!0Za!>W%4Mz1h926wpyz51DHNApA7*( z6A8L#w&QU&w+t5UVNwL|qx#IQfpuVd;Asqll#bBC8JW*uD#9fyJ6vgOFJZP^Q)Ic- zGOuJ9x^>K>b2jtSSdRlw3+D+ZcBWwo+T*73*ONleS-#Yf;!XEb>K%8l>h6PhGKz`s zzUj!wdKV6IQH4?R5L$NS;jQ**njDaXOrrl&&jv0)UzMQ4e{V%T_Dnz}pw*PVi=FK&-SRPDe3 zF09^MoDM5@8S*-6Z?Mt=eduZImuWrg85d<{EqqFn9F`f!{W13u$wL7XCa#ngg)64U zb;I1#fU!IN<-_D-*F9xRQN+%n4#2!OiQ6@HF)_O97wi1NQ|g#KAw0*NFZqvp(DjXP zj?;Iffz=X`ju!f2ME(vI`{@CV!RVn?=rxp&AZIS@Ns9a5xyBdQG3M8${7qI1lxY_M z%_WFXPOVgA9jI%mo5??I`N8M|o8}>sB#4c`Js}Y0mtC?m60PabGN7(TDU|b=3CFMZ zmdam`^AW%KH@-UuniJxsMV{hO!+vw(#tx)peRHB7^b`W(;^=6mcs8>G%YJG%(i?SU z;@`B9yEu|||7EBW=-2yJVQYur^R?oAc&*EyJ$9etNwPRcc2qkJ8lCP+ zNvGBo+-;^x$5tubak})_^(zY(okH1f@h6Vv!1GY?&2E9Rn};sR`|6^0_r_jQG#!u{ ztC5HKgLY+(6Go`kllCX07eG&_qJ<^$wP zNEcUwb#ixWS`)e9E2b&)Vmj2pNooSI_aIOis$!wY#ED6Y=stG{Dt8$ALKgDj%**2+ zI$j=c)Tx>QE1$^96=5LQ$?<6%YjFLNoiXKdY)jz9{;zv#3VUA+wru$FqOhYhO1V1F zMiS^npKziJeI=QAxaG)XZTcElJb%zXk4g$v);57Ul+JmjdX{n?y5Hq;^+NRw8VYG0 zZ0#gGeF_0^JD6?lqgT-MKOnJ){uf+k)G=%MKIqmszpQZOLlX&=xKMdd(dh6#PRktg z9ve$^tzvi<7GMAii`x>&fNw8ei74Ec~}yM zmrtbyDW&JA+mfhIM=Iq%QD1vh$?OM@MFZ*afb!5g)zTNv(E@`QMGuAH#)G51)5xxy_JJ&#O@v7`XJ?l(b?I}la zLHY2cqRDo?>^r|Nu&pNx7t8`}Spo)gYOcEsvR~{l;7%t~Q|Mu-^dxg^o&i?erdv&j z-7YG;YoL_M_#x%n2P=Imr2J7q%7+J}{JZu{=*JFG1`_W4GtR|=m}Eu(=MFC=VfKiP z!%ki7e~YKItjL`@Owg_S$w)P(dlgAfd>RQkWVq$@^eA;uHj2o`#R1o)b3OX%O{Vmf z9-^KH$?W3fx}Y$mL(f_QduRCno^Xs08H}(n1o~ebUoy@B2;o-9T=d75UQoDX*D>4> zg%1+cWaUG~zfYQl=n$m=+BUK8NGSEqG+KW766#p6 z(k{vl!IJQ9w5k61?+{9ETDqwzYd43`OF#|+$buVQV?W&`?u6VAGD} z272g-UUoE3$lF67w8y`UAu2LKHO3w23#qSmP`Zsg#4IRNwCn}hSeN8`j?BG?Zp*vr zmM)C#c}3QkT81c61X@Wfadca8vs`WB_f6FH7Y-jb{f={NUH&|4H1X#5{bkqop6r>N z*x^+&7`U^hC6c-rrSN-k!q>~2R*azy@bIJx%(Z-z0S|1DhmWeF=G{`PNA8c#0T4kN zShV+|B4^5A>=4vbg`Dw_vmCS*x}re_Bzc&o(@hEY&K7@XKbE7SSVr+l$?Cx3ioWu;)xRFvH&hFBm`| z<5Lof8>CD#tYb{T*_f(MvIyWq9==HYnuBhX7n&9;e}o2ff3bXS#jW-7ILm9_Gd=cW zea=|>!On@jxBDi&?|mPzblenz! z$eOPl_G}RTxcyh**LabY9Vu&;DELzxebQGpV2ItFDUFHdaM=N^uc4Q-{3?`Ruk2ywq84K zY@qhdF5AXDzt|6l@BE29erm3~Z{qvj{DB(LWCH18t6i)U5b_1kwZcjK?rfyO(+3mJ z5KqxpB^9K*qpn8={2Ke{I(0Rv_`;T7e0yd`b#2tWZ~TrXrUV!J{L97YF$BuOy*Tz> zdNfOzrrPyaIVUr0A@HoM~_Fd*8X~Wna&4x1ri-wI@)9pU|LBG>?Hi z{XgDFZWo7(4TI!o3it67<7p+g6=T_T{GZD!zbem+DNs%@R$(LTi5Mh!ul}l^!~5H& zKKR$AhoM{MPK@_+Y3D}?^?4U0kxL-?6%RLnJtOL1xY8YwC6u2m4QrKL$VqBmw5`$M z!-kx(z1Qm}zy4)yt2L~s{6}f|?FSAYx&s4tw*-`4zEj`vZ|8I`OgtYv>E}#rhucGR z?q+tIE=w>(n|_!A4GRvySI=kT_Egt?#^|)->9GNTrN>-Vgof61xBHl*t~>KxCm&S= zT}6wDX5UoWy>}Xo<7e-c@r2hm239JfU_!^WkuWbR6uQHm8U20I=s8Suwv_KUesJp| z{+ts^FkVxje^@)gPO+dXbZ3V~>9+6t7nMw7Cf?%-izhs3zEZe8eXHVf{+m1M$DhgL zM+{^GIMWxtnS-mo8PMqeEsS`C3dUYj>$2HyP?4CLN&h)9T+wFiz=Uh7*22r`_=dU6 z?8k=-%<=aHc06B#YvyJ1Kelq@B-!xSO7E zPXlY!(L{^v5OQQf{bYqJa+!I>3LE61kCSN}8g{Ub?LoqgAFnlCZ!aCpJi0XgP}ann zx9j3GJ=gV(?M*lGNV32YyE=lWm(KsJt0uAL)ssgehNAI0EBitU}ULi0n^s>sD_ ziuKUH@EW zD7X-!mKpJX9%LSlo`HnyFhu7dWM+wkyY(EHX-F}8;!nd7=X}%VZQOW&WqHxWeV@4p zll&G<-vD}yF!cWEiK~-ys-J4WZob%;OxHlaIey-S+Sw#I!w}ont&hbegeqfwz3mx`i;DU* zeH>j-wD9to_2(6V0+mxHsK<&$J?c8wr*dq4e5BvcuLB1Jta zg>1px8U^T~xS@#tlvH^ad7Q;i`B|1=Z6VD2!carrRCdH}c1ApU((w*IFSN31U2H|P zgRNduEPpl#z^8y!vaGi~owl8uuT4Q$_uHXcS_wGE?~FCCp8Q*Isu5vdZ5G=I4y2=X zGJULTe5qofM%Rh4iI7?Tw?JtJwEZj~I1;7iGA@BI)q-V3fVG%uv0BYRYBdHUhvW2> z!NIndy7a#s6rSXR35>9;3>$$Y_LK3AC|>n6lk zpK{Pvt=4Hq01PqE!Y;G9L1xxGb5F!haKkV|6Wnbmr$Bi>pNkgId3=2|*k%V*^p;Cx zBGUj4iVZ?tlhi=Fy>MABvvLMR0#eoF3Ydu_2f%tkA2uQtCl!i9E%-dYN2r~{YPIDd zIn?IT(TD8O4mNVNgC6MiTrRXB6MV5>pW^q9W;9PKeB*xAJ5tO=(LCTze21@@1;d9j z4Qt``-Fr}o%#vpS9gvoTj*QJv38n1_ceJod5$LLlO&h_CY(c0VWH|;w8PrY#d3dUk zI}yDUPR#rPLT@~vx@pJ7>qep*BhdM2&|TF<90e-_p&(a`4DvFS~fX3KN zFl$JUv@ZaXOBlfL&@mor^+3oUzM@;-i)N$qgRqt6fhG+@uB79n3JO`3a?Nr`k|{LBhP%Z43| z5~f6TGkS0{<@%VbJzT}2BGh^ep=ZgcVY`k46SYM3(q>vA?OLXK41yj$ii@y7tJft; z^VLZ^oX-DM-hNVkKZ)LcMjGo|%U@cdXljtU1lLA=e8;y9 zQ7+xyokh@JzNm?l2O@;lc*rTvqt!m7!%WV$;fp}9{8+Rq(e}k8fru#keFoa>a zB05M}ete|rw^-QpY$Y)C5JM5^x@fg}wgUGg_n`=#MC;b=Z2W}bD|||dl_1{(504Mv zWCDI?;T(md+M_1uq*g*Y$In&+TmBF6M9W6} zp6zb2%OOlT^_D_+Xf5cTO(^=9r*hPg+%1Zq32x5-9fM06jiG2S#K{9w2FewWnMkYw zkQr$yF4zm>Uoe^n2*e1lB>8nWVGZH1u5Tg!=I;k->8cy*T)>mC#G1n_fCsDLrdrHx zMbXEi_@Mx9j!<^2cciw;>m^QTyf%6_+o(p*HD?M@Y{e;iWW}>dq|+7?sy$JJ23J*k5^Sm08iJNm4bJsw8VpvtjL&yo3kOsbmQfYE|)`TrgfFmOgYAP*ZT z&431^1pZbIxw`nGD{|&ZfO_WMVzez76FaU#5$`|ci53BGwK`-j>PY59XV4QLJJD-o z(KF^LCCT}eb1YV|7^9;&?;1Gmv#bTvfajBoKii19M#2y0in?}yKLaoKP?fK21pC;p z3X7M)%}$QU7Ddim0ta-h5T)K(adawoiU;e50w$KoL&oR|=9!zmk~fsQsd zbq*&&+e#qMiGoiiP^`F?(r{#!!W160{ymK*=5Uag%AYu3D({A%5tc30bwlEVGmsg) z<8z)*SVAobjv7*Tm9tN+LK}e(k7|lKuNZq(@meA}8P43hX8x}>?R0p*;nt@`sW6;b zp~DlJsN-LNF`dUtN4NTCN&?ly-}gzG)0WoAr>+n@r$Qyyqeb_Q@{y&wS-MsL${O-9?bT)`6tbysQBl$R@-Y(_D; zzohk&35)7sFI0b`rYlY7t|ux%vTF5o@h$>8%)yNyZJwg=vP7{PP#4=A)B;_sLa88? zBbfo!q*BH52!5`~09gAh3HlRUjSc$yaF?Oc=TPfEGro$*f z6T?F|;BCb>ja#kh`O4UeI>!E_A|FX8#W+p$Yz$OnI6anyHeEv=;A!;<)KeFwjy{}Z z32k*~79>-@`WokfPlHh0t?x_$(Mu$K;_g!9Qn${cw{e&3;b2=@9>t!o+#IP~H+~h& zLST#zg_bp^Yy&h_(bD1Bu_iM}Ukuww5Knx$*?V~cPC_I9{R|`?Zia4IVCbSE3kGUL zxXRm~My*eH%~C%6o5cTxlkGK9ZI^w03CuquVOAG~jpNin{&3o4YG**>jH4LI zjV7J<_2QH?E0T|PoJA2c^qrZGdPb*AF?N=uo~okWM93~%_WIAvr!>Np!#DTFV!}`M zu%j3vAcA;7#iX!#g*ERf!1VAd6UXD(Ysu6KojARQ{@1^?RmTIt5U$G-qnF4E@2cK; zDE>E58BWl)I0EXfxMW1%10#+=9*@+;-!KS4SnKjJ=)7<-OL+5Cp9P7K6*0PK`?2hY zxAH*Zy1A{BTM}PgYZ;;(cUU@sIVS3swusMBEL8Bip*ss7g^-CC8K28(AH}2Z=se

peFr0AEB;m8|Az^zKf@tuw=T*hfbjTCH(*&WocPpX7hmM#QiAGzR9s*-Y+k;{b zpo$$xLmT}JlGU+<-Z&Wa7_@0z3oO|+6k!6H24tQn=FYB+`^|xqJM&$bo;|gE!%*44 zIY#Hh>PyIejT_n~iukZqtsOvZ9F-^5oTNw6(E9t5%*|d2ec(!I=Rwucyo5gIG1Y7P zYextj=c0g66bDjcl?qSPp3mnXe|U0`_3asnuAR%QziIe8EETzXD{bc;)rmpKGqFAcUG4UaH z(6lqm{>I~e+!z5jCRL>iJVm+y1`|WUA3~E8f`oK51P6A9J=Qux4cQSWZXcp;*Q>^) zPL?B4YlEvj_#zVP($q(5%|V9wvH#ZTY=9HCPiD6}>FTRNrt+(qzd*KiuOqdg`>oX2 znd*E;J;0H2RO=jqz8g#~;FPVNL1rY@2ZJ*@HWac5O~@i1VsREYT_i;jcabYc2dTHIi)+`7a9PZ0P^bU>Hb(4r z#O@-jJl^=Z`pFso(i@7~ZLzNDEem9&OPrbJ%A4}S_sdZBcEh+Db#h$}Mh?OLvElfw z*K07t@Evcs*`{k)|8D-4HO1GNvGtN!vT_*>NPqEK#$ALWnTI|sB$mzfK;@o4RNemxg2!i8mhRY?#T43 z#@N!(WC$cSNVw_k%L8X9kY!reT{n9WFmWL9FfU403Sm7o<>||%uW!BlxGrA{<5gX_ zl2+hRWSgmzD#nT#wHGxLXY#0=-ATwQ=Y>h|}C#B_CD`oj>WLmiRyL7F@&=o_Ljy7l2f4n ziak2J3iE*|%Me1@vQ7?hzH}Kc%!SxuUXv>75_>cpf$JgtX=A^_z7+ z*Hzt%_QARq8>5=cKFff>b-$u~CgG!waDzVagwLjv>03txC0l4zI&j)yK)t!~A0F{- zqXF?Gl*>&e2QNlLPx-IAjQbl`;CzaqywXF9ptTUU52tM*>}jxCt+h}ZLfgjSw9#k0 zwbs!OMy3^8xyMoa9wac;8IBgNL_dbj9{ku=I(zWKkkkFPwW1gMjHrG2gCW_{`iK80 zYvx2L*S-7o2L=7`ur$KOhu%%ST$6cubIGq_yNshsMM6iAtP0JDF!`)1Xz`mS`5Q>en}TeGfnJWEpORVT{cC*8Vq^6CfQ;RxS5Z=Gze zP4ndtvUON8}W(dr3__s0v1qla@7$o*G!3W|8p_pnmk zY21U3AS~_(PKOSKuuO>mT3ZREDg9`hx&sCL4h_;Mje}-r6Z{^JImCm-!|c{c>3q7s zcg|qPjoRkI&r+8&dCbUZeZHTzRH@S;FV{~V^tLYCb>{1uqiZ7&mn%-D-SAarMDtya z-`;hsWNyPr9*q)?;KY&SN_ija4h1RPAdJFc90bAdf46v2N|`&t+&$J>m3jw{SJfX> zx9f0J2bt4E+JFyA9|b7pJ6pUVND+jlsI$rZjoRSaBm1gMYFd^Vh`w#-L*aU( zmJ0Wl1-_L=V`9}SrEKUNP4xAs_sKiZnO~g7~ML6#((-wFXy^i?W0%yQ1!`Zc7o>W;WVhY zL(hYQS>uVLC)HTKn=(2cI&=4cs8oqY4AIc)KYwvlvOx-kZ>Qfkc$} z1sQ=-pcYH$!~!!?MH*cAR-Jslts4%6eo;D`S2EKZ}=N3Jt4(wWjn zG3t<1kKKU`Vh2hdv_60tGev#V&&&S(v+B9m!3nX~tl} zbifxh9myoLt;9*>#m6Mg=7c$L%TjD~6Si9ecC-1!D??`rtRe)(C35ILt0QF0P)GO+ zC_p`CVd4WpYzg(TqL70R0UR5DP*+c=f$HL`CFLyW(syA&ExYqWZ~ar!KY5HXAP5v7 zOESw}mcRc)4_t*yH$krN;@9o z<94|Q<)?_J=;5I+cYQcB^?C+ehN6RkUP`%nDQb;?a2}8dhOjkAajyU))wk(2I4RH6 zQ;8w+OEoPcji^bSEB$i<2gi}!c1tCJj`8J+pEaB0&o`}j@O@2(FZ!8-hKkyA)Gz|T z5=)3;pDJv5`*@+ZC&?c-jM!lS`q&L4iVbVXk7*N9>KJW-9EvZvjsKkk=oZH=RrwDq zu!tc=h&}|+#J*mC%Q*&Utv70SMXiAYQ$N)dz6eohhEj(^6dF#9Mu04E_8b3>8HI>5 zRiomu$SC4v4(3k8S-G-k@o05?`({pY87Js9`;jN_X^PlP9siwtLHzaDo18$n4(o&C zjnPAU6cK`xt3RV{YAj&?%75Nf+cE`B;-GIgskYg>l{x#yiB7ZydZ+UroZ+hh$OOO= z(n2~{bi3Q7f1LR6P6O1sh*aeQ%9`T=A!d8AVqoP-v~3{H1Q};bjd2=uoM(`t{`{mzEa6%%s)-cU z9R722Ut^jfbtFPjbW|RCnx}DC9*x!TLn|faKmF+ug~%k{PHd?w(ie1%5LfEhd2x88 zATgZ=DSOSghzl5E`>Z?rH z;!A8aT9jxY8jTjcKuDT{)^!Xt#b*V}M}aNm$D7qe9k5p>zn2yQ_v>3H#glCdrz(cf zd}W62nO$sStV+!o`{@FN`?Zan0>7T|P>LJq;>+jH_7p!Pk7y=Lv}-!{iR5V~o~hsU z%;DX(rIzR+WOXyx;_v=|Nv>s_&O_?tvCIP1l_#Hq)&J@6ECgDnD|Rg?9hh`>eke9! zKtmvm`I(`*TxryhYG;by%@ST6uqciDzBRl0;a}%$biV)I>3T=yq1SD8xm0EU=D@)n z;Y$6hilzId!;wuNQnqI?OJ3}@;qA~BE4{R=g=K~qOZy4pxt`lOyrGpRH1(ogiBVk6 zCJ$~q&f`8Hk8m(;VuALP$#19q-fF=)TR+9ZnX*Dajw-Cd#cxeONXNru$b6+ey2T72 zBMbCrI1>Nz1PFc#zgHG`z+dDPDL3EnakXQ7>kZ2Yg~JrMpE+!h7)>kgx%s3q zaKhxz)n5&%sn6QVS?TuP;+^PZEW1+ls%?FXQMGZA=^1Z(OjwwHa(0k4N*u3F({%?XUyzxTQ- zr9{lSzJMt|0epYzBK!(C7A9xl|vwY76iP0CzdAzkILZzP&< zs?{-vX`C3VR^cZ*s;;=M9wq`>7u!S?V1uJ`>oFa`h znui)|R06?TORm%Ig-v%BbeFb`ihmj^3V|iHmFwNfa z`?#ji*PmZ@Lpi`i zlDCJ$o)o9;Ly~tro7^=k+U>V@iq4(m#=O>_CDNPQaWA8Ap?se--x=J2h`Y4gKUXw( zxZLGluc+-MqiN1s)0>6;h!xwb?4KS%tiZUB9_l2NAYy*N*~UNBY2b5lzg$=P^lL-! z1ZNYU#{UcsoHBOWb)YeJrQ=}OX-}8kXWGh5?c=w|0zY@RWt~x!u91Duqz3rF0@(F< zozuV9&ZPojiC8%@Myc~uBGKdF38o&z$S|uQS1e(q83^r#@U$y!$Ivra9JYP-Y{yh> zW#1&tgj`UX-3*4Z%s4(f*jUtdgIbbX)8fK7{C)ZPCppXqtYteZeR>QNuuAZ$HN z{+Zx47H=?SuWVuak%IAN)`T>O1B+S$f$;uaWQ;G?_6Z7FKHZ3AX5zlYCt<^+RVyoP zaVLe*`L5fIv5j@p!sJKG`sd6U3{`5y|ES;cbn2LGvcUhIfFhTRTJ4`G&1KZs**w7v zE~d#XIFKavS;LLtkU|FpoJk=Iw0lBB)iA65nI4*zAB9CHKR@>~h`+7lvg>hU;B)H* z@{#FLYv1z|qLV{KPSoY^%=yRp3@I8eR<0NUgF#P%<0UZCOgIAd1Ho(f?9EHWG&yJE zUzu=D>9MQ~V$wrSUbc?>b1KCpT<#G5ad_dMPY%kxgb{N;?Cv4a<2tJ3!(jb@B8xBo zBr523X1!2s#b32(U*?Kk)Q=>K_YEUPLZ2Sf6N_o1zqmn+_-8}AiMU8l?Z#rktW}ElwS@})>?q}@+xlW<$^8WS zAc5B=LccHp;}r9PjA@cJ1Y52xurI6jQpaq2Gk3Jv_fHz(v^J%%I@Hj9FnvvjU1`5v zaysP$hrmQQGd0p5{C2n2;@jFLF8Hi5YgnUj;5iUj>xyYNLavvH)CohuStnOc$haNt z_9khM40hMe)M;E+IxM>5rP{2P1>J$$_Fn8?E3;#yEuT5g%=D}1*K+ae)SUP8w~YL; zT>Q#8URy6HeerOfX2K{At{=iJ^Cv+YbdHXR*hU=>`nJBbCQxT+Q%b$P@|C}3xI#Zg z2#fB8*_%a?ia)pb4?0?ID(3#VF~Rbyg#Y3w*7WBoUF0h+@S^u7;D|L{PuqRO7yxWc zHzdqPG`r83d-r9Sb?Beg_UTD~u$ zYBGPH45ku&al7}v_{O`IsSZaAE-k-ATk#fi3u@q>+q>(%mKi>1u-sKlCUDseChWV9 z*7G8}ZfJ_EBKqApFvJC;R=sln!cyFL8r4&=cf4Xcovqj7G z|8;il|4jC8e4DW~r;$@lZ6eWA4^k-AHiZf~RVsO`rqmOrCps;=x1n-sNK&5K;+rDn zm^owzeUY5$K*}6CP{SNIcKY7_h41ycfBRhbb>E-Y^||i*`W)Wx>rYSED2!1AhojL( zHyA_+gqcb5fWp@4d1M{f6BwqAf7w$qqYY>n^FtN~#zljgW=|tQw$%0Er;4l?kYmV0 zRu&vO!|kD&nbxl*Fu{Se-}1w0YyJXwCvjWcO9Eu4p5TYAKlXg`Q#|;nj8e7f#V&}k zwVKfs+k<_0`y>~`Xp2&JSNOY3^s{|#oQ*Zc(GCQJ5RTL^GRlgf)9)Gx1^a5qwx71` zEs&g-lzUbrrHFEu953ZyPFt7es*`e6h$&53^pCZGHl1fGP-)$ciOp)&M-%=lcwbHA z6hWX)VBTTlw$c;{DYUYB47{v;l=b(Ffi3lW4>o@!yjWg8)7k!+{dh%=3eljBu_7ezMrN(I@bjk=4x7U)bRhnI=2{8NfRMHDT7M2oQvy&z z$oNY8BPHbjoOpQX** ziqs+42Otj_;rtHwp^$XWK79=Qis21rwHGfH1Xvq`yOu>qz3=hHD)D(EB4*QHb6y>t zzq?%3D-aNnRCG(UXm`<~ zOPRe+T9@n?oT4H#7M!>FGa@ujz~QSl!(&LCA_LGvM#ce5Pn7=A?Y_9~z+Z}TcKUt; z)`4NnCZ$UkN z-+G%pOlE+q1-nqC{F>1^T@HcNdZ<6e9R4mDJr;fhp;h$5-VbLyP%Y$~Dg72}N;~{m zayUp))XTd*O);>cfi&mNQ9;*2rMX#l3jca^b!_dAFzXamB0-MXIXD2>2zN2d$rcFO zY#bm2BGDPe>DEkmz}u&!j%}oVunqMbZw7dLP2Eb_#Cx{*lVhh@u}1dW zQfR((rO&}Rtl&$r6B13d-^_r5rM$b7pn5u}{Jq4U{0ecbvFe#uXvAlcTQXsq($Zgd zG>I?!1c>;cnYyKLo<-MM&D3Tmuxx=KfncN&oM6FOsbRdw3@6}lwqwe^5#38JzF=p8 zlWMie5fHj}C6}%2chY*UcDORi-i7M(9-yDX7ol-vQi{FSyBYK?5&@!lRdC(83By+nJzY-vt8oh5)^)&J!P`e8_ zVa(&^ZC`&sm}&Qw2XiuPY19^qI7&%E{o%^GR<%P6p;xuw znTb$4N1F8+BR~7|xW#s}ukOCKafNxGPAnz&s61kI zrTsX+_c{z(!8{$3X0LNb_e_4avUpN}0<>Lrc!^B-KC-K7p9_<|ad^Q^N2xbr3ek6b z@1L4c6{~*IEeH_C7C3XP5rRcJ#N(!2?ENPt4Wc|F-NcgoIm<>0P?dU_=>@1}RZ-HK zHL}O;idD4pB;%|5)0J<^w_A`<8Yr6_e_tfB!t!>+V^3AxJIezt{ac;`AG4bf zdQ8+Fo76*p(447XO(eX=zAkXULF4b|Y^hq0-P5g) z0(1@l!RiaUgB2J@p|hgKSrzU)o(9~f=v5;7to#!NPIEW^Ibntt2-|M03@*GAkX$3) zX37)xV2$|7Ri)eC0r_!nLd{u-yeXtC`tRI!tq%6-a3dBXS|`;IZjNh<$jb7#Gwxt> z{lGZ&yy{D;(v{y0JW6~tFZ#xk+tynO){!nM8!t@J#^~Tgc(tE>q)U9}z<(M$HeJ&= zjrUoaJ;|BNn2nQPzCN1;e9Y#%)6+(d5D@TZW`28xG2Zw41&>u!Q}V{^@lU2+E>>Ln z%>7Pj4aLHGOrV$kAQ>+EXRUuz`4HKL8n}#sq6n z`(2RUBx0+Yb^(66q)@W`3IBBP;6ZtAz2f3R8{Pz7Ez0}0JNTS>D01cn{U)Q~mILlq z{CL`ztI8Uqgg-#aiE&M2bB%P39( za)t8hM!3B^-K9)+q%mKSQl&`aZy-sU1TD;p#=+uO!>0(AsDe`+bJn-IO7>ecHE^F$ z-q@(yDnAk9ofZ8*6{GG1iKd&1i~NjFcUouGmB1`C2;XzJPFRjG3D^yuv|CO$!H(q1q zq0znk@6}()7#9aA?6Z;zy(_7Q$Ps-WD0syK0*;Z`enk8^dC-KH_JfUhJgXWiXV;Ij z_rb%gwf_iQEU1XB{fsxvc?FC|HX-43?@0Pt<+hfA2Cl=xWzhs0_#PVjCF+7}Q?}ad zi#Br2iZF2oJ*@zaiyq}Iykc+O-`ZzVd$eD_LsHROC5fDSp>Q0&%ilGHedCPCW1;jO z8I}Tq$QWM~6#1oPC6TeOs!ul6EzNw|oIClnz7J1zS_)DrgH>-WX$$Q%ax#6$pD1lA z_^Q=sbuy3?Z{2y&Z|+h#H|gpxDP>X4lJ(DEeCo~TOooGMLuKbHXS(DwR;+gx_~ln<#iK_Ue72Q5v>Vj!cs46jGZKqBO-JP_Q!? z$8h;r<&;W1&mO&u{dtkHY?Ym{Mqh^u6NZ3{V&XH)ukCTR2b;|Us<00iCLV}rhX3qL zZ~0!&)y{NC$C|I~bJcJHCCgE{bqDzS7AaT#Ru#WGPm}Nec4Wvm-4bS`A#@<_xHm;X z6zm#4j$Zb}#|G)s>uN2&yy8OjAb3ycbR9l;qLP9q-+}^;rI7_cA|^zuBwTPQvP+lY z&RBMgYqRGD#mV71ymmcCF?e4U-2(MK5BuO5;!DMkEqthD!Hgd^G`| zW2F)gvc+H7A!qyR@B=pzL#OSgEdqJ+if-kqbD!i36}D9x_f_i~pvW`7l$IKRSNg#5 zyat?r!7hXvcJ)mus>;gzbyXJ|?WBdmPWI4GgR(JsZ@t{HUD^Lbm5MQ;o%fSnddFq^ z=ri$)f$FyqPsZgK`;d8+p{8^Nlbo=e)ZS~aw;#2p!&Xb(mc5gSwZXClGM>(opZE>YN`z?bG1pz2#+PWf^Uf z@FaOfBNbkvp0$=Oj;c15gAv%r`hPFqRJIy>1EKt}`O!3Z*dDB=-Fq^X3V}0YprXo_c~n5arRng4OIhG z0D#8oRm;}{fWogRpeezR!Toik0BmQiUhd$r|MTysT}75%(G3mO6DDokx$(qO56T|NraDmZ;Kx*1?ZYo5y}TU!UTh@4Wa>zVoH8UG0+BQFm^NbGFhkhjS;& z&M@w<6R-HRzs~ejpSI4;am$<7m*4KrA9?!enxHPEL+eXd)BR^_vv$q8e!l2&JN~X{ z|L$oAfdl<|&Kh-NjVz8|*}tZyK+s zo^a4PX*P4+j@^ztA?3yWfH`6zhpLpLFU)!Rb>fr_3#%+js7=z(ozdsD3=eZUpQ?0y z{(XA2_s%ktzaC%uklj4>FPx5Lo1d@?sN8j@RDb#1e>Sa;xQE2k7<8jK#{2J{+GO;4 zk+G_mB^4m27bS}+FK_PNnA9|JuJTs?{LzOM5$Z9~6wT@{lQ0MN^W}H9X{easJRgy_ zju)peK8~Huz_3t5Va}>lzIj*H(!rdx@R6YVZTd@5Rm>qJ_6S`+?+WMOOAO^Z7KTmk zE@Dp+th4#36(YI)OqTj}!&bg|^K=}idpMi%^jF1jQ*O#Jy`bZ)UkPoTcl7#>I@%m+RoC($sWq6K2P8E?xb+ks3c@8z@6+4Dj$@& z@r`Yw2Y;K;;s!-3sD5WbkkEmqH1*&-N{*_q+_pI|ZRFNX_{p|3|dz5X?lcecLKwh5W6 z!65@;^Vh-v(anc>7k7{bOQUIVyvz_Kvq=TF(Ra$BTEaq(^f@~)r_w#z?DJq8O=kfE z3UcmuZ|a?f`>4YqVw3&r-2~7)mdM&7y?>~6AUQOGRF7lO%#-KeM|JMc-s_A5UBu|c zR94Dp!yEq3-o?xqxJPX)(O#{>p3_EvtytvIQ(UfHi{AT@Acb&rDY5JoL_BpELG7%D zSqILfo<5o|b~QVu1*(FBC;Pe`-C)uTErvBQ{Zl9J()G@{DXqJo{fRVAG>&8BOl3rt zos@k?zEi)eM7~*+JmwuO=S<;NU;G)EGO8Q`S74|7$TukN+r%wABRIGWgvW-h9iI3# z|D6ode|r6cc%3&|4s?$V^uD19K2^$jpV9C+hIzi9$h^hNuIwgd>=NVg0sGGC?f8ri zFD+nH<90mrjpVo3z@9AZM~wfVViUH8y|@{j$hQ>cPlE5Si%93orOvXj%;bQ|~wNlbe!A94}J*6rnufe!#^df}@1^LawU!!mS1m9(m6&n`$ z%w`XsBP-hf9Oc&hGQQk?HJ(9>OaAUjdE*$Q>I z$z%^orYm>W^ZIAVce_v(o#40Jf;6^*fhraxjxJ6p8F7p?x5 zfgUivIGbEk!9RA6g*ni-JT)lXrjpcw-NuBkO47;Pw%#?8Yu^lBIA}UZO85H;=9NCyI8}I4$uo`xwNp*Jk~!5?(zDNw@jSlr)|KOsIoPCaWPZTWMdSy*a zX?D!`lpJk(Vbd(4RP(fPOrbmZeQtPmanpC@@pU~zj|;=@GX@q7EU_nl-im2G5?%T` z+Iz9|kGn$NIW}Pjnw0`2)tdGP)GkjezTa?g`I!xamw)PY|30Heh2-S&;zE>@I?fKC zRsOWMGh+8>;;`Ef`S-1VJXrn=g^sPTv;R|iddZp6i1lSZVlO_F4}XylzB%;BrV?5L zEti7*XY$T7gcn!RcyS~!|4)L%2~K?&M{o`Z2Vz|>`($0=XR`)7)<{3@X8F?<%U+5{ zmE~`Vph6qoPp8;`HkR#R!f}Owa#}4Y^POT-!l_s^`C%ITSq&&Q62B!lW`%FXp!*rA zqBx{_Z_g}c-ow++x3VriW6AsAs&t(UW%~oJe-A19q{(mfvicW{E$qosSTy~?xuVrI z-#?4rzI}j}){@_zZ+*%ob+|heN1}SXbk}_rPW463*7Bo%{8`kO@ZSAY>x-p7E2l3C zJ#9kEz4fi1*o18gK22vrY24G!CnCG}$7;DZRCjj19MXx^jAI0^>!y99Z3^2P_SzT8 zj(%mGGPm>=H(5S!-eJs&7FT;mpCP)@rrPI4oijDL_rvpF8eFdpq?zm{`+ct!X9Ox8 z1oo5k^BY2`d0y1yokw_8@vQXGqKMIX*i@)aG|D^y@xv4Oe&xB6gXv`H_GMS2Pc<0I zd4myGN?GF3U1L>qpq{% z=Qt-eOTW0azxq(Eu@cEqU@1T>D6!X~s{PbToH$#WjQ2`6T=?nCBp5k&UgRQwrl4xx z)V}-VSj>>|oUvaHOFpdNHeO%=oPN}kl7Cza?NfU8=^c7YHIi@dNrVpZeTNcQ`rc;4 zaL-+z(LUOpa=(hrYM#`_xAOi3$4}7Qj)oC`>)ufeCL@@BpEe)$3IPyU%7aix09LXD%X~G?ouQK)M9!T6Q>ekX3 z%*TUTO{{Svq9^@6Cph1ohV8VL9g(!hL)1dCE%}vc*rS6d%WU`KXJ+M%)w2FWb%SZ_ z?I3(Wv?GBGD;@03SzM+;gKOV6o78Y%HD~KfB1V!mB;)V(dpU1>21YB7(vRFPI$(1k zDmR=M=?grP`mEkf0WG{uj`)z%p+@shVmi~8xq#ki%&`t6jur_!v45@XvbMfI19ul3aZHPq4bn)CIze2ww7>M7U% z{C(*3;@w93N+MfMZU`KV7}byNVdQ6(EZD2{X7ey-;V!M}!youh&-{5qi-ekm4un$b zN~GMniaWjBMx#SYq+sLY-i)j#1z7I*(|Jiryi~CdNUY7Wt_2`1=#AVI}R~l*$C2~cn?USWntLl~>ENclb-S>5V zK)~a&g3+tok7b=vg9ZnG$EZ0#+kL(S4+;1+AJ6?rGRZx4+uWCI;QshtE0fm$H9V`A z|62BTUiD+va7$EAL#uqg`#3+R1Dp!7hieW!S zu$J!S4~0NByEYFlJgD(BwbiM&e&(NN5zDUwF{Y7JktV{owxVn}O1}sV#X!zS`SQ27n{f@jDC_a&)-Bj)GulR`+#OTq zZts5Fc6s^o|Ff_4B_}GnM9eK~St=hn-aOhLRozn2Io9^9eTe((&f*c};b~XTeCX)3 z8}vP>^)&50LL(rNpLe)#Z}9~^k3D<-Xc<$9mT_;(L>hu5cgc4fH?CqF$OyWs%2 zuT{2oxGlObx`)fr=lVl+v3#1)>e|7Js(3)j+2h`{c(Q%UFt@{EGaR+KTb|I?GGAW& zKYKZJoZF_%?rMD}t&s8$_v|GHQkuy>i+7SmMW?uGSj^a+VFghQM?fydn*tn?a&+00 z>ae4iHSo)_zvib>T0bw1`OX?0ejBc__E-PO?yVa}SvwX5ke6LXl3$-cOOAQBW`#<3 zeK@3$+9JJ6VrG!=%;>Pki<@&UCraD67EL4VSD}V>8SSxJbTg7!_+!89kongQlX^4A zqTU$>dVK*!5cubY33b0WMJNW42_I#vA1c-E9{_{%!@EE*;2N$OU+k%q7{Pp>@9DkTN)TDXDN295Q`X}TWuv7vGF@ltNznN;yQ(OReI z$B>u*P|&q~hph77R|lHNqCfY^NDIMj^zcyK;^AfU)#4bdZ0SCdg~STDS*RZ;LPjoW zln?7P@qY}k!aJR|kMhNrJ2F1XbFW6#lyqk9+396SxC|EX-}QeQXu1>&@DywoqDU9Cv2AKK~S>Q^t-l_F(Vh+z7x)tJXC~ZW+e^>eS z>+;A)vhF_lH1L5kfT}j@(#2B}y+WxmeoVSprTk0Q-MG+D4MOA!OOw z8@@v!lOKSs&4CTu5&PMXfV5x&`8eYtu#qozq| zcxdCJ)cAS+E3XAdf{DN0RJTO6T-#T<-e1XH{7fm%5Oesu03A6(c#j?-+}oDL=yZeq zEsGt7BK&@xB6_YDPN=z5wGz)Oa5Y4t%kT0h)FfoWzY`0dG|@U+zOBSNKAa-xjgZ>| zy2O&jjT4eeTcj#P$`>(t;g@o1F1WvnY4GfyaJj!L|2u0@1%%>YZ<7KVODeE>Z8? z*z5l*0qK9%Ga7`S9;>(BxL>~}?M-XRCoRf1S>-mCI?X{NjzK-E>~X*u|D6IZS`yW4 zx%;}o&enZvyLJioeaSl1bLDwEVSEt6;DIxgwa{Y9qHhk{bIM+{6SAnkw;b$?E?as~ zKiVkL{p&8l(-AoKeQDIRbQrbG#}AtleAB{nl8;eGFTIYAnC!4UGX5)H-50Ta;HF-*QpCexat%eZxnMydY`mq54eE~ z27_rJAU_qnx42pQj+{f|bzgEBfv6R<%x0hOC+C$=+mkxm{ue#D>0Eu(?9ff^Wq7Z4Zj!m9I@aslm0)(Ts~b<`{WsGBJOn9w!;0LByL| z@-^5?&rI)~spTD@+KC9lR5+dpuTZc3=jzzuOWEGF+~0$>vO3Abv>)gHM<|CvgCvF; zz3XOj$+y;BALExjxNRIP`9I>A`dPnpyW}EvfYEn4qj`UCOLKie|Ib=I+1S&lUKoZ& z+&5O2!uMB6GQ+ByrJ(2-i08^A<$}_{n9vWYfpC@(|mB zqQRxC{uShnGY2k8Z9hsLKRBy3c}aNJp~%PDvWsr~4$dVgTL4y{-M9TWwQunEn`>Wp z+*cM%_)}?d{pF;gL`9Y!r`maa_p+h`=E5myOa3wd7rsc^`)1+*DDpzUUIrM}v)t3# zsF9Pq(f!XpGeZJqe~nX2ym}kS{So#3%aq=`f_*K=-)_av{sj=ANHqa(58ypC6z-P@ zrnu#*0ObG)7ygVnY(KleLmPxH5d5Infldm5%fK|~7Q!n~2X{Mwhbp+R*}oeOWb1$n z?W!p8Mhp&v;yW{d&{46cPwq_Me-Q|b0IaEE2ymf-D1iGw5G^iO)Iq|}IxFhX`W|Y4 z31Ciu)d$gQC`7Chu>UVZDj>2B2$umxN{s>!6!hGt;A;O}PX$;m2F%+>hfX^y7dQYhp3dIAgc8)Lkf8R<`u+TFQr;mc}$A=_y#u z%XPUN0zeu7tKRoroL=p7a_dM1F5KU?SRPNuJoSJTWQNdjKc8oXGXrjllfI zmx9|#tyIB2UBFQSIqe?Yx{+B;2IQ&@8FBdF@DWc69m<~yDgh`iUigMvGv#&$ck0S4 zQ++cW$a(+@Un?G$fFAefBiZVQw&o-gc(wuY2tYZ)hj<$-^IF4AJv-+G$VL7;+S-Pc zFMls}4H=#Nl2FG6XSMd~Hy9|?Qw{v31yWmAYx}B0vf2HfI36q zWlA22H5Gf$l_zTiPNwZRf@@AuflQqH@3w>o!r6oZJwV*Mz+FESaqiDMeg3Tv?%G>am0TFS zbsBU}1f>c56=JkO2sMD$BaH3hhIuj6^9?Dg^ck`Li%unZ-bMbFPgzA>7he#o?F1;s zu~HEF8YLzGUY@8L18SmzVCng|a%d=uQ!twj5&@{k2-~W_&g4iUU9}rFTskAOK8c=0LkU1L zp8-5x^}YbiIx0Z{cts_5Q<*f}ko)(tbQj?P;KJROPNO=kCRl3M&Iyh4?OCA zMt^4TYPHQ6|Ixe2ARPrQU)~h%v3V;utt8p6rQwtLkG}c=bUM9U6V;r-V5F;=wg0W* zutFD-$<91EcT!&l;4B_nFqgaGW8liaOV>-=-#||TK^zV+%Uynid;AA^HJx8RvUz5K z#ata4&U2*WmIxLBosEwYF&FT5H1Vryxh?7;>d+WsTyGR;QtmN?WFizO3X%=Mn|M$| z1v++P3#de0WgtiD-@YuU>*(}1+gC6dYUq3uPNwyLIM4!*p=$|n0Q$)*2b#zkmumTz z;xrrCM5p6H979Z%hD<+1FU(NW$LZG%4p}IJ4nRO%uJ3L;TQ2vk%JLgci zT(BHLjWlTbs(_#%a(@uF2wOdiqualDU}re!1Yq}Dxw9&y?N4`Lg*4bkK4T*5e6p?j z98;XJotC~;9nnM=WA!>Gu z`+NeKt~j}(VEMf_+?{5@^g@8#A8-V*Gts5vNJ*U#yfy3xx%zDre}y5JQy5l?i~iRB&;C`xY=K0Ng-ST{TMG$~B!mRp+P!{>VVkXwB=R zGw*33t&t3M7&Zauchko}WVeUoYirU11r5@hw}MH(Z0=++UwDz#JXk&Pfd<8&R-6Mc z`^k<#gz_F!U@Y=pnOF#T<{&bC5~y@MPd;wqUX_$3W(AKnoY*8FJnV2obVokjY#G)) z<(er#;7)*0e@AY1{XyRPGQi)`#b52hB=ZxqLV6Y$?Fu3`Okf`)HXzs>R~k}lfxYoV zF>D^{M&_e z6hkdvthaNq!=Ql)?Wiy1Ln@A9na528@&M%idK|I=owEkQ`-(sqGg}ODQb|1(P#7$q z^;2X#H!}8X+h50(DGT&=&{xF~`m}%cX}YPPH_p+SN(v8$>jIbmFmVaF`uS(u??2tT=|H@;;ZXI3dNtxp$Se?P(Y?^}BXcGXKp0xt%&{;Vm z0`>%uJVvVi;AYhF6D9K!v()8hOnqku7o zG(-iL#Q#Y6?WF93$DpubS+FKjYl*C(rF*OWD-?K$L_gqBf&NVl_=&jM^GIDVBUxTH z`vR$i#xUj^mHV0i8as|a7*KKRw7_Wuq$q){5^hkD)Yx2hGh7}-V>Ff__7cFd*mCe@0keS*kG9u#U|p4Kp~GJumgB_yNWjvc5OONpOmW?zh0cKhk`piF}0Q`WT>0J;>HQ3K-Z8DQ1L#kxfA> z=uCNIbDfEB{RFf)3ORXHR}oo2s!=c<;J$#-tW-lK?p7`XbAV5$HRU@Z;7c}reHcrq zEu|IH9EgEPjtiX`gbRx8$1xftO#+E?5d4}!E((nD5Y99h7(mTC3^=e_#H11z0SJHr z^$rJIWPwdmu|$-xN*GdY(H)Q4iA5-}$q-ioYhQz!@8Q^<;TO@(aK~+XdcB{qoM}EY zodl&<=|n0}Xi^;lh9)AMOc*s)01!2s7iuBAPX{BO3OB2TWF`BW)Kmr^!x0mX89~I( zzy{wy?f_dI*yl6ApxSg(u<8PZsYEO^1Rlyr!SX0zT^up?#X8jmLu_8g65t74$XZvZ zpwyqAzmwkzFfc^I_BbpyJCo*5zYSmQ6hK^gQrC! z29$`fktu*z1DI|Rnw&Wwl+sr}Le}b}Q36zQ!M?YCRK}OFPqP-#BNGs$L6sgEFoC9@ zt&X(%3&5LkmkNk*kRDozRLh}A#o zg>Xv%76O4jNP2u)`Im5}5~wgI&U+eiCfnt|GROC$#J=Ym>@`?>OJ4;lPXh_GM4G7I%cHg+L&=E$g3L1wH zVo3`P3<1!E;2~R-Kx#ACPV?#XK-O*o3>CyU`u)?fcUh5HV|?!Q2UmLo?e&tNaJA`? zsR3p0lWu5O&~?E=ix1AL*ZamwnmbpF43%N?;T6r4=p)tr^nt)W2{^8QXSnB zPf>#nThlQ0g&+waW9t7RLZR)zlFZUlEnp_X`WOB4AAy zBXBhwlxYJu%#-L^l=RLM*`E(|Y4#Hw@%666+pgevaE^}7h^L$>v49zrAuaOi3&k}vi!sK{j^Hhx4fxb_VLbS*NW2=X5-n1Mid_WZ$M|c2^ZKFW@mc23; zq{GVAB2U7WAsk#DWF_A)vDc8hy=Yj)HIPA3ZIis@`7gTWjV}_eZu{b+5)V^>JojF?t+q3f6X)60! z?g^M*x?rF=oJh2D%wrwebz2c-l1|-gyyOvAHE7U9Q!Kh>|N?j)Au8YD^~w1 zDFFI9@)ynG%bGa|^0M26vFkYJf@N%@KOsKb(XzO0#OIa?@r;B%Dnk!*Yos&ZDxq;D zAbqOhICLIp2v>O_Z`?pq1ogTa(rg)<{U=ocrWX0vO@u!^4k=oS=+Kd9BQQWHUs1^j zZdCPgBId2mL4Bb7W@9rRU>H48>}nj9B}>b)c$jl_!W7LpDwrEI2Fmj#1VCxef@0XZ ziuq<*;GbMDmg@<&S_r0LuN-iDJx+}_`;yv7d6X_eB!tl;w-pA}90%y6CMSVIz`srg zCVN|Td8_D2QLe%hAGC`Xo~)uSnX?qjlztz3=uNw>iuf_cu>x|0zx4zc#bBEgEG8f) z-F#6Dsngys3XHh}u^~$hQF$3#rT_!HcM=uK@)00weV+k`GB;+hhYFo(7(S80_a zvD;O+{+=lv7X(@_z>-#GmJnONMe{dVS&ay zw=1TRuR~Q{iO^J5|FYqH@|%0IuNOnO1Vch*u3-pPQ&6LAG$IHE=BnTw9SmArsSQkrHfoOb;T#WMs$e}^qb6L%e{L>vw}ddq z@du_5|I-HOynBueU7G$)HDv4rowy<}M{!RbAkYSEXi5`NO?6*1!Jn2s*93oT3X5ew zEn|Oh<*l_vm=8T^iP8RPMUMC+bkYgvkjyYnutF^Cy`a<6@r9RQ5@rv7w_54SG09QN zoS{M6Sxq&Xh2!TJ==D{z{K6)b>pS64imOw-24&9hNRQ!f=uFqi=*lpNM5>Lw+Sp(a59UrN z?mk&jhrD_+?zAUu)l*ZZ#t2UW9Z|NMUx zwKq*LN@r)L66@>;6(rRcZIGr4{GN%MU~>?1R~Kzirxn*VG0mV0*g`=su82(BCO8nu(q*wx1&i`}V$Z>&RS#)MhXH|h+Y4&~LVB6c(%RTG{G1(`PqD_2*J z2LgZ&DeKen&g9@Qe`B|`N&y~&l6>EbF(QbXqLa^+;~awSA)z@bM5 zbk2cexKo&YA&s82QWroS8RvWFk=SdSJSav=wDJeo4V8ML0 z9Sx0i!4%Cr8n((CF*;^0pm{=xH6c70;w*)`G6d`3(Lb|{J;pQOJ@)8xEHK4qjtaA6 z>}}Z^$Qui+&ICVLK-4S|AbRKSUnB>Gvk-W@fv@O5RoNQl6!hLs73^iKX*{T9 z&NnKiBHpTW##7jMhFcMkGu)2HjzUh< z0|a~JhiMeE*91{c;CQSV{GXC9+Lvg+A22f&2<0|?=?9o~SUbXYeR zIttR&kO_2sSH*cTnAU7p!4X0$Z7LdHuyp!Tqy3fsCP}*3Kfa0zZet#r*E4s?bny0| zvlB&8>HRrhkAtF=*I|Nko&jf@72~dHqydU-k3w#z;>S&hWECv4+)*&3?U3RHo~SZE z-9~3TK>88^th>PJ3{RSV$|D0_gLb;bd+MAbhR|o5l`x~$Ng!oK@56R1e;MmWvp*wW z)ht4TwvlX-wCOc0l@f9P|42`uZ!g?}G^!PqIX}sR77)`%-SiS?{ zVZl%WYYoDQ&ZWoZ0}K$VYRI++m};Pg&#FgQF+Hh9dsVtxpNUY}HUDcuR!|`_BIu_iAvZ*6a1M04@1ZixS1qv!OPexLqB@q zs(A3*oCwv2u{$iuYF^m9gSTOy3RGi%jr8OqQ=A^zf3hIe0JVzw>Zyc<-cc09u6q$L zPQdGyNCrm(Q2qwEA$YsV=^AAoh=f|Hj2zlU`^tEFn;GMv zOKPifltL@1m3*nya4So`=ho{?HJFXmdV(jnkz!C20&p5SRHI~e$Gm-?1?Ys9wGe=B z@2TA?COlp}s81CvIg(1e^%wLWeq48Misq7MI@MU8ACeaG`(NbW7ti+piOE!2yKPK!cZ=ya5rcf^a>Ylx#j=_erO zLzN8YQ{WQoFR2xJY3Q4IQC6zcBWri4RMv^m{fx-Vg}n54jrX4YeQA+VO+#Ok9ZfWbTh7^`Od+;#)%UfN|feZMdjw<=#$DGDjmrZe~d z$!90Om>i7-Q0NL_gX)WGO-rSV;g`695#Vw%6s#%k&+Z^~$s7e#ZU;>=+5 zemb@35ms74*fle;a8<*mNx>`!;0PbypuY=Bx6Wm6E8$@m3djMt?je^Zh6S|A~rX zWKuMZ?oClwj?`gr^0biLdLmPiG(f^rY7yXx_=LmSkG(q}f;9n?tqi!PVAJxm z9;<==PgDTm{2t@IKYpVgtftxPH|Htsa1yMGk3vGAwF{M9OrdJpDi{9a02Q=y2MR0R zkmX0;G!!2+bi^`t>1}$_Iy-@vD~xK?Zk2$HR6$cUfBiC0d{+fTnF9Mp6kcQf`{47h zDuWp1EFvdV5#53q4 zfmzh};BVWC>}nQ}F>gc#nKE~~`{aS2P297mSQCJiqMrj09Qh3q%lv}Gnj_y1_Q$A! z;xSmp7T6-dXd_tVi_qtB0r1cS_G$=-Ih*wK95<+{t)fwyru34lcTU9jMIwiyjQ2#y z<-=(!^PDLb5!kCH73MkFJAg|Uef>isfQ=-yE&!2A@K&~GSWBaln_M7q*Qw&}{2;BC zhW*~%{gry7gr!B*&%7p9$O>f0AQ?fngVHNkrHu)?g zF>Bre@_9n*JsZ+J)g^C$pMpRzW_lpRSr5_}uyJS>m4FA^3wQh(n`cV~y%klLkDjU` z>uRJ0MOEZzFZW*ZdULYlF;=q<1p$&Qq5duuB*8*IlpSH7LGmyWf7NrT^$HR9(Oj}^ zbjxfzS%1gmALPWD!1e_wlt=H3scw;zJ91$^%K%xkR${D>x-A&tC#{9uD~q4Ra6`<3 z<%gKu?IP|d>#4u5cvg4@H%S&4wv|+?7$A?Hmo4rZhVof~5kE&RO#t1nOK7^-cv73R ztm`8GlM<_PH91jU7+6u2Wv=(4m1R9volCN8=8y_SUO4gvcJ_dzuEM|xP@-uP@k|-K z*nGma-ieBs@|h&-?L~6YSYT%`c|Z9c7YZP9CA{EKJi7Wr?Ev?z;-0-LX~RV1`id$t zJM@#a`B>nbAFkx0CcX`@R{?ZOa#k>@4}*;HNoJA|j{N1li>&RnfvwWno1cw7fn4Qf zkhkFf&MY1?&OR?Iun4iE$FB7`W_&nrvEsawR*2i)wMfhS-V|(>AAcuJRoX7yDB}M8 zV#?5y(I>{%4~u2;+Y_%yjH9ZuQe@7gm!BvrOp&o|t zF!|S7?gfPa>o%GmiFH8)HOfRR0$M)zdN?xoRK;*pD!INKF(2CHK=0wwx~q?<_rF^= z_pMh5*&mp5;uj7prfA4`}M8@q_sG9QZ#Lj|(|mm z)D|+^RBUW`f%LDHx;u*a!BVx~tGRE}EkfiD6{Pv8bNpwLn_i>NLs=8lwc#)fND$7>#k((GK&pbyS+g6eFLEJExn^Mbn9Mb9!)>E>@@m=)*aMZx( z6nbUrGf8jCb7_c;h~;g|t+JCZsAqET$B-}b+6p$wC&o$^FA`~iat(D4oVXi?Pr{&Z zXM!47AG+PvHsK5Vf{4XtlFQpW(#aWHZ}60YlpPDDs+_8ar}lQ+YW^4Qv?A|wB!0;O2u}hUdu_vL|Q<) zDS2DRRem*w>nma%m2{|3KRMuc4cL>%BRuT{+0UJF*A-%xcVK%PDLk^?kRKtpE*~Dd zZzXZf;J<#x@|dGvldlxXfMSBNIq-Pa7I;9MRdJD23CU>N7$*s|sU#o!iV8mVMyyIF z&t8`pUr6V7)wF$b;xd+IJSUdWa;lX)rh?DDfJp^)I~xl&iC7Ixa>G(_$ePhJWZm#% zJ?_}G>`92*Za()`O>frr!Lgf4qH620v25iJUs|v>$z5bIRpP3?gf`8nUY8SH^J8**WpG-P%PocS;gxfMnjU~H)Zyj(0E2Jut&i`v{Pq?e?X ztX=!~QA@=va>&^wztxLRF2C0nc={acqx8y-6m44LQR2(qwx?gZ6$ZrQZWbH=*de!h zCw;G7NuCRme+}XrkF|zn@R#%3Vk)?~Mb82swP}g1S@iTr)EOoOfO%^BR9LP|0i1`d z-JU7OC0Z_F@>wS0w9A9;Om0CN89R%FDRg*Uf`H_ys2jp5fPs-m zz4Xvwaa;QZGRH^6s{Sl}X-baue6v0);RpDB7`C1x37nZM-OxL1kt#hP()w}D$N_KB zrkBA!qGSVfXz}F)LlnAgcI203KU1rv8-rU)tFk`!hGdYEh-U#|3t2-?KaI(p&&lMk z&hfKjph|@c1vAxY3|@gUvI{U$b~7XS8X#v8U{>)r%oS(-2$5H*USP@8{Y<&PW4Lcj z$$kjYb_}Kx@`NrxL2dp$2N=;AX_#-8dt!vk|JaUxI$3gEl5p?)!W5}Ru)HZns+B)D z79_8akKKdgy6ApT*lcc$B_&W4b;zG0R^APXwdtpkEYox{_?{$7Ci<0K-?EjTj;99(X1S+}b{CA#2pR=oES4?4Q8v>jttuTT7fC9*JE}FI0#3pgd2+G!D3e zn%tx22uS#AaW?rPowW56^Ev5c{UgcwU8!HBDx4idV`n@n1Mhw33T(M5_w^I(Vpa2N z+FT|o4dx=|^ule_{eVFLE$~SURtzm!141c4b%&gi&V4HiJa&$MLZTpj?I(U_@OC*< zdJv7>We1BupFqu5fMe+AY(=`r^E_Ki3L2%uwe!jT#>kAoa_K1;#8k1CbUTYk!-5pA z{usVT1$j8Ar8gv1x@JuFIGz=|JH<+oi)+vtC*p)E7|DkQ02>iZxu-547z+Gb?4CK; zagpE9YkjvLIz7F%h=*<5E%|K)>9FgOmG~$wG6_Ib00~;?XF8n%RA`M0e|i{#%dOjC z*MOC*_n?SnmL}OgN4)t0x%bL2)} zQ?5$8@ohs^11zggsN>-m$aL;+<|*U&z%`5{rP%Lbge8rG;t)M|7Qv2d zKzw-)4b11QTv;6mFYq@UR^S7xX>iPShxGb~wAP@T-wSrgRZPj*)05soy)XUc%#qH^ z@i>>Kp6)|cu;*5-RtZ{;GeA|C0yLL!BW-{EO$LEiE@>P ziCANA$67w}C1JPf4@oY2@Y{A)aLvsm?%I`sbANRNOIL5TNl>*k)lXmLaUQEDX3*#l zAZ1BxDjmVs!5E&`we>=$l@&O^ zGIy*9oc}9rzI3uj=xQ`}x1B%-ZOjM3Ak-P*nR&u~4*LNBOS0t)$J*9dj6okAv$2wl z3zFXr;`g`?w={XT`t25T3t}o*AEY5em03kqw}1XUFJ-Q5hv_;Mz3cIy}MQN69u#)&KAn!W7URAhwhP!JH{oK)5rwz;*$ zeeQGax#ynyIc+Y4G>>=Y(P+#Ka(@y&yrqQDwmv23m8TW76PKoxPLJodMbWf}=gApB)x+a5l zAfj3m1vZ)Sc`NL>Y>pJLR{>s+VM&0!H%qaffTr&^uCFDg!?!IdkbWTVyJc9W{gV2f zs)VZO!SsaUxX$y0)Fl}mNKU)q*Wb{uOW_g>r&|-%RS$=Z}9eRzrgFkw8VEetk}})+Kld9+n0{bJYlzQl}sQf(@S* zjzcyeIcP1ojnlc(PMcxC)4KVOf_a)kT%N>Ho?NrQwT%yFX=m$@_J&FO&G}+zAsMe{ zV#z9CV^!#4-Dy3fhNl2B^R;365(|Dc4t*bKjzxSl2d@q_8=%U8_M1n$uwo)MR4 z{d=M;Yt=7mu)Vh#Qfo*cZ#fDC{sctL)VjnX_#eqtaF{MtV*+0{BlWw>V1-E6`oLx| zh2kSWRbOC@j3tWIh60(74->d5vJZJ54C))ACJfw{@Q%7nb$thY=4A9>_Ksul786sn zg7rOdA&Km^_a$2~S}v$HL1&T(SUIae3OWtQPmRIBjhHuXJes36tm_k!{ikGS2i#{0 zw?S;Rh{d9^RHSGaL9F-dqIb8mCc|xbmR(44?RM)pEUR<=P& zIFED+!+2u=gFUId${w%eo%J+Dk5Ur;O%oG2e1HV5={ckJEhC_7|0&QF7GWera@Pgt zr}jy-3X?4J!_nJSz`|#k2fXeBa^|PktZF06eI~P}%w>9MJ<~Lm%{9IkLm`iqUEtlQ z4btyTpiAE6wf2+Ig>3@pzs$#Dk>G>^_TRxQK#blJ_=;)#ao`x$4 zqf?8(kq@Me;N)Yt5|Hj*3z7t5<2M^xzJrAq^mp7_LvQGO01P1ncKv`I1b{6}3%UEb zW*X;VDML)-6nXJl`nbGE%XZp7nb4EcEL|n&f+ax+?Q{wy6i8awWgrO#mB3|hKsANz z?%5`Qt~G==mA&t@8W^f)Po)F$juqRbpL4$BPGdaE2GA-6RMJ9rRn%aXiPvVv9qypB zT-xB`VI1UPYA@Co^xzX+;gb#sC4zS*gqo4@IiLgxL_rYc54vW3Cqppc-V+;7+uTwi zkdwif$U(S5n*rHR7~EymY>2-y`KfN6<8JWy)TWm)@cy9IBPKJhM)2z{&O0}JsjAiw zwzWFCClIWfk#x`?vRW7!L=9}*X+8fnrXU3j;}x;^Wt!P+I*LHs0**C@f3A5=P}Ba? z9KUWr@$kPLchUq0eJG~=qr@LAiS0KiW?zDCeS=yD<)+WqGd6(BgEaJ%K_Zl_jB!5aXzm)`MMToMw4mjBHb_%?u zq!`-%l@&5baNAEhrr;G2ioJp0)S1nxeXPeEt&J7K`tr*M$-6~b<0f{4yAptaC~(Mui{Ys38oO5)(Qn*DFYT>T?g|Mj z0`CBUvJaG}H^))!a{IqvkE5r(7`Bvig z`aK2%qN{A24bLwT2Omtp!6zyfkylN`vfP6Os7AZMLOT%!(#N6GV7D#Jj*BB{1d9&K z)gCIUJ|%YYPa1fjL-pKi+y@)@p z^})q}oM8?yx}Y9MYPewm-dxc33YeYWw5BT!ENNTa!ITZ4l4Zc9^A4Z&E@2~h0}Na$ zhdB}Kxs+!mkcQ;g+B1_GeA{&aY&Uwh6NdDbDUD;npFy$*k7hRiTtW{2`Ps*w+q0+Ged(}61r1;A$8$^N+?*{|@0N6`xw^v{ zER=O0Uc)=J^Xhgi8)$)K*8p>&bl9GJW?UFmS&Fs-WIYr>;-Ex#>@_>h`WOvLL}fmP1l)E#Pb#Ofk9q z&LX6Mvap?7ZmOuo2(&jXKzIjOsDQLVxf3t=btU`; zMXG0c?E-}y3GfFyJh$x?_>H1Es1m}+Hu9~`!nIq&FYE444&eHxAK!bv6FV&41$BV`c-_=_KW0SUEl(oodlp1o5RK|j50hSwh=B?1+>!O4-B zc|_nMIV(FaX2^j8Rr&A&Mym(tioRS8X9 z@$K!z0)$ui*mezP9o{=(^#QwOK?>mtZ1Pmf^eO1Bncq@ zOp=`0;iJ%=frG|lDE@6wg~AmFU1n$pv1y$@k}33X7TS=?JJi+-O^-uT;Q81I7d5c8 zPa3=ALE3|&+G?cT6bwWGadaxcKI)J(>&TAwNv>86@i*EYN3Ongy24szcM*rK} zsAWHw(f5p}uB=IK_<-m#U9`1IH}b((N>tHJU;? z#Tro{(%MAdiho@JQwIK}svs=~l=A_h;LzYeKRDH!efda)nqFN?{5wm+q@X-v#0N)t z8f+ZA3bc{%w!68LVmIlZ51(A4XTjlX^bEWlUBzhn$25}H;*h^OtnZp(9La=F z*diEeF_h}ihQz(phb!}rubFJdWNH@osts?X7e1WIWtjCsb_VR=+Z{9k64zH}99r^T znsE9I*PBqYB2?a{BE0k*0mA@+DW6c`_ZNd|Pk;9jTrR25*B~E?i;B}XLmkW?>lD{N zikzYueE0KMR=>!P-dc)(hYB={zr)DWG&3prvrr3`t(pJl(96egMj+{zKf)AL0m&nV z!-sKMs>T)X=9=_q-1^jXs)D_9G>Jv^=;vA0^;(dtqmjEVM1P6TSin11a zIJkrx{OG0sc{<(lG&wYe@cPtJUOsV&R*6&t=03k%Ll&e&g6?QR?l*x>tRa=G67OX@ z0Z`uo)!&3AI33XU?Qn3g*x1iTNrZ@U84!XB1I}w^8B0x6PTf?PU7@EZpi~dQ|1<$omzS4~TWuitbuq}! zDC^l1mA@D7tF{+pdwrFfI^%`X{Pa`59TZx!%#qrnt>#%VPiJXo5*-^z(w;@LkFn1_(^C&&HY3A-qNMh|O& z7BvS&{IPhj`g%ytYnOz`vS~c2zogLwlkO9$j9I?o(jqohFOj5rcw#nmyI}A2H%wh$ z?tQD@cyA>b+wouQ!F;F=%?oOW3VnI;jn;oDGxH)S(mCGoDkjaphtcA-UiM&oK|NUM z5`#QxaqkRaTYm2afu2J2^*E-zO#7x)uNWE8i9}S6>Q99?P7+$7gDI4UHf@k=8;89E zNi4*+++$xJq$5p?Po6WyE1=M2x>Y+ZoS9euXrJlwAIkdTR%g_yhkbO3^MYupK5J*v zpy&2+V}5xXp*A2 zWO1JHWHp7_FnrTI?rhZ2rz(U%P~=R?j$?+qXexJUG@0AW5L{rpC}PT@?g~4eT*#M5z{W^83}Vo z+~D)rT?-L=C6^T+@v1v$>b%OCP2ZwnV^5amH48$(T^-U5f9ls0H>FW2xWSe9chBV8 ztI@Suh0AgE?E#bh#7Xk4iM()r`sVAd_0Z10<(qknA@9m>yevOp(*I^a{R5mIpLuNo z;t#ktO@;bip$GQF$$rd|EO!+=termN9p-3DAPR@IQPrfkhOU`OpPXR@D#5qTa1JCa zO3PR9Jpb>yVK_o7IMQt3ywtrD&XMn+ec=U09o)x;4p>&eQ`-qOVj$j?Ir_p=uuEz} zgX^V_h*Xh-$N~zZNZa@+9Bk>2gZmLA)&6yuOe)*wZe9Q{T40$T)K1Yj!_zymoa6^r z?K?ZE^^15)e90_ae!cQD#sYO#m;5rg zJO2cf-=%cn;Ko#DureGH;b{II$pHx~5yy94%l-RB2|{QBwmYHzUJ7M~82g)RfIO zo?fJ>&W~m^++UR5dY7VkVyQZicfk*4Bl@DaR-UPySa#jb<9^G}3F+^uwp@-JSo9+A zZt~D42d_ps$JM@S&Q_xoFHZXMq63!;0-xs%1st@Zp7apW8V(as)=i)4HbUlGd6&{@ z$U~=7R%fBrkNBnUF1c5EMQYHLWN>}NKyFm0&I`W{_!~N;#vs!weWTii`leJD({a8t zXtXWq8y6a***ycZ)kfUc!))c-9mL*scH$Sdd`Di!YIW4HWhjb!@pe6yJlrkmeSGLy zq+9>et6M&-ZOt?vCHN6DVm1q&OZbHw8Os@aGM0}#R&Wz%R9}1`DNqVy+^yO4;lb8Ob%9&&DFU5#nWJI9;Rp@z8blkfRu@YJM zxe``47#3XpQsQ^DeEZD@UO_mrIJz#yyD=egQChhfZ{Nz2>!6h7uC}xjy*W>H2x~I- z!!trhYs#!S!t^QMRh<{X{+zxE%I#-H6Lu9D+=s4@{1@zkurej!SxVQ?Ya`5hpGS|= z=35r53PruSwO^HcLI~X=(2>P18Mp1t(_&j*-JpIh7zC7%3N&IpaOu^bn(Fgq z+QZPbwqD&1`?23dwRC!~x31@R;QcvvS&x*EMCpd(gZUp-xlDHhZt!o8x?%_g8r_6G zP(q@=3&UK{5}B}b7nx{w%Q-@ziOuf&gch=^Aa+_4{`ko0v76x)OS$fQ-;hb2o%N}= z!_hkpQ~MKV9(;$2(D~3AU9Yz~AfZ&#=*FDakb}rXCaOV0YO2}NTyHV%i0L1{iZdLC ziB7|qI}`j1@r|W=J7>Dx9mYh|GzF>S4Qa%4U8uhCWMn2H7g}u%gLN^XZ%K`!(`J}( zYwT@|Tn;Hk$!WU^>|ZX@ElDB=B@heQPUm^y^DV0hc;SYQ4>7MCSat*0skI&L16$M8 z4<6T$?Q)KPH%C$vpMFm*RUM9;JR_J=`jJtjV1KziFPO7px@vQ$Cdi=h3)>Y+InEp__18IHE!CsGRj50P~q+k@m8E(-V4L-#)58 zgv{O2>~=!k?k3KKhhFo7D^-?+&K>2Ng0e43DGPNwM0BbI#F3j#N> z>Mv%{B3G<(q=O+u6a13;f!GVe%jaGEWV-@|S0%Q}9Xp2p{5pH_2dz+xZ>IOrnGcv( zv$*RAAf6-7|0mQFRm;@3wC;})$}l6wTdr^j8ntG`%+lrclR)I@UKZ%C7x(rX~; z|MSyPa^W`0N+W%zT-@!rMa4l*z*QuBGG=k;j%9oNmUBh0n+IS&d1rI|11y7Fc} z^R?AWydtsf1BU6Qk^)gSc(QSPlnTmoVOQohRKAbA{Zs^T+j1EV*+>2>3XOKr%e3u` zqScR-r6uaG(RdWfgk3j{8uSk@kRRUloE_cj{f`PNU*bQwe&SVAG@^%JI_YBwb(kaC z^%pRqpGaRj=1;A~Mg2b7ihW)wVoi9N$WNIs<*8&sgWaqwODaaji#;-6CMLN2;*{Z} zZ-lmYHiRveD9? z@kv;+lle?pf0%$zkq(l9=En$|OK^N%TU}~!>tabq0cHQXmM`|t@(V_@^V!jw91G5j zboX(eGF1`AOM45EGE-(O(@z}r=Iw~%Okn((5f^lAdA+LCv{0;h2?%C!cV;@^>xkAL z-#TOEh^CSIjLeSruXy(Bs{Lk`+p?pONavSBPAe!Y}e z+5xXEe&(=e=AzH=+$;#^%f`(Kw{nZEl3cItxsqxacPR!Hty^w-N6|{<_;{XhGjWn{ zSC#K22RZ}l53#;Ga;KUsuIlaU(Nq)>QNs-=eRL7Q0>v%l(D8;!Pxs zbPU^s=Ot)z+d^BfE;v!LlQd~>mJ7d$JrZSoh~2^udr9H5`dGQSuBZi#I+XdLZ=L(`a=faVAnZWF zKny;Cn<$QQ4@D%tyj6cn+sGf2I>Y+!cOxnI$iww~M%uau;n^;08*KN#^alk6Qdzl6 zQd^oB|6C)kt0GsO#DXn@1^qY(A)NHfinxf@Gk-r}zbKF<-+e1J%f7dIyz%!p>(3~S z;`K;|>I`2n55XG_oiq4`Y~Zd`{83)odC}{xq8DGGM9P^uT5!T!g^jZE5I#qa;?7TQ zof6oWb5^gqvr1oZvd*P)iYktRUwrYu!`o(BQlW#w7-^SZNW{8`kQkBA6@>qKz8cR=~|9OEZW4)?f3^rL^)a1A}#CUUexoMYopDL3VQPUZ# z7IcZ%va^&yJ_6%V{wBDg6u>m^NB<0$ZXoB{V=#qVWIXrWQk6hnfn**7(RuDyLq=ONV>TvW2|mxg zPIkHPTKH*!NEfC)Yt%Fv5FTwgvk5a|j1Hu4{%0l{lZ%!>X694Su_q?+)1N<$ggG}D^gC?L4P)(MrXKF3m`h!WAF}v#GV!h5)Apoix13Sa z_)XQCK^uik0@08!RT%9gjI2y39^bme&5f}iO3JeMa+zSlW*tWj&FgnR1RlvfHs^>hIO@=+2b_!|08_F34eQ#4eo2rK|Sf@1jww zwN+#$BQ}aTl?hx6gWrSSGI>xSYOcbgC$o|S4(TYwD{L$Vv2QB-qmG%Xn@?A~{;h;;6UUg1 zPty0lzH#=LOmN)#f!89+=KtUCQ1L%nbts_)mQH(uP8*PYy$j)+fg=ZPtxETM#r+TU C)9keX literal 0 HcmV?d00001 diff --git a/build/icon-o-prompt.png b/build/icon-o-prompt.png new file mode 100644 index 0000000000000000000000000000000000000000..3c3da74ab6ff85ac9ea0c57ef9905269a66868d2 GIT binary patch literal 58904 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˰Avc+K_pi3m0wrl; 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 literal 0 HcmV?d00001 diff --git a/build/icon-open-o.png b/build/icon-open-o.png new file mode 100644 index 0000000000000000000000000000000000000000..6f14ed4ec74effa986bb42e9e3991973cb8b1bdc GIT binary patch literal 36546 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 C)9keX literal 0 HcmV?d00001 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..24d5776 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,116 @@ +# OpenTerminal 长期路线图 + +目标:以开源组件组合构建现代化的本地 + SSH 终端应用,按里程碑推进,每个里程碑可独立交付使用。 + +## 里程碑总览 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| M1 | 本地终端 + 分屏 + 主题/字体 + 设置持久化 | ✅ 已完成(v0.1) | +| M2 | SSH 远程会话 + 连接管理器 | ✅ 已完成(v0.2) | +| M2.5 | 关键词高亮(提前自 M4)+ 系统设置(自启动/阻止休眠)+ UI 打磨 | ✅ 已完成(v0.2.1) | +| M3 | SSH 产品化:Terminal/SSH 分离 + 服务器硬件监控(Wave 风格) | ✅ 已完成(v0.3) | +| M4 | SFTP 文件管理 + 传输面板 + 权限/属主/chmod | ✅ 已完成(v0.4) | +| M5 | 命令历史/命令库/补全 + 会话日志 | ✅ 已完成(v0.5) | +| M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) | +| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) | +| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) | +| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 | + +## M2 — SSH 远程会话 ✅ + +- [x] `src/main/ssh.ts`:ssh2 连接池(connect/shell/resize/close),PTY_DATA/PTY_EXIT 广播通道统一复用(本地与 SSH 会话同数据面) +- [x] 连接配置模型(`shared/connections.ts`):host/port/username、密码 vs 私钥(keyPath/keyContent)vs Agent、keepalive +- [x] 凭据加密存储:safeStorage(DPAPI) + `plain:` 兜底;渲染层只见 savedAuth 标志 +- [x] 连接管理器 UI:书签侧栏(分组手风琴)+ 新建/编辑对话框 + 双击连接 +- [x] Host key 校验:known_hosts 固定、首次确认、变更告警、30s 超时拒绝 +- [x] 测试:tests/ssh-loopback.mjs(ssh2 回环)+ tests/ssh-session-e2e.mjs(真实会话路由层 e2e) +- 待解锁后的 GUI 验收:双击书签连接、指纹弹窗交互、断线提示 +- 已知限制:SSH 面板暂不支持作为分屏参照(总在新标签打开);连接中 Modal 不可取消;agent 认证未在本机验证 + +## M3 — SSH 产品化:Terminal/SSH 分离 + 服务器硬件监控 ✅ + +### A. Terminal 与 SSH 功能分离(产品形态) + +- [ ] 侧栏双分区:「终端」(本地)与「SSH 服务器」各自独立区块(独立折叠、独立头部、独立计数) +- [ ] SSH 会话标识:标签页带服务器图标/主机名前缀,本地与 SSH 标签视觉区分 +- [ ] 新建入口分离:图标条「+」只建本地终端;SSH 会话仅从服务器列表发起(双击连接,右键:连接/编辑/删除/复制主机) +- [ ] SSH 区增加「最近会话」(按 lastConnectedAt 排序,一键重连) +- [ ] (可选增强)工作区模式:本地/SSH 两个独立工作区布局,互不混排 +- 验收:不开侧栏也能一眼区分标签是本地还是 SSH;图标条「+」只出本地终端 + +### B. 服务器硬件监控(参考 Wave Terminal 的 sysinfo 块) + +技术可行性:✅ 高,零部署方案已在真实服务器上验证。 + +- **零部署路线**:ssh2 `exec` 读 Linux 标准接口——CPU `/proc/stat`(双采样算占用率)、内存 `/proc/meminfo`、负载 `/proc/loadavg`、磁盘 `df -kP`、网络 `/proc/net/dev`(双采样算速率)、运行时间 `/proc/uptime` +- **增强路线(部署脚本)**:首次连接部署 `~/.openterminal/sysinfo.sh`(纯 shell 输出 JSON,Linux/macOS 通用),解锁进程 Top 列表 + +数据面:ssh2 `conn.exec` → stdout JSON → 主进程解析 → `STATS:{id, stats}` 广播 → 渲染层 + +- [ ] `src/main/sysinfo.ts`:按会话轮询(默认 3s,可配置),会话关闭/断线自动停止;解析容错 +- [ ] 契约:`shared/sysinfo.ts`(SysinfoSample + STATS 通道 + 面板开关事件) +- [ ] UI:SSH 会话右侧可折叠监控面板——CPU 多核条形、内存/磁盘用量条、网络上下行、负载/运行时间 +- [ ] (增强路线)进程 Top 列表 + 结束进程 +- 验收:连接后 ≤3s 看到实时 CPU/内存;断开会话监控自动停止;服务器跑 `stress` 曲线明显上升 + +## M4 — SFTP 文件管理 ✅ + +- [ ] `src/main/sftp.ts`:ssh2 SFTP 封装(readdir/stat/rename/mkdir/rm/mv,大文件分块传输 + 进度事件) +- [ ] 双栏界面(本地 ↔ 远程)+ 拖拽上传/下载 + 传输面板(队列/暂停/取消) +- [ ] 右键菜单:压缩/解压(tar.gz)、编辑远程文件(Monaco,下载-编辑-回传) +- 验收:100MB 文件传输进度流畅、可取消续传 + +## M5 — 命令系统与会话日志 ✅ + +- [ ] 命令历史(sqlite + better-sqlite3,按会话去重) +- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴) +- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成) +- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示 +- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 11 条预设 + 设置页管理,见 M2.5) +- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅 + +## M6 — 多终端协同 ✅ + +- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭 +- [x] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 自动执行(\r) + 快捷命令 + 最近命令;广播感知发送 +- [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs) +- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide) +- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测) + +## M7 — 打包与分发 ✅ + +- [x] electron-builder:**MSI**(固定 upgradeCode,手动/企业部署)+ NSIS(x64),`npm run dist` / `dist:dir` +- [x] 原生模块:@lydell/node-pty 预编译 + asarUnpack(node-pty/ssh2),npmRebuild=false,win-unpacked 冒烟通过 +- [x] 自动更新(electron-updater,generic feed = Gitea generic package 稳定地址,公网可读;仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN 可覆盖) +- [x] 应用图标(build/icon.png)已补齐,MSI/NSIS 桌面与开始菜单快捷方式已启用 +- [ ] 代码签名(可选,未做) + +## M9 — 体验打磨 ✅ + +- [x] 系统托盘:托盘图标 + 右键菜单(显示窗口/关闭行为/退出);关闭按钮行为可设「每次询问/最小化到托盘/直接退出」,询问对话框支持记住选择(settings.closeAction) +- [x] 单实例运行:requestSingleInstanceLock,二次启动唤出已有窗口(托盘隐藏也可恢复) +- [x] 主题联动标题栏:titleBarOverlay 自定义标题栏,颜色跟随终端主题实时刷新;窗口背景同色 +- [x] 布局菜单重做:弃用 antd Dropdown 传送门(与 dockview 全局指针处理冲突导致闪退),改为侧栏内联弹出面板 +- [x] 补全交互修正:回车不再接受建议(此前会把建议拼接到已输入内容后,如 kimi → kimikimi);与已输入完全相同的建议自动过滤;新增「输入建议」设置开关 +- [x] 标签页视觉打磨:圆角胶囊 + 描边 + 独立关闭按钮 +- [x] 本地 PTY 声明终端能力(TERM/COLORTERM/TERM_PROGRAM),TUI 程序不再降级配色 + +## 工程约定(贯穿各阶段) + +1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端 +2. **原生依赖**:优先选带预编译二进制的包(@lydell/node-pty 模式),避免用户机器装构建链 +3. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测 +4. **里程碑验收后打 git tag** + +## 已知待办(M1 遗留) + +- [x] ~~布局塌陷~~(v0.2.1 修复:antd `` 包裹层 `.ant-app` 无高度导致百分比高度链断裂) +- [x] ~~关闭标签页整窗崩溃~~(v0.2.1 修复:xterm IDisposable 对象被当函数调用;并新增 ErrorBoundary 兜底 + 渲染层报错转发主进程日志) +- [x] ~~首启动空屏~~(v0.2.1:启动自动打开一个终端) +- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制) +- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令) +- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等) +- [ ] rendererMode webgl 的终端数量上限自动降级(大量终端实例时回退 DOM 渲染) +- [ ] 会话数据在面板间拖拽迁移(dockview 原生支持面板拖动,已具备) +- [ ] 界面语言切换(中/英):需先把全应用文案抽为 i18n 资源目录(一次完整重构),设置页预留「系统」分区 diff --git a/docs/STATE.md b/docs/STATE.md new file mode 100644 index 0000000..4b2727c --- /dev/null +++ b/docs/STATE.md @@ -0,0 +1,97 @@ +# OpenTerminal 状态快照(上下文压缩续接文档) + +> 用途:新会话/子代理从此文件恢复全部工作状态,无需对话历史。每次里程碑后更新。 + +## 当前版本与仓库 + +- v0.1.0(版本重置:作为全新开源项目从 0.1.0 起步),远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取 +- 开源协议:MIT(LICENSE) +- 更新通道 = `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 +- 契约先行约定:所有跨进程接口定义在 `src/shared/`,主/预载/渲染各自实现;新功能先改 shared 再派两端 + +## 里程碑状态(详见 ROADMAP.md) + +| 阶段 | 状态 | +|---|---| +| M1 本地终端+分屏+主题/字体 | ✅ | +| M2 SSH+连接管理器+凭据加密+指纹校验 | ✅(真实服务器验收通过) | +| M2.5 关键词高亮+系统设置(自启/阻止休眠)+UI 深色化 | ✅ | +| M3 Terminal/SSH 分离+服务器监控(Wave 风格) | ✅(真实服务器验收通过) | +| M4 SFTP 文件管理+传输+权限/chmod/chown+监控图表化 | ✅(真实服务器验收通过) | +| M5 命令历史/命令库/补全+会话日志 | ✅ | +| M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) | +| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) | +| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) | +| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ | + +## M8 实现要点(v0.8.0,已落地) + +- **工作区分离**:两个独立 Dockview 实例(terminal/ssh),切换 = 容器 display:none(状态保留);workspaceModeStore(zustand+localStorage);IconRail 双模式入口(选中高亮);ConnectionSidebar 按模式只显示对应区块(终端:终端+命令;SSH:服务器+最近会话);旧的「单 Dockview+marker 面板+setVisible 分组」方案已废弃删除 +- **SSH 底部文件面板**(SshBottomPanel):终端在上、文件浏览器在下、4px 可拖分隔条(120px~60%);右侧栏只留监控(300px) +- **sftp.css 从未被 import 的隐藏 bug**:文件面板样式一直裸奔,FilePanel.tsx 补 `import './sftp.css'` 后才真正生效——教训:新增 css 文件必须确认被入口 import +- **文件右键菜单**:刷新/打开·下载/上传/重命名/新建文件夹/复制路径/文件权限/删除(复用现有 handler;「新建文件」因无 touch API 未做) +- **监控美化**:内存橙条、磁盘三列行、网络上下行进区块头(↓蓝 ↑金)、toFixed(4) 精度、tabular-nums +- **补全修复**:①Enter 也接受补全(Tab||Enter);②丢字符根因=differenceWrite 只写差量而 lineBuf 与 shell 行缓冲失同步→改为接受时强制重写(\x15 Ctrl+U 清行+写全命令)+diffRewriteRef 让下一次 onData(shell 重画 echo)重置缓冲 +- **chmod 无效 bug**:bitsToOctal 把八进制位按十进制拼数(dr-x--x--x → 329 而非 511),新旧相等致 chmod 根本没发→改字符串拼接;chown 只在 uid/gid 真变化时执行 +- **弹层被侧栏盖住**:dockview 渲染层 `contain:layout paint; isolation:isolate` 形成独立层叠上下文,面板内 antd 弹层逃不出→图标栏布局菜单/广播 Popover/文件右键菜单三处 `getPopupContainer={() => document.body}` +- **图标栏瘦身**:分屏两项+模板两项收进「布局」主菜单 +- **SSH 标签强化**(SshHostBadge):hostLabel 胶囊+绿 accent;日志工具栏 hover 才显示(录制时常显);侧栏终端列表 max-height 滚动 + +## M6/M7 实现要点(已落地) + +- **广播输入**:渲染层 zustand(broadcastStore)维护 enabled/targets/sessions;TerminalView 全部写路径(onData/补全/粘贴/Workspace runCommand)走 writeBroadcast 扇出;目标 <2 自动禁用;tab 目标圆点 + 按钮计数徽标 +- **ZMODEM**:主进程引擎(src/main/zmodem.ts,仅 SSH 会话;本地 pty 走 ConPTY 只给 UTF-8 字符串,二进制会损坏——不支持,注释已说明);Sentry 常驻分流非 zmodem 字节;offer→渲染层选文件/目录→respond;传输期抑制 PTY_DATA/replay/日志并丢弃用户键入;offer 120s/传输 90s 看门狗;进度复用 TransferPanel(kind=zmodem-upload/download);测试 tests/zmodem-e2e.mjs 用第二个 zmodem.js Sentry 模拟远端,双向内容一致性断言 +- **快捷输入面板**:QuickInputPanel 右下 fixed 浮层(bottom:44 避让 TransferPanel),发送经 writeBroadcast(广播感知),列表打开沿刷新 +- **快捷键**:Ctrl+=/-/0 字号(main.tsx capture 监听,xterm-helper-textarea 放行——隐藏 textarea 曾被误判为输入框导致终端聚焦时失效,已修);globalShowHide accelerator(globalShortcuts.ts,设置页系统分区可配,注册失败仅 warn);Ctrl+PgUp/PgDn 面板循环(Workspace capture 监听) +- **打包**:electron-builder.yml(msi 固定 upgradeCode 5ab9f79e-e4eb-4052-9df6-3af3a301ab0a + nsis;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址) + +## 发布流程(下一版本照抄) + +1. package.json version 升位 → `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM) +2. `git tag vX.Y.Z && git push origin main vX.Y.Z` +3. Gitea API 建发布:POST /api/v1/repos/admin/OpenTerminal/releases(中文 body 必须走 UTF-8 文件 --data-binary @file,shell 内联会坏) +4. 附件:POST .../releases/{id}/assets?name=(msi/exe/blockmap/latest.yml,实测 141MB 可传) +5. 更新通道:DELETE .../api/packages/admin/generic/openterminal-update/stable(旧版)→ PUT 同 URL 依次上传 latest.yml / exe.blockmap / exe +6. 校验:curl 无 token 拉 latest.yml 应 200 且 version 正确 + +## 测试(全部通过,改动后必跑) + +```bash +npx tsc --noEmit -p tsconfig.node.json && npx tsc --noEmit -p tsconfig.web.json +npm run build +node tests/ssh-loopback.mjs +npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty --external:ssh2 --alias:electron=./tests/electron-stub.cjs && node tests/ssh-session-e2e.mjs +node tests/sysinfo-e2e.mjs +npx esbuild tests/hl-split-smoke.mjs --bundle --platform=node --format=cjs --outfile=tests/.hl-split-smoke.cjs && node tests/.hl-split-smoke.cjs +node tests/commands-store.mjs +node tests/zmodem-e2e.mjs +# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期): +# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs +# 打包:npm run dist(产物 release/) +``` + +## 关键架构与已踩坑(勿重蹈) + +1. **主进程 = 注入式**:pty.ts 的 sessions Map(local/ssh 统一数据面);sysinfo/sftp/zmodem/日志均经 provider/configure 注入,保持 ssh.ts/commands.ts/zmodem.ts 无 electron 依赖 +2. **JD 云服务器三个怪癖**:① SFTP SETSTAT 被静默忽略 → chmod/chown 走 SSH exec;② SFTP 写 ACK 惰性 → 窗口化流水线 + close 兜底;③ 每连接仅一个 SFTP 子系统通道 → 传输复用缓存通道 +3. **esbuild CJS bundle 会让 ssh2 fastPut 回调不触发**(数据实际到达)→ 测试用 stat 轮询;真实应用 rollup 未复现 +4. **antd Modal 必须包在 ``+darkAlgorithm 内**,`.ant-app` 需 height:100%(高度链) +5. **xterm 清理**:IDisposable 只能 .dispose(),不可当函数调用 +6. **zmodem.js 0.1.10**:Node 可 require 核心(Sentry/Session);`on_input` 交付的是普通 Array 非 Uint8Array(需防御转换);send_files 在 zmodem_browser.js:58(FileReader 版,已移植 fs 版) +7. ~~**MSI 无图标会链接失败**~~(已修复:build/icon.png 由程序生成,electron-builder 自动转 ico,MSI/NSIS 快捷方式已启用) +8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发 +9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2) +10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘 + +## 编排约定 + +- 主代理:契约先行 → 并行派遣子代理(每个带自审清单)→ **code-review 子代理** → 主代理集成验收(typecheck/build/测试/真实打包/UI 活体验证)→ 提交推送 + tag +- 主代理额度紧张时:契约与验收保持主代理,实现全部下沉子代理;GUI 验收(computer-use)必须主代理亲自做 +- 自动化差异备忘:antd Popover 触发按钮与 portal 内按钮对 AXPress 不响应(真实鼠标点击正常)——验收一律用 event 策略坐标点击 + 精确 bounds + +## 待办(按优先级) + +1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标) +2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键 +3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、i18n(中英切换)、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修) diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..4f9ecbf --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,50 @@ +appId: site.codingplan.openterminal +productName: OpenTerminal + +directories: + output: release + +files: + - out/** + - package.json + +asar: true +asarUnpack: + - "**/node_modules/@lydell/node-pty/**" + - "**/node_modules/ssh2/**" + +# @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild. +npmRebuild: false + +# Electron-updater only supports NSIS auto-updates (MSI has no update channel). +# The NSIS .exe + latest.yml get uploaded together; MSI is manual/enterprise only. +win: + target: + - target: msi + - target: nsis + +msi: + oneClick: false + # Fixed upgrade code keeps the upgrade chain consistent across releases. + upgradeCode: "{5ab9f79e-e4eb-4052-9df6-3af3a301ab0a}" + artifactName: ${productName}-${version}-setup.${ext} + # Shortcuts need an ; build/icon.png (auto-converted to .ico) provides it. + # Without an icon the WiX shortcut Icon reference fails to link (LGHT0094). + createDesktopShortcut: true + createStartMenuShortcut: true + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + perMachine: false + createDesktopShortcut: true + createStartMenuShortcut: true + artifactName: ${productName}-${version}-setup.${ext} + +# Declaring a publish provider makes electron-builder emit the update-info file +# (latest.yml) into directories.output for upload. No token is stored here; the +# main agent handles feed authentication during upload. Build runs with the +# default publish policy, so nothing is uploaded from this script. +publish: + provider: generic + url: https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/ \ No newline at end of file diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..38e5182 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +const sharedAlias = { '@shared': resolve('src/shared') } + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: sharedAlias } + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: sharedAlias } + }, + renderer: { + plugins: [react()], + resolve: { + alias: { + '@shared': resolve('src/shared'), + '@renderer': resolve('src/renderer/src') + } + } + } +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bc14891 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7286 @@ +{ + "name": "open-terminal", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-terminal", + "version": "0.8.0", + "dependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "antd": "^6.6.2", + "dockview-react": "^8.2.0", + "electron-updater": "^6.8.9", + "font-list": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "ssh2": "^1.17.0", + "zmodem.js": "^0.1.10", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "electron": "^44.2.0", + "electron-builder": "^26.15.3", + "electron-vite": "^5.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.4", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.3.4.tgz", + "integrity": "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.6.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz", + "integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.15", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.15", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.15", + "@lydell/node-pty-linux-x64": "1.2.0-beta.15", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.15", + "@lydell/node-pty-win32-x64": "1.2.0-beta.15" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmmirror.com/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.22.0", + "resolved": "https://registry.npmmirror.com/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.6", + "resolved": "https://registry.npmmirror.com/@rc-component/form/-/form-1.8.6.tgz", + "integrity": "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.1", + "@rc-component/virtual-list": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mentions/-/mentions-1.12.0.tgz", + "integrity": "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.1", + "@rc-component/menu": "~1.5.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@rc-component/menu/-/menu-1.5.0.tgz", + "integrity": "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/@rc-component/notification/-/notification-2.0.8.tgz", + "integrity": "sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.12.2", + "resolved": "https://registry.npmmirror.com/@rc-component/picker/-/picker-1.12.2.tgz", + "integrity": "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.13.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/progress/-/progress-1.0.3.tgz", + "integrity": "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/@rc-component/select/-/select-1.10.1.tgz", + "integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@rc-component/steps/-/steps-1.2.3.tgz", + "integrity": "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tabs/-/tabs-1.13.0.tgz", + "integrity": "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@rc-component/tooltip/-/tooltip-1.5.2.tgz", + "integrity": "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.10.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-3.10.1.tgz", + "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.13.0.tgz", + "integrity": "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "6.6.2", + "resolved": "https://registry.npmmirror.com/antd/-/antd-6.6.2.tgz", + "integrity": "sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.22.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.6", + "@rc-component/image": "~1.10.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/listy": "~1.2.3", + "@rc-component/mentions": "~1.12.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.8", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.12.0", + "@rc-component/progress": "~1.0.3", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.10.1", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.3", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.11.1", + "@rc-component/tabs": "~1.13.0", + "@rc-component/tooltip": "~1.5.2", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/tree-select": "~1.16.1", + "@rc-component/trigger": "^3.10.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.12.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmmirror.com/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dockview": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview/-/dockview-8.2.0.tgz", + "integrity": "sha512-Jj+fnFWSrdEhF+1DmpGNYjEvyqf7J0FHkDqsD98wL4WOYB2CSKavDCL49B3LHIGYvWNN7Q1O1j4p7Pts38CM5A==", + "license": "MIT", + "dependencies": { + "dockview-core": "^8.2.0" + } + }, + "node_modules/dockview-core": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview-core/-/dockview-core-8.2.0.tgz", + "integrity": "sha512-+L+xdvmO1b4in8rVkFtQM13IkPDKqDLoHJvq5HtAIsMxsPptSVveKeViYBqquaoHF+t0xb9f6692gTeKnjCKGw==", + "license": "MIT" + }, + "node_modules/dockview-react": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview-react/-/dockview-react-8.2.0.tgz", + "integrity": "sha512-OHa/4uuaw3VpHVkGophdYh2U1f5tOvp/ANf5CAE3QQVh2oSMKNimaWaFXEg/R1wGBdZogQyYOKQSU+wLrE0taw==", + "license": "MIT", + "dependencies": { + "dockview": "^8.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.2.0", + "resolved": "https://registry.npmmirror.com/electron/-/electron-44.2.0.tgz", + "integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmmirror.com/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/font-list": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/font-list/-/font-list-2.1.0.tgz", + "integrity": "sha512-GMDcD8Wymmhz7pxOzu+Gm/bbVzxvUHJJSiLPCF/rt02g2W7P08Nhz6XiaYjXo8xklXWhLoCiyi1cBPwwASC2fQ==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "4.35.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmmirror.com/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmmirror.com/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zmodem.js": { + "version": "0.1.10", + "resolved": "https://registry.npmmirror.com/zmodem.js/-/zmodem.js-0.1.10.tgz", + "integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==", + "license": "Apache-2.0", + "dependencies": { + "crc-32": "^1.1.1" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e3f89d --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "open-terminal", + "productName": "OpenTerminal", + "version": "0.1.0", + "description": "Open-source terminal with SSH, split panes, themes and fonts", + "main": "out/main/index.js", + "author": "Bill", + "license": "MIT", + "private": true, + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", + "dist": "electron-vite build && electron-builder --win msi nsis", + "dist:dir": "electron-vite build && electron-builder --win --dir" + }, + "dependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "antd": "^6.6.2", + "dockview-react": "^8.2.0", + "electron-updater": "^6.8.9", + "font-list": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "ssh2": "^1.17.0", + "zmodem.js": "^0.1.10", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "electron": "^44.2.0", + "electron-builder": "^26.15.3", + "electron-vite": "^5.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0" + } +} diff --git a/src/main/assets/tray.png b/src/main/assets/tray.png new file mode 100644 index 0000000000000000000000000000000000000000..a10d48c36b7aea094d384d912edff913a513e5f3 GIT binary patch literal 131 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzFHaZ8kcwN$2@WtY(BP<~*AvYV`-l+@xri4Z=Ct#jT@>u38pVYAHaxJhRh9(f*8$e`+Lw8}C; eq^C)cmBDL<-hq_J^&fzyGI+ZBxvX literal 0 HcmV?d00001 diff --git a/src/main/broadcast.ts b/src/main/broadcast.ts new file mode 100644 index 0000000..c2ed2bb --- /dev/null +++ b/src/main/broadcast.ts @@ -0,0 +1,8 @@ +import { BrowserWindow } from 'electron' + +/** Send `payload` to every live BrowserWindow. */ +export function broadcast(channel: string, ...args: unknown[]): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(channel, ...args) + } +} \ No newline at end of file diff --git a/src/main/commands.ts b/src/main/commands.ts new file mode 100644 index 0000000..4ce0e23 --- /dev/null +++ b/src/main/commands.ts @@ -0,0 +1,288 @@ +/** + * Command history / command library + session output logs (M5). + * + * Persistence mirrors the connections store: plain JSON files under + * / without sqlite. Two concerns live here: + * + * - Commands: /commands.json as `{ history, library }`. + * History is capped, deduped against the newest entry and sorted by + * lastUsedAt; library items are sorted by createdAt and support id-based + * update-or-create. + * - Session logs: /logs/-.log plain-text + * files plus an index file (/logs/index.json) of SessionLogMeta + * that survives restart. PTY data is pushed in by the session layer via the + * registered data logger; streams stop on session close. + * + * The only Electron binding is `app.getPath` (data dir) and `shell.openPath` + * (open the logs folder). Both are injectable so this module can be bundled + * and tested under plain Node. + */ + +import { app, shell } from 'electron' +import { randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs' +import { appendFile } from 'fs/promises' +import { join } from 'path' +import type { CommandItem, SessionLogMeta } from '../shared/commands' + +/** Upper bound on recorded history entries. */ +const HISTORY_CAP = 500 + +interface CommandsFile { + history: CommandItem[] + library: CommandItem[] +} + +function emptyFile(): CommandsFile { + return { history: [], library: [] } +} + +/** Default data root: (computed by the store itself). */ +export function defaultCommandsPath(userDataPath: string): string { + return userDataPath +} + +function two(n: number): string { + return String(n).padStart(2, '0') +} + +/** YYYYMMDD-HHmmss timestamp used in log file names. */ +function stamp(d = new Date()): string { + return ( + `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}` + + `-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}` + ) +} + +export class CommandsStore { + /** /commands.json */ + private readonly file: string + /** /logs */ + private readonly logsDir: string + /** /logs/index.json */ + private readonly indexFile: string + private readonly openPathImpl?: (p: string) => Promise + + /** All known logs keyed by absolute file path (hydrated from index on boot). */ + private readonly metasByFile = new Map() + /** Logs currently accumulating output, keyed by sessionId. */ + private readonly activeBySession = new Map() + + constructor(userData?: string, openPathImpl?: (p: string) => Promise) { + const ud = userData ?? app.getPath('userData') + this.file = join(ud, 'commands.json') + this.logsDir = join(ud, 'logs') + this.indexFile = join(this.logsDir, 'index.json') + this.openPathImpl = openPathImpl + this.hydrateIndex() + } + + // ---- commands file (history + library) ----------------------------------- + + private loadCommands(): CommandsFile { + try { + const raw: unknown = JSON.parse(readFileSync(this.file, 'utf8')) + if (raw && typeof raw === 'object' && Array.isArray((raw as CommandsFile).history)) { + const data = raw as CommandsFile + return { + history: Array.isArray(data.history) ? data.history : [], + library: Array.isArray(data.library) ? data.library : [] + } + } + } catch { + // missing / corrupted file -> start fresh + } + return emptyFile() + } + + private saveCommands(data: CommandsFile): void { + mkdirSync(join(this.file, '..'), { recursive: true }) + writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8') + } + + /** Record one executed command: trim; dedupe vs newest; move to front; cap. */ + recordCommand(cmd: string): void { + const trimmed = cmd.trim() + if (!trimmed) return + const data = this.loadCommands() + const top = data.history[0] + if (top && top.command === trimmed) { + // Same as the newest entry: refresh its recency (already at the front). + top.lastUsedAt = Date.now() + } else { + data.history.unshift({ + id: randomUUID(), + command: trimmed, + createdAt: Date.now(), + lastUsedAt: Date.now() + }) + if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP + } + this.saveCommands(data) + } + + /** History sorted by most recently used. */ + listHistory(): CommandItem[] { + return this.loadCommands() + .history.slice() + .sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)) + } + + clearHistory(): void { + const data = this.loadCommands() + data.history = [] + this.saveCommands(data) + } + + /** Library sorted newest first. */ + listLibrary(): CommandItem[] { + return this.loadCommands() + .library.slice() + .sort((a, b) => b.createdAt - a.createdAt) + } + + /** Insert (no id) or update (has id); returns the stored entry. */ + saveLibraryItem(item: CommandItem): CommandItem { + const data = this.loadCommands() + let stored: CommandItem | undefined + if (typeof item.id === 'string' && item.id.length > 0) { + stored = data.library.find((x) => x.id === item.id) + } + if (stored) { + stored.name = item.name + stored.command = item.command + stored.note = item.note + stored.group = item.group + stored.params = item.params + } else { + stored = { + id: item.id && item.id.length > 0 ? item.id : randomUUID(), + name: item.name, + command: item.command, + note: item.note, + group: item.group, + params: item.params, + createdAt: Date.now() + } + data.library.push(stored) + } + this.saveCommands(data) + return stored + } + + deleteLibraryItem(id: string): void { + const data = this.loadCommands() + const next = data.library.filter((x) => x.id !== id) + if (next.length < data.library.length) { + data.library = next + this.saveCommands(data) + } + } + + // ---- session logs ----------------------------------------------------------- + + private hydrateIndex(): void { + try { + if (!existsSync(this.indexFile)) return + const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8')) + if (!Array.isArray(raw)) return + for (const x of raw) { + if ( + x && + typeof (x as SessionLogMeta).sessionId === 'string' && + typeof (x as SessionLogMeta).file === 'string' + ) { + const meta = x as SessionLogMeta + this.metasByFile.set(meta.file, meta) + if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta) + } + } + } catch { + // index missing / corrupt -> rebuild on next write + } + } + + /** Write the full log index so listSessionLogs survives restart. */ + private persistIndex(): void { + try { + mkdirSync(this.logsDir, { recursive: true }) + const all = Array.from(this.metasByFile.values()).sort( + (a, b) => b.startedAt - a.startedAt + ) + writeFileSync(this.indexFile, JSON.stringify(all, null, 2), 'utf8') + } catch { + // best effort: never break the session over an index write failure + } + } + + /** Finalize a log (set endedAt, drop from active, persist index). */ + private finishLog(meta: SessionLogMeta): void { + meta.endedAt = Date.now() + this.activeBySession.delete(meta.sessionId) + this.persistIndex() + } + + /** Start logging a session; any prior active log for the id is stopped first. */ + logStart(sessionId: string): SessionLogMeta { + const prev = this.activeBySession.get(sessionId) + if (prev) this.finishLog(prev) + + mkdirSync(this.logsDir, { recursive: true }) + const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log` + const file = join(this.logsDir, fileName) + // Create the file eagerly so the first async append cannot race a missing fd. + describeFile(file) + const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() } + this.metasByFile.set(file, meta) + this.activeBySession.set(sessionId, meta) + this.persistIndex() + return meta + } + + /** Append output for an active session. No-op when not logging (post-stop safe). */ + logWrite(sessionId: string, data: string): void { + const meta = this.activeBySession.get(sessionId) + if (!meta) { + // not logging (or already stopped) -> ignore, prevents stop/append race + return + } + appendFile(meta.file, data, 'utf8').catch(() => { + // file may have been removed after stop -> ignore + }) + } + + /** Stop logging a session, stamping endedAt into the registry + index. */ + logStop(sessionId: string): void { + const meta = this.activeBySession.get(sessionId) + if (!meta) return + this.finishLog(meta) + } + + /** All known session logs (including persisted ones), newest first. */ + listSessionLogs(): SessionLogMeta[] { + return Array.from(this.metasByFile.values()).sort((a, b) => b.startedAt - a.startedAt) + } + + /** Reveal the logs directory in the OS file manager. */ + openLogsDir(): void { + try { + mkdirSync(this.logsDir, { recursive: true }) + if (this.openPathImpl) { + void this.openPathImpl(this.logsDir) + } else { + void shell.openPath(this.logsDir) + } + } catch { + // best effort + } + } +} + +/** Create a log file empty so the path is real before async writes begin. */ +function describeFile(p: string): void { + try { + writeFileSync(p, '', 'utf8') + } catch { + // best effort; appends will also create it lazily + } +} \ No newline at end of file diff --git a/src/main/connectionsStore.ts b/src/main/connectionsStore.ts new file mode 100644 index 0000000..c9281a3 --- /dev/null +++ b/src/main/connectionsStore.ts @@ -0,0 +1,219 @@ +/** + * SSH connection bookmarks. Persists to /connections.json as an array. + * + * Secret fields (password / keyContent / passphrase) are persisted only + * encrypted via Electron safeStorage (base64 blob in `*_enc` fields). The + * renderer contracts (`SshConnection`) never contain a secret: `*_enc` fields + * are stripped from the returned objects and replaced with `savedAuth` flags. + * + * When safeStorage is unavailable (e.g. during headless dev) the plaintext + * secret is base64'd and prefixed with `plain:` so the record stays + * serialisable — a dev-only fallback surfaced via console.warn. + */ + +import { safeStorage } from 'electron' +import { randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections' + +const ENC_SUFFIX = '_enc' +const PLAIN_PREFIX = 'plain:' +const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const + +/** Internal persisted record: public fields + encrypted secret fields. */ +interface StoredConnection { + id: string + name: string + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + askPasswordAtConnect: boolean + askPassphraseAtConnect: boolean + keyPath?: string + keepaliveIntervalSec: number + createdAt: number + lastConnectedAt?: number + password_enc?: string + keyContent_enc?: string + passphrase_enc?: string +} + +/** Public fields of SshConnectionInput reflected onto a StoredConnection. */ +const PUBLIC_KEYS = [ + 'name', + 'group', + 'host', + 'port', + 'username', + 'auth', + 'askPasswordAtConnect', + 'askPassphraseAtConnect', + 'keyPath', + 'keepaliveIntervalSec' +] as const satisfies readonly (keyof Omit)[] + +function encrypt(plain: string): string { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(plain).toString('base64') + } + // Dev fallback: no OS keychain, store a reversible base64 marker so the + // field still round-trips through JSON. + console.warn( + '[connections] safeStorage unavailable - storing plain: prefixed base64 secret (dev only)' + ) + return PLAIN_PREFIX + Buffer.from(plain, 'utf8').toString('base64') +} + +function decrypt(stored: string | undefined): string | undefined { + if (typeof stored !== 'string' || stored.length === 0) return undefined + if (stored.startsWith(PLAIN_PREFIX)) { + return Buffer.from(stored.slice(PLAIN_PREFIX.length), 'base64').toString('utf8') + } + if (safeStorage.isEncryptionAvailable()) { + try { + return safeStorage.decryptString(Buffer.from(stored, 'base64')) + } catch { + return undefined + } + } + return undefined +} + +/** Strip secret fields from an internal record into the renderer contract. */ +function toPublic(stored: StoredConnection): SshConnection { + return { + id: stored.id, + name: stored.name, + group: stored.group, + host: stored.host, + port: stored.port, + username: stored.username, + auth: stored.auth, + askPasswordAtConnect: stored.askPasswordAtConnect, + askPassphraseAtConnect: stored.askPassphraseAtConnect, + keyPath: stored.keyPath, + keepaliveIntervalSec: stored.keepaliveIntervalSec, + createdAt: stored.createdAt, + lastConnectedAt: stored.lastConnectedAt, + savedAuth: { + hasPassword: stored.password_enc !== undefined && stored.password_enc.length > 0, + hasKeyContent: stored.keyContent_enc !== undefined && stored.keyContent_enc.length > 0, + hasPassphrase: stored.passphrase_enc !== undefined && stored.passphrase_enc.length > 0 + } + } +} + +export class ConnectionsStore { + constructor(private readonly filePath: string) {} + + private load(): StoredConnection[] { + try { + const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (Array.isArray(raw)) { + return raw.filter( + (x): x is StoredConnection => + x !== null && + typeof x === 'object' && + typeof (x as StoredConnection).id === 'string' && + typeof (x as StoredConnection).host === 'string' + ) + } + } catch { + // missing / corrupted file -> start fresh + } + return [] + } + + private save(list: StoredConnection[]): void { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8') + } + + listConnections(): SshConnection[] { + return this.load().map(toPublic) + } + + /** Decrypt a stored secret for a connection (undefined when absent). */ + + /** Decrypt a stored secret for a connection (undefined when absent). */ + getSecret( + conn: SshConnection, + field: 'password' | 'keyContent' | 'passphrase' + ): string | undefined { + const encField = `${field}${ENC_SUFFIX}` as keyof StoredConnection + const stored = this.load().find((c) => c.id === conn.id) + return stored ? decrypt(stored[encField] as string | undefined) : undefined + } + + saveConnection(input: SshConnectionInput): SshConnection { + const list = this.load() + let stored: StoredConnection | undefined + + if (typeof input.id === 'string' && input.id.length > 0) { + stored = list.find((c) => c.id === input.id) + } + + if (stored) { + // Update in place; omitted secrets keep their previously stored value. + const target = stored as unknown as Record + const source = input as unknown as Record + for (const key of PUBLIC_KEYS) { + target[key] = source[key] + } + } else { + stored = { + id: input.id && input.id.length > 0 ? input.id : randomUUID(), + name: input.name, + group: input.group, + host: input.host, + port: input.port, + username: input.username, + auth: input.auth, + askPasswordAtConnect: input.askPasswordAtConnect, + askPassphraseAtConnect: input.askPassphraseAtConnect, + keyPath: input.keyPath, + keepaliveIntervalSec: input.keepaliveIntervalSec, + createdAt: Date.now() + } + list.push(stored) + } + + // Encrypt new secret values; absent secrets leave the previous value intact. + const target = stored as unknown as Record + for (const key of SECRET_KEYS) { + const value = input[key] + if (value === undefined) continue + if (value === '') { + delete target[`${key}${ENC_SUFFIX}`] + } else { + target[`${key}${ENC_SUFFIX}`] = encrypt(value) + } + } + + this.save(list) + return toPublic(stored) + } + + deleteConnection(id: string): void { + const list = this.load() + const next = list.filter((c) => c.id !== id) + if (next.length < list.length) this.save(next) + } + + /** Mark a connection as recently used. */ + touch(id: string, at = Date.now()): void { + const list = this.load() + const conn = list.find((c) => c.id === id) + if (!conn) return + conn.lastConnectedAt = at + this.save(list) + } +} + +/** Default location: /connections.json */ +export function defaultConnectionsPath(userDataPath: string): string { + return `${userDataPath}/connections.json` +} \ No newline at end of file diff --git a/src/main/globalShortcuts.ts b/src/main/globalShortcuts.ts new file mode 100644 index 0000000..a05e68a --- /dev/null +++ b/src/main/globalShortcuts.ts @@ -0,0 +1,37 @@ +import { BrowserWindow, globalShortcut } from 'electron' + +/** + * Register the global show/hide toggle for the main window. + * + * - accelerator '' / undefined => disabled (no global key bound). + * - Passing an invalid accelerator string makes Electron's register() throw; + * we swallow that here so a bad user-supplied value never crashes the app. + * - register() returning false means the accelerator is already taken by + * another application; we only log a warning and keep running. + */ +export function applyGlobalShortcut(accelerator: string | undefined): void { + // No other global shortcuts are registered anywhere in this app, so a full + // unregister is safe and guarantees a clean re-register on every change. + globalShortcut.unregisterAll() + if (!accelerator) return + + const handler = (): void => { + const win = BrowserWindow.getAllWindows()[0] + if (!win || win.isDestroyed()) return + if (win.isVisible() && win.isFocused()) { + win.hide() + } else { + win.show() + win.focus() + } + } + + try { + const ok = globalShortcut.register(accelerator, handler) + if (!ok) { + console.warn(`[global-shortcut] register "${accelerator}" failed (conflict with another app)`) + } + } catch (err) { + console.warn(`[global-shortcut] register "${accelerator}" threw`, err) + } +} \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..654a112 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,122 @@ +import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron' +import { existsSync } from 'fs' +import { join } from 'path' +import { registerIpc } from './ipc' +import { killAllPtys } from './pty' +import { applyStartupSystemSettings, loadSettings } from './settingsStore' +import { initTray, markQuitting, onMainWindowClose } from './tray' +import { configureAutoUpdater } from './updater' +import { applyWindowChrome } from './windowChrome' +import { getThemeById } from '@shared/theme' + +/** Re-create the main window (tray restore path after all windows are gone). */ +function showOrCreate(): void { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + else { + const win = BrowserWindow.getAllWindows()[0] + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } +} + +// Single instance: a second launch just surfaces the existing window (pulls +// it out of the tray if hidden there) instead of starting another process. +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', () => showOrCreate()) +} + +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. + const devIcon = join(__dirname, '../../build/icon.png') + const settings = loadSettings() + const theme = getThemeById(settings.terminal.themeId, settings.customThemes) + const win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 720, + minHeight: 480, + show: false, + backgroundColor: theme.colors.background, + autoHideMenuBar: true, + // Custom title bar: the renderer draws the strip, Windows draws the + // min/max/close overlay — colors follow the active terminal theme. + ...(process.platform === 'win32' + ? { + titleBarStyle: 'hidden' as const, + titleBarOverlay: { + color: theme.colors.background, + symbolColor: theme.colors.foreground, + height: 36 + } + } + : {}), + ...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + + win.on('ready-to-show', () => win.show()) + + // Close button → tray / exit per the closeAction setting (ask by default). + win.on('close', (e) => { + void onMainWindowClose(win, e, showOrCreate) + }) + + // Surface renderer console errors in the dev terminal for diagnosis. + win.webContents.on('console-message', (event) => { + if (event.level === 'error') { + console.error(`[renderer] ${event.message}`) + } + }) + + win.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + const devUrl = process.env['ELECTRON_RENDERER_URL'] + if (devUrl) { + win.loadURL(devUrl) + } else { + win.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +process.on('unhandledRejection', (reason) => { + console.error('[main] unhandledRejection:', reason) +}) +process.on('uncaughtException', (err) => { + console.error('[main] uncaughtException:', err) +}) + +app.whenReady().then(() => { + registerIpc() + // OS-level effects (login item, sleep blocker) must apply even if the + // settings dialog is never opened this run. + applyStartupSystemSettings(loadSettings()) + createWindow() + initTray(showOrCreate) + configureAutoUpdater() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('before-quit', () => { + // Let window 'close' events pass through so teardown completes. + markQuitting() + killAllPtys() + globalShortcut.unregisterAll() +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..235c4d0 --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,174 @@ +import { app, ipcMain } from 'electron' +import fontList from 'font-list' +import { homedir } from 'os' +import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc' +import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' +import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' +import { startPolling, stopPolling } from './sysinfo' +import { registerSettingsIpc } from './settingsStore' +import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' +import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts' +import { resolveHostKey } from './ssh' +import { + listRemote, + mkdirRemote, + renameRemote, + deleteRemote, + chmodRemote, + chownRemote, + uploadRemote, + downloadRemote, + cancelTransfer, + pickFiles, + pickDirectory +} from './sftp' +import { broadcast } from './broadcast' +import { CommandsStore, defaultCommandsPath } from './commands' +import type { CommandItem } from '../shared/commands' +import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty' +import { respondZmodem } from './zmodem' +import type { ZmodemResponse } from '../shared/ipc' + +let commandsStore: CommandsStore | undefined + +/** M5: inject a custom command store (tests) or default to userData-backed. */ +export function setCommandsStore(store: CommandsStore): void { + commandsStore = store +} + +export function getCommandsStore(): CommandsStore { + if (!commandsStore) { + commandsStore = new CommandsStore(defaultCommandsPath(app.getPath('userData'))) + } + return commandsStore +} + +export function registerIpc(): void { + const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData'))) + const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData'))) + const cmds = getCommandsStore() + + // Route PTY output into the session log (M5). logWrite decides whether a + // session is actively logging; logStop finalizes on session close / kill. + registerLogHooks( + (id, data) => cmds.logWrite(id, data), + (id) => cmds.logStop(id) + ) + + // Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays + // Electron-free). + configureSessionRuntime({ + broadcast: (channel, ...args) => broadcast(channel, ...args), + getConnection: (connectionId) => { + const found = connectionsStore.listConnections().find((c) => c.id === connectionId) + if (!found) throw new Error(`连接书签不存在 (${connectionId})`) + return found + }, + getSecret: (conn, field) => connectionsStore.getSecret(conn, field), + touch: (id) => connectionsStore.touch(id), + knownHosts: { + check: (host, port, key) => knownHostsStore.check(host, port, key), + accept: (host, port, key, fingerprint) => knownHostsStore.accept(host, port, key, fingerprint) + }, + promptHostKey: (prompt) => broadcast(Ipc.HOSTKEY_PROMPT, prompt) + }) + + ipcMain.handle( + Ipc.APP_INFO, + (): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() }) + ) + + ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts)) + ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts)) + ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id)) + ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data)) + ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) => + resizePty(id, cols, rows) + ) + ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id)) + + // ---- zmodem (M6: main-process engine over ssh sessions) ---- + // ZMODEM_OFFER / ZMODEM_DONE are broadcasts (main -> renderer); this is the + // renderer's answer, forwarded to the engine (which routes on resp.id). + ipcMain.on(Ipc.ZMODEM_RESPOND, (_event, resp: ZmodemResponse) => respondZmodem(resp)) + + // ---- sysinfo (M3: remote hardware monitoring, ssh sessions only) ---- + ipcMain.on(Ipc.SYSINFO_START, (_event, id: string) => startPolling(id)) + ipcMain.on(Ipc.SYSINFO_STOP, (_event, id: string) => stopPolling(id)) + + // ---- ssh connections (bookmarks) ---- + ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections()) + ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection => + connectionsStore.saveConnection(input) + ) + ipcMain.handle(Ipc.CONNECTIONS_DELETE, (_event, id: string) => { + connectionsStore.deleteConnection(id) + }) + + // HOSTKEY_PROMPT is broadcast; the renderer answers here (send, not handle). + ipcMain.on(Ipc.HOSTKEY_RESPOND, (_event, promptId: string, action: HostKeyAction) => { + resolveHostKey(promptId, action) + }) + + // ---- sftp (M4) ---- + ipcMain.handle(Ipc.SFTP_LIST, (_e, sessionId: string, dir: string) => listRemote(sessionId, dir)) + ipcMain.handle(Ipc.SFTP_MKDIR, (_e, sessionId: string, dir: string, name: string) => + mkdirRemote(sessionId, dir, name) + ) + ipcMain.handle(Ipc.SFTP_RENAME, (_e, sessionId: string, from: string, to: string) => + renameRemote(sessionId, from, to) + ) + ipcMain.handle(Ipc.SFTP_DELETE, (_e, sessionId: string, paths: string[]) => + deleteRemote(sessionId, paths) + ) + ipcMain.handle(Ipc.SFTP_CHMOD, (_e, sessionId: string, path: string, mode: string) => + chmodRemote(sessionId, path, mode) + ) + ipcMain.handle(Ipc.SFTP_CHOWN, (_e, sessionId: string, path: string, uid: number, gid: number) => + chownRemote(sessionId, path, uid, gid) + ) + ipcMain.handle(Ipc.SFTP_UPLOAD, (_e, sessionId: string, localPaths: string[], remoteDir: string) => + uploadRemote(sessionId, localPaths, remoteDir, broadcast) + ) + ipcMain.handle( + Ipc.SFTP_DOWNLOAD, + (_e, sessionId: string, remotePaths: string[], localDir: string) => + downloadRemote(sessionId, remotePaths, localDir, broadcast) + ) + ipcMain.on(Ipc.TRANSFER_CANCEL, (_e, transferId: string) => cancelTransfer(transferId)) + ipcMain.handle(Ipc.DIALOG_PICK_FILES, () => pickFiles()) + ipcMain.handle(Ipc.DIALOG_PICK_DIR, () => pickDirectory()) + + ipcMain.handle(Ipc.FONTS_LIST, async () => { + try { + return await fontList.getFonts({ disableQuoting: true }) + } catch { + return [] + } + }) + + ipcMain.handle(Ipc.LAYOUTS_LIST, (): LayoutMeta[] => listLayouts()) + ipcMain.handle(Ipc.LAYOUTS_GET, (_event, id: string) => getLayout(id)) + ipcMain.handle(Ipc.LAYOUTS_SAVE, (_event, meta: LayoutMeta, json: string) => + saveLayout(meta, json) + ) + ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id)) + + // ---- command history / library + session logs (M5) ---- + ipcMain.handle(Ipc.COMMANDS_RECORD, (_event, cmd: string) => cmds.recordCommand(cmd)) + ipcMain.handle(Ipc.COMMANDS_HISTORY_LIST, () => cmds.listHistory()) + ipcMain.handle(Ipc.COMMANDS_HISTORY_CLEAR, () => cmds.clearHistory()) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_LIST, () => cmds.listLibrary()) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_SAVE, (_event, item: CommandItem) => + cmds.saveLibraryItem(item) + ) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_DELETE, (_event, id: string) => + cmds.deleteLibraryItem(id) + ) + ipcMain.handle(Ipc.LOG_START, (_event, sessionId: string) => cmds.logStart(sessionId)) + ipcMain.handle(Ipc.LOG_STOP, (_event, sessionId: string) => cmds.logStop(sessionId)) + ipcMain.handle(Ipc.LOG_LIST, () => cmds.listSessionLogs()) + ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir()) + + registerSettingsIpc() +} \ No newline at end of file diff --git a/src/main/knownHosts.ts b/src/main/knownHosts.ts new file mode 100644 index 0000000..1852eb2 --- /dev/null +++ b/src/main/knownHosts.ts @@ -0,0 +1,111 @@ +/** + * Host key pinning store. Persists to /ssh_known_hosts.json. + * No Electron imports here: the file location is injected so the module can be + * reused by the loopback test harness (and any future main-process unit tests). + */ + +import { createHash, randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname } from 'path' + +export interface KnownHostEntry { + /** uuid; addedAt is split out so fingerprint clash updates can be precise */ + id: string + host: string + port: number + /** raw ssh wire-format host key (base64, no padding) */ + keyBase64: string + /** 'SHA256:' + base64(sha256(key)) without padding, OpenSSH style */ + fingerprint: string + addedAt: number +} + +export interface KnownHostsStoreShape { + version: 1 + entries: KnownHostEntry[] +} + +export type HostKeyCheckResult = + | { status: 'match'; entry: KnownHostEntry } + | { status: 'new' } + | { status: 'changed'; stored: KnownHostEntry } + +/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */ +export function fingerprintOf(key: Buffer): string { + return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') +} + +export class KnownHostsStore { + constructor(private readonly filePath: string) {} + + private load(): KnownHostsStoreShape { + try { + const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (raw !== null && typeof raw === 'object') { + const shape = raw as Partial + 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' + ) + } + } + } + } catch { + // missing / corrupted file -> start fresh + } + return { version: 1, entries: [] } + } + + private save(shape: KnownHostsStoreShape): void { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8') + } + + /** + * Compare the live host key against the stored entry for (host, port). + */ + check(host: string, port: number, key: Buffer): HostKeyCheckResult { + const entries = this.load().entries.filter((e) => e.host === host && e.port === port) + if (entries.length === 0) return { status: 'new' } + + const fingerprint = fingerprintOf(key) + const keyBase64 = key.toString('base64') + const stored = entries[0] + if (stored.keyBase64 === keyBase64 || stored.fingerprint === fingerprint) { + return { status: 'match', entry: stored } + } + return { status: 'changed', stored } + } + + /** Record a new host key (accept of a 'new' or 'changed' prompt). */ + accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry { + const shape = this.load() + const entry: KnownHostEntry = { + id: randomUUID(), + host, + port, + keyBase64: key.toString('base64'), + fingerprint, + addedAt: Date.now() + } + shape.entries = shape.entries.filter((e) => !(e.host === host && e.port === port)) + shape.entries.push(entry) + this.save(shape) + return entry + } + + list(): KnownHostEntry[] { + return [...this.load().entries] + } +} + +/** Default location: /ssh_known_hosts.json */ +export function defaultKnownHostsPath(userDataPath: string): string { + return `${userDataPath}/ssh_known_hosts.json` +} \ No newline at end of file diff --git a/src/main/layouts.ts b/src/main/layouts.ts new file mode 100644 index 0000000..73570fb --- /dev/null +++ b/src/main/layouts.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import type { LayoutMeta } from '../shared/ipc' + +const layoutsDir = (): string => { + const dir = join(app.getPath('userData'), 'layouts') + mkdirSync(dir, { recursive: true }) + return dir +} + +function layoutPath(id: string): string { + return join(layoutsDir(), `${id}.json`) +} + +interface LayoutFile { + id: string + name: string + createdAt: number + json: string +} + +export function listLayouts(): LayoutMeta[] { + const dir = layoutsDir() + const metas: LayoutMeta[] = [] + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.json')) continue + try { + const file = JSON.parse(readFileSync(join(dir, entry), 'utf8')) as Partial + const id = typeof file.id === 'string' ? file.id : entry.slice(0, -'.json'.length) + const name = typeof file.name === 'string' ? file.name : id + const createdAt = typeof file.createdAt === 'number' ? file.createdAt : 0 + metas.push({ id, name, createdAt }) + } catch { + // skip corrupted file + } + } + return metas.sort((a, b) => b.createdAt - a.createdAt) +} + +export function getLayout(id: string): string | null { + try { + const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial + return typeof file.json === 'string' ? file.json : null + } catch { + return null + } +} + +export function saveLayout(meta: LayoutMeta, json: string): void { + const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json } + writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8') +} + +export function deleteLayout(id: string): void { + try { + rmSync(layoutPath(id)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } +} \ No newline at end of file diff --git a/src/main/pty.ts b/src/main/pty.ts new file mode 100644 index 0000000..0997a11 --- /dev/null +++ b/src/main/pty.ts @@ -0,0 +1,346 @@ +import { spawn, type IPty } from '@lydell/node-pty' +import { randomUUID } from 'crypto' +import { homedir } from 'os' +import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc' +import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections' +import { broadcast } from './broadcast' +import { connectSsh, type SshSessionHandle } from './ssh' +import type { HostKeyCheckResult } from './knownHosts' +import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo' +import { registerSftpClientProvider, closeSftp } from './sftp' +import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem' + +// 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' + +/** + * M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the + * sysinfo injection pattern so pty.ts routes PTY data into the session log + * without importing the store directly. `log` runs on every onData chunk (the + * logger itself decides whether a session is actively logging), `stop` runs on + * session close / kill to finalize the log file. + */ +type DataLogger = (id: string, data: string) => void +let dataLogger: DataLogger | undefined +let logStopper: (id: string) => void | undefined + +export function registerLogHooks(log: DataLogger, stop: (id: string) => void): void { + dataLogger = log + logStopper = stop +} + +function safeLog(id: string, data: string): void { + try { + dataLogger?.(id, data) + } catch { + // never crash the event loop + } +} + +function safeStopLog(id: string): void { + try { + logStopper?.(id) + } catch { + // never crash the event loop + } +} + +/** + * Session routing table. A session is either a local pty or an established ssh + * shell; the generic PTY_* (data plane) channels address both. PTY_DATA / + * PTY_EXIT broadcasts are identical for both kinds. + */ +type Session = { kind: 'local'; pty: IPty } | { kind: 'ssh'; ssh: SshSessionHandle } + +/** The live ssh2 client behind an ssh session, or undefined. */ +export function getSshClient(id: string): SshSessionHandle['client'] | undefined { + const session = sessions.get(id) + return session?.kind === 'ssh' ? session.ssh.client : undefined +} + +const sessions = new Map() + +/** + * Per-session replay of recent output (capped). Late subscribers — a terminal + * view mounting after the shell already printed its banner, or a panel rebound + * by template apply — read this instead of losing the head of the stream. + */ +const REPLAY_CAP = 64 * 1024 +const replayBuffers = new Map() + +function appendReplay(id: string, data: string): void { + const prev = replayBuffers.get(id) ?? '' + const next = prev.length + data.length > REPLAY_CAP + ? (prev + data).slice(prev.length + data.length - REPLAY_CAP) + : prev + data + replayBuffers.set(id, next) +} + +/** Recent output of a session ('' when unknown). */ +export function getSessionReplay(id: string): string { + return replayBuffers.get(id) ?? '' +} + +/** + * Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts + * stays free of store / known-hosts imports and the ssh service can remain + * decoupled from Electron. + */ +export interface SessionRuntimeDeps { + /** resolve a bookmark by id (throws a Chinese message when missing) */ + getConnection(connectionId: string): SshConnection + /** decrypt a stored secret for a connection */ + getSecret(conn: SshConnection, field: 'password' | 'keyContent' | 'passphrase'): string | undefined + /** mark a bookmark as recently connected */ + touch(connectionId: string): void + /** host key pinning: check against the store, record an accepted key */ + knownHosts: { + check(host: string, port: number, key: Buffer): HostKeyCheckResult + accept(host: string, port: number, key: Buffer, fingerprint: string): void + } + /** ask the renderer to decide an unknown / changed host key */ + promptHostKey(prompt: HostKeyPromptEvent): void + broadcast(channel: string, ...args: unknown[]): void +} + +let runtimeDeps: SessionRuntimeDeps | undefined + +/** Wire the real stores + renderer prompt. Called once during app startup. */ +export function configureSessionRuntime(deps: SessionRuntimeDeps): void { + runtimeDeps = deps + configureSysinfo({ + broadcast: (channel, ...args) => deps.broadcast(channel, ...args) + }) + registerSysinfoClient((id) => { + const session = sessions.get(id) + if (session?.kind === 'ssh') return session.ssh.client + return undefined + }) + registerSftpClientProvider((id) => { + const session = sessions.get(id) + if (session?.kind === 'ssh') return session.ssh.client + return undefined + }) +} + +function defaultShell(): string { + switch (process.platform) { + case 'win32': + return 'powershell.exe' + case 'darwin': + return process.env.SHELL || '/bin/zsh' + default: + return process.env.SHELL || '/bin/bash' + } +} + +export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { + const id = randomUUID() + const shell = opts.shell ?? defaultShell() + const cwd = opts.cwd ?? homedir() + // Advertise color capability + terminal identity so TUIs (e.g. kimi CLI) + // use their full-color theme instead of the degraded white/amber fallback. + const env = { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'OpenTerminal', + ...opts.env + } as Record + + const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env }) + sessions.set(id, { kind: 'local', pty }) + replayBuffers.set(id, '') + + pty.onData((data) => { + try { + appendReplay(id, data) + safeLog(id, data) + broadcast(Ipc.PTY_DATA, { id, data }) + } catch { + // never crash the event loop + } + }) + + pty.onExit(({ exitCode }) => { + try { + sessions.delete(id) + safeStopLog(id) + broadcast(Ipc.PTY_EXIT, { id, exitCode }) + } catch { + // never crash the event loop + } + }) + + return { id, shell, cwd } +} + +/** + * Open a session. `local` reuses createPty; `ssh` goes through the ssh service + * and resolves only once the shell stream is ready to stream data. + */ +export async function openSession(opts: SessionOpenOptions): Promise<{ id: string }> { + if (opts.kind === 'local') { + return { id: createPty().id } + } + + const deps = runtimeDeps + if (!deps) { + throw new Error('SSH 会话服务尚未初始化') + } + if (!opts.connectionId) { + throw new Error('SSH 会话缺少 connectionId') + } + + const conn = deps.getConnection(opts.connectionId) + const handle = await connectSsh(conn, opts.secretOverride, { + connections: { + getSecret: (c, field) => deps.getSecret(c, field), + touch: (id: string) => { + // Successful connect: record lastConnectedAt on the bookmark. + try { + deps.touch(id) + } catch { + // store write failure must not break the session + } + } + }, + knownHosts: deps.knownHosts, + broadcast: deps.broadcast, + promptHostKey: deps.promptHostKey + }) + sessions.set(handle.id, { kind: 'ssh', ssh: handle }) + + // ZMODEM (M6): attach the in-process engine to the raw ssh stream. Only ssh + // sessions are supported -- node-pty/Windows ConPTY hands out UTF-8 strings + // only and would corrupt binary frames. The sentry inspects every data chunk: + // non-ZMODEM traffic is routed back through toTerminal below; once a ZMODEM + // header is spotted the transfer suppresses the terminal data plane (and + // writePty drops user keystrokes while isZmodemActive is true). + attachZmodem(handle.id, { + broadcast: (channel, ...args) => deps.broadcast(channel, ...args), + toTerminal: (id, text) => { + appendReplay(id, text) + safeLog(id, text) + deps.broadcast(Ipc.PTY_DATA, { id, data: text }) + }, + writeStream: (id, data) => { + const s = sessions.get(id) + if (s?.kind === 'ssh') { + try { + s.ssh.stream.write(data) + } catch { + // stream may already be dead + } + } + } + }) + + handle.stream.on('data', (data: Buffer) => { + // Route through the ZMODEM sentry. It forwards non-ZMODEM bytes to + // deps.toTerminal and absorbs the transfer untouched; while a transfer is + // active the engine suppresses the normal data plane entirely. + feedZmodem(handle.id, data) + }) + + handle.stream.on('close', () => { + try { + stopPolling(handle.id) + closeSftp(handle.id) + detachZmodem(handle.id) + safeStopLog(handle.id) + sessions.delete(handle.id) + deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) + } catch { + // never crash the event loop + } + }) + + return { id: handle.id } +} + +export function writePty(id: string, data: string): void { + // While a ZMODEM transfer is active the stream carries binary frames; user + // keystrokes must be dropped (they would corrupt the transfer). + if (isZmodemActive(id)) return + const session = sessions.get(id) + if (!session) return + try { + if (session.kind === 'local') session.pty.write(data) + else session.ssh.stream.write(data) + } catch { + // session may already be dead + } +} + +export function resizePty(id: string, cols: number, rows: number): void { + const session = sessions.get(id) + if (!session) return + try { + if (session.kind === 'local') session.pty.resize(cols, rows) + // ssh2 Channel.setWindow(rows, cols, height, width) + else session.ssh.stream.setWindow(rows, cols, 0, 0) + } catch { + // session may already be dead + } +} + +export function killPty(id: string): void { + stopPolling(id) + closeSftp(id) + detachZmodem(id) + safeStopLog(id) + replayBuffers.delete(id) + const session = sessions.get(id) + if (!session) return + if (session.kind === 'ssh') { + try { + session.ssh.stream.close() + } catch { + // best effort + } + try { + session.ssh.client.end() + } catch { + // best effort + } + } else { + try { + session.pty.kill() + } catch { + // best effort + } + } + sessions.delete(id) +} + +export function killAllPtys(): void { + for (const id of sessions.keys()) { + stopPolling(id) + closeSftp(id) + detachZmodem(id) + safeStopLog(id) + const session = sessions.get(id) + if (!session) continue + if (session.kind === 'ssh') { + try { + session.ssh.stream.close() + } catch { + // best effort + } + try { + session.ssh.client.end() + } catch { + // best effort + } + } else { + try { + session.pty.kill() + } catch { + // best effort + } + } + } + sessions.clear() +} \ No newline at end of file diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts new file mode 100644 index 0000000..845d5a4 --- /dev/null +++ b/src/main/settingsStore.ts @@ -0,0 +1,177 @@ +import { app, ipcMain, powerSaveBlocker } from 'electron' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import { join } from 'path' +import { Ipc } from '../shared/ipc' +import { + DEFAULT_HIGHLIGHT_RULES, + DEFAULT_SETTINGS, + type AppSettings, + type HighlightRule, + type SystemSettings, + type TerminalSettings +} from '../shared/settings' +import type { TerminalTheme } from '../shared/theme' +import { broadcast } from './broadcast' +import { applyGlobalShortcut } from './globalShortcuts' +import { applyWindowChrome } from './windowChrome' + +const settingsPath = (): string => join(app.getPath('userData'), 'settings.json') + +const DEFAULT_SYSTEM: SystemSettings = { + launchAtLogin: false, + preventSleep: false, + globalShowHide: '', + closeAction: 'ask' +} + +const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[]) + +/** Light structural check for a persisted highlight rule. */ +function isHighlightRule(value: unknown): value is HighlightRule { + if (value === null || typeof value !== 'object') return false + const rule = value as Record + return ( + typeof rule.id === 'string' && + typeof rule.pattern === 'string' && + typeof rule.enabled === 'boolean' && + typeof rule.priority === 'number' && + rule.color !== null && + typeof rule.color === 'object' && + typeof (rule.color as { fg?: unknown }).fg === 'string' + ) +} + +function sanitizeRules(value: unknown): HighlightRule[] { + if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES + const rules = value.filter(isHighlightRule) + return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES +} + +function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { + const errors: string[] = [] + let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal } + let customThemes: unknown = DEFAULT_SETTINGS.customThemes + let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES + + if (raw !== null && typeof raw === 'object') { + const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown } + if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') { + const candidate = packageSettings.terminal as Record + const merged: Record = { ...terminal } + for (const key of TERMINAL_KEYS) { + const keyType = typeof DEFAULT_SETTINGS.terminal[key] + if (candidate[key] !== undefined && typeof candidate[key] === keyType) { + merged[key] = candidate[key] + } else if (candidate[key] !== undefined) { + errors.push(`terminal.${key}`) + } + } + terminal = merged as unknown as TerminalSettings + } + if (Array.isArray(packageSettings.customThemes)) { + customThemes = packageSettings.customThemes + } + if (packageSettings.highlightRules !== undefined) { + highlightRules = packageSettings.highlightRules + } + } + + let system: unknown = DEFAULT_SYSTEM + if (raw !== null && typeof raw === 'object') { + const sys = (raw as { system?: unknown }).system + if (sys !== null && typeof sys === 'object') { + const candidate = sys as Record + const globalShowHide = + typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : '' + const closeAction = + candidate.closeAction === 'tray' || candidate.closeAction === 'exit' + ? candidate.closeAction + : 'ask' + system = { + launchAtLogin: candidate.launchAtLogin === true, + preventSleep: candidate.preventSleep === true, + globalShowHide, + closeAction + } + } + } + + const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : [] + + return { + settings: { + terminal, + customThemes: themes, + highlightRules: sanitizeRules(highlightRules), + system: system as SystemSettings + }, + errors + } +} + +// ---- real system side effects ------------------------------------------------ + +let sleepBlockerId: number | undefined + +/** Apply OS-level effects of the system settings (login item, sleep blocker). */ +function applySystemSettings(system: SystemSettings): void { + try { + app.setLoginItemSettings({ openAtLogin: system.launchAtLogin }) + } catch (err) { + console.error('[settings] setLoginItemSettings failed', err) + } + if (system.preventSleep && sleepBlockerId === undefined) { + sleepBlockerId = powerSaveBlocker.start('prevent-app-suspension') + } else if (!system.preventSleep && sleepBlockerId !== undefined) { + powerSaveBlocker.stop(sleepBlockerId) + sleepBlockerId = undefined + } + // Re-register the global show/hide shortcut whenever system settings change. + applyGlobalShortcut(system.globalShowHide) +} + +/** Apply system side effects for the settings loaded at startup. */ +export function applyStartupSystemSettings(settings: AppSettings): void { + applySystemSettings(settings.system) +} + +export function loadSettings(): AppSettings { + try { + const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8')) + const { settings } = deepMerge(raw) + return settings + } catch { + return { + terminal: { ...DEFAULT_SETTINGS.terminal }, + customThemes: [...DEFAULT_SETTINGS.customThemes], + highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })), + system: { ...DEFAULT_SYSTEM } + } + } +} + +export function saveSettings(next: AppSettings): AppSettings { + const merged = deepMerge({ + terminal: next.terminal, + customThemes: next.customThemes, + highlightRules: next.highlightRules, + system: next.system ?? DEFAULT_SYSTEM + }).settings + applySystemSettings(merged.system) + applyWindowChrome(merged) + const pretty = JSON.stringify(merged, null, 2) + + mkdirSync(app.getPath('userData'), { recursive: true }) + const path = settingsPath() + const tmp = `${path}.tmp` + writeFileSync(tmp, pretty, 'utf8') + renameSync(tmp, path) + + broadcast(Ipc.SETTINGS_CHANGED, merged) + return merged +} + +export function registerSettingsIpc(): void { + ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings()) + ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next)) +} \ No newline at end of file diff --git a/src/main/sftp.ts b/src/main/sftp.ts new file mode 100644 index 0000000..07fd3bf --- /dev/null +++ b/src/main/sftp.ts @@ -0,0 +1,424 @@ +import { dialog } from 'electron' +import type { Client, SFTPWrapper } from 'ssh2' +import { randomUUID } from 'crypto' +import { createWriteStream, promises as fsp } from 'fs' +import { basename, join } from 'path' +import { Ipc } from '../shared/ipc' +import type { SftpEntry, TransferProgressEvent } from '../shared/sftp' + +/** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */ +let clientProvider: ((id: string) => Client | undefined) | undefined + +export function registerSftpClientProvider(provider: (id: string) => Client | undefined): void { + clientProvider = provider +} + +function p(fn: (cb: (err: Error | null, res: T) => void) => void): Promise { + return new Promise((resolve, reject) => { + fn((err, res) => (err ? reject(err) : resolve(res))) + }) +} + +/** For ssh2 calls whose callback only yields an error. */ +function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise { + return new Promise((resolve, reject) => { + fn(err => (err ? reject(err) : resolve())) + }) +} + +type StatLike = { isDirectory(): boolean; size: number; mtime: number; mode: number; uid: number; gid: number } + +function lstat(sftp: SFTPWrapper, path: string): Promise { + return p(cb => sftp.lstat(path, cb)) +} + +/** "drwxr-xr-x"-style string from a numeric POSIX mode (best effort). */ +export function formatMode(mode: number): string { + const kind = (mode & 0o170000) === 0o040000 ? 'd' : '-' + const groups: [number, string][] = [ + [0o400, 'r'], [0o200, 'w'], [0o100, 'x'], + [0o040, 'r'], [0o020, 'w'], [0o010, 'x'], + [0o004, 'r'], [0o002, 'w'], [0o001, 'x'] + ] + const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-')).join('') + return kind + perm +} + +/** + * One SFTP channel PER SESSION, cached. Strict sshd builds (MaxSessions 2-3, + * e.g. hardened cloud images) refuse extra channels and drop the whole + * connection when every operation opens its own subsystem. + */ +const sftpCache = new Map() + +async function sftpOf(sessionId: string): Promise { + const cached = sftpCache.get(sessionId) + if (cached) return cached + const client = clientProvider?.(sessionId) + if (!client) { + console.error(`[sftp-debug] provider=${typeof clientProvider} sessionId=${sessionId} cacheSize=${sftpCache.size}`) + throw new Error('SSH 会话不存在或已断开') + } + const sftp = await new Promise((resolve, reject) => { + client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_))) + }) + sftpCache.set(sessionId, sftp) + return sftp +} + +function evictSftp(sessionId: string): void { + sftpCache.delete(sessionId) +} + +/** + * Server-side SFTP failures (Failure / permission denied / no such file / + * already exists) mean the CHANNEL IS ALIVE — retrying them would just open + * another subsystem channel, which strict sshd (MaxSessions 2) punishes by + * dropping the whole connection. Only transport-level death retries. + */ +function isTransportError(err: unknown): boolean { + const msg = (err as Error | undefined)?.message ?? '' + return /not connected|会话不存在|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg) +} + +/** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */ +async function withSftp(sessionId: string, fn: (sftp: SFTPWrapper) => Promise): Promise { + try { + return await fn(await sftpOf(sessionId)) + } catch (err) { + if (!isTransportError(err)) throw err + evictSftp(sessionId) + return fn(await sftpOf(sessionId)) + } +} + +/** Drop the cached channel when the session is gone. */ +export function closeSftp(sessionId: string): void { + evictSftp(sessionId) +} + +const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) + +export function listRemote(sessionId: string, dir: string): Promise { + return withSftp(sessionId, async sftp => { + const raw = await new Promise>((resolve, reject) => { + sftp.readdir(dir, (err, names) => (err ? reject(err) : resolve(names))) + }) + // ssh2 readdir yields plain strings OR {filename} objects depending on version/options + const names = raw.map(n => (typeof n === 'string' ? n : n.filename)) + const entries: SftpEntry[] = [] + const queue = [...names] + const base = dir.replace(/\/+$/, '') || '/' + const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => { + for (;;) { + const name = queue.shift() + if (name === undefined) return + const path = `${base}/${name}` + try { + const st = await lstat(sftp, path) + entries.push({ + name, path, isDir: st.isDirectory(), size: st.size, mtime: st.mtime * 1000, + uid: st.uid, gid: st.gid, mode: formatMode(st.mode) + }) + } catch { + entries.push({ name, path, isDir: false, size: 0, mtime: 0 }) + } + } + }) + await Promise.all(workers) + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + return entries + }) +} + +export function mkdirRemote(sessionId: string, dir: string, name: string): Promise { + return withSftp(sessionId, sftp => { + const base = dir.replace(/\/+$/, '') || '/' + return pVoid(cb => sftp.mkdir(`${base}/${name}`, cb)) + }) +} + +export function renameRemote(sessionId: string, from: string, to: string): Promise { + return withSftp(sessionId, sftp => pVoid(cb => sftp.rename(from, to, cb))) +} + +async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): Promise { + if (!isDir) { + await pVoid(cb => sftp.unlink(path, cb)) + return + } + const raw = await new Promise>((resolve, reject) => { + sftp.readdir(path, (err, names) => (err ? reject(err) : resolve(names))) + }) + const names = raw.map(n => (typeof n === 'string' ? n : n.filename)) + const base = path.replace(/\/+$/, '') || '/' + for (const name of names) { + const child = `${base}/${name}` + let childIsDir = false + try { + childIsDir = (await lstat(sftp, child)).isDirectory() + } catch { + // unreadable child — fall through to unlink + } + await deleteRecursive(sftp, child, childIsDir) + } + await pVoid(cb => sftp.rmdir(path, cb)) +} + +export function deleteRemote(sessionId: string, paths: string[]): Promise { + return withSftp(sessionId, async sftp => { + const failures: string[] = [] + for (const path of paths) { + try { + let isDir = false + try { + isDir = (await lstat(sftp, path)).isDirectory() + } catch { + // stat failed — fall through to unlink + } + await deleteRecursive(sftp, path, isDir) + } catch (err) { + failures.push(`${path}: ${(err as Error).message}`) + } + } + if (paths.length > 0 && failures.length === paths.length) { + throw new Error(`删除失败: ${failures.join('; ')}`) + } + }) +} + +/** mode = octal string, e.g. "755" or "0644". */ +/** + * chmod/chown run over an exec channel: the JD test server's sftp subsystem + * accepts SETSTAT but silently ignores it, while shell chmod/chown work. + */ +export function chmodRemote(sessionId: string, path: string, mode: string): Promise { + if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`) + return execQuiet(sessionId, `chmod ${mode} ${JSON.stringify(path)}`) +} + +export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise { + return execQuiet(sessionId, `chown ${uid}:${gid} ${JSON.stringify(path)}`) +} + +/** Run a command on the session's shell channel and wait for it to finish. */ +function execQuiet(sessionId: string, cmd: string): Promise { + const client = clientProvider?.(sessionId) + if (!client) throw new Error('SSH 会话不存在或已断开') + return new Promise((resolve, reject) => { + client.exec(cmd, (err, stream) => { + if (err) { + reject(err) + return + } + let stderr = '' + stream.on('data', () => undefined) + stream.stderr?.on('data', (d: Buffer) => { + stderr += d.toString('utf8') + }) + stream.on('close', (code: number) => { + if (code) reject(new Error(stderr || `命令退出码 ${code}`)) + else resolve() + }) + }) + }) +} + +// ---- transfers ---------------------------------------------------------------- +// +// Low-level positional read/write over an SFTP file handle. The JD cloud sshd +// ACKs write requests LAZILY — awaiting each ack deadlocks (the ack only +// flushes when more requests arrive) — so writes keep a bounded pipeline in +// flight and the remote handle is closed with a stall guard at the end. +// Reads respond normally, so downloads stay sequential. + +interface ActiveTransfer { + kind: 'upload' | 'download' + cancelled: boolean +} + +const activeTransfers = new Map() + +export function cancelTransfer(transferId: string): void { + const t = activeTransfers.get(transferId) + if (t) t.cancelled = true +} + +type Broadcast = (channel: string, payload: unknown) => void +const CHUNK = 256 * 1024 +const UPLOAD_WINDOW = 64 + +export function uploadRemote( + sessionId: string, + localPaths: string[], + remoteDir: string, + broadcast: Broadcast +): Promise { + return withSftp(sessionId, async sftp => { + const id = randomUUID() + const transfer: ActiveTransfer = { kind: 'upload', cancelled: false } + activeTransfers.set(id, transfer) + const emit = (e: TransferProgressEvent): void => broadcast(Ipc.TRANSFER_PROGRESS, e) + + void (async () => { + try { + for (const local of localPaths) { + if (transfer.cancelled) break + const name = basename(local) + const size = (await fsp.stat(local)).size + const remote = `${remoteDir.replace(/\/+$/, '')}/${name}` + const handle = await p(cb => sftp.open(remote, 'w', cb)) + const inflight = new Set>() + let firstError: Error | undefined + try { + const localHandle = await fsp.open(local, 'r') + try { + let pos = 0 + let lastEmit = 0 + const buf = Buffer.alloc(CHUNK) + for (;;) { + if (transfer.cancelled) throw new Error('已取消') + if (firstError) throw firstError + while (inflight.size >= UPLOAD_WINDOW) { + await Promise.race(inflight) + if (firstError) throw firstError + } + const { bytesRead } = await localHandle.read(buf, 0, CHUNK, pos) + if (bytesRead === 0) break + const offset = pos + pos += bytesRead + const now = Date.now() + if (now - lastEmit > 150 || pos === size) { + lastEmit = now + emit({ transferId: id, kind: 'upload', state: 'running', file: name, bytes: pos, totalBytes: size }) + } + const pr = pVoid(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb)) + inflight.add( + pr.catch((err: Error) => { + firstError = firstError ?? err + }) + ) + } + // NOTE: do NOT await the write acks before close — this server + // acks lazily; close flushes and commits everything server-side. + } finally { + await localHandle.close() + } + await Promise.race([ + pVoid(cb => sftp.close(handle, cb)), + new Promise((r) => setTimeout(r, 10_000)) + ]) + await Promise.allSettled(inflight) + if (firstError) throw firstError + } catch (err) { + await pVoid(cb => sftp.unlink(remote, cb)).catch(() => undefined) + throw err + } + if (transfer.cancelled) { + await pVoid(cb => sftp.unlink(remote, cb)).catch(() => undefined) + emit({ transferId: id, kind: 'upload', state: 'cancelled', file: name, bytes: 0, totalBytes: size }) + return + } + } + emit({ transferId: id, kind: 'upload', state: 'done', file: '', bytes: 0, totalBytes: 0 }) + } catch (err) { + emit({ + transferId: id, kind: 'upload', + state: transfer.cancelled ? 'cancelled' : 'error', + file: '', bytes: 0, totalBytes: 0, + error: (err as Error).message + }) + } finally { + activeTransfers.delete(id) + closeSftp(sessionId) + } + })() + + return id + }) +} + +export function downloadRemote( + sessionId: string, + remotePaths: string[], + localDir: string, + broadcast: Broadcast +): Promise { + return withSftp(sessionId, async sftp => { + const id = randomUUID() + const transfer: ActiveTransfer = { kind: 'download', cancelled: false } + activeTransfers.set(id, transfer) + const emit = (e: TransferProgressEvent): void => broadcast(Ipc.TRANSFER_PROGRESS, e) + + void (async () => { + try { + for (const remote of remotePaths) { + if (transfer.cancelled) break + const name = basename(remote) + const { size } = await p<{ size: number }>(cb => sftp.stat(remote, cb)) + const local = join(localDir, name) + const handle = await p(cb => sftp.open(remote, 'r', cb)) + try { + const localHandle = await fsp.open(local, 'w') + try { + let pos = 0 + let lastEmit = 0 + const buf = Buffer.alloc(CHUNK) + for (;;) { + if (transfer.cancelled) throw new Error('已取消') + const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb => + sftp.read(handle, buf, 0, CHUNK, pos, cb) + ) + if (bytesRead === 0) break + await localHandle.write(buf, 0, bytesRead, pos) + pos += bytesRead + const now = Date.now() + if (now - lastEmit > 150 || pos === size) { + lastEmit = now + emit({ transferId: id, kind: 'download', state: 'running', file: name, bytes: pos, totalBytes: size }) + } + } + } finally { + await localHandle.close() + } + } catch (err) { + await fsp.rm(local, { force: true }) + throw err + } + if (transfer.cancelled) { + await fsp.rm(local, { force: true }) + emit({ transferId: id, kind: 'download', state: 'cancelled', file: name, bytes: 0, totalBytes: 0 }) + return + } + } + emit({ transferId: id, kind: 'download', state: 'done', file: '', bytes: 0, totalBytes: 0 }) + } catch (err) { + emit({ + transferId: id, kind: 'download', + state: transfer.cancelled ? 'cancelled' : 'error', + file: '', bytes: 0, totalBytes: 0, + error: (err as Error).message + }) + } finally { + activeTransfers.delete(id) + closeSftp(sessionId) + } + })() + + return id + }) +} + +/** Native file dialogs parented to the focused window. */ +export async function pickFiles(): Promise { + const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] }) + return r.filePaths +} + +export async function pickDirectory(): Promise { + const r = await dialog.showOpenDialog({ properties: ['openDirectory'] }) + return r.filePaths[0] ?? '' +} diff --git a/src/main/ssh.ts b/src/main/ssh.ts new file mode 100644 index 0000000..09260b7 --- /dev/null +++ b/src/main/ssh.ts @@ -0,0 +1,310 @@ +/** + * SSH session service (M2). + * + * Deliberately decoupled from Electron: broadcast and host-key store are + * injected (`SshServiceDeps`), so the connect / verify / shell flow can be + * exercised by the pure-node loopback harness (tests/ssh-loopback.mjs) with a + * plain ssh2 client and no Chromium runtime. + * + * Host-key verification pauses the handshake inside the `hostVerifier` + * callback: ssh2's kex suspends until `verify()` is called, which is exactly + * what we need for store-check + renderer prompt + accept-record. The 15s + * connect timeout is paused while a host-key prompt is outstanding so the + * prompt's own 30s budget governs that wait. + */ + +import type { SshConnection, SshSecretOverride } from '../shared/connections' +import { Ipc } from '../shared/ipc' +import { randomUUID } from 'crypto' +import { readFileSync } from 'fs' +import { fingerprintOf, type HostKeyCheckResult } from './knownHosts' +import type { ConnectionsStore } from './connectionsStore' +import type { Client, ClientChannel, ConnectConfig } from 'ssh2' + +export interface SshSessionHandle { + id: string + /** established ssh connection; powers the session routing in pty.ts */ + client: Client + /** open interactive shell channel (ClientChannel, a Duplex) */ + stream: ClientChannel +} + +export interface SshServiceDeps { + /** resolves saved secrets (service never writes them) */ + connections: Pick + /** host key pinning store access */ + knownHosts: { + check(host: string, port: number, key: Buffer): HostKeyCheckResult + accept(host: string, port: number, key: Buffer, fingerprint: string): void + } + /** electron-free broadcast; matches main/broadcast.ts */ + broadcast(channel: string, ...args: unknown[]): void + /** + * Ask the renderer to decide on an unknown / changed host key. The prompt + * resolves via the module-level `resolveHostKey(promptId, action)`. + */ + promptHostKey(prompt: { + promptId: string + host: string + port: number + fingerprint: string + reason: 'new' | 'changed' + }): void + timeoutMs?: { prompt: number; connect: number } +} + +// Host-key confirmation is a security decision — users may take time to +// compare fingerprints. 30s timed out on people who merely screenshot it. +const DEFAULT_TIMEOUTS = { prompt: 120_000, connect: 15_000 } + +/** + * Open an ssh session (connect + auth + open shell) for `conn`. + * + * Resolves with `{ id, client, stream }` once the shell stream is ready to + * stream data. Rejects with a human-readable Chinese Error when anything goes + * wrong before the shell is ready. + */ +export async function connectSsh( + conn: SshConnection, + secretOverride: SshSecretOverride | undefined, + deps: SshServiceDeps +): Promise { + const mod = await import('ssh2') + const Client = mod.Client + const timeouts = { ...DEFAULT_TIMEOUTS, ...deps.timeoutMs } + + const sessionId = randomUUID() + const handshake = new Client() + + let settled = false + let connectTimer: NodeJS.Timeout | undefined + let verifierErr: string | undefined + let resolvePromise!: (handle: SshSessionHandle) => void + let rejectPromise!: (err: Error) => void + + const armConnectTimer = (): void => { + if (connectTimer) clearTimeout(connectTimer) + connectTimer = setTimeout(() => { + fail(new Error(`连接超时 (${conn.host}:${conn.port})`)) + }, timeouts.connect) + } + + const fail = (err: Error): void => { + if (settled) return + settled = true + if (connectTimer) clearTimeout(connectTimer) + rejectPromise(err) + try { + handshake.destroy() + } catch { + // best effort + } + } + + // --- host key verification ------------------------------------------------ + // 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' + 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' + } + + if (status === 'match') { + verify(true) + return + } + + // Pause the connect timeout; the user's decision owns this wait. + if (connectTimer) clearTimeout(connectTimer) + + promptUser(conn.host, conn.port, fingerprint, status, timeouts.prompt, deps) + .then((accepted) => { + if (accepted) { + try { + deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint) + } catch (err) { + verifierErr = `保存主机指纹失败: ${(err as Error).message}` + verify(false) + return + } + verify(true) + } else { + verifierErr = '用户拒绝了主机指纹' + verify(false) + } + }) + .finally(() => { + // Resume the overall connect timer once the decision lands. + if (!settled) armConnectTimer() + }) + } + + return new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + + // failure paths before resolution: `fail` and the connect timer + armConnectTimer() + + handshake.on('error', (err: Error) => { + console.error(`[ssh] client error: ${err.message}`) + const message = verifierErr ?? err.message + if (!settled) { + fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`)) + return + } + // Session already established: surface as a session exit and clean up. + try { + deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 }) + } catch { + // never crash the event loop + } + try { + handshake.destroy() + } catch { + // best effort + } + }) + + handshake.on('ready', () => { + handshake.shell( + { term: 'xterm-256color', cols: 80, rows: 24 }, + (err: Error | undefined, shell: ClientChannel) => { + if (err) { + fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`)) + return + } + if (settled) { + try { + shell.end() + } catch { + // best effort + } + return + } + settled = true + if (connectTimer) clearTimeout(connectTimer) + // Successful connect: record lastConnectedAt on the bookmark. + try { + deps.connections.touch(conn.id) + } catch { + // store write failure must not break the session + } + resolvePromise({ id: sessionId, client: handshake, stream: shell }) + } + ) + }) + + const cfg: ConnectConfig = { + host: conn.host, + port: conn.port, + username: conn.username, + // SshConnection keeps it in seconds; ssh2 expects milliseconds. + keepaliveInterval: Math.round(conn.keepaliveIntervalSec * 1000), + hostVerifier + } + + // Password auth + const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password') + if (password !== undefined) cfg.password = password + + // Private key auth (keyPath takes precedence over stored keyContent) + if (conn.auth === 'privateKey') { + const keyContent = deps.connections.getSecret(conn, 'keyContent') + const privateKey: string | undefined = + conn.keyPath !== undefined && conn.keyPath.length > 0 + ? readKeyFile(conn.keyPath) + : keyContent !== undefined && keyContent.length > 0 + ? keyContent + : undefined + if (privateKey !== undefined) { + cfg.privateKey = privateKey + const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase') + if (passphrase !== undefined) cfg.passphrase = passphrase + } + } + + // Agent auth (defaults supplied only when an agent socket is reachable) + if (conn.auth === 'agent') { + const agent = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? 'pageant' : undefined) + if (agent !== undefined) cfg.agent = agent + } + + try { + handshake.connect(cfg) + } catch (err) { + // e.g. unparseable privateKey is thrown synchronously by ssh2 + fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`)) + } + }) +} + +// --- host-key prompt routing ------------------------------------------------- + +interface PendingPrompt { + resolve: (accepted: boolean) => void + timer: NodeJS.Timeout +} + +const pendingPrompts = new Map() + +/** + * Ask the renderer to approve / reject a host key. Resolves `true`/`false`. + * Times out (default 30s) -> treated as reject. + */ +function promptUser( + host: string, + port: number, + fingerprint: string, + reason: 'new' | 'changed', + timeoutMs: number, + deps: SshServiceDeps +): Promise { + const promptId = randomUUID() + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingPrompts.delete(promptId) + resolve(false) + }, timeoutMs) + + pendingPrompts.set(promptId, { resolve, timer }) + try { + deps.promptHostKey({ promptId, host, port, fingerprint, reason }) + } catch { + // broadcast failure must not hang the attempt forever + pendingPrompts.delete(promptId) + clearTimeout(timer) + resolve(false) + } + }) +} + +/** + * Route a renderer host-key decision to the matching pending prompt. + * Wired by the main process: ipcMain.on(Ipc.HOSTKEY_RESPOND, (…, promptId, action)). + */ +export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): void { + const pending = pendingPrompts.get(promptId) + if (!pending) { + console.error(`[ssh] resolveHostKey: no pending prompt ${promptId}`) + return + } + clearTimeout(pending.timer) + pendingPrompts.delete(promptId) + pending.resolve(action === 'accept') +} + +/** Read a private key file; surfaces a descriptive error on failure. */ +function readKeyFile(keyPath: string): string { + try { + return readFileSync(keyPath, 'utf8') + } catch (err) { + throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`) + } +} \ No newline at end of file diff --git a/src/main/ssh2.d.ts b/src/main/ssh2.d.ts new file mode 100644 index 0000000..37559ab --- /dev/null +++ b/src/main/ssh2.d.ts @@ -0,0 +1,137 @@ +/** + * Minimal ambient typings for the `ssh2` package (v1.17.0). + * + * ssh2 ships no type declarations and `@types/ssh2` is not installed; npm + * install is out of scope here, so this file declares exactly the surface this + * project consumes (verified against node_modules/ssh2/lib/client.js, + * Channel.js, kex.js, agent.js, server.js). Lives under src/main/, which is + * owned by this milestone. + */ + +declare module 'ssh2' { + import { Duplex } from 'stream' + import { Socket } from 'net' + + /** Duplex wrapper around an SSH channel (shell/exec). */ + export interface ClientChannel extends Duplex { + setWindow(rows: number, cols: number, height: number, width: number): void + signal(signalName: string): void + exit(statusOrSignal: number | string, coreDumped?: boolean, msg?: string): void + close(): void + stderr: import('stream').Readable + } + + export interface PseudoTtyOptions { + rows?: number + cols?: number + width?: number + height?: number + term?: string + } + + export interface ShellOptions { + rows?: number + cols?: number + width?: number + height?: number + term?: string + /** environment (name => value) requested over the session */ + env?: Record + x11?: boolean | number | Record + /** forward a local ssh-agent to the remote session */ + agentForward?: boolean + } + + export interface ConnectConfig { + host?: string + port?: number + username: string + password?: string + privateKey?: Buffer | string + passphrase?: string + /** ssh-agent socket path or a BaseAgent instance */ + agent?: string + /** forward the local agent to the remote (requires `agent`) */ + agentForward?: boolean + /** keepalive interval in milliseconds (0 disables) */ + keepaliveInterval?: number + keepaliveCountMax?: number + /** ms to wait for handshake before erroring (0 disables) */ + readyTimeout?: number + /** socket connect timeout in ms (0 disables) */ + timeout?: number + hostHash?: string + /** + * Optional host key verification. When it returns a boolean, ssh2 uses it + * synchronously; a promise-using implementer must instead call the `verify` + * callback (returning `undefined`), which defers the handshake. + */ + hostVerifier?: (key: Buffer, verify: (permitted: boolean) => void) => boolean | void + debug?: (...args: unknown[]) => void + algorithms?: Record + ident?: string | Buffer + sock?: Socket + strictVendor?: boolean + localAddress?: string + localHostname?: string + localUsername?: string + /** try keyboard-interactive auth */ + tryKeyboard?: boolean + authHandler?: unknown + forceIPv4?: boolean + forceIPv6?: boolean + } + + export class Client { + connect(cfg: ConnectConfig): this + end(): this + destroy(): this + shell(cb: (err: Error | undefined, stream: ClientChannel) => void): this + shell( + opts: PseudoTtyOptions | ShellOptions | false, + cb: (err: Error | undefined, stream: ClientChannel) => void + ): this + exec(cmd: string, cb: (err: Error | undefined, stream: ClientChannel) => void): this + /** request an SFTP channel (M4) */ + sftp(cb: (err: Error | undefined, sftp: SFTPWrapper) => void): this + on(event: 'ready' | 'close' | 'continue', listener: () => void): this + on(event: 'error', listener: (err: Error) => void): this + on(event: string, listener: (...args: never[]) => void): this + } + + /** POSIX attributes ssh2 attachments to readdir entries / returns from stat. */ + export interface SftpAttrs { + mode: number + uid: number + gid: number + size: number + mtime: number + } + + export interface SftpStats extends SftpAttrs { + isDirectory(): boolean + } + + /** Minimal SFTP surface used by src/main/sftp.ts (verified against SFTPWrapper). */ + export interface SFTPWrapper { + end?(): void + readdir( + location: string, + cb: (err: Error | null, names: Array) => void + ): void + mkdir(location: string, cb: (err: Error | null) => void): void + rmdir(location: string, cb: (err: Error | null) => void): void + unlink(location: string, cb: (err: Error | null) => void): void + rename(src: string, dest: string, cb: (err: Error | null) => void): void + setstat(location: string, attrs: { permissions?: number; uid?: number; gid?: number }, cb: (err: Error | null) => void): void + stat(location: string, cb: (err: Error | null, stats: SftpStats) => void): void + lstat(location: string, cb: (err: Error | null, stats: SftpStats) => void): void + open(location: string, flags: string, cb: (err: Error | null, handle: Buffer) => void): void + close(handle: Buffer, cb: (err: Error | null) => void): void + fstat(handle: Buffer, cb: (err: Error | null, stats: SftpStats) => void): void + write(handle: Buffer, buf: Buffer, offset: number, length: number, position: number, cb: (err: Error | null) => void): void + read(handle: Buffer, buf: Buffer, offset: number, length: number, position: number, cb: (err: Error | null, res: { bytesRead: number; buffer: Buffer }) => void): void + } + + export function createAgent(path: string): unknown +} \ No newline at end of file diff --git a/src/main/sysinfo.ts b/src/main/sysinfo.ts new file mode 100644 index 0000000..e9d0cc1 --- /dev/null +++ b/src/main/sysinfo.ts @@ -0,0 +1,415 @@ +/** + * Remote server hardware monitoring (M3): ssh sessions only. + * + * Polls a remote via an ssh2 `exec` (a single command returns CPU / memory / + * load / uptime / disk / network in one transport), parses the cat'd /proc + * files, diffs successive samples to compute usage and throughput, and + * broadcasts SYSINFO_SAMPLE to every live window. SYSINFO_META (hostname + os) + * is emitted once per session. + * + * The ssh `Client` for a session is injected by pty.ts (registerSysinfoClient); + * the broadcast sink is injected by pty.ts at runtime configure time, so this + * module stays Electron-free and testable through the same session-layer bundle + * as the ssh harness. + */ + +import { Ipc } from '../shared/ipc' +import type { Client } from 'ssh2' +import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo' + +/** One exec returns stat+mem+load+uptime, then df, then net, then host/uname. */ +const COLLECT_CMD = + 'cat /proc/stat /proc/meminfo /proc/loadavg /proc/uptime 2>/dev/null; ' + + 'echo __DF__; df -kP 2>/dev/null; ' + + 'echo __NET__; cat /proc/net/dev 2>/dev/null; ' + + 'echo __HOST__; hostname 2>/dev/null; uname -sr 2>/dev/null' + +/** Failures this many in a row before auto-stopping the poll. */ +const MAX_CONSECUTIVE_FAILS = 3 + +/** File-system types produced by `df -kP` that are not real disks. */ +const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) +// Some df versions key the type column slightly differently; 'overlay'/'shm' +// are the common container hosts. sysfs/procfs never appear in df -kP. + +/** Mirrors main/broadcast.ts but injectable so the loopback harness can capture it. */ +interface SysinfoDeps { + broadcast(channel: string, ...args: unknown[]): void +} + +let deps: SysinfoDeps | undefined + +/** Resolve an ssh `Client` for a session id (injected by pty.ts). */ +type ClientProvider = (id: string) => Client | undefined +let clientProvider: ClientProvider | undefined + +/** Wire the broadcast sink. Called once during app startup (from pty.ts). */ +export function configureSysinfo(d: SysinfoDeps): void { + deps = d +} + +/** Register the session-id -> ssh client resolver (called by pty.ts). */ +export function registerSysinfoClient(provider: ClientProvider): void { + clientProvider = provider +} + +interface Prev { + /** cpu `idle`+`iowait` ticks from the previous sample */ + idle: number + /** total ticks from the previous sample */ + total: number + /** cumulative rx/tx bytes from the previous sample */ + rx: number + tx: number +} + +interface PollState { + id: string + intervalMs: number + stopped: boolean + consecutiveFails: number + prev?: Prev + /** most recent successful sample (kept for error frames) */ + lastSample?: SysinfoSample + prevAt: number + metaSent: boolean + timer: NodeJS.Timeout +} + +const polls = new Map() + +/** + * Start polling a session. Calling again for the same id stops the previous + * poll first (re-entrancy safe). + */ +export function startPolling(id: string, intervalMs = 3000): void { + stopPolling(id) + const state: PollState = { + id, + intervalMs, + stopped: false, + consecutiveFails: 0, + prev: undefined, + lastSample: undefined, + prevAt: 0, + metaSent: false, + timer: setTimeout(() => pollOnce(id, state), 0) + } + polls.set(id, state) +} + +/** Stop polling a session (no-op when not polling). Also run on session close. */ +export function stopPolling(id: string): void { + const state = polls.get(id) + if (!state) return + state.stopped = true + clearTimeout(state.timer) + polls.delete(id) +} + +function armNext(id: string, state: PollState): void { + if (state.stopped) return + state.timer = setTimeout(() => pollOnce(id, state), state.intervalMs) +} + +function pollOnce(id: string, state: PollState): void { + if (state.stopped) return + const client = clientProvider?.(id) + if (!client) { + handleError(id, state, 'SSH 会话不存在或已断开') + return + } + + try { + client.exec(COLLECT_CMD, (err: Error | undefined, stream) => { + if (state.stopped) return + if (err || !stream) { + handleError(id, state, err?.message || 'SSH exec 失败') + return + } + let out = '' + stream.on('data', (d: Buffer) => { + out += d.toString('utf8') + }) + const onEnd = (): void => { + if (state.stopped) return + try { + stream.close() + } catch { + // best effort + } + handleOutput(id, state, out) + } + stream.on('close', onEnd) + stream.on('error', () => onEnd()) + }) + } catch (err) { + handleError(id, state, (err as Error).message) + } +} + +function handleError(id: string, state: PollState, message: string): void { + if (state.stopped) return + state.consecutiveFails += 1 + const last = state.lastSample + const sample: SysinfoSample = last + ? { + ...last, + ts: Date.now(), + error: message + } + : { + ts: Date.now(), + cpu: { usage: 0, cores: 0, loadavg: [0, 0, 0] }, + mem: { totalMb: 0, usedMb: 0 }, + disks: [], + net: { rxKbs: 0, txKbs: 0 }, + uptimeSec: 0, + error: message + } + broadcastSample(id, sample) + + if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`) + stopPolling(id) + return + } + armNext(id, state) +} + +function handleOutput(id: string, state: PollState, out: string): void { + if (state.stopped) return + const now = Date.now() + try { + const parsed = parseOutput(out) + const sample: SysinfoSample = { + ts: now, + cpu: parsed.cpu, + mem: parsed.mem, + disks: parsed.disks, + net: parsed.net, + uptimeSec: parsed.uptimeSec + } + state.consecutiveFails = 0 + + // Diff against the previous sample when one exists and rates are computable. + if (state.prev && state.prevAt > 0) { + const dtSec = (now - state.prevAt) / 1000 + const totalDelta = parsed.cpuTotal - state.prev.total + const idleDelta = parsed.cpuIdle - state.prev.idle + if (totalDelta > 0) { + sample.cpu.usage = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta))) + } + if (dtSec > 0) { + sample.net.rxKbs = Math.max(0, (parsed.netRx - state.prev.rx) / 1024 / dtSec) + sample.net.txKbs = Math.max(0, (parsed.netTx - state.prev.tx) / 1024 / dtSec) + } + } + state.prevAt = now + state.prev = { + idle: parsed.cpuIdle, + total: parsed.cpuTotal, + rx: parsed.netRx, + tx: parsed.netTx + } + state.lastSample = sample + broadcastSample(id, sample) + + if (!state.metaSent && (parsed.hostname || parsed.osInfo)) { + state.metaSent = true + broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo }) + } + } catch (err) { + handleError(id, state, `解析失败: ${(err as Error).message}`) + return + } + armNext(id, state) +} + +function broadcastSample(id: string, sample: SysinfoSample): void { + try { + deps?.broadcast(Ipc.SYSINFO_SAMPLE, { id, sample }) + } catch { + // never crash the event loop + } +} + +function broadcastMeta(id: string, meta: SysinfoMeta): void { + try { + deps?.broadcast(Ipc.SYSINFO_META, { id, meta }) + } catch { + // never crash the event loop + } +} + +// ---- parsing ------------------------------------------------------------------ + +interface Parsed { + cpu: SysinfoSample['cpu'] + cpuIdle: number + cpuTotal: number + mem: SysinfoSample['mem'] + disks: SysinfoSample['disks'] + net: SysinfoSample['net'] + netRx: number + netTx: number + uptimeSec: number + hostname: string + osInfo: string +} + +function parseOutput(raw: string): Parsed { + // Split into the four delimited regions. + const parts = raw.split(/__DF__|__NET__|__HOST__/) + const procBlob = parts[0] ?? '' + const dfBlob = parts[1] ?? '' + const netBlob = parts[2] ?? '' + const hostBlob = parts[3] ?? '' + + return { + ...parseProc(procBlob), + disks: parseDf(dfBlob), + ...parseNet(netBlob), + ...parseHost(hostBlob) + } +} + +function toNumber(s: string | undefined): number { + const n = Number(s) + return Number.isFinite(n) ? n : 0 +} + +function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number; cpuTotal: number; mem: SysinfoSample['mem']; uptimeSec: number } { + const lines = blob.split('\n') + let total = 0 + let idle = 0 + let cores = 0 + let user = 0 + const loadavg: [number, number, number] = [0, 0, 0] + let memTotal = 0 + let memAvailable = 0 + let uptimeSec = 0 + + for (const raw of lines) { + const line = raw.trimEnd() + if (line.startsWith('cpu')) { + if (line.startsWith('cpu ')) { + const f = line.split(/\s+/).slice(1).map(toNumber) + user = f[0] ?? 0 + const nice = f[1] ?? 0 + const system = f[2] ?? 0 + const idleTicks = f[3] ?? 0 + const iowait = f[4] ?? 0 + const irq = f[5] ?? 0 + const softirq = f[6] ?? 0 + const steal = f[7] ?? 0 + const guest = f[8] ?? 0 + const guestNice = f[9] ?? 0 + idle = idleTicks + iowait + total = + user + + nice + + system + + idle + + irq + + softirq + + steal + + guest + + guestNice + } else { + cores += 1 + } + } else if (line.startsWith('MemTotal:')) { + memTotal = toNumber(line.split(/\s+/)[1]) + } else if (line.startsWith('MemAvailable:')) { + memAvailable = toNumber(line.split(/\s+/)[1]) + } else if (line.includes('/')) { + // /proc/loadavg: "0.00 0.01 0.05 1/234 5678" + const loadMatch = /^\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(line) + if (loadMatch) { + loadavg[0] = toNumber(loadMatch[1]) + loadavg[1] = toNumber(loadMatch[2]) + loadavg[2] = toNumber(loadMatch[3]) + } + } else if (/^\d/.test(line) && line.split(/\s+/).length === 2) { + // /proc/uptime: "12345.67 9876.54" + uptimeSec = toNumber(line.split(/\s+/)[0]) + } + } + + return { + cpu: { + usage: 0, + cores, + loadavg + }, + cpuIdle: idle, + cpuTotal: total, + mem: { + // meminfo columns are kB; sample schema is MiB. + totalMb: Math.round(memTotal / 1024), + usedMb: memAvailable > 0 && memTotal >= memAvailable + ? Math.round((memTotal - memAvailable) / 1024) + : 0 + }, + uptimeSec: Math.round(uptimeSec) + } +} + +function parseDf(blob: string): SysinfoSample['disks'] { + const disks: SysinfoSample['disks'] = [] + for (const raw of blob.split('\n')) { + const line = raw.trim() + if (!line || line.startsWith('Filesystem')) continue + const f = line.split(/\s+/) + if (f.length < 6) continue + // Columns: Filesystem 1K-blocks Used Available Use% Mounted on + const fs = f[0] + const mount = f.slice(5).join(' ') + // df -kP has no type column; pseudofs appear as the device name (tmpfs, + // overlay, udev, ...) or as a mount point under the shared /dev harness. + if (FAKE_FS.has(fs) || FAKE_FS.has(mount)) continue + const totalKb = toNumber(f[1]) + const usedKb = toNumber(f[2]) + if (totalKb <= 0) continue + disks.push({ + mount, + totalMb: Math.round(totalKb / 1024), + usedMb: Math.round(usedKb / 1024) + }) + } + return disks +} + +function parseNet( + blob: string +): { net: SysinfoSample['net']; netRx: number; netTx: number } { + let rx = 0 + let tx = 0 + for (const raw of blob.split('\n')) { + const line = raw.trim() + if (!line || line.startsWith('Inter')) continue + const colon = line.indexOf(':') + if (colon <= 0) continue + const iface = line.slice(0, colon) + const fields = line + .slice(colon + 1) + .trim() + .split(/\s+/) + .map(Number) + // /proc/net/dev rows: rx_bytes rx_packets ... rx_multicast tx_bytes ... + if (fields.length < 10 || fields.some((n) => !Number.isFinite(n))) continue + if (iface === 'lo') continue + rx += fields[0] + tx += fields[8] + } + return { net: { rxKbs: 0, txKbs: 0 }, netRx: rx, netTx: tx } +} + +function parseHost(blob: string): { hostname: string; osInfo: string } { + const lines = blob.split('\n').map((l) => l.trim()).filter(Boolean) + return { + hostname: lines[0] ?? '', + osInfo: lines.slice(1).join(' ').trim() + } +} \ No newline at end of file diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..68f89a0 --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,118 @@ +import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron' +import type { SystemSettings } from '@shared/settings' +import { loadSettings, saveSettings } from './settingsStore' +import trayIconPath from './assets/tray.png?asset' + +export type CloseAction = NonNullable + +let tray: Tray | null = null +let quitting = false +let balloonShown = false + +/** Once true, window close events pass through and the app really quits. */ +export function markQuitting(): void { + quitting = true +} + +function persistCloseAction(action: CloseAction): void { + const next = loadSettings() + next.system.closeAction = action + saveSettings(next) +} + +function showMainWindow(showOrCreate: () => void): void { + const win = BrowserWindow.getAllWindows()[0] + if (win) { + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } else { + showOrCreate() + } +} + +/** Rebuild the context menu so the close-action radio items reflect settings. */ +function refreshContextMenu(showOrCreate: () => void): void { + if (!tray) return + const action = loadSettings().system.closeAction ?? 'ask' + const setAction = (value: CloseAction) => (): void => { + persistCloseAction(value) + refreshContextMenu(showOrCreate) + } + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) }, + { type: 'separator' }, + { label: '关闭按钮行为', enabled: false }, + { label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') }, + { label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') }, + { label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') }, + { type: 'separator' }, + { + label: '退出', + click: (): void => { + markQuitting() + app.quit() + } + } + ]) + ) +} + +/** Create the tray icon and wire left-click to window restore. */ +export function initTray(showOrCreate: () => void): void { + const icon = nativeImage.createFromPath(trayIconPath) + tray = new Tray(icon) + tray.setToolTip('OpenTerminal') + tray.on('click', (): void => showMainWindow(showOrCreate)) + refreshContextMenu(showOrCreate) +} + +function hideToTray(win: BrowserWindow): void { + win.hide() + if (process.platform === 'win32' && tray && !balloonShown) { + balloonShown = true + tray.displayBalloon({ + iconType: 'info', + title: 'OpenTerminal 仍在运行', + content: '已最小化到系统托盘,终端会话保持运行。点击托盘图标可恢复窗口。' + }) + } +} + +/** + * Window close interception. Must be called synchronously from the 'close' + * event: preventDefault happens before any await on every branch. + */ +export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise { + if (quitting) return + const action = loadSettings().system.closeAction ?? 'ask' + if (action === 'tray') { + e.preventDefault() + hideToTray(win) + return + } + if (action === 'exit') return // fall through: close proceeds, app quits + e.preventDefault() + const { response, checkboxChecked } = await dialog.showMessageBox(win, { + type: 'question', + title: '关闭 OpenTerminal', + message: '最小化到托盘,还是直接退出?', + detail: '最小化到托盘时终端会话保持运行。', + buttons: ['最小化到托盘', '直接退出'], + defaultId: 0, + cancelId: 1, + checkboxLabel: '记住我的选择,不再询问', + checkboxChecked: false, + noLink: true + }) + if (response === 0) { + if (checkboxChecked) persistCloseAction('tray') + hideToTray(win) + } else { + if (checkboxChecked) persistCloseAction('exit') + markQuitting() + app.quit() + } + refreshContextMenu(showOrCreate) +} diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..e1b7d74 --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,54 @@ +import { app } from 'electron' +import { autoUpdater } from 'electron-updater' + +/** + * Default update feed (generic provider). The main agent uploads latest.yml and + * the NSIS installer to this stable path. + * + * KNOWN LIMITATION: electron-updater does NOT support MSI auto-updates. The + * update channel always runs through the NSIS .exe (latest.yml + .exe are + * uploaded together). MSI is manual/enterprise distribution only. + */ +const DEFAULT_FEED_URL = + 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/' + +export function configureAutoUpdater(): void { + // Update checks only run in packaged builds; no-op during development. + if (!app.isPackaged) { + return + } + + const url = process.env.OT_UPDATE_URL || DEFAULT_FEED_URL + const token = process.env.OT_UPDATE_TOKEN + + autoUpdater.setFeedURL({ + provider: 'generic', + url, + ...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {}) + }) + + autoUpdater.logger = console + autoUpdater.autoDownload = true + autoUpdater.autoInstallOnAppQuit = true + + // Check shortly after startup; log errors without crashing the app. + const timer = setTimeout(() => { + autoUpdater + .checkForUpdatesAndNotify() + .catch((err) => console.warn('[updater] checkForUpdates failed:', err)) + }, 5000) + timer.unref?.() +} + +/** Public entry point for later UI wiring (not connected this milestone). */ +export function checkForUpdates(): Promise { + if (!app.isPackaged) { + return Promise.resolve() + } + return autoUpdater + .checkForUpdates() + .then(() => {}) + .catch((err) => { + console.warn('[updater] checkForUpdates failed:', err) + }) +} \ No newline at end of file diff --git a/src/main/windowChrome.ts b/src/main/windowChrome.ts new file mode 100644 index 0000000..a589d18 --- /dev/null +++ b/src/main/windowChrome.ts @@ -0,0 +1,16 @@ +import { BrowserWindow } from 'electron' +import type { AppSettings } from '@shared/settings' +import { getThemeById } from '@shared/theme' + +/** Title bar overlay + window background follow the active terminal theme. */ +export function applyWindowChrome(settings: AppSettings): void { + const theme = getThemeById(settings.terminal.themeId, settings.customThemes) + const bg = theme.colors.background + const fg = theme.colors.foreground + for (const win of BrowserWindow.getAllWindows()) { + win.setBackgroundColor(bg) + if (process.platform === 'win32') { + win.setTitleBarOverlay({ color: bg, symbolColor: fg, height: 36 }) + } + } +} diff --git a/src/main/zmodem-js.d.ts b/src/main/zmodem-js.d.ts new file mode 100644 index 0000000..69a9c04 --- /dev/null +++ b/src/main/zmodem-js.d.ts @@ -0,0 +1,125 @@ +/** + * Minimal ambient typings for the `zmodem.js` package (v0.1.10). + * + * zmodem.js ships no type declarations and `@types/zmodem.js` is not installed; + * npm install is out of scope here, so this file declares exactly the surface + * src/main/zmodem.ts consumes. Verified against the sources in + * node_modules/zmodem.js/src/{zsentry,zsession,zvalidation,zheader}.js: + * + * - Sentry.consume() accepts a Uint8Array / ArrayBuffer / octet Array. + * - Detection.confirm() returns the matched Session; the Session's `.type` + * is "receive" (remote `sz` -> we download) or "send" (remote `rz` -> we + * upload). + * - The byte arrays passed to `to_terminal` / `sender` are plain number[]. + */ +declare module 'zmodem.js' { + /** one batch of a file being received (see Offer) */ + export interface FileDetails { + name: string + /** can be null when the peer streams an unknown size; zmodem.js accepts */ + size?: number | null + /** POSIX mode bits (0x8000 | perm); optional */ + mode?: number | null + /** Date or epoch seconds; optional */ + mtime?: Date | number | null + files_remaining?: number | null + bytes_remaining?: number | null + } + + /** A sender-side single-file transfer handle (result of send_offer()). */ + export interface Transfer { + get_details(): FileDetails + get_offset(): number + /** Send a non-terminal chunk. */ + send(arrayLike: number[] | Uint8Array): void + /** Send the terminal chunk; resolves when the receiver confirms file end. */ + end(arrayLike?: number[] | Uint8Array): Promise + } + + /** A receiver-side offerable file (shown on a receive Session's "offer"). */ + export interface Offer { + get_details(): FileDetails + get_offset(): number + /** Skip this file (not an error, unlike abort()). */ + skip(): Promise + /** + * Accept the offer. With { on_input: fn } the payload of every data + * subpacket is delivered to `fn` (streaming, no full spool in memory). + */ + accept(opts?: { + offset?: number + on_input?: 'spool_array' | 'spool_uint8array' | ((payload: Uint8Array) => void) + }): Promise + on(event: 'input', cb: (payload: Uint8Array) => void): Offer + on(event: 'complete', cb: () => void): Offer + } + + /** Base ZMODEM session (Send/Receive share this). */ + export interface Session { + type: 'send' | 'receive' + set_sender(fn: (octets: number[]) => void): void + has_ended(): boolean + aborted(): boolean + /** Abort the transfer (sends the ZMODEM CAN sequence to the peer). */ + abort(): void + /** Register a session_end handler (fires once the transfer is done). */ + on(event: 'session_end', cb: () => void): void + /** Catch-all overload (keeps the Send|Receive union from collapsing to never). */ + on(event: never, cb: (...args: unknown[]) => void): void + } + + /** Send session (we upload, remote ran rz). */ + export interface SendSession extends Session { + type: 'send' + /** Offer one file; resolves with the Transfer or undefined if skipped. */ + send_offer(params: FileDetails): Promise + /** Ends a send session gracefully; resolves when the receiver acks ZFIN. */ + close(): Promise + } + + /** Receive session (we download, remote ran sz). */ + export interface ReceiveSession extends Session { + type: 'receive' + on(event: 'offer', cb: (offer: Offer) => void): void + /** Begin receiving: tells the peer we are ready for the first offer. */ + start(): Promise + } + + export interface SentryOptions { + /** Non-ZMODEM octets to forward to the terminal. */ + to_terminal(octets: number[]): void + /** Called with a Detection when a ZMODEM header is spotted. */ + on_detect(detection: Detection): void + /** Called when a pending Detection is retracted (no session after all). */ + on_retract(): void + /** Send octets to the peer (the ssh stream). */ + sender(octets: number[]): void + } + + export interface Detection { + /** Whether the detection is still valid (not retracted). */ + is_valid(): boolean + /** Confirm the ZMODEM session and return the active Session object. */ + confirm(): SendSession | ReceiveSession + /** Give the session's role: 'send' or 'receive'. */ + get_session_role(): 'send' | 'receive' + /** Reject: tell the peer to abort the session. */ + deny(): void + } + + export const Sentry: new (options: SentryOptions) => Sentry + export interface Sentry { + consume(input: Uint8Array | ArrayBuffer | number[]): void + get_confirmed_session(): Session | null + } + + export const ZMLIB: { ABORT_SEQUENCE: number[] } + export const ZDLE: new () => unknown + export const CRC: { crc16(input: ArrayLike): number } + export const Header: Record + export const Subpacket: Record + export const Session: { + Send: new (zrinitHeader: unknown) => SendSession + Receive: new () => ReceiveSession + } +} \ No newline at end of file diff --git a/src/main/zmodem.ts b/src/main/zmodem.ts new file mode 100644 index 0000000..3446ff3 --- /dev/null +++ b/src/main/zmodem.ts @@ -0,0 +1,476 @@ +/** + * ZMODEM engine (M6) - lives in the MAIN process and only ever runs over an SSH + * session stream, using zmodem.js@0.1.10. + * + * LOCAL PTY SESSIONS ARE DELIBERATELY NOT SUPPORTED: node-pty / Windows ConPTY + * only hand us UTF-8 strings and are lossy for arbitrary bytes, so a binary + * ZMODEM stream would be corrupted (and there is no file dialog on the local + * side anyway). The engine is therefore only ever attached to an ssh2 channel, + * where we get raw Buffers. + * + * Design mirrors pty.ts's dependency-injection pattern: this module holds no + * Electron import. pty.ts injects `broadcast`/`toTerminal`/`writeStream`, and + * routes the ssh stream's 'data' through `feedZmodem`. While a transfer is + * active we suppress the normal terminal data-plane (binary frames must never + * reach the terminal), and pty.ts's writePty drops user keystrokes via + * isZmodemActive. + */ +import { basename, join } from 'path' +import { createReadStream, createWriteStream } from 'fs' +import { stat } from 'fs/promises' +import type { Writable } from 'stream' +import * as Zmodem from 'zmodem.js' +import { Ipc } from '../shared/ipc' +import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc' +import type { TransferProgressEvent } from '../shared/sftp' + +/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */ +const OFFER_TIMEOUT_MS = 120_000 +/** Abort a transfer that makes no byte progress for this long (ms). */ +const STALL_TIMEOUT_MS = 90_000 + +const ABORT_BUFFER = Buffer.from(Zmodem.ZMLIB.ABORT_SEQUENCE) + +export interface ZmodemDeps { + /** Emit a main->renderer broadcast (ZMODEM_OFFER / ZMODEM_DONE / TRANSFER_PROGRESS). */ + broadcast(channel: string, ...args: unknown[]): void + /** + * Forward NON-ZMODEM octets back through the normal terminal data path + * (utf8 + replay + session log + PTY_DATA). pty.ts injects this; the engine + * only calls it when no transfer is active. + */ + toTerminal(sessionId: string, text: string): void + /** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */ + writeStream(sessionId: string, data: Buffer): void +} + +interface Engine { + id: string + deps: ZmodemDeps + sentry: Zmodem.Sentry + detection: Zmodem.Detection | null + session: Zmodem.SendSession | Zmodem.ReceiveSession | null + /** True from header detection until the session ends / teardown. */ + active: boolean + mode: 'send' | 'receive' | null + dir: string | null + /** True once the detection is confirmed and files start flowing. */ + confirmed: boolean + transferId: string + /** one progress terminal event at most */ + progressEmitted: boolean + doneEmitted: boolean + offerTimer: NodeJS.Timeout | null + stallTimer: NodeJS.Timeout | null + receiveStream: Writable | null + bytes: number + totalBytes: number +} + +const engines = new Map() + +function current(sessionId: string): Engine | undefined { + return engines.get(sessionId) +} + +function cancelTimers(engine: Engine): void { + if (engine.offerTimer) clearTimeout(engine.offerTimer) + if (engine.stallTimer) clearTimeout(engine.stallTimer) + engine.offerTimer = null + engine.stallTimer = null +} + +function emitProgress(engine: Engine, patch: Partial): void { + const evt: TransferProgressEvent = { + transferId: engine.transferId, + kind: engine.mode === 'receive' ? 'zmodem-download' : 'zmodem-upload', + state: 'running', + file: '', + bytes: engine.bytes, + totalBytes: engine.totalBytes, + ...patch + } + try { + engine.deps.broadcast(Ipc.TRANSFER_PROGRESS, evt) + } catch { + // never crash the event loop + } +} + +function emitDone(engine: Engine, ok: boolean, message?: string): void { + if (engine.doneEmitted) return + engine.doneEmitted = true + const evt: ZmodemDoneEvent = { id: engine.id, ok, message } + try { + engine.deps.broadcast(Ipc.ZMODEM_DONE, evt) + } catch { + // never crash the event loop + } +} + +/** + * End-of-session / cancel / error common path: stop suppressing the terminal + * data plane, drop timers, release streams, and emit the terminal events once. + */ +function finalize( + engine: Engine, + ok: boolean, + message?: string, + failState: 'cancelled' | 'error' = 'error' +): void { + engine.active = false + cancelTimers(engine) + if (engine.receiveStream) { + try { + engine.receiveStream.end() + } catch { + // already closed + } + engine.receiveStream = null + } + engine.session = null // the zsession already ended; drop the reference + engine.detection = null + + if (!engine.progressEmitted) { + emitProgress(engine, { state: ok ? 'done' : failState, file: message ?? '' }) + } + engine.progressEmitted = true + + if (ok) emitDone(engine, true) + else emitDone(engine, false, message ?? '传输失败') +} + +/** Called by the zsession's own 'session_end' event (its `this` is the session). */ +function makeSessionEnd(engine: Engine): () => void { + return () => finalize(engine, true) +} + +function touchActivity(engine: Engine): void { + if (engine.stallTimer) clearTimeout(engine.stallTimer) + const timer = setTimeout(() => { + if (engine.stallTimer === null) return // already finalized + finalize(engine, false, '传输超时') + }, STALL_TIMEOUT_MS) + engine.stallTimer = timer +} + +function wireSession(engine: Engine): void { + const session = engine.session + if (!session) return + const sendToPeer = (octets: number[]): void => { + try { + engine.deps.writeStream(engine.id, Buffer.from(octets)) + } catch { + // stream gone mid-transfer + } + } + session.set_sender(sendToPeer) + ;(session as unknown as { on: (event: string, cb: () => void) => void }).on('session_end', makeSessionEnd(engine)) + touchActivity(engine) +} + +// --------------------------------------------------------------------------- +// Sending (remote `rz` -> we upload local files) +// --------------------------------------------------------------------------- + +async function sendOneFile( + engine: Engine, + session: Zmodem.SendSession, + file: { path: string; name: string; size: number; mtimeMs: number } +): Promise { + const xfer = await session.send_offer({ + name: file.name, + size: file.size, + mtime: new Date(file.mtimeMs), + mode: undefined + }) + if (!xfer) return // receiver skipped the file + emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + + await new Promise((resolve, reject) => { + const rs = createReadStream(file.path) + rs.on('data', (chunk: string | Buffer) => { + rs.pause() + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + try { + xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)) + } catch (err) { + rs.destroy() + reject(err instanceof Error ? err : new Error('发送失败')) + return + } + engine.bytes += buf.length + emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + touchActivity(engine) + rs.resume() + }) + rs.on('end', () => { + xfer + .end() + .then(() => resolve()) + .catch(reject) + }) + rs.on('error', reject) + }) +} + +async function runSend(engine: Engine, paths: string[]): Promise { + try { + const session = engine.session as Zmodem.SendSession + const files = await Promise.all( + paths.map(async (p) => { + const s = await stat(p) + return { path: p, name: basename(p), size: s.size, mtimeMs: s.mtimeMs } + }) + ) + engine.totalBytes = files.reduce((acc, f) => acc + f.size, 0) + + for (const f of files) { + emitProgress(engine, { file: f.name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + await sendOneFile(engine, session, f) + } + emitProgress(engine, { file: '', bytes: engine.totalBytes, totalBytes: engine.totalBytes }) + // Final state + ZMODEM_DONE come from the session_end event fired by close(). + await session.close() + } catch (err) { + finalize(engine, false, err instanceof Error ? err.message : '上传失败') + } +} + +// --------------------------------------------------------------------------- +// Receiving (remote `sz` -> we download into a chosen local directory) +// --------------------------------------------------------------------------- + +function handleOffer(engine: Engine, offer: Zmodem.Offer): void { + const details = offer.get_details() + const name = basename(String(details.name ?? 'file')) + const dest = join(engine.dir ?? '.', name) + if (details.size) engine.totalBytes += details.size + + const stream = createWriteStream(dest) + engine.receiveStream = stream + emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + + offer + .accept({ + on_input: (payload: Uint8Array | number[]) => { + // zmodem.js delivers the payload as a plain octet Array (not a + // Uint8Array); coerce defensively to a Buffer before writing. + const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength) + if (!stream.destroyed && !stream.closed) { + stream.write(buf) + } + engine.bytes += buf.byteLength + emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + touchActivity(engine) + } + }) + .then(() => { + stream.end(() => { + if (engine.receiveStream === stream) engine.receiveStream = null + emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes }) + }) + }) + .catch((err: unknown) => { + try { + stream.destroy() + } catch { + // already gone + } + finalize(engine, false, err instanceof Error ? err.message : '接收失败') + }) +} + +// --------------------------------------------------------------------------- +// Public API (used by pty.ts / ipc.ts) +// --------------------------------------------------------------------------- + +/** + * Attach a ZMODEM Sentry to an ssh session. Only call for ssh sessions (see + * module docs: local ptys are unsupported by design). deps are injected by + * pty.ts so this module stays Electron-free. + */ +export function attachZmodem(sessionId: string, deps: ZmodemDeps): void { + const engine: Engine = { + id: sessionId, + deps, + sentry: new Zmodem.Sentry({ + to_terminal: (octets: number[]) => { + // Analysis: also defensive against active http-parsing leftovers. + if (engine.active) return // suppress binary terminal output during transfer + try { + deps.toTerminal(sessionId, Buffer.from(octets).toString('utf8')) + } catch { + // never crash the event loop + } + }, + on_detect: (detection: Zmodem.Detection) => { + const role = detection.get_session_role() + engine.mode = role === 'receive' ? 'receive' : 'send' + engine.detection = detection + engine.active = true + engine.transferId = `zm-${sessionId}` + emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 }) + try { + deps.broadcast(Ipc.ZMODEM_OFFER, { id: sessionId, mode: engine.mode }) + } catch { + // never crash the event loop + } + touchActivity(engine) + engine.offerTimer = setTimeout(() => { + if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消') + }, OFFER_TIMEOUT_MS) + }, + on_retract: () => { + // The detected "session" turned out not to be ZMODEM; return to normal. + engine.detection = null + engine.mode = null + engine.active = false + cancelTimers(engine) + }, + sender: (octets: number[]) => { + try { + deps.writeStream(sessionId, Buffer.from(octets)) + } catch { + // stream gone mid-transfer + } + } + }), + detection: null, + session: null, + active: false, + mode: null, + dir: null, + confirmed: false, + transferId: `zm-${sessionId}`, + progressEmitted: false, + doneEmitted: false, + offerTimer: null, + stallTimer: null, + receiveStream: null, + bytes: 0, + totalBytes: 0 + } + engines.set(sessionId, engine) +} + +/** + * Abort an unconfirmed (or pre-session) offer. No live zsession yet, so we write + * the CAN abort sequence directly so the remote program exits. + */ +function abortWithoutSession(engine: Engine, message: string): void { + try { + engine.deps.writeStream(engine.id, ABORT_BUFFER) + } catch { + // stream may be gone + } + engine.active = false + cancelTimers(engine) + if (!engine.progressEmitted) emitProgress(engine, { state: 'cancelled', file: message }) + engine.progressEmitted = true + emitDone(engine, false, message) +} + +/** Abort a confirmed session cleanly (sends CAN so remote rz/sz exits). */ +function abortSession(engine: Engine, message: string): void { + try { + engine.session?.abort() + } catch { + // fall through to write the abort sequence ourselves + } + try { + engine.deps.writeStream(engine.id, ABORT_BUFFER) + } catch { + // stream may be gone + } + finalize(engine, false, message, 'cancelled') +} + +/** Feed raw ssh-stream bytes into the engine. Only call when isZmodemActive(). */ +export function feedZmodem(sessionId: string, data: Buffer): void { + const engine = current(sessionId) + if (!engine) return + try { + engine.sentry.consume(data) + } catch (err) { + // A zmodem.js Error (peer abort / corrupt frame) ends the session. + if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常') + } +} + +/** Whether a zmodem transfer is active (pty.ts suppresses user writes while true). */ +export function isZmodemActive(sessionId: string): boolean { + return current(sessionId)?.active ?? false +} + +/** + * The renderer answered an offer (via ipc.ts). `cancelled` aborts; otherwise + * the flow proceeds according to the detected mode. + */ +export function respondZmodem(resp: ZmodemResponse): void { + const engine = current(resp.id) + if (!engine || engine.confirmed) return + + if (resp.cancelled) { + abortWithoutSession(engine, '已取消') + return + } + + const detection = engine.detection + if (!detection || !detection.is_valid()) { + abortWithoutSession(engine, '会话已失效') + return + } + + engine.confirmed = true + // The offer was answered — stop the offer watchdog; the stall timer takes over. + if (engine.offerTimer) { + clearTimeout(engine.offerTimer) + engine.offerTimer = null + } + engine.session = detection.confirm() + if (!engine.session) { + abortWithoutSession(engine, '无法建立 ZMODEM 会话') + return + } + wireSession(engine) + + if (engine.mode === 'receive') { + if (!resp.dir) { + abortSession(engine, '未指定保存目录') + return + } + engine.dir = resp.dir + const session = engine.session as Zmodem.ReceiveSession + session.on('offer', (offer) => handleOffer(engine, offer)) + void session + .start() + .catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败')) + } else { + const paths = resp.paths ?? [] + if (paths.length === 0) { + abortSession(engine, '未选择文件') + return + } + void runSend(engine, paths) + } +} + +/** Detach + clean up (session close / kill). Safe to call any number of times. */ +export function detachZmodem(sessionId: string): void { + const engine = current(sessionId) + if (!engine) return + engine.active = false + cancelTimers(engine) + if (engine.receiveStream) { + try { + engine.receiveStream.end() + } catch { + // ignore + } + engine.receiveStream = null + } + engine.session = null + engine.detection = null + engines.delete(sessionId) +} \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..93da59c --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,123 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { Ipc } from '../shared/ipc' +import type { AppSettings } from '../shared/settings' +import type { AppApi } from '../shared/api' +import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' +import type { + HostKeyAction, + HostKeyPromptEvent, + SessionOpenOptions, + SshConnectionInput +} from '../shared/connections' +import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo' +import type { TransferProgressEvent } from '../shared/sftp' + +const api: AppApi = { + appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO), + + createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), + openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), + getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id), + writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data), + resizePty: (id: string, cols: number, rows: number) => + ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows), + killPty: (id: string) => ipcRenderer.send(Ipc.PTY_KILL, id), + onPtyData: (cb: (e: PtyDataEvent) => void) => { + const listener = (_: unknown, e: PtyDataEvent): void => cb(e) + ipcRenderer.on(Ipc.PTY_DATA, listener) + return () => ipcRenderer.removeListener(Ipc.PTY_DATA, listener) + }, + onPtyExit: (cb: (e: PtyExitEvent) => void) => { + const listener = (_: unknown, e: PtyExitEvent): void => cb(e) + ipcRenderer.on(Ipc.PTY_EXIT, listener) + return () => ipcRenderer.removeListener(Ipc.PTY_EXIT, listener) + }, + + sysinfoStart: (id: string) => ipcRenderer.send(Ipc.SYSINFO_START, id), + sysinfoStop: (id: string) => ipcRenderer.send(Ipc.SYSINFO_STOP, id), + onSysinfoMeta: (cb: (e: { id: string; meta: SysinfoMeta }) => void) => { + const listener = (_: unknown, e: { id: string; meta: SysinfoMeta }): void => cb(e) + ipcRenderer.on(Ipc.SYSINFO_META, listener) + return () => ipcRenderer.removeListener(Ipc.SYSINFO_META, listener) + }, + onSysinfoSample: (cb: (e: { id: string; sample: SysinfoSample }) => void) => { + const listener = (_: unknown, e: { id: string; sample: SysinfoSample }): void => cb(e) + ipcRenderer.on(Ipc.SYSINFO_SAMPLE, listener) + return () => ipcRenderer.removeListener(Ipc.SYSINFO_SAMPLE, listener) + }, + + listRemote: (sessionId: string, dir: string) => ipcRenderer.invoke(Ipc.SFTP_LIST, sessionId, dir), + mkdirRemote: (sessionId: string, dir: string, name: string) => + ipcRenderer.invoke(Ipc.SFTP_MKDIR, sessionId, dir, name), + renameRemote: (sessionId: string, from: string, to: string) => + ipcRenderer.invoke(Ipc.SFTP_RENAME, sessionId, from, to), + deleteRemote: (sessionId: string, paths: string[]) => + ipcRenderer.invoke(Ipc.SFTP_DELETE, sessionId, paths), + chmodRemote: (sessionId: string, path: string, mode: string) => + ipcRenderer.invoke(Ipc.SFTP_CHMOD, sessionId, path, mode), + chownRemote: (sessionId: string, path: string, uid: number, gid: number) => + ipcRenderer.invoke(Ipc.SFTP_CHOWN, sessionId, path, uid, gid), + uploadRemote: (sessionId: string, localPaths: string[], remoteDir: string) => + ipcRenderer.invoke(Ipc.SFTP_UPLOAD, sessionId, localPaths, remoteDir), + downloadRemote: (sessionId: string, remotePaths: string[], localDir: string) => + ipcRenderer.invoke(Ipc.SFTP_DOWNLOAD, sessionId, remotePaths, localDir), + cancelTransfer: (transferId: string) => ipcRenderer.send(Ipc.TRANSFER_CANCEL, transferId), + onTransferProgress: (cb: (e: TransferProgressEvent) => void) => { + const listener = (_: unknown, e: TransferProgressEvent): void => cb(e) + ipcRenderer.on(Ipc.TRANSFER_PROGRESS, listener) + return () => ipcRenderer.removeListener(Ipc.TRANSFER_PROGRESS, listener) + }, + pickFiles: () => ipcRenderer.invoke(Ipc.DIALOG_PICK_FILES), + pickDirectory: () => ipcRenderer.invoke(Ipc.DIALOG_PICK_DIR), + + recordCommand: (cmd: string) => ipcRenderer.invoke(Ipc.COMMANDS_RECORD, cmd), + listHistory: () => ipcRenderer.invoke(Ipc.COMMANDS_HISTORY_LIST), + clearHistory: () => ipcRenderer.invoke(Ipc.COMMANDS_HISTORY_CLEAR), + listLibrary: () => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_LIST), + saveLibraryItem: (item) => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_SAVE, item), + deleteLibraryItem: (id: string) => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_DELETE, id), + logStart: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_START, sessionId), + logStop: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_STOP, sessionId), + listSessionLogs: () => ipcRenderer.invoke(Ipc.LOG_LIST), + openLogsDir: () => ipcRenderer.send(Ipc.LOG_OPEN_DIR), + + listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST), + saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input), + deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id), + + onZmodemOffer: (cb: (e: ZmodemOfferEvent) => void) => { + const listener = (_: unknown, e: ZmodemOfferEvent): void => cb(e) + ipcRenderer.on(Ipc.ZMODEM_OFFER, listener) + return () => ipcRenderer.removeListener(Ipc.ZMODEM_OFFER, listener) + }, + zmodemRespond: (resp: ZmodemResponse) => ipcRenderer.send(Ipc.ZMODEM_RESPOND, resp), + onZmodemDone: (cb: (e: ZmodemDoneEvent) => void) => { + const listener = (_: unknown, e: ZmodemDoneEvent): void => cb(e) + ipcRenderer.on(Ipc.ZMODEM_DONE, listener) + return () => ipcRenderer.removeListener(Ipc.ZMODEM_DONE, listener) + }, + onHostKeyPrompt: (cb: (e: HostKeyPromptEvent) => void) => { + const listener = (_: unknown, e: HostKeyPromptEvent): void => cb(e) + ipcRenderer.on(Ipc.HOSTKEY_PROMPT, listener) + return () => ipcRenderer.removeListener(Ipc.HOSTKEY_PROMPT, listener) + }, + respondHostKey: (promptId: string, action: HostKeyAction) => + ipcRenderer.send(Ipc.HOSTKEY_RESPOND, promptId, action), + + getSettings: () => ipcRenderer.invoke(Ipc.SETTINGS_GET), + saveSettings: (next: AppSettings) => ipcRenderer.invoke(Ipc.SETTINGS_SET, next), + onSettingsChanged: (cb: (s: AppSettings) => void) => { + const listener = (_: unknown, s: AppSettings): void => cb(s) + ipcRenderer.on(Ipc.SETTINGS_CHANGED, listener) + return () => ipcRenderer.removeListener(Ipc.SETTINGS_CHANGED, listener) + }, + + listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST), + + listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST), + getLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_GET, id), + saveLayout: (meta: LayoutMeta, json: string) => ipcRenderer.invoke(Ipc.LAYOUTS_SAVE, meta, json), + deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id) +} + +contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..a012544 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,15 @@ + + + + + OpenTerminal + + + +

+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx new file mode 100644 index 0000000..5f44934 --- /dev/null +++ b/src/renderer/src/App.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import Workspace from '@renderer/workspace/Workspace' +import { SettingsDialog } from '@renderer/settings/SettingsDialog' +import { TransferPanel } from '@renderer/sftp/TransferPanel' +import { useSettingsStore } from '@renderer/settings/store' +import { getThemeById } from '@shared/theme' + +/** Custom window title bar: draggable strip themed with the active terminal + * theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */ +function TitleBar(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const theme = getThemeById(settings.terminal.themeId, settings.customThemes) + return ( +
+ OpenTerminal +
+ ) +} + +export default function App(): React.JSX.Element { + const hydrate = useSettingsStore((s) => s.hydrate) + const [settingsOpen, setSettingsOpen] = useState(false) + + useEffect(() => { + void hydrate() + }, [hydrate]) + + return ( +
+ + setSettingsOpen(true)} /> + setSettingsOpen(false)} /> + +
+ ) +} diff --git a/src/renderer/src/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx new file mode 100644 index 0000000..ee77766 --- /dev/null +++ b/src/renderer/src/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode +} + +interface ErrorBoundaryState { + error: Error | null +} + +/** + * Last-resort boundary: a render/effect crash must never take the whole app + * down to a black window. Shows the error and a reload escape instead. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + render(): ReactNode { + if (this.state.error) { + return ( +
+
界面出现异常
+
+            {this.state.error.message}
+          
+ +
+ ) + } + return this.props.children + } +} diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx new file mode 100644 index 0000000..81d4527 --- /dev/null +++ b/src/renderer/src/commands/CommandsPanel.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useState } from 'react' +import { Button, Modal, Popconfirm, Tabs } from 'antd' +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' +import type { CommandItem } from '@shared/commands' +import { useWorkspaceModeStore } from '../workspace/workspaceModeStore' +import './commands.css' + +export interface CommandsPanelProps { + /** run a command in the currently active terminal/ssh panel */ + onRun: (cmd: string) => void +} + +/** Short "time ago" for history rows (kept tiny — mirrors the connection patch). */ + +/** local date/time string for a row's sub-line */ +export function formatClock(ts: number): string { + return new Date(ts).toLocaleString() +} + +/** + * [M5] 「命令」 sidebar section: command history (listHistory/clearHistory) and + * a small command library (saveLibraryItem/deleteLibraryItem). Clicking a row + * runs the command via the parent workspace's `onRun`. + */ +export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element { + const [history, setHistory] = useState([]) + const [library, setLibrary] = useState([]) + const [loaded, setLoaded] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [name, setName] = useState('') + const [command, setCommand] = useState('') + const [note, setNote] = useState('') + /** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */ + const workspaceMode = useWorkspaceModeStore((s) => s.mode) + + const refresh = useCallback((): void => { + void Promise.all([window.api.listHistory(), window.api.listLibrary()]) + .then(([h, l]) => { + setHistory(h) + setLibrary(l) + }) + .catch(() => { + setHistory([]) + setLibrary([]) + }) + .finally(() => setLoaded(true)) + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + const handleClearHistory = useCallback(async (): Promise => { + await window.api.clearHistory() + refresh() + }, [refresh]) + + const handleDeleteLibrary = useCallback( + async (id: string): Promise => { + await window.api.deleteLibraryItem(id) + refresh() + }, + [refresh] + ) + + const handleAdd = useCallback((): void => { + setName('') + setCommand('') + setNote('') + setAddOpen(true) + }, []) + + const handleSave = useCallback(async (): Promise => { + const trimmed = command.trim() + if (!trimmed) return + await window.api.saveLibraryItem({ + id: window.crypto.randomUUID(), + name: name.trim() || undefined, + command: trimmed, + note: note.trim() || undefined, + createdAt: Date.now(), + lastUsedAt: Date.now() + }) + setAddOpen(false) + refresh() + }, [name, command, note, refresh]) + + return ( +
+
+ 发送目标 + {workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'} +
+ +
+ 历史命令 + {history.length > 0 && ( + void handleClearHistory()} + > + + + )} +
+ {history.length === 0 ? ( +
{loaded ? '暂无历史命令' : '加载中…'}
+ ) : ( +
    + {history.map((item) => ( + + ))} +
+ )} +
+ ) + }, + { + key: 'library', + label: '命令库', + children: ( +
+
+ 命令库 +
+ {library.length === 0 ? ( +
{loaded ? '命令库为空' : '加载中…'}
+ ) : ( +
    + {library.map((item) => ( + void handleDeleteLibrary(item.id)} + /> + ))} +
+ )} +
+ ) + } + ]} + /> + + void handleSave()} + onCancel={() => setAddOpen(false)} + className="commands-modal" + > +
+ + + +
+
+ + ) +} + +function Row({ + item, + onRun, + onDelete +}: { + item: CommandItem + onRun: (cmd: string) => void + onDelete?: () => void +}): React.JSX.Element { + return ( +
  • onRun(item.command)} title={item.command}> +
    +
    {item.name ?? item.command}
    +
    + {item.name ? item.command : ''} + {item.name ? ' · ' : ''} + {formatClock(item.createdAt)} +
    +
    + {item.name && onDelete && ( + +
  • + ) +} \ No newline at end of file diff --git a/src/renderer/src/commands/commands.css b/src/renderer/src/commands/commands.css new file mode 100644 index 0000000..f84d7b2 --- /dev/null +++ b/src/renderer/src/commands/commands.css @@ -0,0 +1,164 @@ +/* Command history / library sidebar section (commands/**). */ + +.commands-panel { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; +} + +.commands-panel .ant-tabs { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.commands-panel .ant-tabs-nav { + margin: 0; +} + +.commands-panel .ant-tabs-content-holder { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.commands-panel .ant-tabs-content, +.commands-panel .ant-tabs-tabpane { + height: 100%; +} + +.commands-section { + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.commands-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 2px 4px 6px; +} + +.commands-section-title { + flex: 1 1 auto; + font-size: 11px; + font-weight: 600; + color: #8a8a8a; + letter-spacing: 0.6px; +} + +.commands-clear-btn { + color: #8a8a8a; + font-size: 11px; + padding: 0 2px; +} + +.commands-clear-btn:hover { + color: #cf6679 !important; +} + +.commands-empty { + padding: 20px 8px; + text-align: center; + color: #8a8a8a; + font-size: 12px; +} + +.commands-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +.commands-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 6px 6px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.commands-row:hover { + background: var(--chrome-hover); +} + +.commands-row-main { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.commands-row-name { + font-size: 13px; + color: var(--chrome-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commands-row-sub { + font-size: 11px; + color: #8a8a8a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.commands-row-del { + flex: 0 0 auto; + display: none; + color: #9a9a9a; +} + +.commands-row:hover .commands-row-del { + display: inline-flex; +} + +.commands-row-del:hover { + color: #cf6679 !important; +} + +/* ---- add-library modal ---- */ +.commands-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.commands-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.commands-field-label { + font-size: 12px; + color: var(--chrome-fg); +} + +.commands-input { + width: 100%; + padding: 5px 8px; + border: 1px solid var(--chrome-border); + border-radius: 4px; + background: var(--chrome-bg); + color: var(--chrome-fg); + font: inherit; + box-sizing: border-box; + outline: none; +} + +.commands-input:focus { + border-color: var(--chrome-accent); +} \ No newline at end of file diff --git a/src/renderer/src/connections/Common.tsx b/src/renderer/src/connections/Common.tsx new file mode 100644 index 0000000..177cf23 --- /dev/null +++ b/src/renderer/src/connections/Common.tsx @@ -0,0 +1,84 @@ +import type { SshAuthMethod, SshConnection, SshConnectionInput, SshSavedAuthFlags } from '@shared/connections' + +/** + * Shared helpers for the connection manager UI (connections/**, renderer). + * Nothing here ever holds secret plaintext; secrets live only in the dialog's + * controlled Form state and are stripped before anything is passed around. + */ + +/** Numeric parse for InputNumber stores: undefined/'' → null, else Number(). */ +export function num(v: unknown): number | null { + if (v == null || v === '') return null + return Number(v) +} + +export type SecretKind = 'password' | 'keyContent' | 'passphrase' + +export function hasSavedKind(kind: SecretKind, savedAuth: SshSavedAuthFlags | undefined): boolean { + if (!savedAuth) return false + if (kind === 'password') return savedAuth.hasPassword + if (kind === 'keyContent') return savedAuth.hasKeyContent + return savedAuth.hasPassphrase +} + +export function authLabel(kind: SshAuthMethod): string { + return kind === 'privateKey' ? '私钥' : kind === 'agent' ? 'Agent' : '密码' +} + +export function AuthTags({ auth }: { auth: SshAuthMethod }): React.JSX.Element { + return {authLabel(auth)} +} + +export interface BuildCtx { + /** present when editing; its id is reused for the update */ + conn?: SshConnection | null +} + +/** + * Build the renderer→main save payload from raw Form values. + * Secret fields are included ONLY when a non-empty value was typed; an empty + * value means "keep the previously stored one" and the key is simply omitted, + * so no plaintext secret is ever passed through this path on edit. + */ +export function buildInput(values: Record, ctx: BuildCtx): SshConnectionInput { + const auth = values.auth as SshAuthMethod + const askPasswordAtConnect = Boolean(values.askPasswordAtConnect) + const askPassphraseAtConnect = Boolean(values.askPassphraseAtConnect) + + const rawGroup = values.group + const group = Array.isArray(rawGroup) + ? rawGroup.find((g): g is string => typeof g === 'string' && g.trim() !== '') + : typeof rawGroup === 'string' && rawGroup.trim() !== '' + ? rawGroup.trim() + : undefined + + const input: SshConnectionInput = { + id: ctx.conn?.id, + name: String(values.name ?? '').trim(), + host: String(values.host ?? '').trim(), + port: num(values.port) ?? 22, + username: String(values.username ?? '').trim(), + auth, + askPasswordAtConnect, + askPassphraseAtConnect, + keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30, + group + } + + const secret = (key: SecretKind): string | undefined => { + const raw = values[key] + return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined + } + + if (auth === 'password') { + if (!askPasswordAtConnect) input.password = secret('password') + } else if (auth === 'privateKey') { + if (typeof values.keyPath === 'string' && values.keyPath.trim() !== '') { + input.keyPath = values.keyPath.trim() + } + input.keyContent = secret('keyContent') + if (!askPassphraseAtConnect) input.passphrase = secret('passphrase') + } + // agent: no secret fields in the input + return input +} \ No newline at end of file diff --git a/src/renderer/src/connections/ConnectionEditDialog.tsx b/src/renderer/src/connections/ConnectionEditDialog.tsx new file mode 100644 index 0000000..05b6426 --- /dev/null +++ b/src/renderer/src/connections/ConnectionEditDialog.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd' +import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections' +import { AuthFieldsRenderer } from './Fields' +import { buildInput, num, hasSavedKind, type SecretKind } from './Common' + +export interface ConnectionEditDialogProps { + open: boolean + /** connection being edited; null = create new */ + editing: SshConnection | null + onClose: () => void + onSaved: () => void +} + +/** + * Modal for creating / editing an SSH bookmark. + * + * Secret handling: the form holds only what the user typed. On edit, secret + * inputs start empty and stay empty to mean "keep the stored value" — those + * fields are simply omitted from the save payload (see buildInput). No secret + * plaintext is ever written to the console or logs. + */ +export function ConnectionEditDialog({ + open, + editing, + onClose, + onSaved +}: ConnectionEditDialogProps): React.JSX.Element { + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const [errorMsg, setErrorMsg] = useState(null) + const [auth, setAuth] = useState('password') + + /** Editing target identity; re-initializes the form when it changes. */ + const editKey = editing?.id ?? 'new' + + // Unique group names from all saved connections, for the group Select. + const [groupOptions, setGroupOptions] = useState([]) + useEffect(() => { + if (!open) return + window.api + .listConnections() + .then((conns: SshConnection[]) => { + const seen = new Set() + for (const c of conns) { + if (c.group) seen.add(c.group) + } + setGroupOptions([...seen].sort()) + }) + .catch(() => { + // sidebar keeps its own list; a transient failure here just leaves the + // group field as a plain text input + setGroupOptions([]) + }) + }, [open]) + + // Populate / clear the form for each open + target change. + useEffect(() => { + if (!open) return + setErrorMsg(null) + const base: SshAuthMethod = editing?.auth ?? 'password' + setAuth(base) + form.setFieldsValue({ + name: editing?.name, + group: editing?.group, + host: editing?.host, + port: editing?.port, + username: editing?.username, + auth: base, + askPasswordAtConnect: editing?.askPasswordAtConnect, + askPassphraseAtConnect: editing?.askPassphraseAtConnect, + keyPath: editing?.keyPath, + keepaliveIntervalSec: editing?.keepaliveIntervalSec + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, editKey, form]) + + const handleSave = async (): Promise => { + const values = await form.validateFields() + const input = buildInput(values as unknown as Record, { + conn: editing + }) + setSaving(true) + setErrorMsg(null) + try { + await window.api.saveConnection(input) + onSaved() + onClose() + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : '保存失败,请检查输入后重试') + } finally { + setSaving(false) + } + } + + const handleCancel = (): void => { + if (!saving) onClose() + } + + const savedNotes = useMemo(() => { + if (!editing) return [] + const kinds: Array<{ kind: SecretKind; label: string }> = [ + { kind: 'password', label: '密码' }, + { kind: 'keyContent', label: '私钥' }, + { kind: 'passphrase', label: '口令' } + ] + const active = auth === 'password' ? ['password'] : auth === 'privateKey' ? ['keyContent', 'passphrase'] : [] + return kinds.filter((k) => active.includes(k.kind) && hasSavedKind(k.kind, editing.savedAuth)) + }, [editing, auth]) + + return ( + + {errorMsg} + + + + } + > +
    void handleSave()} + > + + + + + + + + + + + + + + + + + + setAuth(e.target.value as SshAuthMethod)}> + 密码 + 私钥 + Agent + + + + {savedNotes.length > 0 && + savedNotes.map((n) => ( + + ))} + + + + + + + +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx new file mode 100644 index 0000000..e636be6 --- /dev/null +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -0,0 +1,320 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { Button, Collapse, Popconfirm, Tooltip } from 'antd' +import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' +import type { SshConnection } from '@shared/connections' +import { AuthTags } from './Common' +import { ConnectionEditDialog } from './ConnectionEditDialog' +import { CommandsPanel } from '../commands/CommandsPanel' +import { useWorkspaceModeStore } from '../workspace/workspaceModeStore' +import './connections.css' +import '../workspace/workspace.css' + +export interface ConnectionSidebarProps { + className?: string + /** called when the user double-clicks a connection (or clicks a recent session) */ + onConnect: (conn: SshConnection) => void + /** open local terminal panels fed by the workspace ("终端" section). */ + localPanels?: Array<{ id: string; title: string }> + /** id of the currently active panel in the workspace, for highlighting. */ + activePanelId?: string | null + /** focus a local terminal panel; e.g. dockview api.getPanel(id)?.setActive(). */ + onFocusPanel?: (panelId: string) => void + /** [M5] run a command in the workspace's currently active panel. */ + onRunCommand?: (cmd: string) => void +} + +export interface ConnectionSidebarHandle { + /** reload the connection list from main */ + refresh: () => Promise +} + +const DEFAULT_GROUP = '默认' + +/** Short human "time ago" for the recent-session rows. */ +function formatRelativeTime(ts: number): string { + const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000)) + if (diffSec < 60) return '刚刚' + const mins = Math.floor(diffSec / 60) + if (mins < 60) return `${mins} 分钟前` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} 小时前` + const days = Math.floor(hours / 24) + if (days < 30) return `${days} 天前` + return new Date(ts).toLocaleDateString() +} + +/** + * Left-hand SSH bookmark sidebar (XTerminal server-list style). + * + * Refresh contract: the parent may call `useRef()` and + * invoke `refresh()` after opening an SSH session elsewhere (e.g. to update + * `lastConnectedAt`), or simply remount the component with a `key`. + */ +export const ConnectionSidebar = forwardRef( + function ConnectionSidebar( + { + className, + onConnect, + localPanels = [], + activePanelId, + onFocusPanel, + onRunCommand + }: ConnectionSidebarProps, + ref + ): React.JSX.Element { + const mode = useWorkspaceModeStore((s) => s.mode) + const isTerminal = mode === 'terminal' + const [connections, setConnections] = useState([]) + const [loaded, setLoaded] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const refresh = async (): Promise => { + try { + const conns = await window.api.listConnections() + setConnections(conns) + } catch { + setConnections([]) + } finally { + setLoaded(true) + } + } + + useImperativeHandle(ref, () => ({ refresh }), [refresh]) + + useEffect(() => { + void refresh() + }, []) + + // Fold every group open whenever the dataset changes. + const activeGroups = useMemo(() => { + const groups = new Set() + for (const c of connections) groups.add(c.group ?? DEFAULT_GROUP) + return [...groups] + }, [connections]) + + const groups = useMemo(() => { + const map = new Map() + for (const c of connections) { + const key = c.group ?? DEFAULT_GROUP + const list = map.get(key) + if (list) list.push(c) + else map.set(key, [c]) + } + const entries = [...map.entries()].sort((a, b) => { + if (a[0] === DEFAULT_GROUP) return -1 + if (b[0] === DEFAULT_GROUP) return 1 + return a[0].localeCompare(b[0]) + }) + return entries.map(([group, list]) => ({ group, list })) + }, [connections]) + + const openCreate = (): void => { + setEditing(null) + setDialogOpen(true) + } + + const openEdit = (conn: SshConnection): void => { + setEditing(conn) + setDialogOpen(true) + } + + const handleDelete = async (id: string): Promise => { + await window.api.deleteConnection(id) + await refresh() + } + + const handleSaved = (): void => { + void refresh() + } + + // Most-recently-connected servers (top 5, most recent first). The active + // connection list must be consulted so the row renders its auth badge etc. + const recent = useMemo(() => { + return connections + .filter((c) => typeof c.lastConnectedAt === 'number') + .sort((a, b) => (b.lastConnectedAt as number) - (a.lastConnectedAt as number)) + .slice(0, 5) + }, [connections]) + + const collapseItems = groups.map(({ group, list }) => ({ + key: group, + label: ( +
    + {group} + {list.length} +
    + ), + children: ( +
    + {list.map((conn) => ( + onConnect(conn)} + onEdit={() => openEdit(conn)} + onDelete={() => void handleDelete(conn.id)} + /> + ))} +
    + ) + })) + + return ( + + ) + } +) + +function Row({ + conn, + onDoubleClick, + onEdit, + onDelete +}: { + conn: SshConnection + onDoubleClick: () => void + onEdit: () => void + onDelete: () => void +}): React.JSX.Element { + const [confirming, setConfirming] = useState(false) + return ( +
    +
    +
    + {conn.name} + + + +
    +
    + {conn.group ? `${conn.group} · ` : ''} + {conn.host}:{conn.port} · {conn.username} +
    +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/Fields.tsx b/src/renderer/src/connections/Fields.tsx new file mode 100644 index 0000000..6568bd9 --- /dev/null +++ b/src/renderer/src/connections/Fields.tsx @@ -0,0 +1,89 @@ +import { Form, Input, Switch } from 'antd' +import useFormInstance from 'antd/es/form/hooks/useFormInstance' +import { useWatch } from 'antd/es/form/Form' +import type { SshAuthMethod } from '@shared/connections' + +/** + * Auth-method dependent fields rendered inside the edit dialog's
    . + * Secret inputs (password / key content / passphrase) are controlled Form + * state only — they never enter connection objects shown elsewhere, and no + * plaintext is ever logged here. + */ + +export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX.Element { + const form = useFormInstance() + const askPasswordAtConnect = useWatch('askPasswordAtConnect', form) ?? false + const askPassphraseAtConnect = useWatch('askPassphraseAtConnect', form) ?? false + + if (auth === 'agent') { + return
    使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。
    + } + + return ( +
    + {auth === 'password' ? ( + <> + + {!askPasswordAtConnect && ( + + + + )} + + ) : ( + <> + + + + + + + + {!askPassphraseAtConnect && ( + + + + )} + + )} +
    + ) +} + +function SwitchRow({ + name, + label, + hint +}: { + name: 'askPasswordAtConnect' | 'askPassphraseAtConnect' + label: string + hint: string +}): React.JSX.Element { + const form = useFormInstance() + const checked = useWatch(name, form) ?? false + const set = (next: boolean): void => { + form.setFieldValue(name, next) + } + return ( +
    + + {label} +
    {hint}
    +
    + +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/connections.css b/src/renderer/src/connections/connections.css new file mode 100644 index 0000000..62476b3 --- /dev/null +++ b/src/renderer/src/connections/connections.css @@ -0,0 +1,439 @@ +/* ============================================================ + Connection manager UI (connections/**): sidebar + edit dialog. + Palette is driven by the global chrome variables (global.css). + ============================================================ */ + +/* ---------- sidebar shell ---------- */ + +.connections-sidebar { + width: 240px; + flex: 0 0 240px; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--chrome-bg); + border-right: 1px solid var(--chrome-border); + box-sizing: border-box; +} + +.connections-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px 0 12px; + height: 34px; + box-sizing: border-box; +} + +.connections-title { + font-size: 13px; + font-weight: 600; + color: var(--chrome-fg); + letter-spacing: 0.5px; +} + +.connections-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 8px 12px; + box-sizing: border-box; +} + +/* ---------- grouped accordion ---------- */ + +.connections-group-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.connections-group-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--chrome-fg); +} + +.connections-group-count { + flex: 0 0 auto; + font-size: 11px; + color: #8a8a8a; + background: rgba(255, 255, 255, 0.08); + border-radius: 9px; + padding: 0 7px; + line-height: 18px; +} + +.connections-collapse .ant-collapse-item { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.connections-collapse .ant-collapse-item:last-child { + border-bottom: none; +} + +.connections-collapse .ant-collapse-header { + align-items: center; + padding: 7px 4px; +} + +.connections-collapse .ant-collapse-content > .ant-collapse-content-box { + padding: 0 0 6px; +} + +/* ---------- connection rows ---------- */ + +.connections-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 6px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.connections-row:hover { + background: var(--chrome-hover); +} + +.connections-row-main { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.connections-row-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--chrome-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.connections-row-sub { + font-size: 11px; + color: #8a8a8a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +/* text-only auth badge: 密码 / 私钥 / Agent */ +.connections-auth-badge { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +.connections-row-actions { + flex: 0 0 auto; + display: none; + align-items: center; + gap: 0; +} + +.connections-row:hover .connections-row-actions { + display: inline-flex; +} + +.connections-row-actions .ant-btn { + color: #9a9a9a; +} + +.connections-row-actions .ant-btn-text:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +/* ---------- empty state ---------- */ + +.connections-empty { + padding: 28px 8px; + text-align: center; + color: #8a8a8a; + font-size: 12px; + line-height: 20px; +} + +/* ---------- edit dialog ---------- */ + +.connections-dialog .connections-auth-agents { + display: flex; + flex-direction: column; + gap: 16px; +} + +.connections-form-section { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 8px; +} + +.connections-form-section-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.5px; + margin: 10px 0 0; +} + +.connections-switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 2px 0 14px; +} + +.connections-switch-label { + color: rgba(255, 255, 255, 0.88); + font-size: 13px; +} + +.connections-switch-hint { + color: rgba(255, 255, 255, 0.45); + font-size: 12px; + margin-top: 2px; +} + +.connections-dialog .ant-form-item { + margin-bottom: 14px; +} + +.connections-dialog .ant-form-item:last-child { + margin-bottom: 0; +} + +.connections-dialog .ant-alert { + font-size: 12px; +} + +.connections-secret-note { + margin-bottom: 12px; +} + +.connections-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.connections-dialog-error { + flex: 1 1 auto; + min-width: 0; + font-size: 12px; + color: #cf6679; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.connections-empty-create { + border: none; + background: none; + color: #8a8a8a; + font: inherit; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.connections-empty-create:hover { + color: #ffffff; + background: var(--chrome-hover); +} + +.connections-row-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---------- dual sections: 终端 / SSH 服务器 ---------- */ + +.connections-section { + margin-bottom: 12px; +} + +.connections-section-head { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 4px 6px; +} + +.connections-section-title { + flex: 1 1 auto; + font-size: 11px; + font-weight: 600; + color: #8a8a8a; + letter-spacing: 0.6px; +} + +.connections-section-count { + flex: 0 0 auto; + font-size: 11px; + color: #8a8a8a; + background: rgba(255, 255, 255, 0.08); + border-radius: 9px; + padding: 0 7px; + line-height: 18px; +} + +.connections-section-empty { + padding: 10px 8px; + color: #6a6a6a; + font-size: 12px; +} + +/* local terminal list — capped height + inner scroll so many panes never + crowd out the SSH/command sections below. */ +.connections-local-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; +} + +.connections-local-list::-webkit-scrollbar { + width: 8px; +} + +.connections-local-list::-webkit-scrollbar-track { + background: transparent; +} + +.connections-local-list::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.45); + border-radius: 4px; +} + +.connections-local-item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.connections-local-item:hover { + background: var(--chrome-hover); +} + +.connections-local-item.is-active { + background: rgba(63, 185, 80, 0.12); +} + +.connections-local-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--chrome-fg); +} + +.connections-local-tag { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +/* recent sessions */ +.connections-recent { + margin-top: 8px; +} + +.connections-recent-head { + padding: 2px 4px 4px; +} + +.connections-recent-title { + font-size: 11px; + font-weight: 600; + color: #8a8a8a; + letter-spacing: 0.6px; +} + +.connections-recent-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + border: none; + background: none; + font: inherit; + text-align: left; + padding: 5px 6px 5px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; + color: var(--chrome-fg); +} + +.connections-recent-item:hover { + background: var(--chrome-hover); +} + +.connections-recent-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.connections-recent-time { + flex: 0 0 auto; + font-size: 11px; + color: #6a6a6a; +} + +/* [M5] command history/library sidebar section fills the rest of the list. */ +.commands-sidebar-section { + flex: 1 1 0; + min-height: 160px; + display: flex; + flex-direction: column; + margin-bottom: 0; +} + +.commands-sidebar-section .connections-section-head { + flex: 0 0 auto; +} + +.commands-sidebar-section .commands-panel { + flex: 1 1 auto; + min-height: 0; +} \ No newline at end of file diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts new file mode 100644 index 0000000..e8e6140 --- /dev/null +++ b/src/renderer/src/env.d.ts @@ -0,0 +1,9 @@ +import type { AppApi } from '../shared/api' + +declare global { + interface Window { + api: AppApi + } +} + +export {} diff --git a/src/renderer/src/global.css b/src/renderer/src/global.css new file mode 100644 index 0000000..5551b7f --- /dev/null +++ b/src/renderer/src/global.css @@ -0,0 +1,86 @@ +/* Global chrome styles. Terminal-specific styles live in their own modules. */ +:root { + --chrome-bg: #181818; + --chrome-fg: #cccccc; + --chrome-border: #2d2d2d; + --chrome-accent: #3b8eea; + --chrome-hover: #2a2a2a; +} + +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--chrome-bg); + color: var(--chrome-fg); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 13px; +} + +/* App-wide slim dark scrollbars: the native Windows style renders light + tracks + arrow buttons that look broken on the dark surface. */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(121, 121, 121, 0.45) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.45); + border-radius: 5px; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(160, 160, 160, 0.6); +} + +*::-webkit-scrollbar-button, +*::-webkit-scrollbar-corner { + display: none; + background: transparent; +} + +button { + font: inherit; +} + +/* antd renders this wrapper between #root and .app-root; without an + explicit height the percentage chain breaks and the workspace collapses. */ +.ant-app { + height: 100%; +} + +.app-root { + height: 100%; + display: flex; + flex-direction: column; +} + +/* ---- custom window title bar (native buttons via titleBarOverlay) ---- */ +.app-titlebar { + flex: 0 0 36px; + display: flex; + align-items: center; + /* right padding clears the native min/max/close overlay buttons */ + padding: 0 148px 0 14px; + -webkit-app-region: drag; + user-select: none; + font-size: 12px; + letter-spacing: 0.2px; +} + +.app-titlebar-title { + opacity: 0.75; + font-weight: 500; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx new file mode 100644 index 0000000..0f76e71 --- /dev/null +++ b/src/renderer/src/main.tsx @@ -0,0 +1,161 @@ +import ReactDOM from 'react-dom/client' +import { useEffect } from 'react' +import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd' +import type { AppApi } from '@shared/api' +import { DEFAULT_SETTINGS } from '@shared/settings' +import App from './App' +import { ErrorBoundary } from './ErrorBoundary' +import ZmodemOffers from './workspace/ZmodemOffers' +import { useSettingsStore } from './settings/store' +import './global.css' + +// Dev convenience: allow opening the renderer in a plain browser (vite page) +// so layout/UX work doesn't require the electron shell. No-op under electron. +if (typeof window !== 'undefined' && !window.api) { + const noop = (): void => undefined + const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}` + const api: AppApi = { + appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }), + createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), + openSession: async () => ({ id: stubId() }), + getSessionReplay: async () => '', + writePty: noop, + resizePty: noop, + killPty: noop, + onPtyData: () => noop, + onPtyExit: () => noop, + sysinfoStart: noop, + sysinfoStop: noop, + onSysinfoMeta: () => noop, + onSysinfoSample: () => noop, + listRemote: async () => [], + mkdirRemote: async () => undefined, + renameRemote: async () => undefined, + deleteRemote: async () => undefined, + chmodRemote: async () => undefined, + chownRemote: async () => undefined, + uploadRemote: async () => stubId(), + downloadRemote: async () => stubId(), + cancelTransfer: noop, + onTransferProgress: () => noop, + pickFiles: async () => [], + pickDirectory: async () => '', + recordCommand: async () => undefined, + listHistory: async () => [], + clearHistory: async () => undefined, + listLibrary: async () => [], + saveLibraryItem: async (item) => item, + deleteLibraryItem: async () => undefined, + logStart: async (sessionId) => ({ + sessionId, + file: '', + fileName: '', + startedAt: Date.now() + }), + logStop: async () => undefined, + listSessionLogs: async () => [], + openLogsDir: noop, + listConnections: async () => [], + saveConnection: async (input) => ({ + id: input.id ?? stubId(), + name: input.name, + group: input.group, + host: input.host, + port: input.port, + username: input.username, + auth: input.auth, + askPasswordAtConnect: input.askPasswordAtConnect, + askPassphraseAtConnect: input.askPassphraseAtConnect, + keyPath: input.keyPath, + keepaliveIntervalSec: input.keepaliveIntervalSec, + createdAt: Date.now(), + savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false } + }), + deleteConnection: async () => undefined, + onZmodemOffer: () => noop, + zmodemRespond: noop, + onZmodemDone: () => noop, + onHostKeyPrompt: () => noop, + respondHostKey: noop, + getSettings: async () => DEFAULT_SETTINGS, + saveSettings: async (s) => s, + onSettingsChanged: () => noop, + listFonts: async () => [], + listLayouts: async () => [], + getLayout: async () => null, + saveLayout: async () => undefined, + deleteLayout: async () => undefined + } + ;(window as unknown as { api: AppApi }).api = api +} + +const MIN_FONT = 8 +const MAX_FONT = 40 +const RESET_FONT = 14 + +/** + * Window-level font-size hotkeys (Ctrl+Equal/+ / Ctrl+Minus/- / Ctrl+Digit0). + * Uses the same updateTerminal store path as the settings dialog so TerminalView + * re-renders immediately. Skipped while typing in an input/textarea/contentEditable. + */ +function FontHotkeyListener(): null { + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent): void => { + if (!e.ctrlKey || e.metaKey) return + const target = e.target as HTMLElement | null + const isXtermHelper = + target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea') + if ( + target && + !isXtermHelper && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return + } + + let delta: number | undefined + if (e.key === '=' || e.key === '+') delta = 1 + else if (e.key === '-') delta = -1 + else if (e.key === '0') delta = 0 + + if (delta === undefined) return + e.preventDefault() + e.stopPropagation() + + const current = useSettingsStore.getState().settings.terminal.fontSize + const next = + delta === 0 + ? RESET_FONT + : Math.min(MAX_FONT, Math.max(MIN_FONT, current + delta)) + void updateTerminal({ fontSize: next }) + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [updateTerminal]) + + return null +} + +// antd provides the context that App.useApp() consumers (workspace +// message/notification) rely on; without it they get empty stubs and crash. +// ErrorBoundary keeps any render crash from black-screening the whole window. +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + +) diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx new file mode 100644 index 0000000..52301b3 --- /dev/null +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -0,0 +1,272 @@ +import { useEffect, useRef, useState } from 'react' +import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' +import './monitor.css' + +export interface MonitorPanelProps { + /** ssh session id */ + sessionId: string +} + +/** Max history points kept (60 samples @ 3s = ~3 minutes). */ +const HISTORY_LEN = 60 + +interface HistoryChartProps { + data: number[] + lineColor: string + fillColor: string + /** canvas CSS height in px */ + height: number + /** fixed Y max; omit for auto-scale (max * 1.2) */ + maxY?: number +} + +/** Hand-drawn area line chart on a DPR-scaled canvas. No third-party deps. */ +function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element { + const containerRef = useRef(null) + const canvasRef = useRef(null) + const dataRef = useRef(data) + dataRef.current = data + + useEffect(() => { + const container = containerRef.current + const canvas = canvasRef.current + if (!container || !canvas) return + + const draw = (): void => { + const dpr = window.devicePixelRatio || 1 + const width = Math.max(1, container.clientWidth) + canvas.width = Math.floor(width * dpr) + canvas.height = Math.floor(height * dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const pts = dataRef.current + if (pts.length < 2) return + + const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2) + const x = (i: number): number => (i / (pts.length - 1)) * width + const y = (v: number): number => height - (Math.min(v, top) / top) * height + + // area fill + ctx.beginPath() + pts.forEach((v, i) => { + if (i === 0) ctx.moveTo(x(i), y(v)) + else ctx.lineTo(x(i), y(v)) + }) + ctx.lineTo(x(pts.length - 1), height) + ctx.lineTo(0, height) + ctx.closePath() + ctx.fillStyle = fillColor + ctx.fill() + + // line + ctx.beginPath() + pts.forEach((v, i) => { + if (i === 0) ctx.moveTo(x(i), y(v)) + else ctx.lineTo(x(i), y(v)) + }) + ctx.strokeStyle = lineColor + ctx.lineWidth = 1.5 + ctx.lineJoin = 'round' + ctx.lineCap = 'round' + ctx.stroke() + } + + draw() + const ro = new ResizeObserver(draw) + ro.observe(container) + return () => ro.disconnect() + }, [height, maxY, lineColor, fillColor]) + + return ( +
    + +
    + ) +} + +/** Red/green/yellow threshold bar color for CPU usage. */ +function cpuColor(usage: number): string { + if (usage > 80) return '#f85149' + if (usage > 60) return '#e3b341' + return '#3fb950' +} + +/** Format a size in MB: show GB (1 decimal) once over 1024 MB. */ +function formatSize(mb: number): string { + const gb = mb / 1024 + if (gb >= 1) return `${gb.toFixed(1)}G` + return `${Math.round(mb)}MB` +} + +/** human-readable uptime */ +function humanizeUptime(sec: number): string { + const s = Math.floor(sec) + if (s < 60) return `${s}秒` + const mins = Math.floor(s / 60) + if (s < 3600) return `${mins}分钟` + const hours = Math.floor(s / 3600) + if (s < 86400) return `${hours}小时${Math.floor((s % 3600) / 60)}分` + return `${Math.floor(s / 86400)}天` +} + +function Bar({ pct, color }: { pct: number; color: string }) { + const clamped = Math.max(0, Math.min(100, pct)) + return ( +
    +
    +
    + ) +} + +export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Element { + const [meta, setMeta] = useState(null) + const [sample, setSample] = useState(null) + const sampleRef = useRef(null) + const [cpuHist, setCpuHist] = useState([]) + const [rxHist, setRxHist] = useState([]) + const [txHist, setTxHist] = useState([]) + + useEffect(() => { + let disposed = false + setCpuHist([]) + setRxHist([]) + setTxHist([]) + window.api.sysinfoStart(sessionId) + const offMeta = window.api.onSysinfoMeta((e: { id: string; meta: SysinfoMeta }) => { + if (e.id !== sessionId || disposed) return + setMeta(e.meta) + }) + const offSample = window.api.onSysinfoSample((e: { id: string; sample: SysinfoSample }) => { + if (e.id !== sessionId || disposed) return + sampleRef.current = e.sample + setSample(e.sample) + if (!e.sample.error) { + setCpuHist((prev) => (prev.length >= HISTORY_LEN ? [...prev.slice(1), e.sample.cpu.usage] : [...prev, e.sample.cpu.usage])) + setRxHist((prev) => (prev.length >= HISTORY_LEN ? [...prev.slice(1), e.sample.net.rxKbs] : [...prev, e.sample.net.rxKbs])) + setTxHist((prev) => (prev.length >= HISTORY_LEN ? [...prev.slice(1), e.sample.net.txKbs] : [...prev, e.sample.net.txKbs])) + } + }) + return () => { + disposed = true + offMeta() + offSample() + window.api.sysinfoStop(sessionId) + } + }, [sessionId]) + + if (!meta || !sample) { + return ( +
    + + 采集中… +
    + ) + } + + const cpu = sample.cpu + const mem = sample.mem + const disks = sample.disks ?? [] + const net = sample.net + const failed = Boolean(sample.error) + const memPct = mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0 + + return ( +
    + {failed &&
    采集中断:{sample.error}
    } + +
    + {meta.hostname || 'unknown'} + {meta.os} +
    + +
    +
    + CPU + + {cpu.usage.toFixed(0)}% · {cpu.cores} 核 + +
    + +
    + 负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')} +
    + +
    + +
    +
    + 内存 + + {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} + +
    +
    +
    + +
    + {memPct.toFixed(0)}% +
    +
    + + {disks.length > 0 && ( +
    +
    + 磁盘 +
    +
    + {disks.slice(0, 6).map((d) => { + const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0 + return ( +
    + + {d.mount} + +
    + +
    + + {formatSize(d.usedMb)} / {formatSize(d.totalMb)} + +
    + ) + })} +
    +
    + )} + +
    +
    + 网络 +
    + + ↓ + {(net?.rxKbs ?? 0).toFixed(4)} + KB/s + + + ↑ + {(net?.txKbs ?? 0).toFixed(4)} + KB/s + +
    +
    +
    + + +
    +
    + +
    + 运行时间 {humanizeUptime(sample.uptimeSec)} +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/monitor/monitor.css b/src/renderer/src/monitor/monitor.css new file mode 100644 index 0000000..80d69cf --- /dev/null +++ b/src/renderer/src/monitor/monitor.css @@ -0,0 +1,290 @@ +/* M3 server sysinfo monitor panel (SSH sessions only). */ + +.mm-root { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + background: #1a1a1a; + color: var(--chrome-fg); + font-size: 12px; + overflow-y: auto; +} + +/* error state: dim the whole panel + show a notice */ +.mm-failed { + opacity: 0.7; +} + +.mm-error { + border: 1px solid #f85149; + border-radius: 6px; + background: rgba(248, 81, 73, 0.12); + color: #f85149; + padding: 5px 8px; + font-size: 12px; +} + +/* ---------- header ---------- */ +.mm-header { + display: flex; + flex-direction: column; + gap: 1px; + line-height: 1.3; + padding-bottom: 8px; + border-bottom: 1px solid #262626; + margin-bottom: 2px; +} + +.mm-host { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mm-os { + font-size: 11px; + color: #8a8a8a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---------- blocks ---------- */ +.mm-block { + border: 1px solid #2a2a2a; + border-radius: 8px; + background: rgba(255, 255, 255, 0.018); + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.mm-block-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + line-height: 1.2; +} + +/* weak small section labels */ +.mm-label { + font-size: 11px; + font-weight: 500; + color: #8a8a8a; + text-transform: uppercase; + letter-spacing: 0.04em; + user-select: none; +} + +.mm-value { + font-size: 13px; + color: #3fb950; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.mm-value-sub { + font-size: 11px; + color: #8a8a8a; +} + +.mm-sub { + font-size: 11px; + color: #8a8a8a; +} + +.mm-load { + font-variant-numeric: tabular-nums; +} + +/* ---------- bar ---------- */ +.mm-bar { + height: 5px; + border-radius: 3px; + overflow: hidden; + background: #2c2c2c; + width: 100%; +} + +.mm-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +/* ---------- memory ---------- */ +.mm-mem-size { + font-size: 12px; + color: rgba(255, 255, 255, 0.78); + font-variant-numeric: tabular-nums; +} + +.mm-mem-row { + display: flex; + align-items: center; + gap: 8px; +} + +.mm-mem-bar { + flex: 1; + min-width: 0; +} + +.mm-mem-pct { + flex: 0 0 auto; + font-size: 11px; + color: #d29922; + font-variant-numeric: tabular-nums; + min-width: 32px; + text-align: right; +} + +/* ---------- disks ---------- */ +.mm-disks { + display: flex; + flex-direction: column; + gap: 5px; + max-height: 150px; + overflow-y: auto; +} + +.mm-disk { + display: grid; + grid-template-columns: minmax(0, 1fr) 108px 70px; + align-items: center; + column-gap: 8px; + min-height: 16px; +} + +.mm-mount { + color: rgba(255, 255, 255, 0.75); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mm-disk-bar { + min-width: 0; +} + +.mm-disk-size { + font-size: 11px; + color: #8a8a8a; + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ---------- network ---------- */ +.mm-net { + display: inline-flex; + align-items: center; + gap: 14px; + justify-content: flex-end; +} + +.mm-net-charts { + display: flex; + gap: 10px; +} + +.mm-net-charts .mm-chart { + flex: 1; + min-width: 0; +} + +.mm-chart { + width: 100%; + line-height: 0; +} + +.mm-chart canvas { + display: block; + width: 100%; +} + +.mm-net-item { + display: inline-flex; + align-items: baseline; + gap: 4px; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.82); +} + +.mm-net-unit { + font-size: 11px; + color: #8a8a8a; +} + +.mm-arrow { + font-size: 13px; + line-height: 1; + align-self: center; +} + +.mm-up { + color: #58a6ff; +} + +.mm-down { + color: #f0a55a; +} + +/* ---------- footer ---------- */ +.mm-footer { + font-size: 11px; + color: #8a8a8a; +} + +.mm-uptime { + color: rgba(255, 255, 255, 0.85); +} + +/* ---------- empty / collecting state ---------- */ +.mm-empty { + align-items: center; + justify-content: center; + min-height: 120px; + color: #8a8a8a; +} + +.mm-dots { + display: inline-flex; + gap: 4px; + margin-right: 8px; +} + +.mm-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); + animation: mm-pulse 1.2s infinite ease-in-out; +} + +.mm-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.mm-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes mm-pulse { + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-2px); + } +} \ No newline at end of file diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx new file mode 100644 index 0000000..66ffcd2 --- /dev/null +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Alert, + Button, + Input, + InputNumber, + Modal, + Popconfirm, + Switch, + Table, + Tooltip +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings' +import type { HighlightRule } from '@shared/settings' +import { useSettingsStore } from './store' +import './highlight.css' + +const ORDERED_SORT: 'ascend' = 'ascend' + +/** preset color swatches for the highlighting editor. */ +const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60'] + +/** clone the builtin preset set to prevent reference pollution by mutations. */ +const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[] + +function tryCompile(pattern: string): { ok: boolean; message?: string } { + if (pattern.trim() === '') return { ok: true } + try { + // eslint-disable-next-line no-new + new RegExp(pattern) + return { ok: true } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : '正则表达式无效' } + } +} + +interface RuleDraft { + pattern: string + priority: number + fg: string + bg?: string + note?: string + enabled: boolean +} + +interface EditorState { + open: boolean + /** editing an existing rule → its id; create mode → undefined */ + id?: string +} + +export function HighlightTab(): React.JSX.Element { + const highlightRules = useSettingsStore((s) => s.settings.highlightRules) + const setHighlightRules = useSettingsStore((s) => s.setHighlightRules) + const [editor, setEditor] = useState({ open: false }) + + const sorted = useMemo( + () => [...highlightRules].sort((a, b) => a.priority - b.priority), + [highlightRules] + ) + + const openCreate = (): void => setEditor({ open: true, id: undefined }) + const openEdit = (target: HighlightRule): void => setEditor({ open: true, id: target.id }) + const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev)) + + const replaceRule = async (updated: HighlightRule): Promise => { + const next = highlightRules.map((r) => (r.id === updated.id ? updated : r)) + await setHighlightRules(next) + } + + const toggleRule = async (target: HighlightRule, checked: boolean): Promise => { + await replaceRule({ ...target, enabled: checked }) + } + + const handleDelete = async (id: string): Promise => { + await setHighlightRules(highlightRules.filter((r) => r.id !== id)) + } + + const handleReset = async (): Promise => { + await setHighlightRules(cloneDefaults()) + } + + const columns: ColumnsType = [ + { + title: '启用', + dataIndex: 'enabled', + width: 64, + align: 'center', + render: (enabled: boolean, record) => ( + void toggleRule(record, c)} /> + ) + }, + { + title: '正则', + dataIndex: 'pattern', + ellipsis: true, + render: (pattern: string) => + }, + { + title: '优先级', + dataIndex: 'priority', + width: 76, + align: 'center', + sorter: (a, b) => a.priority - b.priority, + defaultSortOrder: ORDERED_SORT, + render: (priority: number) => {priority} + }, + { + title: '预览', + dataIndex: 'color', + width: 120, + align: 'center', + render: (color: HighlightRule['color']) => ( + + Highlight + + ) + }, + { + title: '备注', + dataIndex: 'note', + ellipsis: true, + render: (note: string | undefined) => + note ? {note} : — + }, + { + title: '操作', + key: 'action', + width: 120, + align: 'center', + render: (_, record) => ( +
    + + void handleDelete(record.id)} + > + + +
    + ) + } + ] + + return ( +
    + +
    + void handleReset()} + > + + + +
    +
    + + + + + ) +} + +function PatternCell({ pattern }: { pattern: string }): React.JSX.Element { + const { ok, message } = tryCompile(pattern) + return ( +
    + + {pattern} + + {!ok && ( + + ⚠ + + )} +
    + ) +} + +/** + * Modal for creating / editing a single highlight rule. Mirrors the structure + * of ThemeEditor: local draft state, initialized on open, saved through the store. + */ +function HighlightEditor({ + open, + onClose, + ruleId +}: { + open: boolean + onClose: () => void + ruleId?: string +}): React.JSX.Element { + const highlightRules = useSettingsStore((s) => s.settings.highlightRules) + const setHighlightRules = useSettingsStore((s) => s.setHighlightRules) + + const isCreate = ruleId === undefined + const editing = useMemo( + () => (ruleId ? highlightRules.find((r) => r.id === ruleId) : undefined), + [highlightRules, ruleId] + ) + + const [draft, setDraft] = useState({ + pattern: '', + priority: 1, + fg: '#3fb950', + bg: undefined, + note: undefined, + enabled: true + }) + const [saving, setSaving] = useState(false) + const [showBg, setShowBg] = useState(false) + + // (re)initialize the form each time the modal opens + useEffect(() => { + if (!open) return + if (!isCreate && editing === undefined) { + onClose() + return + } + const pattern = editing ? editing.pattern : '' + const priority = editing ? editing.priority : 1 + const fg = editing ? editing.color.fg : PRESET_COLORS[0] + const bg = editing ? editing.color.bg : undefined + const note = editing ? editing.note : undefined + const enabled = editing ? editing.enabled : true + setDraft({ pattern, priority, fg, bg, note, enabled }) + setShowBg(bg != null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const compile = tryCompile(draft.pattern) + const valid = draft.pattern.trim() !== '' && compile.ok + + const patch = (partial: Partial): void => setDraft((prev) => ({ ...prev, ...partial })) + + const handleSave = async (): Promise => { + if (!valid) return + setSaving(true) + try { + const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) } + if (isCreate) { + const rule: HighlightRule = { + id: crypto.randomUUID(), + pattern: draft.pattern.trim(), + priority: draft.priority, + enabled: draft.enabled, + color, + note: draft.note?.trim() ? draft.note.trim() : undefined + } + await setHighlightRules([...highlightRules, rule]) + } else if (editing !== undefined) { + const rule: HighlightRule = { + ...editing, + pattern: draft.pattern.trim(), + priority: draft.priority, + enabled: draft.enabled, + color, + note: draft.note?.trim() ? draft.note.trim() : undefined + } + await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r))) + } + onClose() + } finally { + setSaving(false) + } + } + + return ( + void handleSave()} + okText="保存" + cancelText="取消" + confirmLoading={saving} + destroyOnHidden + width={560} + title={isCreate ? '新建高亮规则' : '编辑高亮规则'} + okButtonProps={{ disabled: !valid }} + > +
    +
    + 正则 +
    + patch({ pattern: e.target.value })} + placeholder="例如 \b(ERROR|FAILED)\b" + autoSize={{ minRows: 1, maxRows: 4 }} + className={compile.ok ? undefined : 'hl-editor-input-bad'} + /> + {!compile.ok && compile.message != null && ( +
    {compile.message}
    + )} +
    +
    + +
    + 优先级 + { + if (v !== null) patch({ priority: v }) + }} + /> + 值越小越先应用 +
    + +
    + 颜色 +
    + patch({ fg: v })} + /> +
    + 背景 + {showBg ? ( + <> + patch({ bg: v })} + /> + + + ) : ( + + )} +
    +
    +
    + +
    + 色板 +
    + {showBg && 背景} + {PRESET_COLORS.map((c) => ( + (showBg ? patch({ bg: v }) : patch({ fg: v }))} + /> + ))} +
    +
    + +
    + 备注 + patch({ note: e.target.value })} + placeholder="规则说明(可选)" + maxLength={60} + /> +
    + +
    + 启用 + patch({ enabled: c })} /> +
    +
    +
    + ) +} + +function ColorField({ + label, + value, + onChange +}: { + label?: string + value: string + onChange: (value: string) => void +}): React.JSX.Element { + return ( +
    + {label != null && {label}} + onChange(e.target.value)} + /> +
    + ) +} + +function PaletteSwatch({ + color, + selected, + onClick +}: { + color: string + selected?: string + onClick: (value: string) => void +}): React.JSX.Element { + const isSel = color === selected + return ( + onClick(color)} + /> + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx new file mode 100644 index 0000000..0266d47 --- /dev/null +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -0,0 +1,70 @@ +import { useMemo, useState } from 'react' +import { Button, Modal, Tabs } from 'antd' +import type { TabsProps } from 'antd' +import { DEFAULT_SETTINGS } from '@shared/settings' +import { useSettingsStore } from './store' +import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs' +import { ThemeSettingsTab } from './ThemeSettingsTab' +import { HighlightTab } from './HighlightTab' +import { ThemeEditor } from '../theme/editor/ThemeEditor' +import './settings.css' + +export interface SettingsDialogProps { + open: boolean + onClose: () => void +} + +export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JSX.Element { + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({ + open: false + }) + + const tabItems: TabsProps['items'] = [ + { key: 'font', label: '字体', children: }, + { key: 'cursor', label: '光标与滚动', children: }, + { key: 'render', label: '渲染与行为', children: }, + { key: 'highlight', label: '高亮', children: }, + { + key: 'theme', + label: '主题', + children: ( + setEditorState({ open: true, themeId })} + onCreateTheme={() => setEditorState({ open: true, themeId: undefined })} + /> + ) + }, + { key: 'system', label: '系统', children: } + ] + + const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev)) + + const handleResetDefaults = (): void => { + void updateTerminal({ ...DEFAULT_SETTINGS.terminal }) + } + + return ( + + + + + } + > + + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx new file mode 100644 index 0000000..76c8d2b --- /dev/null +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -0,0 +1,399 @@ +import type { ReactNode } from 'react' +import { useMemo, useState } from 'react' +import { Input, InputNumber, Radio, Select, Switch } from 'antd' +import type { ThemeColors } from '@shared/theme' +import { useSettingsStore, useResolvedTheme } from './store' +import { + DEFAULT_FONT_STACK, + SettingRow, + cursorStyleOptions, + inactiveCursorStyleOptions, + rendererModeOptions, + weightOptions +} from './fields' + +export function FontSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const theme = useResolvedTheme() + + const [fonts, setFonts] = useState([]) + + // refresh system font list when the component mounts (dialog open first time); + // results are cached for this component's lifetime + useState(() => { + window.api.listFonts().then( + (list: string[]) => setFonts(list.filter((f: string) => f !== '')), + (err: unknown) => console.error('[settings] listFonts failed', err) + ) + return [] + }) + + const fontOptions = useMemo(() => { + const seen = new Set() + const items: Array<{ value: string; label: ReactNode }> = [] + for (const f of fonts) { + if (seen.has(f)) continue + seen.add(f) + items.push({ value: f, label: {f} }) + } + return items + }, [fonts]) + + /** map the persisted family to a recognized option value (default / system font / verbatim) */ + const selectedFont = (): string => { + const cur = settings.terminal.fontFamily + if (cur === DEFAULT_FONT_STACK) return 'default' + if (fonts.includes(cur)) return cur + return cur + } + + const handleFontChange = (value: string): void => { + void updateTerminal({ + fontFamily: value === 'default' ? DEFAULT_FONT_STACK : value + }) + } + + return ( +
    + 默认 }, + ...fontOptions + ]} + filterOption={(input, option) => + typeof option?.value === 'string' && + option.value.toLowerCase().includes(input.toLowerCase()) + } + /> + } + /> + { + if (v !== null) void updateTerminal({ fontSize: v }) + }} + /> + } + /> + { + if (v !== null) void updateTerminal({ fontWeight: v }) + }} + options={weightOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ boldFontWeight: v }) + }} + options={weightOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ letterSpacing: v }) + }} + /> + } + /> + { + if (v !== null) void updateTerminal({ lineHeight: v }) + }} + /> + } + /> + + +
    + ) +} + +export function CursorSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + return ( +
    + void updateTerminal({ cursorBlink: checked })} + /> + } + /> + { + if (v !== null) void updateTerminal({ cursorStyle: v }) + }} + options={cursorStyleOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ cursorInactiveStyle: v }) + }} + options={inactiveCursorStyleOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ scrollback: v }) + }} + /> + } + /> +
    + ) +} + +export function RenderSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + return ( +
    +
    + 渲染模式 + 终端单元渲染加速方式,改动将应用到所有终端 +
    + void updateTerminal({ rendererMode: e.target.value })} + options={rendererModeOptions} + optionType="button" + buttonStyle="solid" + /> +
    + {rendererModeOptions.find((o) => o.value === settings.terminal.rendererMode)?.hint} +
    + void updateTerminal({ autoWrap: checked })} + /> + } + /> + void updateTerminal({ copyOnSelect: checked })} + /> + } + /> + void updateTerminal({ pasteRiskConfirm: checked })} + /> + } + /> + void updateTerminal({ suggestEnabled: checked })} + /> + } + /> +
    + ) +} + +export function SystemSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateSystem = useSettingsStore((s) => s.updateSystem) + return ( +
    + void updateSystem({ launchAtLogin: checked })} + /> + } + /> + void updateSystem({ preventSleep: checked })} + /> + } + /> + void updateSystem({ globalShowHide: e.target.value })} + /> + } + /> +
    + 配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。 +
    +
    + ) +} + +/** + * Live preview of the current font settings, rendered as an `ls -la`-style + * terminal screen with ANSI-colored tokens on the active theme background. + */ +function TerminalPreview({ + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing, + colors +}: { + fontFamily: string + fontSize: number + fontWeight: number + lineHeight: number + letterSpacing: number + colors: ThemeColors +}): React.JSX.Element { + const lines: Array = [ + + drwxr-xr-x{' '} + 12 bill users 4096 Sep 5 10:02{' '} + node_modules + , + + -rw-r--r--{' '} + 1 bill users 890 Sep 5 09:41{' '} + package.json + , + + -rw-r--r--{' '} + 1 bill users 42 Sep 5 09:41{' '} + index.ts + , + + + bill@desktop:~$ _ + + + ] + const gridStyle: React.CSSProperties = { + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing + } + return ( +
    + 预览 +
    + {lines.map((l, i) => ( +
    + {l} +
    + ))} +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/ThemeSettingsTab.tsx b/src/renderer/src/settings/ThemeSettingsTab.tsx new file mode 100644 index 0000000..5ab1610 --- /dev/null +++ b/src/renderer/src/settings/ThemeSettingsTab.tsx @@ -0,0 +1,121 @@ +import { useMemo } from 'react' +import { Button, Popconfirm } from 'antd' +import type { TerminalSettings } from '@shared/settings' +import { DEFAULT_SETTINGS } from '@shared/settings' +import type { TerminalTheme, ThemeColors } from '@shared/theme' +import { BUILTIN_THEMES } from '@shared/theme' +import { useSettingsStore, useResolvedTheme } from './store' + +export interface ThemeSettingsTabProps { + onCreateTheme: () => void + onEditTheme: (themeId: string) => void +} + +const ANSI_KEYS: ReadonlyArray = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white' +] + +export function ThemeSettingsTab({ + onCreateTheme, + onEditTheme +}: ThemeSettingsTabProps): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const setCustomThemes = useSettingsStore((s) => s.setCustomThemes) + const activeTheme = useResolvedTheme() + + const themeCards = useMemo( + () => [...BUILTIN_THEMES, ...settings.customThemes], + [settings.customThemes] + ) + + const handleDelete = async (themeId: string): Promise => { + const next = settings.customThemes.filter((t) => t.id !== themeId) + if (settings.terminal.themeId === themeId) { + // currently-active custom theme removed — fall back to the default dark theme + await updateTerminal({ themeId: DEFAULT_SETTINGS.terminal.themeId }) + } + await setCustomThemes(next) + } + + return ( +
    + {themeCards.map((t) => { + const selected = t.id === activeTheme.id + const isCustom = t.builtin === false + return ( +
    void updateTerminal({ themeId: t.id })} + title={t.name} + > +
    +
    +
    + {t.name} + {isCustom && 自定义} +
    +
    +{ANSI_KEYS.map((k) => ( + + ))} +
    +
    + {isCustom ? ( + <> + + { + e?.stopPropagation() + void handleDelete(t.id) + }} + > + + + + ) : null} +
    +
    +
    + ) + })} + +
    +
    +
    +
    新建主题
    +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/fields.tsx b/src/renderer/src/settings/fields.tsx new file mode 100644 index 0000000..688e6f6 --- /dev/null +++ b/src/renderer/src/settings/fields.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' +import type { TerminalSettings } from '@shared/settings' + +/** font stack shown as the persistent "默认" option */ +export const DEFAULT_FONT_STACK = "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace" + +export const WEIGHT_OPTIONS = [100, 200, 300, 400, 500, 600, 700, 800, 900] + +export const weightOptions = WEIGHT_OPTIONS.map((w) => ({ value: w, label: String(w) })) + +export const cursorStyleOptions: Array<{ value: TerminalSettings['cursorStyle']; label: string }> = [ + { value: 'bar', label: '竖线 (bar)' }, + { value: 'block', label: '方块 (block)' }, + { value: 'underline', label: '下划线 (underline)' } +] + +export const inactiveCursorStyleOptions: Array<{ + value: TerminalSettings['cursorInactiveStyle'] + label: string +}> = [ + { value: 'outline', label: '轮廓 (outline)' }, + { value: 'block', label: '方块 (block)' }, + { value: 'bar', label: '竖线 (bar)' }, + { value: 'underline', label: '下划线 (underline)' }, + { value: 'none', label: '无 (none)' } +] + +export const rendererModeOptions: Array<{ + value: TerminalSettings['rendererMode'] + label: string + hint: string +}> = [ + { value: 'auto', label: '自动', hint: '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染' }, + { value: 'webgl', label: '高性能 WebGL', hint: '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动' }, + { value: 'dom', label: '兼容 DOM', hint: '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境' } +] + +/** one label + control row inside a settings pane */ +export function SettingRow({ + label, + desc, + control +}: { + label: ReactNode + desc?: ReactNode + control: ReactNode +}): React.JSX.Element { + return ( +
    + + {label} + {desc != null && {desc}} + +
    {control}
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/highlight.css b/src/renderer/src/settings/highlight.css new file mode 100644 index 0000000..7d9545c --- /dev/null +++ b/src/renderer/src/settings/highlight.css @@ -0,0 +1,221 @@ +/* ============================================================ + Highlight settings tab + rule editor (settings/highlight.css) + ============================================================ */ + +.hl-tab { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; +} + +.hl-toolbar { + display: flex; + justify-content: flex-end; + gap: 8px; + align-items: center; +} + +.hl-table .ant-table { + background: transparent; +} + +.hl-table .ant-table-thead > tr > th { + background: rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--chrome-border); + color: rgba(255, 255, 255, 0.85); + font-size: 12px; +} + +.hl-table .ant-table-tbody > tr > td { + background: transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.hl-table .ant-table-tbody > tr:hover > td { + background: var(--chrome-hover) !important; +} + +/* ---- regex cell ---- */ + +.hl-pattern-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.hl-pattern-text { + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.88); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hl-pattern-bad { + color: #f85149; +} + +.hl-warn-icon { + flex: 0 0 auto; + color: #f85149; + font-size: 12px; + line-height: 1; + cursor: help; +} + +/* ---- misc cells ---- */ + +.hl-cell-priority { + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.88); +} + +.hl-cell-note { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); +} + +.hl-cell-empty { + font-size: 12px; + color: rgba(255, 255, 255, 0.3); +} + +.hl-preview-text { + display: inline-block; + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + line-height: 1; + min-width: 74px; +} + +.hl-actions { + display: flex; + justify-content: center; + gap: 6px; +} + +/* ---- rule editor ---- */ + +.hl-editor { + padding-top: 4px; +} + +.hl-editor-row { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.hl-editor-label { + flex: 0 0 56px; + color: rgba(255, 255, 255, 0.88); + padding-top: 5px; +} + +.hl-editor-control { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.hl-editor-hint { + align-self: center; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); +} + +.hl-editor-error { + font-size: 12px; + color: #f85149; +} + +.hl-editor-input-bad { + border-color: #f85149; +} + +.hl-editor-input-bad:hover, +.hl-editor-input-bad:focus { + border-color: #f85149; + box-shadow: 0 0 0 2px rgba(248, 81, 73, 0.2); +} + +.hl-editor-sub-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.hl-editor-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.hl-color-field { + display: flex; + align-items: center; + gap: 8px; +} + +.hl-color-input { + flex: 0 0 auto; + width: 40px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.hl-color-input::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.hl-color-input::-webkit-color-swatch { + border: none; + border-radius: 3px; +} + +/* ---- palette swatches (16px, radius 3px, clickable) ---- */ + +.hl-palette { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding-top: 4px; +} + +.hl-palette-tag { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-right: 2px; +} + +.hl-swatch { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + display: inline-block; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.hl-swatch:hover { + transform: scale(1.15); +} + +.hl-swatch-selected { + box-shadow: 0 0 0 2px var(--chrome-accent) inset; + border-color: var(--chrome-accent); +} \ No newline at end of file diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css new file mode 100644 index 0000000..585fbe2 --- /dev/null +++ b/src/renderer/src/settings/settings.css @@ -0,0 +1,346 @@ +/* ============================================================ + Settings dialog + theme editor styles (settings/**, theme/**) + ============================================================ */ + +/* ---------- settings dialog scaffolding ---------- */ + +.ant-modal-header { + border-bottom: 1px solid #2d2d2d; + padding-bottom: 14px; +} + +.ant-modal .ant-tabs-nav { + margin-bottom: 8px; + border-bottom: 1px solid #2d2d2d; +} + +.settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.settings-pane { + padding-top: 8px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 44px; + gap: 16px; + padding: 6px 8px; + border-radius: 4px; + transition: background-color 0.15s ease; +} + +.settings-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.settings-row + .settings-row { + margin-top: 2px; +} + +.settings-row-label { + flex: 0 0 46%; + color: rgba(255, 255, 255, 0.88); + font-size: 13px; + line-height: 1.4; +} + +.settings-row-desc { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 400; + color: rgba(255, 255, 255, 0.45); +} + +.settings-row-control { + flex: 1 1 auto; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + +.settings-select { + width: 220px; +} + +.settings-input-number { + width: 220px; +} + +.settings-block-label { + display: flex; + align-items: baseline; + gap: 12px; + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.settings-block-hint { + font-weight: 400; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); +} + +.settings-radio-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.settings-radio-group .ant-radio-button-wrapper { + min-width: 120px; + text-align: center; +} + +.settings-render-hint { + margin: -8px 0 16px; +} + +.settings-shortcut-hint { + margin: 2px 8px 0; +} + +/* ---------- font tab: live terminal preview ---------- */ + +.settings-preview { + margin-top: 14px; + border-radius: 6px; + padding: 12px 14px; + border: 1px solid #2d2d2d; + overflow: hidden; +} + +.settings-preview-title { + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; +} + +.settings-preview-grid { + white-space: pre; + font-variant-ligatures: none; + overflow: hidden; +} + +.settings-preview-line { + overflow: hidden; + text-overflow: clip; +} + +/* ---------- theme gallery ---------- */ + +.theme-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + max-height: 480px; + overflow-y: auto; + padding: 4px 2px 12px; +} + +.theme-card { + position: relative; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + border: 2px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.theme-card:hover { + border-color: var(--chrome-accent); + background: rgba(255, 255, 255, 0.07); +} + +.theme-card-selected { + border-color: #3b8eea; + box-shadow: 0 0 0 1px #3b8eea inset; +} + +.theme-card-bg { + height: 64px; +} + +.theme-card-body { + padding: 8px 10px 10px; +} + +.theme-card-name-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.theme-card-name { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.88); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-card-badge { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + background: rgba(59, 142, 234, 0.2); + color: #6fb3ff; +} + +.theme-card-dots { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.theme-card-dot { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.25); + display: inline-block; +} + +.theme-card-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + opacity: 0; + transition: opacity 0.15s ease; +} + +.theme-card:hover .theme-card-actions, +.theme-card:focus-within .theme-card-actions { + opacity: 1; +} + +.theme-card-new { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 148px; + border-style: dashed; +} + +.theme-card-new-icon { + font-size: 26px; + line-height: 1; + color: rgba(255, 255, 255, 0.55); +} + +.theme-card-new-text { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); +} + +/* ---------- theme editor ---------- */ + +.theme-editor { + padding-top: 4px; +} + +.theme-editor-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.theme-editor-label { + flex: 0 0 56px; + color: rgba(255, 255, 255, 0.88); +} + +.theme-editor-colors { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px 12px; + max-height: 300px; + overflow-y: auto; + padding-right: 4px; +} + +.theme-editor-color-field { + display: flex; + align-items: center; + gap: 8px; +} + +.theme-editor-color-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-editor-color-input { + flex: 0 0 auto; + width: 40px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.theme-editor-color-input::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.theme-editor-color-input::-webkit-color-swatch { + border: none; + border-radius: 3px; +} + +.theme-editor-divider { + grid-column: 1 / -1; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 2px 0; +} + +.theme-editor-preview { + margin-top: 14px; +} + +.theme-editor-preview-label { + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 6px; +} + +.theme-editor-preview-box { + border-radius: 6px; + padding: 10px 14px; + border: 1px solid #2d2d2d; + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; +} + +.theme-editor-preview-line { + white-space: pre; + line-height: 1.6; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts new file mode 100644 index 0000000..2ee13f2 --- /dev/null +++ b/src/renderer/src/settings/store.ts @@ -0,0 +1,114 @@ +import type { AppSettings, SystemSettings, TerminalSettings } from '@shared/settings' +import { DEFAULT_SETTINGS } from '@shared/settings' +import type { TerminalTheme } from '@shared/theme' +import { getThemeById } from '@shared/theme' +import { create } from 'zustand' + +export interface SettingsState { + /** true once initial settings have been loaded from the main process */ + hydrated: boolean + settings: AppSettings + hydrate: () => Promise + /** shallow-merge into settings.terminal and persist */ + updateTerminal: (partial: Partial) => Promise + /** replace customThemes array and persist */ + setCustomThemes: (themes: AppSettings['customThemes']) => Promise + /** replace highlightRules array and persist */ + setHighlightRules: (rules: AppSettings['highlightRules']) => Promise + /** shallow-merge into settings.system and persist */ + updateSystem: (partial: Partial) => Promise +} + +/** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */ +let unsubscribeStoreListener: (() => void) | null = null +/** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */ +let hydrating = false + +export const useSettingsStore = create((set, get) => ({ + hydrated: false, + settings: DEFAULT_SETTINGS, + + hydrate: async () => { + if (get().hydrated) return + // guard against concurrent hydrate() calls racing on the first render + if (hydrating) return + hydrating = true + try { + const settings = await window.api.getSettings() + set({ settings, hydrated: true }) + + if (unsubscribeStoreListener === null) { + unsubscribeStoreListener = window.api.onSettingsChanged((s: AppSettings) => + set({ settings: s }) + ) + } + } catch (err) { + console.error('[settings] hydrate failed', err) + } finally { + hydrating = false + } + }, + + updateTerminal: async (partial) => { + const prev = get().settings + const next: AppSettings = { + ...prev, + terminal: { ...prev.terminal, ...partial } + } + // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] updateTerminal failed, rolling back', err) + set({ settings: prev }) + } + }, + + setCustomThemes: async (themes) => { + const prev = get().settings + const next: AppSettings = { ...prev, customThemes: themes } + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] setCustomThemes failed, rolling back', err) + set({ settings: prev }) + } + }, + + setHighlightRules: async (rules) => { + const prev = get().settings + const next: AppSettings = { ...prev, highlightRules: rules } + // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] setHighlightRules failed, rolling back', err) + set({ settings: prev }) + } + }, + + updateSystem: async (partial) => { + const prev = get().settings + const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } } + // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] updateSystem failed, rolling back', err) + set({ settings: prev }) + } + } +})) + +/** + * Resolve the currently active TerminalTheme (builtin or custom) from the store. + * Subscribes to settings so consumers re-render when the active theme changes. + */ +export function useResolvedTheme(): TerminalTheme { + const settings = useSettingsStore((s) => s.settings) + return getThemeById(settings.terminal.themeId, settings.customThemes) +} \ No newline at end of file diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx new file mode 100644 index 0000000..50515d3 --- /dev/null +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -0,0 +1,638 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ArrowUpOutlined, + CopyOutlined, + DeleteOutlined, + DownloadOutlined, + FileAddOutlined, + FileOutlined, + FolderAddOutlined, + FolderOpenOutlined, + FolderOutlined, + FormOutlined, + ReloadOutlined, + SafetyOutlined, + UploadOutlined +} from '@ant-design/icons' +import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import type { SftpEntry, TransferProgressEvent } from '@shared/sftp' +import './sftp.css' + +/** Remote file browser for one SSH session (M4). */ +export interface FilePanelProps { + sessionId: string +} + +/** 9 permission bits: [owner r,w,x, group r,w,x, other r,w,x]. */ +type PermBits = boolean[] + +function parseModeBits(mode: string | undefined): PermBits { + const chars = mode ? Array.from(mode.slice(1)) : [] + return Array.from({ length: 9 }, (_, i) => chars[i] != null && chars[i] !== '-') +} + +function formatModeType(mode: string | undefined): string { + if (!mode || mode.length === 0) return '-' + return mode[0] +} + +function bitsToModeStr(type: string, bits: PermBits): string { + const letters = bits.map((on, i) => { + if (!on) return '-' + return ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x'][i] + }) + return type + letters.join('') +} + +/** e.g. r+w+x = 7 → returns the 3-bit octal digit (0..7). */ +function octalDigit(start: number, bits: PermBits): number { + return (bits[start] ? 4 : 0) + (bits[start + 1] ? 2 : 0) + (bits[start + 2] ? 1 : 0) +} + +function bitsToOctal(bits: PermBits): string { + // Concatenate octal digits as a string — 5,1,1 → "511". (The previous + // arithmetic version computed 5*64+1*8+1 = 329 decimal, which is NOT the + // octal "511" and made chmod a silent no-op when both sides agreed on the + // same wrong value.) + return `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}` +} + +function fmtSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G` + if (bytes >= 1024) return `${Math.round(bytes / 1024)}K` + return String(bytes) +} + +function fmtTime(ms: number): string { + if (!ms) return '' + const d = new Date(ms) + return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +const PERM_ROW_LABELS = ['所有者', '组', '其他'] as const +const PERM_COL_LABELS = ['读', '写', '执行'] as const + +interface PermissionModalProps { + open: boolean + sessionId: string + entry: SftpEntry | null + onClose: () => void + onSaved: () => void +} + +function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element { + const { message } = App.useApp() + const [bits, setBits] = useState(Array.from({ length: 9 }, () => false)) + const [octal, setOctal] = useState('') + const [uid, setUid] = useState('') + const [gid, setGid] = useState('') + const [saving, setSaving] = useState(false) + const hasUidGid = entry != null && (entry.uid != null || entry.gid != null) + + const modeStr = useMemo(() => { + if (!entry) return { type: '-', str: '---------' } + return { type: formatModeType(entry.mode), str: (entry.mode ?? '').slice(1) || '---------' } + }, [entry]) + + // seed state when opening + useEffect(() => { + if (!open) return + if (entry) { + const b = parseModeBits(entry.mode) + setBits(b) + setOctal(bitsToOctal(b)) + setUid(entry.uid != null ? String(entry.uid) : '') + setGid(entry.gid != null ? String(entry.gid) : '') + } else { + setBits(Array.from({ length: 9 }, () => false)) + setOctal('') + setUid('') + setGid('') + } + }, [open, entry]) + + const applyOctal = (v: string | null): void => { + const s = (v ?? '').trim() + if (/^[0-7]{1,4}$/.test(s)) { + const num = parseInt(s, 8) || 0 + setBits([ + (num >> 6) & 4 ? true : false, + (num >> 6) & 2 ? true : false, + (num >> 6) & 1 ? true : false, + (num >> 3) & 4 ? true : false, + (num >> 3) & 2 ? true : false, + (num >> 3) & 1 ? true : false, + num & 4 ? true : false, + num & 2 ? true : false, + num & 1 ? true : false + ]) + } + setOctal(s) + } + + const toggleBit = (idx: number): void => { + setBits((prev) => { + const next = prev.slice() + next[idx] = !next[idx] + setOctal(bitsToOctal(next)) + return next + }) + } + + const onSave = (): void => { + void (async () => { + if (!entry) return + setSaving(true) + try { + const octalStr = bitsToOctal(bits) + const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null + // Only chown when the uid/gid actually differ from the entry's current + // owner — the inputs are seeded with the current values, so "untouched" + // would otherwise chown on every save. + const newUid = uid.trim() !== '' && !Number.isNaN(Number(uid)) ? Number(uid) : null + const newGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) ? Number(gid) : null + const ownerChanged = + (newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid) + + if (octalStr !== curOctal) { + await window.api.chmodRemote(sessionId, entry.path, octalStr) + } + if (ownerChanged) { + const u = newUid ?? entry.uid ?? 0 + const g = newGid ?? newUid ?? entry.gid ?? 0 + await window.api.chownRemote(sessionId, entry.path, u, g) + } + if (octalStr !== curOctal || ownerChanged) { + message.success('已应用权限') + } + onClose() + onSaved() + } catch (err) { + message.error((err as Error).message) + } finally { + setSaving(false) + } + })() + } + + return ( + +
    + {entry && ( +
    + {modeStr.str} + {bitsToOctal(bits)} +
    + )} + +
    +
    + + {PERM_COL_LABELS.map((l) => ( + {l} + ))} +
    + {PERM_ROW_LABELS.map((row, r) => ( +
    + {row} + {PERM_COL_LABELS.map((_, c) => { + const idx = r * 3 + c + return ( + toggleBit(idx)} /> + ) + })} +
    + ))} +
    + +
    + 八进制 + applyOctal(e.target.value)} + placeholder="如 755" + addonAfter={bitsToModeStr(modeStr.type, bits)} + /> +
    + +
    +
    + UID + setUid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} /> +
    +
    + GID + setGid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} /> +
    +
    +
    UID/GID 留空表示不修改属主
    +
    +
    + ) +} + +export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { + const { message, modal } = App.useApp() + const [dir, setDir] = useState('/') + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [selected, setSelected] = useState(null) + const [menuEntry, setMenuEntry] = useState(null) + const [mkdirOpen, setMkdirOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [permOpen, setPermOpen] = useState(false) + const [inputValue, setInputValue] = useState('') + + /** the entry the context menu is operating on (selected when right-clicked) */ + const menuTarget = menuEntry ?? selected + + const refresh = useCallback( + async (target: string): Promise => { + setLoading(true) + setError('') + try { + const list = await window.api.listRemote(sessionId, target) + setEntries(list) + setDir(target) + setSelected(null) + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + }, + [sessionId] + ) + + useEffect(() => { + void refresh('/') + }, [refresh]) + + const segments = dir.split('/').filter(Boolean) + const jumpTo = (index: number): string => '/' + segments.slice(0, index + 1).join('/') + const parent = '/' + segments.slice(0, -1).join('/') + + const selectedPath = selected?.path ?? '' + + const doUpload = (): void => { + void (async () => { + try { + const files = await window.api.pickFiles() + if (files.length === 0) return + await window.api.uploadRemote(sessionId, files, dir) + // refresh when the transfer completes + const off = window.api.onTransferProgress((e: TransferProgressEvent) => { + if (e.state === 'done' || e.state === 'error' || e.state === 'cancelled') { + off() + void refresh(dir) + } + }) + } catch (err) { + message.error((err as Error).message) + } + })() + } + + const doDownload = (entry: SftpEntry): void => { + void (async () => { + try { + const dirTo = await window.api.pickDirectory() + if (!dirTo) return + await window.api.downloadRemote(sessionId, [entry.path], dirTo) + } catch (err) { + message.error((err as Error).message) + } + })() + } + + const doDelete = (): void => { + void (async () => { + if (!selected) return + try { + await window.api.deleteRemote(sessionId, [selected.path]) + void refresh(dir) + } catch (err) { + message.error((err as Error).message) + } + })() + } + + const doMkdir = (): void => { + void (async () => { + const name = inputValue.trim() + if (!name) return + try { + await window.api.mkdirRemote(sessionId, dir, name) + setMkdirOpen(false) + setInputValue('') + void refresh(dir) + } catch (err) { + message.error((err as Error).message) + } + })() + } + + const doRename = (): void => { + void (async () => { + const name = inputValue.trim() + if (!name || !selected) return + try { + const to = `${dir.replace(/\/$/, '')}/${name}` + await window.api.renameRemote(sessionId, selected.path, to) + setRenameOpen(false) + setInputValue('') + void refresh(dir) + } catch (err) { + message.error((err as Error).message) + } + })() + } + + const doCopyPath = (entry: SftpEntry): void => { + void (async () => { + try { + await navigator.clipboard.writeText(entry.path) + message.success('已复制路径') + } catch { + message.error('复制路径失败') + } + })() + } + + /** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */ + const menu: MenuProps = useMemo(() => { + const target = menuTarget + const isDir = target?.isDir + const canAction = target != null + return { + items: [ + { key: 'refresh', label: '刷新', icon: }, + { type: 'divider' }, + { + key: 'open', + label: isDir ? '打开' : '下载', + icon: isDir ? : , + disabled: !canAction + }, + { + key: 'download', + label: '下载', + icon: , + disabled: !canAction || Boolean(isDir) + }, + { key: 'upload', label: '上传…', icon: }, + { + key: 'rename', + label: '重命名', + icon: , + disabled: !canAction + }, + { + type: 'submenu', + key: 'new', + label: '新建', + icon: , + children: [ + { key: 'mkdir', label: '新建文件夹', icon: } + // 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做 + ] + }, + { type: 'divider' }, + { + key: 'copyPath', + label: '复制路径', + icon: , + disabled: !canAction + }, + { + key: 'permission', + label: '文件权限…', + icon: , + disabled: !canAction + }, + { key: 'delete', label: '删除', icon: , danger: true, disabled: !canAction } + ], + onClick: ({ key }) => { + const entry = menuTarget + if (key === 'refresh') { + void refresh(dir) + } else if (key === 'open') { + if (!entry) return + if (entry.isDir) void refresh(entry.path) + else doDownload(entry) + } else if (key === 'download') { + if (entry && !entry.isDir) doDownload(entry) + } else if (key === 'upload') { + doUpload() + } else if (key === 'rename') { + setInputValue(entry?.name ?? '') + setRenameOpen(true) + } else if (key === 'mkdir') { + setInputValue('') + setMkdirOpen(true) + } else if (key === 'copyPath') { + if (entry) doCopyPath(entry) + } else if (key === 'permission') { + setPermOpen(true) + } else if (key === 'delete') { + modal.confirm({ + title: '确认删除?', + content: entry ? `${entry.name}(${entry.path})` : undefined, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: () => + new Promise((resolve, reject) => { + if (!entry) { + resolve() + return + } + window.api.deleteRemote(sessionId, [entry.path]).then( + () => { + void refresh(dir) + resolve() + }, + (err: unknown) => reject(err) + ) + }) + }) + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [menuTarget, dir, refresh]) + + return ( +
    +
    + +
    + +
    + void refresh('/')}> + / + + {segments.map((seg, i) => ( + void refresh(jumpTo(i))}> + {seg}/ + + ))} +
    + + {error ? ( +
    {error}
    + ) : ( +
    + {loading && entries.length === 0 &&
    加载中…
    } + {!loading && entries.length === 0 &&
    空目录
    } + {entries.map((e) => ( + document.body} + onOpenChange={(open) => { + if (!open) setMenuEntry(null) + }} + > +
    setSelected(e)} + onContextMenu={() => { + setSelected(e) + setMenuEntry(e) + }} + onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))} + > + {e.isDir ? : } + + {e.name} + + + {e.mode ?? '----------'} + + + {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'} + + + {e.isDir ? '' : fmtSize(e.size)} + + {fmtTime(e.mtime)} +
    +
    + ))} +
    + )} + + setPermOpen(false)} + onSaved={() => void refresh(dir)} + /> + setMkdirOpen(false)} + okText="创建" + cancelText="取消" + width={360} + > + setInputValue(e.target.value)} + onPressEnter={doMkdir} + /> + + setRenameOpen(false)} + okText="确定" + cancelText="取消" + width={360} + > + setInputValue(e.target.value)} + onPressEnter={doRename} + /> + +
    + ) +} diff --git a/src/renderer/src/sftp/TransferPanel.tsx b/src/renderer/src/sftp/TransferPanel.tsx new file mode 100644 index 0000000..9ba2fb3 --- /dev/null +++ b/src/renderer/src/sftp/TransferPanel.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from 'react' +import { CloseOutlined } from '@ant-design/icons' +import type { TransferKind, TransferProgressEvent } from '@shared/sftp' + +interface TransferState { + kind: TransferKind + file: string + bytes: number + totalBytes: number + state: 'running' | 'done' | 'error' | 'cancelled' + error?: string +} + +/** + * Global transfer overlay (bottom-right). Aggregates progress events by + * transferId; done transfers fade out, errors stay until dismissed. + */ +export function TransferPanel(): React.JSX.Element | null { + const [transfers, setTransfers] = useState>(new Map()) + const timersRef = useRef([]) + + useEffect(() => { + const off = window.api.onTransferProgress((e: TransferProgressEvent) => { + setTransfers((prev) => { + const next = new Map(prev) + if (e.file) { + next.set(e.transferId, { + kind: e.kind, + file: e.file, + bytes: e.bytes, + totalBytes: e.totalBytes, + state: e.state, + error: e.error + }) + } else { + // terminal event for the whole transfer + const existing = next.get(e.transferId) + if (existing) { + if (e.state === 'error') { + next.set(e.transferId, { ...existing, state: 'error', error: e.error }) + } else if (e.state === 'cancelled') { + next.set(e.transferId, { ...existing, state: 'cancelled' }) + } else { + next.set(e.transferId, { ...existing, state: 'done' }) + } + } + } + return next + }) + }) + return () => { + off() + for (const t of timersRef.current) window.clearTimeout(t) + } + }, []) + + // auto-dismiss finished entries after 3s + useEffect(() => { + for (const [id, t] of transfers) { + if ((t.state === 'done' || t.state === 'cancelled') && !t.error) { + const timer = window.setTimeout(() => { + setTransfers((prev) => { + const next = new Map(prev) + next.delete(id) + return next + }) + }, 3000) + timersRef.current.push(timer) + } + } + }, [transfers]) + + const list = [...transfers.entries()] + if (list.length === 0) return null + + return ( +
    + {list.map(([id, t]) => { + const pct = t.totalBytes > 0 ? Math.min(100, Math.round((t.bytes / t.totalBytes) * 100)) : t.state === 'done' ? 100 : 0 + return ( +
    +
    + + {t.state === 'error' + ? '传输失败' + : t.state === 'cancelled' + ? '已取消' + : t.state === 'done' + ? '传输完成' + : t.kind === 'upload' + ? '上传中' + : t.kind === 'download' + ? '下载中' + : t.kind === 'zmodem-upload' + ? 'ZMODEM 上传中' + : 'ZMODEM 下载中'} + + {(t.state === 'error' || t.state === 'done') && ( + + )} +
    +
    {t.file || t.error || ''}
    + {t.state === 'running' && t.totalBytes > 0 && ( +
    +
    +
    + )} + {t.error &&
    {t.error}
    } +
    + ) + })} +
    + ) +} diff --git a/src/renderer/src/sftp/sftp.css b/src/renderer/src/sftp/sftp.css new file mode 100644 index 0000000..7382096 --- /dev/null +++ b/src/renderer/src/sftp/sftp.css @@ -0,0 +1,313 @@ +/* M4: remote file browser + transfer overlay */ + +.sftp-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--chrome-bg); +} + +.sftp-toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 4px; + border-bottom: 1px solid var(--chrome-border); +} + +.sftp-breadcrumb { + padding: 3px 8px; + font-size: 12px; + color: #8a8a8a; + border-bottom: 1px solid var(--chrome-border); + overflow-x: auto; + white-space: nowrap; +} + +.sftp-crumb { + cursor: pointer; +} + +.sftp-crumb:hover { + color: var(--chrome-accent); +} + +.sftp-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* Right-click menu wrapper: antd wraps each row in a span by default; + force block so rows keep full-width hover/selection and normal flow. */ +.sftp-list .ant-dropdown-trigger { + display: block; +} + +.sftp-row { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px; + align-items: center; + column-gap: 8px; + padding: 3px 8px; + font-size: 13px; + color: var(--chrome-fg); + cursor: pointer; +} + +.sftp-row:hover { + background: var(--chrome-hover); +} + +.sftp-row-selected { + background: rgba(59, 142, 234, 0.18); +} + +.sftp-icon { + display: inline-flex; + color: #8a8a8a; + font-size: 14px; +} + +.sftp-icon-dir { + color: #e3b341; +} + +.sftp-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13.5px; +} + +.sftp-mode { + text-align: center; + color: #9a9a9a; + font-family: Consolas, 'Courier New', monospace; + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} + +.sftp-owner { + text-align: right; + color: #8a8a8a; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sftp-size { + text-align: right; + color: #8a8a8a; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sftp-time { + text-align: right; + color: #8a8a8a; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sftp-empty, +.sftp-error { + padding: 12px; + text-align: center; + color: #8a8a8a; + font-size: 12px; +} + +.sftp-error { + color: #f85149; +} + +/* ---- permission / owner dialog ---- */ + +.sftp-perm { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sftp-perm-mode { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-family: Consolas, 'Courier New', monospace; + font-size: 14px; + color: var(--chrome-fg); + padding: 6px 10px; + background: #141414; + border: 1px solid #2d2d2d; + border-radius: 6px; +} + +.sftp-perm-sym { + color: rgba(255, 255, 255, 0.9); + letter-spacing: 1px; +} + +.sftp-perm-octal { + color: #3fb950; +} + +.sftp-perm-grid { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sftp-perm-grid-head, +.sftp-perm-grid-row { + display: grid; + grid-template-columns: 52px repeat(3, 1fr); + align-items: center; + column-gap: 8px; + font-size: 12px; + color: #8a8a8a; +} + +.sftp-perm-grid-head span { + text-align: center; +} + +.sftp-perm-grid-label { + color: rgba(255, 255, 255, 0.7); +} + +.sftp-perm-grid-head span:first-child, +.sftp-perm-grid-row .sftp-perm-grid-label { + text-align: left; +} + +.sftp-perm-grid-row .ant-checkbox-wrapper { + justify-content: center; + display: flex; +} + +.sftp-perm-octal-input { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sftp-perm-own { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.sftp-perm-own-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sftp-perm-label { + font-size: 12px; + color: #8a8a8a; +} + +.sftp-perm-own-field .ant-input { + text-align: left; +} + +.sftp-perm-hint { + font-size: 11px; + color: #6a6a6a; +} + +/* ---- global transfer overlay ---- */ + +.sftp-transfer-panel { + position: fixed; + right: 14px; + bottom: 14px; + z-index: 900; + display: flex; + flex-direction: column; + gap: 8px; + width: 300px; +} + +.sftp-transfer { + background: #1f1f1f; + border: 1px solid var(--chrome-border); + border-radius: 6px; + padding: 8px 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); +} + +.sftp-transfer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sftp-transfer-title { + font-size: 12px; + color: var(--chrome-fg); + font-weight: 600; +} + +.sftp-transfer-done .sftp-transfer-title { + color: #3fb950; +} + +.sftp-transfer-error .sftp-transfer-title { + color: #f85149; +} + +.sftp-transfer-cancelled .sftp-transfer-title { + color: #8a8a8a; +} + +.sftp-transfer-close { + border: none; + background: none; + color: #8a8a8a; + cursor: pointer; + padding: 0 2px; +} + +.sftp-transfer-close:hover { + color: #ffffff; +} + +.sftp-transfer-file { + font-size: 11px; + color: #9a9a9a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 3px 0; +} + +.sftp-transfer-bar { + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.sftp-transfer-fill { + height: 100%; + background: var(--chrome-accent); + border-radius: 3px; + transition: width 0.2s ease; +} + +.sftp-transfer-err { + font-size: 11px; + color: #f85149; + margin-top: 3px; + word-break: break-all; +} diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx new file mode 100644 index 0000000..6340def --- /dev/null +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -0,0 +1,916 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ForwardRefExoticComponent, Ref } from 'react' +import { Terminal } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebglAddon } from '@xterm/addon-webgl' +import { SearchAddon } from '@xterm/addon-search' +import '@xterm/xterm/css/xterm.css' +import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons' +import { getThemeById } from '@shared/theme' +import type { CommandItem } from '@shared/commands' +import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc' +import type { TerminalSettings } from '@shared/settings' +import { useSettingsStore } from '@renderer/settings/store' +import { writeBroadcast } from '@renderer/workspace/broadcastStore' +import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine' +import './terminal.css' + +/** Platform-modifier-based chord (Cmd on macOS, Ctrl elsewhere), plus Shift. */ +function isModShiftChord(e: KeyboardEvent, key: string): boolean { + const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey + return Boolean(mod) && e.shiftKey && e.key.toLowerCase() === key +} + +/** Keep xterm cursor/font weights in its accepted FontWeight format. */ +function toFontWeight(n: number): 'normal' | 'bold' | number { + if (n === 400) return 'normal' + if (n === 700) return 'bold' + return n +} + +/** Paste-risk heuristic: multi-line or unreasonably long text. */ +function isRiskyPaste(text: string): boolean { + if (text.includes('\n') || text.includes('\r')) return true + return text.length > 100 +} + +const SEARCH_DECORATIONS: NonNullable[1]>['decorations']> = { + matchBackground: '#3b8eea', + matchBorder: '#2d2d2d', + matchOverviewRuler: '#3b8eea', + activeMatchBackground: '#c77800', + activeMatchBorder: '#2d2d2d', + activeMatchColorOverviewRuler: '#c77800' +} + +export interface TerminalHandle { + focus(): void + clear(): void + /** currently attached pty session id */ + sessionId(): string +} + +export interface TerminalViewProps { + /** pty session created via window.api.createPty() */ + sessionId: string + /** called once after the pty process exits and the user closes the dead pane */ + onClose: () => void + className?: string +} + +interface SearchBarProps { + value: string + onChange: (v: string) => void + onPrev: () => void + onNext: () => void + onClose: () => void + enabled: boolean + result: string +} + +/** M5: cap for the floating completion popup height in px. */ +const SUGGEST_POPUP_H = 200 + +/** M5: estimated popup height for a given number of suggestion rows. */ +function estimatePopupHeight(itemCount: number): number { + // item ≈ 21px, hint row ≈ 18px, popup v-padding + gaps ≈ 10px. + return Math.min(SUGGEST_POPUP_H, Math.max(70, itemCount * 22 + 28)) +} + +function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element { + return ( +
    + onChange(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') onNext() + else if (e.key === 'Escape') onClose() + }} + /> + + + {result} + +
    + ) +} + +/** + * [M5] Feed one data chunk into the per-session line buffer. Returns the + * accumulated (non-empty) line for the completion overlay, or '' when the + * buffer is empty (some control data only). Enter/backspace/espace/ctrl-c are + * handled here; escape sequences (\x1b) and the leading non-printable control + * bytes on a chunk — like \x1b[K clear-line — never enter the buffer. + * + * `rewrite` flags a completion acceptance that just rewrote the shell line (a + * Ctrl+U kill + full-command write). The very next onData chunk is the shell's + * redraw echoing the accepted line, so instead of appending onto the stale + * pre-accept buffer we *replace* it with what the pty actually echoed. This is + * the echo-suppression that keeps the local buffer in sync with the shell and + * prevents the accepted text from being duplicated. + */ +function stepLineBuffer( + outBuf: React.MutableRefObject, + data: string, + rewrite: boolean +): { line: string; submit?: string } { + const b = outBuf.current + if (!data) return { line: b } + // Completion-rewrite redraw: reset to this chunk's visible text verbatim. For + // a shell like PowerShell the redraw is a leading escape/control sequence + // + the line text; strip leading control bytes so only the echoed command + // enters the buffer (matching how a normal chunk is captured). Any trailing + // text on the chunk is appended as usual. + if (rewrite) { + let i = 0 + while ((data[i] === '\x1b' && (data[i + 1] === '[' || data[i + 1] === '(')) || data[i] === '\r' || data[i] === '\n') { + i += data[i] === '\x1b' ? 2 : 1 + } + const visible = data.slice(i) + if (visible) { + outBuf.current = visible + return { line: visible } + } + // Only control output (e.g. the kill sequence handling) — treat as a reset. + outBuf.current = '' + return { line: '' } + } + // A chunk that begins with escape/control sequences (e.g. the terminal's + // clear-line) only removes buffer content; never appends. + const first = data[0] + if (b === '' && first === '\x1b') return { line: '' } + if (first === '\r') return submitOr(b) + if (first === '\x7f') { + outBuf.current = b.slice(0, -1) + return { line: outBuf.current } + } + if (first === '\x03') { + outBuf.current = '' + return { line: '' } + } + if (first === '\x1b') return { line: b } + outBuf.current = b + data + return { line: outBuf.current } +} + +function submitOr(b: string): { line: string; submit?: string } { + const t = b.trim() + return t ? { line: '', submit: t } : { line: '', submit: '' } +} + +/** M5: build the inline suggestion list (history + library, prefix, capped 5). */ +function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] { + const q = buffer.trim() + if (!q) return [] + const out: CommandItem[] = [] + for (const item of cache) { + if (item.command.startsWith(q)) out.push(item) + if (out.length >= 5) break + } + return out +} + +/** + * [M5] Accept a completion by forcing the pty to rewrite the whole line rather + * than appending only the missing tail. + * + * The previous implementation wrote just `full.slice(buf.length)` when the + * shell line buffer (`buf`) was a prefix of the target (`full`). That delta + * approach is what lost characters: `lineBufRef` is accumulated locally from + * user keystrokes and races the shell's real line buffer. Whenever the shell + * redraws the line (e.g. it emits a clear-line/control sequence, or the pty + * echoes control output back to us) the local buffer drifts out of sync with + * the shell, and the next keystroke overwrites the completion we just wrote. + * + * Rewriting the whole line removes that shared-mutable-buffer dependence: + * 1. `\x15` (Ctrl+U) kills the current line. bash, zsh, fish, and Windows + * PowerShell's PSReadLine all honor it, and — unlike `\x03` (Ctrl+C) — + * it leaves no caret and does not start a fresh prompt, so the shell stays + * on the same line ready for the replacement text. (A control-sequence + * fallback of `\x1b[D` repeated N times + `\x1b[K` would also work but is + * noisier and shell-specific; Ctrl+U is simpler and reliably supported.) + * 2. We then write `full`; the pty echoes it into the session buffer. + * + * `buf` is retained purely for call-site symmetry with the old signature; the + * rewrite always sends the Ctrl+U + full command. Whatever the local buffer + * previously held is irrelevant to the pty after the kill. + */ +function differenceWrite(sessionId: string, full: string, buf: string): void { + writeBroadcast(sessionId, '\x15') + writeBroadcast(sessionId, full) +} + +export const TerminalView: ForwardRefExoticComponent }> = + forwardRef(function TerminalViewInner({ sessionId, onClose, className }, ref) { + const settings = useSettingsStore((s) => s.settings) + + const hostRef = useRef(null) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const webglRef = useRef(null) + const searchRef = useRef(null) + const observerRef = useRef(null) + const timerRef = useRef(0) + const deadRef = useRef(false) + const searchOpenRef = useRef(false) + const searchTermRef = useRef('') + const nativePasteRef = useRef(null) + const onCloseRef = useRef(onClose) + const compiledRef = useRef([]) + const streamRef = useRef(null) + // M5: per-session line-capture buffer + completion candidate cache. + const lineBufRef = useRef('') + // M5: when a completion acceptance rewrites the shell line (Ctrl+U + full + // command via writeBroadcast in `differenceWrite`), the shell answers with a + // redraw that echoes `full`. If that echo were folded into the local buffer + // on top of the pre-accept text it would double the line. `diffRewriteRef` + // flags that the *next* onData chunk is that redraw output, so stepLineBuffer + // replaces the buffer with the freshly-echoed line instead of appending. + const diffRewriteRef = useRef(false) + const completionCacheRef = useRef([]) + const completionKeyRef = useRef('') + const recordingRef = useRef(false) + // M5: completion selection tracked in refs so the construction-time key + // handler always reads the live values without a stale closure. + const suggestionsRef = useRef([]) + const selIndexRef = useRef(0) + // Absolute-positioning hook for the completion popup (the box that wraps the + // xterm surface), so popup coordinates start at the terminal content area. + const suggestHookRef = useRef(null) + // M5: absolute position (view-relative) of the floating completion popup. + const [popupPos, setPopupPos] = useState<{ left: number; top: number; flip: boolean } | null>(null) + + // Compile keyword-highlight rules once per rules change; keep them in a ref so + // the (stable, single-construction) pty/term subscriptions always read the + // latest rules without triggering a terminal rebuild. Only new output is + // highlighted — the existing scrollback buffer is left untouched. + const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules]) + compiledRef.current = compiledRules + streamRef.current?.setRules(compiledRules) + + // M5: load completion candidates once per session (history + library cached). + const ensureCompletionCache = useCallback((): void => { + if (completionCacheRef.current.length > 0) return + const key = `${sessionId}|${window.location.pathname}` + if (completionKeyRef.current === key) return + completionKeyRef.current = key + void Promise.all([window.api.listHistory(), window.api.listLibrary()]) + .then(([h, l]) => { + // Newest-first history is already ordered; library keeps insertion order. + completionCacheRef.current = [...h, ...l] + }) + .catch(() => undefined) + }, [sessionId]) + + const [searchOpen, setSearchOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [resultInfo, setResultInfo] = useState('') + const [paste, setPaste] = useState(null) + const [dead, setDead] = useState(false) + const [exitCode, setExitCode] = useState(0) + const [recording, setRecording] = useState(false) + // Inline command-completion overlays built from history/library (M5). + const [suggestions, setSuggestions] = useState([]) + const [suggestionIndex, setSuggestionIndex] = useState(0) + const tSettings = settings.terminal + + // [M5] Mirror completion selection into state's single source of truth. Kept + // as a pair of effects so the construction-time key handler (below) always + // reads the live values through refs, never a stale closure. + useEffect(() => { + suggestionsRef.current = suggestions + }, [suggestions]) + useEffect(() => { + selIndexRef.current = suggestionIndex + }, [suggestionIndex]) + + const applyRenderers = useCallback((mode: TerminalSettings['rendererMode']) => { + const term = termRef.current + if (!term) return + if (webglRef.current) { + webglRef.current.dispose() + webglRef.current = null + } + if (mode === 'dom') return + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => { + // WebGL context lost -> fall back to the DOM renderer, keep the buffer. + if (!webglRef.current) return + webglRef.current.dispose() + webglRef.current = null + }) + term.loadAddon(webgl) + webglRef.current = webgl + } catch { + webglRef.current = null + } + }, []) + + const scheduleFit = useCallback(() => { + window.cancelAnimationFrame(timerRef.current) + timerRef.current = window.requestAnimationFrame(() => { + const fit = fitAddonRef.current + if (!fit) return + try { + fit.fit() + } catch { + return + } + const term = termRef.current + if (term && !deadRef.current) { + window.api.resizePty(sessionId, term.cols, term.rows) + } + }) + }, [sessionId]) + + const closeSearch = useCallback(() => { + searchOpenRef.current = false + searchRef.current?.clearDecorations() + setSearchOpen(false) + setResultInfo('') + termRef.current?.focus() + }, []) + + const search = useCallback((dir: 1 | -1) => { + const addon = searchRef.current + const q = searchTermRef.current.trim() + if (!addon || !q) return + const found = + dir === 1 + ? addon.findNext(q, { decorations: SEARCH_DECORATIONS }) + : addon.findPrevious(q, { decorations: SEARCH_DECORATIONS }) + setResultInfo(found ? '已找到' : '未找到') + }, []) + + const confirmPaste = useCallback( + (text: string) => { + const data = nativePasteRef.current ?? text + nativePasteRef.current = null + setPaste(null) + if (!deadRef.current) writeBroadcast(sessionId, data) + termRef.current?.focus() + }, + [sessionId] + ) + + const cancelPaste = useCallback(() => { + nativePasteRef.current = null + setPaste(null) + termRef.current?.focus() + }, []) + + /** [M5] Start recording this session's output to a log file. */ + const handleLogStart = useCallback(async (): Promise => { + try { + await window.api.logStart(sessionId) + recordingRef.current = true + setRecording(true) + } catch { + /* log start denied — stay idle */ + } + }, [sessionId]) + + /** [M5] Stop recording; finalize the session's log file. */ + const handleLogStop = useCallback(async (): Promise => { + try { + await window.api.logStop(sessionId) + recordingRef.current = false + setRecording(false) + } catch { + /* log stop denied — keep the current visual state */ + } + }, [sessionId]) + + const beginPaste = useCallback( + (text: string) => { + if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { + nativePasteRef.current = text + setPaste(text) + return + } + confirmPaste(text) + }, + [settings.terminal.pasteRiskConfirm, confirmPaste] + ) + + const copySelection = useCallback(async () => { + const term = termRef.current + if (!term) return + const sel = term.getSelection() + if (!sel) return + try { + await navigator.clipboard.writeText(sel) + } catch { + /* clipboard write denied */ + } + if (!settings.terminal.copyOnSelect) term.clearSelection() + }, [settings.terminal.copyOnSelect]) + + const pasteFromClipboard = useCallback(async () => { + try { + const text = await navigator.clipboard.readText() + if (text) beginPaste(text) + } catch { + /* clipboard read denied */ + } + termRef.current?.focus() + }, [beginPaste]) + + /** [M5] Accept the selected suggestion: substitute the buffer via a pty write. */ + const handleTab = useCallback( + (selected: CommandItem): void => { + const cmd = selected.command + // Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a + // delta — the pty redraw, not our local buffer, becomes the source of + // truth for the line (see differenceWrite). The redraw trigger sets the + // rewrite flag so onData resets the line buffer to the echoed line. + differenceWrite(sessionId, cmd, lineBufRef.current) + diffRewriteRef.current = true + lineBufRef.current = cmd + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + termRef.current?.focus() + }, + [sessionId] + ) + + /** [M5] Nudge the completion selection up (↑) or down (↓). */ + const moveSelection = useCallback((dir: 1 | -1): void => { + const n = suggestionsRef.current.length + if (n === 0) return + const next = (selIndexRef.current + dir + n) % n + selIndexRef.current = next + setSuggestionIndex(next) + }, []) + + const onTerminalKey = useCallback( + (e: KeyboardEvent): boolean => { + if (e.type !== 'keydown') return true + if (searchOpenRef.current && e.key === 'Escape') { + closeSearch() + return false + } + const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey + if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) { + searchOpenRef.current = true + setSearchOpen(true) + return false + } + if (isModShiftChord(e, 'c')) { + void copySelection() + return false + } + if (isModShiftChord(e, 'v')) { + void pasteFromClipboard() + return false + } + // M5: inline completion keyboard handling (pure overlay; never blocks the + // underlying pty write when suggestions are absent). + const list = suggestionsRef.current + if (list.length > 0) { + if (e.key === 'Escape') { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + return false + } + if (e.key === 'ArrowDown') { + moveSelection(1) + return false + } + if (e.key === 'ArrowUp') { + moveSelection(-1) + return false + } + if (e.key === 'Tab') { + // Tab accepts the highlighted suggestion: rewrite the whole line. + // Enter deliberately falls through — it must always execute the + // typed line (the old enter-accepts path resubmitted with the + // suggestion appended, e.g. `kimi` → `kimikimi`). + const selected = list[selIndexRef.current] ?? list[0] + if (selected) handleTab(selected) + return false + } + if (e.key === 'Enter') { + // Never let the popup swallow Enter: dismiss it and submit as typed. + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + return true + } + } + return true + }, + [closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab] + ) + + // ---- terminal instance: built once per session ---- + useLayoutEffect(() => { + const term = new Terminal({ + fontSize: tSettings.fontSize, + fontFamily: tSettings.fontFamily, + fontWeight: toFontWeight(tSettings.fontWeight), + fontWeightBold: toFontWeight(tSettings.boldFontWeight), + letterSpacing: tSettings.letterSpacing, + lineHeight: tSettings.lineHeight, + scrollback: tSettings.scrollback, + cursorBlink: tSettings.cursorBlink, + cursorStyle: tSettings.cursorStyle, + cursorInactiveStyle: tSettings.cursorInactiveStyle, + theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, + allowProposedApi: true, + overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false }, + drawBoldTextInBrightColors: true, + minContrastRatio: 1, + automaticFontFallback: true, + convertEol: false, + windowsPty: { backend: 'conpty', buildNumber: 0 } + } as Terminal['options'] & object) + + term.attachCustomKeyEventHandler(onTerminalKey) + + if (!hostRef.current) return + term.open(hostRef.current) + + const fit = new FitAddon() + term.loadAddon(fit) + fitAddonRef.current = fit + // Place any popup that was already showing (e.g. the pty started with a + // visible completion) once the terminal surface has real dimensions. + requestAnimationFrame(() => { + if (termRef.current === term && suggestionsRef.current.length > 0) placeSuggestPopup() + }) + + const searchAddon = new SearchAddon() + searchAddon.onDidChangeResults((ev) => { + if (searchOpenRef.current && searchTermRef.current.trim()) { + setResultInfo(`${Math.max(ev.resultIndex + 1, 0)}/${ev.resultCount}`) + } + }) + term.loadAddon(searchAddon) + searchRef.current = searchAddon + + termRef.current = term + // Rebind (e.g. template apply) must clear a death marker left by the + // previous session's exit — otherwise the pane stays under the dead mask. + deadRef.current = false + setDead(false) + setExitCode(0) + // M5: reset per-session state on (re)bind — fresh line buffer, no leftovers + // from a template apply or panel reuse; recording resumes idle. + lineBufRef.current = '' + setSuggestions([]) + setSuggestionIndex(0) + if (recordingRef.current) { + recordingRef.current = false + setRecording(false) + } + applyRenderers(tSettings.rendererMode) + scheduleFit() + + // xterm event hooks return IDisposable objects, api subscriptions return + // unsubscribe functions — keep the two shapes apart or cleanup throws. + // A fresh HighlightStream is created per session so carry data never leaks + // across a sessionId rebind (e.g. template apply). + const stream = new HighlightStream(compiledRules) + streamRef.current = stream + // The stream holds a trailing text run for cross-chunk anchor consistency. + // A shell prompt is exactly such a run with no follow-up data, so flush the + // carry on a short timer — prompts render within a frame, anchors survive. + let carryTimer: number | undefined + const scheduleCarryFlush = (): void => { + if (carryTimer !== undefined || !stream.hasPending()) return + carryTimer = window.setTimeout(() => { + carryTimer = undefined + if (deadRef.current) return + const rest = stream.flush() + if (rest) term.write(rest) + }, 16) + } + const writeHighlighted = (data: string) => { + const out = stream.push(data) + if (out) term.write(out) + scheduleCarryFlush() + } + const disposables: { dispose(): void }[] = [ + term.onData((data) => { + if (!deadRef.current) writeBroadcast(sessionId, data) + // M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it + // now so this redraw chunk replaces (not appends to) the line buffer. + const consumeRewrite = diffRewriteRef.current + diffRewriteRef.current = false + // M5: line capture → recordCommand + inline completion overlay. + const step = stepLineBuffer(lineBufRef, data, consumeRewrite) + if (typeof step.submit === 'string' && step.submit) { + void window.api.recordCommand(step.submit).catch(() => undefined) + } + if (deadRef.current) { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + } else if (step.line) { + ensureCompletionCache() + // Suggest only when enabled, and drop entries identical to the line + // already typed — a popup repeating the full input is pure noise. + const suggestOn = useSettingsStore.getState().settings.terminal.suggestEnabled + const list = suggestOn + ? buildSuggestions(completionCacheRef.current, step.line).filter( + (s) => s.command !== step.line + ) + : [] + suggestionsRef.current = list + selIndexRef.current = Math.min(selIndexRef.current, Math.max(list.length - 1, 0)) + if (list.length === 0) selIndexRef.current = 0 + setSuggestions(list) + setSuggestionIndex(selIndexRef.current) + } else { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + } + }), + term.onSelectionChange(() => { + if (!useSettingsStore.getState().settings.terminal.copyOnSelect) return + const sel = term.getSelection() + if (!sel) return + void navigator.clipboard.writeText(sel).catch(() => undefined) + }), + // Keep the floating completion popup pinned to wherever the text cursor + // currently is (repositioned only while suggestions are visible; opening + // the popup also places it once in the effect further below). + term.onCursorMove(() => { + if (suggestionsRef.current.length > 0) placeSuggestPopup() + }) + ] + // [M5] Placement for a visible popup — one measurement per popup opening, + // so the overlay appears before a subsequent cursor-move event fires. + if (suggestionsRef.current.length > 0) placeSuggestPopup() + // Live data is queued until the replay lands so output keeps its order. + let replayDone = false + const pending: string[] = [] + const unsubscribes: (() => void)[] = [ + window.api.onPtyData((e: PtyDataEvent) => { + if (e.id !== sessionId || deadRef.current) return + if (!replayDone) { + pending.push(e.data) + return + } + writeHighlighted(e.data) + }), + window.api.onPtyExit((e: PtyExitEvent) => { + if (e.id !== sessionId) return + deadRef.current = true + setExitCode(e.exitCode) + setDead(true) + }) + ] + // Late-subscriber catch-up: output emitted before this subscription + // (shell banner, template-apply rebind) replays from the main buffer. + void window.api + .getSessionReplay(sessionId) + .then((replay: string) => { + if (!deadRef.current && replay) writeHighlighted(replay) + }) + .catch(() => undefined) + .finally(() => { + replayDone = true + if (!deadRef.current) for (const chunk of pending) writeHighlighted(chunk) + pending.length = 0 + }) + + let observer: ResizeObserver | undefined + if (hostRef.current) { + observer = new ResizeObserver(() => scheduleFit()) + observer.observe(hostRef.current) + observerRef.current = observer + } + + return () => { + if (carryTimer !== undefined) window.clearTimeout(carryTimer) + for (const unsubscribe of unsubscribes) unsubscribe() + for (const disposable of disposables) disposable.dispose() + observer?.disconnect() + observerRef.current = null + deadRef.current = true + webglRef.current?.dispose() + webglRef.current = null + // Drop the stream with the session; a fresh one is created next bind so no + // carry survives across sessions. A mid-session carry is flushed below + // (harmless: it is treated as raw text by the resync-aware xterm). + streamRef.current?.flush() + streamRef.current = null + term.dispose() + termRef.current = null + fitAddonRef.current = null + searchRef.current = null + if (timerRef.current) window.cancelAnimationFrame(timerRef.current) + timerRef.current = 0 + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- terminal constructed exactly once + }, [sessionId]) + + // ---- options reactivity (no instance rebuild) ---- + useEffect(() => { + const term = termRef.current + if (!term) return + term.options.fontSize = tSettings.fontSize + term.options.fontFamily = tSettings.fontFamily + term.options.fontWeight = toFontWeight(tSettings.fontWeight) + term.options.fontWeightBold = toFontWeight(tSettings.boldFontWeight) + term.options.letterSpacing = tSettings.letterSpacing + term.options.lineHeight = tSettings.lineHeight + term.options.cursorBlink = tSettings.cursorBlink + term.options.cursorStyle = tSettings.cursorStyle + term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle + term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) } + scheduleFit() + }, [tSettings, settings.customThemes, scheduleFit]) + + // ---- renderer mode changes: keep the instance, swap renderer, refit ---- + useEffect(() => { + const next = tSettings.rendererMode + if (next === 'dom') { + applyRenderers('dom') + } else if (!webglRef.current) { + applyRenderers(next) + } + scheduleFit() + }, [tSettings.rendererMode, applyRenderers, scheduleFit]) + + // keep onClose fresh for the dead mask + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useImperativeHandle( + ref, + () => ({ + focus() { + termRef.current?.focus() + }, + clear() { + termRef.current?.clear() + }, + sessionId() { + return sessionId + } + }), + [sessionId] + ) + + const terminalClassName = useMemo(() => ['terminal-view', className].filter(Boolean).join(' '), [className]) + + /** + * [M5] Compute and store the floating popup position so it hugs the text + * cursor. Called whenever the suggestions become visible (the cursor does not + * move while a completion is open, so one placement per popup is enough) and + * on every cursor move while the popup is open. Coordinates are relative to + * `suggestHookRef` (the container that wraps the xterm surface), so `top` + * starts from the terminal content row area. + */ + /** + * [M5] Compute and store the floating popup position so it hugs the text + * cursor. Called whenever the suggestions become visible (the cursor does not + * move while a completion is open, so one placement per popup is enough) and + * on every cursor move while the popup is open. Coordinates are relative to + * `suggestHookRef` (the container that wraps the xterm surface), so `top` + * starts from the terminal content row area. + */ + const placeSuggestPopup = useCallback((): void => { + const term = termRef.current + if (!term) return + // xterm 6 internal API (`term._core._renderService.dimensions.css.cell`) + // may change across versions — keep the guarded fallback to a + // font-size-based cell estimate. + const ctx = term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } } } + const dim = ctx._core?._renderService?.dimensions?.css?.cell + const fontSize = term.options.fontSize ?? 14 + const lineHeight = term.options.lineHeight ?? 1 + const cellWidth = dim && dim.width > 0 ? dim.width : fontSize * 0.6 + const cellHeight = dim && dim.height > 0 ? dim.height : fontSize * lineHeight + const padLeft = 4 + const cursorX = term.buffer.active.cursorX + const cursorY = term.buffer.active.cursorY + const left = Math.round(cursorX * cellWidth + padLeft) + const hook = suggestHookRef.current + const roof = hook ? hook.getBoundingClientRect().height : 0 + const hookWidth = hook ? hook.getBoundingClientRect().width : 0 + // Keep a right-side margin so a wide popup (min 240px) never overflows the + // terminal container — e.g. a narrow split pane with the cursor mid-text. + const leftClamped = Math.min(Math.max(left, 0), Math.max(0, hookWidth - 240)) + // Below the cursor row by default; only flip above when there is not enough + // room under the cursor (uses a height estimate keyed off live count). + const popH = estimatePopupHeight(suggestionsRef.current.length) + const belowTop = (cursorY + 1) * cellHeight + const flip = belowTop > Math.max(roof - popH, 100) + const top = Math.round(flip ? Math.max(0, cursorY * cellHeight - popH) : belowTop) + setPopupPos({ left: leftClamped, top, flip }) + }, []) + + const onSearchChange = useCallback((value: string) => { + setSearchTerm(value) + searchTermRef.current = value + }, []) + + return ( +
    termRef.current?.focus()}> + {searchOpen && ( + search(-1)} + onNext={() => search(1)} + onClose={closeSearch} + enabled={searchTerm.trim().length > 0} + result={resultInfo} + /> + )} +
    + + +
    + {paste !== null && !dead && ( +
    + 检测到多行/长文本粘贴 + + +
    + )} + {/* Relative-positioned wrapper around the xterm surface: the suggestion + popup anchors to this box, so its coordinates start at the terminal + content rows (the search bar above never offsets the top). */} +
    + {popupPos !== null && suggestions.length > 0 && ( +
    e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > +
    + {suggestions.map((s, i) => ( + + ))} +
    +
    ↑/↓ 选择 · Tab 接受 · Esc 关闭
    +
    + )} +
    + {dead && ( +
    +
    进程已退出 (代码 {exitCode})
    + +
    + )} +
    +
    +
    + ) +}) \ No newline at end of file diff --git a/src/renderer/src/terminal/highlightEngine.ts b/src/renderer/src/terminal/highlightEngine.ts new file mode 100644 index 0000000..9907aa5 --- /dev/null +++ b/src/renderer/src/terminal/highlightEngine.ts @@ -0,0 +1,294 @@ +import type { HighlightRule } from '@shared/settings' + +/** + * Keyword highlight engine. + * + * Pure functions (no React / electron / xterm deps) that rewrite plain terminal + * text into ANSI truecolor SGR runs *before* it is handed to xterm. Escape + * sequences are never matched; only plain-text segments are, rule-by-rule in + * ascending priority order, each rule claiming spans that later (lower- + * priority) rules must not overlap. + */ + +export interface CompiledRule { + id: string + /** compiled with the 'g' flag, pattern's own flags otherwise untouched */ + regex: RegExp + /** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */ + fg: string + /** optional hex bg */ + bg?: string + /** copied from the source rule for stable priority ordering */ + priority: number +} + +type Token = { kind: 'seq' | 'text'; value: string } +type Span = { start: number; end: number; fg: string; bg?: string } + +/** ANSI escape tokens: CSI, OSC, two-char (ESC ( / ) ..), and ESC = / > . */ +const ESCAPE_RE = + /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g + +/** Matches a single rule may inject per chunk (bomb guard). */ +const MAX_PER_RULE = 300 +/** Chunks larger than this are returned unmodified (budget guard). */ +const MAX_CHUNK = 512 * 1024 + +/** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } { + let h = (hex || '').trim().replace(/^#/, '') + if (h.length === 3) h = h.replace(/./g, (c) => c + c) + if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 } + const n = parseInt(h, 16) + return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff } +} + +/** Split a chunk into escape-sequence tokens and plain-text runs. */ +export function tokenize(input: string): Token[] { + const out: Token[] = [] + let acc = '' + let i = 0 + let m: RegExpExecArray | null + // No /y — anchored alternations (e.g. a leading `^...` rule) still need to + // try matching at position 0 of each run. lastIndex is reset per call. + while ((m = ESCAPE_RE.exec(input)) !== null) { + if (m.index > i) acc += input.slice(i, m.index) + if (acc.length > 0) { + out.push({ kind: 'text', value: acc }) + acc = '' + } + out.push({ kind: 'seq', value: m[0] }) + i = m.index + m[0].length + } + if (i < input.length) acc += input.slice(i) + if (acc.length > 0) out.push({ kind: 'text', value: acc }) + return out +} + +/** Compile only enabled + safely-compilable rules, sorted by priority asc. */ +export function compileRules(rules: HighlightRule[]): CompiledRule[] { + const out: CompiledRule[] = [] + for (const rule of rules) { + if (!rule || rule.enabled === false) continue + const pattern = rule.pattern + if (typeof pattern !== 'string' || pattern.length === 0) continue + let regex: RegExp + try { + // 'g' always; case-sensitivity left to the pattern (no automatic 'i'). + regex = new RegExp(pattern, 'g') + } catch { + continue // invalid pattern → silently skip + } + const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : '' + if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue + const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined + out.push({ id: rule.id, regex, fg, bg, priority: rule.priority }) + } + out.sort((a, b) => a.priority - b.priority) + return out +} + +/** Wrap one plain-text span with truecolor SGR. */ +function wrap(text: string, fgHex: string, bgHex?: string): string { + const f = hexToRgb(fgHex) + let s = `\x1b[38;2;${f.r};${f.g};${f.b}` + if (bgHex) { + const b = hexToRgb(bgHex) + s += `;48;2;${b.r};${b.g};${b.b}` + } + return `${s}m${text}\x1b[0m` +} + +/** True when [s,e) overlaps any claimed span. */ +function overlaps(claimed: Span[], s: number, e: number): boolean { + for (const c of claimed) { + if (s < c.end && e > c.start) return true + } + return false +} + +/** + * Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule + * remaining match allowance shared across the whole chunk). Returns the + * SGR-injected text. With a global budget of zero for every rule the run is + * returned untouched. + */ +function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string { + if (text.length === 0) return '' + const claimed: Span[] = [] + for (let ri = 0; ri < rules.length; ri++) { + const rule = rules[ri] + if (budgets[ri] <= 0) continue + rule.regex.lastIndex = 0 + let m: RegExpExecArray | null + let made = 0 + while ((m = rule.regex.exec(text)) !== null) { + const start = m.index + const end = start + m[0].length + // guard against zero-width / runaway matches + if (end <= start) { + rule.regex.lastIndex++ + continue + } + if (!overlaps(claimed, start, end)) { + claimed.push({ start, end, fg: rule.fg, bg: rule.bg }) + made++ + budgets[ri]-- + if (budgets[ri] <= 0) break + } + if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse + } + } + if (claimed.length === 0) return text + claimed.sort((a, b) => a.start - b.start) + let out = '' + let pos = 0 + for (const c of claimed) { + if (c.start > pos) out += text.slice(pos, c.start) + out += wrap(text.slice(c.start, c.end), c.fg, c.bg) + pos = c.end + } + if (pos < text.length) out += text.slice(pos) + return out +} + +/** + * Rewrite `chunk` with truecolor highlights. Returns the input unchanged when + * there are no enabled rules, the chunk is empty, or it exceeds the budget. + */ +export function applyHighlights(chunk: string, rules: CompiledRule[]): string { + if (!chunk || rules.length === 0) return chunk + if (chunk.length > MAX_CHUNK) return chunk + + let budgets = rules.map(() => MAX_PER_RULE) + let usedBudget = false + let out = '' + for (const token of tokenize(chunk)) { + if (token.kind === 'seq') { + out += token.value + continue + } + // Double insurance: a residual raw ESC that somehow survives tokenization is + // never highlighted (it is a partial/unknown escape, do not corrupt it). + if (token.value.includes('\x1b')) { + out += token.value + continue + } + const before = budgets.reduce((a, b) => a + b, 0) + out += applyRun(token.value, rules, budgets) + const after = budgets.reduce((a, b) => a + b, 0) + if (after < before) usedBudget = true + } + return usedBudget ? out : chunk +} + +/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */ +function isIncompleteEscape(seg: string): boolean { + // `seg` is expected to start at (or be) a lone ESC. + // A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence. + if (seg === '\x1b') return true + // Unfinished CSI: ESC [ plus parameter bytes but no final byte in 0x40..0x7E. + if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true + // Unfinished OSC: ESC ] ... with no BEL (\x07) or ST (\x1b\ ) terminator. + // (A trailing half-ST is covered by the lone-ESC case above.) + if (seg.startsWith('\x1b]') && !seg.includes('\x07') && !seg.includes('\x1b\\')) return true + return false +} + +/** Index of the last complete escape sequence's end in `input`, or -1. */ +function lastEscapeEnd(input: string): number { + let end = -1 + let m: RegExpExecArray | null + ESCAPE_RE.lastIndex = 0 + while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length + return end +} + +/** Index of the dangling incomplete escape at the tail of `input`, or -1. */ +function findIncompleteIndex(input: string): number { + const idx = input.lastIndexOf('\x1b') + if (idx < 0) return -1 + return isIncompleteEscape(input.slice(idx)) ? idx : -1 +} + +/** Upper bound a held (unfinished) escape fragment is allowed to grow to. */ +const MAX_CARRY = 1024 + +/** + * Stateful, block-agnostic highlighter. + * + * PTY output arrives in arbitrary chunks: (a) escape sequences are routinely cut + * mid-sequence, and (b) a plain-text run can span many chunks. To make the + * streamed result identical to highlighting the whole stream at once (important + * for `^`/`$`-anchored rules), `push` only emits a safe *closed* prefix and + * holds back anything that a later chunk could continue: + * - a trailing *incomplete escape* (no final byte / OSC terminator), or + * - the final *text run* (it may extend into the next chunk). + * Held bytes are re-joined with the next chunk before highlighting, so run / + * anchor boundaries never shift. `flush` highlights whatever is still held. + */ +export class HighlightStream { + private rules: CompiledRule[] + private buffer = '' + + constructor(rules: CompiledRule[]) { + this.rules = rules + } + + /** Swap the active rules without touching any buffered content. */ + setRules(rules: CompiledRule[]): void { + this.rules = rules + } + + /** True while content is held back (a pending flush will release it). */ + hasPending(): boolean { + return this.buffer.length > 0 + } + + /** Join held bytes with `chunk`, return content safe to hand to xterm. */ + push(chunk: string): string { + this.buffer += chunk + + // (a) trailing incomplete escape -> hold it, emit the closed prefix. + const incompleteAt = findIncompleteIndex(this.buffer) + if (incompleteAt >= 0) { + const head = this.buffer.slice(0, incompleteAt) + const tail = this.buffer.slice(incompleteAt) + if (tail.length > MAX_CARRY) { + // Give up waiting on a pathologically long unterminated sequence: emit + // the fragment raw (applyHighlights skips raw \x1b in text runs). + this.buffer = '' + return applyHighlights(head, this.rules) + tail + } + this.buffer = tail + return applyHighlights(head, this.rules) + } + + // (b) no incomplete escape -> find the last *closed* escape and emit only + // through its end, holding the trailing text run for a future chunk. + const lastEnd = lastEscapeEnd(this.buffer) + if (lastEnd < 0) { + // Pure text so far, nothing is closed yet. Bounds memory: if it grew past + // the per-chunk budget just pass it through raw rather than hold forever. + if (this.buffer.length > MAX_CHUNK) { + const raw = this.buffer + this.buffer = '' + return applyHighlights(raw, this.rules) + } + return '' + } + const head = this.buffer.slice(0, lastEnd) + this.buffer = this.buffer.slice(lastEnd) + return applyHighlights(head, this.rules) + } + + /** Flush any held content (session end) — highlighted as a final run. */ + flush(): string { + const b = this.buffer + this.buffer = '' + return applyHighlights(b, this.rules) + } +} + +/** Exposed for self-tests only — not part of the public contract. */ +export const __testHooks = { tokenize, hexToRgb, isIncompleteEscape, findIncompleteIndex, lastEscapeEnd } \ No newline at end of file diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css new file mode 100644 index 0000000..223ca92 --- /dev/null +++ b/src/renderer/src/terminal/terminal.css @@ -0,0 +1,333 @@ +/* Terminal surface */ +.terminal-view { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--chrome-bg); +} + +.terminal-view .xterm { + height: 100%; +} + +/* Dark slim scrollbar for the xterm viewport — the native Windows one renders + light track + arrow buttons that look broken on the dark surface. */ +.terminal-view .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(121, 121, 121, 0.45) transparent; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar { + width: 10px; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-track { + background: transparent; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.45); + border-radius: 5px; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(160, 160, 160, 0.6); +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-button, +.terminal-view .xterm-viewport::-webkit-scrollbar-corner { + display: none; + background: transparent; +} + +/* ---- search bar ---- */ +.term-searchbar { + display: flex; + align-items: center; + gap: 6px; + flex: none; + padding: 4px 8px; + background: var(--chrome-hover); + border-bottom: 1px solid var(--chrome-border); + font-size: 12px; + color: var(--chrome-fg); +} + +.term-searchbar-input { + flex: 1; + min-width: 0; + padding: 3px 8px; + border: 1px solid var(--chrome-border); + border-radius: 4px; + background: var(--chrome-bg); + color: var(--chrome-fg); + font: inherit; + outline: none; +} + +.term-searchbar-input:focus { + border-color: var(--chrome-accent); +} + +.term-searchbar-btn { + flex: none; + padding: 3px 6px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--chrome-fg); + cursor: pointer; + font: inherit; +} + +.term-searchbar-btn:hover:not(:disabled), +.term-searchbar-btn:focus-visible { + background: var(--chrome-hover); + color: var(--chrome-accent); +} + +.term-searchbar-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.term-searchbar-count { + flex: none; + min-width: 48px; + text-align: center; + color: var(--chrome-fg); + opacity: 0.8; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ---- session log record / open-dir buttons (floating, top-left) ---- */ +.term-recbar { + position: absolute; + top: 4px; + right: 14px; + z-index: 4; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 6px; + background: rgba(24, 26, 31, 0.85); + border: 1px solid var(--chrome-border); + /* Hidden until the terminal is hovered — it must not sit over the output. */ + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; +} + +.terminal-view:hover .term-recbar, +.term-recbar:focus-within { + opacity: 1; + pointer-events: auto; +} + +.term-rec-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #8a8a8a; + font-size: 12px; + cursor: pointer; +} + +.term-rec-btn:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +.term-rec-btn.is-recording { + color: var(--chrome-accent); +} + +/* While a log is recording, keep the bar visible so the state is obvious. */ +.term-recbar:has(.term-rec-btn.is-recording) { + opacity: 1; + pointer-events: auto; +} + +/* ---- M5 inline command-completion floating popup ---- + Absolutely positioned inside .term-suggest-hook (the box that wraps the + terminal surface), so it tracks the text cursor and is never clipped by the + terminal-view's own overflow. The list scrolls internally when too tall. */ +.term-suggest-hook { + position: relative; + flex: 1 1 0; + min-height: 0; + min-width: 0; +} + +.term-suggestbar { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 240px; + max-width: 420px; + max-height: 200px; + padding: 5px 6px 6px; + overflow-y: auto; + background: var(--chrome-bg); + border: 1px solid var(--chrome-border); + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); + user-select: none; + box-sizing: border-box; +} + +.term-suggest-list { + display: flex; + flex-direction: column; + gap: 1px; + flex: 0 0 auto; +} + +.term-suggest-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 2px 6px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--chrome-fg); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.term-suggest-item:hover, +.term-suggest-item.is-active { + background: rgba(59, 142, 234, 0.18); + color: #ffffff; +} + +.term-suggest-cmd { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; +} + +.term-suggest-tag { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +.term-suggest-hint { + flex: 0 0 auto; + font-size: 11px; + color: #8a8a8a; + opacity: 0.9; + padding-top: 4px; + line-height: 13px; + white-space: nowrap; +} + +/* ---- paste risk confirm bar ---- */ +.term-pastebar { + display: flex; + align-items: center; + gap: 8px; + flex: none; + padding: 4px 8px; + background: #3a2e1a; + border-bottom: 1px solid #5c4a26; + font-size: 12px; + color: #e8d9a8; +} + +.term-pastebar-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.term-pastebar-btn { + flex: none; + padding: 3px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + font: inherit; +} + +.term-pastebar-btn-ok { + background: var(--chrome-accent); + color: #fff; +} + +.term-pastebar-btn-ok:hover { + filter: brightness(1.1); +} + +.term-pastebar-btn-cancel { + background: transparent; + color: var(--chrome-fg); +} + +.term-pastebar-btn-cancel:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* ---- terminal docked area (flex:1) ---- */ +.terminal-view-dock { + position: relative; + flex: 1 1 0; + min-height: 0; + height: 100%; + padding: 0; +} + +/* ---- dead process overlay ---- */ +.term-dead-mask { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 14px; +} + +.term-dead-btn { + padding: 6px 24px; + border: none; + border-radius: 4px; + background: var(--chrome-accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +.term-dead-btn:hover { + filter: brightness(1.1); +} \ No newline at end of file diff --git a/src/renderer/src/theme/editor/ThemeEditor.tsx b/src/renderer/src/theme/editor/ThemeEditor.tsx new file mode 100644 index 0000000..5bbb867 --- /dev/null +++ b/src/renderer/src/theme/editor/ThemeEditor.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from 'react' +import { Input, Modal } from 'antd' +import type { ThemeColors, TerminalTheme } from '@shared/theme' +import { DEFAULT_DARK, getThemeById } from '@shared/theme' +import { useSettingsStore } from '../../settings/store' + +export interface ThemeEditorProps { + open: boolean + onClose: () => void + /** + * Editing mode: theme id to edit (must exist in settings.customThemes). + * Create mode: undefined — initial values are the currently active theme's colors. + */ + themeId?: string +} + +/** editable palette keys; cursorAccent is derived and intentionally not exposed */ +type ColorKey = Exclude + +interface FieldSpec { + key: ColorKey + label: string +} + +const HEADER_KEYS: FieldSpec[] = [ + { key: 'foreground', label: '前景' }, + { key: 'background', label: '背景' }, + { key: 'cursor', label: '光标' }, + { key: 'selectionBackground', label: '选区' } +] + +const ANSI_KEYS: FieldSpec[] = [ + { key: 'black', label: 'Black' }, + { key: 'red', label: 'Red' }, + { key: 'green', label: 'Green' }, + { key: 'yellow', label: 'Yellow' }, + { key: 'blue', label: 'Blue' }, + { key: 'magenta', label: 'Magenta' }, + { key: 'cyan', label: 'Cyan' }, + { key: 'white', label: 'White' }, + { key: 'brightBlack', label: 'Bright Black' }, + { key: 'brightRed', label: 'Bright Red' }, + { key: 'brightGreen', label: 'Bright Green' }, + { key: 'brightYellow', label: 'Bright Yellow' }, + { key: 'brightBlue', label: 'Bright Blue' }, + { key: 'brightMagenta', label: 'Bright Magenta' }, + { key: 'brightCyan', label: 'Bright Cyan' }, + { key: 'brightWhite', label: 'Bright White' } +] + +export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const setCustomThemes = useSettingsStore((s) => s.setCustomThemes) + + /** create mode = themeId undefined; edit mode must point at a custom theme */ + const isCreate = themeId === undefined + const editingTheme = useMemo(() => { + if (themeId === undefined) return undefined + return settings.customThemes.find((t) => t.id === themeId) + }, [settings.customThemes, themeId]) + + const [name, setName] = useState('') + const [colors, setColors] = useState(DEFAULT_DARK.colors) + const [saving, setSaving] = useState(false) + + // (re)initialize the form each time the modal opens + useEffect(() => { + if (!open) return + if (!isCreate) { + if (editingTheme === undefined) { + // the target theme disappeared (e.g. deleted in another window) — close the editor + onClose() + return + } + setName(editingTheme.name) + setColors({ ...editingTheme.colors }) + } else { + /** create mode: seed a fresh copy based on the currently active theme */ + const active = getThemeById(settings.terminal.themeId, settings.customThemes) + setName(active.name + '(副本)') + setColors({ ...active.colors }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleSave = async (): Promise => { + const trimmed = name.trim() + if (trimmed === '') return + setSaving(true) + try { + if (isCreate) { + const theme: TerminalTheme = { + id: crypto.randomUUID(), + name: trimmed, + builtin: false, + colors: { ...colors } + } + // persist the new theme and activate it immediately + await setCustomThemes([...settings.customThemes, theme]) + await updateTerminal({ themeId: theme.id }) + onClose() + } else if (editingTheme !== undefined) { + await setCustomThemes( + settings.customThemes.map((t) => + t.id === editingTheme.id ? { ...t, name: trimmed, colors: { ...colors } } : t + ) + ) + onClose() + } + } finally { + setSaving(false) + } + } + + const setColor = (key: ColorKey, value: string): void => { + setColors((prev) => ({ ...prev, [key]: value })) + } + + return ( + +
    +
    + 名称 + setName(e.target.value)} + placeholder="主题名称" + maxLength={60} + /> +
    + +
    + {HEADER_KEYS.map((spec) => ( + + ))} +
    + {ANSI_KEYS.map((spec) => ( + + ))} +
    + +
    + 预览 + +
    +
    + + ) +} + +function ColorField({ + spec, + value, + onChange +}: { + spec: FieldSpec + value: string + onChange: (key: ColorKey, value: string) => void +}): React.JSX.Element { + return ( +
    + {spec.label} + onChange(spec.key, e.target.value)} + /> +
    + ) +} + +function ThemePreviewSnippet({ colors }: { colors: ThemeColors }): React.JSX.Element { + const lines: ReadonlyArray = [ + ['drwxr-xr-x 12 bill users 4096 Sep 5 10:02 node_modules', colors.brightBlue], + ['-rw-r--r-- 1 bill users 890 Sep 5 09:41 package.json', colors.brightBlack], + ['-rw-r--r-- 1 bill users 42 Sep 5 09:41 index.ts', colors.brightBlack], + ['OpenTerminal v0.1.0', colors.brightCyan] + ] + return ( +
    + {lines.map(([text, color], i) => ( +
    + {text} +
    + ))} +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/ConnectFlow.tsx b/src/renderer/src/workspace/ConnectFlow.tsx new file mode 100644 index 0000000..e4f3b7e --- /dev/null +++ b/src/renderer/src/workspace/ConnectFlow.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { Button, Input, Modal } from 'antd' +import { LoadingOutlined } from '@ant-design/icons' +import type { SshConnection, SshSecretOverride } from '@shared/connections' + +/** + * 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 + * openSession cannot be aborted before it resolves. + * - 'connecting' → an uncancellable "连接中…" modal while openSession flies. + * + * Workspace moves `phase` secret→connecting when the secret is confirmed and + * back to null when the attempt settles. Each dialog is `key`-ed by the + * connection id so a new attempt never inherits previously typed secrets. + */ + +export type ConnectStage = 'idle' | 'password' | 'passphrase' | 'connecting' + +export interface ConnectFlowProps { + /** one pending double-click request; null clears the flow */ + conn: SshConnection | null + /** 'connecting' once a secret (if any) has been committed and openSession started */ + phase: 'secret' | 'connecting' + onConfirmed: (secretOverride: SshSecretOverride) => void + onCancel: () => void +} + +export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowProps): React.JSX.Element { + const [password, setPassword] = useState('') + const [passphrase, setPassphrase] = useState('') + + const stage: ConnectStage = + conn == null + ? 'idle' + : phase === 'connecting' + ? 'connecting' + : conn.askPasswordAtConnect + ? 'password' + : conn.askPassphraseAtConnect + ? 'passphrase' + : 'connecting' + + // A fresh connection must not inherit a previous attempt's typed value, so + // each stage modal carries a per-connection `key` that forces a new mount. + const key = conn?.id ?? 'none' + + if (conn == null) return <> + + if (stage === 'password') { + return ( + setPassword(v)} + onOk={() => onConfirmed({ password })} + onCancel={onCancel} + /> + ) + } + + if (stage === 'passphrase') { + return ( + setPassphrase(v)} + onOk={() => onConfirmed({ passphrase })} + onCancel={onCancel} + /> + ) + } + + if (stage === 'connecting') { + return ( + +
    + + + 正在连接 {conn?.name != null ? conn.name : '…'} + +
    +
    + ) + } + + return <> +} + +function SecretModal({ + key, + title, + description, + placeholder, + value, + onChange, + onOk, + onCancel +}: { + /** forces per-connection mount */ + key: string + title: string + description: string + placeholder: string + value: string + onChange: (value: string) => void + onOk: () => void + onCancel: () => void +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + + const handleOk = (): void => { + setSubmitting(true) + onOk() + } + + return ( + +
    +
    {description}
    + onChange(event.target.value)} + onPressEnter={handleOk} + disabled={submitting} + /> +
    +
    + ) +} + +/** + * Small "连接中…" indicator rendered in the toolbar while a connection attempt + * is in flight. + */ +export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Element | null { + return busy ? ( + + ) : null +} \ No newline at end of file diff --git a/src/renderer/src/workspace/HostKeyModal.tsx b/src/renderer/src/workspace/HostKeyModal.tsx new file mode 100644 index 0000000..5b14f29 --- /dev/null +++ b/src/renderer/src/workspace/HostKeyModal.tsx @@ -0,0 +1,60 @@ +import { Alert, Button, Modal } from 'antd' +import type { HostKeyPromptEvent } from '@shared/connections' + +/** + * Host key verification dialog. Shown one at a time (Workspace keeps a FIFO + * queue and renders the head) for the two distinct cases: + * + * - 'new' → first time we see this host: show host:port + fingerprint. + * - 'changed' → the stored fingerprint no longer matches: red warning header + * about a possible man-in-the-middle attack. + * + * The decision is returned to the caller, which commits it through + * `window.api.respondHostKey(promptId, action)`. + */ + +export interface HostKeyModalProps { + event: HostKeyPromptEvent + onDecision: (action: 'accept' | 'reject') => void +} + +export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JSX.Element { + const isChanged = event.reason === 'changed' + const target = `${event.host}:${event.port}` + + return ( + 主机指纹已变更!
    + ) : ( + '未知主机' + ) + } + okText="接受并连接" + cancelText="拒绝" + okButtonProps={isChanged ? { danger: true } : { type: 'primary' }} + cancelButtonProps={{ danger: !isChanged }} + onOk={() => onDecision('accept')} + onCancel={() => onDecision('reject')} + width={480} + > +
    + {isChanged ? ( + + ) : ( +
    首次连接该主机,尚未记录其指纹。
    + )} + +
    主机:{target}
    +
    指纹:{event.fingerprint}
    +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/QuickInputPanel.tsx b/src/renderer/src/workspace/QuickInputPanel.tsx new file mode 100644 index 0000000..6967db2 --- /dev/null +++ b/src/renderer/src/workspace/QuickInputPanel.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { App, Input } from 'antd' +import { create } from 'zustand' +import type { CommandItem } from '@shared/commands' +import { writeBroadcast } from './broadcastStore' + +/** Open state shared between the tab-bar toggle button and the floating panel. */ +export const useQuickInputStore = create<{ + open: boolean + toggle: () => void + setOpen: (open: boolean) => void +}>((set) => ({ + open: window.localStorage.getItem('ot.quickInputOpen') === '1', + toggle: () => + set((state) => { + const next = !state.open + window.localStorage.setItem('ot.quickInputOpen', next ? '1' : '0') + return { open: next } + }), + setOpen: (open) => { + window.localStorage.setItem('ot.quickInputOpen', open ? '1' : '0') + set({ open }) + } +})) + +/** + * M6 quick-input panel (bottom-right floating). Three sections: + * 1. free-text command line (with auto-execute(\r) toggle) + * 2. library commands (api.listLibrary()) + * 3. recent history (api.listHistory(), top 10) + * + * Sends target = the active session via `onResolveActiveSession`; when broadcast + * is enabled and the active session is a target, the send routes through the + * broadcast fan-out so it reaches every target. Lists refresh on open. + */ +export interface QuickInputPanelProps { + /** resolve the currently active terminal session id ('' when none) */ + onResolveActiveSession: () => string +} + +export function QuickInputPanel({ onResolveActiveSession }: QuickInputPanelProps): React.JSX.Element | null { + const { message } = App.useApp() + const open = useQuickInputStore((s) => s.open) + const close = useQuickInputStore((s) => s.setOpen) + // auto-execute(\r); persisted across sessions. + const [autoEnter, setAutoEnter] = useState(() => window.localStorage.getItem('ot.quickInputEnter') !== '0') + const [value, setValue] = useState('') + const [history, setHistory] = useState([]) + const [library, setLibrary] = useState([]) + /** tracks the previous `open` so lists refresh on the false→true transition */ + const prevOpenRef = useRef(open) + + // Refresh lists every time the panel opens (and stay fresh if it was open). + const refresh = useCallback(async (): Promise => { + try { + const [h, l] = await Promise.all([window.api.listHistory(), window.api.listLibrary()]) + setHistory(h.slice(0, 10)) + setLibrary(l) + } catch { + setHistory([]) + setLibrary([]) + } + }, []) + + useEffect(() => { + if (open && !prevOpenRef.current) void refresh() + prevOpenRef.current = open + }, [open, refresh]) + + const send = useCallback( + (text: string) => { + const target = onResolveActiveSession() + if (!target) { + void message.info('没有激活的终端会话') + return + } + const data = autoEnter ? `${text}\r` : text + writeBroadcast(target, data) + // keep "最近命令" fresh immediately after a send + void window.api + .listHistory() + .then((h: CommandItem[]) => setHistory(h.slice(0, 10))) + .catch(() => undefined) + setValue('') + }, + [autoEnter, onResolveActiveSession] + ) + + const onEnter = useCallback((): void => { + const t = value.trim() + if (t) send(t) + }, [value, send]) + + const onToggleAutoEnter = useCallback((checked: boolean): void => { + setAutoEnter(checked) + window.localStorage.setItem('ot.quickInputEnter', checked ? '1' : '0') + }, []) + + if (!open) return null + + return ( +
    +
    + 快捷输入 + +
    +
    + setValue(e.target.value)} + onPressEnter={onEnter} + /> + +
    + +
    +
    快捷命令
    + {library.length === 0 ? ( +
    暂无快捷命令
    + ) : ( +
    + {library.map((item) => ( + + ))} +
    + )} +
    + +
    +
    最近命令
    + {history.length === 0 ? ( +
    暂无历史记录
    + ) : ( +
    + {history.map((item) => ( + + ))} +
    + )} +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx new file mode 100644 index 0000000..6d2c834 --- /dev/null +++ b/src/renderer/src/workspace/SshBottomPanel.tsx @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' + +import { FilePanel } from '../sftp/FilePanel' + +export interface SshBottomPanelProps { + sessionId: string + /** The terminal surface rendered in the flexible top region. */ + terminal: ReactNode +} + +/** File browser height in px — clamped to [120, 60% of container height]. */ +const MIN_HEIGHT = 120 + +export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element { + /** Current file-browser height in px. */ + const [filesHeight, setFilesHeight] = useState(220) + /** Container height captured while a drag is in flight. */ + const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null) + + const onDividerMouseDown = useCallback((e: React.MouseEvent): void => { + if (e.button !== 0) return + const container = (e.currentTarget.parentElement as HTMLElement | null) ?? null + if (!container) return + dragInfoRef.current = { + startY: e.clientY, + startHeight: filesHeight, + containerH: container.clientHeight + } + e.preventDefault() + }, [filesHeight]) + + useEffect(() => { + const onMove = (e: MouseEvent): void => { + const info = dragInfoRef.current + if (!info) return + const delta = e.clientY - info.startY + const max = Math.round(info.containerH * 0.6) + setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta))) + } + const onUp = (): void => { + dragInfoRef.current = null + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, []) + + return ( +
    +
    {terminal}
    +
    +
    + +
    +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/SshHostBadge.tsx b/src/renderer/src/workspace/SshHostBadge.tsx new file mode 100644 index 0000000..adb6411 --- /dev/null +++ b/src/renderer/src/workspace/SshHostBadge.tsx @@ -0,0 +1,17 @@ +/** + * Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`) + * on an SSH terminal tab. Host labels are often long, so the display string is + * truncated with an ellipsis rather than wrapped; the full label is always + * available via the native `title` tooltip and the tab-level tooltip. + */ +export function SshHostBadge({ label }: { label: string }): React.JSX.Element { + return ( + + {label} + + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx new file mode 100644 index 0000000..4d6258a --- /dev/null +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react' +import type { IDockviewPanelProps } from 'dockview-react' +import { Button } from 'antd' +import { AreaChartOutlined } from '@ant-design/icons' + +import { TerminalView } from '../terminal/TerminalView' +import { MonitorPanel } from '../monitor/MonitorPanel' +import { SshBottomPanel } from './SshBottomPanel' +import { useBroadcastStore } from './broadcastStore' +import type { TerminalParams } from './Workspace' + +export interface TerminalPanelExtraProps { + /** + * Notifies the workspace that a session is already dead (its process + * exited) so the panel close skips the pty kill. + */ + onSessionDead?: (sessionId: string) => void +} + +export type TerminalPanelProps = IDockviewPanelProps & TerminalPanelExtraProps + +/** + * Dockview content component registered as `'terminal'`. + * + * Binds the pty `sessionId` param to . When the underlying + * process exits, TerminalView calls `onClose` and we close the panel — + * marking the session dead first so onDidRemovePanel won't try to kill an + * already-exited process. + * + * For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with + * [系统监控] is docked next to the terminal, and a resizable file browser + * (SshBottomPanel) sits below the terminal. Local sessions render terminal + * only. + */ +export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element { + const sessionId = params.sessionId ?? '' + const sessionKind = params.sessionKind + const isSsh = sessionKind === 'ssh' + const closedRef = useRef(false) + /** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */ + const [sideOpen, setSideOpen] = useState(true) + + // M6 broadcast: keep the session in the broadcast registry while mounted so + // it can be picked as a target / receive fan-out writes (idempotent upsert). + useEffect(() => { + useBroadcastStore.getState().registerSession({ id: sessionId, title: api.title ?? '', isSsh }) + return () => useBroadcastStore.getState().unregisterSession(sessionId) + // eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below + }, [sessionId]) + + // M6: keep the registered broadcast title in sync with the tab title. + useEffect(() => { + const disposable = api.onDidTitleChange((event) => { + useBroadcastStore.getState().registerSession({ id: sessionId, title: event.title, isSsh }) + }) + return () => disposable.dispose() + }, [api, sessionId, isSsh]) + + const terminal = ( + { + if (closedRef.current) return + closedRef.current = true + onSessionDead?.(sessionId) + api.close() + }} + /> + ) + + return ( +
    + {isSsh && ( +
    + ) +} diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx new file mode 100644 index 0000000..3c587aa --- /dev/null +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -0,0 +1,251 @@ +import { useEffect, useState } from 'react' +import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' +import type { MenuProps } from 'antd' +import { CloudServerOutlined } from '@ant-design/icons' +import type { IDockviewPanelHeaderProps } from 'dockview-react' + +import { useBroadcastStore } from './broadcastStore' +import { SshHostBadge } from './SshHostBadge' +import type { LayoutMeta } from '@shared/ipc' + +export type TerminalTabProps = IDockviewPanelHeaderProps<{ + sessionId?: string + sessionKind?: 'local' | 'ssh' + hostLabel?: string +}> + +/** + * Custom tab renderer for terminal panels: default dockview tab look (title + + * close glyph) with an antd context menu: + * 关闭 / 关闭其他 / 关闭右侧 + * + * SSH sessions get a 🖥 server icon and a green left-border accent so they're + * visually distinct from local terminals. All menu actions close panels + * directly through `api.close()`. The workspace's `onDidRemovePanel` handles + * the pty kill, so nothing leaks. + */ +export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element { + const panel = api.group // bound panel via the panel api + const [title, setTitle] = useState(api.title) + const selfIndex = panel.panels.findIndex((p) => p.api === api) + + const isSsh = params?.sessionKind === 'ssh' + const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined + const tooltip = isSsh + ? hostLabel && hostLabel !== title + ? `${title} — ${hostLabel}` + : (hostLabel || title) + : '本地终端' + // M6 broadcast: show a badge on tabs that are current broadcast targets. + const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? '')) + + // Keep the tab title live (dockview re-renders tab components only on + // params changes; title updates arrive through this event). + useEffect(() => { + const disposable = api.onDidTitleChange((event) => { + setTitle(event.title) + }) + return () => disposable.dispose() + }, [api]) + + const closePanel = (target: typeof panel.panels[number]): void => { + target.api.close() + } + + const menu: MenuProps = { + items: [ + { key: 'close', label: '关闭' }, + { key: 'closeOthers', label: '关闭其他', disabled: panel.panels.length <= 1 }, + { key: 'closeRight', label: '关闭右侧', disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } + ], + onClick: ({ key }) => { + if (key === 'close') { + api.close() + } else if (key === 'closeOthers') { + for (const sibling of panel.panels) { + if (sibling.api !== api) closePanel(sibling) + } + } else if (key === 'closeRight') { + for (let i = selfIndex + 1; i < panel.panels.length; i++) { + closePanel(panel.panels[i]) + } + } + } + } + + return ( + +
    + {isSsh && } + {title} + {isSsh && hostLabel && } + {isBroadcastTarget && ( + + 播 + + )} + +
    +
    + ) +} + +function CloseGlyph(): React.JSX.Element { + return ( + + ) +} + +export function SaveTemplateModal({ + open, + onCancel, + onConfirm +}: { + open: boolean + onCancel: () => void + onConfirm: (name: string) => Promise +}): React.JSX.Element { + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + + const handleOk = async (): Promise => { + const trimmed = name.trim() + if (!trimmed) return + setSaving(true) + try { + await onConfirm(trimmed) + setName('') + onCancel() + } finally { + setSaving(false) + } + } + + return ( + + setName(event.target.value)} + onPressEnter={handleOk} + /> + + ) +} + +export function ApplyTemplateModal({ + open, + onClose, + onApply, + onDelete, + list +}: { + open: boolean + onClose: () => void + onApply: (meta: LayoutMeta) => Promise + onDelete: (meta: LayoutMeta) => Promise + list: () => Promise +}): React.JSX.Element { + const [items, setItems] = useState([]) + const [busyId, setBusyId] = useState(null) + + const refresh = async (): Promise => { + setItems(await list()) + } + + useEffect(() => { + if (open) void refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleApply = async (meta: LayoutMeta): Promise => { + setBusyId(meta.id) + try { + await onApply(meta) + onClose() + } finally { + setBusyId(null) + } + } + + const handleDelete = async (meta: LayoutMeta): Promise => { + setBusyId(meta.id) + try { + await onDelete(meta) + await refresh() + } finally { + setBusyId(null) + } + } + + return ( + + {items.length === 0 ? ( +
    暂无已保存的模板
    + ) : ( +
      + {items.map((meta) => ( +
    • +
      +
      {meta.name}
      +
      + {new Date(meta.createdAt).toLocaleString()} +
      +
      + + + void handleDelete(meta)} + > + + + +
    • + ))} +
    + )} +
    + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx new file mode 100644 index 0000000..c29ebb8 --- /dev/null +++ b/src/renderer/src/workspace/Workspace.tsx @@ -0,0 +1,1084 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { App, Button, Checkbox, Popover, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { + CloudServerOutlined, + CodeOutlined, + LayoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + SettingOutlined, + ShareAltOutlined, + ThunderboltOutlined +} from '@ant-design/icons' +import { DockviewReact } from 'dockview-react' +import type { + DockviewApi, + DockviewReadyEvent, + IDockviewHeaderActionsProps, + IDockviewPanel, + IDockviewPanelHeaderProps, + IDockviewPanelProps +} from 'dockview-react' +import 'dockview-react/dist/styles/dockview.css' + +import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections' +import { ConnectionSidebar } from '../connections/ConnectionSidebar' +import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar' +import { ConnectFlow } from './ConnectFlow' +import { HostKeyModal } from './HostKeyModal' +import { TerminalPanel } from './TerminalPanel' +import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab' +import { useBroadcastStore, writeBroadcast } from './broadcastStore' +import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel' +import type { WorkspaceMode } from './workspaceMode' +import { useWorkspaceModeStore } from './workspaceModeStore' +import './workspace.css' + +export interface WorkspaceProps { + onOpenSettings: () => void +} + +interface LayoutMetaLike { + id: string + name: string + createdAt: number +} + +interface TerminalParams { + sessionId?: string + sessionKind?: 'local' | 'ssh' + hostLabel?: string +} + +export type { TerminalParams } + +const TERMINAL_COMPONENT = 'terminal' +const TERMINAL_TAB_COMPONENT = 'terminal-tab' + +/** + * Per-workspace monotonic title counter for "终端 N". Lives on `window` so + * dev hot-reloads (which reset module state) don't restart the numbering and + * produce duplicate tab titles. + */ +const titleSeqHolder = window as unknown as { __otTitleSeq?: number } +function nextTerminalTitle(): string { + titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1 + return `终端 ${titleSeqHolder.__otTitleSeq}` +} + +function sessionIdOf(panel: IDockviewPanel): string | undefined { + return (panel.params as TerminalParams | undefined)?.sessionId +} + +/** The dockview api that owns a given workspace mode. */ +function apiOfMode(mode: WorkspaceMode, terminalApi: DockviewApi | undefined, sshApi: DockviewApi | undefined): DockviewApi | undefined { + return mode === 'terminal' ? terminalApi : sshApi +} + +/** Shared per-mode right-header actions (the "+" creates a terminal in the + * terminal dockview and hints from the server list in the ssh dockview). */ +function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element { + function TabActions({ containerApi, group }: IDockviewHeaderActionsProps): React.JSX.Element { + // The per-group "+" creates a local terminal — SSH sessions can only be + // opened from the server list, so in ssh mode the button just hints. + const { message } = App.useApp() + const handleAdd = (): void => { + if (mode === 'ssh') { + message.info('SSH 会话请从服务器列表连接') + return + } + void (async (): Promise => { + const result = await window.api.createPty() + containerApi.addPanel({ + id: result.id, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: nextTerminalTitle(), + params: { sessionId: result.id, sessionKind: 'local' }, + position: { referenceGroup: group.id, direction: 'within' } + }) + })() + } + + return ( +
    + + + +
    + ) + } + return TabActions +} + +const TerminalTabActions = createTabActions('terminal') +const SshTabActions = createTabActions('ssh') + +/** + * Workspace owns TWO independent dockviews — one for local terminals, one for + * SSH sessions — so the two session kinds never mix on the same tab strip. + * Switching workspace mode simply toggles `display:none` on the container div + * (React state / panel instances stay mounted, so the switch is free). + * + * Sessions: panels carry a `params.sessionId`. Killing a session is idempotent + * through `deadSessionsRef` so a session is killed at most once — either + * up-front (TerminalView `onClose` after the process exits) or when its panel + * is removed. Each dockview registers `onDidRemovePanel` so a panel close in + * either workspace kills the bound session. + */ +export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX.Element { + const { message } = App.useApp() + /** Dockview apis for the two isolated workspaces. */ + const terminalApiRef = useRef(undefined) + const sshApiRef = useRef(undefined) + /** Currently selected workspace group (terminal / ssh) — drives visibility. */ + const workspaceMode = useWorkspaceModeStore((s) => s.mode) + /** session counts per dockview — "empty" means no *session* panel. */ + const [modeCounts, setModeCounts] = useState<{ terminal: number; ssh: number }>({ + terminal: 0, + ssh: 0 + }) + const terminalCount = modeCounts.terminal + const sshCount = modeCounts.ssh + /** Per-workspace active panel used as the split anchor. */ + const activeTerminalPanelRef = useRef(undefined) + const activeSshPanelRef = useRef(undefined) + /** The currently-visible mode's active panel (broadcast / quick-input target). */ + const activePanelRef = useRef(undefined) + + /** Sessions we already asked to kill — kill once, never twice. */ + const deadSessionsRef = useRef>(new Set()) + /** Bind-time disposables for both dockviews, disposed on unmount. */ + const disposablesRef = useRef>([]) + + const [saveOpen, setSaveOpen] = useState(false) + const [applyOpen, setApplyOpen] = useState(false) + /** Terminal dockview ready — gates first-run seeding. */ + const [terminalReady, setTerminalReady] = useState(false) + + /** Local terminal panels surfaced to the sidebar's "终端" section. */ + const [localPanels, setLocalPanels] = useState>([]) + /** id of the currently active panel (for sidebar highlight). */ + const [activePanelId, setActivePanelId] = useState(null) + + /** [侧栏] toggle — hidden by default; the choice persists per device. */ + const [sidebarOpen, setSidebarOpen] = useState(() => + window.localStorage.getItem('ot.sidebarOpen') === '1' + ) + const toggleSidebar = useCallback((): void => { + setSidebarOpen((prev) => { + const next = !prev + window.localStorage.setItem('ot.sidebarOpen', next ? '1' : '0') + return next + }) + }, []) + /** one pending SSH connection request (null = idle). */ + const [requestedConn, setRequestedConn] = useState(null) + /** true while openSession is in flight — drives the "连接中…" modal. */ + const [connecting, setConnecting] = useState(false) + /** FIFO of pending host-key confirmations — render the head only. */ + const [hostKeyQueue, setHostKeyQueue] = useState([]) + + /** B's sidebar handle; refresh() after a successful SSH connect */ + const sidebarRef = useRef(null) + + /** Idempotently kill a pty session. */ + const killSession = useCallback((sessionId: string | undefined): void => { + if (!sessionId) return + if (deadSessionsRef.current.has(sessionId)) return + deadSessionsRef.current.add(sessionId) + window.api.killPty(sessionId) + }, []) + + /** Mark a session dead ahead of a panel close so the cleanup skips it. */ + const markSessionDead = useCallback((sessionId: string): void => { + deadSessionsRef.current.add(sessionId) + }, []) + + const createSession = useCallback(async (): Promise => { + const result = await window.api.createPty() + return result.id + }, []) + + /** Rebuild the local-terminal list for the sidebar's "终端" section. The + * terminal dockview holds only local sessions, so it is the single source. */ + const recomputeLocalPanels = useCallback((): void => { + const current = terminalApiRef.current + if (!current) { + setLocalPanels([]) + return + } + const panels = current.panels + .filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local') + .map((p) => ({ id: p.id, title: p.title ?? '' })) + setLocalPanels(panels) + }, []) + + /** Recount session panels per dockview into local state. */ + const recountAll = useCallback((): void => { + const terminal = terminalApiRef.current + const ssh = sshApiRef.current + const t = terminal ? terminal.panels.filter((p) => sessionIdOf(p)).length : 0 + const s = ssh ? ssh.panels.filter((p) => sessionIdOf(p)).length : 0 + setModeCounts((prev) => (prev.terminal === t && prev.ssh === s ? prev : { terminal: t, ssh: s })) + }, []) + + /** Focus a local terminal panel from the sidebar (terminal dockview). */ + const onFocusPanel = useCallback((panelId: string): void => { + const panel = terminalApiRef.current?.getPanel(panelId) + if (!panel) return + useWorkspaceModeStore.getState().setMode('terminal') + panel.api.setActive() + }, []) + + /** + * Bind a dockview's api: stash it in its ref, subscribe to lifecycle events + * (remove → kill session, active → track panel), and keep indices fresh. + */ + const bindApi = useCallback( + (mode: WorkspaceMode, api: DockviewApi): void => { + if (mode === 'terminal') terminalApiRef.current = api + else sshApiRef.current = api + + const cleanups = [ + api.onDidRemovePanel((panel: IDockviewPanel) => { + killSession(sessionIdOf(panel)) + recomputeLocalPanels() + recountAll() + }), + api.onDidActivePanelChange((event) => { + if (mode === 'terminal') activeTerminalPanelRef.current = event.panel ?? undefined + else activeSshPanelRef.current = event.panel ?? undefined + if (useWorkspaceModeStore.getState().mode === mode) { + activePanelRef.current = event.panel ?? undefined + setActivePanelId(event.panel?.id ?? null) + } + }), + api.onDidLayoutChange(() => { + recomputeLocalPanels() + recountAll() + }), + api.onDidAddPanel(() => recountAll()) + ] + disposablesRef.current.push(...cleanups) + }, + [killSession, recomputeLocalPanels, recountAll] + ) + + const handleTerminalReady = useCallback( + (event: DockviewReadyEvent): void => { + bindApi('terminal', event.api) + recountAll() + recomputeLocalPanels() + setTerminalReady(true) + }, + [bindApi, recountAll, recomputeLocalPanels] + ) + + const handleSshReady = useCallback( + (event: DockviewReadyEvent): void => { + bindApi('ssh', event.api) + recountAll() + }, + [bindApi, recountAll] + ) + + /** Dispose dockview subscriptions on unmount. */ + useEffect(() => { + return () => { + for (const disposable of disposablesRef.current) disposable.dispose() + disposablesRef.current = [] + } + }, []) + + /** Keep `activePanelRef` pointing at the currently-visible workspace's + * active panel whenever the mode changes (a mode toggle alone doesn't fire + * onDidActivePanelChange). */ + useEffect(() => { + const sync = (): void => { + const mode = useWorkspaceModeStore.getState().mode + const api = apiOfMode(mode, terminalApiRef.current, sshApiRef.current) + const active = api?.activePanel + activePanelRef.current = active ?? undefined + setActivePanelId(active?.id ?? null) + } + sync() + return useWorkspaceModeStore.subscribe(sync) + }, []) + + /** First run: seed one local terminal so the terminal workspace is never + * empty on launch. Runs once, after the terminal dockview is ready. */ + useEffect(() => { + if (!terminalReady) return + const current = terminalApiRef.current + if (current && !current.panels.some((p) => sessionIdOf(p))) void addTerminal() + // seed the terminal workspace exactly once, on first ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [terminalReady]) + + /** Kill remaining sessions on window close. */ + useEffect(() => { + const handler = (): void => { + for (const api of [terminalApiRef.current, sshApiRef.current]) { + if (!api) continue + for (const panel of api.panels) { + killSession(sessionIdOf(panel)) + } + } + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [killSession]) + + /** + * Host-key verification subscription. Events stream in a FIFO queue; each + * workspace shows a single modal over its head and pops on every decision. + */ + useEffect(() => { + return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => { + setHostKeyQueue((prev) => [...prev, event]) + }) + }, []) + + const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null + + const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => { + // The queue head's decision is final (accept/reject both consume the prompt). + setHostKeyQueue((prev) => { + const head = prev[0] + if (!head) return prev + window.api.respondHostKey(head.promptId, action) + return prev.slice(1) + }) + }, []) + + /** + * M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with + * capture:true so it runs before xterm's own key handling; it only cycles the + * currently-visible workspace's session panels. + */ + useEffect(() => { + const handler = (e: KeyboardEvent): void => { + const ctrl = e.ctrlKey + const code = e.code + if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return + const mode = useWorkspaceModeStore.getState().mode + const current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current) + if (!current) return + const panels = current.panels.filter((p) => sessionIdOf(p)) + if (panels.length < 2) return + e.preventDefault() + e.stopPropagation() + const active = current.activePanel ?? activePanelRef.current + let idx = active ? panels.findIndex((p) => p.id === active.id) : -1 + const step = code === 'PageDown' ? 1 : -1 + let next = active + for (let i = 1; i <= panels.length; i++) { + const candidate = panels[(idx + step * i + panels.length * 2) % panels.length] + if (sessionIdOf(candidate)) { + next = candidate + break + } + } + if (next && next !== active) next.api.setActive() + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, []) + + /** New local terminals always land in the terminal dockview. Ensure it is + * visible first so the new panel shows up. */ + const addTerminal = useCallback( + async (direction?: 'right' | 'below'): Promise => { + const current = terminalApiRef.current + if (!current) return + useWorkspaceModeStore.getState().setMode('terminal') + const sessionId = await createSession() + const reference = activeTerminalPanelRef.current + + current.addPanel({ + id: sessionId, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: nextTerminalTitle(), + params: { sessionId, sessionKind: 'local' }, + position: + reference && direction + ? { referencePanel: reference.id, direction } + : undefined + }) + }, + [createSession] + ) + + /** + * SSH connection pipeline. openSession resolves once the session is ready to + * stream; `connectionId` + any staged `secretOverride` are the only inputs + * the main process needs — no extra IPC channel. New sessions land in the ssh + * dockview. + */ + const connect = useCallback( + async (conn: SshConnection, secretOverride: SshSecretOverride): Promise => { + setConnecting(true) + try { + const result = await window.api.openSession({ + kind: 'ssh', + connectionId: conn.id, + secretOverride + }) + const current = sshApiRef.current + if (!current) return + // SSH sessions belong to the ssh workspace — switch to it so the panel + // is visible once added. + useWorkspaceModeStore.getState().setMode('ssh') + current.addPanel({ + id: result.id, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: conn.name, + params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host } + }) + // B's sidebar refreshes lastConnectedAt on connect. + void sidebarRef.current?.refresh() + } catch (error) { + message.error(typeof error === 'string' ? error : 'SSH 连接失败') + } finally { + setConnecting(false) + setRequestedConn(null) + } + }, + [message] + ) + + const handleConnectRequest = useCallback( + (conn: SshConnection): void => { + // 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) { + setRequestedConn(conn) + return + } + void connect(conn, {}) + }, + [connect] + ) + + const handleConnectCancel = useCallback((): void => { + setRequestedConn(null) + }, []) + + const handleSecretConfirmed = useCallback( + (secretOverride: SshSecretOverride): void => { + const conn = requestedConn + if (!conn) return + // Swap the secret modal for the connecting modal and fire openSession. + setRequestedConn(conn) + void connect(conn, secretOverride) + }, + [requestedConn, connect] + ) + + const handleNewTerminal = useCallback((): void => { + void addTerminal() + }, [addTerminal]) + + /** Icon-rail "+": in terminal mode make a terminal; in ssh mode hand off to + * the server list instead (SSH sessions can't be created on the fly). */ + const handleRailNewTerminal = useCallback((): void => { + const mode = useWorkspaceModeStore.getState().mode + if (mode === 'ssh') { + message.info('SSH 会话请从服务器列表连接') + return + } + void addTerminal() + }, [addTerminal, message]) + + const handleSplit = useCallback( + (direction: 'right' | 'below'): void => { + const mode = useWorkspaceModeStore.getState().mode + if (mode === 'terminal') { + // New local terminal split in the terminal workspace. + void addTerminal(direction) + return + } + // ssh mode: split only when there's already a live SSH session to split. + const current = sshApiRef.current + const hasSession = current ? current.panels.some((p) => sessionIdOf(p)) : false + if (!hasSession) { + message.info('请先连接 SSH 服务器') + return + } + const reference = activeSshPanelRef.current + if (!reference || !current) return + const sessionId = sessionIdOf(reference) + if (!sessionId) return + const refParams = reference.params as TerminalParams | undefined + current.addPanel({ + id: `ssh-${sessionId}-${window.crypto.randomUUID().slice(0, 8)}`, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: reference.title, + params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel }, + position: { referencePanel: reference.id, direction } + }) + }, + [message, addTerminal] + ) + + const handleSaveTemplate = useCallback(async (name: string): Promise => { + await window.api.saveLayout( + { + id: window.crypto.randomUUID(), + name, + createdAt: Date.now() + }, + JSON.stringify({ + terminal: terminalApiRef.current?.toJSON(), + ssh: sshApiRef.current?.toJSON() + }) + ) + }, []) + + /** + * Rebind every restored session panel to a fresh local pty. Templates cannot + * carry live session state (SSH needs credentials not stored in the layout), + * so sessions are recreated as local terminals in whichever workspace the + * layout put them. + */ + const rebindRestoredPanels = useCallback( + async (api: DockviewApi): Promise => { + for (const panel of api.panels) { + const id = sessionIdOf(panel) + if (id && panel.view.contentComponent === TERMINAL_COMPONENT) { + const newSessionId = (await window.api.createPty()).id + panel.api.updateParameters({ sessionId: newSessionId, sessionKind: 'local' }) + deadSessionsRef.current.delete(id) + } + } + }, + [] + ) + + const handleApplyTemplate = useCallback( + async (meta: LayoutMetaLike): Promise => { + const raw = await window.api.getLayout(meta.id) + if (raw == null) return + + // Sessions currently bound to panels — the template load replaces them. + const previousSessions = new Set() + for (const api of [terminalApiRef.current, sshApiRef.current]) { + if (!api) continue + for (const panel of api.panels) { + const sid = sessionIdOf(panel) + if (sid) previousSessions.add(sid) + } + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return + } + const payload = parsed as { terminal?: unknown; ssh?: unknown } + const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload + + // Dual layout (M6.1+): restore each dockview from its own payload. + // Legacy layout (pre-M6.1 single toJSON): restore it wholly into the + // terminal dockview. + if (hasDualLayout) { + terminalApiRef.current?.fromJSON(payload.terminal as never) + sshApiRef.current?.fromJSON(payload.ssh as never) + } else { + // Legacy single-dockview layout → restore into the terminal workspace. + terminalApiRef.current?.fromJSON((payload as unknown) as never) + } + + // Fresh sessions for every restored terminal panel in both workspaces. + if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) + if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current) + + // Old sessions are unreachable after the layout swap — kill them. + for (const sid of previousSessions) killSession(sid) + recountAll() + recomputeLocalPanels() + const mode = useWorkspaceModeStore.getState().mode + activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel + }, + [killSession, rebindRestoredPanels, recountAll, recomputeLocalPanels] + ) + + const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise => { + await window.api.deleteLayout(meta.id) + }, []) + + /** [M5] Run a command in the currently active terminal/ssh session. */ + const runCommand = useCallback( + (cmd: string): void => { + const panel = activePanelRef.current + if (!panel) { + message.info('没有激活的终端:请至少打开一个本地终端或 SSH 会话') + return + } + const sid = sessionIdOf(panel) + if (!sid) { + message.info('当前激活面板没有可用的会话') + return + } + writeBroadcast(sid, `${cmd}\r`) + panel.api.setActive() + }, + [message] + ) + + const layoutMenu: MenuProps = { + items: [ + { key: 'split-right', label: '水平分屏' }, + { key: 'split-below', label: '垂直分屏' }, + { type: 'divider' }, + { key: 'save', label: '保存为模板' }, + { key: 'apply', label: '应用模板' } + ], + 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) + } + } + + /** Session id of the currently active, visible terminal panel ('' when none). */ + const resolveActiveSessionId = useCallback((): string => { + const panel = activePanelRef.current + if (!panel) return '' + return sessionIdOf(panel) ?? '' + }, []) + + return ( +
    + +
    + {sidebarOpen && ( + + )} +
    + {/* Local-terminal workspace. */} +
    + ) => ( + + ) + }} + tabComponents={{ + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) + }} + rightHeaderActionsComponent={TerminalTabActions} + onReady={handleTerminalReady} + theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + /> + {terminalCount === 0 && ( +
    +
    +
    暂无终端
    + +
    +
    + )} +
    + {/* SSH workspace. */} +
    + ) => ( + + ) + }} + tabComponents={{ + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) + }} + rightHeaderActionsComponent={SshTabActions} + onReady={handleSshReady} + theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + /> + {sshCount === 0 && ( +
    +
    +
    暂无 SSH 会话
    +
    请从左侧服务器列表连接
    +
    +
    + )} +
    +
    +
    + + {/* M6: floating quick-input panel (bottom-right). Sends to the active + session; broadcast target → fan-out. Sits above the sftp transfer + overlay's 14px bottom offset so the two never overlap. */} + + + {/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */} + {requestedConn != null && ( + + )} + + {/* Host-key verification — head of the FIFO queue only. */} + {hostKeyHead != null && ( + + )} + + setSaveOpen(false)} + onConfirm={handleSaveTemplate} + /> + setApplyOpen(false)} + onApply={handleApplyTemplate} + onDelete={handleDeleteTemplate} + list={() => window.api.listLayouts()} + /> +
    + ) +} + +/** "+" renders the new-terminal button (per-group tab addition). */ +function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element { + return ( + + ) +} + +/** 广播 target picker: toggle + Popover with per-session checkboxes. */ +function BroadcastToggleButton(): React.JSX.Element { + const enabled = useBroadcastStore((s) => s.enabled) + const targets = useBroadcastStore((s) => s.targets) + const sessions = useBroadcastStore((s) => s.sessions) + const toggleTarget = useBroadcastStore((s) => s.toggleTarget) + const setEnabled = useBroadcastStore((s) => s.setEnabled) + /** M6.1: only the current workspace group's sessions are offered as targets. */ + const mode = useWorkspaceModeStore((s) => s.mode) + + const cls = [ + 'workspace-tabbar-btn2', + enabled ? ' is-active' : '' + ].join('') + return ( + document.body} + content={ + + } + > + + + ) +} + +/** Popover body: list the current mode group's open sessions with a checkbox + * each; enable gate >= 2. Hidden-group targets stay in `targets` (the store + * doesn't care about visibility) but are not offered while in the other mode. */ +function WorkspaceBroadcastPopover({ + mode, + enabled, + targets, + sessions, + onToggleTarget, + onSetEnabled +}: { + mode: WorkspaceMode + enabled: boolean + targets: Set + sessions: { id: string; title: string; isSsh: boolean }[] + onToggleTarget: (id: string) => void + onSetEnabled: (enabled: boolean) => void +}): React.JSX.Element { + const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh + const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s))) + const label = (s: { id: string; title: string; isSsh: boolean }): string => + `${s.title}${s.isSsh ? ' (SSH)' : ''}` + return ( +
    +
    广播输入({mode === 'ssh' ? 'SSH' : '终端'})
    +
    + 广播开关 + onSetEnabled(e.target.checked)} + > + {enabled ? `已启用 (${targets.size})` : targets.size < 2 ? '需至少选择 2 个目标' : '未启用'} + +
    +
    + {groupSessions.length === 0 ? ( +
    + {mode === 'ssh' ? '暂无 SSH 服务器会话' : '暂无终端'} +
    + ) : ( + groupSessions.map((s) => ( +
    + onToggleTarget(s.id)}> + {label(s)} + +
    + )) + )} +
    +
    选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。
    +
    + ) +} + +/** 快捷输入 panel toggle (ThunderboltOutlined) — state lives in the shared store. */ +function QuickInputToggleButton(): React.JSX.Element { + const toggle = useQuickInputStore((s) => s.toggle) + const open = useQuickInputStore((s) => s.open) + return ( + + + + ) +} + +/** + * XTerminal-style slim icon rail on the left edge — replaces the top toolbar. + * Top: workspace actions; bottom: settings. + */ +function IconRail({ + onOpenSettings, + onNewTerminal, + onSplit, + onToggleSidebar, + sidebarOpen, + connecting, + layoutMenu +}: { + onOpenSettings: () => void + onNewTerminal: () => void + onSplit: (direction: 'right' | 'below') => void + onToggleSidebar: () => void + sidebarOpen: boolean + connecting: boolean + layoutMenu: MenuProps +}): React.JSX.Element { + return ( +
    + : } + onClick={onToggleSidebar} + /> +
    + } onClick={onNewTerminal} /> + +
    + +
    + {connecting && ( + + + + )} + } onClick={onOpenSettings} /> +
    + ) +} + +/** M6.1: terminal / SSH workspace entries in the icon rail. */ +function ModeRailButtons(): React.JSX.Element { + const mode = useWorkspaceModeStore((s) => s.mode) + const setMode = useWorkspaceModeStore((s) => s.setMode) + return ( + <> + } + active={mode === 'terminal'} + onClick={() => setMode('terminal')} + /> + } + active={mode === 'ssh'} + onClick={() => setMode('ssh')} + /> + + ) +} + +/** + * Layout menu as an in-layout flyout panel. antd Dropdown portals to + * document.body, where dockview's global pointer handling races it: the menu + * painted over the rail and dismissed itself on open. This panel lives in the + * rail's own stacking context, opens to the right of the rail, and closes only + * on item pick, outside pointerdown, or Escape. + */ +function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown) + return (): void => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + const pick = (key: string): void => { + setOpen(false) + menu.onClick?.({ key, keyPath: [key] } as Parameters>[0]) + } + + return ( +
    + + + + {open && ( +
    + {(menu.items ?? []).map((item, i) => { + if (!item) return null + if (typeof item === 'string' || (item as { type?: unknown }).type === 'divider') { + return
    + } + const entry = item as { key?: React.Key; label?: ReactNode } + if (entry.key === undefined) return null + return ( + + ) + })} +
    + )} +
    + ) +} + +function RailButton({ + title, + icon, + onClick, + active, + hideTooltip +}: { + title: string + icon: ReactNode + onClick?: () => void + active?: boolean + hideTooltip?: boolean +}): React.JSX.Element { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/ZmodemOffers.tsx b/src/renderer/src/workspace/ZmodemOffers.tsx new file mode 100644 index 0000000..76e33c1 --- /dev/null +++ b/src/renderer/src/workspace/ZmodemOffers.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { App as AntdApp, Button, Modal, Space, Typography } from 'antd' +import type { ZmodemOfferEvent, ZmodemResponse } from '@shared/ipc' + +/** + * M6 ZMODEM offer/done UX: renders one dialog per active zmodem offer. + * + * - mode='send' : remote ran `rz` -> user picks local files to upload. + * - mode='receive': remote ran `sz` -> user picks a local save directory. + * + * Binary details never touch the renderer; it only answers offers (paths/dir) + * and shows completion via App.useApp().message. Progress reuses the shared + * transfer panel (kind 'zmodem-upload' / 'zmodem-download'). + */ +export default function ZmodemOffers(): null | React.JSX.Element { + const { message } = AntdApp.useApp() + const [offers, setOffers] = useState>(new Map()) + const busyRef = useRef>(new Set()) + + const respond = useCallback((resp: ZmodemResponse) => { + window.api.zmodemRespond(resp) + busyRef.current.delete(resp.id) + setOffers((prev) => { + const next = new Map(prev) + next.delete(resp.id) + return next + }) + }, []) + + const handleCancel = useCallback( + (id: string) => respond({ id, cancelled: true }), + [respond] + ) + + const handlePickFiles = useCallback( + async (id: string) => { + if (busyRef.current.has(id)) return + busyRef.current.add(id) + try { + const paths = await window.api.pickFiles() + if (paths.length > 0) { + respond({ id, cancelled: false, paths }) + } else { + busyRef.current.delete(id) + } + } catch { + busyRef.current.delete(id) + } + }, + [respond] + ) + + const handlePickDir = useCallback( + async (id: string) => { + if (busyRef.current.has(id)) return + busyRef.current.add(id) + try { + const dir = await window.api.pickDirectory() + if (dir) { + respond({ id, cancelled: false, dir }) + } else { + busyRef.current.delete(id) + } + } catch { + busyRef.current.delete(id) + } + }, + [respond] + ) + + // queue: more than one offer may arrive before the user answers the first + useEffect(() => { + const off = window.api.onZmodemOffer((evt: ZmodemOfferEvent) => { + setOffers((prev) => { + const next = new Map(prev) + next.set(evt.id, evt) + return next + }) + }) + return off + }, []) + + const offDone = useCallback( + (evt: { id: string; ok: boolean; message?: string }) => { + if (evt.ok) message.success('ZMODEM 传输完成') + else message.error(`ZMODEM 传输失败:${evt.message ?? '未知错误'}`) + }, + [message] + ) + useEffect(() => { + const off = window.api.onZmodemDone(offDone) + return off + }, [offDone]) + + const pending = [...offers.values()] + const first = pending[0] + if (!first) return null + + const isReceive = first.mode === 'receive' + return ( + + + + + } + > + + {isReceive + ? '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。' + : '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。'} + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/broadcastStore.ts b/src/renderer/src/workspace/broadcastStore.ts new file mode 100644 index 0000000..e42cb90 --- /dev/null +++ b/src/renderer/src/workspace/broadcastStore.ts @@ -0,0 +1,100 @@ +import { create } from 'zustand' + +/** One open terminal session eligible to be a broadcast target. */ +export interface BroadcastSession { + /** pty / ssh session id (also the dockview panel id) */ + id: string + title: string + isSsh: boolean +} + +export interface BroadcastState { + /** master switch; only settable while there are >= 2 open sessions */ + enabled: boolean + /** session ids receiving broadcast input */ + targets: Set + /** open terminal sessions, registration order preserved */ + sessions: BroadcastSession[] + /** register a session as broadcast-eligible (idempotent, keeps it fresh) */ + registerSession: (session: BroadcastSession) => void + /** remove a session; if it was a target, prune it (may auto-disable <2 targets) */ + unregisterSession: (id: string) => void + /** add/remove one target; never lets the set drop below 2 while enabled */ + toggleTarget: (id: string) => void + /** turn broadcast on/off */ + setEnabled: (enabled: boolean) => void +} + +/** + * Resolve the set of sessions a keypress should be written to. + * - Broadcast disabled OR the originating session is not a target → the + * session alone (existing single-write behaviour; the origin's own fallback + * echo is handled by individual pty instances). + * - Broadcast enabled and origin is a target → every target session. + */ +export function broadcastFanOut(originId: string, targets: Set, enabled: boolean): string[] { + if (!enabled) return [originId] + if (!targets.has(originId)) return [originId] + return [...targets] +} + +function pruneOnTargetLoss(enabled: boolean, nextTargets: Set): { enabled: boolean; targets: Set } { + if (nextTargets.size < 2) return { enabled: false, targets: new Set() } + return { enabled, targets: nextTargets } +} + +export const useBroadcastStore = create((set) => ({ + enabled: false, + targets: new Set(), + sessions: [], + + registerSession: (session) => + set((state) => { + const exists = state.sessions.some((s) => s.id === session.id) + if (exists) { + // keep title/isSsh fresh (e.g. template apply retitles a panel) + const sessions = state.sessions.map((s) => (s.id === session.id ? session : s)) + const targets = new Set(state.targets) + return { sessions, targets } + } + return { sessions: [...state.sessions, session] } + }), + + unregisterSession: (id) => + set((state) => { + const sessions = state.sessions.filter((s) => s.id !== id) + if (!state.targets.has(id)) return { sessions } + const targets = new Set(state.targets) + targets.delete(id) + return { sessions, ...pruneOnTargetLoss(state.enabled, targets) } + }), + + toggleTarget: (id) => + set((state) => { + const targets = new Set(state.targets) + if (targets.has(id)) { + targets.delete(id) + return pruneOnTargetLoss(state.enabled, targets) + } + targets.add(id) + // requires >= 2 targets to become/become-enabled + if (targets.size < 2) return { enabled: false, targets } + return { enabled: true, targets } + }), + + setEnabled: (enabled) => + set((state) => { + if (enabled && state.targets.size < 2) return { enabled: state.enabled } + return { enabled } + }) +})) + +/** Write `data` to the full broadcast fan-out for an originating session. */ +export function writeBroadcast(originId: string, data: string): void { + const { targets, enabled, sessions } = useBroadcastStore.getState() + const out = broadcastFanOut(originId, targets, enabled) + for (const id of out) { + // never write to a session that is no longer open + if (sessions.some((s) => s.id === id)) window.api.writePty(id, data) + } +} \ No newline at end of file diff --git a/src/renderer/src/workspace/workspace.css b/src/renderer/src/workspace/workspace.css new file mode 100644 index 0000000..de9ce66 --- /dev/null +++ b/src/renderer/src/workspace/workspace.css @@ -0,0 +1,880 @@ +/* Workspace chrome: top toolbar + dockview layout area. */ + +.workspace-root { + display: flex; + flex-direction: row; + /* flex child of .app-root below the custom title bar */ + flex: 1 1 auto; + min-height: 0; + background: var(--chrome-bg); +} + +/* ---- left icon rail (XTerminal-style collapsed menu) ---- */ +.workspace-rail { + position: relative; + z-index: 960; + display: flex; + flex-direction: column; + align-items: center; + flex: 0 0 40px; + width: 40px; + padding: 8px 0; + gap: 6px; + background: var(--chrome-bg); + border-right: 1px solid var(--chrome-border); + box-sizing: border-box; +} + +.workspace-rail-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: #9a9a9a; + font-size: 15px; + cursor: pointer; +} + +.workspace-rail-btn:hover { + background: var(--chrome-hover); + color: #ffffff; +} + +.workspace-rail-divider { + width: 20px; + height: 1px; + background: var(--chrome-border); + margin: 2px 0; +} + +/* ---- M6.1 rail active state (mode entries) ---- */ +.workspace-rail-btn.is-active { + color: var(--dv-active-tab-foreground-color, #e8e8e8); + box-shadow: inset 2px 0 0 #3fb950; + background: rgba(63, 185, 80, 0.10); +} + +.workspace-rail-spacer { + flex: 1 1 auto; +} + +/* ---- layout flyout (custom panel, dockview-safe) ---- */ +.workspace-flyout-anchor { + position: relative; + display: flex; +} + +.workspace-flyout { + position: absolute; + left: calc(100% + 6px); + top: 0; + z-index: 960; + min-width: 148px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + background: #1f1f1f; + border: 1px solid var(--chrome-border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.workspace-flyout-item { + display: flex; + align-items: center; + height: 32px; + padding: 0 12px; + border: none; + border-radius: 5px; + background: transparent; + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.workspace-flyout-item:hover { + background: var(--chrome-hover); + color: #ffffff; +} + +.workspace-flyout-divider { + height: 1px; + margin: 4px 6px; + background: var(--chrome-border); +} + +.workspace-rail-busy { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--chrome-accent); + animation: workspace-rail-pulse 1s ease-in-out infinite; +} + +@keyframes workspace-rail-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +/* ---- main row: sidebar + dockview ---- */ +.workspace-body { + display: flex; + flex-direction: row; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* B's sidebar owns the aside chrome; the fallback width below only applies if + the className isn't passed — it is, so the 240px lives in connections.css. */ +.workspace-body .connections-sidebar { + height: 100%; +} + +/* ---- dockview area ---- */ +.workspace-dockview { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +/* ---- per-workspace dockview containers (terminal / ssh). + Switching mode toggles `.is-hidden` → display:none. The DockviewReact stays + mounted (state/layout preserved) so the switch is instant and free. */ +.workspace-dockview-container { + position: absolute; + inset: 0; + display: flex; +} + +.workspace-dockview-container.is-hidden { + display: none; +} + +.workspace-dockview-container .dockview-theme-abyss, +.workspace-dockview-container .dv-dockview { + width: 100%; + height: 100%; +} + +/* Align terminal chrome with the app chrome. */ +.dockview-theme-abyss { + --dv-group-view-background-color: var(--chrome-bg); + --dv-tabs-and-actions-container-background-color: #10141a; + --dv-tabs-and-actions-container-height: 32px; + --dv-tab-divider-color: transparent; +} + +/* ---- tab polish: rounded pills, quiet inactive, green active ---- */ +.workspace-dockview .dv-tabs-and-actions-container { + padding: 4px 5px 0; + box-sizing: border-box; +} + +.workspace-dockview .dv-tab { + border-radius: 9px; + padding: 0 8px; + margin-right: 4px; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); + color: #98a2ab; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} + +.workspace-dockview .dv-tab.dv-inactive-tab:hover { + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20); + color: #d0d0d0; +} + +/* XTerminal-style active tab: green outline + tinted bg + green title. */ +.workspace-dockview .dv-tab.dv-active-tab { + background: rgba(63, 185, 80, 0.10); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.75); + color: #3fd06a; +} + +.workspace-dockview .dv-tab.dv-active-tab:hover { + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.95); + color: #4ad476; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab { + gap: 6px; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-title { + font-size: 12.5px; + line-height: 1; +} + +/* Close glyph as its own mini outlined pill, mirroring the tab border. */ +.workspace-dockview .dv-tab .workspace-terminal-tab-close { + width: 17px; + height: 17px; + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + opacity: 0.8; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover { + opacity: 1; + color: #3fd06a; + background: rgba(63, 185, 80, 0.08); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65); +} + +/* ---- empty state overlay ---- */ +.workspace-empty-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: var(--chrome-bg); + pointer-events: none; +} + +.workspace-empty-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + pointer-events: auto; +} + +.workspace-empty-title { + color: #8a8a8a; + font-size: 14px; + user-select: none; +} + +.workspace-empty-hint { + color: #6a6a6a; + font-size: 12px; + user-select: none; +} + +/* ---- terminal panel ---- */ +.workspace-terminal-panel { + position: relative; + width: 100%; + height: 100%; +} + +.workspace-terminal-split { + width: 100%; + height: 100%; + display: flex; + flex-direction: row; +} + +.workspace-terminal-view { + width: 100%; + height: 100%; + flex: 1 1 auto; + min-width: 0; +} + +/* ---- SSH session right dock (M3 monitor, collapsible) ---- */ +.workspace-terminal-panel > .workspace-terminal-split > .mm-root, +.workspace-terminal-panel > .workspace-terminal-split > .mm-empty { + flex: 0 0 300px; + width: 300px; + min-width: 0; +} + +.workspace-terminal-side { + display: flex; + flex-direction: column; + flex: 0 0 300px; + width: 300px; + min-width: 0; + border-left: 1px solid var(--chrome-border); + background: var(--chrome-bg); +} + +.workspace-terminal-side > .mm-root, +.workspace-terminal-side > .mm-empty { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + border-radius: 0; +} + +.workspace-terminal-monitor-toggle { + position: absolute; + top: 4px; + right: 4px; + z-index: 4; + color: #8a8a8a; + font-size: 13px; +} + +.workspace-terminal-monitor-toggle:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +/* ---- SSH bottom file browser (M8, FileZilla-style resizable bottom panel) ---- + Flex column: terminal on top (flex 1), 4px drag divider, fixed-height file + browser at the bottom. Height is state-driven from SshBottomPanel. */ +.ssh-bottom-panel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.ssh-bottom-top { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.ssh-bottom-divider { + flex: 0 0 4px; + height: 4px; + background: var(--chrome-border); + cursor: row-resize; + box-sizing: border-box; + touch-action: none; +} + +.ssh-bottom-divider:hover, +.ssh-bottom-divider.is-resizing, +body.ssh-resizing .ssh-bottom-divider { + background: var(--chrome-accent); +} + +.ssh-bottom-files { + flex: 0 0 auto; + min-height: 0; + overflow: hidden; + border-top: 1px solid var(--chrome-border); + background: var(--chrome-bg); +} + +/* ---- term tab: SSH accent ---- */ +.workspace-terminal-tab-icon { + flex: 0 0 auto; + font-size: 12px; + color: #3fb950; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-ssh { + border-left: 2px solid #3fb950; + padding-left: 5px !important; + gap: 5px; +} + +/* Host label pill on SSH tabs: faint green outline, green monospace text. */ +.workspace-dockview .dv-tab .workspace-terminal-tab-ssh .ssh-host-badge { + flex: 0 1 auto; + min-width: 0; + max-width: 132px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 1px 6px 2px; + box-sizing: border-box; + border-radius: 9px; + border: 1px solid rgba(63, 185, 80, 0.38); + background: rgba(63, 185, 80, 0.08); + color: #3fb950; + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; + font-size: 10.5px; + line-height: 1; + letter-spacing: 0; +} + +.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge { + border-color: rgba(63, 185, 80, 0.6); + background: rgba(63, 185, 80, 0.14); + color: #46c96a; +} + +/* ---- per-group "+" right after the last tab ---- + Push the flexible drag-target void to the far end so the right-actions + slot (the + button) hugs the tabs, XTerminal-style. */ +.workspace-dockview .dv-void-container { + order: 3; +} + +.workspace-tabbar-add { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin: 0 6px; + padding: 0; + border: none; + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + background: transparent; + color: #98a2ab; + font-size: 10px; + cursor: pointer; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} + +.workspace-tabbar-add:hover { + background: rgba(63, 185, 80, 0.08); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65); + color: #3fd06a; +} + +/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */ +.workspace-tabbar-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.workspace-tabbar-btn2 { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + width: 22px; + height: 22px; + margin: 0 2px; + padding: 0; + border: none; + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + background: transparent; + color: #98a2ab; + font-size: 10px; + cursor: pointer; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} + +.workspace-tabbar-btn2:hover { + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24); + color: #ffffff; +} + +.workspace-tabbar-btn2.is-active { + color: #3fd06a; + background: rgba(63, 185, 80, 0.12); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.55); +} + +.workspace-tabbar-bcast-count { + font-size: 10px; + font-weight: 600; + color: #3fb950; + line-height: 1; +} + +/* ---- broadcast popover ---- */ +.workspace-bcast-pop { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 220px; +} + +.workspace-bcast-pop-switch { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--chrome-border); +} + +.workspace-bcast-pop-mode { + font-size: 12px; + font-weight: 600; + color: var(--chrome-fg); +} + +.workspace-bcast-pop-label { + font-size: 12px; + color: var(--chrome-fg); +} + +.workspace-bcast-pop-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; +} + +.workspace-bcast-pop-item { + padding: 2px 0; +} + +.workspace-bcast-pop-item .ant-checkbox-wrapper { + font-size: 12px; + color: var(--chrome-fg); +} + +.workspace-bcast-pop-empty { + padding: 10px 0; + text-align: center; + color: #8a8a8a; + font-size: 12px; +} + +.workspace-bcast-pop-hint { + font-size: 11px; + color: #8a8a8a; + line-height: 1.4; + border-top: 1px solid var(--chrome-border); + padding-top: 6px; +} + +/* ---- tab broadcast-target badge ---- + A small "播" pill coordinated with the SSH host-label pill so both can sit + on an SSH tab (SSH left accent + host pill left-ish + bcast pill right) + without visual clutter. */ +.workspace-dockview .dv-tab .workspace-terminal-tab-bcast { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + box-sizing: border-box; + border-radius: 8px; + font-size: 10px; + font-weight: 600; + line-height: 1; + color: #3fb950; + background: rgba(63, 185, 80, 0.1); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35); + user-select: none; +} + +/* ---- M6 quick-input floating panel (bottom-right) ---- + bottom: 44px so it clears the sftp transfer overlay (right/bottom 14px). */ +.quick-input-panel { + position: fixed; + right: 14px; + bottom: 44px; + z-index: 950; + width: 320px; + max-height: 60vh; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + background: #1f1f1f; + border: 1px solid var(--chrome-border); + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + color: var(--chrome-fg); +} + +.quick-input-headbar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.quick-input-headbar-title { + font-size: 12px; + font-weight: 600; + color: var(--chrome-fg); +} + +.quick-input-close { + border: none; + background: none; + color: #8a8a8a; + font-size: 15px; + line-height: 1; + cursor: pointer; + padding: 0 2px; +} + +.quick-input-close:hover { + color: #ffffff; +} + +.quick-input-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.quick-input-field .ant-input { + background: var(--chrome-bg); + border-color: var(--chrome-border); + color: var(--chrome-fg); +} + +.quick-input-autoenter { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: #9a9a9a; + cursor: pointer; + user-select: none; +} + +.quick-input-autoenter input { + accent-color: var(--chrome-accent); +} + +.quick-input-section { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 0; +} + +.quick-input-head { + font-size: 11px; + color: #8a8a8a; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.quick-input-list { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 140px; + overflow-y: auto; +} + +.quick-input-item { + display: flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + color: var(--chrome-fg); + font: inherit; + font-size: 12px; + text-align: left; + padding: 3px 6px; + border-radius: 4px; + cursor: pointer; + min-width: 0; +} + +.quick-input-item:hover { + background: rgba(59, 142, 234, 0.18); + color: #ffffff; +} + +.quick-input-item-cmd { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; +} + +.quick-input-item-tag { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +.quick-input-empty { + padding: 8px 4px; + color: #8a8a8a; + font-size: 12px; + text-align: center; +} + +/* ---- custom tab (title + close + context menu) ---- */ +.workspace-terminal-tab { + position: relative; + height: 100%; + width: 100%; + display: flex; + align-items: center; + white-space: nowrap; + text-overflow: ellipsis; + gap: 4px; +} + +.workspace-terminal-tab-title { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-terminal-tab-close { + padding: 0; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: none; + background: none; + color: inherit; + font: inherit; + font-size: 10px; + line-height: 1; + cursor: pointer; + border-radius: 4px; +} + +.workspace-terminal-tab-close:hover { + background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31)); +} + +/* ---- template modals ---- */ +.workspace-template-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 60vh; + overflow-y: auto; +} + +.workspace-template-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 4px; + border-bottom: 1px solid var(--chrome-border); +} + +.workspace-template-item:last-child { + border-bottom: none; +} + +.workspace-template-meta { + min-width: 0; +} + +.workspace-template-name { + color: var(--chrome-fg); + font-size: 13px; +} + +.workspace-template-time { + color: #8a8a8a; + font-size: 12px; + margin-top: 2px; +} + +.workspace-template-empty { + padding: 24px 0; + text-align: center; + color: #8a8a8a; +} + +/* ---- SSH connect flow modals ---- */ +.connect-flow-connecting { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.connect-flow-connecting-text { + color: var(--chrome-fg); + font-size: 13px; +} + +.connect-flow-secret { + display: flex; + flex-direction: column; + gap: 10px; +} + +.connect-flow-secret-target { + color: var(--chrome-fg); + font-size: 12px; + word-break: break-all; +} + +/* ---- host key verification modal ---- */ +.hostkey-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hostkey-changed-title { + color: #ff4d4f; + font-weight: 600; +} + +.hostkey-desc { + color: var(--chrome-fg); +} + +.hostkey-target, +.hostkey-fingerprint { + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; + font-size: 12px; + color: var(--chrome-fg); + word-break: break-all; + user-select: text; +} +/* M6.1 sidebar current-mode title: small, quiet, aligned to the sections. */ +.connections-mode-title { + font-size: 11px; + letter-spacing: 0.4px; + color: #8a8a8a; + padding: 10px 14px 2px; + user-select: none; +} + +/* M6.1 command-panel current-mode badge (shared workspace mode affordance). */ +.commands-mode-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 8px; +} +.commands-mode-badge-text { + font-size: 11px; + color: #8a8a8a; + letter-spacing: 0.4px; +} +.commands-mode-badge-tag { + font-size: 11px; + font-weight: 600; + padding: 1px 7px; + border-radius: 8px; + line-height: 16px; +} +.commands-mode-badge--terminal .commands-mode-badge-tag { + color: #4cc38a; + background: rgba(76, 195, 138, 0.14); +} +.commands-mode-badge--ssh .commands-mode-badge-tag { + color: #6aa9ff; + background: rgba(106, 169, 255, 0.14); +} diff --git a/src/renderer/src/workspace/workspaceMode.ts b/src/renderer/src/workspace/workspaceMode.ts new file mode 100644 index 0000000..8aad828 --- /dev/null +++ b/src/renderer/src/workspace/workspaceMode.ts @@ -0,0 +1,30 @@ +/** M6.1 workspace mode: local terminal vs SSH 服务器 —— 两组面板互不混排。 */ +export type WorkspaceMode = 'terminal' | 'ssh' + +export const WORKSPACE_MODE_ORDER: readonly WorkspaceMode[] = ['terminal', 'ssh'] + +/** Dockview panel id 前缀:同组内面板以此为前缀,切换模式时按前缀整体 hide/show。 */ +export const WORKSPACE_GROUP_PREFIX: Record = { + terminal: 'terminal:', + ssh: 'ssh:' +} + +/** 每个 workspace 组顶部的固定标记面板(不可关闭),作为该组显示/隐藏与激活的锚点。 */ +export const WORKSPACE_MARKER_ID: Record = { + terminal: '__workspace-marker-terminal', + ssh: '__workspace-marker-ssh' +} + +/** 判断一个面板属于哪个 workspace 组(marker 面板本身不算会话面板)。 */ +export function workspaceModeOfPanelId(id: string): WorkspaceMode | null { + if (id.startsWith(WORKSPACE_GROUP_PREFIX.terminal)) return 'terminal' + if (id.startsWith(WORKSPACE_GROUP_PREFIX.ssh)) return 'ssh' + return null +} + +/** 从面板 params 判断(新逻辑以此为第一优先,兼容旧无 params 的面板回退到 id 前缀)。 */ +export function workspaceModeOfParams(params: { sessionKind?: 'local' | 'ssh' } | undefined): WorkspaceMode | null { + if (params?.sessionKind === 'local') return 'terminal' + if (params?.sessionKind === 'ssh') return 'ssh' + return null +} diff --git a/src/renderer/src/workspace/workspaceModeStore.ts b/src/renderer/src/workspace/workspaceModeStore.ts new file mode 100644 index 0000000..17863df --- /dev/null +++ b/src/renderer/src/workspace/workspaceModeStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' + +/** 工作区模式:终端(本地)/ SSH(远程服务器)。 */ +export type WorkspaceMode = 'terminal' | 'ssh' + +interface WorkspaceModeState { + mode: WorkspaceMode + setMode: (mode: WorkspaceMode) => void +} + +const LS_KEY = 'ot.workspaceMode' + +function readInitial(): WorkspaceMode { + try { + const v = window.localStorage.getItem(LS_KEY) + return v === 'ssh' ? 'ssh' : 'terminal' + } catch { + return 'terminal' + } +} + +/** + * 全局工作区模式 store。IconRail 的模式入口、Workspace 的分流、 + * ConnectionSidebar 的区块过滤都订阅它。 + */ +export const useWorkspaceModeStore = create((set) => ({ + mode: readInitial(), + setMode: (mode) => { + try { + window.localStorage.setItem(LS_KEY, mode) + } catch { + // ignore + } + set({ mode }) + } +})) diff --git a/src/shared/api.ts b/src/shared/api.ts new file mode 100644 index 0000000..f53f87d --- /dev/null +++ b/src/shared/api.ts @@ -0,0 +1,108 @@ +import type { + AppInfo, + LayoutMeta, + PtyCreateOptions, + PtyCreateResult, + PtyDataEvent, + PtyExitEvent, + SessionOpenResult +} from './ipc' +import type { AppSettings } from './settings' +import type { + HostKeyAction, + HostKeyPromptEvent, + SessionOpenOptions, + SshConnection, + SshConnectionInput +} from './connections' +import type { SysinfoMeta, SysinfoSample } from './sysinfo' +import type { SftpEntry, TransferProgressEvent } from './sftp' +import type { CommandItem, SessionLogMeta } from './commands' +import type { ZmodemDoneEvent, ZmodemOfferEvent, ZmodemResponse } from './ipc' + +/** + * The single API surface exposed on `window.api` by the preload script. + * Main process implements the handlers; renderer consumes this contract. + */ +export interface AppApi { + appInfo(): Promise + + // ---- sessions (pty data plane is shared by local and ssh sessions) ---- + createPty(opts?: PtyCreateOptions): Promise + /** open a local or ssh session; resolves once the session is ready to stream */ + openSession(opts: SessionOpenOptions): Promise + /** output a session produced before this renderer subscribed (capped ring buffer) */ + getSessionReplay(id: string): Promise + writePty(id: string, data: string): void + resizePty(id: string, cols: number, rows: number): void + killPty(id: string): void + /** subscribe to all session output; returns unsubscribe */ + onPtyData(cb: (e: PtyDataEvent) => void): () => void + onPtyExit(cb: (e: PtyExitEvent) => void): () => void + + // ---- sysinfo (M3: ssh sessions only; main polls the remote and broadcasts) ---- + /** start polling for an ssh session; stop happens automatically on session close */ + sysinfoStart(id: string): void + sysinfoStop(id: string): void + onSysinfoMeta(cb: (e: { id: string; meta: SysinfoMeta }) => void): () => void + onSysinfoSample(cb: (e: { id: string; sample: SysinfoSample }) => void): () => void + + // ---- sftp (M4: bound to an established ssh session) ---- + listRemote(sessionId: string, dir: string): Promise + mkdirRemote(sessionId: string, dir: string, name: string): Promise + renameRemote(sessionId: string, from: string, to: string): Promise + deleteRemote(sessionId: string, paths: string[]): Promise + /** mode = octal string, e.g. "755" or "0644" */ + chmodRemote(sessionId: string, path: string, mode: string): Promise + chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise + /** upload local files into remote dir; returns transferId */ + uploadRemote(sessionId: string, localPaths: string[], remoteDir: string): Promise + /** download remote paths into local dir; returns transferId */ + downloadRemote(sessionId: string, remotePaths: string[], localDir: string): Promise + cancelTransfer(transferId: string): void + onTransferProgress(cb: (e: TransferProgressEvent) => void): () => void + /** native open dialog; returns picked file paths (empty when cancelled) */ + pickFiles(): Promise + pickDirectory(): Promise + + // ---- command history / library + session logs (M5) ---- + /** record one executed command (deduped, newest first, capped) */ + recordCommand(cmd: string): Promise + listHistory(): Promise + clearHistory(): Promise + listLibrary(): Promise + saveLibraryItem(item: CommandItem): Promise + deleteLibraryItem(id: string): Promise + /** session output logging */ + logStart(sessionId: string): Promise + logStop(sessionId: string): Promise + listSessionLogs(): Promise + openLogsDir(): void + + // ---- ssh connections ---- + listConnections(): Promise + saveConnection(input: SshConnectionInput): Promise + deleteConnection(id: string): Promise + onHostKeyPrompt(cb: (e: HostKeyPromptEvent) => void): () => void + respondHostKey(promptId: string, action: HostKeyAction): void + + // ---- zmodem (M6: main-process engine over ssh sessions; progress reuses transfer events) ---- + onZmodemOffer(cb: (e: ZmodemOfferEvent) => void): () => void + zmodemRespond(resp: ZmodemResponse): void + onZmodemDone(cb: (e: ZmodemDoneEvent) => void): () => void + + // ---- settings ---- + getSettings(): Promise + saveSettings(next: AppSettings): Promise + onSettingsChanged(cb: (s: AppSettings) => void): () => void + + // ---- fonts ---- + listFonts(): Promise + + // ---- layout templates ---- + listLayouts(): Promise + getLayout(id: string): Promise + /** json = serialized dockview layout (DockviewApi.toJSON()) */ + saveLayout(meta: LayoutMeta, json: string): Promise + deleteLayout(id: string): Promise +} diff --git a/src/shared/commands.ts b/src/shared/commands.ts new file mode 100644 index 0000000..235fb1a --- /dev/null +++ b/src/shared/commands.ts @@ -0,0 +1,26 @@ +/** Command history / command library + session output logs (M5). */ + +export interface CommandItem { + id: string + /** library items have a name; history items don't */ + name?: string + command: string + note?: string + group?: string + /** library items support {{var}} placeholders; recorded purely as metadata */ + params?: string[] + createdAt: number + /** epoch ms of last execution (history ordering) */ + lastUsedAt?: number +} + +export interface SessionLogMeta { + sessionId: string + /** absolute file path of the plain-text log */ + file: string + fileName: string + /** epoch ms when logging started */ + startedAt: number + /** undefined while still logging */ + endedAt?: number +} diff --git a/src/shared/connections.ts b/src/shared/connections.ts new file mode 100644 index 0000000..0c873c4 --- /dev/null +++ b/src/shared/connections.ts @@ -0,0 +1,86 @@ +import type { LayoutMeta } from './ipc' + +/** + * SSH connection model. Secrets (password / key content / passphrase) are only + * ever persisted encrypted (Electron safeStorage, base64) inside the main + * process. The renderer receives connections with secret fields stripped and + * replaced by the `savedAuth` flags, so plaintext secrets never cross the + * contextBridge. + */ + +export type SshAuthMethod = 'password' | 'privateKey' | 'agent' + +export interface SshSavedAuthFlags { + hasPassword: boolean + hasKeyContent: boolean + hasPassphrase: boolean +} + +export interface SshConnection { + id: string + name: string + /** optional group for the bookmark tree; undefined = root */ + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + /** prompt for password in a dialog at connect time instead of using the stored one */ + askPasswordAtConnect: boolean + /** prompt for key passphrase at connect time */ + askPassphraseAtConnect: boolean + keyPath?: string + /** seconds between keepalive packets; 0 disables */ + keepaliveIntervalSec: number + createdAt: number + lastConnectedAt?: number + savedAuth: SshSavedAuthFlags +} + +/** Renderer-supplied connection data on save; secrets here are plaintext on purpose (one-way, encrypted before persisting). */ +export interface SshConnectionInput { + id?: string + name: string + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + askPasswordAtConnect: boolean + askPassphraseAtConnect: boolean + keyPath?: string + keepaliveIntervalSec: number + /** plain password to store; omit to keep the previously stored one */ + password?: string + /** private key content to store inline; omit to keep the previously stored one */ + keyContent?: string + passphrase?: string +} + +/** Secrets typed at connect time when ask*AtConnect is set. */ +export interface SshSecretOverride { + password?: string + passphrase?: string +} + +export interface SessionOpenOptions { + kind: 'local' | 'ssh' + /** ssh only */ + connectionId?: string + secretOverride?: SshSecretOverride +} + +export type HostKeyAction = 'accept' | 'reject' + +export interface HostKeyPromptEvent { + promptId: string + host: string + port: number + /** sha256 fingerprint, base64 (no padding), ssh "SHA256:..." style */ + fingerprint: string + reason: 'new' | 'changed' +} + +/** Reuse LayoutMeta shape for layout templates only; groups are a plain string field on SshConnection. */ +export type ConnectionMeta = Pick +export type { LayoutMeta } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..38c9910 --- /dev/null +++ b/src/shared/ipc.ts @@ -0,0 +1,161 @@ +/** + * IPC channel names + payload contracts shared between main / preload / renderer. + * DO NOT rename channels or change payload shapes without updating all consumers. + */ + +export const Ipc = { + // ---- PTY session lifecycle ---- + // NOTE: since M2 these channels are session-generic: `id` addresses both + // local pty sessions and ssh sessions; data plane is identical. + PTY_CREATE: 'pty:create', + PTY_WRITE: 'pty:write', + PTY_RESIZE: 'pty:resize', + PTY_KILL: 'pty:kill', + /** main -> renderer broadcast: { id, data } */ + PTY_DATA: 'pty:data', + /** main -> renderer broadcast: { id, exitCode } */ + PTY_EXIT: 'pty:exit', + + // ---- settings ---- + SETTINGS_GET: 'settings:get', + SETTINGS_SET: 'settings:set', + /** main -> renderer broadcast after any save */ + SETTINGS_CHANGED: 'settings:changed', + + // ---- fonts ---- + FONTS_LIST: 'fonts:list', + + // ---- layouts ---- + LAYOUTS_LIST: 'layouts:list', + LAYOUTS_GET: 'layouts:get', + LAYOUTS_SAVE: 'layouts:save', + LAYOUTS_DELETE: 'layouts:delete', + + // ---- sessions (openSession unifies local pty and ssh) ---- + SESSION_OPEN: 'session:open', + /** main keeps a short replay buffer per session so late subscribers catch up */ + SESSION_REPLAY: 'session:replay', + + // ---- ssh connections (bookmarks) ---- + CONNECTIONS_LIST: 'connections:list', + CONNECTIONS_SAVE: 'connections:save', + CONNECTIONS_DELETE: 'connections:delete', + + // ---- host key verification ---- + /** main -> renderer: needs user decision before connect proceeds */ + HOSTKEY_PROMPT: 'hostkey:prompt', + HOSTKEY_RESPOND: 'hostkey:respond', + + // ---- sysinfo (M3) ---- + SYSINFO_START: 'sysinfo:start', + SYSINFO_STOP: 'sysinfo:stop', + /** main -> renderer broadcast: { id, meta } (once per session) */ + SYSINFO_META: 'sysinfo:meta', + /** main -> renderer broadcast: { id, sample } (poll interval) */ + SYSINFO_SAMPLE: 'sysinfo:sample', + + // ---- sftp (M4) ---- + SFTP_LIST: 'sftp:list', + SFTP_MKDIR: 'sftp:mkdir', + SFTP_RENAME: 'sftp:rename', + SFTP_DELETE: 'sftp:delete', + SFTP_CHMOD: 'sftp:chmod', + SFTP_CHOWN: 'sftp:chown', + SFTP_UPLOAD: 'sftp:upload', + SFTP_DOWNLOAD: 'sftp:download', + TRANSFER_CANCEL: 'transfer:cancel', + /** main -> renderer broadcast: TransferProgressEvent */ + TRANSFER_PROGRESS: 'transfer:progress', + DIALOG_PICK_FILES: 'dialog:pickFiles', + DIALOG_PICK_DIR: 'dialog:pickDirectory', + + // ---- commands + logs (M5) ---- + COMMANDS_RECORD: 'commands:record', + COMMANDS_HISTORY_LIST: 'commands:historyList', + COMMANDS_HISTORY_CLEAR: 'commands:historyClear', + COMMANDS_LIBRARY_LIST: 'commands:libraryList', + COMMANDS_LIBRARY_SAVE: 'commands:librarySave', + COMMANDS_LIBRARY_DELETE: 'commands:libraryDelete', + LOG_START: 'log:start', + LOG_STOP: 'log:stop', + LOG_LIST: 'log:list', + LOG_OPEN_DIR: 'log:openDir', + + // ---- zmodem (M6: main-process engine, ssh sessions only) ---- + /** main -> renderer: remote ran rz (mode=send) or sz (mode=receive); renderer must ask the user */ + ZMODEM_OFFER: 'zmodem:offer', + /** renderer -> main: user answered the zmodem offer */ + ZMODEM_RESPOND: 'zmodem:respond', + /** main -> renderer: zmodem session finished (ok, cancelled or failed) */ + ZMODEM_DONE: 'zmodem:done', + + // ---- misc ---- + APP_INFO: 'app:info' +} as const + +export interface PtyCreateOptions { + cwd?: string + /** executable; omit for platform default shell */ + shell?: string + env?: Record +} + +export interface PtyCreateResult { + id: string + shell: string + cwd: string +} + +export interface PtyDataEvent { + id: string + data: string +} + +export interface PtyExitEvent { + id: string + exitCode: number +} + +export interface AppInfo { + platform: NodeJS.Platform | string + appVersion: string + homeDir: string +} + +export interface LayoutMeta { + id: string + name: string + createdAt: number +} + +export interface SessionOpenResult { + id: string +} + +export interface HostKeyResponse { + promptId: string + action: 'accept' | 'reject' +} + +// ---- zmodem (M6) ---- +/** 'send' = remote ran `rz`, we upload files; 'receive' = remote ran `sz`, we download */ +export interface ZmodemOfferEvent { + id: string + mode: 'send' | 'receive' +} + +export interface ZmodemResponse { + id: string + cancelled: boolean + /** local files to upload (mode=send) */ + paths?: string[] + /** local directory to save into (mode=receive) */ + dir?: string +} + +export interface ZmodemDoneEvent { + id: string + ok: boolean + /** human-readable failure reason when ok=false */ + message?: string +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts new file mode 100644 index 0000000..85ea500 --- /dev/null +++ b/src/shared/settings.ts @@ -0,0 +1,106 @@ +import type { TerminalTheme } from './theme' + +/** One terminal keyword-highlight rule (regex → ANSI color injection). */ +export interface HighlightRule { + id: string + /** regex source; compiled with 'g' flag at use site */ + pattern: string + enabled: boolean + /** 1..100, lower runs first and claims its spans first */ + priority: number + /** truecolor hex (#rrggbb); fg required, bg optional */ + color: { fg: string; bg?: string } + note?: string + /** part of the factory preset set (restored by 恢复默认) */ + builtin?: boolean +} + +export interface TerminalSettings { + fontFamily: string + fontSize: number + /** 100..900 */ + fontWeight: number + /** 100..900 */ + boldFontWeight: number + letterSpacing: number + lineHeight: number + scrollback: number + cursorBlink: boolean + cursorStyle: 'bar' | 'block' | 'underline' + cursorInactiveStyle: 'outline' | 'block' | 'bar' | 'underline' | 'none' + themeId: string + rendererMode: 'auto' | 'webgl' | 'dom' + autoWrap: boolean + copyOnSelect: boolean + /** confirm before pasting multi-line / suspicious text */ + pasteRiskConfirm: boolean + /** inline command suggestion popup while typing (history + library) */ + suggestEnabled: boolean +} + +export interface SystemSettings { + /** register the app to launch at OS login */ + launchAtLogin: boolean + /** hold a powerSaveBlocker so the OS never auto-suspends while running */ + preventSleep: boolean + /** Electron accelerator that toggles window visibility; '' = disabled (M6) */ + globalShowHide?: string + /** close-button behavior: prompt every time / hide to tray / quit directly */ + closeAction?: 'ask' | 'tray' | 'exit' +} + +export interface AppSettings { + terminal: TerminalSettings + /** user-created themes; builtin themes are immutable and live in shared/theme.ts */ + customThemes: TerminalTheme[] + /** terminal keyword highlight rules, applied at write time in priority order */ + highlightRules: HighlightRule[] + system: SystemSettings +} + +const RULE = ( + id: string, + pattern: string, + priority: number, + fg: string, + note: string, + bg?: string +): HighlightRule => ({ id, pattern, enabled: true, priority, color: { fg, bg }, note, builtin: true }) + +export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [ + RULE('perm', '^[-dlbcps][rwxstST-]{9}', 1, '#3fb950', 'Linux 权限与用户'), + RULE('path', '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)', 2, '#58a6ff', 'Linux 文件路径'), + RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 3, '#d2a8ff', 'Shell 关键字与流程'), + RULE('okstate', '\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b', 5, '#3fb950', '成功状态'), + RULE('badstate', '\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b', 6, '#f85149', '错误/警告状态'), + RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 8, '#e3b341', '字符串与引号'), + RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 10, '#79c0ff', '环境变量与参数'), + RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'), + RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'), + RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'), + RULE('url', 'https?://[^\\s]+', 30, '#58a6ff', '网址链接') +] + +export const DEFAULT_SETTINGS: AppSettings = { + terminal: { + fontFamily: "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace", + fontSize: 14, + fontWeight: 400, + boldFontWeight: 700, + letterSpacing: 0, + lineHeight: 1, + scrollback: 5000, + cursorBlink: true, + cursorStyle: 'block', + cursorInactiveStyle: 'outline', + themeId: 'default-dark', + rendererMode: 'auto', + autoWrap: true, + copyOnSelect: false, + pasteRiskConfirm: true, + suggestEnabled: true + }, + customThemes: [], + highlightRules: DEFAULT_HIGHLIGHT_RULES, + system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'ask' } +} diff --git a/src/shared/sftp.ts b/src/shared/sftp.ts new file mode 100644 index 0000000..d084b8f --- /dev/null +++ b/src/shared/sftp.ts @@ -0,0 +1,31 @@ +/** SFTP file model + transfer progress (M4). */ + +export interface SftpEntry { + name: string + /** absolute remote path */ + path: string + isDir: boolean + /** bytes (0 for dirs) */ + size: number + /** epoch ms */ + mtime: number + /** e.g. "drwxr-xr-x" (best effort) */ + mode?: string + /** numeric uid/gid (best effort) */ + uid?: number + gid?: number +} + +export type TransferKind = 'upload' | 'download' | 'zmodem-upload' | 'zmodem-download' +export type TransferState = 'running' | 'done' | 'error' | 'cancelled' + +export interface TransferProgressEvent { + transferId: string + kind: TransferKind + state: TransferState + /** file currently being transferred */ + file: string + bytes: number + totalBytes: number + error?: string +} diff --git a/src/shared/sysinfo.ts b/src/shared/sysinfo.ts new file mode 100644 index 0000000..5424cdf --- /dev/null +++ b/src/shared/sysinfo.ts @@ -0,0 +1,34 @@ +/** Remote server hardware monitoring sample (M3, ssh sessions only). */ + +export interface SysinfoMeta { + /** e.g. "Linux 5.15.0 x86_64" */ + os: string + hostname: string +} + +export interface SysinfoSample { + ts: number + cpu: { + /** 0..100 overall */ + usage: number + cores: number + loadavg: [number, number, number] + } + mem: { + totalMb: number + usedMb: number + } + disks: Array<{ + mount: string + totalMb: number + usedMb: number + }> + /** kilobytes per second since previous sample */ + net: { + rxKbs: number + txKbs: number + } + uptimeSec: number + /** when set, the last collection failed (render an error state, keep old data) */ + error?: string +} diff --git a/src/shared/theme.ts b/src/shared/theme.ts new file mode 100644 index 0000000..33bb0a3 --- /dev/null +++ b/src/shared/theme.ts @@ -0,0 +1,382 @@ +/** + * Terminal color theme model. Palette layout mirrors xterm.js ITheme. + * Values of builtin themes come from the public-domain iTerm2-Color-Schemes / xterm-theme collections. + */ + +export interface ThemeColors { + background: string + foreground: string + cursor: string + /** accent block behind cursor text; falls back to foreground if omitted */ + cursorAccent?: string + selectionBackground: string + black: string + red: string + green: string + yellow: string + blue: string + magenta: string + cyan: string + white: string + brightBlack: string + brightRed: string + brightGreen: string + brightYellow: string + brightBlue: string + brightMagenta: string + brightCyan: string + brightWhite: string +} + +export interface TerminalTheme { + id: string + name: string + builtin: boolean + colors: ThemeColors +} + +export const DEFAULT_DARK: TerminalTheme = { + id: 'default-dark', + name: 'OpenTerminal Dark', + builtin: true, + colors: { + background: '#1e1e1e', + foreground: '#cccccc', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + selectionBackground: '#264f78', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff' + } +} + +export const BUILTIN_THEMES: TerminalTheme[] = [ + DEFAULT_DARK, + { + id: 'dracula', + name: 'Dracula', + builtin: true, + colors: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#282a36', + selectionBackground: '#44475a', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff' + } + }, + { + id: 'one-half-dark', + name: 'One Half Dark', + builtin: true, + colors: { + background: '#282c34', + foreground: '#dcdfe4', + cursor: '#61afef', + cursorAccent: '#282c34', + selectionBackground: '#3e4451', + black: '#282c34', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#dcdfe4', + brightBlack: '#5c6370', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff' + } + }, + { + id: 'one-half-light', + name: 'One Half Light', + builtin: true, + colors: { + background: '#fafafa', + foreground: '#383a42', + cursor: '#383a42', + cursorAccent: '#fafafa', + selectionBackground: '#d2d6db', + black: '#383a42', + red: '#e45649', + green: '#50a14f', + yellow: '#c18401', + blue: '#0184bc', + magenta: '#a626a4', + cyan: '#0997b3', + white: '#fafafa', + brightBlack: '#4f525e', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff' + } + }, + { + id: 'solarized-dark', + name: 'Solarized Dark', + builtin: true, + colors: { + background: '#002b36', + foreground: '#839496', + cursor: '#93a1a1', + cursorAccent: '#002b36', + selectionBackground: '#073642', + black: '#002b36', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#073642', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + } + }, + { + id: 'solarized-light', + name: 'Solarized Light', + builtin: true, + colors: { + background: '#fdf6e3', + foreground: '#657b83', + cursor: '#586e75', + cursorAccent: '#fdf6e3', + selectionBackground: '#eee8d5', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#93a1a1', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + } + }, + { + id: 'gruvbox-dark', + name: 'Gruvbox Dark', + builtin: true, + colors: { + background: '#282828', + foreground: '#ebdbb2', + cursor: '#ebdbb2', + cursorAccent: '#282828', + selectionBackground: '#504945', + black: '#282828', + red: '#cc241d', + green: '#98971a', + yellow: '#d79921', + blue: '#458588', + magenta: '#b16286', + cyan: '#689d6a', + white: '#a89984', + brightBlack: '#928374', + brightRed: '#fb4934', + brightGreen: '#b8bb26', + brightYellow: '#fabd2f', + brightBlue: '#83a598', + brightMagenta: '#d3869b', + brightCyan: '#8ec07c', + brightWhite: '#ebdbb2' + } + }, + { + id: 'nord', + name: 'Nord', + builtin: true, + colors: { + background: '#2e3440', + foreground: '#d8dee9', + cursor: '#d8dee9', + cursorAccent: '#2e3440', + selectionBackground: '#434c5e', + black: '#3b4252', + red: '#bf616a', + green: '#a3be8c', + yellow: '#ebcb8b', + blue: '#81a1c1', + magenta: '#b48ead', + cyan: '#88c0d0', + white: '#e5e9f0', + brightBlack: '#4c566a', + brightRed: '#bf616a', + brightGreen: '#a3be8c', + brightYellow: '#ebcb8b', + brightBlue: '#81a1c1', + brightMagenta: '#b48ead', + brightCyan: '#8fbcbb', + brightWhite: '#eceff4' + } + }, + { + id: 'monokai', + name: 'Monokai', + builtin: true, + colors: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + cursorAccent: '#272822', + selectionBackground: '#49483e', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5' + } + }, + { + id: 'github-light', + name: 'GitHub Light', + builtin: true, + colors: { + background: '#ffffff', + foreground: '#4d4d4c', + cursor: '#4d4d4c', + cursorAccent: '#ffffff', + selectionBackground: '#dcdcdc', + black: '#3e3e3e', + red: '#c91b00', + green: '#00c200', + yellow: '#c7c400', + blue: '#0225c7', + magenta: '#c930c7', + cyan: '#00c5c7', + white: '#c7c7c7', + brightBlack: '#676767', + brightRed: '#ff6d67', + brightGreen: '#5ff967', + brightYellow: '#ffff6c', + brightBlue: '#6871ff', + brightMagenta: '#ff76ff', + brightCyan: '#5ffdff', + brightWhite: '#ffffff' + } + }, + { + id: 'afterglow', + name: 'Afterglow', + builtin: true, + colors: { + background: '#1f1f1f', + foreground: '#d0d0d0', + cursor: '#f5f5f5', + cursorAccent: '#1f1f1f', + selectionBackground: '#4a4a4a', + black: '#1f1f1f', + red: '#ac4142', + green: '#7e8e50', + yellow: '#e5b567', + blue: '#6c99bb', + magenta: '#9f4e85', + cyan: '#508451', + white: '#d0d0d0', + brightBlack: '#5a5a5a', + brightRed: '#ac4142', + brightGreen: '#7e8e50', + brightYellow: '#e5b567', + brightBlue: '#6c99bb', + brightMagenta: '#9f4e85', + brightCyan: '#508451', + brightWhite: '#f5f5f5' + } + }, + { + id: 'material-dark', + name: 'Material Dark', + builtin: true, + colors: { + background: '#263238', + foreground: '#eeffff', + cursor: '#ffffff', + cursorAccent: '#263238', + selectionBackground: '#2a4153', + black: '#263238', + red: '#f07178', + green: '#c3e88d', + yellow: '#ffcb6b', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#89ddff', + white: '#eeeeee', + brightBlack: '#546e7a', + brightRed: '#f07178', + brightGreen: '#c3e88d', + brightYellow: '#ffcb6b', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#89ddff', + brightWhite: '#ffffff' + } + } +] + +/** Resolve a theme by id; falls back to the default dark theme. */ +export function getThemeById(id: string, customThemes: TerminalTheme[] = []): TerminalTheme { + return [...BUILTIN_THEMES, ...customThemes].find((t) => t.id === id) ?? DEFAULT_DARK +} diff --git a/tests/.exact-inline.mjs b/tests/.exact-inline.mjs new file mode 100644 index 0000000..b4ce7df --- /dev/null +++ b/tests/.exact-inline.mjs @@ -0,0 +1,66 @@ +// Decisive test: exact sftp.ts upload code, inline (no esbuild bundle). +import { createRequire } from 'module' +import { promises as fsp } from 'fs' +const require_ = createRequire(import.meta.url) +const ssh2 = require_('ssh2') +const sessionLayer = require_('D:/Coding/Terminal/tests/.session-e2e.cjs') + +let clientProvider +function p(fn) { + return new Promise((resolve, reject) => { + fn((err, res) => (err ? reject(err) : resolve(res))) + }) +} + +sessionLayer.configureSessionRuntime({ + broadcast: () => {}, + getConnection: () => ({ + id: 'jd', name: 't', host: process.env.JD_HOST, port: 22, username: process.env.JD_USER, + auth: 'password', askPasswordAtConnect: false, askPassphraseAtConnect: false, + keepaliveIntervalSec: 30, createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + }), + getSecret: () => process.env.JD_PASS, + touch: () => {}, + knownHosts: { check: () => ({ status: 'match' }), accept: () => {} }, + promptHostKey: () => { throw new Error('x') } +}) + +const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' }) +const client = sessionLayer.getSshClient(id) + +// --- exact sftp.ts upload pattern --- +const transfer = { kind: 'upload', cancelled: false } +const emit = (e) => console.log('[evt]', e.state, e.file, e.bytes + '/' + e.totalBytes, e.error ?? '') +const local = './.exact-test.bin' +const content = Buffer.alloc(256 * 1024, 2) +await fsp.writeFile(local, content) +const remote = '/root/ot_exact.bin' + +await new Promise((resolve, reject) => { + client.sftp((err, sftp) => (err != null ? reject(err) : resolve(sftp))) +}) +const sftp = await new Promise((resolve, reject) => { + client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_))) +}) +console.log('[exact] sftp channel open, calling fastPut') +await new Promise((resolve, reject) => { + sftp.fastPut(local, remote, { + concurrency: 4, + step: (totalTransferred, _chunk, total) => { + console.log('[exact] step', totalTransferred, '/', total) + if (transfer.cancelled) { + reject(new Error('已取消')) + return + } + } + }, (err) => (err ? reject(err) : resolve())) +}) +const st = await new Promise((resolve, reject) => { + sftp.stat(remote, (err, s) => (err ? reject(err) : resolve(s))) +}) +console.log('[exact] OK, remote size:', st.size, 'expected:', content.length) +await new Promise((resolve, reject) => sftp.unlink(remote, (e) => (e ? reject(e) : resolve()))) +await fsp.rm(local, { force: true }) +sessionLayer.killPty(id) +process.exit(0) diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs new file mode 100644 index 0000000..8251dce --- /dev/null +++ b/tests/commands-store.mjs @@ -0,0 +1,139 @@ +/** + * M5 command-store + session-log self-test (commands-store.mjs). + * + * Bundles src/main/commands.ts for plain Node with an in-memory electron stub, + * then verifies: history record/dedupe/cap/clear, library CRUD + ordering, and + * session log write/read/index behavior against a temp userData dir. + * + * Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \ + * --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs + * Run: node tests/commands-store.mjs (must exit 0) + */ +import { mkdtempSync, readFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'module' +const require_ = createRequire(import.meta.url) + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} +const ok = (cond, msg) => { + if (!cond) fail(msg) + console.log(` ok: ${msg}`) +} +const wait = (ms) => new Promise((r) => setTimeout(r, ms)) + +// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) ------- +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') +} + +// ---- 2. Temp userData; capture openDir target ---------------------------------- +const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-')) +let openedPath = null +const store = new commandsMod.CommandsStore(userData, async (p) => { + openedPath = p +}) + +// ---- 3. History record / dedupe / cap ------------------------------------------ +store.recordCommand(' echo hello ') // trims to 'echo hello' +store.recordCommand('ls -la') +store.recordCommand('ps aux') +let hist = store.listHistory() +ok(hist.length === 3, 'history records 3 distinct commands') +ok(hist[0].command === 'ps aux', 'newest first by lastUsedAt') + +// Re-run the newest command -> no new entry, recency refreshed, still length 3. +store.recordCommand('ps aux') +hist = store.listHistory() +ok(hist.length === 3, 're-run of newest command is deduped (no new entry)') + +// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front +// entry (spec dedupes only against the newest entry) with blank-recency guard. +store.recordCommand('echo hello') +hist = store.listHistory() +ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)') +ok(hist[0].command === 'echo hello', 'older re-run lands at front') + +// Cap: fill until well above 500 keeps only the newest 500. +for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`) +hist = store.listHistory() +ok(hist.length === 500, `history capped at 500 (got ${hist.length})`) +ok(hist[0].command === 'cmd-519', 'newest filler at front') + +// Clear. +store.clearHistory() +ok(store.listHistory().length === 0, 'clearHistory empties history') + +// ---- 4. Library CRUD + ordering ------------------------------------------------ +const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' }) +await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion +const b = store.saveLibraryItem({ command: 'df -h', name: 'disk', group: 'ops' }) +ok(typeof a.id === 'string' && a.id.length > 0, 'saveLibraryItem assigns an id + createdAt') +let lib = store.listLibrary() +ok(lib.length === 2, 'library has 2 items') +ok(lib[0].id === b.id, 'library newest first by createdAt') + +// Update existing by id. +const updated = store.saveLibraryItem({ ...b, command: 'df -hT', note: 'human + type' }) +lib = store.listLibrary() +ok(lib.length === 2, 'update keeps count at 2') +ok(lib.find((x) => x.id === b.id).command === 'df -hT', 'update applied fields') +ok(lib.find((x) => x.id === b.id).createdAt === b.createdAt, 'update preserves createdAt') + +// Delete. +store.deleteLibraryItem(a.id) +lib = store.listLibrary() +ok(lib.length === 1 && lib[0].id === b.id, 'deleteLibraryItem removes the item') + +// ---- 5. Session logs ------------------------------------------------------------ +const sid = '11111111-2222-3333-4444-555555555555' +const start = store.logStart(sid) +ok(!!start.fileName && start.fileName.startsWith('20') && start.file.endsWith('11111111.log'), 'logStart names file -.log') +ok(start.endedAt === undefined, 'logStart has no endedAt yet') +ok(store.listSessionLogs().length === 1, 'listSessionLogs sees the active log') + +// Writes land asynchronously in the file. +store.logWrite(sid, 'hello line one\n') +store.logWrite(sid, 'hello line two\n') +await wait(100) +const content = readFileSync(start.file, 'utf8') +ok(content === 'hello line one\nhello line two\n', 'async appends are written in order') + +// Stop -> endedAt stamped + stays in list. +store.logStop(sid) +let logs = store.listSessionLogs() +ok(logs[0].endedAt !== undefined && logs[0].endedAt >= start.startedAt, 'logStop stamps endedAt') +ok(logs.length === 1, 'stopped log still listed') + +// Writes after stop are ignored (no reopen, no throw). +store.logWrite(sid, 'after stop\n') +await wait(100) +ok(readFileSync(start.file, 'utf8') === 'hello line one\nhello line two\n', 'writes after stop are ignored') + +// A fresh store (same userData) restores the log from the index file. +const store2 = new commandsMod.CommandsStore(userData) +logs = store2.listSessionLogs() +ok(logs.length === 1, 'new store hydrates log list from index.json') +ok(logs[0].sessionId === sid && logs[0].endedAt !== undefined, 'hydrated meta intact') + +// Re-logStart same id stops the previous and creates a new file (delay so the +// stamp differs). +await wait(1100) +const start2 = store.logStart(sid) +ok(start2.file !== start.file, 're-logStart creates a new file') +logs = store.listSessionLogs() +ok(logs.length === 2, 're-logStart yields 2 (old is stopped, new is active)') +ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log finalized on re-start') + +// ---- 6. openLogsDir ------------------------------------------------------------ +store.openLogsDir() +ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir') + +console.log('\n[commands] ALL CHECKS PASSED') +process.exit(0) \ No newline at end of file diff --git a/tests/electron-stub.cjs b/tests/electron-stub.cjs new file mode 100644 index 0000000..c9f130b --- /dev/null +++ b/tests/electron-stub.cjs @@ -0,0 +1,12 @@ +// Minimal electron stub for bundling src/main/pty.ts under plain Node +// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches. +module.exports = { + BrowserWindow: { + getAllWindows: () => [] + }, + app: {}, + ipcMain: {}, + safeStorage: { + isEncryptionAvailable: () => false + } +} diff --git a/tests/hl-split-smoke.mjs b/tests/hl-split-smoke.mjs new file mode 100644 index 0000000..e62cf4c --- /dev/null +++ b/tests/hl-split-smoke.mjs @@ -0,0 +1,64 @@ +// Split-chunk regression test for HighlightStream. +// Builds the engine + TerminalView-free stream, feeds a kimi-like banner in +// deliberately nasty chunk splits, and requires byte-identical output vs the +// single-chunk reference. +import { compileRules, applyHighlights, HighlightStream } from '../src/renderer/src/terminal/highlightEngine.ts' + +const rules = compileRules([ + { id: 'okstate', pattern: '\\b(SUCCESS|PASS|OK|DONE)\\b', enabled: true, priority: 5, color: { fg: '#3fb950' } }, + { id: 'badstate', pattern: '\\b(ERROR|FAILED)\\b', enabled: true, priority: 6, color: { fg: '#f85149' } }, + { id: 'numbers', pattern: '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', enabled: true, priority: 25, color: { fg: '#f2cc60' } }, + { id: 'url', pattern: 'https?://[^\\s]+', enabled: true, priority: 30, color: { fg: '#58a6ff' } } +]) + +// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints +const banner = + '\x1b]0;kimi — session\x07' + + '\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' + + '\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' + + '\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n' + +const reference = applyHighlights(banner, rules) + +// every possible single split point + a few double splits +let failed = 0 +const splitAt = (s, i) => [s.slice(0, i), s.slice(i)] + +for (let i = 1; i < banner.length - 1; i++) { + const [a, b] = splitAt(banner, i) + const stream = new HighlightStream(rules) + const out = stream.push(a) + stream.push(b) + stream.flush() + if (out !== reference) { + failed++ + if (failed <= 3) { + console.log(`MISMATCH at split ${i}:`) + console.log(' got: ' + JSON.stringify(out.slice(0, 120))) + console.log(' expected: ' + JSON.stringify(reference.slice(0, 120))) + } + } +} + +// double splits at 1/3 and 2/3, plus a pathological 1-byte-per-push feed +{ + const i = Math.floor(banner.length / 3) + const j = Math.floor((banner.length * 2) / 3) + const stream = new HighlightStream(rules) + const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush() + if (out !== reference) failed++ +} + +{ + const stream = new HighlightStream(rules) + let out = '' + for (const ch of banner) out += stream.push(ch) + out += stream.flush() + if (out !== reference) failed++ +} + +if (failed === 0) { + console.log('SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference') + process.exit(0) +} else { + console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`) + process.exit(1) +} diff --git a/tests/sftp-chmod.mjs b/tests/sftp-chmod.mjs new file mode 100644 index 0000000..0306b2c --- /dev/null +++ b/tests/sftp-chmod.mjs @@ -0,0 +1,78 @@ +// Real-server chmod/chown + mode-string verification (M4). Credentials from env: +// JD_HOST / JD_USER / JD_PASS +// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service) +import { createRequire } from 'module' +const require_ = createRequire(import.meta.url) +const sessionLayer = require_('./.session-e2e.cjs') +const sftpMod = await import('./.sftp-svc.mjs') +sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id)) + +const events = [] +sessionLayer.configureSessionRuntime({ + broadcast: (channel, payload) => events.push({ channel, payload }), + getConnection: () => ({ + id: 'jd', + name: 'test', + host: process.env.JD_HOST, + port: 22, + username: process.env.JD_USER, + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 30, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + }), + getSecret: () => process.env.JD_PASS, + touch: () => {}, + knownHosts: { check: () => ({ status: 'match' }), accept: () => {} }, + promptHostKey: () => { throw new Error('unexpected prompt') } +}) + +const fail = (m) => { console.error('FAIL:', m); process.exit(1) } +if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) } + +console.log('[chmod] connecting ...') +const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' }) +console.log('[chmod] session ready') + +// 1. mode-string format from a live listing +const root = await sftpMod.listRemote(id, '/') +const sample = root.find((e) => e.mode && e.mode.length === 10) +if (!sample) fail('no entry carries a mode string in /') +const expectedType = sample.isDir ? 'd' : '-' +if (sample.mode[0] !== expectedType) fail(`mode type bit wrong: ${sample.mode} isDir=${sample.isDir}`) +const permPart = sample.mode.slice(1) +if (!/^[rwx-]{9}$/.test(permPart)) fail(`perm digits malformed: ${sample.mode}`) +console.log('[chmod] mode string ok:', sample.mode, + 'uid=' + sample.uid, 'gid=' + sample.gid) + +// 2. chmod a scratch dir, verify, restore +const dir = '/root/ot_chmod_test' +await sftpMod.deleteRemote(id, [dir]).catch(() => undefined) +await sftpMod.mkdirRemote(id, '/root', 'ot_chmod_test') +try { + const before = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir) + if (!before || !before.mode) fail('scratch dir missing or lacks mode') + console.log('[chmod] before:', before.mode) + + await sftpMod.chmodRemote(id, dir, '700') + const after700 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir) + if (after700.mode !== 'drwx------') fail(`expected drwx------ got ${after700.mode}`) + console.log('[chmod] chmod 700 ->', after700.mode) + + await sftpMod.chmodRemote(id, dir, '755') + const after755 = (await sftpMod.listRemote(id, '/root')).find((e) => e.path === dir) + if (after755.mode !== 'drwxr-xr-x') fail(`expected drwxr-xr-x got ${after755.mode}`) + console.log('[chmod] chmod 755 ->', after755.mode) +} finally { + await sftpMod.deleteRemote(id, [dir]).catch(() => undefined) +} +console.log('[chmod] cleanup ok') + +const exits = events.filter((e) => e.channel === 'pty:exit') +console.log('[chmod] unexpected exits:', exits.length) + +sessionLayer.killPty(id) +console.log('[chmod] ALL CHECKS PASSED') +setTimeout(() => process.exit(0), 300) \ No newline at end of file diff --git a/tests/sftp-real.mjs b/tests/sftp-real.mjs new file mode 100644 index 0000000..7e2b37d --- /dev/null +++ b/tests/sftp-real.mjs @@ -0,0 +1,109 @@ +// Real-server SFTP verification (M4). Credentials from env (never hardcoded): +// JD_HOST / JD_USER / JD_PASS +// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.cjs (sftp service) +import { createRequire } from 'module' +import { randomUUID } from 'crypto' +import { promises as fsp } from 'fs' +const require_ = createRequire(import.meta.url) +const sessionLayer = require_('./.session-e2e.cjs') +const sftpMod = await import('./.sftp-svc.mjs') +sftpMod.registerSftpClientProvider((id) => sessionLayer.getSshClient(id)) + +const events = [] +sessionLayer.configureSessionRuntime({ + broadcast: (channel, payload) => events.push({ channel, payload }), + getConnection: () => ({ + id: 'jd', + name: 'test', + host: process.env.JD_HOST, + port: 22, + username: process.env.JD_USER, + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 30, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + }), + getSecret: () => process.env.JD_PASS, + touch: () => {}, + knownHosts: { check: () => ({ status: 'match' }), accept: () => {} }, + promptHostKey: () => { throw new Error('unexpected prompt') } +}) + +const fail = (m) => { console.error('FAIL:', m); process.exit(1) } +if (!process.env.JD_HOST) { console.error('set JD_HOST/JD_USER/JD_PASS'); process.exit(2) } + +console.log('[sftp] connecting ...') +const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' }) +console.log('[sftp] session ready') + +const root = await sftpMod.listRemote(id, '/') +if (!root.some((e) => e.isDir)) fail('no directories listed in /') +console.log('[sftp] listRemote(/) ok,', root.length, 'entries') + +const dir = '/root/ot_sftp_test' +// tolerate leftovers from earlier aborted runs +await sftpMod.deleteRemote(id, [dir]).catch(() => undefined) +await sftpMod.mkdirRemote(id, '/root', 'ot_sftp_test') +console.log('[sftp] mkdir ok') + +const content = randomUUID() + '\n' + 'x'.repeat(256 * 1024) +const localFile = `./.sftp-test-${Date.now()}.bin` +await fsp.writeFile(localFile, content, 'utf8') +console.log('[debug] alive before upload:', !!sessionLayer.getSshClient(id)) +try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('upload timeout 60s')), 60000) + sftpMod.uploadRemote(id, [localFile], dir, (e) => { + if (e.state === 'done') { clearTimeout(timer); resolve() } + if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'upload error')) } + }) +}) + console.log('[debug] alive after upload:', !!sessionLayer.getSshClient(id)) +} catch (err) { + console.error('[debug] upload error:', err.message) + console.error('[debug] alive:', !!sessionLayer.getSshClient(id)) + console.error('[debug] alive:', !!sessionLayer.getSshClient(id)) + console.error('[debug] exits:', JSON.stringify(events.filter((e) => e.channel === 'pty:exit'))) + console.error('[debug] all events:', JSON.stringify(events.map((e) => e.channel))) + throw err +} +await new Promise((r) => setTimeout(r, 500)) +console.log('[debug] alive after 500ms sleep:', !!sessionLayer.getSshClient(id)) +console.log('[debug] events so far:', JSON.stringify(events.map((e) => e.channel))) +const uploaded = await sftpMod.listRemote(id, dir) +const entry = uploaded.find((e) => e.name.endsWith('.bin')) +if (!entry || entry.size !== Buffer.byteLength(content)) fail(`upload size mismatch: ${entry?.size}`) +console.log('[sftp] upload ok,', entry.size, 'bytes') + +const dlDir = `./.sftp-dl-${Date.now()}` +await fsp.mkdir(dlDir, { recursive: true }) +await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('download timeout 60s')), 60000) + sftpMod.downloadRemote(id, [entry.path], dlDir, (e) => { + if (e.state === 'done') { clearTimeout(timer); resolve() } + if (e.state === 'error') { clearTimeout(timer); reject(new Error(e.error ?? 'download error')) } + }) +}) +const back = await fsp.readFile(`${dlDir}/${entry.name}`, 'utf8') +if (back !== content) fail('download content mismatch') +console.log('[sftp] download round-trip ok') + +await sftpMod.deleteRemote(id, [dir]) +try { + await sftpMod.listRemote(dir) + fail('dir still exists after delete') +} catch { + // expected — dir gone +} +console.log('[sftp] recursive delete ok') + +const exits = events.filter((e) => e.channel === 'pty:exit') +console.log('[sftp] unexpected exits:', exits.length) + +await fsp.rm(localFile, { force: true }) +await fsp.rm(dlDir, { recursive: true, force: true }) +sessionLayer.killPty(id) +console.log('[sftp] ALL CHECKS PASSED') +setTimeout(() => process.exit(0), 300) diff --git a/tests/ssh-loopback.mjs b/tests/ssh-loopback.mjs new file mode 100644 index 0000000..809b463 --- /dev/null +++ b/tests/ssh-loopback.mjs @@ -0,0 +1,163 @@ +/** + * SSH loopback verification (M2). Pure Node ESM, no Electron. + * + * Spins up an in-process ssh2.Server (127.0.0.1, random port), then connects + * with a plain ssh2.Client using the exact same connect parameters the main + * process ssh service uses (host/port/username/password/keepalive/hostVerifier), + * and confirms the full data plane round-trip: + * + * ready -> shell -> banner "LOOPBACK-OK" -> write data -> echo -> close + * + * Run: `node tests/ssh-loopback.mjs` (must exit 0) + */ + +import pkg from 'ssh2' +const { Server, Client, utils } = pkg +import { createHash } from 'crypto' + +function fingerprintOf(key) { + return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') +} + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} + +// ---- 1. Boot the loopback server ------------------------------------------- +const serverKey = utils.generateKeyPairSync('ed25519') +let serverPort = 0 +let seenWindowChange = { cols: 0, rows: 0 } + +const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + + client.on('ready', () => { + client.on('session', (accept, reject) => { + const session = accept() + session.on('pty', (accept) => accept()) + session.on('window-change', (accept, reject, info) => { + seenWindowChange = { cols: info.cols, rows: info.rows } + accept && accept() + }) + session.on('shell', (accept, reject) => { + const stream = accept() + stream.write('LOOPBACK-OK\n') + stream.on('data', (d) => { + const data = d.toString('utf8') + stream.write(data) // echo back + if (data.includes('exit')) { + stream.end() + client.end() + } + }) + }) + }) + }) + + client.on('error', (err) => console.error('[server] client error', err.message)) +}) + +await new Promise((resolve, reject) => { + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + serverPort = srv.address().port + resolve() + }) +}) +console.log(`[loopback] ssh server listening on 127.0.0.1:${serverPort}`) + +// ---- 2. Connect with the same params the main ssh service uses -------------- +const client = new Client() +let bannerSeen = false +let echoed = false +let output = '' +let verifyCalls = 0 +let resolveDone +const done = new Promise((r) => (resolveDone = r)) +const timer = setTimeout(() => { + fail(`timed out (client connected: ${client._stream ? 'yes' : 'no'})`) +}, 10000) + +client.on('ready', () => { + console.log('[loopback] client ready') + client.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => { + if (err) return fail(`shell error: ${err.message}`) + console.log('[loopback] shell open') + let sentInput = false + let sentExit = false + + stream.on('data', (d) => { + const chunk = d.toString('utf8') + output += chunk + if (output.includes('LOOPBACK-OK') && !sentInput) { + bannerSeen = true + console.log('[loopback] banner received') + // exercise resize while the session is live + stream.setWindow(40, 120, 0, 0) + sentInput = true + stream.write('hello loopback\n') + } + if (output.includes('hello loopback') && echoed === false) { + echoed = true + console.log('[loopback] echo received') + } + if (bannerSeen && echoed && !sentExit) { + sentExit = true + stream.write('exit\n') + } + }) + + stream.on('close', () => { + clearTimeout(timer) + console.log('[loopback] stream closed') + client.end() + resolveDone() + }) + }) +}) + +client.on('error', (err) => fail(`client error: ${err.message}`)) + +// hostVerifier mirrors ssh.ts (SHA256 fingerprint; loopback accepts the key) +client.connect({ + host: '127.0.0.1', + port: serverPort, + username: 'test', + password: 'test', + keepaliveInterval: 0, + hostVerifier: (hostKey, verify) => { + verifyCalls++ + const fp = fingerprintOf(hostKey) + console.log(`[loopback] hostVerifier called (${verifyCalls}), fp=${fp}`) + verify(true) + } +}) + +// ---- 3. Assertions ---------------------------------------------------------- +await done +srv.close() + +const checks = [ + ['client reached ready', bannerSeen], + ['banner LOOPBACK-OK received', bannerSeen], + ['typed data echoed back', echoed], + ['host key verified exactly once', verifyCalls === 1], + ['resize propagated to server (40x120)', seenWindowChange.cols === 120 && seenWindowChange.rows === 40] +] + +let ok = true +for (const [label, pass] of checks) { + console.log(`[loopback] ${pass ? 'PASS' : 'FAIL'}: ${label}`) + if (!pass) ok = false +} + +if (!ok) fail('one or more checks failed') +console.log('[loopback] ALL CHECKS PASSED') +process.exit(0) \ No newline at end of file diff --git a/tests/ssh-session-e2e.mjs b/tests/ssh-session-e2e.mjs new file mode 100644 index 0000000..dc56a46 --- /dev/null +++ b/tests/ssh-session-e2e.mjs @@ -0,0 +1,151 @@ +/** + * Session-layer e2e (M2). Drives the REAL session routing (src/main/pty.ts + + * src/main/ssh.ts, bundled by esbuild with an electron stub) against an + * in-process ssh2.Server — the same openSession/write/resize/kill pipeline the + * app uses, minus the IPC + renderer shells. + * + * Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs + * --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty + * --external:ssh2 --alias:electron=./tests/electron-stub.cjs + * Run: node tests/ssh-session-e2e.mjs (must exit 0) + */ + +import pkg from 'ssh2' +import { createHash } from 'crypto' +import { createRequire } from 'module' +const require_ = createRequire(import.meta.url) +const { Server, utils } = pkg + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} + +// ---- 1. Loopback ssh server -------------------------------------------------- +const serverKey = utils.generateKeyPairSync('ed25519') +let serverPort = 0 +let seenWindowChange = { cols: 0, rows: 0 } + +const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept() + session.on('pty', (accept) => accept()) + session.on('window-change', (accept, reject, info) => { + seenWindowChange = { cols: info.cols, rows: info.rows } + accept && accept() + }) + session.on('shell', (accept) => { + const stream = accept() + stream.write('SESSION-E2E-BANNER\n') + stream.on('data', (d) => { + const data = d.toString('utf8') + stream.write(data) + if (data.includes('exit')) { + stream.end() + client.end() + } + }) + }) + }) + }) + client.on('error', () => {}) +}) + +await new Promise((resolve, reject) => { + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + serverPort = srv.address().port + resolve() + }) +}) +console.log(`[e2e] ssh server listening on 127.0.0.1:${serverPort}`) + +// ---- 2. Load the real session layer ------------------------------------------ +// esbuild replaces 'electron' with tests/electron-stub.cjs via --alias. +const sessionLayer = require_('./.session-e2e.cjs') + +const events = [] +sessionLayer.configureSessionRuntime({ + broadcast: (channel, payload) => events.push({ channel, payload }), + getConnection: (id) => { + if (id !== 'conn-1') fail(`unexpected connectionId ${id}`) + return { + id: 'conn-1', + name: 'loopback', + host: '127.0.0.1', + port: serverPort, + username: 'test', + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 0, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + } + }, + getSecret: (conn, field) => (field === 'password' ? 'test' : undefined), + touch: (id) => events.push({ channel: 'touched', payload: id }), + knownHosts: { + check: (host, port, key) => { + const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') + return { status: 'match', fingerprint: fp } + }, + accept: () => {} + }, + promptHostKey: () => fail('promptHostKey must not fire for matching keys') +}) + +// ---- 3. Drive the pipeline ---------------------------------------------------- +const timer = setTimeout(() => fail('e2e timed out'), 15000) +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}`) + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)) +const seenData = (needle) => + events.some((e) => e.channel === 'pty:data' && typeof e.payload?.data === 'string' && e.payload.data.includes(needle)) + +// banner should already have streamed once the shell opened +for (let i = 0; i < 50 && !seenData('SESSION-E2E-BANNER'); i++) await wait(100) +if (!seenData('SESSION-E2E-BANNER')) fail('banner never arrived via PTY_DATA broadcast') +console.log('[e2e] banner received through session data plane') + +// replay buffer: a late subscriber must still get the banner +const replay = await sessionLayer.getSessionReplay(openResult.id) +if (!replay.includes('SESSION-E2E-BANNER')) fail('replay buffer missing the banner') +console.log('[e2e] replay buffer serves the banner to late subscribers') + +sessionLayer.writePty(openResult.id, 'ping-session-e2e\n') +for (let i = 0; i < 50 && !seenData('ping-session-e2e'); i++) await wait(100) +if (!seenData('ping-session-e2e')) fail('written data never echoed back') +console.log('[e2e] write -> echo round-trip ok') + +sessionLayer.resizePty(openResult.id, 111, 33) +for (let i = 0; i < 50 && seenWindowChange.cols !== 111; i++) await wait(100) +if (seenWindowChange.cols !== 111 || seenWindowChange.rows !== 33) fail(`resize not propagated (${JSON.stringify(seenWindowChange)})`) +console.log('[e2e] resize propagated (111x33)') + +sessionLayer.killPty(openResult.id) +const exited = events.find((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id) +if (!exited) { + // 'close' on the client stream may lag slightly behind kill + for (let i = 0; i < 50 && !events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id); i++) await wait(100) +} +if (!events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id)) fail('PTY_EXIT never broadcast after killPty') +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') + +clearTimeout(timer) +srv.close() +console.log('[e2e] ALL CHECKS PASSED') +process.exit(0) diff --git a/tests/sysinfo-e2e.mjs b/tests/sysinfo-e2e.mjs new file mode 100644 index 0000000..90927d0 --- /dev/null +++ b/tests/sysinfo-e2e.mjs @@ -0,0 +1,200 @@ +/** + * Sysinfo polling e2e (M3). Drives the REAL M3 engine (src/main/sysinfo.ts, + * reached through pty.ts's re-export) against an in-process ssh2.Server whose + * `exec(accept)` responds with canned /proc-style text. Verifies the META + * broadcast, growing samples (usage + net rates), and that stopPolling halts + * the stream. + * + * Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs + * --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty + * --external:ssh2 --alias:electron=./tests/electron-stub.cjs + * Run: node tests/sysinfo-e2e.mjs (must exit 0) + */ + +import pkg from 'ssh2' +import { createHash } from 'crypto' +import { createRequire } from 'module' +const require_ = createRequire(import.meta.url) +const { Server, utils } = pkg + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} +const wait = (ms) => new Promise((r) => setTimeout(r, ms)) + +// ---- 1. Loopback ssh server with canned /proc exec ----------------------------- +const serverKey = utils.generateKeyPairSync('ed25519') +let serverPort = 0 + +// Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute. +let execCount = 0 +const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept() + session.on('pty', (accept) => accept()) + session.on('shell', (accept) => { + const stream = accept() + stream.write('SYSINFO-E2E-BANNER\n') + stream.on('data', () => {}) + }) + session.on('exec', (accept, reject, info) => { + if (!info || typeof info.command !== 'string') { + reject && reject() + return + } + const stream = accept() + execCount += 1 + const rising = execCount > 1 + const rxBytes = (rising ? 80 * 1024 * 1024 : 40 * 1024 * 1024) // 64KB/s basis + const txBytes = (rising ? 32 * 1024 * 1024 : 16 * 1024 * 1024) + const user = rising ? 4000 : 2000 + const idle = rising ? 8000 : 6000 + const idle2 = rising ? 8950 : 6000 + const idle3 = rising ? 9600 : 7500 + const out = + `cpu ${user} 100 500 ${idle} 120 30 20 0 0 0\n` + + `cpu0 ${user} 100 500 ${idle} 120 30 20 0 0 0\n` + + `cpu1 ${user} 100 500 ${idle2} 120 30 20 0 0 0\n` + + `cpu2 ${user} 100 500 ${idle3} 120 30 20 0 0 0\n` + + `irq 0 0 0 0 0 0 0 0 0 0\n` + + `MemTotal: ${4000000} kB\n` + + `MemFree: 1000000 kB\n` + + `MemAvailable: ${3100000} kB\n` + + `Buffers: 100000 kB\n` + + '0.15 0.12 0.10 1/234 5678\n' + + '12345.67 9876.54\n' + + `__DF__\n` + + 'Filesystem 1K-blocks Used Available Use% Mounted on\n' + + '/dev/sda1 10240000 2048000 8192000 20% /\n' + + 'tmpfs 2048000 128 2047872 1% /dev/shm\n' + + 'overlay 10240000 2048000 8192000 20% /overlay\n' + + '/dev/nvme0n1p3 51200000 12800000 38400000 25% /home\n' + + `__NET__\n` + + `Inter-| Receive | Transmit\n` + + ` face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n` + + ` lo: ${rxBytes} 1000 0 0 0 0 0 0 ${txBytes} 1000 0 0 0 0 0 0\n` + + ` eth0: ${rxBytes} 5000 0 0 0 0 0 0 ${txBytes} 2000 0 0 0 0 0 0\n` + + `__HOST__\n` + + 'loopback-host\n' + + 'Linux 6.8.0-45-generic x86_64\n' + stream.write(out) + stream.exit(0) + stream.end() + }) + }) + }) + client.on('error', () => {}) +}) + +await new Promise((resolve, reject) => { + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + serverPort = srv.address().port + resolve() + }) +}) +console.log(`[sysinfo] ssh server listening on 127.0.0.1:${serverPort}`) + +// ---- 2. Load the real session layer + sysinfo engine --------------------------- +const sessionLayer = require_('./.session-e2e.cjs') + +const events = [] +sessionLayer.configureSessionRuntime({ + broadcast: (channel, payload) => events.push({ channel, payload }), + getConnection: (id) => { + if (id !== 'conn-1') fail(`unexpected connectionId ${id}`) + return { + id: 'conn-1', + name: 'loopback', + host: '127.0.0.1', + port: serverPort, + username: 'test', + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 0, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + } + }, + getSecret: (conn, field) => (field === 'password' ? 'test' : undefined), + touch: () => {}, + knownHosts: { + check: (host, port, key) => { + const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') + return { status: 'match', fingerprint: fp } + }, + accept: () => {} + }, + promptHostKey: () => fail('promptHostKey must not fire for matching keys') +}) + +const timer = setTimeout(() => fail('sysinfo e2e timed out'), 20000) + +const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' }) +if (!openResult?.id) fail('openSession did not resolve with an id') +console.log(`[sysinfo] session open: ${openResult.id}`) + +const seenMeta = (id) => + events.some((e) => e.channel === 'sysinfo:meta' && e.payload?.id === id) +const samples = (id) => + events.filter((e) => e.channel === 'sysinfo:sample' && e.payload?.id === id).map((e) => e.payload.sample) + +// ---- 3. Start polling ----------------------------------------------------------- +sessionLayer.startPolling(openResult.id, 200) + +// META once the first sample has fields to build it from. +for (let i = 0; i < 100 && !seenMeta(openResult.id); i++) await wait(50) +if (!seenMeta(openResult.id)) fail('SYSINFO_META never broadcast') +const meta = events.find((e) => e.channel === 'sysinfo:meta' && e.payload?.id === openResult.id).payload.meta +if (meta.hostname !== 'loopback-host') fail(`meta.hostname wrong: ${meta.hostname}`) +if (!meta.os.includes('Linux') || !meta.os.includes('6.8.0')) fail(`meta.os wrong: ${meta.os}`) +console.log(`[sysinfo] SYSINFO_META ok: hostname=${meta.hostname} os="${meta.os}"`) + +// At least two SYSINFO_SAMPLE. +for (let i = 0; i < 100 && samples(openResult.id).length < 2; i++) await wait(50) +const got = samples(openResult.id) +if (got.length < 2) fail(`expected >=2 samples, got ${got.length}`) +const first = got[0] +const second = got[1] + +if (first.cpu.usage !== 0) fail(`first sample cpu.usage must be 0, got ${first.cpu.usage}`) +if (first.net.rxKbs !== 0 || first.net.txKbs !== 0) fail('first sample net rates must be 0') + +if (!(second.cpu.usage > 0 && second.cpu.usage <= 100)) fail(`second cpu.usage out of range: ${second.cpu.usage}`) +if (!(second.net.rxKbs > 0 && second.net.rxKbs < 1_000_000)) fail(`second net.rxKbs unreasonable: ${second.net.rxKbs}`) + if (!(second.net.txKbs > 0 && second.net.txKbs < 1_000_000)) fail(`second net.txKbs unreasonable: ${second.net.txKbs}`) +if (second.cpu.cores !== 3) fail(`cpu.cores should be 3, got ${second.cpu.cores}`) +if (second.mem.totalMb !== Math.round(4000000 / 1024)) fail(`mem.totalMb wrong: ${second.mem.totalMb}`) +if (second.uptimeSec !== 12346 && second.uptimeSec !== 12345) fail(`uptimeSec wrong: ${second.uptimeSec}`) +// df must skip tmpfs + overlay pseudofs; only the two real mounts remain. +const mounts = second.disks.map((d) => d.mount).sort() +if (mounts.length !== 2 || mounts[0] !== '/' || mounts[1] !== '/home') { + fail(`df mounts wrong (pseudofs not filtered?): ${JSON.stringify(mounts)}`) +} +console.log( + `[sysinfo] samples ok: usage=${second.cpu.usage.toFixed(1)} cores=${second.cpu.cores} ` + + `rx=${second.net.rxKbs.toFixed(1)}kB/s tx=${second.net.txKbs.toFixed(1)}kB/s disks=${mounts.join(',')}` +) + +// ---- 4. Stop polling: no further samples ----------------------------------------- +const before = samples(openResult.id).length +sessionLayer.stopPolling(openResult.id) +await wait(400) +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') + +clearTimeout(timer) +srv.close() +console.log('[sysinfo] ALL CHECKS PASSED') +process.exit(0) \ No newline at end of file diff --git a/tests/zmodem-e2e.mjs b/tests/zmodem-e2e.mjs new file mode 100644 index 0000000..145232d --- /dev/null +++ b/tests/zmodem-e2e.mjs @@ -0,0 +1,257 @@ +/** + * ZMODEM engine e2e (M6). In-process, no SSH server and no real credentials: + * our engine (src/main/zmodem.ts) is attached to one end of an in-process wire, + * and a SEPARATE zmodem.js Sentry plays the remote rz/sz on the other end. Two + * full duplex directions are exercised over the real ZMODEM framing: + * + * 场景 1 (download): remote `sz` sends a file -> our engine receives it into + * a temp dir; assert content matches + done(ok) + progress. + * 场景 2 (upload): our engine uploads a local temp file -> remote `rz` + * receives it; assert remote content matches + done(ok). + * + * Pre-req (the bundle is built automatically on first run): + * npx esbuild src/main/zmodem.ts --bundle --platform=node --format=cjs \ + * --external:ssh2 --alias:electron=./tests/electron-stub.cjs \ + * --outfile=tests/.zmodem-e2e.cjs + * zmodem.js stays bundled (NOT external). + * + * Run: node tests/zmodem-e2e.mjs (must exit 0) + */ +import { createRequire } from 'module' +import { + mkdtempSync, + writeFileSync, + readFileSync, + mkdirSync, + rmSync, + existsSync +} from 'fs' +import { join, dirname, resolve, basename } from 'path' +import { fileURLToPath } from 'url' +import { execFileSync } from 'child_process' +import { randomBytes } from 'crypto' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const require_ = createRequire(import.meta.url) +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} + +// ---- 0. ensure bundle --------------------------------------------------------- +const bundlePath = join(__dirname, '.zmodem-e2e.cjs') +if (!existsSync(bundlePath)) { + console.log('[zmodem-e2e] building bundle ...') + const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx' + execFileSync( + cmd, + [ + 'esbuild', resolve(__dirname, '../src/main/zmodem.ts'), + '--bundle', '--platform=node', '--format=cjs', + '--external:ssh2', + '--alias:electron=./tests/electron-stub.cjs', + `--outfile=${bundlePath}` + ], + { stdio: 'inherit', shell: process.platform === 'win32' } + ) +} + +const zmodem = require_('zmodem.js') +const engine = require_(bundlePath) + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +/** + * Drive a zmodem.js SEND session with a real file (the fs-port of the browser + * send_files helper, used by the remote half of the download scenario). + */ +async function driveRemoteSend(session, filePath) { + const data = readFileSync(filePath) + const name = basename(filePath) + const xfer = await session.send_offer({ name, size: data.length, mtime: new Date() }) + if (!xfer) throw new Error('offer was skipped') + const CHUNK = 8192 + let off = 0 + if (data.length === 0) { + await xfer.end(new Uint8Array(0)) + } else { + while (off < data.length) { + const end = Math.min(off + CHUNK, data.length) + if (end < data.length) { + xfer.send(new Uint8Array(data.buffer, data.byteOffset + off, end - off)) + } else { + await xfer.end(new Uint8Array(data.buffer, data.byteOffset + off, end - off)) + } + off = end + } + } + await session.close() +} + +/** Read all bytes a remote receive side has drained so far. */ +function collectRemote(self) { + return Buffer.concat(self._incoming) +} + +/** + * Build a remote (peer) Sentry. `remoteOnDetect(confirm)` wires the remote's + * on_detect: it should confirm + drive its side. `routeOut` is called with the + * octets the remote sent so we can feed them into our engine. + */ +function makeRemoteSentry(remoteOnDetect, routeOut) { + const self = { _incoming: [] } + const remoteSentry = new zmodem.Sentry({ + to_terminal: () => {}, + on_retract: () => {}, + on_detect: (detection) => remoteOnDetect(detection, self), + sender: (octets) => routeOut(Buffer.from(octets)) + }) + self.sentry = remoteSentry + self.consume = (buf) => { + self._incoming.push(Buffer.from(buf)) + } + return self +} + +// ---- SCENARIO 1: download (remote `sz` -> our engine receives) ---------------- +async function scenarioDownload() { + console.log('\n=== 场景 1: 远端 sz + 本地接收 (download) ===') + const sessionId = `sess-${randomBytes(3).toString('hex')}` + const srcDir = mkdtempSync(join(__dirname, '.zm-src-dl-')) + const srcFile = join(srcDir, 'transferred.bin') + const payload = randomBytes(200_000) + writeFileSync(srcFile, payload) + const outDir = mkdtempSync(join(__dirname, '.zm-out-dl-')) + + const events = [] + const remote = makeRemoteSentry((detection) => { + const s = detection.confirm() + if (s.type === 'send') { + // remote plays sz: send the source file, then finish + driveRemoteSend(s, srcFile).catch((e) => fail(`remote send error: ${e}`)) + } + }, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0)) + + engine.attachZmodem(sessionId, { + broadcast: (channel, payload) => events.push({ channel, payload }), + toTerminal: () => {}, + writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0) + }) + + // remote sz initiates with ZRQINIT + engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex())) + + // wait for the receive offer + for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10) + const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload + if (!offer) fail('download: no ZMODEM_OFFER received') + if (offer.mode !== 'receive') fail(`download: expected receive offer, got ${offer.mode}`) + console.log(`[download] offer received (mode=receive) id=${offer.id}`) + + engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir }) + + // wait for done + for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10) + const done = events.find((e) => e.channel === 'zmodem:done')?.payload + if (!done) fail('download: no ZMODEM_DONE received') + if (!done.ok) fail(`download: expected ok, got ${JSON.stringify(done)}`) + + // assert progress events flowed + const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload) + if (prog.length === 0) fail('download: no TRANSFER_PROGRESS emitted') + if (prog[0].kind !== 'zmodem-download') fail(`download: expected zmodem-download kind, got ${prog[0].kind}`) + const finalProg = prog[prog.length - 1] + if (finalProg.state !== 'done') fail(`download: last progress not done (${finalProg.state})`) + + // assert content + const got = readFileSync(join(outDir, 'transferred.bin')) + if (got.length !== payload.length || !got.equals(payload)) { + fail(`download: content mismatch (len ${got.length} vs ${payload.length})`) + } + console.log(`[download] file received: ${got.length} bytes, content matches`) + console.log(`[download] progress events: ${prog.length} (state->done), done(ok)=true`) + + rmSync(srcDir, { recursive: true, force: true }) + rmSync(outDir, { recursive: true, force: true }) +} + +// ---- SCENARIO 2: upload (our engine -> remote `rz`) ---------------------------- +async function scenarioUpload() { + console.log('\n=== 场景 2: 本地上传 + 远端 rz (upload) ===') + const sessionId = `sess-${randomBytes(3).toString('hex')}` + const srcDir = mkdtempSync(join(__dirname, '.zm-src-up-')) + const srcFile = join(srcDir, 'sendme.bin') + const payload = randomBytes(150_000) + writeFileSync(srcFile, payload) + const outDir = mkdtempSync(join(__dirname, '.zm-out-up-')) + + const events = [] + let remoteGot = null + const remote = makeRemoteSentry((detection, self) => { + const s = detection.confirm() + if (s.type === 'receive') { + // remote plays rz: accept the offered file, spool to disk. + s.on('offer', (offer) => { + const name = basename(offer.get_details().name) + const chunks = [] + offer.on('input', (p) => chunks.push(Buffer.from(p))) + offer + .accept() + .then(async () => { + const data = Buffer.concat(chunks) + writeFileSync(join(outDir, name), data) + remoteGot = data + }) + .catch((e) => fail(`remote receive error: ${e}`)) + }) + // start() once; zmodem.js re-sends ZRINIT itself after each accepted file. + s.start().catch((e) => fail(`remote receive start error: ${e}`)) + } + }, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0)) + + engine.attachZmodem(sessionId, { + broadcast: (channel, payload) => events.push({ channel, payload }), + toTerminal: () => {}, + writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0) + }) + + // remote rz receiver initiates: we feed it ZRQINIT so it detects "receive", + // confirms and start()s, which sends ZRINIT toward our engine. + remote.sentry.consume(Buffer.from(zmodem.Header.build('ZRQINIT').to_hex())) + + // engine should detect send and offer + for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10) + const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload + if (!offer) fail('upload: no ZMODEM_OFFER received') + if (offer.mode !== 'send') fail(`upload: expected send offer, got ${offer.mode}`) + console.log(`[upload] offer received (mode=send) id=${offer.id}`) + + engine.respondZmodem({ id: offer.id, cancelled: false, paths: [srcFile] }) + + for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10) + const done = events.find((e) => e.channel === 'zmodem:done')?.payload + if (!done) fail('upload: no ZMODEM_DONE received') + if (!done.ok) fail(`upload: expected ok, got ${JSON.stringify(done)}`) + + const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload) + if (prog.length === 0) fail('upload: no TRANSFER_PROGRESS emitted') + if (prog[0].kind !== 'zmodem-upload') fail(`upload: expected zmodem-upload kind, got ${prog[0].kind}`) + + if (!remoteGot) fail('upload: remote never received the file data') + if (remoteGot.length !== payload.length || !payload.equals(remoteGot)) { + fail(`upload: content mismatch (remote ${remoteGot.length} vs src ${payload.length})`) + } + console.log(`[upload] remote received ${remoteGot.length} bytes, content matches`) + console.log(`[upload] progress events: ${prog.length}, done(ok)=true`) + + rmSync(srcDir, { recursive: true, force: true }) + rmSync(outDir, { recursive: true, force: true }) +} + +// ---- run ---------------------------------------------------------------------- +const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000) +await scenarioDownload() +await scenarioUpload() +clearTimeout(timer) +console.log('\n[zmodem-e2e] ALL CHECKS PASSED') +process.exit(0) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..155ebaa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..1dcf65e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node", "electron-vite/node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"] + } + }, + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/shared/**/*" + ] +} diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..306f669 --- /dev/null +++ b/tsconfig.web.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"], + "@renderer/*": ["src/renderer/src/*"] + } + }, + "include": ["src/renderer/**/*", "src/shared/**/*"] +}