chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4906 -2489

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
name: Bug Report
description: Report a reproducible problem in OpsXShell
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug. Please search [existing issues](https://github.com/L00J/opsxshell/issues) first.
Do not paste passwords, private keys, or passphrase material.
- type: dropdown
id: platform
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: OpsXShell version
description: Settings → About, or the latest [release](https://github.com/L00J/opsxshell/releases/latest).
placeholder: "e.g. 2.1.1"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install OpsXShell?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb)
- Built from source (`./run.sh` / `./scripts/build.sh`)
- Other
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
multiple: true
options:
- SSH connection / terminal
- SFTP
- Port forwarding / proxy
- RDP / VNC
- Serial / local terminal
- AI assistant
- Settings / UI layout
- Crash / app won't start
- Other
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps so we can follow exactly.
placeholder: |
1. Open OpsXShell and connect to host X
2. Open Agent CLI
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: |
Optional but helpful. Settings → About shows the application log path.
Redact hosts you do not want public. Never paste passwords or private keys.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I removed passwords, private keys, and other secrets from this report
required: true
+9
View File
@@ -48,6 +48,15 @@ __pycache__/
src-tauri/target/ src-tauri/target/
src-tauri/tauri.signing.conf.json src-tauri/tauri.signing.conf.json
# 本地下载的 Apple 证书 / 私钥(说明文档可提交)
download/*.cer
download/*.crt
download/*.key
download/*.p12
download/*.pem
download/*.csr
download/*.certSigningRequest
# Database # Database
*.db *.db
*.sqlite *.sqlite
+13 -2
View File
@@ -30,6 +30,13 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
4. 界面:Zustand 持有资源 ID 与 UI 状态;可见文案同步七语种。 4. 界面:Zustand 持有资源 ID 与 UI 状态;可见文案同步七语种。
5. 测试:功能改动优先 `npm run test:patrol`,再补对应单测。 5. 测试:功能改动优先 `npm run test:patrol`,再补对应单测。
## IPC 响应约定
- 业务失败(SSH / SFTP / 转发 / 会话 CRUD / 本地文件)使用信封 `{ success, error, errorCode? }`。`errorCode` 是稳定点分路径(如 `native.taskJoin`),前端用 `translateIpcError` 映射 `errors:*`。
- RDP / VNC 运行态(connect / input / clipboard)走 throw;在桌面视图 catch。
- 禁止第三种失败形态。组件和 store 不直接 `invoke`,只经 `src/platform/`(`invokeNative` / `invokeEnvelope`)。
- 同步对话框 command(`show_open_dialog` / `show_save_dialog`)必须在主线程调用,不得 `spawn_blocking`(macOS 保存框依赖主线程)。磁盘 I/O 走 `spawn_blocking`。
## 规则 ## 规则
- 未由 Rust 后端实现的能力不能在界面中显示为可用。 - 未由 Rust 后端实现的能力不能在界面中显示为可用。
@@ -41,6 +48,9 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
- 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。 - 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。 - 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
- SSH 断开 / keepalive 失败 / 空闲挂起 / 同窗重连:未点「停止」的转发 Listener 应保留;按 `user@host:port` 指纹 orphan-adopt;同窗用进程内 `forwardIntent` 自动恢复;关标签不得自动再启。空闲挂起应先断开原生会话并 `retainForwards`,避免僵尸 TCP。 - SSH 断开 / keepalive 失败 / 空闲挂起 / 同窗重连:未点「停止」的转发 Listener 应保留;按 `user@host:port` 指纹 orphan-adopt;同窗用进程内 `forwardIntent` 自动恢复;关标签不得自动再启。空闲挂起应先断开原生会话并 `retainForwards`,避免僵尸 TCP。
- SSH 标签还开着时:协议心跳保链路(不写入 Shell,不对抗 TMOUT);意外断开有限次自动重连(新 PTY)。远端空闲退出只提示重开该分屏。不要默认向 Shell 发送回车/空格。长任务用远端 tmux/screen。
- 活跃 SFTP 标签同样有限次自动重连并恢复上次远程目录。
- RDP/VNC 标签还开着时意外断开有限次自动重连;不发送假鼠标对抗空闲 GPO。断开即取消本地 worker,重连是新会话。
- 动态转发(SOCKS5)默认仅本机 `127.0.0.1:1080`;流量经当前 SSH 服务器出口 IP 访问外网。每台 SSH 主机仅保留一条动态规则。状态栏「转发」打开抽屉管理,右键复制 `socks5://127.0.0.1:1080`。 - 动态转发(SOCKS5)默认仅本机 `127.0.0.1:1080`;流量经当前 SSH 服务器出口 IP 访问外网。每台 SSH 主机仅保留一条动态规则。状态栏「转发」打开抽屉管理,右键复制 `socks5://127.0.0.1:1080`。
- 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。 - 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。 - SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
@@ -56,10 +66,11 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
npm run test:patrol npm run test:patrol
npm run acceptance:quick npm run acceptance:quick
npm run acceptance:full npm run acceptance:full
./scripts/build.sh # 当前平台安装包(macOS → .app + .dmg) ./scripts/build.sh # 当前平台安装包(macOS → Tauri .app + .dmg)
./scripts/macos-notarize.sh # 新 DMG 的 Apple 公证 + staple
``` ```
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。 平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。macOS 有 Developer ID 时:仓库默认 `signingIdentity` 仍是 `"-"`;本机把身份写进 gitignore 的 `src-tauri/tauri.signing.conf.json`,`build.sh` 会自动带上,并走 `bundle.macOS.dmg` 的 Tauri 窗口布局。新打的 DMG 必须再跑 `macos-notarize.sh` 才能给别人直接打开。
## 评审边界 ## 评审边界
+1 -1
View File
@@ -9,7 +9,7 @@
- `src/`:React UI;`src-tauri/`:Rust / Tauri 原生后端 - `src/`:React UI;`src-tauri/`:Rust / Tauri 原生后端
- `shared/`:前后端共享 TypeScript 契约 - `shared/`:前后端共享 TypeScript 契约
- `test/`:Vitest;基础巡检在 `test/patrol/`(优先 `npm run test:patrol`) - `test/`:Vitest;基础巡检在 `test/patrol/`(优先 `npm run test:patrol`)
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`) - `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`;macOS 公证用 `./scripts/macos-notarize.sh`)
- `docs/`:使用 / 架构 / 验收 / 发布 - `docs/`:使用 / 架构 / 验收 / 发布
- 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md` - 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`
+37 -98
View File
@@ -1,117 +1,56 @@
# OpsXShell # OpsXShell
简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。 原生运维终端:SSH、SFTP、端口转发、RDP/VNC、串口、本地终端。短路径好用,不做审计/审批/堡垒机。
## 已支持 需要 Node.js 18+、npm 9+、Rust 1.77+。macOS 打包还要 Xcode Command Line Tools。
免费层: ## 本地调试(GUI)
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
- 会话管理:分组、标签、快速连接、多标签、终端分屏、命令片段;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
- SFTP:双栏、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限)
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段(批量清单导入属专业版)
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
- 本地终端:系统 Shell
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
专业版(应用内升级):
- 端口转发:本地、远程、动态 SOCKS5;规则与运行实例分离
- 连接代理:HTTP CONNECT、SOCKS5
- 批量命令与终端广播(不含 RDP / VNC)
- 远程桌面:原生 RDP(IronRDP)与 VNC(vnc-rs)
- 串口
- 批量导入导出主机清单
- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;最多 8 步执行循环,**严格 / 宽松**两级安全模式,五级风险,critical 硬拒绝(Rust 二次校验)
## 产品边界
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
仍在打磨、不影响「先用起来」的方向:
- Agent Forwarding、OpenSSH 证书与 FIDO2
- 断网重连、休眠恢复和大量标签切换的持续压测
- SFTP 内容校验和、显式冲突选择和任务恢复
- RDP / VNC 生产级证书校验与更多通道能力
- Windows 实机验收、自动更新、代码签名、macOS 公证和崩溃诊断
## 技术栈
| 层级 | 技术 |
| ---------- | ------------------------------------------------- |
| 桌面运行时 | Tauri 2 |
| 原生后端 | Rust 2021 |
| SSH / SFTP | `ssh2 0.9` / libssh2 |
| 远程桌面 | IronRDP / `vnc-rs` |
| 本地终端 | `portable-pty 0.9` |
| 串口 | `serialport 4.8` |
| 前端 | React 18 + TypeScript 5 + Vite 5 |
| 状态管理 | Zustand 4.4+ |
| 终端 | XTerm.js 5.5+ |
| 国际化 | i18next(zh-CN / zh-TW / en / ja / ko / vi / ru) |
| 测试 | Vitest + Rust test |
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
## 开发
环境要求:Node.js 18+、npm 9+、Rust 1.77+。
```bash ```bash
npm install ./run.sh
npm run dev
``` ```
常用检查与构建: 会清掉旧的 Vite / 应用进程,缺依赖时自动 `npm install`,再开 Tauri 开发窗。DevTools:`Cmd+Option+I` 或 `F12`。日志:
```bash ```bash
tail -f "$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
```
只要前端、不要原生窗:`npm run dev:ui`(默认 `http://127.0.0.1:1420`)。
## 测试
```bash
npm run test:patrol # 改功能后优先跑
npm run acceptance:quick npm run acceptance:quick
npm run acceptance:full npm run acceptance:full
./scripts/build.sh v2.1.1 mac-arm64 ```
scripts\build.bat v2.1.1 win-x64
## 打包
必须在目标系统上打:macOS 包在 Mac 打,Windows 包在 Windows 打。
```bash
./scripts/build.sh # 当前平台;产物在 release/v<版本>/
./scripts/build.sh mac-arm64 --quick # 本机重复打包
./scripts/macos-notarize.sh # 新 DMG:Apple 公证 + staple
```
Windows:`scripts\build.bat win-x64`
本机有 gitignore 的 `src-tauri/tauri.signing.conf.json` 时,`build.sh` 会签 Developer ID。没这份配置就是 ad-hoc,别人电脑会 Gatekeeper 拦截。新打的 DMG **必须再公证**。申请证书、导入钥匙串的步骤见 [download/Apple开发者签名.md](./download/Apple开发者签名.md);发版细节见 [docs/release.md](./docs/release.md)。
Gitee Release(tag 推上去之后):
```bash
./scripts/release.py v2.1.1 --dry-run ./scripts/release.py v2.1.1 --dry-run
./scripts/release.py v2.1.1
``` ```
`scripts/build.sh`(macOS/Git Bash)和 `scripts/build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`scripts/release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
## 目录
```text
src/ React 界面与状态管理
src/i18n/ 界面文案(多语言)
src/platform/ Tauri 命令和事件适配层
shared/types/ 前后端共享 TypeScript 契约
src-tauri/ Rust / Tauri 原生后端
test/ 前端单元、组件与巡检测试
test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
scripts/ 启动、验收、构建与发布脚本
docs/ 使用 / 架构 / 验收 / 发布文档
docs/assets/ 品牌与设计资源
```
## 快捷键
| 快捷键 | 功能 |
| -------------------------- | -------------------- |
| `Cmd/Ctrl + N` | 新建会话 |
| `Cmd/Ctrl + K` | 快速连接 |
| `Cmd/Ctrl + B` | 折叠/展开侧栏 |
| `Cmd/Ctrl + D` | 终端分屏 |
| `Cmd/Ctrl + W` | 关闭标签 |
| `Cmd + Option + I` / `F12` | 开发构建打开调试工具 |
## 文档 ## 文档
- [使用指南](docs/user-guide.md) - [使用指南](docs/user-guide.md)
- [架构说明](docs/architecture.md) - [架构](docs/architecture.md) · [验收](docs/testing-acceptance.md) · [发布](docs/release.md)
- [SSH Agent CLI 设计](docs/ai-agent.md) - [路线图](ROADMAP.md) · [当前任务](TASKS.md)
- [测试与验收](docs/testing-acceptance.md)
- [发布指南](docs/release.md)
- [路线图](ROADMAP.md)
- [当前任务](TASKS.md)
- [测试计划](TDD_PLAN.md)
## 许可证 MIT,见 [LICENSE](./LICENSE)。
MIT。完整文本见 [LICENSE](./LICENSE)。
+3 -3
View File
@@ -1,6 +1,6 @@
# OpsXShell 路线图 # OpsXShell 路线图
> 最后更新:2026-08-16 > 最后更新:2026-09-14
> 版本基线:v2.1.1(Tauri 2 原生) > 版本基线:v2.1.1(Tauri 2 原生)
> 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。 > 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。
@@ -18,7 +18,7 @@
- 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存 - 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存
- 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards - 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards
- AI Agent:当前 SSH connection、strict/relaxed、critical 硬拒绝、不落命令历史 - AI Agent:当前 SSH connection、strict/relaxed、critical 硬拒绝、不落命令历史
- 打包:macOS arm64 `.dmg` 可由 `./scripts/build.sh` 产出(无签名凭据时仅为 ad-hoc,未公证) - 打包:macOS arm64 `.dmg` 由 `./scripts/build.sh` 走 Tauri 窗口布局产出;本机有 `tauri.signing.conf.json` 时签 Developer ID,新包必须再 `./scripts/macos-notarize.sh`;无凭据时仍为 ad-hoc
## 下一阶段 ## 下一阶段
@@ -26,7 +26,7 @@
2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复 2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测 3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2 4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新。步骤见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成)。 5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;每份新 macOS DMG 公证 + staple;Windows Authenticode;带清单的自动更新。步骤见 [docs/release.md](./docs/release.md#签名公证和自动更新)。
6. **架构重构**(单独轨道,不与功能 PR 混做):EventSink → specta/ts-rs → infrastructure 端口。步骤见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)。 6. **架构重构**(单独轨道,不与功能 PR 混做):EventSink → specta/ts-rs → infrastructure 端口。步骤见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)。
7. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出) 7. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出)
+7 -6
View File
@@ -1,6 +1,6 @@
# OpsXShell 当前任务 # OpsXShell 当前任务
> 最后更新:2026-08-16 > 最后更新:2026-09-14
> 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。 > 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。
## 本周期已完成(勿回退) ## 本周期已完成(勿回退)
@@ -10,27 +10,28 @@
- [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab - [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab
- [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出 - [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出
- [x] `AGENTS.md` / `CLAUDE.md` 对齐当前架构与命令 - [x] `AGENTS.md` / `CLAUDE.md` 对齐当前架构与命令
- [x] macOS arm64 v2.1.1 安装包(ad-hoc,未公证) - [x] macOS arm64 v2.1.1 安装包走 Tauri DMG 布局;本机 Developer ID 签名(公证/staple 仍要每打一次包跑一遍)
- [x] 删除过时 `docs/ux-foundation-audit.md` - [x] 删除过时 `docs/ux-foundation-audit.md`
## 现在可做 ## 现在可做
按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。 按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。
- [ ] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」 - [x] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」
- [ ] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录) - [x] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录)
- [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖 - [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖
- [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」 - [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」
- [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开 - [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起 - [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成)) - [ ] 把当前已签名的 v2.1.1 DMG 公证并 staple(`./scripts/macos-notarize.sh`),未钉票不得对外写「已公证」
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新))
## 稍后 ## 稍后
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起) - [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
- [ ] SFTP 校验和与失败任务恢复 - [ ] SFTP 校验和与失败任务恢复
- [ ] RDP/VNC 生产级证书校验 - [ ] RDP/VNC 生产级证书校验
- [ ] macOS Developer ID + 公证;Windows Authenticode(有证书后再做,见 release.md) - [ ] Windows Authenticode(有证书后再做,见 release.md);macOS 每次新 DMG 仍须公证 staple
- [ ] 自动更新(签名清单 + HTTPS + 回滚;缺一不可开 UI) - [ ] 自动更新(签名清单 + HTTPS + 回滚;缺一不可开 UI)
- [ ] EventSink / specta / infrastructure 端口(单独轨道,见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)) - [ ] EventSink / specta / infrastructure 端口(单独轨道,见 [docs/architecture-refactor.md](./docs/architecture-refactor.md))
- [ ] 用户可导出的脱敏崩溃诊断包 - [ ] 用户可导出的脱敏崩溃诊断包
+12 -11
View File
@@ -2,16 +2,17 @@
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。 文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
| 文档 | 内容 | | 文档 | 内容 |
| ------------------------------------------ | ----------------------------------------------------- | | -------------------------------------------------- | --------------------------------------------------------- |
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 | | [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 | | [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) | | [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) |
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 | | [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 | | [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
| [发布指南](./release.md) | 构建与产物;签名/公证/自动更新的做法(当前未完成) | | [发布指南](./release.md) | 构建、Tauri DMG、本机签名;公证 staple 与自动更新仍待完成 |
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) | | [Apple 开发者签名](../download/Apple开发者签名.md) | 申请 Developer Program、Developer ID、导入钥匙串 |
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 | | [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 | | [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP.md](../ROADMAP.md) / [TASKS.md](../TASKS.md) 维护。 项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP.md](../ROADMAP.md) / [TASKS.md](../TASKS.md) 维护。
+6 -4
View File
@@ -9,6 +9,8 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,按用户选择的安全模式决定确认阈值,失败后根据输出换方法,最后给出有证据的结论。无论选择哪种模式,目标主机始终可见,critical 命令始终硬拒绝。 产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,按用户选择的安全模式决定确认阈值,失败后根据输出换方法,最后给出有证据的结论。无论选择哪种模式,目标主机始终可见,critical 命令始终硬拒绝。
设计边界声明:OpsXShell Agent 只服务桌面端当前 SSH 会话。任务开始时锁定当前 connectionId,后续执行和取消始终作用于该连接;标签切换不得让任务迁移到另一台主机。Agent 专用 SSH 结果在 Rust 原生层脱敏后才返回界面和模型上下文。OpsXShell 不复用 OpsXCLI 的本机执行器,也不承担 AIOps 的资产授权、审批和永久审计职责。
## 参考来源 ## 参考来源
本设计已逐篇评估同级 `aiops/docs/ai-agent/00-08` 及它们引用的工程基线: 本设计已逐篇评估同级 `aiops/docs/ai-agent/00-08` 及它们引用的工程基线:
@@ -43,9 +45,9 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
-> 失败换方法 / 继续验证 / FINAL -> 失败换方法 / 继续验证 / FINAL
``` ```
- 单次任务最多 8 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,8 步更符合注意力、时延和成本边界。 - 单次任务最多 12 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,12 步兼顾排查+修复与注意力、时延、成本。
- 相同命令最多出现 2 次,第 3 次在执行前停止。 - 相同命令最多出现 2 次,第 3 次在执行前停止。
- 第 4 轮检查是否已有足够证据,第 8 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。 - 第 6 轮检查是否已有足够证据,第 12 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。
- 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。 - 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。
- `analyze_output` 是本地确定性工具:统计行数、提取错误关键行、保留首尾,不为压缩输出额外调用模型。 - `analyze_output` 是本地确定性工具:统计行数、提取错误关键行、保留首尾,不为压缩输出额外调用模型。
@@ -74,7 +76,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
| OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider | | OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider |
| `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 | | `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 |
| `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 | | `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 |
| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 | | ReAct 16 轮 | 适配为 12 步 | GUI 前台执行;12 步够排查+验证,仍低于后台异步 16 轮 |
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 | | 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
| 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 | | 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 |
| 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 | | 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 |
@@ -131,7 +133,7 @@ src-tauri/src/services/ai/
### P1:运维效率 ### P1:运维效率
1. 任务中断和“停止”,不等待全部 8 步。 1. 任务中断和“停止”,不等待全部 12 步。
2. 只读组合命令的参数级评估,减少误判。 2. 只读组合命令的参数级评估,减少误判。
3. 用户显式保存的 Runbook 模板,支持变量预览和逐步确认。 3. 用户显式保存的 Runbook 模板,支持变量预览和逐步确认。
4. 把已选终端文本、主机标签和系统信息组成最小上下文包,并显示实际发送范围。 4. 把已选终端文本、主机标签和系统信息组成最小上下文包,并显示实际发送范围。
+2 -2
View File
@@ -6,7 +6,7 @@
- `infrastructure/` 只有 `file_log`,services 并不走它。 - `infrastructure/` 只有 `file_log`,services 并不走它。
- `application/state.rs` 是服务集合,不是端口组合根。 - `application/state.rs` 是服务集合,不是端口组合根。
- `DomainError` 零引用。 - 稳定 IPC 错误码放在 `domain/error.rs` 的 `codes`(如 `native.taskJoin`),不是空壳 `DomainError` 类型。
- SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。 - SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。
- 前后端契约靠手写 `src/desktop.d.ts` + `shared/types/`,没有 specta / ts-rs。 - 前后端契约靠手写 `src/desktop.d.ts` + `shared/types/`,没有 specta / ts-rs。
@@ -66,7 +66,7 @@ pub trait EventSink: Send + Sync {
`infrastructure/mod.rs` 里现在的「SQLite / keychain / PTY 适配器」是过期注释,做 C 轨时改成与真实 trait 一致,没做的删掉,避免再写成已存在。 `infrastructure/mod.rs` 里现在的「SQLite / keychain / PTY 适配器」是过期注释,做 C 轨时改成与真实 trait 一致,没做的删掉,避免再写成已存在。
`DomainError`:有调用方再留,否则删。不要为空壳再加一层错误类型。 IPC 错误码继续用 `domain/error.rs` 的 `codes` 常量,不要再加一层空壳 `DomainError`。
## 明确不做(本轨道也不做) ## 明确不做(本轨道也不做)
+4 -4
View File
@@ -82,7 +82,7 @@ XTerm 当前焦点分屏
-> 当前 connectionId 的 ssh_exec -> 当前 connectionId 的 ssh_exec
-> stdout/stderr/exit_code + 本地 analyze_output -> stdout/stderr/exit_code + 本地 analyze_output
-> 真实观察脱敏后交给 AI 判断下一步 -> 真实观察脱敏后交给 AI 判断下一步
-> 最多 8 步后收敛 -> 最多 12 步后收敛
``` ```
- Provider 配置和模型路由保存在 `ai-providers-v1`,不包含 API Key。 - Provider 配置和模型路由保存在 `ai-providers-v1`,不包含 API Key。
@@ -108,7 +108,7 @@ XTerm 当前焦点分屏
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。 - `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
- 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。 - 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。
- 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。 - 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。 - SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。原生 `ssh_disconnect` 必须带真实 reason(keepalive / idle suspend / 用户关闭);空缺时用 `connection closed`,不要再写 `closed by user`。
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。 - 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。 - 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。
- 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。 - 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。
@@ -118,7 +118,7 @@ XTerm 当前焦点分屏
## 远程桌面生命周期 ## 远程桌面生命周期
- RDP / VNC 仅在 Tauri 原生应用中可用;浏览器预览不得显示为可连接。 - RDP / VNC 仅在 Tauri 原生应用中可用;浏览器预览不得显示为可连接。
- 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。 - 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。标签还开着时意外断开会有限次自动重连(新会话);不发送假指针对抗空闲 GPO。
- RDP 可选剪贴板与共享盘(本机下载目录映射);VNC 剪贴板按 UTF-8 发送。 - RDP 可选剪贴板与共享盘(本机下载目录映射);VNC 剪贴板按 UTF-8 发送。
- 桌面会话不参与批量命令与终端广播。 - 桌面会话不参与批量命令与终端广播。
@@ -160,7 +160,7 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验,
- 不持久化终端命令历史或批量命令输出。 - 不持久化终端命令历史或批量命令输出。
- 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。 - 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。
- 未实现能力不能在界面中显示为可用。 - 未实现能力不能在界面中显示为可用。
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新未完成)。 - 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新)。
- 五层目录补全、specta、EventSink 是单独轨道,见 [架构重构](./architecture-refactor.md);未开始前继续按本文件的 commands → services → platform 扩展。 - 五层目录补全、specta、EventSink 是单独轨道,见 [架构重构](./architecture-refactor.md);未开始前继续按本文件的 commands → services → platform 扩展。
- 应用不能静默绕过 changed host key 或 known_hosts 不匹配。 - 应用不能静默绕过 changed host key 或 known_hosts 不匹配。
+85 -37
View File
@@ -1,5 +1,34 @@
# 发布指南 # 发布指南
<a id="macos-next-steps"></a>
## 当前你要做的操作(v2.1.1 macOS)
本机已打出 **Tauri 窗口布局** 的 Developer ID 签名 DMG(不是手搓 `hdiutil`,Finder 里是大图标 + 拖到 Applications):
```text
release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
```
这份包**还没公证、没 staple**。别人下载后仍可能被 Gatekeeper 拦。你这边只差公证:
1. Finder 里弹出 `/Volumes/OpsXShell`(挂载中无法 staple)。
2. 开 Clash:`proxy-on`(公证走 HTTPS;时间戳构建阶段已经用 HTTP 回退签过了)。
3. 不要把 App 专用密码发到聊天。终端执行其一:
```bash
APPLE_ID='1024way@gmail.com' ./scripts/macos-notarize.sh
# 或
bash ~/Documents/apple签名/公证OpsXShell.sh
```
4. 弹出框里填 **App 专用密码**(appleid.apple.com → 登录与安全 → App 专用密码,标签如 `app notary`)。
5. 脚本会 `notarytool submit --wait --timeout 15m`。第一次可能很久;`--wait` 卡住时等它超时,再跑同一条命令——**已 Accepted 的不会重新上传,只会 staple**。不要对同一份未变化的 DMG 连续第三次 `submit`。
6. 成功标志:`xcrun stapler validate` 通过,且 `codesign -dv` 能看到钉票。把 **这一份** DMG 发出去(尽量不要走微信,隔离属性会更烦)。
7. 改代码后再 `./scripts/build.sh mac-arm64 --quick` 会得到新哈希,必须重新公证。
Windows Authenticode、自动更新仍未做,不要对外写「已公证」直到第 6 步完成。
## 构建命令 ## 构建命令
```bash ```bash
@@ -9,6 +38,10 @@
# macOS Apple Silicon 指定版本 # macOS Apple Silicon 指定版本
./scripts/build.sh v2.1.1 mac-arm64 ./scripts/build.sh v2.1.1 mac-arm64
# 构建完成后公证并钉票(新 DMG 必须再跑;已 staple 会直接退出)
proxy-on # 本机 Clash;codesign 时间戳另见下文 HTTP 回退
APPLE_ID='你的开发者邮箱' ./scripts/macos-notarize.sh
# Windows x86_64(推荐在 Windows CMD/PowerShell 中执行) # Windows x86_64(推荐在 Windows CMD/PowerShell 中执行)
scripts\build.bat v2.1.1 win-x64 scripts\build.bat v2.1.1 win-x64
@@ -87,64 +120,79 @@ lsof +D /Volumes/<临时卷名>
先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。 先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。
## 签名、公证和自动更新(未完成) <a id="签名公证和自动更新未完成"></a>
**当前状态:没有签名凭据,公开包是 ad-hoc / 未签名,未公证,没有自动更新。** ## 签名、公证和自动更新
没有 `APPLE_*` / Authenticode 证书、minisign 私钥和 HTTPS 更新源之前,不得在 README、界面、Release 说明或 CI 绿勾上写「已签名」「已公证」「支持自动更新」。`tauri.conf.json` 的 `signingIdentity` 现在是 `"-"`;`plugins` 只有 deep-link,没有 updater。
现有门禁只做「有凭据才验、没凭据就标明开发包」: 申请账号和 Developer ID、导入钥匙串的完整步骤见 [download/Apple开发者签名.md](../download/Apple开发者签名.md)。
**当前状态(本机可签 macOS;这份 DMG 待公证;自动更新仍未做):**
- 仓库 `tauri.conf.json` 的 `signingIdentity` 仍是 `"-"`,没证书的环境还能打 ad-hoc 开发包。
- 本机若已导入 **Developer ID Application**,把身份写进 **gitignore** 的 `src-tauri/tauri.signing.conf.json`。`./scripts/build.sh` 发现该文件就会按 Developer ID 签名。
- DMG 必须走 Tauri bundler(`bundle.macOS.dmg`:窗口 720×440,应用图标与 Applications 分列)。不要用手搓 `hdiutil create`,Finder 图标会变小。
- `timestamp.apple.com:443` 不通时,构建会用 `scripts/macos-codesign-wrapper.sh` 走 HTTP 时间戳。可设 `CODESIGN_USE_SYSTEM_TIMESTAMP=1` 关闭。
- **每次新打的 DMG 都必须再公证并 staple**:`APPLE_ID='…' ./scripts/macos-notarize.sh`。已钉票会直接退出。`--wait` 卡住时脚本会超时后尝试 staple。
- 证书、CSR、私钥放本机文稿 `~/Documents/apple签名/`,不要进 git。Team 内其他 Mac 桌面应用可复用同一张 Developer ID。
- Windows Authenticode、minisign 自动更新仍未接线。没有凭据、或尚未 staple 时,不得在 README / 界面 / CI 上写「已签名」「已公证」「支持自动更新」。
门禁:
- `REQUIRE_SIGNING=true` 时,`scripts/release-preflight.mjs` 检查环境变量齐全,`scripts/verify-release.mjs` 拒绝 macOS ad-hoc、要求 Windows Authenticode `Valid`。 - `REQUIRE_SIGNING=true` 时,`scripts/release-preflight.mjs` 检查环境变量齐全,`scripts/verify-release.mjs` 拒绝 macOS ad-hoc、要求 Windows Authenticode `Valid`。
- 未设该变量时,`./scripts/build.sh` 仍可打开发包,验证只检查架构和体积。 - 未设该变量时,`./scripts/build.sh` 仍可打开发包;若存在 `tauri.signing.conf.json` 则会签 Developer ID。
- CI 工作流 `.github/workflows/tauri-release.yml` 已能在 Secrets 齐全时写入 `src-tauri/tauri.signing.conf.json` 再构建;**本地 `build.sh` 还不会生成这份配置**,所以本机即使导出了同样的环境变量,也不会走出 ad-hoc。 - CI `.github/workflows/tauri-release.yml` 在 Secrets 齐全时写入 `tauri.signing.conf.json`。本机用同一文件名即可,不要提交。
- 全仓库没有 `notarytool` / `staple`。Developer ID 签过但未公证的 `.app`,用户机器上仍会被 Gatekeeper 拦。
- `scripts/release.py` 仍提示「右键打开」——这是 ad-hoc 包的权宜说明,正式公证后必须删掉。
下面是有凭据之后的做法。没齐之前不要改 `signingIdentity`、不要接 updater 插件、不要在设置里放「检查更新」。 ### 1. 凭据
### 1. 凭据(先买/申请,再接线) | 平台 | 要什么 | 放到哪里 |
| -------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| macOS | Apple Developer Program;**Developer ID Application**(不是 Development);App 专用密码 | 本机钥匙串 + gitignore 的 `tauri.signing.conf.json`;CI Secrets:`APPLE_CERTIFICATE`(p12 的 base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_ID`、`APPLE_PASSWORD`、`APPLE_TEAM_ID` |
| Windows | 代码签名证书(OV/EV PFX) | `WINDOWS_CERTIFICATE_BASE64`、`WINDOWS_CERTIFICATE_PASSWORD` |
| 自动更新 | 单独的 **minisign** 密钥对,与 Apple/Authenticode 不是同一把 | 私钥只进 CI Secrets;**公钥**写进仓库的 updater 配置 |
| 平台 | 要什么 | 放到哪里 | `src-tauri/tauri.signing.conf.json` 必须进 `.gitignore`,不得提交。macOS 示例:
| -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS | Apple Developer Program;**Developer ID Application** 证书(`.p12`);App 专用密码 | 本机环境变量 + GitHub/Gitee Secrets:`APPLE_CERTIFICATE`(p12 的 base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`(如 `Developer ID Application: …`)、`APPLE_ID`、`APPLE_PASSWORD`、`APPLE_TEAM_ID` |
| Windows | 代码签名证书(OV/EV PFX) | `WINDOWS_CERTIFICATE_BASE64`、`WINDOWS_CERTIFICATE_PASSWORD` |
| 自动更新 | 单独的 **minisign** 密钥对,与 Apple/Authenticode 不是同一把 | 私钥只进 CI Secrets;**公钥**写进仓库的 updater 配置 |
Linux 安装包(deb/rpm/AppImage)不走 Apple/Authenticode。自动更新的 `.sig` 是另一套签名,三平台都要。 ```json
{
`src-tauri/tauri.signing.conf.json` 由脚本现场生成,必须进 `.gitignore`,不得提交。 "bundle": {
"macOS": {
"signingIdentity": "Developer ID Application: Logan Li (B8DVUUUF6W)"
}
}
}
```
### 2. 本地与 CI 同源签名 ### 2. 本地与 CI 同源签名
目标:本机 `REQUIRE_SIGNING=true ./scripts/build.sh` 与 CI 走同一条路径,都能复现同一类签名产物。 1. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`。
2. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc。
1. `build.sh` / `build.bat` 在签名变量齐全时,生成 `tauri.signing.conf.json`(macOS 写 `bundle.macOS.signingIdentity`;Windows 写 thumbprint / sha256 / timestamp),再 `npx tauri build --config src-tauri/tauri.signing.conf.json`。 3. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 保持未签名。
2. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`——没凭据的开发机构必须还能打 ad-hoc。
3. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc / 无效 Authenticode;不要为了绿而关校验。
4. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 构建保持未签名。
### 3. macOS 公证(notarytool + staple) ### 3. macOS 公证(notarytool + staple)
签名成功 ≠ 用户能打开。必须公证并钉票: 签名成功 ≠ 用户能打开。必须公证并钉票。不要对同一份未变化的 DMG 连续多次 `submit`。
```bash ```bash
# 构建并 Developer ID 签名之后 # 先弹出已挂载的安装盘,再:
xcrun notarytool submit "$DMG" \ APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh
--apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" \ # 可选:指定 DMG 路径
--wait APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
xcrun stapler staple "$DMG"
# 若 staple 的是 .app,再重新打 DMG,或对 DMG 再 staple
spctl --assess --type install --verbose "$DMG"
``` ```
验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上,不要只在构建机看 `spctl`): 脚本会:已 staple 则退出;否则先 staple;没有票据才要 App 专用密码并 `notarytool submit --wait --timeout 15m`,超时仍 staple。开发者后台网页没有这份 DMG 的进度页,用终端或:
1. 下载 DMG,双击打开,拖到「应用程序」,**不要**右键「打开」、不要 `xattr -d`。 ```bash
xcrun notarytool info <submission-id> --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID"
xcrun notarytool log <submission-id> --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID"
```
验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上):
1. 下载 **staple 之后** 的 DMG,双击打开,拖到「应用程序」,不要右键「打开」、不要 `xattr -d`。
2. 能启动、能连 SSH,菜单里没有 DevTools。 2. 能启动、能连 SSH,菜单里没有 DevTools。
3. `codesign --verify --deep --strict` 且 `codesign -dv` 不是 `Signature=adhoc`。 3. `codesign --verify --deep --strict` 且 `codesign -dv` 含 `Notarization Ticket=stapled`,不是 `Signature=adhoc`。
4. 删掉 Release 说明和 `release.py` 里的「右键绕过 Gatekeeper」。
没做完 staple 之前,文档继续写「未公证」。 没做完 staple 之前,对外说明不要写「已公证」。
### 4. Windows Authenticode ### 4. Windows Authenticode
+1 -1
View File
@@ -62,7 +62,7 @@ npm run acceptance:full
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。 9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。 10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
11. 每一轮只接受唯一单行命令;默认宽松模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;critical 前后端均硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。Agent 发起的 `ssh_exec` 必须带 `fromAgent`。 11. 每一轮只接受唯一单行命令;默认宽松模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;critical 前后端均硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。Agent 发起的 `ssh_exec` 必须带 `fromAgent`。
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。 12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 12 步上限。
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。 13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。 14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
15. 超长工具输出必须限长,`analyze_output` 保留总行数、错误行和首尾;关闭面板后命令与输出消失。 15. 超长工具输出必须限长,`analyze_output` 保留总行数、错误行和首尾;关闭面板后命令与输出消失。
+21 -5
View File
@@ -2,6 +2,12 @@
OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默认收起。不做堡垒审计。 OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默认收起。不做堡垒审计。
## 安装(macOS)
从 `release/v*/macos-arm64/` 打开 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
自己改代码后重新打包:先 `./scripts/build.sh mac-arm64 --quick`(本机有签名配置就会签 Developer ID),再 `./scripts/macos-notarize.sh`(会要 App 专用密码)。新打的 DMG 在公证完成前仍会被 Gatekeeper 拦截。你现在要做的步骤见 [发布指南](./release.md#macos-next-steps)。
## 快速连接 ## 快速连接
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。 按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
@@ -47,13 +53,23 @@ socks5://127.0.0.1:1080
- 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。同一窗口内只要没点「停止」,SSH 断线、保活失败、空闲挂起或重连都会继续监听(本地 / SOCKS 端口不拆,远程转发会自动重听)。点停止、关闭标签、连接面板手动断开,或退出应用,才会关闭真实 Listener。关闭标签后再开同一会话不会自动拉起转发。 - 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。同一窗口内只要没点「停止」,SSH 断线、保活失败、空闲挂起或重连都会继续监听(本地 / SOCKS 端口不拆,远程转发会自动重听)。点停止、关闭标签、连接面板手动断开,或退出应用,才会关闭真实 Listener。关闭标签后再开同一会话不会自动拉起转发。
- SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。 - SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
## 连接保活与重连
默认对齐常见 SSH / RDP 客户端,分三层,不要把「窗口还开着」理解成无视服务器超时:
- **链路心跳**:空闲时发 SSH 协议 keep-alive(不写入 Shell)。挡的是 NAT/防火墙丢掉空闲 TCP,挡不住 Bash `TMOUT` 或 Windows 空闲 GPO。
- **意外断线**:终端、SFTP 或远程桌面标签还开着时,有限次自动重连。SSH 重连后是新 Shell(长任务请在服务器上用 `tmux`/`screen`);SFTP 会回到断线前的远程目录;RDP/VNC 重连是新会话,不会发假鼠标。
- **会话策略**:远端 `TMOUT` / `logout` 只关掉那一格 Shell,点「重新打开 Shell」即可。关标签、空闲挂起不会硬续连接。
设置里的「保持心跳」和「自动重连」对应前两层;第三层尊重服务器,客户端不伪造按键。
## 远程桌面(RDP / VNC) ## 远程桌面(RDP / VNC)
欢迎页或会话入口可打开 RDP / VNC 连接对话框(仅原生桌面应用): 欢迎页或会话入口可打开 RDP / VNC 连接对话框(仅原生桌面应用):
- RDP:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。 - RDP:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。画面铺满当前窗口(任务栏贴底),连接时按窗口客户区请求分辨率;窗口缩放后再走 Display Control。
- VNC:填写主机、端口与可选密码;原生 RFB 引擎,剪贴板按 UTF-8 发送。 - VNC:填写主机、端口与可选密码;原生 RFB 引擎,剪贴板按 UTF-8 发送。
- 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或断开。 - 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或重新连接。标签还开着时网络闪断会有限次自动重连(新会话,不对抗服务器空闲注销)。
- 桌面会话不参与批量命令与终端广播。 - 桌面会话不参与批量命令与终端广播。
## 导入 OpenSSH 配置 ## 导入 OpenSSH 配置
@@ -69,7 +85,7 @@ socks5://127.0.0.1:1080
包含 `*`、`?` 或 `!` 的 Host 模式不会生成会话。导入后请检查目标、用户名、密钥路径和跳板关系再连接。 包含 `*`、`?` 或 `!` 的 Host 模式不会生成会话。导入后请检查目标、用户名、密钥路径和跳板关系再连接。
会话导出为 JSON,不包含密码、私钥口令或代理密码;私钥路径会保留,便于在同一台电脑恢复配置。 会话导出默认保存为 `opsxshell-hosts.oxs`(密码加密的主机清单,格式类似 KeePass `.kdbx`、MobaXterm `.mxtsessions`)。旧版 `.bak` 与 OpenSSH `config` 仍可导入。导出包含主机分组、密钥和密码,务必保管备份密码。
## SFTP ## SFTP
@@ -82,7 +98,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。 - 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
- 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。 - 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。
- 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。 - 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。
- SSH 断线后,SFTP 一键重连会优先回到断线前的远程目录;进行中的传输会标为失败,可重试。终端标签本身也支持一键重新连接(及可选自动重连)。 - SSH 断线后,SFTP 会按与终端相同的有限次自动重连,并优先回到断线前的远程目录;进行中的传输会标为失败,可重试。也可一键手动重连。
- 刷新时状态栏显示「正在刷新…」,传输忙时浏览类操作会立即返回忙提示而不是卡住界面。 - 刷新时状态栏显示「正在刷新…」,传输忙时浏览类操作会立即返回忙提示而不是卡住界面。
- 传输失败会在传输队列和界面通知中显示经多语言映射后的原因。 - 传输失败会在传输队列和界面通知中显示经多语言映射后的原因。
@@ -120,7 +136,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。 - 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。 - 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。 - Agent 最多执行 12 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。 - 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。 - 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
- 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。 - 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+16 -3
View File
@@ -176,10 +176,23 @@ esac
node scripts/release-preflight.mjs "$RELEASE_PLATFORM" node scripts/release-preflight.mjs "$RELEASE_PLATFORM"
log "构建原生安装包" log "构建原生安装包"
TAURI_ARGS=(--ci --target "$RUST_TARGET")
CODESIGN_WRAP_DIR=""
if [ "$RELEASE_PLATFORM" = "macos" ]; then if [ "$RELEASE_PLATFORM" = "macos" ]; then
npx tauri build --ci --target "$RUST_TARGET" --bundles app,dmg TAURI_ARGS+=(--bundles app,dmg)
else CODESIGN_WRAP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/opsx-codesign.XXXXXX")"
npx tauri build --ci --target "$RUST_TARGET" cp "$ROOT_DIR/scripts/macos-codesign-wrapper.sh" "$CODESIGN_WRAP_DIR/codesign"
chmod +x "$CODESIGN_WRAP_DIR/codesign"
export PATH="$CODESIGN_WRAP_DIR:$PATH"
log "macOS 签名使用 HTTP 时间戳(可用 CODESIGN_USE_SYSTEM_TIMESTAMP=1 关闭)"
fi
if [ -f "$ROOT_DIR/src-tauri/tauri.signing.conf.json" ]; then
TAURI_ARGS+=(--config src-tauri/tauri.signing.conf.json)
log "使用 src-tauri/tauri.signing.conf.json 签名"
fi
npx tauri build "${TAURI_ARGS[@]}"
if [ -n "$CODESIGN_WRAP_DIR" ]; then
rm -rf "$CODESIGN_WRAP_DIR"
fi fi
BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle" BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Tauri 默认 `codesign --timestamp` 走 timestamp.apple.com:443。
# 部分网络(含仅开 HTTP 代理、443 不通)会报 “The timestamp service is not available.”
# 构建时把本脚本以 `codesign` 之名放进 PATH,改为 RFC 3161 HTTP 时间戳。
# 关闭:CODESIGN_USE_SYSTEM_TIMESTAMP=1
set -euo pipefail
REAL="/usr/bin/codesign"
TS="${APPLE_TIMESTAMP_URL:-http://timestamp.apple.com/ts01}"
if [ "${CODESIGN_USE_SYSTEM_TIMESTAMP:-}" = "1" ]; then
exec "$REAL" "$@"
fi
signing=0
for a in "$@"; do
case "$a" in
--sign|-s) signing=1 ;;
esac
done
if [ "$signing" -eq 0 ]; then
exec "$REAL" "$@"
fi
new=()
found=0
for a in "$@"; do
if [ "$a" = "--timestamp" ] || [[ "$a" == --timestamp=* ]]; then
new+=("--timestamp=$TS")
found=1
else
new+=("$a")
fi
done
if [ "$found" -eq 0 ]; then
new+=("--timestamp=$TS")
fi
exec "$REAL" "${new[@]}"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# 对已经 Developer ID 签名的 DMG 做 Apple 公证并 staple。
# 已钉票则直接退出,避免重复上传。
# notarytool --wait 有时会一直 In Progress,即使苹果已 Accepted;超时后仍尝试 staple。
set -u
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(node -p "require('$ROOT_DIR/package.json').version")"
DMG="${1:-$ROOT_DIR/release/v$VERSION/macos-arm64/OpsXShell_${VERSION}_aarch64.dmg}"
APPLE_ID="${APPLE_ID:-}"
TEAM_ID="${APPLE_TEAM_ID:-B8DVUUUF6W}"
WAIT_TIMEOUT="${NOTARY_WAIT_TIMEOUT:-15m}"
: "${WAIT_TIMEOUT:=15m}"
if [ ! -f "$DMG" ]; then
echo "找不到安装包:$DMG" >&2
exit 1
fi
# 挂载中的 DMG 无法 staple / validate
if hdiutil info 2>/dev/null | grep -Fq "$DMG"; then
echo "DMG 仍挂载,请先在 Finder 弹出 /Volumes 下的 OpsXShell,再重试。" >&2
hdiutil info | grep -E "image-path|/Volumes/" || true
exit 1
fi
export http_proxy="${http_proxy:-http://127.0.0.1:7897}"
export https_proxy="${https_proxy:-http://127.0.0.1:7897}"
export HTTP_PROXY="$http_proxy"
export HTTPS_PROXY="$https_proxy"
export ALL_PROXY="${ALL_PROXY:-socks5://127.0.0.1:7897}"
export no_proxy="localhost,127.0.0.1,::1"
export NO_PROXY="$no_proxy"
already_stapled() {
xcrun stapler validate "$DMG" >/dev/null 2>&1
}
try_staple() {
echo "尝试 staple(从苹果拉票据钉进 DMG,不需要专用密码)…"
if xcrun stapler staple "$DMG"; then
xcrun stapler validate "$DMG"
echo
echo "完成(已钉票):$DMG"
return 0
fi
return 1
}
if already_stapled; then
echo "这份 DMG 已经公证并 staple,不用再执行。"
echo "$DMG"
xcrun stapler validate "$DMG"
exit 0
fi
echo "尚未钉票。先看苹果是否已经公证通过(常见于 --wait 卡住)。"
if try_staple; then
exit 0
fi
if [ -z "$APPLE_ID" ]; then
APPLE_ID="$(
osascript <<'APPLESCRIPT'
tell application "System Events"
activate
set dlg to display dialog "Apple ID(开发者账号邮箱)" default answer "" with title "OpsXShell 公证" buttons {"取消", "继续"} default button "继续" cancel button "取消"
return text returned of dlg
end tell
APPLESCRIPT
)" || exit 1
fi
echo
echo "需要提交公证。请输入 App 专用密码,不要发到聊天。"
PASSWORD="$(
osascript <<'APPLESCRIPT'
tell application "System Events"
activate
set dlg to display dialog "App 专用密码(xxxx-xxxx-xxxx-xxxx)" default answer "" with hidden answer with title "OpsXShell 公证" buttons {"取消", "提交"} default button "提交" cancel button "取消"
return text returned of dlg
end tell
APPLESCRIPT
)" || {
echo "已取消。" >&2
exit 1
}
if [ -z "$PASSWORD" ] || [ -z "$APPLE_ID" ]; then
echo "未输入 Apple ID 或专用密码,已取消。" >&2
exit 1
fi
echo "正在提交 ${DMG} (最多等 ${WAIT_TIMEOUT}) …"
set +e
SUBMIT_LOG="$(mktemp)"
xcrun notarytool submit "$DMG" \
--apple-id "$APPLE_ID" \
--password "$PASSWORD" \
--team-id "$TEAM_ID" \
--wait \
--timeout "${WAIT_TIMEOUT}" | tee "$SUBMIT_LOG"
SUBMIT_STATUS="${PIPESTATUS[0]}"
set -e
SUB_ID="$(awk '/id: / { print $2; exit }' "$SUBMIT_LOG")"
rm -f "$SUBMIT_LOG"
if [ -n "$SUB_ID" ]; then
echo "提交号:$SUB_ID"
fi
if [ "$SUBMIT_STATUS" -eq 0 ]; then
echo "notarytool 报告处理结束。"
else
echo "等待超时或状态未刷新 (exit ${SUBMIT_STATUS}). 改为直接 staple。"
fi
if try_staple; then
exit 0
fi
echo >&2
echo "staple 仍失败。可等几分钟再执行本脚本(已公证则不会重新上传)。" >&2
if [ -n "${SUB_ID:-}" ]; then
echo " xcrun notarytool info $SUB_ID --apple-id \"$APPLE_ID\" --team-id \"$TEAM_ID\"" >&2
fi
echo "不要对同一份未变化的 DMG 连续第三次提交。" >&2
exit 1
+1 -1
View File
@@ -137,7 +137,7 @@ def release_body(version: str, repo: str, assets: list[Path]) -> str:
"### macOS 安装", "### macOS 安装",
"", "",
"1. 打开 DMG,将 OpsXShell 拖入 Applications。", "1. 打开 DMG,将 OpsXShell 拖入 Applications。",
"2. 当前公开包为 ad-hoc 开发签名且尚未 Apple 公证;首次启动如被 Gatekeeper 拦截,请在 Finder 中右键应用并选择“打开”。", "2. 已 Developer ID 签名并公证、staple 的包可直接打开。若仍提示无法验证,请确认下载的是钉票后的 DMG,或在 Finder 右键应用选择“打开”。",
"", "",
"### 完整性校验", "### 完整性校验",
"", "",
+13 -21
View File
@@ -21,10 +21,15 @@ export interface SSHConnectRequest {
keepAlive?: boolean keepAlive?: boolean
} }
export interface SSHConnectResponse { /** Business command envelope. Do not invent a third failure shape beside this and thrown RDP/VNC runtime errors. */
export interface IpcEnvelope {
success: boolean success: boolean
connectionId?: string
error?: string error?: string
errorCode?: string
}
export interface SSHConnectResponse extends IpcEnvelope {
connectionId?: string
} }
export interface SSHExecRequest { export interface SSHExecRequest {
@@ -32,11 +37,9 @@ export interface SSHExecRequest {
command: string command: string
} }
export interface SSHExecResponse { export interface SSHExecResponse extends IpcEnvelope {
success: boolean
result?: string result?: string
exitCode?: number exitCode?: number
error?: string
} }
export interface SSHCreateShellRequest { export interface SSHCreateShellRequest {
@@ -83,11 +86,9 @@ export interface SSHForwardDynamicRequest {
localPort: number localPort: number
} }
export interface SSHForwardResponse { export interface SSHForwardResponse extends IpcEnvelope {
success: boolean
forwardId?: string forwardId?: string
boundPort?: number boundPort?: number
error?: string
} }
export interface SSHForwardStatus { export interface SSHForwardStatus {
@@ -105,30 +106,21 @@ export interface SSHForwardStatus {
bytesDown: number bytesDown: number
} }
export interface SSHForwardListResponse { export interface SSHForwardListResponse extends IpcEnvelope {
success: boolean
tunnels?: SSHForwardStatus[] tunnels?: SSHForwardStatus[]
error?: string
} }
// ============== Session IPC ============== // ============== Session IPC ==============
export interface SessionCreateResponse { export interface SessionCreateResponse extends IpcEnvelope {
success: boolean
session?: Session session?: Session
error?: string
} }
export interface SessionUpdateResponse { export interface SessionUpdateResponse extends IpcEnvelope {
success: boolean
session?: Session session?: Session
error?: string
} }
export interface SessionDeleteResponse { export type SessionDeleteResponse = IpcEnvelope
success: boolean
error?: string
}
// ============== Settings IPC ============== // ============== Settings IPC ==============
+180 -46
View File
@@ -118,16 +118,28 @@ pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
} }
#[tauri::command] #[tauri::command]
pub fn show_save_dialog(options: DialogOptions) -> DialogResult { pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult {
configure_dialog(rfd::FileDialog::new(), &options) let mut dialog = configure_dialog(rfd::FileDialog::new(), &options);
let missing_dir = options
.default_path
.as_deref()
.map(resolve_dialog_path)
.map(|path| path.directory.is_none())
.unwrap_or(true);
if missing_dir {
if let Ok(downloads) = app.path().download_dir() {
dialog = dialog.set_directory(downloads);
}
}
dialog
.save_file() .save_file()
.map(|path| path_result(path, false)) .map(|path| path_result(path, false))
.unwrap_or_else(canceled) .unwrap_or_else(canceled)
} }
#[tauri::command] #[tauri::command]
pub fn read_file(file_path: String) -> ReadFileResponse { pub async fn read_file(file_path: String) -> ReadFileResponse {
match fs::read(expand_home(&file_path)) { tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) {
Ok(data) => ReadFileResponse { Ok(data) => ReadFileResponse {
success: true, success: true,
data: Some(STANDARD.encode(data)), data: Some(STANDARD.encode(data)),
@@ -138,18 +150,33 @@ pub fn read_file(file_path: String) -> ReadFileResponse {
data: None, data: None,
error: Some(error.to_string()), error: Some(error.to_string()),
}, },
} })
.await
.unwrap_or_else(|_| ReadFileResponse {
success: false,
data: None,
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
})
} }
#[tauri::command] #[tauri::command]
pub fn write_file(file_path: String, data: String) -> OperationResponse { pub async fn write_file(file_path: String, data: String) -> OperationResponse {
let result = STANDARD tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) {
.decode(data) Err(error) => OperationResponse::coded(
.map_err(|error| format!("Base64 数据无效: {error}")) crate::domain::error::codes::INVALID_BASE64,
.and_then(|bytes| { error.to_string(),
fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()) ),
}); Ok(bytes) => {
operation(result) operation(fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()))
}
})
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"write file background task failed",
)
})
} }
#[tauri::command] #[tauri::command]
@@ -187,8 +214,8 @@ pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn local_read_dir(path: String) -> LocalFilesResponse { pub async fn local_read_dir(path: String) -> LocalFilesResponse {
match read_local_dir(&expand_home(&path)) { tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) {
Ok(files) => LocalFilesResponse { Ok(files) => LocalFilesResponse {
success: true, success: true,
files: Some(files), files: Some(files),
@@ -199,48 +226,89 @@ pub fn local_read_dir(path: String) -> LocalFilesResponse {
files: None, files: None,
error: Some(error), error: Some(error),
}, },
} })
.await
.unwrap_or_else(|_| LocalFilesResponse {
success: false,
files: None,
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
})
} }
#[tauri::command] #[tauri::command]
pub fn local_get_stats(path: String) -> LocalStatsResponse { pub async fn local_get_stats(path: String) -> LocalStatsResponse {
let path = expand_home(&path); tauri::async_runtime::spawn_blocking(move || {
match local_entry(&path) { let path = expand_home(&path);
Ok(stats) => LocalStatsResponse { match local_entry(&path) {
success: true, Ok(stats) => LocalStatsResponse {
stats: Some(stats), success: true,
error: None, stats: Some(stats),
}, error: None,
Err(error) => LocalStatsResponse { },
success: false, Err(error) => LocalStatsResponse {
stats: None, success: false,
error: Some(error), stats: None,
}, error: Some(error),
} },
}
})
.await
.unwrap_or_else(|_| LocalStatsResponse {
success: false,
stats: None,
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
})
} }
#[tauri::command] #[tauri::command]
pub fn local_mkdir(path: String) -> OperationResponse { pub async fn local_mkdir(path: String) -> OperationResponse {
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string())) tauri::async_runtime::spawn_blocking(move || {
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
})
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"mkdir background task failed",
)
})
} }
#[tauri::command] #[tauri::command]
pub fn local_delete(path: String) -> OperationResponse { pub async fn local_delete(path: String) -> OperationResponse {
let path = expand_home(&path); tauri::async_runtime::spawn_blocking(move || {
let result = if path.is_dir() { let path = expand_home(&path);
fs::remove_dir_all(path) let result = if path.is_dir() {
} else { fs::remove_dir_all(path)
fs::remove_file(path) } else {
}; fs::remove_file(path)
operation(result.map_err(|error| error.to_string())) };
operation(result.map_err(|error| error.to_string()))
})
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"delete background task failed",
)
})
} }
#[tauri::command] #[tauri::command]
pub fn local_rename(old_path: String, new_path: String) -> OperationResponse { pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse {
operation( tauri::async_runtime::spawn_blocking(move || {
fs::rename(expand_home(&old_path), expand_home(&new_path)) operation(
.map_err(|error| error.to_string()), fs::rename(expand_home(&old_path), expand_home(&new_path))
) .map_err(|error| error.to_string()),
)
})
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"rename background task failed",
)
})
} }
#[tauri::command] #[tauri::command]
@@ -251,12 +319,52 @@ pub fn open_external(url: String) -> Result<(), String> {
open::that(url).map_err(|error| error.to_string()) open::that(url).map_err(|error| error.to_string())
} }
#[derive(Debug, Default, PartialEq, Eq)]
struct DialogPath {
directory: Option<PathBuf>,
file_name: Option<String>,
}
fn resolve_dialog_path(raw: &str) -> DialogPath {
let expanded = expand_home(raw);
if expanded.as_os_str().is_empty() {
return DialogPath::default();
}
if expanded.is_dir() {
return DialogPath {
directory: Some(expanded),
file_name: None,
};
}
let file_name = expanded
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty());
let directory = expanded.parent().and_then(|parent| {
if parent.as_os_str().is_empty() {
None
} else {
Some(parent.to_path_buf())
}
});
DialogPath {
directory,
file_name,
}
}
fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd::FileDialog { fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd::FileDialog {
if let Some(title) = &options.title { if let Some(title) = &options.title {
dialog = dialog.set_title(title); dialog = dialog.set_title(title);
} }
if let Some(path) = &options.default_path { if let Some(path) = &options.default_path {
dialog = dialog.set_directory(expand_home(path)); let resolved = resolve_dialog_path(path);
if let Some(dir) = resolved.directory {
dialog = dialog.set_directory(dir);
}
if let Some(name) = resolved.file_name {
dialog = dialog.set_file_name(name);
}
} }
for filter in &options.filters { for filter in &options.filters {
dialog = dialog.add_filter(&filter.name, &filter.extensions); dialog = dialog.add_filter(&filter.name, &filter.extensions);
@@ -393,3 +501,29 @@ fn operation(result: Result<(), String>) -> OperationResponse {
Err(error) => OperationResponse::error(error), Err(error) => OperationResponse::error(error),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_backup_filename_fills_save_as_not_directory() {
let path = resolve_dialog_path("opsxshell-hosts.oxs");
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
assert_eq!(path.directory, None);
}
#[test]
fn full_file_path_splits_directory_and_name() {
let path = resolve_dialog_path("/tmp/opsxshell-hosts.oxs");
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
}
#[test]
fn existing_directory_is_picker_folder() {
let path = resolve_dialog_path("/tmp");
assert_eq!(path.file_name, None);
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
}
}
+45 -13
View File
@@ -29,25 +29,52 @@ pub struct KnownHostsResponse {
} }
#[tauri::command] #[tauri::command]
pub fn known_hosts_list() -> KnownHostsResponse { pub async fn known_hosts_list() -> KnownHostsResponse {
match list_entries() { tauri::async_runtime::spawn_blocking(list_entries)
Ok(entries) => KnownHostsResponse { .await
.unwrap_or_else(|_| Err(crate::domain::error::codes::TASK_JOIN.to_string()))
.map(|entries| KnownHostsResponse {
success: true, success: true,
entries: Some(entries), entries: Some(entries),
error: None, error: None,
}, })
Err(error) => KnownHostsResponse { .unwrap_or_else(|error| KnownHostsResponse {
success: false, success: false,
entries: None, entries: None,
error: Some(error), error: Some(error),
}, })
}
} }
#[tauri::command] #[tauri::command]
pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse { pub async fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
tauri::async_runtime::spawn_blocking(move || known_hosts_remove_inner(host, port))
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"known_hosts remove background task failed",
)
})
}
#[tauri::command]
pub async fn known_hosts_clear() -> OperationResponse {
tauri::async_runtime::spawn_blocking(known_hosts_clear_inner)
.await
.unwrap_or_else(|_| {
OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"known_hosts clear background task failed",
)
})
}
fn known_hosts_remove_inner(host: String, port: u16) -> OperationResponse {
let Some(path) = known_hosts_path() else { let Some(path) = known_hosts_path() else {
return OperationResponse::error("无法确定 known_hosts 路径"); return OperationResponse::coded(
crate::domain::error::codes::KNOWN_HOSTS_PATH,
"known_hosts path unavailable",
);
}; };
if !path.is_file() { if !path.is_file() {
return OperationResponse::ok(); return OperationResponse::ok();
@@ -85,10 +112,12 @@ pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
} }
} }
#[tauri::command] fn known_hosts_clear_inner() -> OperationResponse {
pub fn known_hosts_clear() -> OperationResponse {
let Some(path) = known_hosts_path() else { let Some(path) = known_hosts_path() else {
return OperationResponse::error("无法确定 known_hosts 路径"); return OperationResponse::coded(
crate::domain::error::codes::KNOWN_HOSTS_PATH,
"known_hosts path unavailable",
);
}; };
match fs::write(path, "") { match fs::write(path, "") {
Ok(()) => OperationResponse::ok(), Ok(()) => OperationResponse::ok(),
@@ -99,7 +128,10 @@ pub fn known_hosts_clear() -> OperationResponse {
#[tauri::command] #[tauri::command]
pub fn ssh_respond_host_key(request_id: String, decision: String) -> OperationResponse { pub fn ssh_respond_host_key(request_id: String, decision: String) -> OperationResponse {
let Some(parsed) = HostKeyDecision::parse(&decision) else { let Some(parsed) = HostKeyDecision::parse(&decision) else {
return OperationResponse::error("无效的主机密钥决定"); return OperationResponse::coded(
crate::domain::error::codes::INVALID_HOST_KEY_DECISION,
"invalid host key decision",
);
}; };
match respond_host_key(&request_id, parsed) { match respond_host_key(&request_id, parsed) {
Ok(()) => OperationResponse::ok(), Ok(()) => OperationResponse::ok(),
+34 -11
View File
@@ -19,7 +19,10 @@ pub async fn ssh_connect(
match tauri::async_runtime::spawn_blocking(move || service.connect(config)).await { match tauri::async_runtime::spawn_blocking(move || service.connect(config)).await {
Ok(Ok(id)) => IdResponse::connection(id), Ok(Ok(id)) => IdResponse::connection(id),
Ok(Err(error)) => IdResponse::error(error), Ok(Err(error)) => IdResponse::error(error),
Err(error) => IdResponse::error(format!("SSH 连接任务失败: {error}")), Err(_) => IdResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"SSH connect background task failed",
),
}, },
) )
} }
@@ -30,18 +33,20 @@ pub async fn ssh_disconnect(
state: State<'_, AppState>, state: State<'_, AppState>,
connection_id: String, connection_id: String,
retain_forwards: Option<bool>, retain_forwards: Option<bool>,
reason: Option<String>,
) -> Result<OperationResponse, String> { ) -> Result<OperationResponse, String> {
let service = state.ssh.clone(); let service = state.ssh.clone();
let retain = retain_forwards.unwrap_or(false); let retain = retain_forwards.unwrap_or(false);
match tauri::async_runtime::spawn_blocking(move || { match tauri::async_runtime::spawn_blocking(move || {
service.disconnect(&app, &connection_id, retain) service.disconnect(&app, &connection_id, retain, reason)
}) })
.await .await
{ {
Ok(result) => Ok(operation(result)), Ok(result) => Ok(operation(result)),
Err(error) => Ok(OperationResponse::error(format!( Err(_) => Ok(OperationResponse::coded(
"断开连接任务失败: {error}" crate::domain::error::codes::TASK_JOIN,
))), "disconnect background task failed",
)),
} }
} }
@@ -52,7 +57,8 @@ pub async fn ssh_exec(
command: String, command: String,
from_agent: Option<bool>, from_agent: Option<bool>,
) -> Result<ExecResponse, String> { ) -> Result<ExecResponse, String> {
if from_agent.unwrap_or(false) { let from_agent = from_agent.unwrap_or(false);
if from_agent {
if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) { if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) {
return Ok(ExecResponse { return Ok(ExecResponse {
success: false, success: false,
@@ -71,7 +77,11 @@ pub async fn ssh_exec(
{ {
Ok(Ok((result, exit_code))) => ExecResponse { Ok(Ok((result, exit_code))) => ExecResponse {
success: true, success: true,
result: Some(result), result: Some(if from_agent {
crate::services::ai::redact_sensitive(&result).0
} else {
result
}),
exit_code: Some(exit_code), exit_code: Some(exit_code),
error: None, error: None,
}, },
@@ -79,7 +89,11 @@ pub async fn ssh_exec(
success: false, success: false,
result: None, result: None,
exit_code: None, exit_code: None,
error: Some(error), error: Some(if from_agent {
crate::services::ai::redact_sensitive(&error).0
} else {
error
}),
}, },
Err(error) => ExecResponse { Err(error) => ExecResponse {
success: false, success: false,
@@ -118,7 +132,10 @@ pub async fn ssh_keepalive(
let service = state.ssh.clone(); let service = state.ssh.clone();
match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await { match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await {
Ok(result) => Ok(operation(result)), Ok(result) => Ok(operation(result)),
Err(error) => Ok(OperationResponse::error(format!("保活任务失败: {error}"))), Err(_) => Ok(OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"keepalive background task failed",
)),
} }
} }
@@ -146,7 +163,10 @@ pub async fn ssh_create_shell(
{ {
Ok(Ok(id)) => IdResponse::shell(id), Ok(Ok(id)) => IdResponse::shell(id),
Ok(Err(error)) => IdResponse::error(error), Ok(Err(error)) => IdResponse::error(error),
Err(error) => IdResponse::error(format!("创建 Shell 任务失败: {error}")), Err(_) => IdResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"create shell background task failed",
),
}, },
) )
} }
@@ -202,7 +222,10 @@ pub async fn sftp_connect(
{ {
Ok(Ok(id)) => IdResponse::sftp(id), Ok(Ok(id)) => IdResponse::sftp(id),
Ok(Err(error)) => IdResponse::error(error), Ok(Err(error)) => IdResponse::error(error),
Err(error) => IdResponse::error(format!("创建 SFTP 任务失败: {error}")), Err(_) => IdResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"create SFTP background task failed",
),
}, },
) )
} }
+195 -80
View File
@@ -5,154 +5,269 @@ use tauri::{AppHandle, State};
use crate::{application::AppState, domain::ssh::OperationResponse}; use crate::{application::AppState, domain::ssh::OperationResponse};
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionsResponse { pub struct SessionsResponse {
success: bool, success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
sessions: Option<Vec<Value>>, sessions: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionResponse { pub struct SessionResponse {
success: bool, success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
session: Option<Value>, session: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingResponse { pub struct SettingResponse {
success: bool, success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
value: Option<Value>, value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
}
fn join_failed() -> String {
crate::domain::error::codes::TASK_JOIN.to_string()
}
fn join_code() -> Option<String> {
Some(crate::domain::error::codes::TASK_JOIN.to_string())
} }
#[tauri::command] #[tauri::command]
pub fn session_get_all(app: AppHandle, state: State<'_, AppState>) -> SessionsResponse { pub async fn session_get_all(
match state.storage.sessions(&app) { app: AppHandle,
Ok(sessions) => SessionsResponse { state: State<'_, AppState>,
success: true, ) -> Result<SessionsResponse, ()> {
sessions: Some(sessions), let storage = state.storage.clone();
error: None, Ok(
match tauri::async_runtime::spawn_blocking(move || storage.sessions(&app)).await {
Ok(Ok(sessions)) => SessionsResponse {
success: true,
sessions: Some(sessions),
error: None,
error_code: None,
},
Ok(Err(error)) => SessionsResponse {
success: false,
sessions: None,
error: Some(error),
error_code: None,
},
Err(_) => SessionsResponse {
success: false,
sessions: None,
error: Some(join_failed()),
error_code: join_code(),
},
}, },
Err(error) => SessionsResponse { )
success: false,
sessions: None,
error: Some(error),
},
}
} }
#[tauri::command] #[tauri::command]
pub fn session_get_by_id( pub async fn session_get_by_id(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
id: String, id: String,
) -> SessionResponse { ) -> Result<SessionResponse, ()> {
match state.storage.session(&app, &id) { let storage = state.storage.clone();
Ok(session) => SessionResponse { Ok(
success: session.is_some(), match tauri::async_runtime::spawn_blocking(move || storage.session(&app, &id)).await {
session, Ok(Ok(session)) => SessionResponse {
error: None, success: session.is_some(),
session,
error: None,
error_code: None,
},
Ok(Err(error)) => SessionResponse {
success: false,
session: None,
error: Some(error),
error_code: None,
},
Err(_) => SessionResponse {
success: false,
session: None,
error: Some(join_failed()),
error_code: join_code(),
},
}, },
Err(error) => SessionResponse { )
success: false,
session: None,
error: Some(error),
},
}
} }
#[tauri::command] #[tauri::command]
pub fn session_create( pub async fn session_create(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
session: Value, session: Value,
) -> SessionResponse { ) -> Result<SessionResponse, ()> {
match state.storage.create_session(&app, session) { let storage = state.storage.clone();
Ok(session) => SessionResponse { Ok(
success: true, match tauri::async_runtime::spawn_blocking(move || storage.create_session(&app, session))
session: Some(session), .await
error: None, {
Ok(Ok(session)) => SessionResponse {
success: true,
session: Some(session),
error: None,
error_code: None,
},
Ok(Err(error)) => SessionResponse {
success: false,
session: None,
error: Some(error),
error_code: None,
},
Err(_) => SessionResponse {
success: false,
session: None,
error: Some(join_failed()),
error_code: join_code(),
},
}, },
Err(error) => SessionResponse { )
success: false,
session: None,
error: Some(error),
},
}
} }
#[tauri::command] #[tauri::command]
pub fn session_update( pub async fn session_update(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
id: String, id: String,
updates: Value, updates: Value,
) -> SessionResponse { ) -> Result<SessionResponse, ()> {
match state.storage.update_session(&app, &id, updates) { let storage = state.storage.clone();
Ok(session) => SessionResponse { Ok(
success: true, match tauri::async_runtime::spawn_blocking(move || {
session: Some(session), storage.update_session(&app, &id, updates)
error: None, })
.await
{
Ok(Ok(session)) => SessionResponse {
success: true,
session: Some(session),
error: None,
error_code: None,
},
Ok(Err(error)) => SessionResponse {
success: false,
session: None,
error: Some(error),
error_code: None,
},
Err(_) => SessionResponse {
success: false,
session: None,
error: Some(join_failed()),
error_code: join_code(),
},
}, },
Err(error) => SessionResponse { )
success: false,
session: None,
error: Some(error),
},
}
} }
#[tauri::command] #[tauri::command]
pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) -> OperationResponse { pub async fn session_delete(
match state.storage.delete_session(&app, &id) { app: AppHandle,
Ok(()) => OperationResponse::ok(), state: State<'_, AppState>,
Err(error) => OperationResponse::error(error), id: String,
} ) -> Result<OperationResponse, ()> {
let storage = state.storage.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || storage.delete_session(&app, &id)).await
{
Ok(Ok(())) => OperationResponse::ok(),
Ok(Err(error)) => OperationResponse::error(error),
Err(_) => OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"delete session background task failed",
),
},
)
} }
#[tauri::command] #[tauri::command]
pub fn session_reorder( pub async fn session_reorder(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
updates: Vec<Value>, updates: Vec<Value>,
) -> OperationResponse { ) -> Result<OperationResponse, ()> {
match state.storage.reorder_sessions(&app, updates) { let storage = state.storage.clone();
Ok(()) => OperationResponse::ok(), Ok(
Err(error) => OperationResponse::error(error), match tauri::async_runtime::spawn_blocking(move || storage.reorder_sessions(&app, updates))
} .await
{
Ok(Ok(())) => OperationResponse::ok(),
Ok(Err(error)) => OperationResponse::error(error),
Err(_) => OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"reorder sessions background task failed",
),
},
)
} }
#[tauri::command] #[tauri::command]
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse { pub async fn settings_get(
match state.storage.get_setting(&app, &key) { app: AppHandle,
Ok(value) => SettingResponse { state: State<'_, AppState>,
success: true, key: String,
value, ) -> Result<SettingResponse, ()> {
error: None, let storage = state.storage.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || storage.get_setting(&app, &key)).await {
Ok(Ok(value)) => SettingResponse {
success: true,
value,
error: None,
error_code: None,
},
Ok(Err(error)) => SettingResponse {
success: false,
value: None,
error: Some(error),
error_code: None,
},
Err(_) => SettingResponse {
success: false,
value: None,
error: Some(join_failed()),
error_code: join_code(),
},
}, },
Err(error) => SettingResponse { )
success: false,
value: None,
error: Some(error),
},
}
} }
#[tauri::command] #[tauri::command]
pub fn settings_set( pub async fn settings_set(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
key: String, key: String,
value: Value, value: Value,
) -> OperationResponse { ) -> Result<OperationResponse, ()> {
match state.storage.set_setting(&app, key, value) { let storage = state.storage.clone();
Ok(()) => OperationResponse::ok(), Ok(
Err(error) => OperationResponse::error(error), match tauri::async_runtime::spawn_blocking(move || storage.set_setting(&app, key, value))
} .await
{
Ok(Ok(())) => OperationResponse::ok(),
Ok(Err(error)) => OperationResponse::error(error),
Err(_) => OperationResponse::coded(
crate::domain::error::codes::TASK_JOIN,
"settings set background task failed",
),
},
)
} }
+10 -8
View File
@@ -1,9 +1,11 @@
use thiserror::Error; /**
* Stable IPC error codes. The renderer maps `errors:native.*` (and other
#[derive(Debug, Error)] * `errors:` keys that match these dotted paths). Command envelopes may also
pub enum DomainError { * still carry a human `error` string for logs.
#[error("resource not found: {0}")] */
NotFound(String), pub mod codes {
#[error("operation is not available: {0}")] pub const TASK_JOIN: &str = "native.taskJoin";
Unsupported(String), pub const INVALID_BASE64: &str = "native.invalidBase64";
pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath";
pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision";
} }
+29
View File
@@ -27,6 +27,8 @@ pub struct OperationResponse {
pub success: bool, pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
} }
impl OperationResponse { impl OperationResponse {
@@ -34,6 +36,7 @@ impl OperationResponse {
Self { Self {
success: true, success: true,
error: None, error: None,
error_code: None,
} }
} }
@@ -41,6 +44,15 @@ impl OperationResponse {
Self { Self {
success: false, success: false,
error: Some(error.into()), error: Some(error.into()),
error_code: None,
}
}
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
Self {
success: false,
error: Some(error.into()),
error_code: Some(code.to_string()),
} }
} }
} }
@@ -57,6 +69,8 @@ pub struct IdResponse {
pub sftp_id: Option<String>, pub sftp_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
} }
impl IdResponse { impl IdResponse {
@@ -67,6 +81,7 @@ impl IdResponse {
shell_id: None, shell_id: None,
sftp_id: None, sftp_id: None,
error: None, error: None,
error_code: None,
} }
} }
@@ -77,6 +92,7 @@ impl IdResponse {
shell_id: Some(id), shell_id: Some(id),
sftp_id: None, sftp_id: None,
error: None, error: None,
error_code: None,
} }
} }
@@ -87,6 +103,7 @@ impl IdResponse {
shell_id: None, shell_id: None,
sftp_id: Some(id), sftp_id: Some(id),
error: None, error: None,
error_code: None,
} }
} }
@@ -97,6 +114,18 @@ impl IdResponse {
shell_id: None, shell_id: None,
sftp_id: None, sftp_id: None,
error: Some(error.into()), error: Some(error.into()),
error_code: None,
}
}
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
Self {
success: false,
connection_id: None,
shell_id: None,
sftp_id: None,
error: Some(error.into()),
error_code: Some(code.to_string()),
} }
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ use url::Url;
use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig}; use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig};
use redaction::redact_sensitive; pub(crate) use redaction::redact_sensitive;
pub struct AiCompletion { pub struct AiCompletion {
pub text: String, pub text: String,
+8 -1
View File
@@ -168,14 +168,21 @@ fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
static TEST_HOME_SEQ: AtomicU64 = AtomicU64::new(0);
fn unique_home() -> PathBuf { fn unique_home() -> PathBuf {
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.expect("time") .expect("time")
.as_nanos(); .as_nanos();
let dir = std::env::temp_dir().join(format!("opsxshell-cred-test-{nanos}")); let seq = TEST_HOME_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"opsxshell-cred-test-{nanos}-{seq}-{}",
std::process::id()
));
fs::create_dir_all(&dir).expect("temp home"); fs::create_dir_all(&dir).expect("temp home");
dir dir
} }
@@ -0,0 +1,105 @@
//! Clipboard text cleanup shared by VNC CutText (and tests).
//!
//! Terminal emulators wrap clipboard paste in bracketed-paste markers
//! (`ESC [ 200 ~` … `ESC [ 201 ~`). If we inject Ctrl+V into a remote
//! terminal, those markers show up as `^[[200~…~`. Strip them on both
//! directions and type Unicode keysyms instead of a paste chord.
/// Max characters typed as RFB key events. Larger payloads stay on CutText
/// and fall back to Ctrl+V so a huge clipboard cannot stall the session.
pub const MAX_TYPE_CHARS: usize = 8192;
pub fn sanitize_clipboard_text(text: &str) -> String {
let stripped = text
.replace("\u{1b}[200~", "")
.replace("\u{1b}[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "");
let mut out = String::with_capacity(stripped.len());
let mut prev_cr = false;
for ch in stripped.chars() {
if prev_cr && ch == '\n' {
prev_cr = false;
continue;
}
prev_cr = false;
match ch {
'\n' => out.push('\n'),
'\r' => {
out.push('\n');
prev_cr = true;
}
'\t' => out.push('\t'),
'\u{feff}' => {}
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
pub fn vnc_keysym_for_char(ch: char) -> Option<u32> {
match ch {
'\n' => Some(0xff0d),
'\t' => Some(0xff09),
c if c.is_control() => None,
c => {
let codepoint = c as u32;
if codepoint < 0x100 {
Some(codepoint)
} else {
Some(0x01000000 | codepoint)
}
}
}
}
pub fn should_type_as_keys(text: &str) -> bool {
!text.is_empty() && text.chars().count() <= MAX_TYPE_CHARS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_bracketed_paste_around_ip() {
let wrapped = "\u{1b}[200~47.110.68.252\u{1b}[201~";
assert_eq!(sanitize_clipboard_text(wrapped), "47.110.68.252");
}
#[test]
fn strips_caret_notation_copied_from_screen() {
assert_eq!(
sanitize_clipboard_text("^[[200~47.110.68.252^[[201~"),
"47.110.68.252"
);
}
#[test]
fn normalizes_crlf_and_drops_other_controls() {
assert_eq!(sanitize_clipboard_text("a\r\nb\rc\u{0007}d"), "a\nb\ncd");
}
#[test]
fn keeps_cjk_and_tabs() {
assert_eq!(sanitize_clipboard_text("主机\t名称"), "主机\t名称");
}
#[test]
fn keysym_for_ascii_and_return() {
assert_eq!(vnc_keysym_for_char('4'), Some(0x34));
assert_eq!(vnc_keysym_for_char('.'), Some(0x2e));
assert_eq!(vnc_keysym_for_char('\n'), Some(0xff0d));
assert_eq!(vnc_keysym_for_char('中'), Some(0x01004e2d));
}
#[test]
fn types_small_payloads_not_huge_ones() {
assert!(should_type_as_keys("47.110.68.252"));
let huge = "x".repeat(MAX_TYPE_CHARS + 1);
assert!(!should_type_as_keys(&huge));
assert!(!should_type_as_keys(""));
}
}
@@ -17,6 +17,8 @@ use tracing::{debug, warn};
use crate::domain::desktop::RdpClipboardEvent; use crate::domain::desktop::RdpClipboardEvent;
use super::clipboard_text::sanitize_clipboard_text;
#[derive(Debug)] #[derive(Debug)]
pub struct OpsxCliprdrBackend { pub struct OpsxCliprdrBackend {
app: AppHandle, app: AppHandle,
@@ -144,6 +146,7 @@ impl CliprdrBackend for OpsxCliprdrBackend {
.to_unicode_string() .to_unicode_string()
.or_else(|_| response.to_string()) .or_else(|_| response.to_string())
.unwrap_or_default(); .unwrap_or_default();
let text = sanitize_clipboard_text(&text);
if text.is_empty() { if text.is_empty() {
return; return;
} }
@@ -179,6 +182,10 @@ pub fn queue_local_clipboard_set(
want_paste_inject: &AtomicBool, want_paste_inject: &AtomicBool,
text: String, text: String,
) { ) {
let text = sanitize_clipboard_text(&text);
if text.is_empty() {
return;
}
if let Ok(mut guard) = local_text.lock() { if let Ok(mut guard) = local_text.lock() {
*guard = Some(text); *guard = Some(text);
} }
+1
View File
@@ -3,6 +3,7 @@
//! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime //! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime
//! state lives only in memory; disconnect cancels the worker thread. //! state lives only in memory; disconnect cancels the worker thread.
mod clipboard_text;
mod cliprdr_backend; mod cliprdr_backend;
mod common; mod common;
mod input_map; mod input_map;
+66 -16
View File
@@ -16,6 +16,7 @@ use vnc::{
use crate::domain::desktop::{VncClipboardEvent, VncConfig}; use crate::domain::desktop::{VncClipboardEvent, VncConfig};
use super::clipboard_text::{sanitize_clipboard_text, should_type_as_keys, vnc_keysym_for_char};
use super::common::{coalesce_desktop_inputs, emit_frame, emit_status, DesktopInput}; use super::common::{coalesce_desktop_inputs, emit_frame, emit_status, DesktopInput};
use super::input_map; use super::input_map;
@@ -125,6 +126,7 @@ async fn run_session_async(
let mut fb_height = height; let mut fb_height = height;
let mut framebuffer: Vec<u8> = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4]; let mut framebuffer: Vec<u8> = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4];
let mut buttons: u8 = 0; let mut buttons: u8 = 0;
let mut last_outbound_cut: Option<String> = None;
let mut last_refresh = std::time::Instant::now(); let mut last_refresh = std::time::Instant::now();
emit_status( emit_status(
@@ -152,7 +154,7 @@ async fn run_session_async(
let _ = vnc.input(X11Event::FullRefresh).await; let _ = vnc.input(X11Event::FullRefresh).await;
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
drain_input(&vnc, &input, &mut buttons).await?; drain_input(&vnc, &input, &mut buttons, &mut last_outbound_cut).await?;
match vnc.poll_event().await { match vnc.poll_event().await {
Ok(Some(event)) => { Ok(Some(event)) => {
@@ -163,6 +165,7 @@ async fn run_session_async(
&mut fb_width, &mut fb_width,
&mut fb_height, &mut fb_height,
&mut framebuffer, &mut framebuffer,
&mut last_outbound_cut,
)?; )?;
} }
Ok(None) => { Ok(None) => {
@@ -187,6 +190,7 @@ async fn drain_input(
vnc: &vnc::VncClient, vnc: &vnc::VncClient,
input: &Mutex<Vec<DesktopInput>>, input: &Mutex<Vec<DesktopInput>>,
buttons: &mut u8, buttons: &mut u8,
last_outbound_cut: &mut Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let queued = { let queued = {
let mut guard = input let mut guard = input
@@ -233,15 +237,20 @@ async fn drain_input(
.map_err(|error| format!("发送键盘事件失败: {error}"))?; .map_err(|error| format!("发送键盘事件失败: {error}"))?;
} }
DesktopInput::ClipboardSet { text } => { DesktopInput::ClipboardSet { text } => {
let cut = sanitize_clipboard_text(&text);
if cut.is_empty() {
continue;
}
// Classic RFB CutText is Latin-1, but vnc-rs writes String::as_bytes() // Classic RFB CutText is Latin-1, but vnc-rs writes String::as_bytes()
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers; // (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers.
// fall back to Latin-1 substitution only when the payload is pure BMP Latin-1. *last_outbound_cut = Some(cut.clone());
let cut = prepare_cut_text(&text); vnc.input(X11Event::CopyText(cut.clone()))
vnc.input(X11Event::CopyText(cut))
.await .await
.map_err(|error| format!("发送剪贴板失败: {error}"))?; .map_err(|error| format!("发送剪贴板失败: {error}"))?;
// Most servers only update remote clipboard; inject Ctrl+V for paste UX. // Do not inject Ctrl+V: remote terminals wrap clipboard paste in
inject_remote_paste(vnc).await?; // bracketed-paste (`ESC[200~` … `ESC[201~`), which shows up as
// `^[[200~47.110.68.252~`. Type Unicode keysyms instead.
inject_clipboard_into_remote(vnc, &cut).await?;
} }
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {} DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
} }
@@ -249,7 +258,46 @@ async fn drain_input(
Ok(()) Ok(())
} }
async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> { async fn inject_clipboard_into_remote(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
release_modifier_keys(vnc).await?;
if should_type_as_keys(text) {
inject_text_as_keys(vnc, text).await
} else {
inject_ctrl_v(vnc).await
}
}
/// Cmd/Ctrl was already forwarded as Super/Control-down before the V key.
/// Release modifiers so typed characters are not Super+digit / Ctrl+letter.
async fn release_modifier_keys(vnc: &vnc::VncClient) -> Result<(), String> {
const MODIFIERS: [u32; 8] = [
0xffe3, // Control_L
0xffe4, // Control_R
0xffe1, // Shift_L
0xffe2, // Shift_R
0xffe9, // Alt_L
0xffea, // Alt_R
0xffeb, // Super_L
0xffec, // Super_R
];
for keycode in MODIFIERS {
send_key(vnc, keycode, false).await?;
}
Ok(())
}
async fn inject_text_as_keys(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
for ch in text.chars() {
let Some(keycode) = vnc_keysym_for_char(ch) else {
continue;
};
send_key(vnc, keycode, true).await?;
send_key(vnc, keycode, false).await?;
}
Ok(())
}
async fn inject_ctrl_v(vnc: &vnc::VncClient) -> Result<(), String> {
const CONTROL: u32 = 0xffe3; const CONTROL: u32 = 0xffe3;
const KEY_V: u32 = 0x0076; const KEY_V: u32 = 0x0076;
for (keycode, down) in [ for (keycode, down) in [
@@ -258,18 +306,15 @@ async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
(KEY_V, false), (KEY_V, false),
(CONTROL, false), (CONTROL, false),
] { ] {
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down })) send_key(vnc, keycode, down).await?;
.await
.map_err(|error| format!("注入粘贴快捷键失败: {error}"))?;
} }
Ok(()) Ok(())
} }
fn prepare_cut_text(text: &str) -> String { async fn send_key(vnc: &vnc::VncClient, keycode: u32, down: bool) -> Result<(), String> {
// Send UTF-8 as-is. Classic Latin-1 servers may mojibake non-ASCII, but replacing vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
// CJK with '?' is worse for OpsXShell's primary locale. Extended Clipboard (UTF-8) .await
// needs a vnc-rs protocol upgrade beyond CopyText. .map_err(|error| format!("发送键盘事件失败: {error}"))
text.to_string()
} }
fn decode_tight_jpeg(data: &[u8]) -> Result<Vec<u8>, String> { fn decode_tight_jpeg(data: &[u8]) -> Result<Vec<u8>, String> {
@@ -292,6 +337,7 @@ fn handle_vnc_event(
fb_width: &mut u16, fb_width: &mut u16,
fb_height: &mut u16, fb_height: &mut u16,
framebuffer: &mut Vec<u8>, framebuffer: &mut Vec<u8>,
last_outbound_cut: &mut Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
match event { match event {
VncEvent::SetResolution(screen) => { VncEvent::SetResolution(screen) => {
@@ -372,9 +418,13 @@ fn handle_vnc_event(
}, },
VncEvent::Error(message) => return Err(message), VncEvent::Error(message) => return Err(message),
VncEvent::Text(text) => { VncEvent::Text(text) => {
let text = sanitize_clipboard_text(&text);
if text.is_empty() { if text.is_empty() {
return Ok(()); return Ok(());
} }
if last_outbound_cut.as_ref() == Some(&text) {
return Ok(());
}
if let Ok(mut clipboard) = arboard::Clipboard::new() { if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(text.clone()); let _ = clipboard.set_text(text.clone());
} }
+13 -11
View File
@@ -96,7 +96,12 @@ impl SshService {
app: &AppHandle, app: &AppHandle,
connection_id: &str, connection_id: &str,
retain_forwards: bool, retain_forwards: bool,
reason: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let reason = reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "connection closed".to_string());
// Dedicated tunnel sessions keep listening across terminal flaps when // Dedicated tunnel sessions keep listening across terminal flaps when
// retain_forwards is true. Explicit teardown (tab close / panel disconnect) // retain_forwards is true. Explicit teardown (tab close / panel disconnect)
// must still close real Listeners. // must still close real Listeners.
@@ -123,7 +128,7 @@ impl SshService {
for shell_id in shell_ids { for shell_id in shell_ids {
if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) { if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) {
let _ = shell.channel.close(); let _ = shell.channel.close();
let _ = shell.session.disconnect(None, "closed by user", None); let _ = shell.session.disconnect(None, &reason, None);
} }
} }
let sftp_ids = { let sftp_ids = {
@@ -148,13 +153,13 @@ impl SshService {
.map_err(lock_error)? .map_err(lock_error)?
.remove(connection_id) .remove(connection_id)
{ {
let _ = connection.session.disconnect(None, "closed by user", None); let _ = connection.session.disconnect(None, &reason, None);
} }
let _ = app.emit( let _ = app.emit(
"ssh:connection-closed", "ssh:connection-closed",
ConnectionClosedEvent { ConnectionClosedEvent {
connection_id: connection_id.to_string(), connection_id: connection_id.to_string(),
reason: "closed by user".to_string(), reason,
}, },
); );
Ok(()) Ok(())
@@ -657,15 +662,12 @@ fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String
|| output.contains("auto-logout") || output.contains("auto-logout")
|| output.contains("auto logout"); || output.contains("auto logout");
if idle_timeout { if idle_timeout {
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true); return ("shell.idleTimeout".to_string(), true);
} }
if let Some(error) = read_error { if let Some(error) = read_error {
return (format!("远端 Shell 读取失败: {error}"), false); return (format!("shell.readFailed: {error}"), false);
} }
( ("shell.exited".to_string(), false)
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
false,
)
} }
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> { fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
@@ -814,7 +816,7 @@ mod tests {
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None); classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
assert!(idle_timeout); assert!(idle_timeout);
assert!(reason.contains("TMOUT")); assert_eq!(reason, "shell.idleTimeout");
} }
#[test] #[test]
@@ -831,7 +833,7 @@ mod tests {
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None); let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
assert!(!idle_timeout); assert!(!idle_timeout);
assert!(reason.contains("Shell 已退出")); assert_eq!(reason, "shell.exited");
} }
#[test] #[test]
+15 -1
View File
@@ -34,7 +34,21 @@
"targets": "all", "targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"macOS": { "macOS": {
"signingIdentity": "-" "signingIdentity": "-",
"dmg": {
"windowSize": {
"width": 720,
"height": 440
},
"appPosition": {
"x": 180,
"y": 190
},
"applicationFolderPosition": {
"x": 520,
"y": 190
}
}
}, },
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal", "shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
+33 -17
View File
@@ -1,9 +1,4 @@
import type { import type { AiBillingMode, AiModelConfig, AiProviderConfig, AiProviderFamily } from '../../shared/types/ai.types'
AiBillingMode,
AiModelConfig,
AiProviderConfig,
AiProviderFamily,
} from '../../shared/types/ai.types'
export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled' | 'hasApiKey'> { export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled' | 'hasApiKey'> {
id: string id: string
@@ -11,10 +6,7 @@ export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled
family: AiProviderFamily family: AiProviderFamily
} }
export const AI_PROVIDER_FAMILY_META: Record< export const AI_PROVIDER_FAMILY_META: Record<AiProviderFamily, { label: string; description: string }> = {
AiProviderFamily,
{ label: string; description: string }
> = {
openai_compatible: { openai_compatible: {
label: 'OpenAI compatible', label: 'OpenAI compatible',
description: 'payg', description: 'payg',
@@ -73,7 +65,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('deepseek-v4-pro', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 'reasoning'), model('deepseek-v4-pro', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 'reasoning'),
model('deepseek-v4-flash', 'deepseek-v4-flash', 'DeepSeek V4 Flash'), model('deepseek-flash', 'deepseek-flash', 'DeepSeek Flash (V4.1)'),
], ],
defaultModelId: 'deepseek-v4-pro', defaultModelId: 'deepseek-v4-pro',
}, },
@@ -88,12 +80,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
baseUrl: 'https://api.openai.com/v1', baseUrl: 'https://api.openai.com/v1',
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('gpt-6-astra', 'gpt-6-astra', 'GPT-6 Astra', 'reasoning'),
model('gpt-5.6-sol', 'gpt-5.6-sol', 'GPT-5.6 Sol', 'reasoning'), model('gpt-5.6-sol', 'gpt-5.6-sol', 'GPT-5.6 Sol', 'reasoning'),
model('gpt-5.6-terra', 'gpt-5.6-terra', 'GPT-5.6 Terra', 'reasoning'), model('gpt-5.6-terra', 'gpt-5.6-terra', 'GPT-5.6 Terra', 'reasoning'),
model('gpt-5.6-luna', 'gpt-5.6-luna', 'GPT-5.6 Luna'), model('gpt-5.6-luna', 'gpt-5.6-luna', 'GPT-5.6 Luna'),
model('gpt-5.5', 'gpt-5.5', 'GPT-5.5', 'reasoning'), model('gpt-5.5', 'gpt-5.5', 'GPT-5.5', 'reasoning'),
], ],
defaultModelId: 'gpt-5.6-sol', defaultModelId: 'gpt-6-astra',
}, },
{ {
id: 'qwen', id: 'qwen',
@@ -107,6 +100,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('qwen3.8-max', 'qwen3.8-max', 'Qwen3.8 Max', 'reasoning'), model('qwen3.8-max', 'qwen3.8-max', 'Qwen3.8 Max', 'reasoning'),
model('qwen3.8-flash', 'qwen3.8-flash', 'Qwen3.8 Flash'),
model('qwen3.7-plus', 'qwen3.7-plus', 'Qwen3.7 Plus', 'reasoning'), model('qwen3.7-plus', 'qwen3.7-plus', 'Qwen3.7 Plus', 'reasoning'),
model('qwen3.7-flash', 'qwen3.7-flash', 'Qwen3.7 Flash'), model('qwen3.7-flash', 'qwen3.7-flash', 'Qwen3.7 Flash'),
], ],
@@ -141,7 +135,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('mimo-v2.5-pro', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'reasoning'), model('mimo-v2.5-pro', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'reasoning'),
model('mimo-v2.5-flash', 'mimo-v2.5-flash', 'MiMo V2.5 Flash'), model('mimo-v2.5', 'mimo-v2.5', 'MiMo V2.5'),
], ],
defaultModelId: 'mimo-v2.5-pro', defaultModelId: 'mimo-v2.5-pro',
}, },
@@ -175,11 +169,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('claude-fable-5-1', 'claude-fable-5-1', 'Claude Fable 5.1', 'reasoning'),
model('claude-fable-5', 'claude-fable-5', 'Claude Fable 5', 'reasoning'),
model('claude-opus-5', 'claude-opus-5', 'Claude Opus 5', 'reasoning'), model('claude-opus-5', 'claude-opus-5', 'Claude Opus 5', 'reasoning'),
model('claude-sonnet-5', 'claude-sonnet-5', 'Claude Sonnet 5', 'reasoning'), model('claude-sonnet-5', 'claude-sonnet-5', 'Claude Sonnet 5', 'reasoning'),
model('claude-haiku-4-5', 'claude-haiku-4-5', 'Claude Haiku 4.5'), model('claude-haiku-4-5', 'claude-haiku-4-5', 'Claude Haiku 4.5'),
], ],
defaultModelId: 'claude-opus-5', defaultModelId: 'claude-fable-5-1',
}, },
{ {
id: 'google', id: 'google',
@@ -193,8 +189,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('gemini-3.1-pro-preview', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro', 'reasoning'), model('gemini-3.1-pro-preview', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro', 'reasoning'),
model('gemini-3-flash-preview', 'gemini-3-flash-preview', 'Gemini 3 Flash'), model('gemini-3.6-flash', 'gemini-3.6-flash', 'Gemini 3.6 Flash'),
model('gemini-3.1-flash-lite', 'gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite'), model('gemini-3.5-flash', 'gemini-3.5-flash', 'Gemini 3.5 Flash'),
model('gemini-3.5-flash-lite', 'gemini-3.5-flash-lite', 'Gemini 3.5 Flash-Lite'),
], ],
defaultModelId: 'gemini-3.1-pro-preview', defaultModelId: 'gemini-3.1-pro-preview',
}, },
@@ -211,6 +208,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
allowInsecureHttp: false, allowInsecureHttp: false,
models: [ models: [
model('glm-5.3-plan', 'glm-5.3', 'GLM-5.3', 'plan', 'plan', {
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
}),
model('glm-5.2-plan', 'glm-5.2', 'GLM-5.2', 'plan', 'plan', { model('glm-5.2-plan', 'glm-5.2', 'GLM-5.2', 'plan', 'plan', {
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
}), }),
@@ -221,7 +221,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
baseUrl: 'https://open.bigmodel.cn/api/paas/v4', baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
}), }),
], ],
defaultModelId: 'glm-5.2-plan', defaultModelId: 'glm-5.3-plan',
}, },
{ {
id: 'minimax-plan', id: 'minimax-plan',
@@ -257,6 +257,22 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
], ],
defaultModelId: 'kimi-k3-plan', defaultModelId: 'kimi-k3-plan',
}, },
{
id: 'mimo-plan',
name: 'Xiaomi MiMo',
description: 'Token Plan 包月',
family: 'plan',
presetId: 'mimo-plan',
protocol: 'openai-chat',
authStyle: 'bearer',
baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1',
allowInsecureHttp: false,
models: [
model('mimo-v2.5-pro-plan', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'plan', 'plan'),
model('mimo-v2.5-plan', 'mimo-v2.5', 'MiMo V2.5', 'plan', 'plan'),
],
defaultModelId: 'mimo-v2.5-pro-plan',
},
// ——— 私有化 ——— // ——— 私有化 ———
{ {
+5 -1
View File
@@ -14,6 +14,7 @@ import AppShell from './AppShell'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores' import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores'
import { getConnectionStore } from '../stores/connection' import { getConnectionStore } from '../stores/connection'
import { classifyDisconnectReason, shouldForgetNativeConnection } from '../utils/disconnectReason'
import { useSettingsStore } from '../stores/settingsStore' import { useSettingsStore } from '../stores/settingsStore'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import { useToast } from '../hooks/useToast' import { useToast } from '../hooks/useToast'
@@ -164,7 +165,8 @@ function App() {
// Keep renderer state authoritative when the native SSH service closes a // Keep renderer state authoritative when the native SSH service closes a
// connection (network loss, keepalive failure, or remote shell failure). // connection (network loss, keepalive failure, or remote shell failure).
useEffect(() => { useEffect(() => {
const handleConnectionClosed = (_event: unknown, connectionId: string) => { const handleConnectionClosed = (_event: unknown, connectionId: string, reason?: string) => {
if (!shouldForgetNativeConnection(classifyDisconnectReason(reason))) return
getConnectionStore().forgetNativeConnection(connectionId) getConnectionStore().forgetNativeConnection(connectionId)
const { tabs, updateTab } = useTabStore.getState() const { tabs, updateTab } = useTabStore.getState()
for (const tab of tabs) { for (const tab of tabs) {
@@ -210,6 +212,7 @@ function App() {
title: t('sessions:importExport.exportTitle'), title: t('sessions:importExport.exportTitle'),
defaultPath: t('sessions:importExport.exportDefaultPath'), defaultPath: t('sessions:importExport.exportDefaultPath'),
filters: [ filters: [
{ name: t('sessions:importExport.exportFilterOxs'), extensions: ['oxs'] },
{ name: t('sessions:importExport.exportFilterBak'), extensions: ['bak'] }, { name: t('sessions:importExport.exportFilterBak'), extensions: ['bak'] },
{ name: t('sessions:importExport.exportFilterJson'), extensions: ['json'] }, { name: t('sessions:importExport.exportFilterJson'), extensions: ['json'] },
], ],
@@ -291,6 +294,7 @@ function App() {
const result = await window.opsxApi?.showOpenDialog({ const result = await window.opsxApi?.showOpenDialog({
title: t('sessions:importExport.importTitle'), title: t('sessions:importExport.importTitle'),
filters: [ filters: [
{ name: t('sessions:importExport.importFilterOxs'), extensions: ['oxs'] },
{ name: t('sessions:importExport.importFilterBak'), extensions: ['bak'] }, { name: t('sessions:importExport.importFilterBak'), extensions: ['bak'] },
{ name: t('sessions:importExport.importFilterJson'), extensions: ['json'] }, { name: t('sessions:importExport.importFilterJson'), extensions: ['json'] },
{ name: t('sessions:importExport.importFilterSshConfig'), extensions: ['conf', 'config'] }, { name: t('sessions:importExport.importFilterSshConfig'), extensions: ['conf', 'config'] },
+6 -15
View File
@@ -3,7 +3,7 @@
* *
* 窗口拖拽约定(Tauri overlay title bar): * 窗口拖拽约定(Tauri overlay title bar):
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放 * - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次 * - 不要再叠加 startDragging() 或 CSS app-region: drag,两者会抢鼠标导致窗口拖不动
* - 标签栏空白区也由 Tauri 原生拖拽区处理 * - 标签栏空白区也由 Tauri 原生拖拽区处理
* *
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。 * RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
@@ -19,7 +19,6 @@ import StatusBar from '../components/layout/StatusBar'
import type { Session } from '../stores' import type { Session } from '../stores'
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore' import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
import { getDesktopPlatform } from '../platform/runtime' import { getDesktopPlatform } from '../platform/runtime'
import { startWindowDragging } from '../platform/tauri'
export interface AppShellProps { export interface AppShellProps {
activeTabId: string | null activeTabId: string | null
@@ -47,10 +46,6 @@ function TitleBar() {
return ( return (
<header <header
data-tauri-drag-region data-tauri-drag-region
onMouseDown={(event) => {
if (event.button !== 0) return
void startWindowDragging()
}}
className="bg-dark-900/95 relative z-20 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800" className="bg-dark-900/95 relative z-20 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
> >
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden /> <div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
@@ -127,11 +122,7 @@ export default function AppShell({
const desktopFullscreen = useDesktopFullscreenStore((state) => state.active) const desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
return ( return (
<div <div className={`app-shell text-content-primary ${desktopFullscreen ? 'bg-black' : 'bg-dark-900'}`}>
className={`flex h-screen flex-col overflow-hidden text-content-primary ${
desktopFullscreen ? 'bg-black' : 'bg-dark-900'
}`}
>
{!desktopFullscreen && <TitleBar />} {!desktopFullscreen && <TitleBar />}
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
@@ -145,7 +136,7 @@ export default function AppShell({
{!desktopFullscreen && ( {!desktopFullscreen && (
<aside <aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${ className={`titlebar-no-drag h-full min-h-0 flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}` ? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: desktopSidebarHidden : desktopSidebarHidden
@@ -163,7 +154,7 @@ export default function AppShell({
</aside> </aside>
)} )}
<main className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}> <main className={`flex min-h-0 min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}>
{!desktopFullscreen && ( {!desktopFullscreen && (
<div className="flex items-stretch"> <div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />} {isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
@@ -188,12 +179,12 @@ export default function AppShell({
</div> </div>
{!desktopFullscreen && ( {!desktopFullscreen && (
<div className="titlebar-no-drag"> <div className="titlebar-no-drag flex-shrink-0">
<StatusBar /> <StatusBar />
</div> </div>
)} )}
<div className="titlebar-no-drag">{children}</div> <div className="contents">{children}</div>
</div> </div>
) )
} }
+93 -73
View File
@@ -7,7 +7,6 @@ import {
Loader2, Loader2,
Plus, Plus,
Send, Send,
ShieldAlert,
Sparkles, Sparkles,
Square, Square,
TerminalSquare, TerminalSquare,
@@ -31,7 +30,7 @@ import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore' import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown' import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n' import i18n from '../../i18n'
import { translateErrorMessage } from '../../i18n/format' import { isSshExecCancelled, translateErrorMessage } from '../../i18n/format'
export interface TerminalAiContext { export interface TerminalAiContext {
selection: string selection: string
@@ -64,9 +63,10 @@ interface ThreadMessage {
steps?: AgentStep[] steps?: AgentStep[]
} }
const MAX_AGENT_STEPS = 8 const MAX_AGENT_STEPS = 12
const MAX_TOOL_OUTPUT_CHARS = 12_000 const MAX_TOOL_OUTPUT_CHARS = 8_000
const MAX_AGENT_CONTEXT_CHARS = 48_000 const MAX_SUMMARIZE_CHARS = 6_000
const MAX_AGENT_CONTEXT_CHARS = 32_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed'] const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
function truncateMiddle(value: string, limit: number): string { function truncateMiddle(value: string, limit: number): string {
@@ -80,6 +80,27 @@ function messageId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
} }
/** Collapse "DeepSeek · DeepSeek V4 Pro" when the model label already names the vendor. */
function modelSubtitle(providerName: string, modelLabel: string): string {
const provider = providerName.trim()
const model = modelLabel.trim()
if (!provider) return model
if (!model) return provider
if (model.toLowerCase().startsWith(provider.toLowerCase())) return model
return `${provider} · ${model}`
}
/** Long command / viewport dumps: keep errors + head/tail instead of the raw wall of text. */
function compactToolOutput(raw: string, limit = MAX_TOOL_OUTPUT_CHARS): string {
const text = raw.trim()
if (!text) return text
const lines = text.split('\n').length
if (text.length > Math.floor(limit / 2) || lines > 80) {
return truncateMiddle(analyzeAgentOutput(text).text, limit)
}
return text
}
function agentPrompt( function agentPrompt(
task: string, task: string,
targetLabel: string, targetLabel: string,
@@ -91,7 +112,7 @@ function agentPrompt(
const checkpoint = const checkpoint =
consecutiveFailures >= 2 consecutiveFailures >= 2
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。` ? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
: round === 4 : round === Math.floor(MAX_AGENT_STEPS / 2)
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。' ? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
: round === MAX_AGENT_STEPS : round === MAX_AGENT_STEPS
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。' ? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
@@ -165,6 +186,7 @@ export default function AiTerminalPanel({
const [running, setRunning] = useState(false) const [running, setRunning] = useState(false)
const [copiedId, setCopiedId] = useState('') const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0) const runIdRef = useRef(0)
const runConnectionIdRef = useRef<string | null>(null)
const sessionAllowRef = useRef(new Set<string>()) const sessionAllowRef = useRef(new Set<string>())
const timelineRef = useRef<HTMLDivElement>(null) const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('') const initialSelectionRef = useRef('')
@@ -229,6 +251,8 @@ export default function AiTerminalPanel({
const runId = runIdRef.current + 1 const runId = runIdRef.current + 1
runIdRef.current = runId runIdRef.current = runId
const runConnectionId = connectionId
runConnectionIdRef.current = runConnectionId
if (!isFollowUp) { if (!isFollowUp) {
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS) initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
} }
@@ -351,18 +375,15 @@ export default function AiTerminalPanel({
} }
setLiveSteps([...completedSteps, proposed]) setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true }) const executed = await window.opsxApi.sshExec(runConnectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) { if (!executed.success && isSshExecCancelled(executed.error)) {
return return
} }
const rawOutput = executed.success const rawOutput = executed.success
? executed.result?.trim() || i18n.t('ai:error.execNoOutput') ? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') }) : i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
const output = const output = compactToolOutput(rawOutput)
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
: rawOutput
const step: AgentStep = { const step: AgentStep = {
round, round,
command, command,
@@ -388,6 +409,7 @@ export default function AiTerminalPanel({
} }
} finally { } finally {
if (runIdRef.current === runId) { if (runIdRef.current === runId) {
runConnectionIdRef.current = null
setRunning(false) setRunning(false)
} }
} }
@@ -426,7 +448,7 @@ export default function AiTerminalPanel({
model, model,
action: 'explain', action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`, prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS), context: compactToolOutput(context, MAX_SUMMARIZE_CHARS),
}) })
if (runIdRef.current !== runId) return if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) { if (!completion.success || !completion.text) {
@@ -446,8 +468,10 @@ export default function AiTerminalPanel({
const stopAgent = () => { const stopAgent = () => {
runIdRef.current += 1 runIdRef.current += 1
dismissAgentRisk() dismissAgentRisk()
if (connectionId) { const runConnectionId = runConnectionIdRef.current
void window.opsxApi.sshExecCancel(connectionId) runConnectionIdRef.current = null
if (runConnectionId) {
void window.opsxApi.sshExecCancel(runConnectionId)
} }
setRunning(false) setRunning(false)
const snapped = liveStepsRef.current const snapped = liveStepsRef.current
@@ -470,73 +494,69 @@ export default function AiTerminalPanel({
} }
return ( return (
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850"> <aside className="app-agent-panel">
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5"> <header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-accent-blue" /> <Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div> <div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
<div className="truncate text-[10px] text-txt-faint"> <div className="mt-0.5 text-[10px] leading-snug text-txt-faint">
{provider && model <div
? t('panel.modelConfigured', { provider: provider.name, model: model.label }) className="truncate"
: t('panel.modelNotConfigured')} title={provider && model ? `${provider.name} · ${model.label}` : t('panel.modelNotConfigured')}
{' · '} >
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')} {provider && model ? modelSubtitle(provider.name, model.label) : t('panel.modelNotConfigured')}
</div>
<div className="break-all" title={sshReady ? targetLabel : undefined}>
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}> <div className="flex flex-shrink-0 items-center gap-0.5">
{SAFETY_MODE_ORDER.map((mode) => { <div role="radiogroup" aria-label={t('safety.aria')} className="flex items-center">
const selected = safetyMode === mode {SAFETY_MODE_ORDER.map((mode) => {
return ( const selected = safetyMode === mode
<button return (
key={mode} <button
type="button" key={mode}
role="radio" type="button"
aria-checked={selected} role="radio"
aria-label={t(`safety.${mode}.label`)} aria-checked={selected}
disabled={running} aria-label={t(`safety.${mode}.label`)}
title={t(`safety.${mode}.description`)} disabled={running}
onClick={() => void setSetting('agentSafetyMode', mode)} title={t(`safety.${mode}.description`)}
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${ onClick={() => void setSetting('agentSafetyMode', mode)}
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary' className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
}`} selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
> }`}
{t(`safety.${mode}.label`)} >
</button> {t(`safety.${mode}.label`)}
) </button>
})} )
})}
</div>
{(messages.length > 0 || liveSteps.length > 0) && (
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
)}
<button <button
type="button" type="button"
aria-pressed={confirmAllCommands} title={t('panel.close')}
disabled={running} aria-label={t('panel.close')}
title={t('safety.confirmAllTitle')} onClick={onClose}
aria-label={t('safety.confirmAllTitle')} className="rounded p-1 text-txt-muted hover:bg-dark-700"
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
className={`ml-1 rounded p-1 disabled:opacity-50 ${
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
}`}
> >
<ShieldAlert className="h-3.5 w-3.5" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<button
type="button"
title={t('panel.close')}
onClick={onClose}
className="rounded p-1 text-txt-muted hover:bg-dark-700"
>
<X className="h-4 w-4" />
</button>
</div> </div>
</header> </header>
+26
View File
@@ -0,0 +1,26 @@
import { type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
/**
* Full-window layer (modal, palette, drawer). Portaled to document.body so
* TabContent overflow / containing blocks cannot clip it or steal title-bar
* hit testing. Only mount when `open` — a leftover inset-0 node blocks
* terminal input and window dragging.
*/
export interface AppOverlayProps {
open: boolean
children: ReactNode
className?: string
onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void
}
export default function AppOverlay({ open, children, className = '', onMouseDown }: AppOverlayProps) {
if (!open || typeof document === 'undefined') return null
return createPortal(
<div className={`app-overlay ${className}`.trim()} data-app-overlay="" onMouseDown={onMouseDown}>
{children}
</div>,
document.body
)
}
+5 -8
View File
@@ -19,6 +19,7 @@ import {
} from '../../stores' } from '../../stores'
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab' import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
import { buildOpenTabPayload } from '../../utils/sessionProtocol' import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import AppOverlay from './AppOverlay'
// ─── 类型定义 ──────────────────────────────────────────────────────── // ─── 类型定义 ────────────────────────────────────────────────────────
@@ -309,13 +310,8 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div <AppOverlay open className="z-[100] flex items-start justify-center pt-[15vh]" onMouseDown={handleBackdropClick}>
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" <div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" onClick={close} />
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
>
{/* Backdrop */}
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
{/* Palette container */} {/* Palette container */}
<div <div
@@ -323,6 +319,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('palette.aria')} aria-label={t('palette.aria')}
onKeyDown={handleKeyDown}
> >
{/* Search input */} {/* Search input */}
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3"> <div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
@@ -451,7 +448,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
<span>{t('palette.resultCount', { count: filteredItems.length })}</span> <span>{t('palette.resultCount', { count: filteredItems.length })}</span>
</div> </div>
</div> </div>
</div> </AppOverlay>
) )
} }
+23 -19
View File
@@ -4,52 +4,56 @@ import { handleError } from '../../utils/error-handler'
interface DefaultFallbackProps { interface DefaultFallbackProps {
error: Error error: Error
onReset: () => void onReset: () => void
compact?: boolean
} }
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) { export default function DefaultFallback({ error, onReset, compact = false }: DefaultFallbackProps) {
const { t } = useTranslation('common') const { t } = useTranslation('common')
const appError = handleError(error) const appError = handleError(error)
return ( return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4"> <div
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700"> className={`flex items-center justify-center bg-dark-900 p-4 ${compact ? 'h-full min-h-[12rem]' : 'min-h-screen'}`}
<div className="flex items-center gap-3 mb-4"> >
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center"> <div className="w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-6">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="mb-4 flex items-center gap-3">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/20">
<svg className="h-6 w-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg> </svg>
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-content-primary">{t('error.title')}</h2> <h2 className="text-lg font-semibold text-content-primary">{t('error.title')}</h2>
<p className="text-sm text-txt-muted">{t('error.body')}</p> <p className="text-sm text-txt-muted">{compact ? t('error.bodyPane') : t('error.body')}</p>
</div> </div>
</div> </div>
<div className="bg-dark-900 rounded-lg p-4 mb-4"> <div className="mb-4 rounded-lg bg-dark-900 p-4">
<p className="text-sm text-content-secondary mb-2">{appError.message}</p> <p className="mb-2 text-sm text-content-secondary">{appError.message}</p>
{appError.recoverable && ( {appError.recoverable && <p className="text-xs text-green-400">{t('error.recoverable')}</p>}
<p className="text-xs text-green-400">{t('error.recoverable')}</p>
)}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onReset} onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-content-primary rounded-lg transition-colors text-sm font-medium" className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium text-content-primary transition-colors hover:bg-blue-600"
> >
{t('error.refreshPage')} {compact ? t('error.retryPane') : t('error.refreshPage')}
</button> </button>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-secondary rounded-lg transition-colors text-sm" className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-secondary transition-colors hover:bg-dark-600"
> >
{t('error.restartApp')} {t('error.restartApp')}
</button> </button>
</div> </div>
<p className="mt-4 text-xs text-txt-faint text-center"> <p className="mt-4 text-center text-xs text-txt-faint">{t('error.supportHint')}</p>
{t('error.supportHint')}
</p>
</div> </div>
</div> </div>
) )
+12 -26
View File
@@ -1,10 +1,3 @@
/**
* 错误边界组件
*
* 捕获子组件树中的 JavaScript 错误,防止整个应用崩溃
* 体现 "安全可靠" 和 "操作便捷人性化" 理念
*/
import { Component, type ReactNode } from 'react' import { Component, type ReactNode } from 'react'
import { createLogger } from '../../utils/logger' import { createLogger } from '../../utils/logger'
import DefaultFallback from './DefaultFallback' import DefaultFallback from './DefaultFallback'
@@ -14,6 +7,10 @@ const logger = createLogger('ErrorBoundary')
interface Props { interface Props {
children: ReactNode children: ReactNode
fallback?: ReactNode fallback?: ReactNode
/** In-pane fallback so one tab crash does not look like a full-app failure. */
compact?: boolean
/** Changing this key clears a previously captured error. */
resetKey?: string | number
onError?: (error: Error, errorInfo: React.ErrorInfo) => void onError?: (error: Error, errorInfo: React.ErrorInfo) => void
} }
@@ -22,14 +19,6 @@ interface State {
error: Error | null error: Error | null
} }
/**
* 错误边界组件
*
* @example
* <ErrorBoundary>
* <MyComponent />
* </ErrorBoundary>
*/
export default class ErrorBoundary extends Component<Props, State> { export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
@@ -40,15 +29,18 @@ export default class ErrorBoundary extends Component<Props, State> {
return { hasError: true, error } return { hasError: true, error }
} }
componentDidUpdate(prevProps: Props) {
if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) {
this.setState({ hasError: false, error: null })
}
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 记录错误日志
logger.error('组件错误边界捕获到异常', { logger.error('组件错误边界捕获到异常', {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
componentStack: errorInfo.componentStack componentStack: errorInfo.componentStack,
}) })
// 调用外部错误处理回调
this.props.onError?.(error, errorInfo) this.props.onError?.(error, errorInfo)
} }
@@ -58,16 +50,10 @@ export default class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError && this.state.error) { if (this.state.hasError && this.state.error) {
// 使用自定义 fallback 或默认 fallback
if (this.props.fallback) { if (this.props.fallback) {
return this.props.fallback return this.props.fallback
} }
return ( return <DefaultFallback error={this.state.error} onReset={this.handleReset} compact={this.props.compact} />
<DefaultFallback
error={this.state.error}
onReset={this.handleReset}
/>
)
} }
return this.props.children return this.props.children
+26 -28
View File
@@ -5,6 +5,7 @@
import { type ReactNode, useEffect, useId, useRef } from 'react' import { type ReactNode, useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import AppOverlay from './AppOverlay'
import Button from './Button' import Button from './Button'
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
@@ -69,9 +70,7 @@ export default function Modal({
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
restoreFocusRef.current = document.activeElement instanceof HTMLElement restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
? document.activeElement
: null
const focusables = () => const focusables = () =>
Array.from( Array.from(
@@ -125,59 +124,58 @@ export default function Modal({
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div <AppOverlay
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" open
role="dialog" className="z-[100] flex items-center justify-center p-4 sm:p-6"
aria-modal="true" onMouseDown={
aria-labelledby={titleId} disableBackdropClose
? undefined
: (event) => {
if (event.target === event.currentTarget) onClose()
}
}
> >
<div <div
className="absolute inset-0 bg-black/55 backdrop-blur-[2px] animate-fade-in" className="absolute inset-0 animate-fade-in bg-black/55 backdrop-blur-[2px]"
onClick={disableBackdropClose ? undefined : onClose} onClick={disableBackdropClose ? undefined : onClose}
/> />
<div <div
ref={panelRef} ref={panelRef}
className={` role="dialog"
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)] aria-modal="true"
flex flex-col aria-labelledby={titleId}
bg-dark-800 border border-dark-700/80 rounded-2xl className={`relative w-full ${sizeClass[size]} border-dark-700/80 flex max-h-[min(90vh,880px)] animate-scale-in flex-col rounded-2xl border bg-dark-800 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)] ${className} `}
shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)]
animate-scale-in
${className}
`}
> >
{/* Header */} {/* Header */}
<div className="flex items-start gap-3 px-5 sm:px-6 py-4 border-b border-dark-700/60 flex-shrink-0"> <div className="border-dark-700/60 flex flex-shrink-0 items-start gap-3 border-b px-5 py-4 sm:px-6">
{icon && ( {icon && (
<div className="w-10 h-10 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center flex-shrink-0 text-accent-blue"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
{icon} {icon}
</div> </div>
)} )}
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1 pt-0.5">
<h3 id={titleId} className="text-base sm:text-lg font-semibold text-content-primary tracking-tight"> <h3 id={titleId} className="text-base font-semibold tracking-tight text-content-primary sm:text-lg">
{title} {title}
</h3> </h3>
{description && ( {description && <p className="mt-0.5 text-xs leading-relaxed text-txt-muted sm:text-sm">{description}</p>}
<p className="text-xs sm:text-sm text-txt-muted mt-0.5 leading-relaxed">{description}</p>
)}
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 -mr-1 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700 transition-colors flex-shrink-0" className="-mr-1 flex-shrink-0 rounded-lg p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
aria-label={t('action.close')} aria-label={t('action.close')}
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Body */} {/* Body */}
<div className="px-5 sm:px-6 py-4 overflow-y-auto flex-1 min-h-0">{children}</div> <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 sm:px-6">{children}</div>
{/* Footer */} {/* Footer */}
{showFooter && ( {showFooter && (
<div className="flex items-center justify-end gap-2.5 px-5 sm:px-6 py-3.5 border-t border-dark-700/60 bg-dark-900/30 rounded-b-2xl flex-shrink-0"> <div className="border-dark-700/60 bg-dark-900/30 flex flex-shrink-0 items-center justify-end gap-2.5 rounded-b-2xl border-t px-5 py-3.5 sm:px-6">
{footer ?? ( {footer ?? (
<> <>
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}> <Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
@@ -199,6 +197,6 @@ export default function Modal({
</div> </div>
)} )}
</div> </div>
</div> </AppOverlay>
) )
} }
@@ -13,6 +13,7 @@ import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import type { PooledConnection } from '../../stores/connection/types' import type { PooledConnection } from '../../stores/connection/types'
import EmptyState from '../common/EmptyState' import EmptyState from '../common/EmptyState'
import StatusDot from '../common/StatusDot' import StatusDot from '../common/StatusDot'
import AppOverlay from '../common/AppOverlay'
// ============== Helpers ============== // ============== Helpers ==============
@@ -222,12 +223,9 @@ export default function ConnectionPanel() {
if (!isOpen) return null if (!isOpen) return null
return ( return (
<> <AppOverlay open className="z-40">
{/* Backdrop */} <div className="absolute inset-0 animate-fade-in bg-black/30" onClick={close} />
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} /> <div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right absolute bottom-0 right-0 top-0 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
{/* Panel */}
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right fixed bottom-0 right-0 top-0 z-50 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
{/* Header */} {/* Header */}
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4"> <div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary"> <h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
@@ -274,6 +272,6 @@ export default function ConnectionPanel() {
)} )}
</div> </div>
</div> </div>
</> </AppOverlay>
) )
} }
+33 -76
View File
@@ -1,22 +1,24 @@
/** /**
* Right drawer for SSH port forwarding (SessionDialog-style). * Right drawer for SSH port forwarding (SessionDialog-style).
* Portaled to document.body so TabContent overflow-hidden cannot clip it. * Uses AppOverlay (portaled to document.body) so TabContent overflow cannot clip it.
* *
* Dynamic is a single SOCKS5 on 127.0.0.1 (default port 1080; port set in the form below). * Dynamic is a single SOCKS5 on 127.0.0.1 (default port 1080; port set in the form below).
* Local / Remote rules appear in the list and can be added repeatedly. * Local / Remote rules appear in the list and can be added repeatedly.
*/ */
import { useEffect } from 'react' import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react' import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
import AppOverlay from '../common/AppOverlay'
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram' import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
import { import {
DEFAULT_DYNAMIC_PORT,
DEFAULT_LOCAL_BIND_HOST, DEFAULT_LOCAL_BIND_HOST,
DEFAULT_REMOTE_BIND_HOST, DEFAULT_REMOTE_BIND_HOST,
formatHostPort, formatHostPort,
isAllInterfacesBind, isAllInterfacesBind,
parseHostPort, parseHostPort,
resolveDynamicPort,
resolveForwardTargetHost, resolveForwardTargetHost,
resolveLocalBindHost, resolveLocalBindHost,
resolveRemoteBindHost, resolveRemoteBindHost,
@@ -24,15 +26,6 @@ import {
type ForwardRuleType, type ForwardRuleType,
} from './forwardRules' } from './forwardRules'
export const DEFAULT_DYNAMIC_PORT = 1080
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
return DEFAULT_DYNAMIC_PORT
}
export interface ForwardRuleDraft { export interface ForwardRuleDraft {
type: ForwardRuleType type: ForwardRuleType
name: string name: string
@@ -164,9 +157,7 @@ export default function ForwardRuleDrawer({
const localRemoteRules = (rules || []).filter((rule) => rule.type !== 'dynamic') const localRemoteRules = (rules || []).filter((rule) => rule.type !== 'dynamic')
const dynamicRuntime = dynamicRule ? runtimes?.get(dynamicRule.id) : undefined const dynamicRuntime = dynamicRule ? runtimes?.get(dynamicRule.id) : undefined
const dynamicRunning = Boolean(dynamicRuntime?.running) const dynamicRunning = Boolean(dynamicRuntime?.running)
const dynamicPort = resolveDynamicPort( const dynamicPort = resolveDynamicPort(dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort)
dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort
)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -190,16 +181,9 @@ export default function ForwardRuleDrawer({
const localName = t('portForward.defaultLocalName') const localName = t('portForward.defaultLocalName')
const remoteName = t('portForward.defaultRemoteName') const remoteName = t('portForward.defaultRemoteName')
const current = (draft.name || '').trim() const current = (draft.name || '').trim()
const isPreset = const isPreset = !current || current === socksName || current === localName || current === remoteName
!current || current === socksName || current === localName || current === remoteName
const nextName = !isPreset const nextName = !isPreset ? current : next === 'dynamic' ? socksName : next === 'remote' ? remoteName : localName
? current
: next === 'dynamic'
? socksName
: next === 'remote'
? remoteName
: localName
if (next === 'dynamic') { if (next === 'dynamic') {
onChange({ onChange({
@@ -217,21 +201,15 @@ export default function ForwardRuleDrawer({
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080 const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
const nextRemotePort = const nextRemotePort =
draft.type === 'dynamic' draft.type === 'dynamic' ? (next === 'remote' ? 18080 : 80) : draft.remotePort || (next === 'remote' ? 18080 : 80)
? next === 'remote'
? 18080
: 80
: draft.remotePort || (next === 'remote' ? 18080 : 80)
onChange({ onChange({
...draft, ...draft,
type: next, type: next,
name: nextName, name: nextName,
localPort: nextLocalPort, localPort: nextLocalPort,
localBindHost: localBindHost: next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '', remoteBindHost: next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
remoteBindHost:
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1', remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
remotePort: nextRemotePort, remotePort: nextRemotePort,
}) })
@@ -246,16 +224,16 @@ export default function ForwardRuleDrawer({
? t('action.save') ? t('action.save')
: t('portForward.addRule') : t('portForward.addRule')
return createPortal( return (
<div className="fixed inset-0 z-[100] flex justify-end"> <AppOverlay open className="z-[100] flex justify-end">
<div className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in" onClick={onClose} /> <div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
<aside <aside
className="relative flex h-full w-full max-w-[420px] flex-col border-l border-dark-700/70 bg-dark-800 shadow-2xl animate-slide-in-right" className="border-dark-700/70 animate-slide-in-right relative flex h-full w-full max-w-[420px] flex-col border-l bg-dark-800 shadow-2xl"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="forward-drawer-title" aria-labelledby="forward-drawer-title"
> >
<div className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/50 px-4 py-3.5"> <div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue"> <div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<ArrowRightLeft className="h-4 w-4" /> <ArrowRightLeft className="h-4 w-4" />
</div> </div>
@@ -286,9 +264,7 @@ export default function ForwardRuleDrawer({
{/* Fixed Dynamic slot */} {/* Fixed Dynamic slot */}
<div <div
className={`rounded-xl border px-3 py-2.5 ${ className={`rounded-xl border px-3 py-2.5 ${
dynamicRunning dynamicRunning ? 'border-accent-blue/40 bg-accent-blue/10' : 'border-dark-700/60 bg-dark-900/30'
? 'border-accent-blue/40 bg-accent-blue/10'
: 'border-dark-700/60 bg-dark-900/30'
}`} }`}
> >
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
@@ -297,21 +273,15 @@ export default function ForwardRuleDrawer({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-content-primary"> <span className="text-xs font-medium text-content-primary">{t('portForward.typeDynamic')}</span>
{t('portForward.typeDynamic')} <span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">SOCKS5</span>
</span>
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">
SOCKS5
</span>
{dynamicRunning && ( {dynamicRunning && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400"> <span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
{t('portForward.running')} {t('portForward.running')}
</span> </span>
)} )}
</div> </div>
<p className="mt-0.5 font-mono text-[11px] text-txt-muted"> <p className="mt-0.5 font-mono text-[11px] text-txt-muted">127.0.0.1:{dynamicPort}</p>
127.0.0.1:{dynamicPort}
</p>
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
{dynamicRule && dynamicRunning && onStopRule && ( {dynamicRule && dynamicRunning && onStopRule && (
@@ -369,9 +339,7 @@ export default function ForwardRuleDrawer({
<div <div
key={rule.id} key={rule.id}
className={`rounded-xl border px-3 py-2 ${ className={`rounded-xl border px-3 py-2 ${
running running ? 'border-green-500/30 bg-green-500/10' : 'border-dark-700/60 bg-dark-900/30'
? 'border-green-500/30 bg-green-500/10'
: 'border-dark-700/60 bg-dark-900/30'
}`} }`}
> >
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
@@ -390,9 +358,7 @@ export default function ForwardRuleDrawer({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-xs font-medium text-content-primary"> <span className="truncate text-xs font-medium text-content-primary">{rule.name}</span>
{rule.name}
</span>
<span <span
className={`rounded px-1.5 py-px text-[10px] ${ className={`rounded px-1.5 py-px text-[10px] ${
rule.type === 'local' rule.type === 'local'
@@ -400,9 +366,7 @@ export default function ForwardRuleDrawer({
: 'bg-purple-500/15 text-purple-300' : 'bg-purple-500/15 text-purple-300'
}`} }`}
> >
{rule.type === 'local' {rule.type === 'local' ? t('portForward.typeLocal') : t('portForward.typeRemote')}
? t('portForward.typeLocal')
: t('portForward.typeRemote')}
</span> </span>
{running && ( {running && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400"> <span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
@@ -463,7 +427,7 @@ export default function ForwardRuleDrawer({
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint"> <div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{showList ? t('portForward.addSection') : t('portForward.selectType')} {showList ? t('portForward.addSection') : t('portForward.selectType')}
</div> </div>
<div className="grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 bg-dark-900/40 p-0.5"> <div className="bg-dark-900/40 grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 p-0.5">
{types.map((item) => ( {types.map((item) => (
<button <button
key={item.type} key={item.type}
@@ -487,7 +451,7 @@ export default function ForwardRuleDrawer({
<ForwardArchitectureDiagram type={type} /> <ForwardArchitectureDiagram type={type} />
{type === 'dynamic' ? ( {type === 'dynamic' ? (
<section className="space-y-2 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5"> <section className="border-dark-700/50 bg-dark-900/30 space-y-2 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint"> <div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.typeDynamic')} {t('portForward.typeDynamic')}
</div> </div>
@@ -511,7 +475,7 @@ export default function ForwardRuleDrawer({
</p> </p>
</section> </section>
) : ( ) : (
<section className="space-y-3 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5"> <section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint"> <div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.ruleFields')} {t('portForward.ruleFields')}
</div> </div>
@@ -531,7 +495,7 @@ export default function ForwardRuleDrawer({
{type === 'local' ? ( {type === 'local' ? (
<> <>
{viaSession ? ( {viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted"> <p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSession', { target: viaSession })} {t('portForward.viaSession', { target: viaSession })}
</p> </p>
) : null} ) : null}
@@ -561,10 +525,7 @@ export default function ForwardRuleDrawer({
<label className="ui-label">{t('portForward.targetAddress')}</label> <label className="ui-label">{t('portForward.targetAddress')}</label>
<input <input
type="text" type="text"
value={formatHostPort( value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.remotePort)}
resolveForwardTargetHost(draft.remoteHost),
draft.remotePort
)}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value.trim() const raw = e.target.value.trim()
const parsed = parseHostPort(raw) const parsed = parseHostPort(raw)
@@ -600,7 +561,7 @@ export default function ForwardRuleDrawer({
: t('portForward.targetAddressHint')} : t('portForward.targetAddressHint')}
</p> </p>
</div> </div>
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"> <details className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2">
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary"> <summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.localBindAdvanced')} {t('portForward.localBindAdvanced')}
</summary> </summary>
@@ -628,7 +589,7 @@ export default function ForwardRuleDrawer({
) : ( ) : (
<> <>
{viaSession ? ( {viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted"> <p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSessionRemote', { target: viaSession })} {t('portForward.viaSessionRemote', { target: viaSession })}
</p> </p>
) : null} ) : null}
@@ -658,10 +619,7 @@ export default function ForwardRuleDrawer({
<label className="ui-label">{t('portForward.localTargetAddress')}</label> <label className="ui-label">{t('portForward.localTargetAddress')}</label>
<input <input
type="text" type="text"
value={formatHostPort( value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.localPort)}
resolveForwardTargetHost(draft.remoteHost),
draft.localPort
)}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value.trim() const raw = e.target.value.trim()
const parsed = parseHostPort(raw) const parsed = parseHostPort(raw)
@@ -696,7 +654,7 @@ export default function ForwardRuleDrawer({
</p> </p>
</div> </div>
<details <details
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2" className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2"
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))} open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
> >
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary"> <summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
@@ -734,7 +692,7 @@ export default function ForwardRuleDrawer({
)} )}
</div> </div>
<div className="flex flex-shrink-0 gap-2 border-t border-dark-700/50 px-4 py-3"> <div className="border-dark-700/50 flex flex-shrink-0 gap-2 border-t px-4 py-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -753,7 +711,6 @@ export default function ForwardRuleDrawer({
</button> </button>
</div> </div>
</aside> </aside>
</div>, </AppOverlay>
document.body
) )
} }
+20 -63
View File
@@ -10,18 +10,17 @@ import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection' import { useConnectionPool } from '../../stores/connection'
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react' import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from '../common/ConfirmDialog' import { useConfirmDialog } from '../common/ConfirmDialog'
import { translateErrorMessage } from '../../i18n/format' import { translateErrorMessage, translateIpcError } from '../../i18n/format'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types' import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, { import ForwardRuleDrawer, { emptyForwardDraft } from './ForwardRuleDrawer'
DEFAULT_DYNAMIC_PORT, import { startNativeForward, stopNativeForward } from './forwardRuntime'
emptyForwardDraft,
resolveDynamicPort,
} from './ForwardRuleDrawer'
import { import {
DEFAULT_DYNAMIC_PORT,
formatTransferBytes, formatTransferBytes,
duplicateForwardRule, duplicateForwardRule,
parseForwardRules, parseForwardRules,
portForwardStorageKey, portForwardStorageKey,
resolveDynamicPort,
resolveForwardTargetHost, resolveForwardTargetHost,
resolveLocalBindHost, resolveLocalBindHost,
resolveRemoteBindHost, resolveRemoteBindHost,
@@ -156,11 +155,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
) )
return return
} }
if ( if (newRule.type === 'dynamic' && !editingRuleId && rules.some((item) => item.type === 'dynamic')) {
newRule.type === 'dynamic' &&
!editingRuleId &&
rules.some((item) => item.type === 'dynamic')
) {
setDrawerError(t('common:portForward.errorDynamicExists')) setDrawerError(t('common:portForward.errorDynamicExists'))
return return
} }
@@ -170,12 +165,9 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
type: newRule.type as 'local' | 'remote' | 'dynamic', type: newRule.type as 'local' | 'remote' | 'dynamic',
name: newRule.name.trim(), name: newRule.name.trim(),
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!, localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
localBindHost: localBindHost: newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined, remoteBindHost: newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
remoteBindHost: remoteHost: newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
remoteHost:
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
remotePort: newRule.remotePort, remotePort: newRule.remotePort,
} }
@@ -194,43 +186,13 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setError(null) setError(null)
try { try {
let result const result = await startNativeForward(connectionId, rule)
if (rule.type === 'local') { if (result.success) {
result = await window.opsxApi?.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi?.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi?.sshForwardDynamic(
connectionId,
resolveDynamicPort(rule.localPort),
rule.id
)
}
if (result?.success) {
rememberForwardIntent(intentKey, rule) rememberForwardIntent(intentKey, rule)
await refreshTunnels() await refreshTunnels()
return true return true
} }
setError( setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed'))
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
} catch (err: unknown) { } catch (err: unknown) {
const raw = err instanceof Error ? err.message : String(err) const raw = err instanceof Error ? err.message : String(err)
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed')) setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
@@ -284,23 +246,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
try { try {
let result = await window.opsxApi?.sshForwardStop(rule.id) const result = await stopNativeForward(connectionId, rule)
if (!result?.success && connectionId) { if (!result.success) {
const listed = await window.opsxApi?.sshForwardList(connectionId) setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed'))
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi?.sshForwardStop(match.forwardId)
}
}
if (!result?.success) {
setError(result?.error || t('common:portForward.errorStopFailed'))
return false return false
} }
forgetForwardIntent(intentKey, rule.id) forgetForwardIntent(intentKey, rule.id)
await refreshTunnels() await refreshTunnels()
return true return true
} catch (err: unknown) { } catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStopFailed')) setError(
translateErrorMessage(err instanceof Error ? err.message : String(err)) ||
t('common:portForward.errorStopFailed')
)
return false return false
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -351,7 +309,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
{needsReconnect && currentTab?.sessionId && ( {needsReconnect && currentTab?.sessionId && (
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center"> <div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="bg-accent-blue/20 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent-blue/20">
<ArrowRightLeft className="h-8 w-8 text-accent-blue" /> <ArrowRightLeft className="h-8 w-8 text-accent-blue" />
</div> </div>
<div className="mb-2 text-lg text-content-primary">{t('common:portForward.sessionRestoredTitle')}</div> <div className="mb-2 text-lg text-content-primary">{t('common:portForward.sessionRestoredTitle')}</div>
@@ -597,7 +555,6 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
)} )}
</div> </div>
<ForwardRuleDrawer <ForwardRuleDrawer
+10 -5
View File
@@ -3,6 +3,15 @@ import i18n from '../../i18n'
export type ForwardRuleType = 'local' | 'remote' | 'dynamic' export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
/** Default SOCKS5 listen port (`-D`). */
export const DEFAULT_DYNAMIC_PORT = 1080
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
return DEFAULT_DYNAMIC_PORT
}
/** Default local listen bind for `-L` (this machine only). */ /** Default local listen bind for `-L` (this machine only). */
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1' export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
@@ -93,11 +102,7 @@ export function isInvalidForwardTargetHost(host: string): boolean {
return isListenOnlyAddress(host) return isListenOnlyAddress(host)
} }
export function portForwardStorageKey( export function portForwardStorageKey(sessionId: string | undefined, tabId: string, connectionId?: string): string {
sessionId: string | undefined,
tabId: string,
connectionId?: string
): string {
if (sessionId) return `portForwards_session_${sessionId}` if (sessionId) return `portForwards_session_${sessionId}`
if (connectionId) return `portForwards_conn_${connectionId}` if (connectionId) return `portForwards_conn_${connectionId}`
return `portForwards_tab_${tabId}` return `portForwards_tab_${tabId}`
@@ -0,0 +1,71 @@
import type { SSHForwardResponse } from '../../../shared/types/ipc.types'
import { tunnelCoversRule } from './forwardIntent'
import {
resolveDynamicPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from './forwardRules'
export type ForwardCommandResult = Pick<SSHForwardResponse, 'success' | 'error' | 'errorCode'>
function asResult(result?: SSHForwardResponse | null): ForwardCommandResult {
return {
success: Boolean(result?.success),
error: result?.error,
errorCode: result?.errorCode,
}
}
/** Start the native listener for a persisted rule. UI layers own toast / intent / refresh. */
export async function startNativeForward(connectionId: string, rule: ForwardRule): Promise<ForwardCommandResult> {
if (!window.opsxApi) return { success: false }
if (rule.type === 'local') {
return asResult(
await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
)
}
if (rule.type === 'remote') {
return asResult(
await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
)
}
return asResult(await window.opsxApi.sshForwardDynamic(connectionId, resolveDynamicPort(rule.localPort), rule.id))
}
/**
* Stop the real native listener. After reconnect the runtime id may differ from
* the persisted rule id — fall back to a tunnel that still covers the rule.
*/
export async function stopNativeForward(
connectionId: string | undefined,
rule: ForwardRule
): Promise<ForwardCommandResult> {
if (!window.opsxApi?.sshForwardStop) return { success: false }
let result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi.sshForwardStop(match.forwardId)
}
}
return asResult(result)
}
+169 -28
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react' import {
useCallback,
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react' import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore' import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import StatusDot from '../common/StatusDot' import StatusDot from '../common/StatusDot'
import type { import type {
@@ -35,8 +44,17 @@ import {
vncInputKey, vncInputKey,
vncInputPointer, vncInputPointer,
} from '../../platform/vnc' } from '../../platform/vnc'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol' import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
import { clampDesktopResolution } from '../../utils/desktopResolution' import {
clampDesktopResolution,
desktopCanvasDisplaySize,
isUsableDesktopPane,
resolutionFromPane,
type DesktopResolution,
} from '../../utils/desktopResolution'
import { reconnectDelayMs } from '../../utils/disconnectReason'
import { subscribeTabReconnect } from '../../utils/tabReconnect'
interface DesktopSessionViewProps { interface DesktopSessionViewProps {
tabId: string tabId: string
@@ -97,6 +115,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 }) const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null) const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const [chromeVisible, setChromeVisible] = useState(true) const [chromeVisible, setChromeVisible] = useState(true)
const [reconnectGeneration, setReconnectGeneration] = useState(0)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef<number | null>(null)
const intentionalDisconnectRef = useRef(false)
const hadConnectedRef = useRef(false)
const pasteLockRef = useRef(0)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp' const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive isActiveRef.current = isActive
@@ -134,6 +158,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
} }
}, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig]) }, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig])
const clearDesktopReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current != null) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}, [])
const scheduleDesktopReconnect = useCallback(() => {
const { settings } = useSettingsStore.getState()
if (!settings.autoReconnect) return
if (intentionalDisconnectRef.current) return
if (!hadConnectedRef.current) return
const maxAttempts = settings.maxReconnectAttempts ?? 5
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
if (reconnectAttemptRef.current >= maxAttempts) {
setError(t('status.reconnectLimit', { max: maxAttempts }))
updateTabStatus(tabId, 'error')
return
}
reconnectAttemptRef.current += 1
const attempt = reconnectAttemptRef.current
const delay = reconnectDelayMs(attempt, baseDelay)
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.reconnecting', { attempt, max: maxAttempts }))
clearDesktopReconnectTimer()
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null
setReconnectGeneration((value) => value + 1)
}, delay)
}, [clearDesktopReconnectTimer, t, tabId, updateTabStatus])
const handleDesktopReconnect = useCallback(() => {
clearDesktopReconnectTimer()
reconnectAttemptRef.current = 0
intentionalDisconnectRef.current = false
setError(null)
setReconnectGeneration((value) => value + 1)
}, [clearDesktopReconnectTimer])
useEffect(() => {
return () => clearDesktopReconnectTimer()
}, [clearDesktopReconnectTimer])
useEffect(() => subscribeTabReconnect(tabId, handleDesktopReconnect), [tabId, handleDesktopReconnect])
useEffect(() => { useEffect(() => {
if (!resolvedConfig) return if (!resolvedConfig) return
let disposed = false let disposed = false
@@ -208,28 +277,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
connectedRef.current = false connectedRef.current = false
lastRemoteResizeRef.current = null lastRemoteResizeRef.current = null
const waitForPaneResolution = async (fallback: DesktopResolution): Promise<DesktopResolution> => {
for (let i = 0; i < 24; i++) {
const pane = paneRef.current
const width = pane?.clientWidth ?? 0
const height = pane?.clientHeight ?? 0
if (pane && isUsableDesktopPane(width, height)) {
return resolutionFromPane(width, height, fallback)
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve())
})
}
const pane = paneRef.current
return resolutionFromPane(pane?.clientWidth ?? 0, pane?.clientHeight ?? 0, fallback)
}
void (async () => { void (async () => {
try { try {
if (protocol === 'rdp') { if (protocol === 'rdp') {
unlistenStatus = await onRdpStatus((payload) => { unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId if (!nativeSessionIdRef.current) {
if (payload.status === 'disconnected' || payload.status === 'error') return
nativeSessionIdRef.current = payload.sessionId
}
if (payload.width && payload.height) { if (payload.width && payload.height) {
setFbSize({ width: payload.width, height: payload.height }) setFbSize({ width: payload.width, height: payload.height })
lastRemoteResizeRef.current = { width: payload.width, height: payload.height } lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
} }
if (payload.status === 'connected') { if (payload.status === 'connected') {
connectedRef.current = true connectedRef.current = true
hadConnectedRef.current = true
reconnectAttemptRef.current = 0
updateTabStatus(tabId, 'connected') updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected')) setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') { } else if (payload.status === 'error') {
connectedRef.current = false connectedRef.current = false
updateTabStatus(tabId, 'error') updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed')) setError(payload.message || t('status.connectFailed'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.status === 'disconnected') { } else if (payload.status === 'disconnected') {
connectedRef.current = false connectedRef.current = false
updateTabStatus(tabId, 'disconnected') updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected')) setStatusMessage(t('status.disconnected'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.message) { } else if (payload.message) {
setStatusMessage(payload.message) setStatusMessage(payload.message)
} }
@@ -241,7 +333,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenClipboard = await onRdpClipboard((payload) => { unlistenClipboard = await onRdpClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) { if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined) void writeClipboardText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced')) setStatusMessage(t('status.clipboardSynced'))
} }
}) })
@@ -249,7 +341,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
if (payload.sessionId !== nativeSessionIdRef.current) return if (payload.sessionId !== nativeSessionIdRef.current) return
setStatusMessage(t('status.savedToDownloads', { name: payload.name })) setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
}) })
const sessionId = await rdpConnect(resolvedConfig as RdpConfig) const rdpConfig = resolvedConfig as RdpConfig
const paneSize = await waitForPaneResolution(clampDesktopResolution(rdpConfig.width, rdpConfig.height))
if (disposed) return
lastRemoteResizeRef.current = paneSize
const sessionId = await rdpConnect({
...rdpConfig,
width: paneSize.width,
height: paneSize.height,
})
if (disposed) { if (disposed) {
await rdpDisconnect(sessionId) await rdpDisconnect(sessionId)
return return
@@ -258,16 +358,26 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
} else { } else {
unlistenStatus = await onVncStatus((payload) => { unlistenStatus = await onVncStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId if (!nativeSessionIdRef.current) {
if (payload.status === 'disconnected' || payload.status === 'error') return
nativeSessionIdRef.current = payload.sessionId
}
if (payload.status === 'connected') { if (payload.status === 'connected') {
connectedRef.current = true
hadConnectedRef.current = true
reconnectAttemptRef.current = 0
updateTabStatus(tabId, 'connected') updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected')) setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') { } else if (payload.status === 'error') {
connectedRef.current = false
updateTabStatus(tabId, 'error') updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed')) setError(payload.message || t('status.connectFailed'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.status === 'disconnected') { } else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected') updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected')) setStatusMessage(t('status.disconnected'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.message) { } else if (payload.message) {
setStatusMessage(payload.message) setStatusMessage(payload.message)
} }
@@ -279,7 +389,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenClipboard = await onVncClipboard((payload) => { unlistenClipboard = await onVncClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) { if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined) void writeClipboardText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced')) setStatusMessage(t('status.clipboardSynced'))
} }
}) })
@@ -320,7 +430,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
else void vncDisconnect(sessionId) else void vncDisconnect(sessionId)
} }
} }
}, [protocol, resolvedConfig, tabId, updateTabStatus, t]) }, [protocol, resolvedConfig, tabId, updateTabStatus, t, reconnectGeneration, scheduleDesktopReconnect])
useEffect(() => { useEffect(() => {
if (!isActive) return if (!isActive) return
@@ -334,13 +444,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
const updateFit = () => { const updateFit = () => {
const availW = Math.max(1, pane.clientWidth) const display = desktopCanvasDisplaySize(
const availH = Math.max(1, pane.clientHeight) protocol,
const scale = Math.min(availW / fbSize.width, availH / fbSize.height) pane.clientWidth,
const displayW = Math.max(1, Math.floor(fbSize.width * scale)) pane.clientHeight,
const displayH = Math.max(1, Math.floor(fbSize.height * scale)) fbSize.width,
canvas.style.width = `${displayW}px` fbSize.height
canvas.style.height = `${displayH}px` )
canvas.style.width = `${display.width}px`
canvas.style.height = `${display.height}px`
} }
const requestRemoteResize = () => { const requestRemoteResize = () => {
@@ -503,14 +615,28 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
} }
} }
const pasteToRemote = async () => { const pasteToRemote = async (textFromEvent?: string) => {
const sessionId = nativeSessionIdRef.current const sessionId = nativeSessionIdRef.current
if (!sessionId) return if (!sessionId) return
const text = await navigator.clipboard.readText() const now = Date.now()
if (!text) return if (now - pasteLockRef.current < 400) return
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text) pasteLockRef.current = now
else await vncClipboardSet(sessionId, text) try {
setStatusMessage(t('status.pastedToRemote')) const text = textFromEvent ?? (await readClipboardText())
if (!text) return
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
else await vncClipboardSet(sessionId, text)
setStatusMessage(t('status.pastedToRemote'))
} catch (reason) {
setStatusMessage(reason instanceof Error ? reason.message : String(reason))
}
}
const onPasteClipboard = (event: ClipboardEvent<HTMLCanvasElement>) => {
event.preventDefault()
event.stopPropagation()
const text = event.clipboardData?.getData('text/plain')
void pasteToRemote(text || undefined)
} }
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => { const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
@@ -533,12 +659,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
event.preventDefault() event.preventDefault()
const isPasteChord = const isPasteChord = (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
if (isPasteChord) { if (isPasteChord) {
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V. // Swallow Cmd/Ctrl+V (keydown and keyup). Native VNC types the text as
void pasteToRemote() // keysyms so remote terminals do not wrap it in bracketed paste.
if (pressed) void pasteToRemote()
return return
} }
@@ -624,6 +750,16 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
)} )}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5"> <div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={connected || tab?.connectionStatus === 'connecting'}
onClick={() => handleDesktopReconnect()}
title={t('toolbar.reconnect')}
aria-label={t('toolbar.reconnectAria')}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button <button
type="button" type="button"
className={toolBtn} className={toolBtn}
@@ -707,14 +843,18 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
<div <div
ref={paneRef} ref={paneRef}
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${ className={`flex min-h-0 flex-1 overflow-hidden bg-black ${
fullscreenActive ? 'p-0' : 'p-2' protocol === 'rdp' || fullscreenActive
? 'items-stretch justify-stretch p-0'
: 'items-center justify-center p-2'
}`} }`}
> >
<canvas <canvas
ref={canvasRef} ref={canvasRef}
tabIndex={0} tabIndex={0}
className="cursor-default shadow-lg shadow-black/40 outline-none" className={`cursor-default outline-none ${
protocol === 'rdp' ? 'h-full w-full' : 'shadow-lg shadow-black/40'
}`}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)} onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)} onMouseDown={(event) => mapPointer(event, true)}
@@ -722,6 +862,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
onMouseLeave={() => { onMouseLeave={() => {
buttonsRef.current = { left: false, middle: false, right: false } buttonsRef.current = { left: false, middle: false, right: false }
}} }}
onPaste={onPasteClipboard}
onKeyDown={(event) => onKey(event, true)} onKeyDown={(event) => onKey(event, true)}
onKeyUp={(event) => onKey(event, false)} onKeyUp={(event) => onKey(event, false)}
/> />
+49 -128
View File
@@ -7,21 +7,20 @@ import type { ConnectionState } from '../../stores/connection'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore' import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import { useSessionStore, useEntitlementStore } from '../../stores' import { useSessionStore, useEntitlementStore } from '../../stores'
import type { HostMetrics } from '../../../shared/types/host-metrics.types' import type { HostMetrics } from '../../../shared/types/host-metrics.types'
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format' import { formatLocaleTime, translateErrorMessage, translateIpcError } from '../../i18n/format'
import { useToast } from '../../hooks/useToast' import { useToast } from '../../hooks/useToast'
import ForwardRuleDrawer, { import ForwardRuleDrawer, { emptyForwardDraft } from '../connection/ForwardRuleDrawer'
DEFAULT_DYNAMIC_PORT,
emptyForwardDraft,
resolveDynamicPort,
} from '../connection/ForwardRuleDrawer'
import { import {
DEFAULT_DYNAMIC_PORT,
parseForwardRules, parseForwardRules,
portForwardStorageKey, portForwardStorageKey,
resolveDynamicPort,
resolveForwardTargetHost, resolveForwardTargetHost,
resolveLocalBindHost, resolveLocalBindHost,
resolveRemoteBindHost, resolveRemoteBindHost,
type ForwardRule, type ForwardRule,
} from '../connection/forwardRules' } from '../connection/forwardRules'
import { startNativeForward, stopNativeForward } from '../connection/forwardRuntime'
import { import {
forgetForwardIntent, forgetForwardIntent,
forwardIntentKey, forwardIntentKey,
@@ -42,43 +41,43 @@ interface StatusDisplay {
const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey: string }> = { const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey: string }> = {
connected: { connected: {
icon: <Wifi className="w-3 h-3" />, icon: <Wifi className="h-3 w-3" />,
labelKey: 'status.connected', labelKey: 'status.connected',
colorClass: 'text-feedback-success', colorClass: 'text-feedback-success',
animateClass: '', animateClass: '',
}, },
connecting: { connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />, icon: <Loader2 className="h-3 w-3 animate-spin" />,
labelKey: 'status.connecting', labelKey: 'status.connecting',
colorClass: 'text-feedback-warning', colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse', animateClass: 'animate-pulse',
}, },
background: { background: {
icon: <Wifi className="w-3 h-3" />, icon: <Wifi className="h-3 w-3" />,
labelKey: 'status.background', labelKey: 'status.background',
colorClass: 'text-feedback-success', colorClass: 'text-feedback-success',
animateClass: '', animateClass: '',
}, },
suspended: { suspended: {
icon: <WifiOff className="w-3 h-3" />, icon: <WifiOff className="h-3 w-3" />,
labelKey: 'status.suspended', labelKey: 'status.suspended',
colorClass: 'text-feedback-warning', colorClass: 'text-feedback-warning',
animateClass: '', animateClass: '',
}, },
reconnecting: { reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />, icon: <Loader2 className="h-3 w-3 animate-spin" />,
labelKey: 'status.reconnecting', labelKey: 'status.reconnecting',
colorClass: 'text-feedback-warning', colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse', animateClass: 'animate-pulse',
}, },
error: { error: {
icon: <AlertCircle className="w-3 h-3" />, icon: <AlertCircle className="h-3 w-3" />,
labelKey: 'status.error', labelKey: 'status.error',
colorClass: 'text-feedback-error', colorClass: 'text-feedback-error',
animateClass: '', animateClass: '',
}, },
disconnected: { disconnected: {
icon: <WifiOff className="w-3 h-3" />, icon: <WifiOff className="h-3 w-3" />,
labelKey: 'status.disconnected', labelKey: 'status.disconnected',
colorClass: 'text-txt-muted', colorClass: 'text-txt-muted',
animateClass: '', animateClass: '',
@@ -276,8 +275,7 @@ function RemoteHostMetricsStrip({
metrics?.memPercent != null ? t('metrics.memory', { percent: metrics.memPercent, detail: memDetail }) : null, metrics?.memPercent != null ? t('metrics.memory', { percent: metrics.memPercent, detail: memDetail }) : null,
transferLabel ? t('metrics.sessionTransfer', { value: transferLabel.replace(/^↑↓\s*/, '') }) : null, transferLabel ? t('metrics.sessionTransfer', { value: transferLabel.replace(/^↑↓\s*/, '') }) : null,
...rankedDisks.map( ...rankedDisks.map(
(d) => (d) => `${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
`${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
), ),
].filter(Boolean) ].filter(Boolean)
@@ -333,21 +331,19 @@ function RemoteHostMetricsStrip({
title={tipParts.join(' · ')} title={tipParts.join(' · ')}
> >
{osLabel && ( {osLabel && (
<span className="text-content-secondary max-w-[10rem] shrink truncate" title={osLabel}> <span className="max-w-[10rem] shrink truncate text-content-secondary" title={osLabel}>
{osLabel} {osLabel}
</span> </span>
)} )}
{tz && <span className="text-txt-faint shrink-0">{tz}</span>} {tz && <span className="shrink-0 text-txt-faint">{tz}</span>}
{metrics?.cpuPercent != null && ( {metrics?.cpuPercent != null && (
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}> <span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>CPU {formatCpuPercent(metrics.cpuPercent)}</span>
CPU {formatCpuPercent(metrics.cpuPercent)}
</span>
)} )}
{metrics?.memPercent != null && ( {metrics?.memPercent != null && (
<span className={`shrink-0 ${pctClass(metrics.memPercent)}`}>MEM {metrics.memPercent}%</span> <span className={`shrink-0 ${pctClass(metrics.memPercent)}`}>MEM {metrics.memPercent}%</span>
)} )}
{transferLabel && ( {transferLabel && (
<span className="text-txt-muted flex shrink-0 items-center gap-1"> <span className="flex shrink-0 items-center gap-1 text-txt-muted">
<HardDrive className="h-3 w-3" /> <HardDrive className="h-3 w-3" />
{transferLabel} {transferLabel}
</span> </span>
@@ -385,8 +381,7 @@ export default function StatusBar() {
const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId]) const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId])
const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } = const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } = useMemo(() => {
useMemo(() => {
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') { if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
return { return {
connectionState: undefined, connectionState: undefined,
@@ -425,9 +420,7 @@ export default function StatusBar() {
} }
} }
const session = activeTab.sessionId const session = activeTab.sessionId ? sessions.find((s) => s.id === activeTab.sessionId) : undefined
? sessions.find((s) => s.id === activeTab.sessionId)
: undefined
const sessionOsPretty = session?.osPrettyName const sessionOsPretty = session?.osPrettyName
const instance = activeTab.connectionInstance const instance = activeTab.connectionInstance
@@ -458,9 +451,7 @@ export default function StatusBar() {
const state = mapTabStatusToConnectionState(activeTab.connectionStatus) const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
const config = activeTab.connectionConfig const config = activeTab.connectionConfig
const label = config const label = config ? `${config.username}@${config.host}:${config.port || 22}` : undefined
? `${config.username}@${config.host}:${config.port || 22}`
: undefined
return { return {
connectionState: state, connectionState: state,
@@ -473,9 +464,7 @@ export default function StatusBar() {
}, [activeTab, connections, sessions, t]) }, [activeTab, connections, sessions, t])
const metricsEnabled = const metricsEnabled =
isRemoteSsh && isRemoteSsh && Boolean(connectionId) && (connectionState === 'connected' || connectionState === 'background')
Boolean(connectionId) &&
(connectionState === 'connected' || connectionState === 'background')
const socksAvailable = metricsEnabled const socksAvailable = metricsEnabled
const { metrics, stale } = useRemoteHostMetrics(connectionId, metricsEnabled) const { metrics, stale } = useRemoteHostMetrics(connectionId, metricsEnabled)
@@ -632,38 +621,11 @@ export default function StatusBar() {
const startForwardRule = useCallback( const startForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => { async (rule: ForwardRule): Promise<boolean> => {
if (!connectionId || !window.opsxApi) return false if (!connectionId) return false
let result const result = await startNativeForward(connectionId, rule)
if (rule.type === 'local') { if (!result.success) {
result = await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi.sshForwardDynamic(
connectionId,
resolveDynamicPort(rule.localPort),
rule.id
)
}
if (!result?.success) {
setForwardDrawerError( setForwardDrawerError(
result?.error translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed')
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
) )
return false return false
} }
@@ -695,21 +657,10 @@ export default function StatusBar() {
const stopForwardRule = useCallback( const stopForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => { async (rule: ForwardRule): Promise<boolean> => {
if (!window.opsxApi?.sshForwardStop) return false const result = await stopNativeForward(connectionId, rule)
let result = await window.opsxApi.sshForwardStop(rule.id) if (!result.success) {
if (!result?.success && connectionId) {
// Runtime id may differ from persisted rule id after reconnect.
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi.sshForwardStop(match.forwardId)
}
}
if (!result?.success) {
setForwardDrawerError( setForwardDrawerError(
result?.error translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed')
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
) )
return false return false
} }
@@ -767,16 +718,7 @@ export default function StatusBar() {
setForwardSaving(false) setForwardSaving(false)
} }
}, },
[ [connectionId, forwardRules, forwardRuntimes, forwardSaving, persistForwardRules, startForwardRule, t, toast]
connectionId,
forwardRules,
forwardRuntimes,
forwardSaving,
persistForwardRules,
startForwardRule,
t,
toast,
]
) )
const handleStopRule = useCallback( const handleStopRule = useCallback(
@@ -788,8 +730,7 @@ export default function StatusBar() {
await stopForwardRule(rule) await stopForwardRule(rule)
} catch (error) { } catch (error) {
setForwardDrawerError( setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) || (error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
t('common:portForward.errorStopFailed')
) )
} finally { } finally {
setForwardSaving(false) setForwardSaving(false)
@@ -811,8 +752,7 @@ export default function StatusBar() {
await persistForwardRules(forwardRules.filter((item) => item.id !== rule.id)) await persistForwardRules(forwardRules.filter((item) => item.id !== rule.id))
} catch (error) { } catch (error) {
setForwardDrawerError( setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) || (error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
t('common:portForward.errorStopFailed')
) )
} finally { } finally {
setForwardSaving(false) setForwardSaving(false)
@@ -872,14 +812,8 @@ export default function StatusBar() {
type: forwardDraft.type || 'local', type: forwardDraft.type || 'local',
name: forwardDraft.name.trim(), name: forwardDraft.name.trim(),
localPort: forwardDraft.localPort, localPort: forwardDraft.localPort,
localBindHost: localBindHost: forwardDraft.type === 'local' ? resolveLocalBindHost(forwardDraft.localBindHost) : undefined,
forwardDraft.type === 'local' remoteBindHost: forwardDraft.type === 'remote' ? resolveRemoteBindHost(forwardDraft.remoteBindHost) : undefined,
? resolveLocalBindHost(forwardDraft.localBindHost)
: undefined,
remoteBindHost:
forwardDraft.type === 'remote'
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
: undefined,
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost), remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
remotePort: forwardDraft.remotePort, remotePort: forwardDraft.remotePort,
} }
@@ -959,35 +893,32 @@ export default function StatusBar() {
const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
const statusLabel = t(statusCfg.labelKey) const statusLabel = t(statusCfg.labelKey)
const transferLabel = const transferLabel = stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
return ( return (
<div className="border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary"> <div className="app-statusbar border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
<button <button
type="button" type="button"
onClick={togglePanel} onClick={togglePanel}
className={`-ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors className={`hover:bg-dark-700/70 group -ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors ${statusCfg.colorClass} ${statusCfg.animateClass}`}
hover:bg-dark-700/70 group
${statusCfg.colorClass} ${statusCfg.animateClass}`}
title={t('common:connectionPanel.title')} title={t('common:connectionPanel.title')}
> >
{statusCfg.icon && <span className="transition-colors duration-300">{statusCfg.icon}</span>} {statusCfg.icon && <span className="transition-colors duration-300">{statusCfg.icon}</span>}
<span className="font-medium transition-colors duration-300">{statusLabel}</span> <span className="font-medium transition-colors duration-300">{statusLabel}</span>
{hostLabel && ( {hostLabel && (
<> <>
<span className="text-txt-faint mx-0.5">·</span> <span className="mx-0.5 text-txt-faint">·</span>
<span className="text-content-secondary max-w-[280px] truncate font-mono" title={hostLabel}> <span className="max-w-[280px] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel} {hostLabel}
</span> </span>
</> </>
)} )}
{totalConnections > 0 && ( {totalConnections > 0 && (
<span className="bg-accent-blue/20 ml-1 rounded-full px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue"> <span className="ml-1 rounded-full bg-accent-blue/20 px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
{totalConnections} {totalConnections}
</span> </span>
)} )}
<ChevronUp className="text-txt-faint h-3 w-3 transition-transform duration-200 group-hover:rotate-180" /> <ChevronUp className="h-3 w-3 text-txt-faint transition-transform duration-200 group-hover:rotate-180" />
</button> </button>
{metricsEnabled && ( {metricsEnabled && (
@@ -1014,12 +945,11 @@ export default function StatusBar() {
} }
aria-label={t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })} aria-label={t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })}
aria-pressed={socksRunning} aria-pressed={socksRunning}
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50 className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50 ${
${ socksRunning
socksRunning ? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30' : 'hover:bg-dark-700/70 text-txt-muted hover:text-content-secondary'
: 'text-txt-muted hover:bg-dark-700/70 hover:text-content-secondary' }`}
}`}
> >
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />} {forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
<span className="tabular-nums"> <span className="tabular-nums">
@@ -1028,7 +958,7 @@ export default function StatusBar() {
</button> </button>
)} )}
<div className="text-txt-faint flex items-center gap-1 tabular-nums"> <div className="flex items-center gap-1 tabular-nums text-txt-faint">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span>{formatTime(currentTime)}</span> <span>{formatTime(currentTime)}</span>
</div> </div>
@@ -1046,18 +976,9 @@ export default function StatusBar() {
setForwardDraft({ setForwardDraft({
type: next.type || 'local', type: next.type || 'local',
name: next.name || '', name: next.name || '',
localPort: localPort: next.type === 'dynamic' ? resolveDynamicPort(next.localPort) : next.localPort || 0,
next.type === 'dynamic' localBindHost: next.type === 'local' ? resolveLocalBindHost(next.localBindHost) : '',
? resolveDynamicPort(next.localPort) remoteBindHost: next.type === 'remote' ? resolveRemoteBindHost(next.remoteBindHost) : '',
: next.localPort || 0,
localBindHost:
next.type === 'local'
? resolveLocalBindHost(next.localBindHost)
: '',
remoteBindHost:
next.type === 'remote'
? resolveRemoteBindHost(next.remoteBindHost)
: '',
remoteHost: next.remoteHost || '', remoteHost: next.remoteHost || '',
remotePort: next.remotePort || 0, remotePort: next.remotePort || 0,
}) })
+12 -2
View File
@@ -15,6 +15,7 @@ import {
} from '../../utils/disposeTab' } from '../../utils/disposeTab'
import { TabItem } from './tabbar/TabItem' import { TabItem } from './tabbar/TabItem'
import { TabContextMenu } from './tabbar/TabContextMenu' import { TabContextMenu } from './tabbar/TabContextMenu'
import { requestTabReconnect, tabSupportsReconnect } from '../../utils/tabReconnect'
interface TabBarProps { interface TabBarProps {
onOpenLauncher?: () => void onOpenLauncher?: () => void
@@ -109,9 +110,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
return ( return (
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"> <div className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */} {/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch"> <div className="app-no-drag flex min-w-0 flex-1 items-stretch">
<div <div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto" className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist" role="tablist"
@@ -209,6 +210,15 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
} }
: undefined : undefined
} }
onReconnect={
tabSupportsReconnect(currentTab)
? () => {
if (!menuState.tabId) return
activateTab(menuState.tabId)
requestTabReconnect(menuState.tabId)
}
: undefined
}
/> />
</div> </div>
) )
+5 -2
View File
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import ErrorBoundary from '../common/ErrorBoundary'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import WelcomeView from '../sessions/WelcomeView' import WelcomeView from '../sessions/WelcomeView'
import TerminalWorkspace from '../terminal/TerminalWorkspace' import TerminalWorkspace from '../terminal/TerminalWorkspace'
@@ -39,7 +40,7 @@ function TabContent({
const tabs = useTabStore((state) => state.tabs) const tabs = useTabStore((state) => state.tabs)
return ( return (
<div className="relative h-full w-full"> <div className="relative h-full min-h-0 w-full overflow-hidden">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTabId const isActive = tab.id === activeTabId
@@ -119,7 +120,9 @@ function TabContent({
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0' isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
}`} }`}
> >
{content} <ErrorBoundary compact resetKey={tab.id}>
{content}
</ErrorBoundary>
</div> </div>
) )
})} })}
@@ -76,7 +76,7 @@ function quietIfNoOp(
return { group, index: insertAt } return { group, index: insertAt }
} }
export function resolveSessionDropTarget( function resolveSessionDropTarget(
clientX: number, clientX: number,
clientY: number, clientY: number,
dragId: string, dragId: string,
@@ -296,7 +296,7 @@ export function SessionTree({
return ( return (
<div <div
ref={scrollerRef} ref={scrollerRef}
className="flex-1 overflow-y-auto" className="session-tree flex-1 overflow-y-auto"
onContextMenu={(e) => { onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
onBlankContextMenu?.(e) onBlankContextMenu?.(e)
@@ -320,7 +320,7 @@ export function SessionTree({
e.stopPropagation() e.stopPropagation()
onGroupContextMenu?.(e, group) onGroupContextMenu?.(e, group)
}} }}
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${ className={`hover:bg-dark-700/50 flex w-full cursor-default items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : '' groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
}`} }`}
> >
@@ -391,7 +391,7 @@ export function SessionTree({
? t('tree.selectSession') ? t('tree.selectSession')
: `${session.username}@${session.host}:${session.port || 22}` : `${session.username}@${session.host}:${session.port || 22}`
} }
className={`group relative flex touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragEnabled ? 'cursor-grab' : 'cursor-pointer'} ${dragId === session.id ? 'opacity-40' : ''} ${ className={`group relative flex cursor-default touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragId === session.id ? 'opacity-40' : ''} ${
selected selected
? 'border-l-2 border-accent-blue bg-accent-blue/10' ? 'border-l-2 border-accent-blue bg-accent-blue/10'
: 'hover:bg-dark-700/40 border-l-2 border-transparent' : 'hover:bg-dark-700/40 border-l-2 border-transparent'
+32 -12
View File
@@ -2,14 +2,14 @@
* TabContextMenu - 标签右键菜单 * TabContextMenu - 标签右键菜单
*/ */
import { useRef, useEffect } from 'react' import { useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface MenuItem { interface MenuItem {
label: string label: string
action: () => void action: () => void
danger?: boolean danger?: boolean
separator?: boolean
} }
interface TabContextMenuProps { interface TabContextMenuProps {
@@ -23,6 +23,7 @@ interface TabContextMenuProps {
onCloseAll: () => void onCloseAll: () => void
onClone?: () => void onClone?: () => void
onOpenSftp?: () => void onOpenSftp?: () => void
onReconnect?: () => void
} }
export function TabContextMenu({ export function TabContextMenu({
@@ -36,6 +37,7 @@ export function TabContextMenu({
onCloseAll, onCloseAll,
onClone, onClone,
onOpenSftp, onOpenSftp,
onReconnect,
}: TabContextMenuProps) { }: TabContextMenuProps) {
const { t } = useTranslation('common') const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
@@ -60,6 +62,24 @@ export function TabContextMenu({
} }
}, [isOpen]) }, [isOpen])
useLayoutEffect(() => {
const el = menuRef.current
if (!el || !isOpen) return
const margin = 8
const width = el.offsetWidth
const height = el.offsetHeight
let left = position.x
let top = position.y
if (left + width + margin > window.innerWidth) {
left = Math.max(margin, window.innerWidth - width - margin)
}
if (top + height + margin > window.innerHeight) {
top = Math.max(margin, window.innerHeight - height - margin)
}
el.style.left = `${left}px`
el.style.top = `${top}px`
}, [isOpen, position.x, position.y])
if (!isOpen) return null if (!isOpen) return null
const items: MenuItem[] = [ const items: MenuItem[] = [
@@ -70,6 +90,9 @@ export function TabContextMenu({
{ label: t('tab.menu.closeAll'), action: onCloseAll, danger: true }, { label: t('tab.menu.closeAll'), action: onCloseAll, danger: true },
] ]
if (onReconnect) {
items.unshift({ label: t('tab.menu.reconnect'), action: onReconnect })
}
if (onClone) { if (onClone) {
items.unshift({ label: t('tab.menu.clone'), action: onClone }) items.unshift({ label: t('tab.menu.clone'), action: onClone })
} }
@@ -77,11 +100,11 @@ export function TabContextMenu({
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp }) items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
} }
return ( return createPortal(
<div <div
ref={menuRef} ref={menuRef}
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]" className="fixed z-[200] w-max min-w-[8.5rem] rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
{items.map((item, index) => ( {items.map((item, index) => (
@@ -92,17 +115,14 @@ export function TabContextMenu({
item.action() item.action()
onClose() onClose()
}} }}
className={` className={`block w-full whitespace-nowrap px-3 py-1.5 text-left text-[13px] transition-colors ${
w-full px-4 py-2 text-left text-sm transition-colors item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
${item.danger }`}
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
}
`}
> >
{item.label} {item.label}
</button> </button>
))} ))}
</div> </div>,
document.body
) )
} }
+15 -14
View File
@@ -4,11 +4,8 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Crown, Sparkles, X } from 'lucide-react' import { Crown, Sparkles, X } from 'lucide-react'
import { import { proFeatureLabel, useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
proFeatureLabel, import AppOverlay from '../common/AppOverlay'
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
export default function UpgradeWall() { export default function UpgradeWall() {
const { t } = useTranslation('common') const { t } = useTranslation('common')
@@ -23,16 +20,20 @@ export default function UpgradeWall() {
const comingSoon = wall.comingSoon const comingSoon = wall.comingSoon
return ( return (
<div <AppOverlay
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm" open
role="dialog" className="z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
aria-modal="true" onMouseDown={(event) => {
aria-labelledby="upgrade-wall-title" if (event.target === event.currentTarget) closeWall()
onClick={closeWall} }}
> >
<div <div
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-dark-600/80 bg-dark-800 shadow-2xl" className="border-dark-600/80 relative w-full max-w-md overflow-hidden rounded-2xl border bg-dark-800 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-wall-title"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
> >
<button <button
type="button" type="button"
@@ -43,7 +44,7 @@ export default function UpgradeWall() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
<div className="border-b border-dark-700/50 bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6"> <div className="border-dark-700/50 border-b bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue"> <div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
<Crown className="h-5 w-5" /> <Crown className="h-5 w-5" />
</div> </div>
@@ -80,6 +81,6 @@ export default function UpgradeWall() {
</button> </button>
</div> </div>
</div> </div>
</div> </AppOverlay>
) )
} }
+341 -319
View File
@@ -22,6 +22,7 @@ import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution' import { getDisplayResolution } from '../../utils/desktopResolution'
import { useToast } from '../../hooks/useToast' import { useToast } from '../../hooks/useToast'
import { translateErrorMessage } from '../../i18n/format' import { translateErrorMessage } from '../../i18n/format'
import AppOverlay from '../common/AppOverlay'
const logger = createLogger('SessionDialog') const logger = createLogger('SessionDialog')
@@ -45,6 +46,18 @@ type KeyInputMode = 'file' | 'text'
const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519' const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519'
function defaultDisplayName(protocol: SessionProtocol, host: string, username: string): string {
if (!host) return ''
return protocol === 'vnc' ? host : `${username}@${host}`
}
function isGeneratedDisplayName(name: string, host: string, previousHost = ''): boolean {
const trimmed = name.trim()
if (!trimmed) return true
if (trimmed.includes('@')) return true
return [host, previousHost].filter(Boolean).some((value) => trimmed === value || trimmed === `vnc:${value}`)
}
function isPemPrivateKey(value: string | undefined | null): boolean { function isPemPrivateKey(value: string | undefined | null): boolean {
return Boolean(value && value.includes('-----BEGIN')) return Boolean(value && value.includes('-----BEGIN'))
} }
@@ -220,10 +233,9 @@ export default function SessionDialog({
host: parsed.host, host: parsed.host,
port: targetIncludesPort(trimmed) ? parsed.port : prev.port, port: targetIncludesPort(trimmed) ? parsed.port : prev.port,
username: parsed.username || prev.username, username: parsed.username || prev.username,
name: name: isGeneratedDisplayName(prev.name, parsed.host, prev.host)
prev.name.trim() && !prev.name.includes('@') ? `${parsed.username || prev.username}@${parsed.host}`
? prev.name : prev.name,
: `${parsed.username || prev.username}@${parsed.host}`,
})) }))
} }
return return
@@ -235,12 +247,13 @@ export default function SessionDialog({
...prev, ...prev,
host, host,
port: portMatch ? Number(portMatch[1]) : prev.port, port: portMatch ? Number(portMatch[1]) : prev.port,
name: name: isGeneratedDisplayName(prev.name, host, prev.host)
prev.name.trim() && !prev.name.includes('@') && !prev.name.startsWith('vnc:') ? defaultDisplayName(
? prev.name formData.protocol,
: formData.protocol === 'vnc' host,
? `vnc:${host}` prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
: `${prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')}@${host}`, )
: prev.name,
})) }))
} }
@@ -320,7 +333,7 @@ export default function SessionDialog({
: undefined : undefined
const sessionData = { const sessionData = {
name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`), name: formData.name.trim() || defaultDisplayName(formData.protocol, host, username),
host, host,
port, port,
username, username,
@@ -436,8 +449,7 @@ export default function SessionDialog({
return ( return (
<> <>
<div className="fixed inset-0 z-[100] flex justify-end"> <AppOverlay open className="z-[100] flex justify-end">
{/* Backdrop */}
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} /> <div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
{/* Drawer */} {/* Drawer */}
@@ -450,7 +462,7 @@ export default function SessionDialog({
{/* Header */} {/* Header */}
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5"> <div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue"> <div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<Server className="w-4.5 h-4.5 h-4 w-4" /> <Server className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary"> <h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
@@ -507,7 +519,11 @@ export default function SessionDialog({
autoFocus={!isEditing} autoFocus={!isEditing}
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('dialog.displayNamePlaceholder')} placeholder={
formData.protocol === 'vnc'
? t('dialog.displayNamePlaceholderVnc')
: t('dialog.displayNamePlaceholder')
}
className="ui-input" className="ui-input"
/> />
</div> </div>
@@ -576,12 +592,13 @@ export default function SessionDialog({
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
username, username,
name: name: isGeneratedDisplayName(prev.name, prev.host)
prev.name.trim() && !prev.name.includes('@') ? defaultDisplayName(
? prev.name prev.protocol,
: prev.host prev.host,
? `${username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')}@${prev.host}` username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')
: prev.name, )
: prev.name,
})) }))
}} }}
placeholder={ placeholder={
@@ -595,20 +612,34 @@ export default function SessionDialog({
</div> </div>
)} )}
{formData.protocol === 'rdp' && ( {formData.protocol === 'rdp' && (
<div> <>
<label htmlFor="session-password" className="ui-label"> <div>
{t('dialog.password')} <label htmlFor="session-password" className="ui-label">
</label> {t('dialog.password')}
<input </label>
id="session-password" <input
type="password" id="session-password"
value={formData.password} type="password"
onChange={(e) => setFormData({ ...formData, password: e.target.value })} value={formData.password}
placeholder="••••••••" onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="ui-input" placeholder="••••••••"
autoComplete="current-password" className="ui-input"
/> autoComplete="current-password"
</div> />
</div>
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
</>
)} )}
{formData.protocol !== 'ssh' && ( {formData.protocol !== 'ssh' && (
<DesktopResolutionPicker <DesktopResolutionPicker
@@ -622,168 +653,155 @@ export default function SessionDialog({
{formData.protocol === 'rdp' && groupField} {formData.protocol === 'rdp' && groupField}
</section> </section>
{/* 凭据 */} {/* 凭据:SSH / VNC。RDP 的密码和域已在基本区,避免重复。 */}
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5"> {(isSsh || formData.protocol === 'vnc') && (
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint"> <section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
{t('dialog.login')} <div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
</div> {t('dialog.login')}
{isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{authTypes.map((type) => {
const Icon = type.icon
const active = formData.authType === type.value
return (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, authType: type.value })}
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{type.label}
</button>
)
})}
</div> </div>
)} {isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(formData.authType === 'password' || formData.protocol === 'vnc') && ( {authTypes.map((type) => {
<div> const Icon = type.icon
<label htmlFor="session-password" className="ui-label"> const active = formData.authType === type.value
{t('dialog.password')} return (
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
)}
{formData.protocol === 'rdp' && (
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
)}
{isSsh && formData.authType === 'key' && (
<div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
<button <button
key={type.value}
type="button" type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600" onClick={() => setFormData({ ...formData, authType: type.value })}
onClick={async () => { className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
const result = await window.opsxApi?.showOpenDialog({ active
title: t('dialog.pickKeyDialogTitle'), ? 'bg-dark-700 text-content-primary shadow-sm'
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }], : 'text-txt-muted hover:text-content-secondary'
}) }`}
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
> >
{t('dialog.pickKeyFile')} <Icon className="h-3.5 w-3.5" />
{type.label}
</button> </button>
</div> )
</div> })}
) : ( </div>
<div> )}
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
{((isSsh && formData.authType === 'password') || formData.protocol === 'vnc') && (
<div> <div>
<label htmlFor="session-passphrase" className="ui-label"> <label htmlFor="session-password" className="ui-label">
{t('dialog.passphrase')} {t('dialog.password')}
</label> </label>
<input <input
id="session-passphrase" id="session-password"
type="password" type="password"
value={formData.passphrase} value={formData.password}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')} placeholder="••••••••"
className="ui-input" className="ui-input"
autoComplete="current-password"
/> />
</div> </div>
</div> )}
)}
{(isSsh || formData.protocol === 'vnc') && groupField} {isSsh && formData.authType === 'key' && (
</section> <div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
<button
type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
onClick={async () => {
const result = await window.opsxApi?.showOpenDialog({
title: t('dialog.pickKeyDialogTitle'),
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
})
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
>
{t('dialog.pickKeyFile')}
</button>
</div>
</div>
) : (
<div>
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
<div>
<label htmlFor="session-passphrase" className="ui-label">
{t('dialog.passphrase')}
</label>
<input
id="session-passphrase"
type="password"
value={formData.passphrase}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')}
className="ui-input"
/>
</div>
</div>
)}
{groupField}
</section>
)}
{/* 高级 */} {/* 高级 */}
<div className="border-dark-700/50 overflow-hidden rounded-xl border"> <div className="border-dark-700/50 overflow-hidden rounded-xl border">
@@ -794,7 +812,7 @@ export default function SessionDialog({
> >
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span> <span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint"> <span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')} {showAdvanced ? t('dialog.collapse') : isSsh ? t('dialog.advancedCollapsed') : t('dialog.tags')}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
</span> </span>
</button> </button>
@@ -817,146 +835,150 @@ export default function SessionDialog({
</div> </div>
</div> </div>
<div> {isSsh && (
<label className="ui-label">{t('dialog.startupCommand')}</label> <>
<input <div>
type="text" <label className="ui-label">{t('dialog.startupCommand')}</label>
value={formData.startupCommand} <input
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })} type="text"
placeholder={t('dialog.startupCommandPlaceholder')} value={formData.startupCommand}
className="ui-input-sm font-mono" onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
/> placeholder={t('dialog.startupCommandPlaceholder')}
</div> className="ui-input-sm font-mono"
/>
</div>
<div> <div>
<label className="ui-label">{t('dialog.proxy')}</label> <label className="ui-label">{t('dialog.proxy')}</label>
<div className="mb-2 flex gap-1"> <div className="mb-2 flex gap-1">
{( {(
[ [
{ value: 'none', label: t('dialog.proxyNone') }, { value: 'none', label: t('dialog.proxyNone') },
{ value: 'http', label: 'HTTP' }, { value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' }, { value: 'socks5', label: 'SOCKS5' },
] as const ] as const
).map((t) => ( ).map((option) => (
<button <button
key={t.value} key={option.value}
type="button" type="button"
onClick={() => { onClick={() => {
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) { if (option.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return return
} }
setFormData({
...formData,
proxy: {
...formData.proxy,
type: option.value,
port:
formData.proxy.port ||
(option.value === 'http' ? 8080 : option.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: option.value === 'none' ? formData.jumpSessionIds : [],
})
}}
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
formData.proxy.type === option.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
>
{option.label}
</button>
))}
</div>
{formData.proxy.type !== 'none' && (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<input
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
</div>
)}
</div>
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({ setFormData({
...formData, ...formData,
proxy: { jumpSessionIds: event.target.value ? [event.target.value] : [],
...formData.proxy, proxy: event.target.value
type: t.value, ? { type: 'none', host: '', port: 0, username: '', password: '' }
port: : formData.proxy,
formData.proxy.port ||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
}) })
}} }
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${ className="ui-input-sm"
formData.proxy.type === t.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
> >
{t.label} <option value="">{t('dialog.jumpNoneAlt')}</option>
</button> {jumpCandidates.map((session) => (
))} <option key={session.id} value={session.id}>
</div> {session.name} ({session.username}@{session.host})
{formData.proxy.type !== 'none' && ( </option>
<div className="space-y-1.5"> ))}
<div className="grid grid-cols-3 gap-1.5"> </select>
<input <p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
</div> </div>
)} </>
</div> )}
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({
...formData,
jumpSessionIds: event.target.value ? [event.target.value] : [],
proxy: event.target.value
? { type: 'none', host: '', port: 0, username: '', password: '' }
: formData.proxy,
})
}
className="ui-input-sm"
>
<option value="">{t('dialog.jumpNoneAlt')}</option>
{jumpCandidates.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.username}@{session.host})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
</div>
</div> </div>
)} )}
</div> </div>
@@ -985,7 +1007,7 @@ export default function SessionDialog({
</div> </div>
</form> </form>
</aside> </aside>
</div> </AppOverlay>
{PromptDialogComponent} {PromptDialogComponent}
</> </>
) )
+97 -84
View File
@@ -200,12 +200,12 @@ export default function AiProviderSettings() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2">
<select <select
aria-label={t('ai.presets')} aria-label={t('ai.presets')}
value={presetId} value={presetId}
onChange={(event) => setPresetId(event.target.value)} onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1" className="ui-select-pill"
> >
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => ( {AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}> <optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
@@ -228,11 +228,11 @@ export default function AiProviderSettings() {
</Button> </Button>
</div> </div>
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6"> <div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
<aside className="space-y-0.5"> <aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div> <div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
{store.providers.length === 0 ? ( {store.providers.length === 0 ? (
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p> <p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
) : ( ) : (
store.providers.map((provider) => { store.providers.map((provider) => {
const model = provider.models.find((item) => item.id === provider.defaultModelId) const model = provider.models.find((item) => item.id === provider.defaultModelId)
@@ -241,7 +241,7 @@ export default function AiProviderSettings() {
key={provider.id} key={provider.id}
type="button" type="button"
onClick={() => setSelectedId(provider.id)} onClick={() => setSelectedId(provider.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${ className={`w-full px-3 py-2 text-left transition-colors ${
selectedId === provider.id selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue' ? 'bg-accent-blue/12 text-accent-blue'
: 'hover:bg-dark-700/50 text-content-secondary' : 'hover:bg-dark-700/50 text-content-secondary'
@@ -260,34 +260,40 @@ export default function AiProviderSettings() {
)} )}
</aside> </aside>
<main className="min-w-0"> <main className="min-w-0 max-w-lg">
{!draft ? ( {!draft ? (
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div> <div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
{t('ai.emptyHint')}
</div>
) : ( ) : (
<div className="max-w-xl space-y-3.5"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<Field label={t('ai.name')}> <FormRow label={t('ai.name')}>
<input <CompactField>
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
className="ui-input-sm"
/>
</Field>
<Field label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<input <input
aria-label={t('ai.model')} value={draft.name}
value={currentModel?.model || ''} onChange={(event) => updateDraft({ name: event.target.value })}
onChange={(event) => updateManualModel(event.target.value)} className="ui-input-sm"
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
/> />
</CompactField>
</FormRow>
<FormRow label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<CompactField>
<input
aria-label={t('ai.model')}
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
/>
</CompactField>
) : ( ) : (
<select <select
aria-label={t('ai.model')} aria-label={t('ai.model')}
value={draft.defaultModelId} value={draft.defaultModelId}
onChange={(event) => chooseModel(event.target.value)} onChange={(event) => chooseModel(event.target.value)}
className="ui-input-sm" className="ui-select-pill"
> >
{draft.models.map((model) => ( {draft.models.map((model) => (
<option key={model.id} value={model.id}> <option key={model.id} value={model.id}>
@@ -297,100 +303,103 @@ export default function AiProviderSettings() {
<option value="__manual">{t('ai.otherModels')}</option> <option value="__manual">{t('ai.otherModels')}</option>
</select> </select>
)} )}
</Field> </FormRow>
{manualModel && draft.models.length > 0 && (
<div className="flex justify-end px-4 py-2">
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
</div>
)}
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
<CompactField wide>
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={
draft.hasApiKey
? t('ai.savedKeepBlank')
: draft.authStyle === 'none'
? t('ai.notRequired')
: 'sk-...'
}
className="ui-input-sm"
/>
</CompactField>
</FormRow>
{(needsEndpoint || advancedOpen) && (
<FormRow label={t('ai.endpoint')}>
<CompactField wide>
<input
aria-label={t('ai.endpoint')}
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</CompactField>
</FormRow>
)}
</div> </div>
{manualModel && draft.models.length > 0 && (
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
)}
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={
draft.hasApiKey
? t('ai.savedKeepBlank')
: draft.authStyle === 'none'
? t('ai.notRequired')
: 'sk-...'
}
className="ui-input-sm"
/>
</Field>
{(needsEndpoint || advancedOpen) && (
<Field label="Endpoint">
<input
aria-label="Endpoint"
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</Field>
)}
<button <button
type="button" type="button"
onClick={() => setAdvancedOpen((open) => !open)} onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted" className="flex items-center gap-1 px-0.5 text-xs text-txt-faint hover:text-txt-muted"
> >
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
{t('ai.advanced')} {t('ai.advanced')}
</button> </button>
{advancedOpen && ( {advancedOpen && (
<div className="grid grid-cols-2 gap-3"> <div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<Field label={t('ai.protocol')}> <FormRow label={t('ai.protocol')}>
<select <select
value={draft.protocol} value={draft.protocol}
onChange={(event) => onChange={(event) =>
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] }) updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
} }
className="ui-input-sm" className="ui-select-pill"
> >
<option value="openai-chat">OpenAI Chat</option> <option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option> <option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
</select> </select>
</Field> </FormRow>
<Field label={t('ai.auth')}> <FormRow label={t('ai.auth')}>
<select <select
value={draft.authStyle} value={draft.authStyle}
onChange={(event) => onChange={(event) =>
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] }) updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
} }
className="ui-input-sm" className="ui-select-pill"
> >
<option value="bearer">Bearer</option> <option value="bearer">Bearer</option>
<option value="x-api-key">x-api-key</option> <option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option> <option value="both">Bearer + x-api-key</option>
<option value="none">{t('ai.authNone')}</option> <option value="none">{t('ai.authNone')}</option>
</select> </select>
</Field> </FormRow>
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted"> <FormRow label={t('ai.allowHttp')}>
<input <input
type="checkbox" type="checkbox"
checked={draft.allowInsecureHttp} checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })} onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="h-3.5 w-3.5 accent-blue-500"
/> />
{t('ai.allowHttp')} </FormRow>
</label>
</div> </div>
)} )}
<div className="flex flex-wrap items-center gap-2 pt-1"> <div className="flex flex-wrap items-center gap-2 pt-0.5">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -450,11 +459,15 @@ export default function AiProviderSettings() {
) )
} }
function Field({ label, children }: { label: string; children: React.ReactNode }) { function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<label className="block"> <label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span> <span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
{children} <div className="ml-auto flex-shrink-0">{children}</div>
</label> </label>
) )
} }
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
}
+178 -73
View File
@@ -18,12 +18,21 @@ import {
Check, Check,
Sparkles, Sparkles,
UserRound, UserRound,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import AiProviderSettings from './AiProviderSettings' import AiProviderSettings from './AiProviderSettings'
import AccountSettings from './AccountSettings' import AccountSettings from './AccountSettings'
import { themes, type TerminalThemeId } from '../../themes/terminal' import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings' import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch' import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
import {
buildBugReportUrl,
formatRuntimeSystem,
GITHUB_DISCUSSIONS_URL,
GITHUB_LICENSE_URL,
GITHUB_RELEASES_URL,
WECHAT_ID,
} from '../../utils/bugReport'
// ─── Tab 类型 ─────────────────────────────────────────────── // ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about' type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
@@ -148,6 +157,17 @@ export default function SettingsView() {
} }
}, []) }, [])
const openBugReport = useCallback(() => {
const url = buildBugReportUrl({
appVersion: runtimeInfo?.appVersion,
os: runtimeInfo?.os,
arch: runtimeInfo?.arch,
logPath: runtimeInfo?.logPath,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
})
openHttpUrl(url)
}, [runtimeInfo])
// 当前 tab 元信息 // 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab]) const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
@@ -171,7 +191,7 @@ export default function SettingsView() {
const diagnosticText = runtimeInfo const diagnosticText = runtimeInfo
? [ ? [
`OpsXShell v${runtimeInfo.appVersion}`, `OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`, formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch),
`Data: ${runtimeInfo.dataPath}`, `Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`, `Log: ${runtimeInfo.logPath}`,
].join('\n') ].join('\n')
@@ -249,7 +269,7 @@ export default function SettingsView() {
}`} }`}
> >
<span <span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${ className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0' settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
}`} }`}
/> />
@@ -334,7 +354,7 @@ export default function SettingsView() {
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)} onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${ className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
settings.terminalTheme === key settings.terminalTheme === key
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1' ? 'border-accent-blue bg-accent-blue/10 ring-1 ring-accent-blue/30'
: 'border-dark-600/60 hover:border-dark-500' : 'border-dark-600/60 hover:border-dark-500'
}`} }`}
> >
@@ -389,7 +409,9 @@ export default function SettingsView() {
const defs = getKeybindingsByCategory(category) const defs = getKeybindingsByCategory(category)
return ( return (
<SettingCard key={category}> <SettingCard key={category}>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${category}`)}</h3> <h3 className="mb-3 text-sm font-semibold text-content-secondary">
{t(`shortcuts.category.${category}`)}
</h3>
<div className="space-y-1"> <div className="space-y-1">
{defs.map((def) => { {defs.map((def) => {
const currentKeys = allKeys[def.id] const currentKeys = allKeys[def.id]
@@ -400,13 +422,13 @@ export default function SettingsView() {
<div <div
key={def.id} key={def.id}
className={`flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${ className={`flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
isRecordingThis ? 'bg-accent-blue/10 border-accent-blue/30 border' : 'hover:bg-dark-800/60' isRecordingThis ? 'border border-accent-blue/30 bg-accent-blue/10' : 'hover:bg-dark-800/60'
}`} }`}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="text-sm text-content-primary">{t(def.labelKey)}</span> <span className="text-sm text-content-primary">{t(def.labelKey)}</span>
{isCustom && ( {isCustom && (
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue"> <span className="ml-2 rounded bg-accent-blue/10 px-1.5 py-0.5 text-[10px] text-accent-blue">
{t('shortcuts.custom')} {t('shortcuts.custom')}
</span> </span>
)} )}
@@ -415,7 +437,7 @@ export default function SettingsView() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isRecordingThis ? ( {isRecordingThis ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue"> <kbd className="inline-flex animate-pulse items-center rounded border border-accent-blue/40 bg-accent-blue/20 px-3 py-1 font-mono text-xs text-accent-blue">
{recordedKeys || t('shortcuts.pressKeys')} {recordedKeys || t('shortcuts.pressKeys')}
</kbd> </kbd>
<button <button
@@ -442,7 +464,7 @@ export default function SettingsView() {
<kbd <kbd
className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${ className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${
isCustom isCustom
? 'bg-accent-blue/10 border-accent-blue/30 border text-accent-blue' ? 'border border-accent-blue/30 bg-accent-blue/10 text-accent-blue'
: 'border border-dark-600 bg-dark-700 text-content-secondary' : 'border border-dark-600 bg-dark-700 text-content-secondary'
} group-hover:border-accent-blue/50`} } group-hover:border-accent-blue/50`}
> >
@@ -472,15 +494,13 @@ export default function SettingsView() {
{/* 录入确认弹层 */} {/* 录入确认弹层 */}
{recordingId && recordedKeys && ( {recordingId && recordedKeys && (
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl"> <div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
<span className="text-sm text-content-secondary"> <span className="text-sm text-content-secondary">{t('shortcuts.setTo', { keys: recordedKeys })}</span>
{t('shortcuts.setTo', { keys: recordedKeys })}
</span>
<button <button
onClick={async () => { onClick={async () => {
await keybindingState.setCustomKeybinding(recordingId, recordedKeys) await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
stopRecording() stopRecording()
}} }}
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors" className="rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors hover:bg-accent-blue/80"
> >
{t('common:action.confirm')} {t('common:action.confirm')}
</button> </button>
@@ -525,6 +545,7 @@ export default function SettingsView() {
checked={settings.autoReconnect !== false} checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)} onChange={(v) => setSetting('autoReconnect', v)}
/> />
<p className="px-3 py-2 text-[11px] leading-relaxed text-txt-faint">{t('ssh.sessionPolicyHint')}</p>
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}> <SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
<input <input
type="number" type="number"
@@ -588,12 +609,12 @@ export default function SettingsView() {
<div className="space-y-6"> <div className="space-y-6">
<SettingGroup title={t('security.credentials')}> <SettingGroup title={t('security.credentials')}>
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}> <SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green"> <span className="rounded-md border border-accent-green/25 bg-accent-green/10 px-2 py-1 text-xs font-medium text-accent-green">
{t('security.enabled')} {t('security.enabled')}
</span> </span>
</SettingRow> </SettingRow>
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}> <SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
<code className="text-xs text-txt-muted break-all"> <code className="break-all text-xs text-txt-muted">
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'} {runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code> </code>
</SettingRow> </SettingRow>
@@ -605,71 +626,101 @@ export default function SettingsView() {
// ──────── 关于 ───────── // ──────── 关于 ─────────
case 'about': case 'about':
return ( return (
<div className="max-w-2xl space-y-5"> <div className="max-w-xl space-y-6">
<SettingCard> <SettingCard>
<div className="flex items-start gap-4 py-2"> <div className="flex items-center gap-4 py-1">
<div className="border-dark-600/50 flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl border bg-dark-700"> <img
<Terminal className="h-8 w-8 text-content-primary" /> src="/app-icon.png"
</div> alt=""
width={64}
height={64}
className="h-16 w-16 flex-shrink-0 rounded-[18px] shadow-sm"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3> <h3 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h3>
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p> <p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-txt-faint">
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue"> <span className="font-mono text-content-secondary">
{t('about.appVersion', { version: appVersion })} {t('about.appVersion', { version: appVersion })}
</span> </span>
<button {runtimeInfo ? (
type="button" <>
onClick={() => copyText('version', diagnosticText)} <span aria-hidden>·</span>
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary" <span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
title={t('about.copyVersion')} </>
> ) : null}
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
</button>
</div> </div>
</div> </div>
</div> </div>
</SettingCard> </SettingCard>
<SettingCard> <SettingGroup title={t('about.resources')}>
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle> <button
<div className="divide-dark-700/30 mt-3 divide-y text-sm"> type="button"
<div className="flex items-center justify-between gap-4 py-2"> onClick={() => void copyText('wechat', WECHAT_ID)}
<span className="text-txt-muted">{t('about.system')}</span> aria-label={t('about.copyWechat')}
<code className="text-right text-xs text-content-secondary"> className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')} >
</code> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('about.wechat')}</div>
<div className="mt-0.5 font-mono text-xs leading-relaxed text-txt-muted">{WECHAT_ID}</div>
</div> </div>
</div> {copiedField === 'wechat' ? (
</SettingCard> <Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
)}
</button>
<AboutLink label={t('about.reportBug')} description={t('about.reportBugDesc')} onClick={openBugReport} />
<AboutLink
label={t('about.github')}
description={t('about.githubDesc')}
onClick={() => openHttpUrl(GITHUB_DISCUSSIONS_URL)}
/>
<AboutLink
label={t('about.releases')}
description={t('about.releasesDesc')}
onClick={() => openHttpUrl(GITHUB_RELEASES_URL)}
/>
<AboutLink
label={t('about.licenseLink')}
description={t('about.licenseLinkDesc')}
onClick={() => openHttpUrl(GITHUB_LICENSE_URL)}
/>
</SettingGroup>
<SettingCard> <SettingGroup title={t('about.diagnostics')}>
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle> <SettingRow label={t('about.logPath')}>
<div className="mt-3 space-y-3 text-xs"> <CopyValueButton
<div> copied={copiedField === 'log'}
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div> value={runtimeInfo?.logPath}
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code> label={t('about.copyPath')}
</div> onCopy={() => runtimeInfo?.logPath && void copyText('log', runtimeInfo.logPath)}
<div> />
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div> </SettingRow>
<code className="block break-all text-content-secondary"> <SettingRow label={t('about.sessionsSettings')}>
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'} <CopyValueButton
</code> copied={copiedField === 'data'}
</div> value={runtimeInfo?.dataPath}
<div> label={t('about.copyPath')}
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div> onCopy={() => runtimeInfo?.dataPath && void copyText('data', runtimeInfo.dataPath)}
<code className="block break-all text-content-secondary"> />
{runtimeInfo?.logPath ?? t('common:status.reading')} </SettingRow>
</code> <SettingRow label={t('about.copyDiagnostics')} description={t('about.copyVersion')}>
</div> <button
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p> type="button"
</div> onClick={() => copyText('version', diagnosticText)}
</SettingCard> className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
</button>
</SettingRow>
</SettingGroup>
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p> <p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
</div> </div>
@@ -833,6 +884,65 @@ function KnownHostsSettingsCard() {
) )
} }
function openHttpUrl(url: string) {
const open = window.opsxApi?.openExternal
if (open) {
void open(url).catch(() => {
window.open(url, '_blank', 'noopener,noreferrer')
})
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{label}</div>
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
</div>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
</button>
)
}
function CopyValueButton({
value,
copied,
label,
onCopy,
}: {
value?: string
copied: boolean
label: string
onCopy: () => void
}) {
if (!value) {
return <span className="text-xs text-txt-faint">—</span>
}
return (
<button
type="button"
onClick={onCopy}
title={value}
aria-label={label}
className="inline-flex max-w-[16rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary"
>
<code className="truncate">{value}</code>
{copied ? (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5 flex-shrink-0" />
)}
</button>
)
}
/** 分组卡片:多行设置 */ /** 分组卡片:多行设置 */
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) { function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return ( return (
@@ -850,7 +960,7 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
return ( return (
<div <div
className={`rounded-2xl p-4 transition-colors ${ className={`rounded-2xl p-4 transition-colors ${
highlight ? 'bg-accent-blue/5 border-accent-blue/25 border' : 'bg-dark-800/50 border-dark-700/45 border' highlight ? 'border border-accent-blue/25 bg-accent-blue/5' : 'bg-dark-800/50 border-dark-700/45 border'
}`} }`}
> >
{children} {children}
@@ -858,11 +968,6 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
) )
} }
/** 区域小标题 */
function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <div className={`mb-1 text-sm font-semibold text-content-secondary ${className}`}>{children}</div>
}
/** 左文右控行 */ /** 左文右控行 */
function SettingRow({ function SettingRow({
label, label,
+467 -401
View File
@@ -6,11 +6,7 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { FolderOpen, Upload, Download, Trash2, Edit3, FileText, ShieldCheck, Unplug, FileCode } from 'lucide-react'
FolderOpen, Upload, Download, Trash2,
Edit3, FileText, ShieldCheck,
Unplug, FileCode,
} from 'lucide-react'
import { useSFTP } from '../../hooks' import { useSFTP } from '../../hooks'
import { useToast } from '../../hooks/useToast' import { useToast } from '../../hooks/useToast'
import { getConnectionStore } from '../../stores/connection' import { getConnectionStore } from '../../stores/connection'
@@ -32,11 +28,8 @@ import type { SftpDragEntry } from './dragDrop'
import { dndLog, shortPath } from './dragDrop' import { dndLog, shortPath } from './dragDrop'
import { isEditableRemoteTextFile } from '../../utils/fileLanguage' import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits' import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
import { import { subscribeTabReconnect } from '../../utils/tabReconnect'
conflictNamesFromListing, import { conflictNamesFromListing, formatOverwriteMessage, transferDestinationPath } from './transferConflicts'
formatOverwriteMessage,
transferDestinationPath,
} from './transferConflicts'
interface SFTPViewProps { interface SFTPViewProps {
tabId: string tabId: string
@@ -89,7 +82,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
connectionId, connectionId,
localLoading, localLoading,
remoteLoading, remoteLoading,
} = useSFTP({ tabId }) } = useSFTP({ tabId, sessionId })
const { toast } = useToast() const { toast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog() const { prompt, PromptDialogComponent } = usePromptDialog()
@@ -147,6 +140,17 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
} }
}, [sessionId]) }, [sessionId])
useEffect(
() =>
subscribeTabReconnect(tabId, () => {
if (!sessionId) return
clearError()
autoConnectAttempted.current = true
void connectRef.current(sessionId)
}),
[tabId, sessionId, clearError]
)
// Keep pool idle-suspend from arming while this SFTP tab is in front. // Keep pool idle-suspend from arming while this SFTP tab is in front.
useEffect(() => { useEffect(() => {
if (!isActive || !connectionId || !isConnected) return if (!isActive || !connectionId || !isConnected) return
@@ -154,285 +158,330 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [isActive, connectionId, isConnected, tabId]) }, [isActive, connectionId, isConnected, tabId])
// Sort handler // Sort handler
const handleSort = useCallback((field: SortField) => { const handleSort = useCallback(
if (sortField === field) { (field: SortField) => {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') if (sortField === field) {
} else { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
setSortField(field) } else {
setSortOrder('asc') setSortField(field)
} setSortOrder('asc')
}, [sortField, sortOrder]) }
},
[sortField, sortOrder]
)
// Local selection // Local selection
const toggleLocalSelect = useCallback((name: string) => { const toggleLocalSelect = useCallback(
setLocalSelected((prev: Set<string>) => { (name: string) => {
const next = new Set(prev) setLocalSelected((prev: Set<string>) => {
if (next.has(name)) next.delete(name) const next = new Set(prev)
else next.add(name) if (next.has(name)) next.delete(name)
return next else next.add(name)
}) return next
}, [setLocalSelected]) })
},
[setLocalSelected]
)
// Remote selection // Remote selection
const toggleRemoteSelect = useCallback((name: string) => { const toggleRemoteSelect = useCallback(
setRemoteSelected((prev: Set<string>) => { (name: string) => {
const next = new Set(prev) setRemoteSelected((prev: Set<string>) => {
if (next.has(name)) next.delete(name) const next = new Set(prev)
else next.add(name) if (next.has(name)) next.delete(name)
return next else next.add(name)
}) return next
}, [setRemoteSelected])
const reportTransfer = useCallback((action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
if (result.failed > 0) {
const detail = result.errors[0] || actionLabel
toast.error(t('toast.uploadPartial', {
action: actionLabel,
completed: result.completed,
failed: result.failed,
detail,
}))
} else if (result.completed > 0) {
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
}
}, [t, toast])
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
const dest = targetPath ?? remotePath
let pending = sources
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
let conflicts =
dest === remotePath
? conflictNamesFromListing(names, remoteFiles)
: []
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
const hits: string[] = []
for (const name of names) {
const remoteTarget = transferDestinationPath('upload', dest, name)
try {
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'upload', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
}) })
if (!overwrite) { },
const skip = new Set(conflicts) [setRemoteSelected]
pending = sources.filter((source) => { )
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
return !skip.has(name)
})
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await uploadFiles(pending, dest)
reportTransfer('upload', result)
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => { const reportTransfer = useCallback(
const dest = targetPath ?? localPath (action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name)) const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
let pending = selected if (result.failed > 0) {
const names = selected.map((file) => file.name) const detail = result.errors[0] || actionLabel
let conflicts = toast.error(
dest === localPath ? conflictNamesFromListing(names, localFiles) : [] t('toast.uploadPartial', {
if (dest !== localPath && window.opsxApi?.localGetStats) { action: actionLabel,
const hits: string[] = [] completed: result.completed,
for (const name of names) { failed: result.failed,
const localTarget = transferDestinationPath('download', dest, name) detail,
try { })
const result = (await window.opsxApi.localGetStats(localTarget)) as { )
success?: boolean } else if (result.completed > 0) {
stats?: unknown toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
}
},
[t, toast]
)
const performUpload = useCallback(
async (
sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>,
targetPath?: string
) => {
const dest = targetPath ?? remotePath
let pending = sources
const names = sources.map(
(source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
)
let conflicts = dest === remotePath ? conflictNamesFromListing(names, remoteFiles) : []
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
const hits: string[] = []
for (const name of names) {
const remoteTarget = transferDestinationPath('upload', dest, name)
try {
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
} }
if (result?.success && result.stats) hits.push(name) }
} catch { conflicts = hits
// treat as missing }
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'upload', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = sources.filter((source) => {
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
return !skip.has(name)
})
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
} }
} }
conflicts = hits setIsTransferPanelOpen(true)
} const result = await uploadFiles(pending, dest)
if (conflicts.length > 0) { reportTransfer('upload', result)
const overwrite = await confirm({ },
title: t('dialog.overwriteTitle'), [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles]
message: formatOverwriteMessage(conflicts, 'download', t), )
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'), const performDownload = useCallback(
danger: true, async (files?: FileItem[], targetPath?: string) => {
}) const dest = targetPath ?? localPath
if (!overwrite) { const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
const skip = new Set(conflicts) let pending = selected
pending = selected.filter((file) => !skip.has(file.name)) const names = selected.map((file) => file.name)
if (pending.length === 0) { let conflicts = dest === localPath ? conflictNamesFromListing(names, localFiles) : []
toast.info(t('toast.allSkippedExisting')) if (dest !== localPath && window.opsxApi?.localGetStats) {
return const hits: string[] = []
for (const name of names) {
const localTarget = transferDestinationPath('download', dest, name)
try {
const result = (await window.opsxApi.localGetStats(localTarget)) as {
success?: boolean
stats?: unknown
}
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
} }
toast.info(t('toast.skippedExisting', { count: conflicts.length })) conflicts = hits
} }
} if (conflicts.length > 0) {
setIsTransferPanelOpen(true) const overwrite = await confirm({
const result = await downloadFiles(pending, dest) title: t('dialog.overwriteTitle'),
reportTransfer('download', result) message: formatOverwriteMessage(conflicts, 'download', t),
}, [ confirmText: t('dialog.overwriteConfirm'),
confirm, cancelText: t('dialog.overwriteSkip'),
downloadFiles, danger: true,
localFiles, })
localPath, if (!overwrite) {
remoteFiles, const skip = new Set(conflicts)
remoteSelected, pending = selected.filter((file) => !skip.has(file.name))
reportTransfer, if (pending.length === 0) {
t, toast.info(t('toast.allSkippedExisting'))
toast, return
]) }
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await downloadFiles(pending, dest)
reportTransfer('download', result)
},
[confirm, downloadFiles, localFiles, localPath, remoteFiles, remoteSelected, reportTransfer, t, toast]
)
// ===== Context menu handlers ===== // ===== Context menu handlers =====
// Rename handler // Rename handler
const handleRename = useCallback(async (file: FileItem) => { const handleRename = useCallback(
const newName = await prompt({ async (file: FileItem) => {
title: t('dialog.renameTitle'), const newName = await prompt({
message: t('dialog.renameMessage'), title: t('dialog.renameTitle'),
placeholder: t('dialog.renamePlaceholder'), message: t('dialog.renameMessage'),
defaultValue: file.name, placeholder: t('dialog.renamePlaceholder'),
}) defaultValue: file.name,
if (newName && newName !== file.name) { })
const ok = await renameRemote(file, newName) if (newName && newName !== file.name) {
if (ok) { const ok = await renameRemote(file, newName)
toast.success(t('toast.renamed', { name: newName })) if (ok) {
} else { toast.success(t('toast.renamed', { name: newName }))
toast.error(t('toast.renameFailed')) } else {
toast.error(t('toast.renameFailed'))
}
} }
} },
}, [prompt, renameRemote, t, toast]) [prompt, renameRemote, t, toast]
)
// Properties handler // Properties handler
const handleShowProperties = useCallback((file: FileItem) => { const handleShowProperties = useCallback((file: FileItem) => {
setPropertiesFile(file) setPropertiesFile(file)
}, []) }, [])
const handleDeleteRemote = useCallback(async (files: FileItem[]) => { const handleDeleteRemote = useCallback(
if (files.length === 0) return async (files: FileItem[]) => {
const hasDir = files.some((file) => file.type === 'directory') if (files.length === 0) return
const names = files.slice(0, 3).map((file) => file.name).join(', ') const hasDir = files.some((file) => file.type === 'directory')
const ok = await confirm({ const names = files
title: t('dialog.deleteTitle'), .slice(0, 3)
message: .map((file) => file.name)
t('dialog.deleteMessage', { count: files.length, names }) + .join(', ')
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''), const ok = await confirm({
danger: true, title: t('dialog.deleteTitle'),
confirmText: t('contextMenu.delete'), message:
}) t('dialog.deleteMessage', { count: files.length, names }) +
if (!ok) return (hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
const success = await deleteRemote(files) danger: true,
if (!success) toast.error(t('toast.deleteFailed')) confirmText: t('contextMenu.delete'),
}, [confirm, deleteRemote, t, toast]) })
if (!ok) return
const success = await deleteRemote(files)
if (!success) toast.error(t('toast.deleteFailed'))
},
[confirm, deleteRemote, t, toast]
)
// Permissions (chmod) handler — fetch stat first, then show dialog // Permissions (chmod) handler — fetch stat first, then show dialog
const handleChmod = useCallback(async (file: FileItem) => { const handleChmod = useCallback(
const stats = await getRemoteStat(file.path) async (file: FileItem) => {
const mode = stats?.mode ?? 0o644 const stats = await getRemoteStat(file.path)
setChmodFile(file) const mode = stats?.mode ?? 0o644
setChmodInitialMode(mode & 0o7777) setChmodFile(file)
}, [getRemoteStat]) setChmodInitialMode(mode & 0o7777)
},
[getRemoteStat]
)
// Chmod confirm callback // Chmod confirm callback
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => { const handleChmodConfirm = useCallback(
const ok = await chmodRemote(filePath, mode) async (filePath: string, mode: number) => {
if (ok) { const ok = await chmodRemote(filePath, mode)
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) })) if (ok) {
return true toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
} else { return true
toast.error(t('toast.chmodFailed')) } else {
return false toast.error(t('toast.chmodFailed'))
} return false
}, [chmodRemote, toast, t]) }
},
[chmodRemote, toast, t]
)
// Local context menu // Local context menu
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { const showLocalContextMenu = useCallback(
e.preventDefault() (e: React.MouseEvent, file: FileItem) => {
const items: MenuItem[] = [ e.preventDefault()
{ const items: MenuItem[] = [
icon: <FolderOpen className="w-4 h-4" />, {
label: t('contextMenu.open'), icon: <FolderOpen className="h-4 w-4" />,
action: () => file.type === 'directory' && navigateLocal(file.path), label: t('contextMenu.open'),
}, action: () => file.type === 'directory' && navigateLocal(file.path),
...(file.type !== 'directory' },
? [{ ...(file.type !== 'directory'
icon: <Upload className="w-4 h-4" />, ? [
label: t('contextMenu.upload'), {
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]), icon: <Upload className="h-4 w-4" />,
} as MenuItem] label: t('contextMenu.upload'),
: []), action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
{ } as MenuItem,
icon: <FileText className="w-4 h-4" />, ]
label: t('contextMenu.properties'), : []),
separator: true, {
action: () => handleShowProperties(file), icon: <FileText className="h-4 w-4" />,
}, label: t('contextMenu.properties'),
] separator: true,
setContextMenu({ x: e.clientX, y: e.clientY, items }) action: () => handleShowProperties(file),
}, [navigateLocal, handleShowProperties, performUpload, t]) },
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
},
[navigateLocal, handleShowProperties, performUpload, t]
)
// Remote context menu // Remote context menu
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { const showRemoteContextMenu = useCallback(
e.preventDefault() (e: React.MouseEvent, file: FileItem) => {
const items: MenuItem[] = [ e.preventDefault()
{ const items: MenuItem[] = [
icon: <FolderOpen className="w-4 h-4" />, {
label: t('contextMenu.open'), icon: <FolderOpen className="h-4 w-4" />,
action: () => file.type === 'directory' && navigateRemote(file.path), label: t('contextMenu.open'),
}, action: () => file.type === 'directory' && navigateRemote(file.path),
{ },
icon: <Download className="w-4 h-4" />, {
label: t('contextMenu.download'), icon: <Download className="h-4 w-4" />,
action: () => void performDownload([file]), label: t('contextMenu.download'),
}, action: () => void performDownload([file]),
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type) },
? [ ...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
{ ? [
icon: <FileCode className="w-4 h-4" />, {
label: t('contextMenu.edit'), icon: <FileCode className="h-4 w-4" />,
action: () => { label: t('contextMenu.edit'),
if (!isWithinRemoteTextEditLimit(file.size)) { action: () => {
toast.warning(t('errors:sftp.editTooLarge')) if (!isWithinRemoteTextEditLimit(file.size)) {
return toast.warning(t('errors:sftp.editTooLarge'))
} return
setEditFile(file) }
}, setEditFile(file)
} as MenuItem, },
] } as MenuItem,
: []), ]
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) }, : []),
{ {
icon: <FileText className="w-4 h-4" />, icon: <Edit3 className="h-4 w-4" />,
label: t('contextMenu.properties'), label: t('contextMenu.rename'),
action: () => handleShowProperties(file), separator: true,
}, action: () => handleRename(file),
{ },
icon: <ShieldCheck className="w-4 h-4" />, {
label: t('contextMenu.permissions'), icon: <FileText className="h-4 w-4" />,
action: () => handleChmod(file), label: t('contextMenu.properties'),
}, action: () => handleShowProperties(file),
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) }, },
] {
setContextMenu({ x: e.clientX, y: e.clientY, items }) icon: <ShieldCheck className="h-4 w-4" />,
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t]) label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{
icon: <Trash2 className="h-4 w-4" />,
label: t('contextMenu.delete'),
separator: true,
danger: true,
action: () => void handleDeleteRemote([file]),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
},
[navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t]
)
// Navigate up // Navigate up
const navigateLocalUp = useCallback(() => { const navigateLocalUp = useCallback(() => {
@@ -465,10 +514,13 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
const focusLocalPane = useCallback(() => setActivePane('local'), []) const focusLocalPane = useCallback(() => setActivePane('local'), [])
const focusRemotePane = useCallback(() => setActivePane('remote'), []) const focusRemotePane = useCallback(() => setActivePane('remote'), [])
const navigateRemoteClear = useCallback((p: string) => { const navigateRemoteClear = useCallback(
clearError() (p: string) => {
navigateRemote(p) clearError()
}, [clearError, navigateRemote]) navigateRemote(p)
},
[clearError, navigateRemote]
)
const navigateRemoteBackClear = useCallback(() => { const navigateRemoteBackClear = useCallback(() => {
clearError() clearError()
@@ -486,29 +538,37 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [clearError, navigateRemoteUp]) }, [clearError, navigateRemoteUp])
// Handle file open // Handle file open
const handleLocalOpen = useCallback((file: FileItem) => { const handleLocalOpen = useCallback(
if (file.type === 'directory') { (file: FileItem) => {
navigateLocal(file.path) if (file.type === 'directory') {
} navigateLocal(file.path)
}, [navigateLocal]) }
},
[navigateLocal]
)
const handleRemoteOpen = useCallback((file: FileItem) => { const handleRemoteOpen = useCallback(
if (file.type === 'directory') { (file: FileItem) => {
navigateRemote(file.path) if (file.type === 'directory') {
} navigateRemote(file.path)
}, [navigateRemote]) }
},
[navigateRemote]
)
// Upload selected local rows first; otherwise use the native picker so Rust // Upload selected local rows first; otherwise use the native picker so Rust
// receives real absolute paths instead of browser-only File objects. // receives real absolute paths instead of browser-only File objects.
const handleUpload = useCallback(async () => { const handleUpload = useCallback(async () => {
const selectedFiles = localFiles.filter((file) => localSelected.has(file.name)) const selectedFiles = localFiles.filter((file) => localSelected.has(file.name))
if (selectedFiles.length > 0) { if (selectedFiles.length > 0) {
await performUpload(selectedFiles.map((file) => ({ await performUpload(
path: file.path, selectedFiles.map((file) => ({
name: file.name, path: file.path,
size: file.size, name: file.name,
isDirectory: file.type === 'directory', size: file.size,
}))) isDirectory: file.type === 'directory',
}))
)
return return
} }
@@ -533,61 +593,67 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [performDownload]) }, [performDownload])
// Handle drag-and-drop upload on remote panel // Handle drag-and-drop upload on remote panel
const handleDropFiles = useCallback((files: FileList, targetPath: string) => { const handleDropFiles = useCallback(
if (!isConnected) return (files: FileList, targetPath: string) => {
const sources = Array.from(files).flatMap((file) => { if (!isConnected) return
const path = (file as File & { path?: string }).path const sources = Array.from(files).flatMap((file) => {
return path ? [{ path, name: file.name, size: file.size }] : [] const path = (file as File & { path?: string }).path
}) return path ? [{ path, name: file.name, size: file.size }] : []
dndLog('dispatch-os-upload', { })
target: shortPath(targetPath), dndLog('dispatch-os-upload', {
fileCount: files.length, target: shortPath(targetPath),
withPath: sources.length, fileCount: files.length,
}) withPath: sources.length,
if (sources.length === 0) { })
toast.warning(t('toast.noDragPath')) if (sources.length === 0) {
return toast.warning(t('toast.noDragPath'))
} return
void performUpload(sources, targetPath) }
}, [isConnected, performUpload, toast, t]) void performUpload(sources, targetPath)
},
[isConnected, performUpload, toast, t]
)
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => { const handleDropEntry = useCallback(
if (!isConnected || entries.length === 0) return (entries: SftpDragEntry[], targetPath: string) => {
const localEntries = entries.filter((entry) => entry.source === 'local') if (!isConnected || entries.length === 0) return
const remoteEntries = entries.filter((entry) => entry.source === 'remote') const localEntries = entries.filter((entry) => entry.source === 'local')
dndLog('dispatch', { const remoteEntries = entries.filter((entry) => entry.source === 'remote')
target: shortPath(targetPath), dndLog('dispatch', {
upload: localEntries.length, target: shortPath(targetPath),
download: remoteEntries.length, upload: localEntries.length,
uploadNames: localEntries.slice(0, 8).map((e) => e.name), download: remoteEntries.length,
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name), uploadNames: localEntries.slice(0, 8).map((e) => e.name),
via: 'pointer', downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
}) via: 'pointer',
if (localEntries.length > 0) { })
void performUpload( if (localEntries.length > 0) {
localEntries.map((entry) => ({ void performUpload(
path: entry.path, localEntries.map((entry) => ({
name: entry.name, path: entry.path,
size: entry.size, name: entry.name,
isDirectory: entry.type === 'directory', size: entry.size,
})), isDirectory: entry.type === 'directory',
targetPath })),
) targetPath
} )
if (remoteEntries.length > 0) { }
void performDownload( if (remoteEntries.length > 0) {
remoteEntries.map((entry) => ({ void performDownload(
name: entry.name, remoteEntries.map((entry) => ({
path: entry.path, name: entry.name,
type: entry.type, path: entry.path,
size: entry.size, type: entry.type,
modified: 0, size: entry.size,
permissions: '', modified: 0,
})), permissions: '',
targetPath })),
) targetPath
} )
}, [isConnected, performDownload, performUpload]) }
},
[isConnected, performDownload, performUpload]
)
const handleDropFailed = useCallback(() => { const handleDropFailed = useCallback(() => {
dndLog('toast-drop-failed') dndLog('toast-drop-failed')
@@ -653,7 +719,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
{/* File Panels */} {/* File Panels */}
{!isConnected ? ( {!isConnected ? (
<div className="flex-1 flex items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<EmptyState <EmptyState
icon={Unplug} icon={Unplug}
title={t('panel.notConnectedTitle')} title={t('panel.notConnectedTitle')}
@@ -665,74 +731,74 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
/> />
</div> </div>
) : ( ) : (
<div className="flex-1 flex gap-2 p-4 min-h-0"> <div className="flex min-h-0 flex-1 gap-2 p-4">
{/* Local Panel */} {/* Local Panel */}
<FileListPanel <FileListPanel
title={t('panel.local')} title={t('panel.local')}
isLocal={true} isLocal={true}
path={localPath} path={localPath}
homePath={localHistory[0] || localPath} homePath={localHistory[0] || localPath}
files={sortedLocalFiles} files={sortedLocalFiles}
selected={localSelected} selected={localSelected}
history={localHistory} history={localHistory}
historyIndex={localHistoryIndex} historyIndex={localHistoryIndex}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onNavigate={navigateLocal} onNavigate={navigateLocal}
onNavigateBack={navigateLocalBack} onNavigateBack={navigateLocalBack}
onNavigateForward={navigateLocalForward} onNavigateForward={navigateLocalForward}
onNavigateUp={navigateLocalUp} onNavigateUp={navigateLocalUp}
onSelect={toggleLocalSelect} onSelect={toggleLocalSelect}
onOpen={handleLocalOpen} onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu} onContextMenu={showLocalContextMenu}
onSort={handleSort} onSort={handleSort}
onDropEntry={handleDropEntry} onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed} onDropFailed={handleDropFailed}
onRefresh={refreshLocal} onRefresh={refreshLocal}
onNewFolder={handleNewLocalFolder} onNewFolder={handleNewLocalFolder}
onFocusPane={focusLocalPane} onFocusPane={focusLocalPane}
refreshing={localLoading} refreshing={localLoading}
loading={localLoading && localFiles.length === 0} loading={localLoading && localFiles.length === 0}
/> />
<TransferRail <TransferRail
canUpload={isConnected} canUpload={isConnected}
canDownload={isConnected && remoteSelected.size > 0} canDownload={isConnected && remoteSelected.size > 0}
onUpload={railUpload} onUpload={railUpload}
onDownload={railDownload} onDownload={railDownload}
/> />
{/* Remote Panel */} {/* Remote Panel */}
<FileListPanel <FileListPanel
title={t('panel.remote')} title={t('panel.remote')}
isLocal={false} isLocal={false}
path={remotePath} path={remotePath}
homePath={remoteHomePath} homePath={remoteHomePath}
files={sortedRemoteFiles} files={sortedRemoteFiles}
selected={remoteSelected} selected={remoteSelected}
history={remoteHistory} history={remoteHistory}
historyIndex={remoteHistoryIndex} historyIndex={remoteHistoryIndex}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onNavigate={navigateRemoteClear} onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear} onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear} onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear} onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect} onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen} onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu} onContextMenu={showRemoteContextMenu}
onSort={handleSort} onSort={handleSort}
onDropFiles={isConnected ? handleDropFiles : undefined} onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry} onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed} onDropFailed={handleDropFailed}
onRefresh={isConnected ? refreshRemote : undefined} onRefresh={isConnected ? refreshRemote : undefined}
onNewFolder={handleNewRemoteFolder} onNewFolder={handleNewRemoteFolder}
onFocusPane={focusRemotePane} onFocusPane={focusRemotePane}
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)} loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
refreshing={remoteLoading} refreshing={remoteLoading}
listError={remoteListError} listError={remoteListError}
/> />
</div> </div>
)} )}
{/* Transfer Panel */} {/* Transfer Panel */}
+19 -7
View File
@@ -1,7 +1,9 @@
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react' import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
import { translateErrorMessage } from '../../i18n/format'
import type { ConnectionState } from '../../stores/connection' import type { ConnectionState } from '../../stores/connection'
import type { DisconnectKind } from '../../utils/disconnectReason'
export interface ShellClosedOverlayProps { export interface ShellClosedOverlayProps {
reason: string reason: string
@@ -33,7 +35,7 @@ export function ShellClosedOverlay({
<div className="mb-1.5 text-sm font-medium text-slate-100"> <div className="mb-1.5 text-sm font-medium text-slate-100">
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')} {idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div> </div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div> <div className="mb-2 text-xs leading-relaxed text-slate-400">{translateErrorMessage(reason)}</div>
<div className="mb-4 text-[11px] text-accent-green"> <div className="mb-4 text-[11px] text-accent-green">
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')} {t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
</div> </div>
@@ -57,6 +59,7 @@ export interface TerminalOverlayProps {
showSuspendedOverlay: boolean showSuspendedOverlay: boolean
needsReconnect: boolean needsReconnect: boolean
reconnectAttempt?: number reconnectAttempt?: number
disconnectKind?: DisconnectKind
isLocal?: boolean isLocal?: boolean
onReconnect: () => void onReconnect: () => void
onResume: () => void onResume: () => void
@@ -68,6 +71,7 @@ function TerminalOverlay({
showSuspendedOverlay, showSuspendedOverlay,
needsReconnect, needsReconnect,
reconnectAttempt = 0, reconnectAttempt = 0,
disconnectKind = 'unexpected',
isLocal = false, isLocal = false,
onReconnect, onReconnect,
onResume, onResume,
@@ -78,18 +82,26 @@ function TerminalOverlay({
return ( return (
<> <>
{showDisconnectBanner && ( {showDisconnectBanner && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]"> <div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center"> <div className="max-w-sm px-6 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
<WifiOff className="h-7 w-7 text-accent-blue" /> <WifiOff className="h-7 w-7 text-accent-blue" />
</div> </div>
<div className="mb-1.5 text-base font-medium text-slate-100"> <div className="mb-1.5 text-base font-medium text-slate-100">
{t(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')} {isLocal
? t('overlay.localDisconnected')
: disconnectKind === 'keepalive'
? t('overlay.disconnectedKeepalive')
: t('overlay.disconnected')}
</div> </div>
<div className="mb-6 text-sm leading-relaxed text-slate-400"> <div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim() {statusMessage?.trim()
? statusMessage ? statusMessage
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')} : isLocal
? t('overlay.localDisconnectedHint')
: disconnectKind === 'keepalive'
? t('overlay.disconnectedKeepaliveHint')
: t('overlay.disconnectedHint')}
</div> </div>
<button <button
type="button" type="button"
@@ -104,7 +116,7 @@ function TerminalOverlay({
)} )}
{showSuspendedOverlay && ( {showSuspendedOverlay && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center"> <div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
<div className="max-w-sm px-6 text-center"> <div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-feedback-warning">{t('overlay.suspended')}</div> <div className="mb-2 text-base font-medium text-feedback-warning">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-slate-400">{t('overlay.suspendedHint')}</div> <div className="mb-5 text-sm text-slate-400">{t('overlay.suspendedHint')}</div>
@@ -129,7 +141,7 @@ function TerminalOverlay({
)} )}
{connectionState === 'error' && ( {connectionState === 'error' && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center"> <div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
<div className="max-w-md px-6 text-center"> <div className="max-w-md px-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15"> <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" /> <AlertTriangle className="h-6 w-6 text-red-400" />
@@ -151,7 +163,7 @@ function TerminalOverlay({
)} )}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && ( {(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]"> <div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
<div className="px-4 text-center"> <div className="px-4 text-center">
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue"> <div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" /> <TerminalIcon className="h-4 w-4" />
+2
View File
@@ -56,6 +56,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
statusMessage, statusMessage,
showSuspendedOverlay, showSuspendedOverlay,
reconnectAttempt, reconnectAttempt,
disconnectKind,
connectionIdRef, connectionIdRef,
shellIdRef, shellIdRef,
hasConnectedRef, hasConnectedRef,
@@ -334,6 +335,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current) !!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
} }
reconnectAttempt={reconnectAttempt} reconnectAttempt={reconnectAttempt}
disconnectKind={disconnectKind}
isLocal={!!currentTab?.isLocal} isLocal={!!currentTab?.isLocal}
onReconnect={handleReconnect} onReconnect={handleReconnect}
onResume={resumeConnection} onResume={resumeConnection}
+62 -30
View File
@@ -46,7 +46,12 @@ import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types' import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard' import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts' import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes' import {
panesAfterConnectionStateChange,
resolveTerminalInputTargets,
shouldBroadcastTerminalInput,
shouldCollapseTerminalSplitForOverlay,
} from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline' import { TerminalPipeline } from '../../utils/terminalPipeline'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge' import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel' import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
@@ -274,6 +279,8 @@ function PaneTerminal({
void shellResize(sid, term.cols, term.rows) void shellResize(sid, term.cols, term.rows)
} }
} }
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => onResize()) : null
resizeObserver?.observe(container)
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24. // Fit before advertising size so the primary shell is not born at 80×24.
@@ -315,6 +322,7 @@ function PaneTerminal({
return () => { return () => {
disposed = true disposed = true
setXtermReady(false) setXtermReady(false)
resizeObserver?.disconnect()
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu) container.removeEventListener('contextmenu', handleContextMenu)
unregisterShellHandler(paneId) unregisterShellHandler(paneId)
@@ -341,9 +349,13 @@ function PaneTerminal({
safeTerminalFit(fitAddonRef.current, term) safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback]) }, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// Resize when shell id attaches // Resize when shell id attaches; drop the stale id when the session is gone.
useEffect(() => { useEffect(() => {
if (!shellId || !xtermRef.current) return if (!shellId) {
shellIdRef.current = null
return
}
if (!xtermRef.current) return
shellIdRef.current = shellId shellIdRef.current = shellId
safeTerminalFit(fitAddonRef.current, xtermRef.current) safeTerminalFit(fitAddonRef.current, xtermRef.current)
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows) void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
@@ -470,6 +482,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const canEnableBroadcast = effectiveBroadcastCount >= 2 const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro) const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false) const [aiPanelOpen, setAiPanelOpen] = useState(false)
const [shellIoReady, setShellIoReady] = useState(false)
const isLocalTab = !!currentTab?.isLocal const isLocalTab = !!currentTab?.isLocal
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
paneId: string paneId: string
@@ -517,6 +530,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
statusMessage, statusMessage,
showSuspendedOverlay, showSuspendedOverlay,
reconnectAttempt, reconnectAttempt,
disconnectKind,
connectionIdRef, connectionIdRef,
shellIdRef, shellIdRef,
hasConnectedRef, hasConnectedRef,
@@ -544,17 +558,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}, [tabId, direction, panes.length, ratio, updateTab]) }, [tabId, direction, panes.length, ratio, updateTab])
// Sync primary shell from connection hook → first pane. // Sync primary shell from connection hook → first pane.
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped. // Drop every pane's dead shell when the pooled session is gone so Cmd+D
// splits recreate after auto-reconnect instead of looking frozen.
useEffect(() => { useEffect(() => {
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId const primaryShellId =
if (!sid) return connectionState === 'connected' || connectionState === 'connecting'
if (connectionState !== 'connected' && connectionState !== 'connecting') return ? shellIdRef.current || currentTab?.connectionInstance?.shellId || null
setPanes((prev) => { : null
if (prev.length === 0) return prev setPanes((prev) => panesAfterConnectionStateChange(prev, connectionState, primaryShellId))
if (prev[0].shellId === sid) return prev
if (prev[0].shellId) return prev
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
})
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef]) }, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
// Register ready shells (SSH + local) so broadcast can cover this tab's panes. // Register ready shells (SSH + local) so broadcast can cover this tab's panes.
@@ -596,8 +607,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false) if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false)
}, [broadcastInput, effectiveBroadcastCount, setBroadcastInput]) }, [broadcastInput, effectiveBroadcastCount, setBroadcastInput])
// Fan-out shell data to panes (SSH + local) // Fan-out shell data to panes (SSH + local). Do not connect until listen() resolves —
// otherwise MOTD from bootstrap shows up and the prompt (later events) never arrives.
useEffect(() => { useEffect(() => {
let cancelled = false
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => { const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
for (const h of shellHandlersRef.current.values()) { for (const h of shellHandlersRef.current.values()) {
h(shellId, data) h(shellId, data)
@@ -608,11 +621,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data) if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data)
} }
} }
window.opsxApi?.onShellData(handler) const api = window.opsxApi
window.opsxApi?.onLocalData?.(handler) const pending = [
api?.onShellData ? Promise.resolve(api.onShellData(handler)) : Promise.resolve(),
api?.onLocalData ? Promise.resolve(api.onLocalData(handler)) : Promise.resolve(),
]
void Promise.all(pending).then(() => {
if (!cancelled) setShellIoReady(true)
})
return () => { return () => {
window.opsxApi?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void) cancelled = true
window.opsxApi?.removeListener('local:data', handler as (...args: unknown[]) => void) setShellIoReady(false)
api?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
api?.removeListener('local:data', handler as (...args: unknown[]) => void)
} }
}, [connectionIdRef]) }, [connectionIdRef])
@@ -712,6 +733,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const armInitialConnect = useCallback(() => { const armInitialConnect = useCallback(() => {
if (connectArmedRef.current || hasConnectedRef.current) return if (connectArmedRef.current || hasConnectedRef.current) return
if (!shellIoReady) return
const current = getTabById(tabId) const current = getTabById(tabId)
if (!current) return if (!current) return
if (!(isActive || current.connectOnMount)) return if (!(isActive || current.connectOnMount)) return
@@ -728,7 +750,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
requestAnimationFrame(() => { requestAnimationFrame(() => {
void connect() void connect()
}) })
}, [connect, getTabById, isActive, restoreSnapshot, snapshotStore, tabId, updateTab, hasConnectedRef]) }, [connect, getTabById, isActive, restoreSnapshot, shellIoReady, snapshotStore, tabId, updateTab, hasConnectedRef])
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => { const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
shellHandlersRef.current.set(paneId, handler) shellHandlersRef.current.set(paneId, handler)
@@ -777,7 +799,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
useEffect(() => { useEffect(() => {
if (!isActive || hasConnectedRef.current || connectArmedRef.current) return if (!isActive || hasConnectedRef.current || connectArmedRef.current) return
if (primaryXtermRef.current) armInitialConnect() if (primaryXtermRef.current) armInitialConnect()
}, [isActive, armInitialConnect, hasConnectedRef]) }, [isActive, armInitialConnect, hasConnectedRef, shellIoReady])
const onShellReady = useCallback( const onShellReady = useCallback(
(paneId: string, shellId: string) => { (paneId: string, shellId: string) => {
@@ -1073,12 +1095,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const multi = panes.length > 1 const multi = panes.length > 1
const isLocal = !!currentTab?.isLocal const isLocal = !!currentTab?.isLocal
const needsReconnect =
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
const layoutSplit =
multi &&
!shouldCollapseTerminalSplitForOverlay({
connectionState,
needsReconnect,
showSuspendedOverlay,
})
const paneStyle = (index: number, paneId: string): CSSProperties => { const paneStyle = (index: number, paneId: string): CSSProperties => {
if (maximizedPaneId === paneId) { if (maximizedPaneId === paneId) {
return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 } return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 }
} }
if (!multi) return { flex: 1 } if (!layoutSplit) return { flex: 1 }
if (panes.length === 2) { if (panes.length === 2) {
const share = index === 0 ? ratio : 1 - ratio const share = index === 0 ? ratio : 1 - ratio
return direction === 'horizontal' return direction === 'horizontal'
@@ -1090,7 +1121,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
} }
return ( return (
<div className="flex h-full flex-col bg-dark-900"> <div className="app-workspace">
<div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1"> <div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1">
<button <button
type="button" type="button"
@@ -1230,8 +1261,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="app-workspace-body">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden p-1"> <div className="terminal-canvas">
{showSearch && ( {showSearch && (
<TerminalSearchBar <TerminalSearchBar
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -1246,13 +1277,15 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)} )}
<div <div
className={`h-full min-h-0 w-full ${ className={`h-full min-h-0 w-full ${
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex' layoutSplit ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
}`} }`}
> >
{panes.map((pane, index) => ( {panes.map((pane, index) => (
<div <div
key={pane.id} key={pane.id}
className={`relative min-h-0 min-w-0 ${maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''}`} className={`relative min-h-0 min-w-0 ${
maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''
} ${!layoutSplit && index > 0 ? 'hidden' : ''}`}
style={paneStyle(index, pane.id)} style={paneStyle(index, pane.id)}
> >
<PaneTerminal <PaneTerminal
@@ -1291,7 +1324,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onReopen={() => void reopenPaneShell(pane.id)} onReopen={() => void reopenPaneShell(pane.id)}
/> />
)} )}
{multi && panes.length === 2 && index === 0 && ( {layoutSplit && panes.length === 2 && index === 0 && (
<div <div
onMouseDown={onDividerMouseDown} onMouseDown={onDividerMouseDown}
className={ className={
@@ -1309,10 +1342,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionState={connectionState} connectionState={connectionState}
statusMessage={statusMessage} statusMessage={statusMessage}
showSuspendedOverlay={showSuspendedOverlay} showSuspendedOverlay={showSuspendedOverlay}
needsReconnect={ needsReconnect={needsReconnect}
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt} reconnectAttempt={reconnectAttempt}
disconnectKind={disconnectKind}
isLocal={isLocal} isLocal={isLocal}
onReconnect={() => { onReconnect={() => {
const count = Math.min(MAX_PANES, Math.max(1, panes.length)) const count = Math.min(MAX_PANES, Math.max(1, panes.length))
+3 -3
View File
@@ -18,7 +18,7 @@ export interface DesktopApiContract {
) => Promise<import('../shared/types/ipc.types').SSHConnectResponse> ) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
sshDisconnect: ( sshDisconnect: (
connectionId: string, connectionId: string,
options?: { retainForwards?: boolean } options?: { retainForwards?: boolean; reason?: string }
) => Promise<{ success: boolean; error?: string }> ) => Promise<{ success: boolean; error?: string }>
sshExec: ( sshExec: (
connectionId: string, connectionId: string,
@@ -27,7 +27,7 @@ export interface DesktopApiContract {
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }> ) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }> sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshHostMetrics: (connectionId: string) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse> sshHostMetrics: (connectionId: string) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }> sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string; errorCode?: string }>
sshCreateShell: ( sshCreateShell: (
connectionId: string, connectionId: string,
options?: import('../shared/types/ipc.types').SSHCreateShellRequest, options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
@@ -81,7 +81,7 @@ export interface DesktopApiContract {
localPort: number, localPort: number,
forwardId?: string forwardId?: string
) => Promise<import('../shared/types/ipc.types').SSHForwardResponse> ) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardStop: (forwardId: string) => Promise<{ success: boolean; error?: string }> sshForwardStop: (forwardId: string) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardList: (connectionId?: string) => Promise<import('../shared/types/ipc.types').SSHForwardListResponse> sshForwardList: (connectionId?: string) => Promise<import('../shared/types/ipc.types').SSHForwardListResponse>
// Sessions // Sessions
+101 -47
View File
@@ -22,6 +22,8 @@ import {
import { joinLocalPath } from '../utils/localPath' import { joinLocalPath } from '../utils/localPath'
import i18n from '../i18n' import i18n from '../i18n'
import { translateErrorMessage } from '../i18n/format' import { translateErrorMessage } from '../i18n/format'
import { useSettingsStore } from '../stores/settingsStore'
import { classifyDisconnectReason, reconnectDelayMs, shouldAutoReconnect } from '../utils/disconnectReason'
const logger = createLogger('useSFTP') const logger = createLogger('useSFTP')
@@ -83,6 +85,7 @@ function localEntryType(entry: LocalDirEntry): FileItem['type'] {
export interface UseSFTPOptions { export interface UseSFTPOptions {
tabId: string tabId: string
sessionId?: string
} }
export interface UploadSource { export interface UploadSource {
@@ -191,7 +194,7 @@ interface QueuedTransferJob {
total: number total: number
} }
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { export function useSFTP({ tabId, sessionId }: UseSFTPOptions): UseSFTPReturn {
const { sessions } = useSessionStore() const { sessions } = useSessionStore()
const { updateTabConnectionInstance, updateTab } = useTabStore() const { updateTabConnectionInstance, updateTab } = useTabStore()
const { createConnection } = useConnectionPool() const { createConnection } = useConnectionPool()
@@ -224,9 +227,16 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
/** Last successfully listed remote directory — used to resume after reconnect. */ /** Last successfully listed remote directory — used to resume after reconnect. */
const lastBrowsePathRef = useRef<string | null>(null) const lastBrowsePathRef = useRef<string | null>(null)
const remoteHistoryRef = useRef<string[]>(['/']) const remoteHistoryRef = useRef<string[]>(['/'])
const sessionIdRef = useRef<string | undefined>(sessionId)
const connectRef = useRef<(id: string) => Promise<void>>(async () => {})
const intentionalDisconnectRef = useRef(false)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const connectingGuardRef = useRef(false)
connectionIdRef.current = connectionId connectionIdRef.current = connectionId
sftpIdRef.current = sftpId sftpIdRef.current = sftpId
remoteHistoryRef.current = remoteHistory remoteHistoryRef.current = remoteHistory
sessionIdRef.current = sessionId
// Transfer state — serial pump avoids concurrent upload+download locking the same SFTP Mutex. // Transfer state — serial pump avoids concurrent upload+download locking the same SFTP Mutex.
const [transferQueue, setTransferQueue] = useState<TransferTask[]>([]) const [transferQueue, setTransferQueue] = useState<TransferTask[]>([])
@@ -487,7 +497,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Connect to SFTP // Connect to SFTP
const connect = useCallback( const connect = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
logger.info('SFTP CONNECT', { sessionId }) if (connectingGuardRef.current) return
connectingGuardRef.current = true
intentionalDisconnectRef.current = false
if (!sessionId) { if (!sessionId) {
logger.error('No sessionId provided') logger.error('No sessionId provided')
@@ -535,6 +547,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setSftpId(sftpResult.sftpId) setSftpId(sftpResult.sftpId)
setIsConnected(true) setIsConnected(true)
reconnectAttemptRef.current = 0
logger.info(`SFTP connected: ${sftpResult.sftpId}`) logger.info(`SFTP connected: ${sftpResult.sftpId}`)
getConnectionStore().addSFTP(connId, sftpResult.sftpId) getConnectionStore().addSFTP(connId, sftpResult.sftpId)
@@ -551,11 +564,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Resume last browse path after reconnect; first connect prefers home then /. // Resume last browse path after reconnect; first connect prefers home then /.
const homePreferred = config.username === 'root' ? '/root' : `/home/${config.username}` const homePreferred = config.username === 'root' ? '/root' : `/home/${config.username}`
const resumePath = lastBrowsePathRef.current const resumePath = lastBrowsePathRef.current
const tryPaths = [ const tryPaths = [...(resumePath ? [resumePath] : []), homePreferred, '/'].filter(
...(resumePath ? [resumePath] : []), (path, index, all) => path && all.indexOf(path) === index
homePreferred, )
'/',
].filter((path, index, all) => path && all.indexOf(path) === index)
let startPath = '/' let startPath = '/'
for (const path of tryPaths) { for (const path of tryPaths) {
@@ -589,14 +600,21 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error('SFTP connection failed', err) logger.error('SFTP connection failed', err)
setError(i18n.t('errors:connection.failed')) setError(i18n.t('errors:connection.failed'))
} finally { } finally {
connectingGuardRef.current = false
setIsConnecting(false) setIsConnecting(false)
} }
}, },
[tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab] [tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab]
) )
connectRef.current = connect
// Disconnect // Disconnect
const disconnect = useCallback(async () => { const disconnect = useCallback(async () => {
intentionalDisconnectRef.current = true
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (sftpId && connectionId) { if (sftpId && connectionId) {
getConnectionStore().removeSFTP(connectionId, sftpId) getConnectionStore().removeSFTP(connectionId, sftpId)
} }
@@ -822,9 +840,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else { } else {
const message = const message =
translateErrorMessage(result.error) || translateErrorMessage(result.error) ||
(job.type === 'upload' (job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
? i18n.t('sftp:errors.uploadFailed')
: i18n.t('sftp:errors.downloadFailed'))
patchTransfer(job.id, { status: 'failed', error: message }) patchTransfer(job.id, { status: 'failed', error: message })
transferResultsRef.current.set(job.id, { transferResultsRef.current.set(job.id, {
status: 'failed', status: 'failed',
@@ -841,9 +857,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else { } else {
const message = const message =
translateErrorMessage(raw) || translateErrorMessage(raw) ||
(job.type === 'upload' (job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
? i18n.t('sftp:errors.uploadFailed')
: i18n.t('sftp:errors.downloadFailed'))
patchTransfer(job.id, { status: 'failed', error: message }) patchTransfer(job.id, { status: 'failed', error: message })
transferResultsRef.current.set(job.id, { transferResultsRef.current.set(job.id, {
status: 'failed', status: 'failed',
@@ -889,33 +903,30 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
[runTransferPump] [runTransferPump]
) )
const cancelTransfer = useCallback( const cancelTransfer = useCallback((taskId: string) => {
(taskId: string) => { const isActive = activeTransferIdRef.current === taskId
const isActive = activeTransferIdRef.current === taskId cancelledTransferIdsRef.current.add(taskId)
cancelledTransferIdsRef.current.add(taskId) // Drop pending jobs immediately so the pump never starts them.
// Drop pending jobs immediately so the pump never starts them. transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId)
transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId) setTransferQueue((prev) => {
setTransferQueue((prev) => { const next = prev.map((task) =>
const next = prev.map((task) => task.id === taskId && (task.status === 'pending' || task.status === 'transferring')
task.id === taskId && (task.status === 'pending' || task.status === 'transferring') ? { ...task, status: 'canceled' as const }
? { ...task, status: 'canceled' as const } : task
: task )
) transferQueueRef.current = next
transferQueueRef.current = next return next
return next })
if (!isActive) {
transferResultsRef.current.set(taskId, { status: 'canceled' })
cancelledTransferIdsRef.current.delete(taskId)
}
if (isActive && sftpIdRef.current) {
void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
logger.warn('cancelSftpTransfer failed', err)
}) })
if (!isActive) { }
transferResultsRef.current.set(taskId, { status: 'canceled' }) }, [])
cancelledTransferIdsRef.current.delete(taskId)
}
if (isActive && sftpIdRef.current) {
void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
logger.warn('cancelSftpTransfer failed', err)
})
}
},
[]
)
const retryTransfer = useCallback( const retryTransfer = useCallback(
(taskId: string) => { (taskId: string) => {
@@ -975,9 +986,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
return { return {
completed, completed,
failed: failedTasks.length, failed: failedTasks.length,
errors: failedTasks.map((result) => errors: failedTasks.map((result) => result.error || i18n.t('sftp:errors.uploadFailed')),
result.error || i18n.t('sftp:errors.uploadFailed')
),
} }
} }
await new Promise((resolve) => setTimeout(resolve, 40)) await new Promise((resolve) => setTimeout(resolve, 40))
@@ -1006,8 +1015,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
stats?: { type?: string; isDirectory?: boolean; size?: number } stats?: { type?: string; isDirectory?: boolean; size?: number }
} }
if (statsResult.success && statsResult.stats) { if (statsResult.success && statsResult.stats) {
isDirectory = isDirectory = statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
total = isDirectory ? 0 : statsResult.stats.size || 0 total = isDirectory ? 0 : statsResult.stats.size || 0
} }
} catch (err: unknown) { } catch (err: unknown) {
@@ -1237,9 +1245,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Do NOT release/disconnect on React unmount — StrictMode remounts would // Do NOT release/disconnect on React unmount — StrictMode remounts would
// kill a healthy SFTP session; tab close goes through disposeTabResources. // kill a healthy SFTP session; tab close goes through disposeTabResources.
useEffect(() => { useEffect(() => {
const onClosed = (_event: unknown, closedId: string) => { const onClosed = (_event: unknown, closedId: string, reason?: string) => {
if (!closedId || closedId !== connectionIdRef.current) return if (!closedId || closedId !== connectionIdRef.current) return
logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId }) logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId, reason })
const sid = sftpIdRef.current const sid = sftpIdRef.current
if (sid) { if (sid) {
getConnectionStore().removeSFTP(closedId, sid) getConnectionStore().removeSFTP(closedId, sid)
@@ -1248,7 +1256,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setSftpId(null) setSftpId(null)
setConnectionId(null) setConnectionId(null)
setRemoteFiles([]) setRemoteFiles([])
const closedMsg = i18n.t('errors:connection.closed') const closedMsg = translateErrorMessage(reason) || i18n.t('errors:connection.unexpectedDropped')
setError(closedMsg) setError(closedMsg)
// Keep lastBrowsePathRef so reconnect can resume the directory. // Keep lastBrowsePathRef so reconnect can resume the directory.
// Fail in-flight queue items so the user can retry after reconnect. // Fail in-flight queue items so the user can retry after reconnect.
@@ -1268,10 +1276,56 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
return next return next
}) })
updateTab(tabId, { connectionInstance: undefined, connectionStatus: 'disconnected' }) updateTab(tabId, { connectionInstance: undefined, connectionStatus: 'disconnected' })
const resumeSessionId = sessionIdRef.current
const { settings } = useSettingsStore.getState()
const kind = classifyDisconnectReason(reason)
if (
!resumeSessionId ||
intentionalDisconnectRef.current ||
!settings.autoReconnect ||
!shouldAutoReconnect(kind)
) {
return
}
const maxAttempts = settings.maxReconnectAttempts ?? 5
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
if (reconnectAttemptRef.current >= maxAttempts) {
setError(
i18n.t('terminal:connection.autoReconnectLimit', {
max: maxAttempts,
reasonSuffix: i18n.t('terminal:connection.autoReconnectLimitReason', {
reason: closedMsg,
}),
})
)
return
}
reconnectAttemptRef.current += 1
const attempt = reconnectAttemptRef.current
const delay = reconnectDelayMs(attempt, baseDelay)
setIsConnecting(true)
setError(
i18n.t('sftp:connection.autoReconnecting', {
attempt,
max: maxAttempts,
})
)
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null
void connectRef.current?.(resumeSessionId)
}, delay)
} }
window.opsxApi?.onConnectionClosed?.(onClosed) window.opsxApi?.onConnectionClosed?.(onClosed)
return () => { return () => {
window.opsxApi?.removeListener?.('ssh:connection-closed', onClosed as (...args: unknown[]) => void) window.opsxApi?.removeListener?.('ssh:connection-closed', onClosed as (...args: unknown[]) => void)
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
} }
}, [tabId, updateTab]) }, [tabId, updateTab])
+35 -3
View File
@@ -12,7 +12,14 @@ import { createLogger } from '../utils/logger'
import { detectAndPersistSessionDistro } from '../utils/detectSessionDistro' import { detectAndPersistSessionDistro } from '../utils/detectSessionDistro'
import i18n from '../i18n' import i18n from '../i18n'
import { translateErrorMessage } from '../i18n/format' import { translateErrorMessage } from '../i18n/format'
import {
classifyDisconnectReason,
reconnectDelayMs,
shouldAutoReconnect,
type DisconnectKind,
} from '../utils/disconnectReason'
import { isDefaultTerminalTitle } from '../utils/tabTitle' import { isDefaultTerminalTitle } from '../utils/tabTitle'
import { subscribeTabReconnect } from '../utils/tabReconnect'
import type { SSHConnectionConfig } from '../../shared/types/session.types' import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('useTerminalConnection') const logger = createLogger('useTerminalConnection')
@@ -37,6 +44,7 @@ export interface UseTerminalConnectionReturn {
statusMessage: string statusMessage: string
showSuspendedOverlay: boolean showSuspendedOverlay: boolean
reconnectAttempt: number reconnectAttempt: number
disconnectKind: DisconnectKind
connectionIdRef: React.MutableRefObject<string | null> connectionIdRef: React.MutableRefObject<string | null>
shellIdRef: React.MutableRefObject<string | null> shellIdRef: React.MutableRefObject<string | null>
hasConnectedRef: React.MutableRefObject<boolean> hasConnectedRef: React.MutableRefObject<boolean>
@@ -63,6 +71,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
const [statusMessage, setStatusMessage] = useState<string>('') const [statusMessage, setStatusMessage] = useState<string>('')
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false) const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
const [reconnectAttempt, setReconnectAttempt] = useState(0) const [reconnectAttempt, setReconnectAttempt] = useState(0)
const [disconnectKind, setDisconnectKind] = useState<DisconnectKind>('unexpected')
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore() const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
@@ -370,12 +379,19 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
const maxAttempts = settings.maxReconnectAttempts ?? 5 const maxAttempts = settings.maxReconnectAttempts ?? 5
const baseDelay = settings.reconnectBaseDelayMs ?? 1500 const baseDelay = settings.reconnectBaseDelayMs ?? 1500
const kind = classifyDisconnectReason(reason)
setDisconnectKind(kind)
if (!shouldAutoReconnect(kind)) return
if (reconnectAttemptRef.current >= maxAttempts) { if (reconnectAttemptRef.current >= maxAttempts) {
setStatusMessage( setStatusMessage(
i18n.t('terminal:connection.autoReconnectLimit', { i18n.t('terminal:connection.autoReconnectLimit', {
max: maxAttempts, max: maxAttempts,
reasonSuffix: reason ? i18n.t('terminal:connection.autoReconnectLimitReason', { reason }) : '', reasonSuffix: reason
? i18n.t('terminal:connection.autoReconnectLimitReason', {
reason: translateErrorMessage(reason),
})
: '',
}) })
) )
setConnectionState('error') setConnectionState('error')
@@ -384,7 +400,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
reconnectAttemptRef.current += 1 reconnectAttemptRef.current += 1
setReconnectAttempt(reconnectAttemptRef.current) setReconnectAttempt(reconnectAttemptRef.current)
const delay = Math.min(baseDelay * Math.pow(1.6, reconnectAttemptRef.current - 1), 30000) const delay = reconnectDelayMs(reconnectAttemptRef.current, baseDelay)
const seconds = Math.round(delay / 1000) const seconds = Math.round(delay / 1000)
const attempt = reconnectAttemptRef.current const attempt = reconnectAttemptRef.current
@@ -430,12 +446,24 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
if (!connectionIdRef.current || closedId !== connectionIdRef.current) return if (!connectionIdRef.current || closedId !== connectionIdRef.current) return
if (intentionalDisconnectRef.current) return if (intentionalDisconnectRef.current) return
const kind = classifyDisconnectReason(reason)
if (kind === 'idle-suspend') {
setDisconnectKind(kind)
setShowSuspendedOverlay(true)
setConnectionState('suspended')
updateTabStatus(tabId, 'disconnected')
return
}
logger.info('[Terminal] Connection closed unexpectedly', closedId, reason) logger.info('[Terminal] Connection closed unexpectedly', closedId, reason)
connectionIdRef.current = null connectionIdRef.current = null
shellIdRef.current = null shellIdRef.current = null
setDisconnectKind(kind)
setConnectionState('disconnected') setConnectionState('disconnected')
updateTabStatus(tabId, 'disconnected') updateTabStatus(tabId, 'disconnected')
xtermRef.current?.writeln(`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`) xtermRef.current?.writeln(
`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason: translateErrorMessage(reason) })}\x1b[0m`
)
scheduleAutoReconnectRef.current?.(reason) scheduleAutoReconnectRef.current?.(reason)
} }
@@ -501,6 +529,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
clearReconnectTimer() clearReconnectTimer()
reconnectAttemptRef.current = 0 reconnectAttemptRef.current = 0
setReconnectAttempt(0) setReconnectAttempt(0)
setDisconnectKind('unexpected')
intentionalDisconnectRef.current = false intentionalDisconnectRef.current = false
const tab = getTabById(tabId) const tab = getTabById(tabId)
@@ -525,11 +554,14 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
connect() connect()
}, [connect, updateTab, tabId, xtermRef, clearReconnectTimer, getTabById]) }, [connect, updateTab, tabId, xtermRef, clearReconnectTimer, getTabById])
useEffect(() => subscribeTabReconnect(tabId, () => handleReconnect()), [tabId, handleReconnect])
return { return {
connectionState, connectionState,
statusMessage, statusMessage,
showSuspendedOverlay, showSuspendedOverlay,
reconnectAttempt, reconnectAttempt,
disconnectKind,
connectionIdRef, connectionIdRef,
shellIdRef, shellIdRef,
hasConnectedRef, hasConnectedRef,
+51 -20
View File
@@ -4,21 +4,37 @@ import { intlLocale } from './detectLocale'
/** Map stable backend / FE error phrases to i18n keys; fall back to raw text. */ /** Map stable backend / FE error phrases to i18n keys; fall back to raw text. */
const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [ const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
{ match: /连接失败,请检查网络|connection failed/i, key: 'errors:connection.failed' }, { match: /连接失败,请检查网络|connection failed/i, key: 'errors:connection.failed' },
{ match: /timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' }, { match: /^shell\.idleTimeout$|timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
{ match: /^shell\.exited$/i, key: 'errors:connection.shellExited' },
{ match: /^shell\.readFailed|远端 Shell 读取失败/i, key: 'errors:connection.shellReadFailed' },
{ match: /连接超时|timed?\s*out/i, key: 'errors:connection.timeout' }, { match: /连接超时|timed?\s*out/i, key: 'errors:connection.timeout' },
{ match: /认证失败|auth(entication)? failed/i, key: 'errors:auth.failed' }, { match: /认证失败|auth(entication)? failed/i, key: 'errors:auth.failed' },
{ match: /主机不可达|unreachable/i, key: 'errors:connection.hostUnreachable' }, { match: /主机不可达|unreachable/i, key: 'errors:connection.hostUnreachable' },
{ match: /保活失败|keepalive failed/i, key: 'errors:connection.keepaliveFailed' }, { match: /保活失败|keepalive failed/i, key: 'errors:connection.keepaliveFailed' },
{ match: /连接已关闭|connection closed/i, key: 'errors:connection.closed' }, { match: /closed by user|connection closed/i, key: 'errors:connection.unexpectedDropped' },
{ match: /连接已关闭/i, key: 'errors:connection.closed' },
{ match: /终端创建失败/i, key: 'errors:shell.createFailed' }, { match: /终端创建失败/i, key: 'errors:shell.createFailed' },
{ match: /命令执行失败/i, key: 'errors:shell.execFailed' }, { match: /命令执行失败/i, key: 'errors:shell.execFailed' },
{ match: /目标地址不能是 0\.0\.0\.0|目标不能是 0\.0\.0\.0/i, key: 'errors:forward.invalidTargetHost' }, { match: /目标地址不能是 0\.0\.0\.0|目标不能是 0\.0\.0\.0/i, key: 'errors:forward.invalidTargetHost' },
{ match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i, key: 'errors:forward.remoteDeniedPrivileged' }, {
{ match: /建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i, key: 'errors:forward.remoteDenied' }, match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i,
key: 'errors:forward.remoteDeniedPrivileged',
},
{
match:
/建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i,
key: 'errors:forward.remoteDenied',
},
{ match: /建立远程端口转发失败|端口转发设置失败/i, key: 'errors:forward.failed' }, { match: /建立远程端口转发失败|端口转发设置失败/i, key: 'errors:forward.failed' },
{ match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i, key: 'errors:forward.alreadyInUse' }, {
match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i,
key: 'errors:forward.alreadyInUse',
},
{ match: /SFTP 正在传输文件|transfer is in progress/i, key: 'errors:sftp.busy' }, { match: /SFTP 正在传输文件|transfer is in progress/i, key: 'errors:sftp.busy' },
{ match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i, key: 'errors:sftp.channelFailed' }, {
match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i,
key: 'errors:sftp.channelFailed',
},
{ match: /SFTP 会话不存在|SSH 连接不存在/i, key: 'errors:sftp.sessionGone' }, { match: /SFTP 会话不存在|SSH 连接不存在/i, key: 'errors:sftp.sessionGone' },
{ match: /文件过大|编辑上限约 2MB|too large|edit limit/i, key: 'errors:sftp.editTooLarge' }, { match: /文件过大|编辑上限约 2MB|too large|edit limit/i, key: 'errors:sftp.editTooLarge' },
{ match: /像是二进制|binary content/i, key: 'errors:sftp.editBinary' }, { match: /像是二进制|binary content/i, key: 'errors:sftp.editBinary' },
@@ -36,38 +52,53 @@ const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
{ match: /主机地址格式/i, key: 'errors:validation.invalidHost' }, { match: /主机地址格式/i, key: 'errors:validation.invalidHost' },
{ match: /端口号必须/i, key: 'errors:validation.invalidPort' }, { match: /端口号必须/i, key: 'errors:validation.invalidPort' },
{ match: /凭据格式/i, key: 'errors:validation.invalidCredentials' }, { match: /凭据格式/i, key: 'errors:validation.invalidCredentials' },
{ match: /命令已终止|command (was )?(cancelled|canceled)|command terminated/i, key: 'ai:step.terminated' },
{ match: /剪贴板/i, key: 'errors:clipboard.readFailed' }, { match: /剪贴板/i, key: 'errors:clipboard.readFailed' },
] ]
function lookupErrorCode(code: string): string | null {
const coded = i18n.t(`errors:${code}`)
if (coded && coded !== `errors:${code}` && coded !== code) return coded
return null
}
export function translateErrorMessage(raw: string | undefined | null): string { export function translateErrorMessage(raw: string | undefined | null): string {
const text = (raw || '').trim() const text = (raw || '').trim()
if (!text) return i18n.t('errors:unknown.error') if (!text) return i18n.t('errors:unknown.error')
if (text.startsWith('native.')) {
const coded = lookupErrorCode(text)
if (coded) return coded
}
for (const entry of ERROR_PHRASE_MAP) { for (const entry of ERROR_PHRASE_MAP) {
const hit = const hit = typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
if (hit) return i18n.t(entry.key) if (hit) return i18n.t(entry.key)
} }
return text return text
} }
export function formatLocaleDate( /** Prefer a stable `errorCode` from a command envelope, then fall back to phrase matching. */
date: Date, export function translateIpcError(error?: string | null, errorCode?: string | null): string {
options?: Intl.DateTimeFormatOptions if (errorCode) {
): string { const coded = lookupErrorCode(errorCode)
if (coded) return coded
}
return translateErrorMessage(error)
}
/** Native `ssh_exec` cancel from Agent Stop. Backend still returns a Chinese phrase. */
export function isSshExecCancelled(error?: string | null): boolean {
return /已终止|command (was )?(cancelled|canceled)|command terminated/i.test(error || '')
}
export function formatLocaleDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
return date.toLocaleDateString(intlLocale(i18n.language), options) return date.toLocaleDateString(intlLocale(i18n.language), options)
} }
export function formatLocaleTime( export function formatLocaleTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
date: Date,
options?: Intl.DateTimeFormatOptions
): string {
return date.toLocaleTimeString(intlLocale(i18n.language), options) return date.toLocaleTimeString(intlLocale(i18n.language), options)
} }
export function formatLocaleDateTime( export function formatLocaleDateTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
date: Date,
options?: Intl.DateTimeFormatOptions
): string {
return date.toLocaleString(intlLocale(i18n.language), options) return date.toLocaleString(intlLocale(i18n.language), options)
} }
+4 -4
View File
@@ -16,10 +16,10 @@
"stop": "Stop", "stop": "Stop",
"send": "Send", "send": "Send",
"placeholder": "Task or follow-up… (Enter to send, Shift+Enter for newline)", "placeholder": "Task or follow-up… (Enter to send, Shift+Enter for newline)",
"hintFollowUp": "Follow-ups include this thread's context", "hintFollowUp": "Follow-ups include a truncated summary of this thread",
"hintSelection": "Will include {{lines}} selected lines", "hintSelection": "Will include {{lines}} selected lines",
"hintSteps": "Up to {{max}} steps · nothing persisted", "hintSteps": "Up to {{max}} steps",
"footerNote": "High risk requires confirmation · critical is blocked · session not saved", "footerNote": "High-risk commands need confirmation · destructive ones are blocked · chat is not saved",
"summarize": "Summarize", "summarize": "Summarize",
"summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands", "summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands",
"summarizeRequest": "Summarize the current terminal output" "summarizeRequest": "Summarize the current terminal output"
@@ -77,7 +77,7 @@
"invalidCommand": "Model did not return a single one-line command; execution refused", "invalidCommand": "Model did not return a single one-line command; execution refused",
"repeatLoop": "Repeated command loop detected; stopped: {{command}}", "repeatLoop": "Repeated command loop detected; stopped: {{command}}",
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}", "criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
"execFailed": "ssh-exec failed: {{error}}", "execFailed": "Remote execution failed: {{error}}",
"execUnknown": "Unknown error", "execUnknown": "Unknown error",
"execNoOutput": "Command finished with no output", "execNoOutput": "Command finished with no output",
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry", "maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
+7 -4
View File
@@ -33,8 +33,10 @@
"error": { "error": {
"title": "Something went wrong", "title": "Something went wrong",
"body": "The app hit an unexpected problem", "body": "The app hit an unexpected problem",
"bodyPane": "This tab hit an unexpected problem. Other tabs still work.",
"recoverable": "This error is recoverable. Try refreshing the page.", "recoverable": "This error is recoverable. Try refreshing the page.",
"refreshPage": "Refresh page", "refreshPage": "Refresh page",
"retryPane": "Retry this tab",
"restartApp": "Restart app", "restartApp": "Restart app",
"supportHint": "If the problem persists, check logs or contact support" "supportHint": "If the problem persists, check logs or contact support"
}, },
@@ -104,11 +106,12 @@
}, },
"menu": { "menu": {
"closeOthers": "Close others", "closeOthers": "Close others",
"closeRight": "Close tabs to the right", "closeRight": "Close right tabs",
"closeLeft": "Close tabs to the left", "closeLeft": "Close left tabs",
"closeAll": "Close all", "closeAll": "Close all",
"clone": "Duplicate tab", "clone": "Clone tab",
"openSftp": "Open SFTP" "openSftp": "Open SFTP",
"reconnect": "Reconnect"
}, },
"renameAria": "Rename tab {{title}}", "renameAria": "Rename tab {{title}}",
"renameHint": "Double-click to rename" "renameHint": "Double-click to rename"
+5 -1
View File
@@ -35,13 +35,17 @@
"fullscreenAria": "Enter fullscreen", "fullscreenAria": "Enter fullscreen",
"exitFullscreen": "Exit fullscreen", "exitFullscreen": "Exit fullscreen",
"exitFullscreenAria": "Exit fullscreen", "exitFullscreenAria": "Exit fullscreen",
"exitFullscreenHint": "Esc / F11 to exit" "exitFullscreenHint": "Esc / F11 to exit",
"reconnect": "Reconnect",
"reconnectAria": "Reconnect remote desktop"
}, },
"status": { "status": {
"connecting": "Connecting…", "connecting": "Connecting…",
"connected": "Connected", "connected": "Connected",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"connectFailed": "Connection failed", "connectFailed": "Connection failed",
"reconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…",
"reconnectLimit": "Auto-reconnect limit reached ({{max}})",
"clipboardSynced": "Remote clipboard synced", "clipboardSynced": "Remote clipboard synced",
"savedToDownloads": "Saved to local Downloads: {{name}}", "savedToDownloads": "Saved to local Downloads: {{name}}",
"pastedToRemote": "Pasted to remote", "pastedToRemote": "Pasted to remote",
+11 -2
View File
@@ -41,7 +41,10 @@
"refused": "Connection refused", "refused": "Connection refused",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"keepaliveFailed": "SSH keep-alive failed repeatedly; connection closed", "keepaliveFailed": "SSH keep-alive failed repeatedly; connection closed",
"idleKicked": "Remote shell logged out due to idle timeout (TMOUT)" "idleKicked": "Remote shell logged out due to idle timeout (TMOUT)",
"unexpectedDropped": "Disconnected (network drop or remote close)",
"shellExited": "Remote shell exited (exit, Ctrl+D, or server policy)",
"shellReadFailed": "Failed to read from the remote shell"
}, },
"auth": { "auth": {
"failed": "Authentication failed. Check the username and password.", "failed": "Authentication failed. Check the username and password.",
@@ -160,7 +163,7 @@
"invalidCommand": "Model did not return a single one-line command; execution refused", "invalidCommand": "Model did not return a single one-line command; execution refused",
"repeatLoop": "Repeated command loop detected; stopped: {{command}}", "repeatLoop": "Repeated command loop detected; stopped: {{command}}",
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}", "criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
"execFailed": "ssh-exec failed: {{error}}", "execFailed": "Remote execution failed: {{error}}",
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry", "maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
"sshRequired": "A connected remote SSH session is required" "sshRequired": "A connected remote SSH session is required"
}, },
@@ -168,6 +171,12 @@
"readFailed": "Read failed: {{name}}", "readFailed": "Read failed: {{name}}",
"writeFailed": "Write failed: {{name}}" "writeFailed": "Write failed: {{name}}"
}, },
"native": {
"taskJoin": "A background task failed. Retry the operation.",
"invalidBase64": "File data is not valid Base64.",
"knownHostsPath": "Could not locate ~/.ssh/known_hosts.",
"invalidHostKeyDecision": "Invalid host key decision."
},
"unknown": { "unknown": {
"error": "An unknown error occurred" "error": "An unknown error occurred"
} }
+5 -2
View File
@@ -132,7 +132,8 @@
"collapse": "Collapse", "collapse": "Collapse",
"protocol": "Protocol", "protocol": "Protocol",
"displayName": "Display name (optional)", "displayName": "Display name (optional)",
"displayNamePlaceholder": "Defaults to user@host or vnc:host", "displayNamePlaceholder": "Defaults to user@host",
"displayNamePlaceholderVnc": "Defaults to the host name",
"host": "Host", "host": "Host",
"hostPlaceholder": "192.168.1.10", "hostPlaceholder": "192.168.1.10",
"hostHint": "IP or hostname; host:port is also accepted", "hostHint": "IP or hostname; host:port is also accepted",
@@ -208,7 +209,8 @@
}, },
"importExport": { "importExport": {
"exportTitle": "Export sessions", "exportTitle": "Export sessions",
"exportDefaultPath": "opsxshell-sessions.bak", "exportDefaultPath": "opsxshell-hosts.oxs",
"exportFilterOxs": "OpsXShell host backup",
"exportFilterBak": "OpsXShell backup", "exportFilterBak": "OpsXShell backup",
"exportFilterJson": "JSON", "exportFilterJson": "JSON",
"exportSuccess": "Exported {{count}} sessions to:\n{{path}}", "exportSuccess": "Exported {{count}} sessions to:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "Passwords do not match", "exportPasswordMismatch": "Passwords do not match",
"exportPasswordRequired": "A backup password is required to export", "exportPasswordRequired": "A backup password is required to export",
"importTitle": "Import sessions", "importTitle": "Import sessions",
"importFilterOxs": "OpsXShell host backup",
"importFilterBak": "OpsXShell backup", "importFilterBak": "OpsXShell backup",
"importFilterJson": "JSON", "importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config", "importFilterSshConfig": "SSH Config",
+31 -19
View File
@@ -13,7 +13,7 @@
"ai": "AI models", "ai": "AI models",
"aiDesc": "AI providers", "aiDesc": "AI providers",
"about": "About", "about": "About",
"aboutDesc": "Version and data", "aboutDesc": "Version, contact, and diagnostics",
"account": "Account", "account": "Account",
"accountDesc": "Email, license, and device" "accountDesc": "Email, license, and device"
}, },
@@ -89,22 +89,23 @@
"ssh": { "ssh": {
"connection": "Connection", "connection": "Connection",
"keepAlive": "Keep-alive", "keepAlive": "Keep-alive",
"keepAliveDesc": "Send SSH keep-alive while idle; disconnect only after several missed replies", "keepAliveDesc": "Send SSH protocol keep-alive while idle (not typed into the shell). Beats NAT drops; does not bypass remote TMOUT",
"autoReconnect": "Auto reconnect", "autoReconnect": "Auto reconnect",
"autoReconnectDesc": "Automatically retry after unexpected disconnects", "autoReconnectDesc": "Retry after unexpected disconnects while the tab stays open (new shell). Closing the tab, idle suspend, or remote TMOUT are not forced alive",
"timeout": "Connection timeout", "timeout": "Connection timeout",
"timeoutDesc": "Maximum wait time when establishing SSH (seconds)", "timeoutDesc": "Maximum wait when establishing an SSH connection (seconds)",
"maxReconnect": "Reconnect attempts", "maxReconnect": "Auto-reconnect attempts",
"maxReconnectDesc": "Maximum retries after unexpected disconnect", "maxReconnectDesc": "Maximum retries after an unexpected disconnect",
"pool": "Connection pool", "pool": "Connection pool",
"maxConnections": "Max connections", "maxConnections": "Maximum connections",
"maxConnectionsDesc": "Shared cap for SSH, SFTP, and batch connections", "maxConnectionsDesc": "Shared by SSH, SFTP, and batch connections",
"maxBackground": "Background connection limit", "maxBackground": "Background connection limit",
"maxBackgroundDesc": "Evict least recently used connections when over limit", "maxBackgroundDesc": "When exceeded, the least recently used connection is reclaimed first",
"suspend": "Background suspend", "suspend": "Background suspend",
"suspendDesc": "Minutes before idle background tabs suspend (active Shell/SFTP stay connected)", "suspendDesc": "Minutes before a background tab is suspended (kept alive while a shell or SFTP is active)",
"keepAliveInterval": "Keep-alive interval (sec)", "keepAliveInterval": "Keep-alive interval (sec)",
"keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT" "keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT",
"sessionPolicyHint": "Keep-alive protects the TCP link, not remote session policy. Bash TMOUT, sshd ClientAlive, and Windows idle GPO still apply. Use tmux/screen on the server for long jobs."
}, },
"security": { "security": {
"credentials": "Credential protection", "credentials": "Credential protection",
@@ -122,16 +123,25 @@
}, },
"about": { "about": {
"tagline": "Simple, practical native ops terminal", "tagline": "Simple, practical native ops terminal",
"appVersion": "App v{{version}}", "appVersion": "v{{version}}",
"copyVersion": "Copy version info", "copyVersion": "Copy version, system, and paths for a GitHub issue",
"runtime": "Runtime", "copyDiagnostics": "Copy diagnostics",
"system": "System", "copyPath": "Copy path",
"localData": "Local data", "resources": "Resources",
"wechat": "WeChat",
"copyWechat": "Copy WeChat ID",
"github": "Discussions",
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
"releases": "Releases",
"releasesDesc": "Installers and release notes",
"licenseLink": "License",
"licenseLinkDesc": "MIT License",
"diagnostics": "Diagnostics",
"sessionsSettings": "Sessions and settings", "sessionsSettings": "Sessions and settings",
"masterKeyPath": "Master key",
"privacyNote": "Passwords and key passphrases are AES-256-GCM encrypted in Rust. No terminal history, no bastion audit trail.",
"license": "MIT License · Rust · XTerm.js", "license": "MIT License · Rust · XTerm.js",
"logPath": "Application log" "logPath": "Application log",
"reportBug": "Report a bug",
"reportBugDesc": "Opens GitHub with this version and platform filled in"
}, },
"ai": { "ai": {
"presets": "Provider presets", "presets": "Provider presets",
@@ -157,7 +167,9 @@
"otherModels": "Other models…", "otherModels": "Other models…",
"customModel": "Custom model", "customModel": "Custom model",
"backToList": "Back to list", "backToList": "Back to list",
"apiKey": "API Key",
"apiKeyOptional": "API Key (not required)", "apiKeyOptional": "API Key (not required)",
"endpoint": "Endpoint",
"advanced": "Advanced", "advanced": "Advanced",
"allowHttp": "Allow HTTP (localhost/LAN)", "allowHttp": "Allow HTTP (localhost/LAN)",
"setActive": "Set as active", "setActive": "Set as active",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "Not connected", "notConnected": "Not connected",
"connecting": "Connecting...", "connecting": "Connecting...",
"resumeHint": "Reconnect will resume at {{path}}", "resumeHint": "Reconnect will resume at {{path}}",
"reconnectingResume": "Reconnecting and resuming {{path}}…" "reconnectingResume": "Reconnecting and resuming {{path}}…",
"autoReconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…"
}, },
"errors": { "errors": {
"notConnected": "SFTP is not connected", "notConnected": "SFTP is not connected",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "Reopen shell", "reopenShell": "Reopen shell",
"reopening": "Opening…", "reopening": "Opening…",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"disconnectedHint": "The SSH session ended or the network dropped. One click below reconnects so you can keep working.", "disconnectedHint": "The network dropped or the server closed SSH. The tab will auto-reconnect while it stays open, or reconnect now with the button below.",
"disconnectedKeepalive": "Keep-alive failed; disconnected",
"disconnectedKeepaliveHint": "SSH keep-alive got no reply — usually a NAT or idle network drop. No keystrokes are sent into the shell. The tab will auto-reconnect while it stays open.",
"reconnect": "Reconnect", "reconnect": "Reconnect",
"suspended": "Connection suspended", "suspended": "Connection suspended",
"suspendedHint": "Inactive for a while; connection was suspended to save resources", "suspendedHint": "Inactive for a while; connection was suspended to save resources",
+4 -4
View File
@@ -16,10 +16,10 @@
"stop": "停止", "stop": "停止",
"send": "送信", "send": "送信",
"placeholder": "タスクまたはフォローアップ… (Enter で送信 · Shift+Enter で改行)", "placeholder": "タスクまたはフォローアップ… (Enter で送信 · Shift+Enter で改行)",
"hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます", "hintFollowUp": "フォローアップにはこのスレッドの要約が含まれます(長い出力は切り詰めます)",
"hintSelection": "選択した {{lines}} 行を含めます", "hintSelection": "選択した {{lines}} 行を含めます",
"hintSteps": "最大 {{max}} ステップ · 何も保存しません", "hintSteps": "最大 {{max}} ステップ",
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません", "footerNote": "高リスクは先に確認 · 破壊的なコマンドは拒否 · 会話は保存しません",
"summarize": "出力を要約", "summarize": "出力を要約",
"summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません", "summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません",
"summarizeRequest": "現在の端末出力を要約" "summarizeRequest": "現在の端末出力を要約"
@@ -77,7 +77,7 @@
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否", "invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}", "repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}", "criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
"execFailed": "ssh-exec 失敗: {{error}}", "execFailed": "リモート実行に失敗しました: {{error}}",
"execUnknown": "不明なエラー", "execUnknown": "不明なエラー",
"execNoOutput": "コマンドは出力なしで完了", "execNoOutput": "コマンドは出力なしで完了",
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください", "maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
+4 -1
View File
@@ -33,8 +33,10 @@
"error": { "error": {
"title": "問題が発生しました", "title": "問題が発生しました",
"body": "アプリで予期しない問題が発生しました", "body": "アプリで予期しない問題が発生しました",
"bodyPane": "このタブで問題が発生しました。他のタブは引き続き使えます。",
"recoverable": "このエラーは復旧可能です。ページを更新してみてください。", "recoverable": "このエラーは復旧可能です。ページを更新してみてください。",
"refreshPage": "ページを更新", "refreshPage": "ページを更新",
"retryPane": "このタブを再試行",
"restartApp": "アプリを再起動", "restartApp": "アプリを再起動",
"supportHint": "問題が解決しない場合は、ログを確認するかサポートにお問い合わせください" "supportHint": "問題が解決しない場合は、ログを確認するかサポートにお問い合わせください"
}, },
@@ -108,7 +110,8 @@
"closeLeft": "左側のタブを閉じる", "closeLeft": "左側のタブを閉じる",
"closeAll": "すべて閉じる", "closeAll": "すべて閉じる",
"clone": "タブを複製", "clone": "タブを複製",
"openSftp": "SFTP を開く" "openSftp": "SFTP を開く",
"reconnect": "再接続"
}, },
"renameAria": "タブ {{title}} の名前を変更", "renameAria": "タブ {{title}} の名前を変更",
"renameHint": "ダブルクリックで名前変更" "renameHint": "ダブルクリックで名前変更"
+5 -1
View File
@@ -35,13 +35,17 @@
"fullscreenAria": "全画面表示にする", "fullscreenAria": "全画面表示にする",
"exitFullscreen": "全画面を終了", "exitFullscreen": "全画面を終了",
"exitFullscreenAria": "全画面を終了", "exitFullscreenAria": "全画面を終了",
"exitFullscreenHint": "Esc / F11 で終了" "exitFullscreenHint": "Esc / F11 で終了",
"reconnect": "再接続",
"reconnectAria": "リモートデスクトップを再接続"
}, },
"status": { "status": {
"connecting": "接続中…", "connecting": "接続中…",
"connected": "接続済み", "connected": "接続済み",
"disconnected": "切断済み", "disconnected": "切断済み",
"connectFailed": "接続失敗", "connectFailed": "接続失敗",
"reconnecting": "自動再接続中({{attempt}}/{{max}})…",
"reconnectLimit": "自動再接続の上限に達しました({{max}})",
"clipboardSynced": "リモートのクリップボードを同期しました", "clipboardSynced": "リモートのクリップボードを同期しました",
"savedToDownloads": "ローカルの Downloads に保存: {{name}}", "savedToDownloads": "ローカルの Downloads に保存: {{name}}",
"pastedToRemote": "リモートに貼り付けました", "pastedToRemote": "リモートに貼り付けました",
+11 -2
View File
@@ -41,7 +41,10 @@
"refused": "接続が拒否されました", "refused": "接続が拒否されました",
"disconnected": "切断されました", "disconnected": "切断されました",
"keepaliveFailed": "SSH キープアライブが連続失敗し、切断されました", "keepaliveFailed": "SSH キープアライブが連続失敗し、切断されました",
"idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)" "idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)",
"unexpectedDropped": "切断されました(ネットワーク切断またはサーバー側クローズ)",
"shellExited": "リモート Shell が終了しました(exit、Ctrl+D、またはサーバーポリシー)",
"shellReadFailed": "リモート Shell の読み取りに失敗しました"
}, },
"auth": { "auth": {
"failed": "認証に失敗しました。ユーザー名とパスワードを確認してください。", "failed": "認証に失敗しました。ユーザー名とパスワードを確認してください。",
@@ -160,7 +163,7 @@
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否", "invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}", "repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}", "criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
"execFailed": "ssh-exec 失敗: {{error}}", "execFailed": "リモート実行に失敗しました: {{error}}",
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください", "maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
"sshRequired": "接続済みのリモート SSH セッションが必要です" "sshRequired": "接続済みのリモート SSH セッションが必要です"
}, },
@@ -168,6 +171,12 @@
"readFailed": "読み取り失敗: {{name}}", "readFailed": "読み取り失敗: {{name}}",
"writeFailed": "書き込み失敗: {{name}}" "writeFailed": "書き込み失敗: {{name}}"
}, },
"native": {
"taskJoin": "バックグラウンド処理に失敗しました。再試行してください。",
"invalidBase64": "ファイルデータが有効な Base64 ではありません。",
"knownHostsPath": "~/.ssh/known_hosts を特定できません。",
"invalidHostKeyDecision": "ホスト鍵の決定が無効です。"
},
"unknown": { "unknown": {
"error": "不明なエラーが発生しました" "error": "不明なエラーが発生しました"
} }
+5 -2
View File
@@ -132,7 +132,8 @@
"collapse": "折りたたむ", "collapse": "折りたたむ",
"protocol": "プロトコル", "protocol": "プロトコル",
"displayName": "表示名 (任意)", "displayName": "表示名 (任意)",
"displayNamePlaceholder": "デフォルトは user@host または vnc:host", "displayNamePlaceholder": "デフォルトは user@host",
"displayNamePlaceholderVnc": "デフォルトはホスト名",
"host": "ホスト", "host": "ホスト",
"hostPlaceholder": "192.168.1.10", "hostPlaceholder": "192.168.1.10",
"hostHint": "IP またはホスト名 · host:port も可", "hostHint": "IP またはホスト名 · host:port も可",
@@ -208,7 +209,8 @@
}, },
"importExport": { "importExport": {
"exportTitle": "セッションをエクスポート", "exportTitle": "セッションをエクスポート",
"exportDefaultPath": "opsxshell-sessions.bak", "exportDefaultPath": "opsxshell-hosts.oxs",
"exportFilterOxs": "OpsXShell ホストバックアップ",
"exportFilterBak": "OpsXShell バックアップ", "exportFilterBak": "OpsXShell バックアップ",
"exportFilterJson": "JSON", "exportFilterJson": "JSON",
"exportSuccess": "{{count}} 件のセッションをエクスポート:\n{{path}}", "exportSuccess": "{{count}} 件のセッションをエクスポート:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "パスワードが一致しません", "exportPasswordMismatch": "パスワードが一致しません",
"exportPasswordRequired": "エクスポートにはバックアップパスワードが必要です", "exportPasswordRequired": "エクスポートにはバックアップパスワードが必要です",
"importTitle": "セッションをインポート", "importTitle": "セッションをインポート",
"importFilterOxs": "OpsXShell ホストバックアップ",
"importFilterBak": "OpsXShell バックアップ", "importFilterBak": "OpsXShell バックアップ",
"importFilterJson": "JSON", "importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config", "importFilterSshConfig": "SSH Config",
+44 -32
View File
@@ -13,7 +13,7 @@
"ai": "AI モデル", "ai": "AI モデル",
"aiDesc": "AI プロバイダー", "aiDesc": "AI プロバイダー",
"about": "情報", "about": "情報",
"aboutDesc": "バージョンとデータ", "aboutDesc": "バージョン、連絡先、診断",
"account": "アカウント", "account": "アカウント",
"accountDesc": "メール、ライセンス、デバイス" "accountDesc": "メール、ライセンス、デバイス"
}, },
@@ -89,9 +89,9 @@
"ssh": { "ssh": {
"connection": "接続", "connection": "接続",
"keepAlive": "キープアライブ", "keepAlive": "キープアライブ",
"keepAliveDesc": "アイドル時に SSH キープアライブを送信。数回連続失敗してから切断", "keepAliveDesc": "アイドル時に SSH プロトコルのキープアライブを送信(シェルへは入力しません)。NAT 切断対策であり、遠隔 TMOUT は迂回しません",
"autoReconnect": "自動再接続", "autoReconnect": "自動再接続",
"autoReconnectDesc": "予期しない切断後に自動で再試行します", "autoReconnectDesc": "タブを開いたままの予期しない切断後に自動再接続(新しいシェル)。タブを閉じた場合、アイドル休止、遠隔 TMOUT は強制維持しません",
"timeout": "接続タイムアウト", "timeout": "接続タイムアウト",
"timeoutDesc": "SSH 接続確立の最大待機時間(秒)", "timeoutDesc": "SSH 接続確立の最大待機時間(秒)",
"maxReconnect": "再接続回数", "maxReconnect": "再接続回数",
@@ -104,7 +104,8 @@
"suspend": "バックグラウンド休止", "suspend": "バックグラウンド休止",
"suspendDesc": "アイドルのバックグラウンドタブを何分後に休止するか(稼働中の Shell/SFTP は維持)", "suspendDesc": "アイドルのバックグラウンドタブを何分後に休止するか(稼働中の Shell/SFTP は維持)",
"keepAliveInterval": "キープアライブ間隔(秒)", "keepAliveInterval": "キープアライブ間隔(秒)",
"keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨" "keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨",
"sessionPolicyHint": "キープアライブは TCP 経路を守るもので、遠隔のセッション方針は変えません。Bash TMOUT、sshd ClientAlive、Windows のアイドル GPO は有効です。長時間ジョブはサーバー側で tmux/screen を使ってください。"
}, },
"security": { "security": {
"credentials": "資格情報の保護", "credentials": "資格情報の保護",
@@ -122,16 +123,25 @@
}, },
"about": { "about": {
"tagline": "シンプルで使いやすいネイティブ運用端末", "tagline": "シンプルで使いやすいネイティブ運用端末",
"appVersion": "アプリ v{{version}}", "appVersion": "v{{version}}",
"copyVersion": "バージョン情報をコピー", "copyVersion": "バージョン・環境・パスをコピーして GitHub に貼り付け",
"runtime": "ランタイム", "copyDiagnostics": "診断情報をコピー",
"system": "システム", "copyPath": "パスをコピー",
"localData": "ローカルデータ", "resources": "リソース",
"wechat": "WeChat",
"copyWechat": "WeChat ID をコピー",
"github": "ディスカッション",
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
"releases": "リリース",
"releasesDesc": "インストーラーと更新内容",
"licenseLink": "ライセンス",
"licenseLinkDesc": "MIT License",
"diagnostics": "診断",
"sessionsSettings": "セッションと設定", "sessionsSettings": "セッションと設定",
"masterKeyPath": "マスターキー",
"privacyNote": "パスワードと鍵パスフレーズは Rust 側で AES-256-GCM 暗号化。コマンド履歴も監査ログも保存しません。",
"license": "MIT License · Rust · XTerm.js", "license": "MIT License · Rust · XTerm.js",
"logPath": "アプリログ" "logPath": "アプリログ",
"reportBug": "不具合を報告",
"reportBugDesc": "GitHub issue を開き、現在のバージョンと環境を記入します"
}, },
"ai": { "ai": {
"presets": "プロバイダープリセット", "presets": "プロバイダープリセット",
@@ -151,18 +161,20 @@
"saved": "保存済み", "saved": "保存済み",
"retest": "再テストしてください", "retest": "再テストしてください",
"testFailed": "接続テストに失敗しました", "testFailed": "接続テストに失敗しました",
"available": "OK · {{latency}} ms", "available": "利用可 · {{latency}} ms",
"savedKeepBlank": "保存済み — 空欄で維持", "savedKeepBlank": "保存済み — 空欄で維持",
"notRequired": "不要", "notRequired": "不要",
"otherModels": "その他のモデル…", "otherModels": "その他のモデル…",
"customModel": "カスタムモデル", "customModel": "カスタムモデル",
"backToList": "一覧に戻る", "backToList": "一覧に戻る",
"apiKeyOptional": "API Key(不要)", "apiKey": "API キー",
"apiKeyOptional": "API キー(不要)",
"endpoint": "エンドポイント",
"advanced": "詳細", "advanced": "詳細",
"allowHttp": "HTTP を許可(localhost / LAN)", "allowHttp": "HTTP を許可(localhost / LAN)",
"setActive": "現在のプロバイダーに設定", "setActive": "現在のプロバイダーに設定",
"deleteTitle": "削除", "deleteTitle": "削除",
"apiKeySaveFailed": "API Key の保存に失敗しました", "apiKeySaveFailed": "API キーの保存に失敗しました",
"providerNotFound": "プロバイダーが見つかりません", "providerNotFound": "プロバイダーが見つかりません",
"modelCatalogReadFailed": "モデルカタログの読み取りに失敗しました", "modelCatalogReadFailed": "モデルカタログの読み取りに失敗しました",
"catalogUnavailable": "models.dev を利用できません", "catalogUnavailable": "models.dev を利用できません",
@@ -175,26 +187,26 @@
} }
}, },
"account": { "account": {
"title": "Account", "title": "アカウント",
"email": "Email", "email": "メール",
"emailDesc": "Used for checkout and license claims", "emailDesc": "決済とライセンス受け取りに使う連絡先メール",
"plan": "Current plan", "plan": "現在のプラン",
"planFree": "Free", "planFree": "無料",
"planYearly": "Pro 年額", "planYearly": "Pro 年額",
"planPerpetual": "Pro 永久", "planPerpetual": "Pro 永久",
"planTrial": "Pro 体験", "planTrial": "Pro 体験",
"expires": "Expires {{date}}", "expires": "{{date}} まで有効",
"lifetime": "Lifetime", "lifetime": "無期限",
"devices": "Seats: up to {{count}} devices", "devices": "デバイス席:最大 {{count}} 台",
"deviceId": "This device ID", "deviceId": "このデバイスの ID",
"saveEmail": "Save email", "saveEmail": "メールを保存",
"activate": "Activate license", "activate": "ライセンスを有効化",
"pasteLicense": "Paste LIC- license key", "pasteLicense": "LIC- ライセンスを貼り付け",
"clear": "Clear local license", "clear": "この端末のライセンスを消去",
"deactivate": "Deactivate this device", "deactivate": "この端末の有効化を解除",
"upgrade": "Upgrade", "upgrade": "Pro にアップグレード",
"saved": "Saved", "saved": "保存しました",
"activateOk": "Activated", "activateOk": "有効化しました",
"deviceList": "紐付けデバイス", "deviceList": "紐付けデバイス",
"loadingDevices": "デバイスを読み込み中…", "loadingDevices": "デバイスを読み込み中…",
"noDevices": "紐付けデバイスはありません", "noDevices": "紐付けデバイスはありません",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "未接続", "notConnected": "未接続",
"connecting": "接続中...", "connecting": "接続中...",
"resumeHint": "再接続後は {{path}} に戻ります", "resumeHint": "再接続後は {{path}} に戻ります",
"reconnectingResume": "{{path}} を復元して再接続中…" "reconnectingResume": "{{path}} を復元して再接続中…",
"autoReconnecting": "自動再接続中({{attempt}}/{{max}})…"
}, },
"errors": { "errors": {
"notConnected": "SFTP に接続されていません", "notConnected": "SFTP に接続されていません",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "シェルを再開", "reopenShell": "シェルを再開",
"reopening": "開いています…", "reopening": "開いています…",
"disconnected": "切断されました", "disconnected": "切断されました",
"disconnectedHint": "SSH セッション終了またはネットワーク切断。下のボタンで再接続して作業を続けられます。", "disconnectedHint": "ネットワーク切断、またはサーバーが SSH を閉じました。タブを開いたままなら自動再接続します。下のボタンですぐに再接続もできます。",
"disconnectedKeepalive": "キープアライブ失敗で切断",
"disconnectedKeepaliveHint": "SSH キープアライブに応答がありません。NAT やアイドル切断が典型です。シェルへキー入力は送りません。タブを開いたままなら自動再接続します。",
"reconnect": "再接続", "reconnect": "再接続",
"suspended": "接続が一時停止", "suspended": "接続が一時停止",
"suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました", "suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました",
+4 -4
View File
@@ -16,10 +16,10 @@
"stop": "중지", "stop": "중지",
"send": "전송", "send": "전송",
"placeholder": "작업 또는 후속 질문… (Enter로 전송, Shift+Enter로 줄 바꿈)", "placeholder": "작업 또는 후속 질문… (Enter로 전송, Shift+Enter로 줄 바꿈)",
"hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다", "hintFollowUp": "후속 질문에는 이 스레드 요약이 포함됩니다(너무 길면 잘림)",
"hintSelection": "선택한 {{lines}}줄을 포함합니다", "hintSelection": "선택한 {{lines}}줄을 포함합니다",
"hintSteps": "최대 {{max}}단계 · 저장되지 않음", "hintSteps": "최대 {{max}}단계",
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함", "footerNote": "고위험은 먼저 확인 · 파괴적 명령은 거부 · 대화는 저장되지 않음",
"summarize": "출력 요약", "summarize": "출력 요약",
"summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다", "summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다",
"summarizeRequest": "현재 터미널 출력 요약" "summarizeRequest": "현재 터미널 출력 요약"
@@ -77,7 +77,7 @@
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다", "invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}", "repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}", "criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
"execFailed": "ssh-exec 실패: {{error}}", "execFailed": "원격 실행 실패: {{error}}",
"execUnknown": "알 수 없는 오류", "execUnknown": "알 수 없는 오류",
"execNoOutput": "명령이 출력 없이 완료됨", "execNoOutput": "명령이 출력 없이 완료됨",
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요", "maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
+4 -1
View File
@@ -33,8 +33,10 @@
"error": { "error": {
"title": "문제가 발생했습니다", "title": "문제가 발생했습니다",
"body": "앱에서 예기치 않은 문제가 발생했습니다", "body": "앱에서 예기치 않은 문제가 발생했습니다",
"bodyPane": "이 탭에서 문제가 발생했습니다. 다른 탭은 계속 사용할 수 있습니다.",
"recoverable": "이 오류는 복구 가능합니다. 페이지를 새로고침해 보세요.", "recoverable": "이 오류는 복구 가능합니다. 페이지를 새로고침해 보세요.",
"refreshPage": "페이지 새로고침", "refreshPage": "페이지 새로고침",
"retryPane": "이 탭 다시 시도",
"restartApp": "앱 재시작", "restartApp": "앱 재시작",
"supportHint": "문제가 지속되면 로그를 확인하거나 지원팀에 문의하세요" "supportHint": "문제가 지속되면 로그를 확인하거나 지원팀에 문의하세요"
}, },
@@ -108,7 +110,8 @@
"closeLeft": "왼쪽 탭 닫기", "closeLeft": "왼쪽 탭 닫기",
"closeAll": "모두 닫기", "closeAll": "모두 닫기",
"clone": "탭 복제", "clone": "탭 복제",
"openSftp": "SFTP 열기" "openSftp": "SFTP 열기",
"reconnect": "다시 연결"
}, },
"renameAria": "탭 {{title}} 이름 바꾸기", "renameAria": "탭 {{title}} 이름 바꾸기",
"renameHint": "더블 클릭해 이름 변경" "renameHint": "더블 클릭해 이름 변경"
+5 -1
View File
@@ -35,13 +35,17 @@
"fullscreenAria": "전체 화면으로 전환", "fullscreenAria": "전체 화면으로 전환",
"exitFullscreen": "전체 화면 종료", "exitFullscreen": "전체 화면 종료",
"exitFullscreenAria": "전체 화면 종료", "exitFullscreenAria": "전체 화면 종료",
"exitFullscreenHint": "Esc / F11로 종료" "exitFullscreenHint": "Esc / F11로 종료",
"reconnect": "재연결",
"reconnectAria": "원격 데스크톱 다시 연결"
}, },
"status": { "status": {
"connecting": "연결 중…", "connecting": "연결 중…",
"connected": "연결됨", "connected": "연결됨",
"disconnected": "연결 끊김", "disconnected": "연결 끊김",
"connectFailed": "연결 실패", "connectFailed": "연결 실패",
"reconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…",
"reconnectLimit": "자동 재연결 한도에 도달했습니다 ({{max}})",
"clipboardSynced": "원격 클립보드 동기화됨", "clipboardSynced": "원격 클립보드 동기화됨",
"savedToDownloads": "로컬 다운로드에 저장됨: {{name}}", "savedToDownloads": "로컬 다운로드에 저장됨: {{name}}",
"pastedToRemote": "원격에 붙여넣음", "pastedToRemote": "원격에 붙여넣음",
+11 -2
View File
@@ -41,7 +41,10 @@
"refused": "연결이 거부되었습니다", "refused": "연결이 거부되었습니다",
"disconnected": "연결 끊김", "disconnected": "연결 끊김",
"keepaliveFailed": "SSH keep-alive가 연속 실패하여 연결이 닫혔습니다", "keepaliveFailed": "SSH keep-alive가 연속 실패하여 연결이 닫혔습니다",
"idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)" "idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)",
"unexpectedDropped": "연결이 끊겼습니다(네트워크 중단 또는 서버 종료)",
"shellExited": "원격 Shell이 종료되었습니다(exit, Ctrl+D 또는 서버 정책)",
"shellReadFailed": "원격 Shell 읽기에 실패했습니다"
}, },
"auth": { "auth": {
"failed": "인증 실패. 사용자 이름과 비밀번호를 확인하세요.", "failed": "인증 실패. 사용자 이름과 비밀번호를 확인하세요.",
@@ -160,7 +163,7 @@
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다", "invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}", "repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}", "criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
"execFailed": "ssh-exec 실패: {{error}}", "execFailed": "원격 실행 실패: {{error}}",
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요", "maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
"sshRequired": "연결된 원격 SSH 세션이 필요합니다" "sshRequired": "연결된 원격 SSH 세션이 필요합니다"
}, },
@@ -168,6 +171,12 @@
"readFailed": "읽기 실패: {{name}}", "readFailed": "읽기 실패: {{name}}",
"writeFailed": "쓰기 실패: {{name}}" "writeFailed": "쓰기 실패: {{name}}"
}, },
"native": {
"taskJoin": "백그라운드 작업에 실패했습니다. 다시 시도하세요.",
"invalidBase64": "파일 데이터가 올바른 Base64가 아닙니다.",
"knownHostsPath": "~/.ssh/known_hosts를 찾을 수 없습니다.",
"invalidHostKeyDecision": "호스트 키 결정이 올바르지 않습니다."
},
"unknown": { "unknown": {
"error": "알 수 없는 오류가 발생했습니다" "error": "알 수 없는 오류가 발생했습니다"
} }
+5 -2
View File
@@ -132,7 +132,8 @@
"collapse": "접기", "collapse": "접기",
"protocol": "프로토콜", "protocol": "프로토콜",
"displayName": "표시 이름 (선택 사항)", "displayName": "표시 이름 (선택 사항)",
"displayNamePlaceholder": "기본값은 user@host 또는 vnc:host", "displayNamePlaceholder": "기본값은 user@host",
"displayNamePlaceholderVnc": "기본값은 호스트 이름",
"host": "호스트", "host": "호스트",
"hostPlaceholder": "192.168.1.10", "hostPlaceholder": "192.168.1.10",
"hostHint": "IP 또는 호스트명; host:port도 허용됨", "hostHint": "IP 또는 호스트명; host:port도 허용됨",
@@ -208,7 +209,8 @@
}, },
"importExport": { "importExport": {
"exportTitle": "세션 내보내기", "exportTitle": "세션 내보내기",
"exportDefaultPath": "opsxshell-sessions.bak", "exportDefaultPath": "opsxshell-hosts.oxs",
"exportFilterOxs": "OpsXShell 호스트 백업",
"exportFilterBak": "OpsXShell 백업", "exportFilterBak": "OpsXShell 백업",
"exportFilterJson": "JSON", "exportFilterJson": "JSON",
"exportSuccess": "{{count}}개 세션을 다음으로 내보냄:\n{{path}}", "exportSuccess": "{{count}}개 세션을 다음으로 내보냄:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "비밀번호가 일치하지 않습니다", "exportPasswordMismatch": "비밀번호가 일치하지 않습니다",
"exportPasswordRequired": "내보내기에는 백업 비밀번호가 필요합니다", "exportPasswordRequired": "내보내기에는 백업 비밀번호가 필요합니다",
"importTitle": "세션 가져오기", "importTitle": "세션 가져오기",
"importFilterOxs": "OpsXShell 호스트 백업",
"importFilterBak": "OpsXShell 백업", "importFilterBak": "OpsXShell 백업",
"importFilterJson": "JSON", "importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config", "importFilterSshConfig": "SSH Config",
+42 -30
View File
@@ -13,7 +13,7 @@
"ai": "AI 모델", "ai": "AI 모델",
"aiDesc": "AI 제공자", "aiDesc": "AI 제공자",
"about": "정보", "about": "정보",
"aboutDesc": "버전 및 데이터", "aboutDesc": "버전, 연락처, 진단",
"account": "계정", "account": "계정",
"accountDesc": "이메일, 라이선스, 기기" "accountDesc": "이메일, 라이선스, 기기"
}, },
@@ -89,9 +89,9 @@
"ssh": { "ssh": {
"connection": "연결", "connection": "연결",
"keepAlive": "연결 유지", "keepAlive": "연결 유지",
"keepAliveDesc": "유휴 시 SSH keep-alive를 보냅니다. 연속 실패 후에만 연결을 끊습니다", "keepAliveDesc": "유휴 시 SSH 프로토콜 keep-alive를 보냅니다(셸에 키를 넣지 않음). NAT 끊김만 막으며 원격 TMOUT을 우회하지 않습니다",
"autoReconnect": "자동 재연결", "autoReconnect": "자동 재연결",
"autoReconnectDesc": "예기치 않은 연결 끊김 후 자동으로 재시도", "autoReconnectDesc": "탭이 열린 채로 예기치 않게 끊기면 자동 재연결(새 셸). 탭 닫기, 유휴 일시 중지, 원격 TMOUT은 강제로 유지하지 않습니다",
"timeout": "연결 시간 초과", "timeout": "연결 시간 초과",
"timeoutDesc": "SSH 설정 시 최대 대기 시간(초)", "timeoutDesc": "SSH 설정 시 최대 대기 시간(초)",
"maxReconnect": "재연결 시도", "maxReconnect": "재연결 시도",
@@ -104,7 +104,8 @@
"suspend": "백그라운드 일시 중지", "suspend": "백그라운드 일시 중지",
"suspendDesc": "유휴 백그라운드 탭을 몇 분 후 일시 중지할지(활성 Shell/SFTP는 유지)", "suspendDesc": "유휴 백그라운드 탭을 몇 분 후 일시 중지할지(활성 Shell/SFTP는 유지)",
"keepAliveInterval": "유지 간격(초)", "keepAliveInterval": "유지 간격(초)",
"keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장" "keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장",
"sessionPolicyHint": "Keep-alive는 TCP 링크를 지킬 뿐 원격 세션 정책을 바꾸지 않습니다. Bash TMOUT, sshd ClientAlive, Windows 유휴 GPO는 그대로입니다. 긴 작업은 서버에서 tmux/screen을 쓰세요."
}, },
"security": { "security": {
"credentials": "자격 증명 보호", "credentials": "자격 증명 보호",
@@ -122,16 +123,25 @@
}, },
"about": { "about": {
"tagline": "간단하고 실용적인 네이티브 운영 터미널", "tagline": "간단하고 실용적인 네이티브 운영 터미널",
"appVersion": "앱 v{{version}}", "appVersion": "v{{version}}",
"copyVersion": "버전 정보 복사", "copyVersion": "버전·시스템·경로를 복사해 GitHub에 붙여넣기",
"runtime": "런타임", "copyDiagnostics": "진단 정보 복사",
"system": "시스템", "copyPath": "경로 복사",
"localData": "로컬 데이터", "resources": "리소스",
"wechat": "WeChat",
"copyWechat": "WeChat ID 복사",
"github": "토론",
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
"releases": "릴리스",
"releasesDesc": "설치 패키지와 변경 사항",
"licenseLink": "라이선스",
"licenseLinkDesc": "MIT License",
"diagnostics": "진단",
"sessionsSettings": "세션 및 설정", "sessionsSettings": "세션 및 설정",
"masterKeyPath": "마스터 키", "license": "MIT License · Rust · XTerm.js",
"privacyNote": "비밀번호·키 암호는 Rust에서 AES-256-GCM으로 암호화합니다. 명령 기록·감사 로그는 저장하지 않습니다.", "logPath": "앱 로그",
"license": "MIT 라이선스 · Rust · XTerm.js", "reportBug": "버그 신고",
"logPath": "앱 로그" "reportBugDesc": "GitHub 이슈를 열고 현재 버전과 시스템을 채웁니다"
}, },
"ai": { "ai": {
"presets": "제공자 프리셋", "presets": "제공자 프리셋",
@@ -157,7 +167,9 @@
"otherModels": "다른 모델…", "otherModels": "다른 모델…",
"customModel": "사용자 지정 모델", "customModel": "사용자 지정 모델",
"backToList": "목록으로", "backToList": "목록으로",
"apiKey": "API 키",
"apiKeyOptional": "API 키 (필요하지 않음)", "apiKeyOptional": "API 키 (필요하지 않음)",
"endpoint": "엔드포인트",
"advanced": "고급", "advanced": "고급",
"allowHttp": "HTTP 허용 (localhost/LAN)", "allowHttp": "HTTP 허용 (localhost/LAN)",
"setActive": "활성으로 설정", "setActive": "활성으로 설정",
@@ -175,26 +187,26 @@
} }
}, },
"account": { "account": {
"title": "Account", "title": "계정",
"email": "Email", "email": "이메일",
"emailDesc": "Used for checkout and license claims", "emailDesc": "결제 및 라이선스 수령에 쓰는 연락 이메일",
"plan": "Current plan", "plan": "현재 요금제",
"planFree": "Free", "planFree": "무료",
"planYearly": "프로 연간", "planYearly": "프로 연간",
"planPerpetual": "프로 영구", "planPerpetual": "프로 영구",
"planTrial": "프로 체험", "planTrial": "프로 체험",
"expires": "Expires {{date}}", "expires": "{{date}}까지",
"lifetime": "Lifetime", "lifetime": "평생",
"devices": "Seats: up to {{count}} devices", "devices": "기기 좌석: 최대 {{count}}대",
"deviceId": "This device ID", "deviceId": "이 기기 ID",
"saveEmail": "Save email", "saveEmail": "이메일 저장",
"activate": "Activate license", "activate": "라이선스 활성화",
"pasteLicense": "Paste LIC- license key", "pasteLicense": "LIC- 라이선스 붙여넣기",
"clear": "Clear local license", "clear": "이 기기의 라이선스 지우기",
"deactivate": "Deactivate this device", "deactivate": "이 기기 활성화 해제",
"upgrade": "Upgrade", "upgrade": "프로로 업그레이드",
"saved": "Saved", "saved": "저장됨",
"activateOk": "Activated", "activateOk": "활성화됨",
"deviceList": "바인딩된 기기", "deviceList": "바인딩된 기기",
"loadingDevices": "기기 목록 불러오는 중…", "loadingDevices": "기기 목록 불러오는 중…",
"noDevices": "바인딩된 기기 없음", "noDevices": "바인딩된 기기 없음",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "연결되지 않음", "notConnected": "연결되지 않음",
"connecting": "연결 중...", "connecting": "연결 중...",
"resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다", "resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다",
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…" "reconnectingResume": "{{path}} 복원하며 다시 연결 중…",
"autoReconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…"
}, },
"errors": { "errors": {
"notConnected": "SFTP가 연결되지 않았습니다", "notConnected": "SFTP가 연결되지 않았습니다",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "셸 다시 열기", "reopenShell": "셸 다시 열기",
"reopening": "여는 중…", "reopening": "여는 중…",
"disconnected": "연결 끊김", "disconnected": "연결 끊김",
"disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 누르면 다시 연결해 작업을 이어갑니다.", "disconnectedHint": "네트워크가 끊겼거나 서버가 SSH를 닫았습니다. 탭이 열려 있으면 자동 재연결되며, 아래 버튼으로 바로 재연결할 수도 있습니다.",
"disconnectedKeepalive": "Keep-alive 실패로 연결 끊김",
"disconnectedKeepaliveHint": "SSH keep-alive에 응답이 없습니다. 대개 NAT나 유휴 네트워크 끊김입니다. 셸에 키 입력을 보내지는 않습니다. 탭이 열려 있으면 자동 재연결됩니다.",
"reconnect": "재연결", "reconnect": "재연결",
"suspended": "연결 일시 중단됨", "suspended": "연결 일시 중단됨",
"suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다", "suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다",
+4 -4
View File
@@ -16,10 +16,10 @@
"stop": "Остановить", "stop": "Остановить",
"send": "Отправить", "send": "Отправить",
"placeholder": "Задача или уточнение… (Enter — отправить, Shift+Enter — новая строка)", "placeholder": "Задача или уточнение… (Enter — отправить, Shift+Enter — новая строка)",
"hintFollowUp": "Уточнения включают контекст этой ветки", "hintFollowUp": "Уточнения включают сжатое резюме этой ветки",
"hintSelection": "Будет включено {{lines}} выбранных строк", "hintSelection": "Будет включено {{lines}} выбранных строк",
"hintSteps": "До {{max}} шагов · ничего не сохраняется", "hintSteps": "До {{max}} шагов",
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется", "footerNote": "Высокий риск — сначала подтверждение · разрушительные команды отклоняются · чат не сохраняется",
"summarize": "Сводка", "summarize": "Сводка",
"summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются", "summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются",
"summarizeRequest": "Суммируйте текущий вывод терминала" "summarizeRequest": "Суммируйте текущий вывод терминала"
@@ -77,7 +77,7 @@
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено", "invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}", "repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}", "criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
"execFailed": "Сбой ssh-exec: {{error}}", "execFailed": "Сбой удалённого выполнения: {{error}}",
"execUnknown": "Неизвестная ошибка", "execUnknown": "Неизвестная ошибка",
"execNoOutput": "Команда завершилась без вывода", "execNoOutput": "Команда завершилась без вывода",
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите", "maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
+7 -4
View File
@@ -33,8 +33,10 @@
"error": { "error": {
"title": "Что-то пошло не так", "title": "Что-то пошло не так",
"body": "В приложении возникла непредвиденная проблема", "body": "В приложении возникла непредвиденная проблема",
"bodyPane": "В этой вкладке произошла ошибка. Остальные вкладки работают.",
"recoverable": "Эта ошибка обратима. Попробуйте обновить страницу.", "recoverable": "Эта ошибка обратима. Попробуйте обновить страницу.",
"refreshPage": "Обновить страницу", "refreshPage": "Обновить страницу",
"retryPane": "Повторить вкладку",
"restartApp": "Перезапустить приложение", "restartApp": "Перезапустить приложение",
"supportHint": "Если проблема сохраняется, проверьте журналы или обратитесь в поддержку" "supportHint": "Если проблема сохраняется, проверьте журналы или обратитесь в поддержку"
}, },
@@ -104,11 +106,12 @@
}, },
"menu": { "menu": {
"closeOthers": "Закрыть остальные", "closeOthers": "Закрыть остальные",
"closeRight": "Закрыть вкладки справа", "closeRight": "Закрыть справа",
"closeLeft": "Закрыть вкладки слева", "closeLeft": "Закрыть слева",
"closeAll": "Закрыть все", "closeAll": "Закрыть все",
"clone": "Дублировать вкладку", "clone": "Клонировать вкладку",
"openSftp": "Открыть SFTP" "openSftp": "Открыть SFTP",
"reconnect": "Переподключиться"
}, },
"renameAria": "Переименовать вкладку {{title}}", "renameAria": "Переименовать вкладку {{title}}",
"renameHint": "Двойной клик — переименовать" "renameHint": "Двойной клик — переименовать"
+5 -1
View File
@@ -35,13 +35,17 @@
"fullscreenAria": "Развернуть на весь экран", "fullscreenAria": "Развернуть на весь экран",
"exitFullscreen": "Выйти из полного экрана", "exitFullscreen": "Выйти из полного экрана",
"exitFullscreenAria": "Выйти из полного экрана", "exitFullscreenAria": "Выйти из полного экрана",
"exitFullscreenHint": "Esc / F11 — выход" "exitFullscreenHint": "Esc / F11 — выход",
"reconnect": "Переподключиться",
"reconnectAria": "Переподключить удалённый рабочий стол"
}, },
"status": { "status": {
"connecting": "Подключение…", "connecting": "Подключение…",
"connected": "Подключено", "connected": "Подключено",
"disconnected": "Отключено", "disconnected": "Отключено",
"connectFailed": "Сбой подключения", "connectFailed": "Сбой подключения",
"reconnecting": "Автопереподключение ({{attempt}}/{{max}})…",
"reconnectLimit": "Достигнут лимит автопереподключения ({{max}})",
"clipboardSynced": "Удалённый буфер обмена синхронизирован", "clipboardSynced": "Удалённый буфер обмена синхронизирован",
"savedToDownloads": "Сохранено в локальную папку Downloads: {{name}}", "savedToDownloads": "Сохранено в локальную папку Downloads: {{name}}",
"pastedToRemote": "Вставлено на удалённый сервер", "pastedToRemote": "Вставлено на удалённый сервер",
+11 -2
View File
@@ -41,7 +41,10 @@
"refused": "В подключении отказано", "refused": "В подключении отказано",
"disconnected": "Отключено", "disconnected": "Отключено",
"keepaliveFailed": "SSH keep-alive несколько раз не удался; соединение закрыто", "keepaliveFailed": "SSH keep-alive несколько раз не удался; соединение закрыто",
"idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)" "idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)",
"unexpectedDropped": "Соединение разорвано (сеть или закрытие на сервере)",
"shellExited": "Удалённая оболочка завершилась (exit, Ctrl+D или политика сервера)",
"shellReadFailed": "Не удалось прочитать удалённую оболочку"
}, },
"auth": { "auth": {
"failed": "Сбой аутентификации. Проверьте имя пользователя и пароль.", "failed": "Сбой аутентификации. Проверьте имя пользователя и пароль.",
@@ -160,7 +163,7 @@
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено", "invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}", "repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}", "criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
"execFailed": "Сбой ssh-exec: {{error}}", "execFailed": "Сбой удалённого выполнения: {{error}}",
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите", "maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
"sshRequired": "Требуется подключённый удалённый SSH-сеанс" "sshRequired": "Требуется подключённый удалённый SSH-сеанс"
}, },
@@ -168,6 +171,12 @@
"readFailed": "Сбой чтения: {{name}}", "readFailed": "Сбой чтения: {{name}}",
"writeFailed": "Сбой записи: {{name}}" "writeFailed": "Сбой записи: {{name}}"
}, },
"native": {
"taskJoin": "Фоновая задача не выполнена. Повторите попытку.",
"invalidBase64": "Данные файла не являются корректным Base64.",
"knownHostsPath": "Не удалось найти ~/.ssh/known_hosts.",
"invalidHostKeyDecision": "Некорректное решение по ключу хоста."
},
"unknown": { "unknown": {
"error": "Произошла неизвестная ошибка" "error": "Произошла неизвестная ошибка"
} }
+5 -2
View File
@@ -132,7 +132,8 @@
"collapse": "Свернуть", "collapse": "Свернуть",
"protocol": "Протокол", "protocol": "Протокол",
"displayName": "Отображаемое имя (необязательно)", "displayName": "Отображаемое имя (необязательно)",
"displayNamePlaceholder": "По умолчанию user@host или vnc:host", "displayNamePlaceholder": "По умолчанию user@host",
"displayNamePlaceholderVnc": "По умолчанию имя хоста",
"host": "Хост", "host": "Хост",
"hostPlaceholder": "192.168.1.10", "hostPlaceholder": "192.168.1.10",
"hostHint": "IP или имя хоста; также принимается host:port", "hostHint": "IP или имя хоста; также принимается host:port",
@@ -208,7 +209,8 @@
}, },
"importExport": { "importExport": {
"exportTitle": "Экспорт сеансов", "exportTitle": "Экспорт сеансов",
"exportDefaultPath": "opsxshell-sessions.bak", "exportDefaultPath": "opsxshell-hosts.oxs",
"exportFilterOxs": "Резервная копия хостов OpsXShell",
"exportFilterBak": "Резервная копия OpsXShell", "exportFilterBak": "Резервная копия OpsXShell",
"exportFilterJson": "JSON", "exportFilterJson": "JSON",
"exportSuccess": "Экспортировано сеансов: {{count}} в:\n{{path}}", "exportSuccess": "Экспортировано сеансов: {{count}} в:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "Пароли не совпадают", "exportPasswordMismatch": "Пароли не совпадают",
"exportPasswordRequired": "Для экспорта требуется пароль резервной копии", "exportPasswordRequired": "Для экспорта требуется пароль резервной копии",
"importTitle": "Импорт сеансов", "importTitle": "Импорт сеансов",
"importFilterOxs": "Резервная копия хостов OpsXShell",
"importFilterBak": "Резервная копия OpsXShell", "importFilterBak": "Резервная копия OpsXShell",
"importFilterJson": "JSON", "importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config", "importFilterSshConfig": "SSH Config",
+45 -33
View File
@@ -13,7 +13,7 @@
"ai": "AI-модели", "ai": "AI-модели",
"aiDesc": "AI-провайдеры", "aiDesc": "AI-провайдеры",
"about": "О приложении", "about": "О приложении",
"aboutDesc": "Версия и данные", "aboutDesc": "Версия, контакты и диагностика",
"account": "Аккаунт", "account": "Аккаунт",
"accountDesc": "Email, лицензия и устройство" "accountDesc": "Email, лицензия и устройство"
}, },
@@ -88,10 +88,10 @@
}, },
"ssh": { "ssh": {
"connection": "Подключение", "connection": "Подключение",
"keepAlive": "Keep-alive", "keepAlive": "Поддержание связи",
"keepAliveDesc": "Отправлять SSH keep-alive в простое; отключать только после нескольких сбоев", "keepAliveDesc": "В простое слать протокол SSH keep-alive (не вводить в оболочку). Защищает от NAT, не обходит удалённый TMOUT",
"autoReconnect": "Авто-переподключение", "autoReconnect": "Авто-переподключение",
"autoReconnectDesc": "Автоматически повторять после неожиданного разрыва", "autoReconnectDesc": "Пока вкладка открыта, после неожиданного разрыва переподключаться (новая оболочка). Закрытие вкладки, простой и удалённый TMOUT не удерживаются силой",
"timeout": "Тайм-аут подключения", "timeout": "Тайм-аут подключения",
"timeoutDesc": "Максимальное время ожидания при установке SSH (секунды)", "timeoutDesc": "Максимальное время ожидания при установке SSH (секунды)",
"maxReconnect": "Попытки переподключения", "maxReconnect": "Попытки переподключения",
@@ -104,7 +104,8 @@
"suspend": "Фоновая приостановка", "suspend": "Фоновая приостановка",
"suspendDesc": "Через сколько минут приостанавливать простаивающие фоновые вкладки (активные Shell/SFTP остаются)", "suspendDesc": "Через сколько минут приостанавливать простаивающие фоновые вкладки (активные Shell/SFTP остаются)",
"keepAliveInterval": "Интервал keep-alive (сек)", "keepAliveInterval": "Интервал keep-alive (сек)",
"keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с" "keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с",
"sessionPolicyHint": "Keep-alive держит TCP-канал, а не политику удалённого сеанса. Bash TMOUT, sshd ClientAlive и простой GPO Windows по-прежнему действуют. Долгие задачи — через tmux/screen на сервере."
}, },
"security": { "security": {
"credentials": "Защита учётных данных", "credentials": "Защита учётных данных",
@@ -122,16 +123,25 @@
}, },
"about": { "about": {
"tagline": "Простой и удобный нативный терминал для эксплуатации", "tagline": "Простой и удобный нативный терминал для эксплуатации",
"appVersion": "Приложение v{{version}}", "appVersion": "v{{version}}",
"copyVersion": "Копировать сведения о версии", "copyVersion": "Скопировать версию, систему и пути для GitHub",
"runtime": "Среда выполнения", "copyDiagnostics": "Копировать диагностику",
"system": "Система", "copyPath": "Копировать путь",
"localData": "Локальные данные", "resources": "Ресурсы",
"wechat": "WeChat",
"copyWechat": "Скопировать WeChat ID",
"github": "Обсуждения",
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
"releases": "Релизы",
"releasesDesc": "Установщики и заметки к выпуску",
"licenseLink": "Лицензия",
"licenseLinkDesc": "MIT License",
"diagnostics": "Диагностика",
"sessionsSettings": "Сеансы и настройки", "sessionsSettings": "Сеансы и настройки",
"masterKeyPath": "Мастер-ключ", "license": "MIT License · Rust · XTerm.js",
"privacyNote": "Пароли и passphrase шифруются в Rust (AES-256-GCM). Без истории команд и без аудита-бастиона.", "logPath": "Журнал приложения",
"license": "Лицензия MIT · Rust · XTerm.js", "reportBug": "Сообщить об ошибке",
"logPath": "Журнал приложения" "reportBugDesc": "Открывает GitHub issue с текущей версией и платформой"
}, },
"ai": { "ai": {
"presets": "Пресеты провайдеров", "presets": "Пресеты провайдеров",
@@ -151,13 +161,15 @@
"saved": "Сохранено", "saved": "Сохранено",
"retest": "Пожалуйста, перепроверьте", "retest": "Пожалуйста, перепроверьте",
"testFailed": "Сбой проверки подключения", "testFailed": "Сбой проверки подключения",
"available": "OK · {{latency}} мс", "available": "Готово · {{latency}} мс",
"savedKeepBlank": "Сохранено — оставьте пустым, чтобы сохранить", "savedKeepBlank": "Сохранено — оставьте пустым, чтобы сохранить",
"notRequired": "Не требуется", "notRequired": "Не требуется",
"otherModels": "Другие модели…", "otherModels": "Другие модели…",
"customModel": "Своя модель", "customModel": "Своя модель",
"backToList": "Назад к списку", "backToList": "Назад к списку",
"apiKeyOptional": "API Key (не требуется)", "apiKey": "API-ключ",
"apiKeyOptional": "API-ключ (не требуется)",
"endpoint": "Адрес API",
"advanced": "Дополнительно", "advanced": "Дополнительно",
"allowHttp": "Разрешить HTTP (localhost/LAN)", "allowHttp": "Разрешить HTTP (localhost/LAN)",
"setActive": "Сделать активным", "setActive": "Сделать активным",
@@ -175,26 +187,26 @@
} }
}, },
"account": { "account": {
"title": "Account", "title": "Аккаунт",
"email": "Email", "email": "Почта",
"emailDesc": "Used for checkout and license claims", "emailDesc": "Контактная почта для оплаты и получения лицензии",
"plan": "Current plan", "plan": "Текущий план",
"planFree": "Free", "planFree": "Бесплатный",
"planYearly": "Pro на год", "planYearly": "Pro на год",
"planPerpetual": "Pro бессрочно", "planPerpetual": "Pro бессрочно",
"planTrial": "Pro пробный", "planTrial": "Pro пробный",
"expires": "Expires {{date}}", "expires": "Действует до {{date}}",
"lifetime": "Lifetime", "lifetime": "Бессрочно",
"devices": "Seats: up to {{count}} devices", "devices": "Места устройств: до {{count}}",
"deviceId": "This device ID", "deviceId": "ID этого устройства",
"saveEmail": "Save email", "saveEmail": "Сохранить почту",
"activate": "Activate license", "activate": "Активировать лицензию",
"pasteLicense": "Paste LIC- license key", "pasteLicense": "Вставьте лицензию LIC-",
"clear": "Clear local license", "clear": "Удалить локальную лицензию",
"deactivate": "Deactivate this device", "deactivate": "Отключить активацию на этом устройстве",
"upgrade": "Upgrade", "upgrade": "Перейти на Pro",
"saved": "Saved", "saved": "Сохранено",
"activateOk": "Activated", "activateOk": "Активировано",
"deviceList": "Привязанные устройства", "deviceList": "Привязанные устройства",
"loadingDevices": "Загрузка устройств…", "loadingDevices": "Загрузка устройств…",
"noDevices": "Нет привязанных устройств", "noDevices": "Нет привязанных устройств",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "Нет подключения", "notConnected": "Нет подключения",
"connecting": "Подключение...", "connecting": "Подключение...",
"resumeHint": "После переподключения вернёмся в {{path}}", "resumeHint": "После переподключения вернёмся в {{path}}",
"reconnectingResume": "Переподключение и восстановление {{path}}…" "reconnectingResume": "Переподключение и восстановление {{path}}…",
"autoReconnecting": "Автопереподключение ({{attempt}}/{{max}})…"
}, },
"errors": { "errors": {
"notConnected": "SFTP не подключён", "notConnected": "SFTP не подключён",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "Открыть оболочку снова", "reopenShell": "Открыть оболочку снова",
"reopening": "Открытие…", "reopening": "Открытие…",
"disconnected": "Отключено", "disconnected": "Отключено",
"disconnectedHint": "SSH-сеанс завершился или сеть упала. Один клик ниже — и можно продолжать работу.", "disconnectedHint": "Сеть оборвалась или сервер закрыл SSH. Пока вкладка открыта, будет автопереподключение; можно нажать кнопку ниже.",
"disconnectedKeepalive": "Сбой keep-alive, отключено",
"disconnectedKeepaliveHint": "SSH keep-alive не ответил — обычно NAT или простой сети. В оболочку клавиши не отправляются. Пока вкладка открыта, будет автопереподключение.",
"reconnect": "Переподключиться", "reconnect": "Переподключиться",
"suspended": "Подключение приостановлено", "suspended": "Подключение приостановлено",
"suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов", "suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов",
+4 -4
View File
@@ -16,10 +16,10 @@
"stop": "Dừng", "stop": "Dừng",
"send": "Gửi", "send": "Gửi",
"placeholder": "Tác vụ hoặc theo dõi… (Enter để gửi, Shift+Enter để xuống dòng)", "placeholder": "Tác vụ hoặc theo dõi… (Enter để gửi, Shift+Enter để xuống dòng)",
"hintFollowUp": "Các theo dõi bao gồm ngữ cảnh của luồng này", "hintFollowUp": "Câu hỏi tiếp theo kèm tóm tắt luồng này, nội dung quá dài sẽ bị cắt",
"hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn", "hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn",
"hintSteps": "Tối đa {{max}} bước · không lưu gì", "hintSteps": "Tối đa {{max}} bước",
"footerNote": "Rủi ro cao cần xác nhận · rủi ro nghiêm trọng bị chặn · phiên không được lưu", "footerNote": "Rủi ro cao cần xác nhận trước · lệnh phá hủy bị từ chối · cuộc trò chuyện không được lưu",
"summarize": "Tóm tắt", "summarize": "Tóm tắt",
"summarizeTitle": "AI tóm tắt đầu ra terminal đang hiển thị (ưu tiên vùng chọn), không chạy lệnh nào", "summarizeTitle": "AI tóm tắt đầu ra terminal đang hiển thị (ưu tiên vùng chọn), không chạy lệnh nào",
"summarizeRequest": "Tóm tắt đầu ra terminal hiện tại" "summarizeRequest": "Tóm tắt đầu ra terminal hiện tại"
@@ -77,7 +77,7 @@
"invalidCommand": "Mô hình không trả về một lệnh một dòng duy nhất; từ chối thực thi", "invalidCommand": "Mô hình không trả về một lệnh một dòng duy nhất; từ chối thực thi",
"repeatLoop": "Phát hiện vòng lặp lệnh lặp lại; đã dừng: {{command}}", "repeatLoop": "Phát hiện vòng lặp lệnh lặp lại; đã dừng: {{command}}",
"criticalBlocked": "Lệnh gặp chặn cứng phá hủy; không có ghi đè: {{command}}", "criticalBlocked": "Lệnh gặp chặn cứng phá hủy; không có ghi đè: {{command}}",
"execFailed": "ssh-exec thất bại: {{error}}", "execFailed": "Thực thi từ xa thất bại: {{error}}",
"execUnknown": "Lỗi không xác định", "execUnknown": "Lỗi không xác định",
"execNoOutput": "Lệnh hoàn tất không có đầu ra", "execNoOutput": "Lệnh hoàn tất không có đầu ra",
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại", "maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
+6 -3
View File
@@ -33,8 +33,10 @@
"error": { "error": {
"title": "Đã xảy ra lỗi", "title": "Đã xảy ra lỗi",
"body": "Ứng dụng gặp sự cố bất ngờ", "body": "Ứng dụng gặp sự cố bất ngờ",
"bodyPane": "Thẻ này gặp sự cố. Các thẻ khác vẫn dùng được.",
"recoverable": "Lỗi này có thể khôi phục. Hãy thử làm mới trang.", "recoverable": "Lỗi này có thể khôi phục. Hãy thử làm mới trang.",
"refreshPage": "Làm mới trang", "refreshPage": "Làm mới trang",
"retryPane": "Thử lại thẻ này",
"restartApp": "Khởi động lại ứng dụng", "restartApp": "Khởi động lại ứng dụng",
"supportHint": "Nếu sự cố vẫn tiếp diễn, hãy kiểm tra nhật ký hoặc liên hệ hỗ trợ" "supportHint": "Nếu sự cố vẫn tiếp diễn, hãy kiểm tra nhật ký hoặc liên hệ hỗ trợ"
}, },
@@ -104,11 +106,12 @@
}, },
"menu": { "menu": {
"closeOthers": "Đóng các tab khác", "closeOthers": "Đóng các tab khác",
"closeRight": "Đóng các tab bên phải", "closeRight": "Đóng tab bên phải",
"closeLeft": "Đóng các tab bên trái", "closeLeft": "Đóng tab bên trái",
"closeAll": "Đóng tất cả", "closeAll": "Đóng tất cả",
"clone": "Nhân đôi tab", "clone": "Nhân đôi tab",
"openSftp": "Mở SFTP" "openSftp": "Mở SFTP",
"reconnect": "Kết nối lại"
}, },
"renameAria": "Đổi tên thẻ {{title}}", "renameAria": "Đổi tên thẻ {{title}}",
"renameHint": "Nhấn đúp để đổi tên" "renameHint": "Nhấn đúp để đổi tên"
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more