diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..db81303 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9a9676a..73cfdfb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,15 @@ __pycache__/ src-tauri/target/ src-tauri/tauri.signing.conf.json +# 本地下载的 Apple 证书 / 私钥(说明文档可提交) +download/*.cer +download/*.crt +download/*.key +download/*.p12 +download/*.pem +download/*.csr +download/*.certSigningRequest + # Database *.db *.sqlite diff --git a/AGENTS.md b/AGENTS.md index 5c22ee7..fa9306d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,13 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus 4. 界面:Zustand 持有资源 ID 与 UI 状态;可见文案同步七语种。 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 后端实现的能力不能在界面中显示为可用。 @@ -41,6 +48,9 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus - 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。 - 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。 - 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`。 - 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。 - SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。 @@ -56,10 +66,11 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus npm run test:patrol npm run acceptance:quick 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` 才能给别人直接打开。 ## 评审边界 diff --git a/CLAUDE.md b/CLAUDE.md index 69a8559..2aa08cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - `src/`:React UI;`src-tauri/`:Rust / Tauri 原生后端 - `shared/`:前后端共享 TypeScript 契约 - `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/`:使用 / 架构 / 验收 / 发布 - 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md` diff --git a/README.md b/README.md index 938a397..6284e6b 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,56 @@ # OpsXShell -简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。 +原生运维终端:SSH、SFTP、端口转发、RDP/VNC、串口、本地终端。短路径好用,不做审计/审批/堡垒机。 -## 已支持 +需要 Node.js 18+、npm 9+、Rust 1.77+。macOS 打包还要 Xcode Command Line Tools。 -免费层: - -- 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+。 +## 本地调试(GUI) ```bash -npm install -npm run dev +./run.sh ``` -常用检查与构建: +会清掉旧的 Vite / 应用进程,缺依赖时自动 `npm install`,再开 Tauri 开发窗。DevTools:`Cmd+Option+I` 或 `F12`。日志: ```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: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 ``` -`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/architecture.md) -- [SSH Agent CLI 设计](docs/ai-agent.md) -- [测试与验收](docs/testing-acceptance.md) -- [发布指南](docs/release.md) -- [路线图](ROADMAP.md) -- [当前任务](TASKS.md) -- [测试计划](TDD_PLAN.md) +- [架构](docs/architecture.md) · [验收](docs/testing-acceptance.md) · [发布](docs/release.md) +- [路线图](ROADMAP.md) · [当前任务](TASKS.md) -## 许可证 - -MIT。完整文本见 [LICENSE](./LICENSE)。 +MIT,见 [LICENSE](./LICENSE)。 diff --git a/ROADMAP.md b/ROADMAP.md index 984f596..6e6a007 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # OpsXShell 路线图 -> 最后更新:2026-08-16 +> 最后更新:2026-09-14 > 版本基线:v2.1.1(Tauri 2 原生) > 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。 @@ -18,7 +18,7 @@ - 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存 - 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards - 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 冲突确认;可选内容校验和与失败任务恢复 3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测 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)。 7. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出) diff --git a/TASKS.md b/TASKS.md index 0a0646e..50b223a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,6 @@ # OpsXShell 当前任务 -> 最后更新:2026-08-16 +> 最后更新:2026-09-14 > 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。 ## 本周期已完成(勿回退) @@ -10,27 +10,28 @@ - [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab - [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出 - [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` ## 现在可做 按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。 -- [ ] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」 -- [ ] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录) +- [x] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」 +- [x] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录) - [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖 - [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」 - [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开 - [ ] 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(高级配置默认收起) - [ ] SFTP 校验和与失败任务恢复 - [ ] RDP/VNC 生产级证书校验 -- [ ] macOS Developer ID + 公证;Windows Authenticode(有证书后再做,见 release.md) +- [ ] Windows Authenticode(有证书后再做,见 release.md);macOS 每次新 DMG 仍须公证 staple - [ ] 自动更新(签名清单 + HTTPS + 回滚;缺一不可开 UI) - [ ] EventSink / specta / infrastructure 端口(单独轨道,见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)) - [ ] 用户可导出的脱敏崩溃诊断包 diff --git a/docs/README.md b/docs/README.md index f9c2b32..e7c60c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,16 +2,17 @@ 文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。 -| 文档 | 内容 | -| ------------------------------------------ | ----------------------------------------------------- | -| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 | -| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 | -| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) | -| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 | -| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 | -| [发布指南](./release.md) | 构建与产物;签名/公证/自动更新的做法(当前未完成) | -| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) | -| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 | -| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 | +| 文档 | 内容 | +| -------------------------------------------------- | --------------------------------------------------------- | +| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 | +| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 | +| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) | +| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 | +| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 | +| [发布指南](./release.md) | 构建、Tauri DMG、本机签名;公证 staple 与自动更新仍待完成 | +| [Apple 开发者签名](../download/Apple开发者签名.md) | 申请 Developer Program、Developer ID、导入钥匙串 | +| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) | +| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 | +| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 | 项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP.md](../ROADMAP.md) / [TASKS.md](../TASKS.md) 维护。 diff --git a/docs/ai-agent.md b/docs/ai-agent.md index 44b4ae0..86c58cf 100644 --- a/docs/ai-agent.md +++ b/docs/ai-agent.md @@ -9,6 +9,8 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,按用户选择的安全模式决定确认阈值,失败后根据输出换方法,最后给出有证据的结论。无论选择哪种模式,目标主机始终可见,critical 命令始终硬拒绝。 +设计边界声明:OpsXShell Agent 只服务桌面端当前 SSH 会话。任务开始时锁定当前 connectionId,后续执行和取消始终作用于该连接;标签切换不得让任务迁移到另一台主机。Agent 专用 SSH 结果在 Rust 原生层脱敏后才返回界面和模型上下文。OpsXShell 不复用 OpsXCLI 的本机执行器,也不承担 AIOps 的资产授权、审批和永久审计职责。 + ## 参考来源 本设计已逐篇评估同级 `aiops/docs/ai-agent/00-08` 及它们引用的工程基线: @@ -43,9 +45,9 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 -> 失败换方法 / 继续验证 / FINAL ``` -- 单次任务最多 8 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,8 步更符合注意力、时延和成本边界。 +- 单次任务最多 12 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,12 步兼顾排查+修复与注意力、时延、成本。 - 相同命令最多出现 2 次,第 3 次在执行前停止。 -- 第 4 轮检查是否已有足够证据,第 8 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。 +- 第 6 轮检查是否已有足够证据,第 12 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。 - 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。 - `analyze_output` 是本地确定性工具:统计行数、提取错误关键行、保留首尾,不为压缩输出额外调用模型。 @@ -74,7 +76,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 | OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider | | `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 | | `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 | -| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 | +| ReAct 16 轮 | 适配为 12 步 | GUI 前台执行;12 步够排查+验证,仍低于后台异步 16 轮 | | 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 | | 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 | | 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 | @@ -131,7 +133,7 @@ src-tauri/src/services/ai/ ### P1:运维效率 -1. 任务中断和“停止”,不等待全部 8 步。 +1. 任务中断和“停止”,不等待全部 12 步。 2. 只读组合命令的参数级评估,减少误判。 3. 用户显式保存的 Runbook 模板,支持变量预览和逐步确认。 4. 把已选终端文本、主机标签和系统信息组成最小上下文包,并显示实际发送范围。 diff --git a/docs/architecture-refactor.md b/docs/architecture-refactor.md index 1c654a4..877c3bb 100644 --- a/docs/architecture-refactor.md +++ b/docs/architecture-refactor.md @@ -6,7 +6,7 @@ - `infrastructure/` 只有 `file_log`,services 并不走它。 - `application/state.rs` 是服务集合,不是端口组合根。 -- `DomainError` 零引用。 +- 稳定 IPC 错误码放在 `domain/error.rs` 的 `codes`(如 `native.taskJoin`),不是空壳 `DomainError` 类型。 - SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。 - 前后端契约靠手写 `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 一致,没做的删掉,避免再写成已存在。 -`DomainError`:有调用方再留,否则删。不要为空壳再加一层错误类型。 +IPC 错误码继续用 `domain/error.rs` 的 `codes` 常量,不要再加一层空壳 `DomainError`。 ## 明确不做(本轨道也不做) diff --git a/docs/architecture.md b/docs/architecture.md index ac59274..745601f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,7 +82,7 @@ XTerm 当前焦点分屏 -> 当前 connectionId 的 ssh_exec -> stdout/stderr/exit_code + 本地 analyze_output -> 真实观察脱敏后交给 AI 判断下一步 - -> 最多 8 步后收敛 + -> 最多 12 步后收敛 ``` - Provider 配置和模型路由保存在 `ai-providers-v1`,不包含 API Key。 @@ -108,7 +108,7 @@ XTerm 当前焦点分屏 - `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。 - 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 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 才能编辑。 - 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。 - 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。 @@ -118,7 +118,7 @@ XTerm 当前焦点分屏 ## 远程桌面生命周期 - RDP / VNC 仅在 Tauri 原生应用中可用;浏览器预览不得显示为可连接。 -- 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。 +- 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。标签还开着时意外断开会有限次自动重连(新会话);不发送假指针对抗空闲 GPO。 - RDP 可选剪贴板与共享盘(本机下载目录映射);VNC 剪贴板按 UTF-8 发送。 - 桌面会话不参与批量命令与终端广播。 @@ -160,7 +160,7 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验, - 不持久化终端命令历史或批量命令输出。 - 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。 - 未实现能力不能在界面中显示为可用。 -- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新未完成)。 +- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新)。 - 五层目录补全、specta、EventSink 是单独轨道,见 [架构重构](./architecture-refactor.md);未开始前继续按本文件的 commands → services → platform 扩展。 - 应用不能静默绕过 changed host key 或 known_hosts 不匹配。 diff --git a/docs/release.md b/docs/release.md index 129099d..0180a3a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,5 +1,34 @@ # 发布指南 + + +## 当前你要做的操作(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 @@ -9,6 +38,10 @@ # macOS Apple Silicon 指定版本 ./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 中执行) scripts\build.bat v2.1.1 win-x64 @@ -87,64 +120,79 @@ lsof +D /Volumes/<临时卷名> 先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。 -## 签名、公证和自动更新(未完成) + -**当前状态:没有签名凭据,公开包是 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`。 -- 未设该变量时,`./scripts/build.sh` 仍可打开发包,验证只检查架构和体积。 -- CI 工作流 `.github/workflows/tauri-release.yml` 已能在 Secrets 齐全时写入 `src-tauri/tauri.signing.conf.json` 再构建;**本地 `build.sh` 还不会生成这份配置**,所以本机即使导出了同样的环境变量,也不会走出 ad-hoc。 -- 全仓库没有 `notarytool` / `staple`。Developer ID 签过但未公证的 `.app`,用户机器上仍会被 Gatekeeper 拦。 -- `scripts/release.py` 仍提示「右键打开」——这是 ad-hoc 包的权宜说明,正式公证后必须删掉。 +- 未设该变量时,`./scripts/build.sh` 仍可打开发包;若存在 `tauri.signing.conf.json` 则会签 Developer ID。 +- CI `.github/workflows/tauri-release.yml` 在 Secrets 齐全时写入 `tauri.signing.conf.json`。本机用同一文件名即可,不要提交。 -下面是有凭据之后的做法。没齐之前不要改 `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 配置 | -| 平台 | 要什么 | 放到哪里 | -| -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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 配置 | +`src-tauri/tauri.signing.conf.json` 必须进 `.gitignore`,不得提交。macOS 示例: -Linux 安装包(deb/rpm/AppImage)不走 Apple/Authenticode。自动更新的 `.sig` 是另一套签名,三平台都要。 - -`src-tauri/tauri.signing.conf.json` 由脚本现场生成,必须进 `.gitignore`,不得提交。 +```json +{ + "bundle": { + "macOS": { + "signingIdentity": "Developer ID Application: Logan Li (B8DVUUUF6W)" + } + } +} +``` ### 2. 本地与 CI 同源签名 -目标:本机 `REQUIRE_SIGNING=true ./scripts/build.sh` 与 CI 走同一条路径,都能复现同一类签名产物。 - -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`。 -2. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`——没凭据的开发机构必须还能打 ad-hoc。 -3. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc / 无效 Authenticode;不要为了绿而关校验。 -4. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 构建保持未签名。 +1. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`。 +2. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc。 +3. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 保持未签名。 ### 3. macOS 公证(notarytool + staple) -签名成功 ≠ 用户能打开。必须公证并钉票: +签名成功 ≠ 用户能打开。必须公证并钉票。不要对同一份未变化的 DMG 连续多次 `submit`。 ```bash -# 构建并 Developer ID 签名之后 -xcrun notarytool submit "$DMG" \ - --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" \ - --wait -xcrun stapler staple "$DMG" -# 若 staple 的是 .app,再重新打 DMG,或对 DMG 再 staple -spctl --assess --type install --verbose "$DMG" +# 先弹出已挂载的安装盘,再: +APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh +# 可选:指定 DMG 路径 +APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg ``` -验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上,不要只在构建机看 `spctl`): +脚本会:已 staple 则退出;否则先 staple;没有票据才要 App 专用密码并 `notarytool submit --wait --timeout 15m`,超时仍 staple。开发者后台网页没有这份 DMG 的进度页,用终端或: -1. 下载 DMG,双击打开,拖到「应用程序」,**不要**右键「打开」、不要 `xattr -d`。 +```bash +xcrun notarytool info --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" +xcrun notarytool log --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" +``` + +验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上): + +1. 下载 **staple 之后** 的 DMG,双击打开,拖到「应用程序」,不要右键「打开」、不要 `xattr -d`。 2. 能启动、能连 SSH,菜单里没有 DevTools。 -3. `codesign --verify --deep --strict` 且 `codesign -dv` 不是 `Signature=adhoc`。 -4. 删掉 Release 说明和 `release.py` 里的「右键绕过 Gatekeeper」。 +3. `codesign --verify --deep --strict` 且 `codesign -dv` 含 `Notarization Ticket=stapled`,不是 `Signature=adhoc`。 -没做完 staple 之前,文档继续写「未公证」。 +没做完 staple 之前,对外说明不要写「已公证」。 ### 4. Windows Authenticode diff --git a/docs/testing-acceptance.md b/docs/testing-acceptance.md index b8944bf..79b3ede 100644 --- a/docs/testing-acceptance.md +++ b/docs/testing-acceptance.md @@ -62,7 +62,7 @@ npm run acceptance:full 9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。 10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。 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 命令必须在确认对话框前硬拒绝。 14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。 15. 超长工具输出必须限长,`analyze_output` 保留总行数、错误行和首尾;关闭面板后命令与输出消失。 diff --git a/docs/user-guide.md b/docs/user-guide.md index e077b42..5efbd85 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -2,6 +2,12 @@ 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`。快速连接适合临时使用,不会自动保存会话。 @@ -47,13 +53,23 @@ socks5://127.0.0.1:1080 - 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。同一窗口内只要没点「停止」,SSH 断线、保活失败、空闲挂起或重连都会继续监听(本地 / SOCKS 端口不拆,远程转发会自动重听)。点停止、关闭标签、连接面板手动断开,或退出应用,才会关闭真实 Listener。关闭标签后再开同一会话不会自动拉起转发。 - 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:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。 +- RDP:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。画面铺满当前窗口(任务栏贴底),连接时按窗口客户区请求分辨率;窗口缩放后再走 Display Control。 - VNC:填写主机、端口与可选密码;原生 RFB 引擎,剪贴板按 UTF-8 发送。 -- 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或断开。 +- 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或重新连接。标签还开着时网络闪断会有限次自动重连(新会话,不对抗服务器空闲注销)。 - 桌面会话不参与批量命令与终端广播。 ## 导入 OpenSSH 配置 @@ -69,7 +85,7 @@ socks5://127.0.0.1:1080 包含 `*`、`?` 或 `!` 的 Host 模式不会生成会话。导入后请检查目标、用户名、密钥路径和跳板关系再连接。 -会话导出为 JSON,不包含密码、私钥口令或代理密码;私钥路径会保留,便于在同一台电脑恢复配置。 +会话导出默认保存为 `opsxshell-hosts.oxs`(密码加密的主机清单,格式类似 KeePass `.kdbx`、MobaXterm `.mxtsessions`)。旧版 `.bak` 与 OpenSSH `config` 仍可导入。导出包含主机分组、密钥和密码,务必保管备份密码。 ## SFTP @@ -82,7 +98,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下 - 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。 - 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。 - 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。 -- SSH 断线后,SFTP 一键重连会优先回到断线前的远程目录;进行中的传输会标为失败,可重试。终端标签本身也支持一键重新连接(及可选自动重连)。 +- SSH 断线后,SFTP 会按与终端相同的有限次自动重连,并优先回到断线前的远程目录;进行中的传输会标为失败,可重试。也可一键手动重连。 - 刷新时状态栏显示「正在刷新…」,传输忙时浏览类操作会立即返回忙提示而不是卡住界面。 - 传输失败会在传输队列和界面通知中显示经多语言映射后的原因。 @@ -120,7 +136,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下 - 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。 - 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。 -- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。 +- Agent 最多执行 12 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。 - 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。 - 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。 - 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。 diff --git a/public/app-icon.png b/public/app-icon.png new file mode 100644 index 0000000..9d86172 Binary files /dev/null and b/public/app-icon.png differ diff --git a/scripts/build.sh b/scripts/build.sh index 2f435f0..935d107 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -176,10 +176,23 @@ esac node scripts/release-preflight.mjs "$RELEASE_PLATFORM" log "构建原生安装包" +TAURI_ARGS=(--ci --target "$RUST_TARGET") +CODESIGN_WRAP_DIR="" if [ "$RELEASE_PLATFORM" = "macos" ]; then - npx tauri build --ci --target "$RUST_TARGET" --bundles app,dmg -else - npx tauri build --ci --target "$RUST_TARGET" + TAURI_ARGS+=(--bundles app,dmg) + CODESIGN_WRAP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/opsx-codesign.XXXXXX")" + 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 BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle" diff --git a/scripts/macos-codesign-wrapper.sh b/scripts/macos-codesign-wrapper.sh new file mode 100755 index 0000000..2fa0d0f --- /dev/null +++ b/scripts/macos-codesign-wrapper.sh @@ -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[@]}" diff --git a/scripts/macos-notarize.sh b/scripts/macos-notarize.sh new file mode 100755 index 0000000..ed5251a --- /dev/null +++ b/scripts/macos-notarize.sh @@ -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 diff --git a/scripts/release.py b/scripts/release.py index 854b758..903032e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -137,7 +137,7 @@ def release_body(version: str, repo: str, assets: list[Path]) -> str: "### macOS 安装", "", "1. 打开 DMG,将 OpsXShell 拖入 Applications。", - "2. 当前公开包为 ad-hoc 开发签名且尚未 Apple 公证;首次启动如被 Gatekeeper 拦截,请在 Finder 中右键应用并选择“打开”。", + "2. 已 Developer ID 签名并公证、staple 的包可直接打开。若仍提示无法验证,请确认下载的是钉票后的 DMG,或在 Finder 右键应用选择“打开”。", "", "### 完整性校验", "", diff --git a/shared/types/ipc.types.ts b/shared/types/ipc.types.ts index 0f5f0c1..23716a9 100644 --- a/shared/types/ipc.types.ts +++ b/shared/types/ipc.types.ts @@ -21,10 +21,15 @@ export interface SSHConnectRequest { 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 - connectionId?: string error?: string + errorCode?: string +} + +export interface SSHConnectResponse extends IpcEnvelope { + connectionId?: string } export interface SSHExecRequest { @@ -32,11 +37,9 @@ export interface SSHExecRequest { command: string } -export interface SSHExecResponse { - success: boolean +export interface SSHExecResponse extends IpcEnvelope { result?: string exitCode?: number - error?: string } export interface SSHCreateShellRequest { @@ -83,11 +86,9 @@ export interface SSHForwardDynamicRequest { localPort: number } -export interface SSHForwardResponse { - success: boolean +export interface SSHForwardResponse extends IpcEnvelope { forwardId?: string boundPort?: number - error?: string } export interface SSHForwardStatus { @@ -105,30 +106,21 @@ export interface SSHForwardStatus { bytesDown: number } -export interface SSHForwardListResponse { - success: boolean +export interface SSHForwardListResponse extends IpcEnvelope { tunnels?: SSHForwardStatus[] - error?: string } // ============== Session IPC ============== -export interface SessionCreateResponse { - success: boolean +export interface SessionCreateResponse extends IpcEnvelope { session?: Session - error?: string } -export interface SessionUpdateResponse { - success: boolean +export interface SessionUpdateResponse extends IpcEnvelope { session?: Session - error?: string } -export interface SessionDeleteResponse { - success: boolean - error?: string -} +export type SessionDeleteResponse = IpcEnvelope // ============== Settings IPC ============== diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 91835d0..9632077 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -118,16 +118,28 @@ pub fn show_open_dialog(options: DialogOptions) -> DialogResult { } #[tauri::command] -pub fn show_save_dialog(options: DialogOptions) -> DialogResult { - configure_dialog(rfd::FileDialog::new(), &options) +pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult { + 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() .map(|path| path_result(path, false)) .unwrap_or_else(canceled) } #[tauri::command] -pub fn read_file(file_path: String) -> ReadFileResponse { - match fs::read(expand_home(&file_path)) { +pub async fn read_file(file_path: String) -> ReadFileResponse { + tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) { Ok(data) => ReadFileResponse { success: true, data: Some(STANDARD.encode(data)), @@ -138,18 +150,33 @@ pub fn read_file(file_path: String) -> ReadFileResponse { data: None, 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] -pub fn write_file(file_path: String, data: String) -> OperationResponse { - let result = STANDARD - .decode(data) - .map_err(|error| format!("Base64 数据无效: {error}")) - .and_then(|bytes| { - fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()) - }); - operation(result) +pub async fn write_file(file_path: String, data: String) -> OperationResponse { + tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) { + Err(error) => OperationResponse::coded( + crate::domain::error::codes::INVALID_BASE64, + error.to_string(), + ), + Ok(bytes) => { + 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] @@ -187,8 +214,8 @@ pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> { } #[tauri::command] -pub fn local_read_dir(path: String) -> LocalFilesResponse { - match read_local_dir(&expand_home(&path)) { +pub async fn local_read_dir(path: String) -> LocalFilesResponse { + tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) { Ok(files) => LocalFilesResponse { success: true, files: Some(files), @@ -199,48 +226,89 @@ pub fn local_read_dir(path: String) -> LocalFilesResponse { files: None, error: Some(error), }, - } + }) + .await + .unwrap_or_else(|_| LocalFilesResponse { + success: false, + files: None, + error: Some(crate::domain::error::codes::TASK_JOIN.to_string()), + }) } #[tauri::command] -pub fn local_get_stats(path: String) -> LocalStatsResponse { - let path = expand_home(&path); - match local_entry(&path) { - Ok(stats) => LocalStatsResponse { - success: true, - stats: Some(stats), - error: None, - }, - Err(error) => LocalStatsResponse { - success: false, - stats: None, - error: Some(error), - }, - } +pub async fn local_get_stats(path: String) -> LocalStatsResponse { + tauri::async_runtime::spawn_blocking(move || { + let path = expand_home(&path); + match local_entry(&path) { + Ok(stats) => LocalStatsResponse { + success: true, + stats: Some(stats), + error: None, + }, + Err(error) => LocalStatsResponse { + success: false, + 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] -pub fn local_mkdir(path: String) -> OperationResponse { - operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string())) +pub async fn local_mkdir(path: String) -> OperationResponse { + 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] -pub fn local_delete(path: String) -> OperationResponse { - let path = expand_home(&path); - let result = if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - }; - operation(result.map_err(|error| error.to_string())) +pub async fn local_delete(path: String) -> OperationResponse { + tauri::async_runtime::spawn_blocking(move || { + let path = expand_home(&path); + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + 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] -pub fn local_rename(old_path: String, new_path: String) -> OperationResponse { - operation( - fs::rename(expand_home(&old_path), expand_home(&new_path)) - .map_err(|error| error.to_string()), - ) +pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse { + tauri::async_runtime::spawn_blocking(move || { + operation( + 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] @@ -251,12 +319,52 @@ pub fn open_external(url: String) -> Result<(), String> { open::that(url).map_err(|error| error.to_string()) } +#[derive(Debug, Default, PartialEq, Eq)] +struct DialogPath { + directory: Option, + file_name: Option, +} + +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 { if let Some(title) = &options.title { dialog = dialog.set_title(title); } 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 { dialog = dialog.add_filter(&filter.name, &filter.extensions); @@ -393,3 +501,29 @@ fn operation(result: Result<(), String>) -> OperationResponse { 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"))); + } +} diff --git a/src-tauri/src/commands/known_hosts.rs b/src-tauri/src/commands/known_hosts.rs index 1d2e9bf..e96dc96 100644 --- a/src-tauri/src/commands/known_hosts.rs +++ b/src-tauri/src/commands/known_hosts.rs @@ -29,25 +29,52 @@ pub struct KnownHostsResponse { } #[tauri::command] -pub fn known_hosts_list() -> KnownHostsResponse { - match list_entries() { - Ok(entries) => KnownHostsResponse { +pub async fn known_hosts_list() -> KnownHostsResponse { + tauri::async_runtime::spawn_blocking(list_entries) + .await + .unwrap_or_else(|_| Err(crate::domain::error::codes::TASK_JOIN.to_string())) + .map(|entries| KnownHostsResponse { success: true, entries: Some(entries), error: None, - }, - Err(error) => KnownHostsResponse { + }) + .unwrap_or_else(|error| KnownHostsResponse { success: false, entries: None, error: Some(error), - }, - } + }) } #[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 { - return OperationResponse::error("无法确定 known_hosts 路径"); + return OperationResponse::coded( + crate::domain::error::codes::KNOWN_HOSTS_PATH, + "known_hosts path unavailable", + ); }; if !path.is_file() { return OperationResponse::ok(); @@ -85,10 +112,12 @@ pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse { } } -#[tauri::command] -pub fn known_hosts_clear() -> OperationResponse { +fn known_hosts_clear_inner() -> OperationResponse { 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, "") { Ok(()) => OperationResponse::ok(), @@ -99,7 +128,10 @@ pub fn known_hosts_clear() -> OperationResponse { #[tauri::command] pub fn ssh_respond_host_key(request_id: String, decision: String) -> OperationResponse { 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) { Ok(()) => OperationResponse::ok(), diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 73debd9..d93734e 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -19,7 +19,10 @@ pub async fn ssh_connect( match tauri::async_runtime::spawn_blocking(move || service.connect(config)).await { Ok(Ok(id)) => IdResponse::connection(id), 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>, connection_id: String, retain_forwards: Option, + reason: Option, ) -> Result { let service = state.ssh.clone(); let retain = retain_forwards.unwrap_or(false); match tauri::async_runtime::spawn_blocking(move || { - service.disconnect(&app, &connection_id, retain) + service.disconnect(&app, &connection_id, retain, reason) }) .await { Ok(result) => Ok(operation(result)), - Err(error) => Ok(OperationResponse::error(format!( - "断开连接任务失败: {error}" - ))), + Err(_) => Ok(OperationResponse::coded( + crate::domain::error::codes::TASK_JOIN, + "disconnect background task failed", + )), } } @@ -52,7 +57,8 @@ pub async fn ssh_exec( command: String, from_agent: Option, ) -> Result { - 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) { return Ok(ExecResponse { success: false, @@ -71,7 +77,11 @@ pub async fn ssh_exec( { Ok(Ok((result, exit_code))) => ExecResponse { success: true, - result: Some(result), + result: Some(if from_agent { + crate::services::ai::redact_sensitive(&result).0 + } else { + result + }), exit_code: Some(exit_code), error: None, }, @@ -79,7 +89,11 @@ pub async fn ssh_exec( success: false, result: None, exit_code: None, - error: Some(error), + error: Some(if from_agent { + crate::services::ai::redact_sensitive(&error).0 + } else { + error + }), }, Err(error) => ExecResponse { success: false, @@ -118,7 +132,10 @@ pub async fn ssh_keepalive( let service = state.ssh.clone(); match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await { 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(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(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", + ), }, ) } diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index eec7a09..d090dec 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -5,154 +5,269 @@ use tauri::{AppHandle, State}; use crate::{application::AppState, domain::ssh::OperationResponse}; #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct SessionsResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] sessions: Option>, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct SessionResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] session: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct SettingResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] value: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, +} + +fn join_failed() -> String { + crate::domain::error::codes::TASK_JOIN.to_string() +} + +fn join_code() -> Option { + Some(crate::domain::error::codes::TASK_JOIN.to_string()) } #[tauri::command] -pub fn session_get_all(app: AppHandle, state: State<'_, AppState>) -> SessionsResponse { - match state.storage.sessions(&app) { - Ok(sessions) => SessionsResponse { - success: true, - sessions: Some(sessions), - error: None, +pub async fn session_get_all( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let storage = state.storage.clone(); + 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] -pub fn session_get_by_id( +pub async fn session_get_by_id( app: AppHandle, state: State<'_, AppState>, id: String, -) -> SessionResponse { - match state.storage.session(&app, &id) { - Ok(session) => SessionResponse { - success: session.is_some(), - session, - error: None, +) -> Result { + let storage = state.storage.clone(); + Ok( + match tauri::async_runtime::spawn_blocking(move || storage.session(&app, &id)).await { + Ok(Ok(session)) => SessionResponse { + 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] -pub fn session_create( +pub async fn session_create( app: AppHandle, state: State<'_, AppState>, session: Value, -) -> SessionResponse { - match state.storage.create_session(&app, session) { - Ok(session) => SessionResponse { - success: true, - session: Some(session), - error: None, +) -> Result { + let storage = state.storage.clone(); + Ok( + match tauri::async_runtime::spawn_blocking(move || storage.create_session(&app, session)) + .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] -pub fn session_update( +pub async fn session_update( app: AppHandle, state: State<'_, AppState>, id: String, updates: Value, -) -> SessionResponse { - match state.storage.update_session(&app, &id, updates) { - Ok(session) => SessionResponse { - success: true, - session: Some(session), - error: None, +) -> Result { + let storage = state.storage.clone(); + Ok( + match tauri::async_runtime::spawn_blocking(move || { + storage.update_session(&app, &id, updates) + }) + .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] -pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) -> OperationResponse { - match state.storage.delete_session(&app, &id) { - Ok(()) => OperationResponse::ok(), - Err(error) => OperationResponse::error(error), - } +pub async fn session_delete( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result { + 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] -pub fn session_reorder( +pub async fn session_reorder( app: AppHandle, state: State<'_, AppState>, updates: Vec, -) -> OperationResponse { - match state.storage.reorder_sessions(&app, updates) { - Ok(()) => OperationResponse::ok(), - Err(error) => OperationResponse::error(error), - } +) -> Result { + let storage = state.storage.clone(); + Ok( + 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] -pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse { - match state.storage.get_setting(&app, &key) { - Ok(value) => SettingResponse { - success: true, - value, - error: None, +pub async fn settings_get( + app: AppHandle, + state: State<'_, AppState>, + key: String, +) -> Result { + 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] -pub fn settings_set( +pub async fn settings_set( app: AppHandle, state: State<'_, AppState>, key: String, value: Value, -) -> OperationResponse { - match state.storage.set_setting(&app, key, value) { - Ok(()) => OperationResponse::ok(), - Err(error) => OperationResponse::error(error), - } +) -> Result { + let storage = state.storage.clone(); + Ok( + 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", + ), + }, + ) } diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index f229987..3a3427a 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -1,9 +1,11 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum DomainError { - #[error("resource not found: {0}")] - NotFound(String), - #[error("operation is not available: {0}")] - Unsupported(String), +/** + * Stable IPC error codes. The renderer maps `errors:native.*` (and other + * `errors:` keys that match these dotted paths). Command envelopes may also + * still carry a human `error` string for logs. + */ +pub mod codes { + pub const TASK_JOIN: &str = "native.taskJoin"; + pub const INVALID_BASE64: &str = "native.invalidBase64"; + pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath"; + pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision"; } diff --git a/src-tauri/src/domain/ssh.rs b/src-tauri/src/domain/ssh.rs index fa7cac9..7cd4bc6 100644 --- a/src-tauri/src/domain/ssh.rs +++ b/src-tauri/src/domain/ssh.rs @@ -27,6 +27,8 @@ pub struct OperationResponse { pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, } impl OperationResponse { @@ -34,6 +36,7 @@ impl OperationResponse { Self { success: true, error: None, + error_code: None, } } @@ -41,6 +44,15 @@ impl OperationResponse { Self { success: false, error: Some(error.into()), + error_code: None, + } + } + + pub fn coded(code: &'static str, error: impl Into) -> 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, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, } impl IdResponse { @@ -67,6 +81,7 @@ impl IdResponse { shell_id: None, sftp_id: None, error: None, + error_code: None, } } @@ -77,6 +92,7 @@ impl IdResponse { shell_id: Some(id), sftp_id: None, error: None, + error_code: None, } } @@ -87,6 +103,7 @@ impl IdResponse { shell_id: None, sftp_id: Some(id), error: None, + error_code: None, } } @@ -97,6 +114,18 @@ impl IdResponse { shell_id: None, sftp_id: None, error: Some(error.into()), + error_code: None, + } + } + + pub fn coded(code: &'static str, error: impl Into) -> Self { + Self { + success: false, + connection_id: None, + shell_id: None, + sftp_id: None, + error: Some(error.into()), + error_code: Some(code.to_string()), } } } diff --git a/src-tauri/src/services/ai/mod.rs b/src-tauri/src/services/ai/mod.rs index e17104c..44791ca 100644 --- a/src-tauri/src/services/ai/mod.rs +++ b/src-tauri/src/services/ai/mod.rs @@ -9,7 +9,7 @@ use url::Url; use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig}; -use redaction::redact_sensitive; +pub(crate) use redaction::redact_sensitive; pub struct AiCompletion { pub text: String, diff --git a/src-tauri/src/services/credential_store.rs b/src-tauri/src/services/credential_store.rs index f0c5bb9..7ce883e 100644 --- a/src-tauri/src/services/credential_store.rs +++ b/src-tauri/src/services/credential_store.rs @@ -168,14 +168,21 @@ fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; + static TEST_HOME_SEQ: AtomicU64 = AtomicU64::new(0); + fn unique_home() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time") .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"); dir } diff --git a/src-tauri/src/services/desktop/clipboard_text.rs b/src-tauri/src/services/desktop/clipboard_text.rs new file mode 100644 index 0000000..3880a04 --- /dev/null +++ b/src-tauri/src/services/desktop/clipboard_text.rs @@ -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 { + 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("")); + } +} diff --git a/src-tauri/src/services/desktop/cliprdr_backend.rs b/src-tauri/src/services/desktop/cliprdr_backend.rs index bf637de..fe4f9f6 100644 --- a/src-tauri/src/services/desktop/cliprdr_backend.rs +++ b/src-tauri/src/services/desktop/cliprdr_backend.rs @@ -17,6 +17,8 @@ use tracing::{debug, warn}; use crate::domain::desktop::RdpClipboardEvent; +use super::clipboard_text::sanitize_clipboard_text; + #[derive(Debug)] pub struct OpsxCliprdrBackend { app: AppHandle, @@ -144,6 +146,7 @@ impl CliprdrBackend for OpsxCliprdrBackend { .to_unicode_string() .or_else(|_| response.to_string()) .unwrap_or_default(); + let text = sanitize_clipboard_text(&text); if text.is_empty() { return; } @@ -179,6 +182,10 @@ pub fn queue_local_clipboard_set( want_paste_inject: &AtomicBool, text: String, ) { + let text = sanitize_clipboard_text(&text); + if text.is_empty() { + return; + } if let Ok(mut guard) = local_text.lock() { *guard = Some(text); } diff --git a/src-tauri/src/services/desktop/mod.rs b/src-tauri/src/services/desktop/mod.rs index 8debdf1..cd1c8dc 100644 --- a/src-tauri/src/services/desktop/mod.rs +++ b/src-tauri/src/services/desktop/mod.rs @@ -3,6 +3,7 @@ //! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime //! state lives only in memory; disconnect cancels the worker thread. +mod clipboard_text; mod cliprdr_backend; mod common; mod input_map; diff --git a/src-tauri/src/services/desktop/vnc_engine.rs b/src-tauri/src/services/desktop/vnc_engine.rs index 65c1952..bd0dd97 100644 --- a/src-tauri/src/services/desktop/vnc_engine.rs +++ b/src-tauri/src/services/desktop/vnc_engine.rs @@ -16,6 +16,7 @@ use vnc::{ 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::input_map; @@ -125,6 +126,7 @@ async fn run_session_async( let mut fb_height = height; let mut framebuffer: Vec = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4]; let mut buttons: u8 = 0; + let mut last_outbound_cut: Option = None; let mut last_refresh = std::time::Instant::now(); emit_status( @@ -152,7 +154,7 @@ async fn run_session_async( let _ = vnc.input(X11Event::FullRefresh).await; 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 { Ok(Some(event)) => { @@ -163,6 +165,7 @@ async fn run_session_async( &mut fb_width, &mut fb_height, &mut framebuffer, + &mut last_outbound_cut, )?; } Ok(None) => { @@ -187,6 +190,7 @@ async fn drain_input( vnc: &vnc::VncClient, input: &Mutex>, buttons: &mut u8, + last_outbound_cut: &mut Option, ) -> Result<(), String> { let queued = { let mut guard = input @@ -233,15 +237,20 @@ async fn drain_input( .map_err(|error| format!("发送键盘事件失败: {error}"))?; } 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() - // (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. - let cut = prepare_cut_text(&text); - vnc.input(X11Event::CopyText(cut)) + // (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers. + *last_outbound_cut = Some(cut.clone()); + vnc.input(X11Event::CopyText(cut.clone())) .await .map_err(|error| format!("发送剪贴板失败: {error}"))?; - // Most servers only update remote clipboard; inject Ctrl+V for paste UX. - inject_remote_paste(vnc).await?; + // Do not inject Ctrl+V: remote terminals wrap clipboard paste in + // 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 { .. } => {} } @@ -249,7 +258,46 @@ async fn drain_input( 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 KEY_V: u32 = 0x0076; for (keycode, down) in [ @@ -258,18 +306,15 @@ async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> { (KEY_V, false), (CONTROL, false), ] { - vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down })) - .await - .map_err(|error| format!("注入粘贴快捷键失败: {error}"))?; + send_key(vnc, keycode, down).await?; } Ok(()) } -fn prepare_cut_text(text: &str) -> String { - // Send UTF-8 as-is. Classic Latin-1 servers may mojibake non-ASCII, but replacing - // CJK with '?' is worse for OpsXShell's primary locale. Extended Clipboard (UTF-8) - // needs a vnc-rs protocol upgrade beyond CopyText. - text.to_string() +async fn send_key(vnc: &vnc::VncClient, keycode: u32, down: bool) -> Result<(), String> { + vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down })) + .await + .map_err(|error| format!("发送键盘事件失败: {error}")) } fn decode_tight_jpeg(data: &[u8]) -> Result, String> { @@ -292,6 +337,7 @@ fn handle_vnc_event( fb_width: &mut u16, fb_height: &mut u16, framebuffer: &mut Vec, + last_outbound_cut: &mut Option, ) -> Result<(), String> { match event { VncEvent::SetResolution(screen) => { @@ -372,9 +418,13 @@ fn handle_vnc_event( }, VncEvent::Error(message) => return Err(message), VncEvent::Text(text) => { + let text = sanitize_clipboard_text(&text); if text.is_empty() { return Ok(()); } + if last_outbound_cut.as_ref() == Some(&text) { + return Ok(()); + } if let Ok(mut clipboard) = arboard::Clipboard::new() { let _ = clipboard.set_text(text.clone()); } diff --git a/src-tauri/src/services/ssh.rs b/src-tauri/src/services/ssh.rs index 0d85030..7f7642b 100644 --- a/src-tauri/src/services/ssh.rs +++ b/src-tauri/src/services/ssh.rs @@ -96,7 +96,12 @@ impl SshService { app: &AppHandle, connection_id: &str, retain_forwards: bool, + reason: Option, ) -> 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 // retain_forwards is true. Explicit teardown (tab close / panel disconnect) // must still close real Listeners. @@ -123,7 +128,7 @@ impl SshService { for shell_id in shell_ids { if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) { let _ = shell.channel.close(); - let _ = shell.session.disconnect(None, "closed by user", None); + let _ = shell.session.disconnect(None, &reason, None); } } let sftp_ids = { @@ -148,13 +153,13 @@ impl SshService { .map_err(lock_error)? .remove(connection_id) { - let _ = connection.session.disconnect(None, "closed by user", None); + let _ = connection.session.disconnect(None, &reason, None); } let _ = app.emit( "ssh:connection-closed", ConnectionClosedEvent { connection_id: connection_id.to_string(), - reason: "closed by user".to_string(), + reason, }, ); 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"); if idle_timeout { - return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true); + return ("shell.idleTimeout".to_string(), true); } if let Some(error) = read_error { - return (format!("远端 Shell 读取失败: {error}"), false); + return (format!("shell.readFailed: {error}"), false); } - ( - "远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(), - false, - ) + ("shell.exited".to_string(), false) } 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); assert!(idle_timeout); - assert!(reason.contains("TMOUT")); + assert_eq!(reason, "shell.idleTimeout"); } #[test] @@ -831,7 +833,7 @@ mod tests { let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None); assert!(!idle_timeout); - assert!(reason.contains("Shell 已退出")); + assert_eq!(reason, "shell.exited"); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bcd29b9..1bdcb43 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,7 +34,21 @@ "targets": "all", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "macOS": { - "signingIdentity": "-" + "signingIdentity": "-", + "dmg": { + "windowSize": { + "width": 720, + "height": 440 + }, + "appPosition": { + "x": 180, + "y": 190 + }, + "applicationFolderPosition": { + "x": 520, + "y": 190 + } + } }, "category": "DeveloperTool", "shortDescription": "Simple native SSH / SFTP / desktop ops terminal", diff --git a/src/ai/providerPresets.ts b/src/ai/providerPresets.ts index 3912185..51c083a 100644 --- a/src/ai/providerPresets.ts +++ b/src/ai/providerPresets.ts @@ -1,9 +1,4 @@ -import type { - AiBillingMode, - AiModelConfig, - AiProviderConfig, - AiProviderFamily, -} from '../../shared/types/ai.types' +import type { AiBillingMode, AiModelConfig, AiProviderConfig, AiProviderFamily } from '../../shared/types/ai.types' export interface AiProviderPreset extends Omit { id: string @@ -11,10 +6,7 @@ export interface AiProviderPreset extends Omit = { +export const AI_PROVIDER_FAMILY_META: Record = { openai_compatible: { label: 'OpenAI compatible', description: 'payg', @@ -73,7 +65,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ allowInsecureHttp: false, models: [ 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', }, @@ -88,12 +80,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ baseUrl: 'https://api.openai.com/v1', allowInsecureHttp: false, 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-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.5', 'gpt-5.5', 'GPT-5.5', 'reasoning'), ], - defaultModelId: 'gpt-5.6-sol', + defaultModelId: 'gpt-6-astra', }, { id: 'qwen', @@ -107,6 +100,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ allowInsecureHttp: false, models: [ 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-flash', 'qwen3.7-flash', 'Qwen3.7 Flash'), ], @@ -141,7 +135,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ allowInsecureHttp: false, models: [ 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', }, @@ -175,11 +169,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ baseUrl: 'https://api.anthropic.com', allowInsecureHttp: false, 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-sonnet-5', 'claude-sonnet-5', 'Claude Sonnet 5', 'reasoning'), model('claude-haiku-4-5', 'claude-haiku-4-5', 'Claude Haiku 4.5'), ], - defaultModelId: 'claude-opus-5', + defaultModelId: 'claude-fable-5-1', }, { id: 'google', @@ -193,8 +189,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ allowInsecureHttp: false, models: [ 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.1-flash-lite', 'gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite'), + model('gemini-3.6-flash', 'gemini-3.6-flash', 'Gemini 3.6 Flash'), + 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', }, @@ -211,6 +208,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', allowInsecureHttp: false, 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', { 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', }), ], - defaultModelId: 'glm-5.2-plan', + defaultModelId: 'glm-5.3-plan', }, { id: 'minimax-plan', @@ -257,6 +257,22 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [ ], 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', + }, // ——— 私有化 ——— { diff --git a/src/app/App.tsx b/src/app/App.tsx index 5cafb34..c768873 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,6 +14,7 @@ import AppShell from './AppShell' import { useTabStore } from '../stores/tabStore' import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores' import { getConnectionStore } from '../stores/connection' +import { classifyDisconnectReason, shouldForgetNativeConnection } from '../utils/disconnectReason' import { useSettingsStore } from '../stores/settingsStore' import { createLogger } from '../utils/logger' import { useToast } from '../hooks/useToast' @@ -164,7 +165,8 @@ function App() { // Keep renderer state authoritative when the native SSH service closes a // connection (network loss, keepalive failure, or remote shell failure). useEffect(() => { - const handleConnectionClosed = (_event: unknown, connectionId: string) => { + const handleConnectionClosed = (_event: unknown, connectionId: string, reason?: string) => { + if (!shouldForgetNativeConnection(classifyDisconnectReason(reason))) return getConnectionStore().forgetNativeConnection(connectionId) const { tabs, updateTab } = useTabStore.getState() for (const tab of tabs) { @@ -210,6 +212,7 @@ function App() { title: t('sessions:importExport.exportTitle'), defaultPath: t('sessions:importExport.exportDefaultPath'), filters: [ + { name: t('sessions:importExport.exportFilterOxs'), extensions: ['oxs'] }, { name: t('sessions:importExport.exportFilterBak'), extensions: ['bak'] }, { name: t('sessions:importExport.exportFilterJson'), extensions: ['json'] }, ], @@ -291,6 +294,7 @@ function App() { const result = await window.opsxApi?.showOpenDialog({ title: t('sessions:importExport.importTitle'), filters: [ + { name: t('sessions:importExport.importFilterOxs'), extensions: ['oxs'] }, { name: t('sessions:importExport.importFilterBak'), extensions: ['bak'] }, { name: t('sessions:importExport.importFilterJson'), extensions: ['json'] }, { name: t('sessions:importExport.importFilterSshConfig'), extensions: ['conf', 'config'] }, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 245b15b..5b2746d 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -3,7 +3,7 @@ * * 窗口拖拽约定(Tauri overlay title bar): * - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放 - * - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次 + * - 不要再叠加 startDragging() 或 CSS app-region: drag,两者会抢鼠标导致窗口拖不动 * - 标签栏空白区也由 Tauri 原生拖拽区处理 * * RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。 @@ -19,7 +19,6 @@ import StatusBar from '../components/layout/StatusBar' import type { Session } from '../stores' import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore' import { getDesktopPlatform } from '../platform/runtime' -import { startWindowDragging } from '../platform/tauri' export interface AppShellProps { activeTabId: string | null @@ -47,10 +46,6 @@ function TitleBar() { return (
{ - 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" >
@@ -127,11 +122,7 @@ export default function AppShell({ const desktopFullscreen = useDesktopFullscreenStore((state) => state.active) return ( -
+
{!desktopFullscreen && }
@@ -145,7 +136,7 @@ export default function AppShell({ {!desktopFullscreen && (