diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f46675..66717d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,12 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: - components: rustfmt + components: rustfmt, clippy + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri - name: Install dependencies run: npm ci @@ -82,6 +87,12 @@ jobs: - name: Run foundation acceptance run: npm run acceptance:quick + - name: Rust clippy + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml + build: name: Build runs-on: ${{ matrix.os }} @@ -98,6 +109,21 @@ jobs: node-version: '20' cache: 'npm' + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ + libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libudev-dev + - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index 4c57e0b..9a9676a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ __pycache__/ # Tauri / Rust src-tauri/target/ +src-tauri/tauri.signing.conf.json # Database *.db diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json index b6b9051..e36ff2d 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,5 @@ { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], - "*.{css,json,md}": ["prettier --write"] + "*.{css,json,md}": ["prettier --write"], + "src-tauri/**/*.rs": ["cargo fmt --manifest-path src-tauri/Cargo.toml --"] } diff --git a/AGENTS.md b/AGENTS.md index 87e1ac8..5c22ee7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,6 @@ OpsXShell 是基于 Tauri 2、Rust、React 和 TypeScript 的轻量运维终端 - `test/`:Vitest 单元/组件测试与基础巡检(`test/patrol/`:SSH / SFTP / RDP / VNC);改功能后优先 `npm run test:patrol`。 - `scripts/`:本地启动、验收、版本同步与原生安装包构建/发布脚本(入口 `./run.sh`、`./scripts/build.sh`)。 - `docs/`:使用、架构、验收与发布文档;路线图与任务见根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`。 -- `license-server/`:授权与结账服务(独立 Node 包)。 React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zustand → `src/platform/*` → Tauri command → `src-tauri` 服务。新增原生能力时先定义 Rust command,再更新 `src/desktop.d.ts`、对应 `src/platform/*.ts` 和 `test/setup.ts`。 @@ -65,6 +64,6 @@ npm run acceptance:full ## 评审边界 - `src/platform/` 与 Tauri command 是本应用内部边界,不是给第三方调用的公共 API。 -- `license-server/` 独立于客户端;不要把结账/授权服务逻辑堆回桌面进程。 +- 结账与在线授权由独立的 opsx-licence 服务承担;不要把结账/签发逻辑堆回桌面进程。桌面端只验 `LIC-`(在线)和 `LICOFF-`(离线 Ed25519)。 - 不把 CLI、MCP、插件宿主或堡垒机审批做成默认能力。 - 转发 Listener、SSH 会话、RDP/VNC worker 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。 diff --git a/CLAUDE.md b/CLAUDE.md index 7c85435..69a8559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,6 @@ - `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`) - `docs/`:使用 / 架构 / 验收 / 发布 - 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md` -- `license-server/`:授权与结账服务(独立包) 数据流:UI → Zustand → `src/platform/*` → Tauri command → Rust 服务。组件不直接 `invoke`,也不直接碰 SSH / 文件系统 / 加密。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5bc90b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpsXShell Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5f6ea5c..938a397 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,26 @@ ## 已支持 +免费层: + - SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验 -- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru) -- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC) -- SFTP:双栏、中间传输箭头、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限,超限/二进制友好提示) -- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网 -- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码 -- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳 +- 会话管理:分组、标签、快速连接、多标签、终端分屏、命令片段;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru) +- SFTP:双栏、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限) +- OpenSSH 配置:导入 `~/.ssh/config` 常用字段(批量清单导入属专业版) - 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认 -- 远程桌面:原生 RDP(IronRDP)与 VNC(vnc-rs);剪贴板与共享盘(RDP) -- 本地终端与串口:系统 Shell、串口设备与波特率配置 -- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;自然语言驱动最多 8 步的执行→观察循环,三级安全模式、五级风险和破坏性命令硬拒绝 +- 本地终端:系统 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、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。 @@ -31,19 +38,19 @@ OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程 ## 技术栈 -| 层级 | 技术 | -| ---------- | -------------------------------- | -| 桌面运行时 | 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+ | +| 层级 | 技术 | +| ---------- | ------------------------------------------------- | +| 桌面运行时 | 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 | +| 测试 | Vitest + Rust test | 会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。 @@ -81,7 +88,6 @@ test/patrol/ SSH / SFTP / RDP / VNC 基础巡检 scripts/ 启动、验收、构建与发布脚本 docs/ 使用 / 架构 / 验收 / 发布文档 docs/assets/ 品牌与设计资源 -license-server/ 授权与结账服务 ``` ## 快捷键 @@ -108,4 +114,4 @@ license-server/ 授权与结账服务 ## 许可证 -MIT +MIT。完整文本见 [LICENSE](./LICENSE)。 diff --git a/ROADMAP.md b/ROADMAP.md index 5bfef45..984f596 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,9 +26,10 @@ 2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复 3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测 4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2 -5. **发布闭环**:Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新;用户可导出的脱敏崩溃包 -6. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出) +5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;macOS Developer ID + 公证;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(非零退出、循环、注入、敏感输出) ## 明确不做 -审计、录像、审批、命令历史、插件宿主、对外 MCP/CLI 公共 API、把 `license-server` 堆回桌面进程。 +审计、录像、审批、命令历史、插件宿主、对外 MCP/CLI 公共 API、把结账/签发服务堆回桌面进程。 diff --git a/TASKS.md b/TASKS.md index 61b6710..0a0646e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -23,15 +23,16 @@ - [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」 - [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开 - [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起 -- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新 +- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成)) ## 稍后 - [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起) - [ ] SFTP 校验和与失败任务恢复 - [ ] RDP/VNC 生产级证书校验 -- [ ] macOS Developer ID + 公证;Windows Authenticode -- [ ] 自动更新(签名清单 + 回滚) +- [ ] macOS Developer ID + 公证;Windows Authenticode(有证书后再做,见 release.md) +- [ ] 自动更新(签名清单 + HTTPS + 回滚;缺一不可开 UI) +- [ ] EventSink / specta / infrastructure 端口(单独轨道,见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)) - [ ] 用户可导出的脱敏崩溃诊断包 - [ ] AI 执行运行时从 React 面板抽离(取消、单步超时、原生 tool calling) - [ ] 主机排障 Agent eval 集 diff --git a/docs/README.md b/docs/README.md index 54eb3ed..f9c2b32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,15 +2,16 @@ 文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。 -| 文档 | 内容 | -| -------------------------------------------- | ------------------------------------------------- | -| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 | -| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 | -| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 | -| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 | -| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 | -| [路线图](../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) | 构建与产物;签名/公证/自动更新的做法(当前未完成) | +| [路线图](../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 86c5176..44b4ae0 100644 --- a/docs/ai-agent.md +++ b/docs/ai-agent.md @@ -51,15 +51,15 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 ### 安全边界 -| 等级 | 示例 | 严格模式 | 均衡模式(默认) | 宽松模式 | -| -------- | --------------------------------- | -------- | ---------------- | -------- | -| safe | `df -h`、`journalctl -u nginx` | 自动执行 | 自动执行 | 自动执行 | -| low | 未知自定义查询命令 | 自动执行 | 自动执行 | 自动执行 | -| medium | 文件写入、普通权限变更 | 先确认 | 自动执行 | 自动执行 | -| high | 服务重启、删除、包安装、K8s 变更 | 先确认 | 先确认 | 自动执行 | -| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝 | 硬拒绝 | 硬拒绝 | +| 等级 | 示例 | 严格模式 | 宽松模式(默认) | +| -------- | --------------------------------- | -------- | ---------------- | +| safe | `df -h`、`journalctl -u nginx` | 自动执行 | 自动执行 | +| low | 未知自定义查询命令 | 自动执行 | 自动执行 | +| medium | 文件写入、普通权限变更 | 先确认 | 自动执行 | +| high | 服务重启、删除、包安装、K8s 变更 | 先确认 | 先确认 | +| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝 | 硬拒绝 | -安全模式在任务开始时锁定,运行中不能切换;需要确认时,对话框必须显示本次锁定模式、目标、风险和完整单行命令。宽松模式明确标注“仅适合隔离测试环境”,它不会降低 critical 硬红线。 +安全模式在任务开始时锁定,运行中不能切换;需要确认时,对话框必须显示本次锁定模式、目标、风险和完整单行命令。critical 在前端与 Rust `guardrail` 都会硬拒绝。旧文档里的「均衡 / 宽松第三级」已合并为宽松。 终端输出被标记为不可信数据,不得将日志中的“忽略规则”等文字当作指令。常见密码、Token、Authorization Header、AWS Key 和私钥块在 Rust 发送 Provider 前脱敏。 @@ -77,7 +77,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 | ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 | | 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 | | 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 | -| 三级安全模式 | 已适配 | 严格从 medium、均衡从 high 确认;宽松仍不可绕过 critical | +| 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 | | WebSocket 事件流 | 不照搬 | 本地 React/Tauri 没有跨程服务器会话,使用内存状态即可 | | 审计、审批、RBAC | 不引入 | 属于 aiops/堡垒机,不是本地 SSH 客户端职责 | | podexec/K8s 专用工具 | 不引入 | `kubectl/helm` 可沿远程 SSH 工具使用,不引入第二套目标和凭据边界 | diff --git a/docs/architecture-refactor.md b/docs/architecture-refactor.md new file mode 100644 index 0000000..1c654a4 --- /dev/null +++ b/docs/architecture-refactor.md @@ -0,0 +1,76 @@ +# 架构重构(单独轨道,尚未开始) + +**当前不要做。** 改动面覆盖几乎全部 `src-tauri` 服务、`src/desktop.d.ts`、`src/platform/*` 和测试替身。本文件只记录怎么拆,避免和日常功能 PR 缠在一起。 + +当前真实分层是 **commands → services → domain DTO**。目录里虽有 `application/`、`infrastructure/`,但: + +- `infrastructure/` 只有 `file_log`,services 并不走它。 +- `application/state.rs` 是服务集合,不是端口组合根。 +- `DomainError` 零引用。 +- SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。 +- 前后端契约靠手写 `src/desktop.d.ts` + `shared/types/`,没有 specta / ts-rs。 + +产品数据流保持不变:UI → Zustand → `src/platform/*` → Tauri command → Rust 服务。重构不得把 SSH、文件系统或加密拉回 React,也不得改成插件宿主。 + +## 为什么单独做 + +1. EventSink 要改所有 `app.emit` 调用点,和功能修复冲突时极难回滚。 +2. specta 会动 command 签名和前端 `invoke` 形状,必须一次对齐 CI,不能「先生成一半」。 +3. 把 storage / SSH / 凭据收进 infrastructure trait,等于重写生命周期,和转发 retain、桌面 worker 取消语义绑在一起。 + +一次只开一条轨道,合入后再开下一条。每条轨道自己的 PR,跑 `npm run test:patrol` 和 `npm run acceptance:full`。 + +## 轨道 A:EventSink(先做,最小) + +目标:services 不再拿 `AppHandle`,也不再 `bind_app_handle`。 + +1. 在 `application/` 定义: + +```rust +pub trait EventSink: Send + Sync { + fn emit_json(&self, channel: &str, payload: serde_json::Value); + fn emit_empty(&self, channel: &str); +} +``` + +2. `TauriEventSink(AppHandle)` 只放在 application / command 边界。 +3. `SshService`、known_hosts、RDP/VNC engine、license 等改为持有 `Arc`。 +4. 删除 `known_hosts` 的 `APP_HANDLE` / `PENDING` / `ONCE_TRUSTED` 全局 static,收进 `AppState`。 +5. 单测用内存 `EventSink` 断言事件,不启 Tauri。 + +完成标准:`rg "use tauri::AppHandle" src-tauri/src/services` 为空(command 层可以留)。行为与现网事件名、payload 完全一致。 + +## 轨道 B:契约生成(specta 或 ts-rs) + +目标:消灭「Rust DTO / `desktop.d.ts` / `shared/types`」三处手工对齐。 + +1. 选一个:`tauri-specta`(和 command 绑定)或 `ts-rs`(只生成类型)。不要两个一起上。 +2. Rust 侧先把 `serde_json::Value` 逃逸收成强类型(`ProxyConfig`、`JumpHostConfig`、会话字段)。 +3. 生成文件放到例如 `shared/types/*.generated.ts`,CI 跑 `git diff --exit-code`。 +4. 手写 `src/desktop.d.ts` 只留窗口/`opsxApi` 外壳,领域类型改 re-export 生成结果。 +5. `test/setup.ts` 跟着生成类型改,不要继续复制一份松散 `unknown`。 + +完成标准:改一个 command 字段,忘跑生成则 CI 红。不要在这一轨改 EventSink 或换存储引擎。 + +## 轨道 C:infrastructure 端口(最后) + +目标:I/O 可替换,services 只编排。 + +建议端口(按需,不要一次全铺): + +| Trait | 先搬谁 | 不要顺便做 | +| -------------- | ---------------------------------- | ----------------------- | +| `SessionStore` | `services/storage.rs` 的 JSON 会话 | 不要同时改成 SQLite | +| `SecretStore` | `credential_store.rs` | 不要同时改系统 keychain | +| `SshTransport` | 仅当要换 russh 时再抽 | 默认保持 libssh2 | + +`infrastructure/mod.rs` 里现在的「SQLite / keychain / PTY 适配器」是过期注释,做 C 轨时改成与真实 trait 一致,没做的删掉,避免再写成已存在。 + +`DomainError`:有调用方再留,否则删。不要为空壳再加一层错误类型。 + +## 明确不做(本轨道也不做) + +- 六边形/整仓重命名目录来「看起来像五层」。 +- 对外 CLI、MCP、插件宿主。 +- 把结账/签发搬回桌面进程。 +- 和签名、公证、自动更新、功能修复混在同一个 PR。 diff --git a/docs/architecture.md b/docs/architecture.md index f77a401..ac59274 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,20 +51,20 @@ services/ ## 原生服务 -| 能力 | 实现 | -| -------------------- | ------------------------------------------------ | -| SSH/SFTP | `ssh2 0.9` / libssh2 | -| HTTP/SOCKS5 连接代理 | Rust TCP 握手后交给 libssh2 | -| ProxyJump | libssh2 direct-tcpip + loopback relay,最多 8 跳 | -| 本地/远程/动态转发 | libssh2 channel + Rust TCP relay | -| RDP | IronRDP(帧 / 输入 / Cliprdr / RDPDR) | -| VNC | `vnc-rs` RFB | -| 本地终端 | `portable-pty` | -| 串口 | `serialport` | -| 会话与设置 | 应用数据目录内原子写入 JSON | -| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` | -| 主机信任 | OpenSSH `~/.ssh/known_hosts` | -| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 | +| 能力 | 实现 | +| -------------------- | ------------------------------------------------------------------- | +| SSH/SFTP | `ssh2 0.9` / libssh2 | +| HTTP/SOCKS5 连接代理 | Rust TCP 握手后交给 libssh2 | +| ProxyJump | libssh2 direct-tcpip + loopback relay,最多 8 跳 | +| 本地/远程/动态转发 | libssh2 channel + Rust TCP relay | +| RDP | IronRDP(帧 / 输入 / Cliprdr / RDPDR) | +| VNC | `vnc-rs` RFB | +| 本地终端 | `portable-pty` | +| 串口 | `serialport` | +| 会话与设置 | 应用数据目录内原子写入 JSON | +| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` | +| 主机信任 | OpenSSH `~/.ssh/known_hosts` | +| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 | | 界面语言 | i18next;zh-CN / zh-TW / en / ja / ko / vi / ru,首启按系统语言检测 | ## SSH Agent CLI 边界 @@ -94,7 +94,7 @@ XTerm 当前焦点分屏 - 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。 - 模型返回内容按不可信文本处理;终端输出也标记为不可信,其中的指令不能覆盖 system Prompt。 - Agent 只接受唯一单行命令,目标固定为当前活跃 SSH connection;本次运行开始后安全模式锁定,确认框必须显示目标、命令、风险和锁定模式。 -- 命令风险为 safe/low/medium/high/critical 五级。严格模式从 medium 起确认;默认均衡模式从 high 起确认;宽松模式仅适合隔离测试环境。格式化磁盘、删除根目录等 critical 操作在任何模式下都硬拒绝。 +- 命令风险为 safe/low/medium/high/critical 五级。严格模式从 medium 起确认;默认宽松模式从 high 起确认。格式化磁盘、删除根目录等 critical 操作在前端与 Rust guardrail 都会硬拒绝。 - 相同命令第 3 次在执行前拦截;连续失败时要求更换排查路径。长输出先经本地 `analyze_output` 保留错误行和首尾。 - 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。 @@ -160,7 +160,8 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验, - 不持久化终端命令历史或批量命令输出。 - 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。 - 未实现能力不能在界面中显示为可用。 -- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。 +- 自动更新必须具备签名公钥、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 9b51992..129099d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -87,25 +87,96 @@ lsof +D /Volumes/<临时卷名> 先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。 -## 签名和公证 +## 签名、公证和自动更新(未完成) -标签 `tauri-v*` 会触发 `.github/workflows/tauri-release.yml`。正式标签构建要求签名变量齐全。 +**当前状态:没有签名凭据,公开包是 ad-hoc / 未签名,未公证,没有自动更新。** +没有 `APPLE_*` / Authenticode 证书、minisign 私钥和 HTTPS 更新源之前,不得在 README、界面、Release 说明或 CI 绿勾上写「已签名」「已公证」「支持自动更新」。`tauri.conf.json` 的 `signingIdentity` 现在是 `"-"`;`plugins` 只有 deep-link,没有 updater。 -macOS: +现有门禁只做「有凭据才验、没凭据就标明开发包」: -- `APPLE_CERTIFICATE` -- `APPLE_CERTIFICATE_PASSWORD` -- `APPLE_SIGNING_IDENTITY` -- `APPLE_ID` -- `APPLE_PASSWORD` -- `APPLE_TEAM_ID` +- `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 包的权宜说明,正式公证后必须删掉。 -Windows: +下面是有凭据之后的做法。没齐之前不要改 `signingIdentity`、不要接 updater 插件、不要在设置里放「检查更新」。 -- `WINDOWS_CERTIFICATE_BASE64` -- `WINDOWS_CERTIFICATE_PASSWORD` +### 1. 凭据(先买/申请,再接线) -缺少凭据时只能生成开发构建,不能标记为正式签名或已公证版本。 +| 平台 | 要什么 | 放到哪里 | +| -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` 是另一套签名,三平台都要。 + +`src-tauri/tauri.signing.conf.json` 由脚本现场生成,必须进 `.gitignore`,不得提交。 + +### 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 构建保持未签名。 + +### 3. macOS 公证(notarytool + staple) + +签名成功 ≠ 用户能打开。必须公证并钉票: + +```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" +``` + +验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上,不要只在构建机看 `spctl`): + +1. 下载 DMG,双击打开,拖到「应用程序」,**不要**右键「打开」、不要 `xattr -d`。 +2. 能启动、能连 SSH,菜单里没有 DevTools。 +3. `codesign --verify --deep --strict` 且 `codesign -dv` 不是 `Signature=adhoc`。 +4. 删掉 Release 说明和 `release.py` 里的「右键绕过 Gatekeeper」。 + +没做完 staple 之前,文档继续写「未公证」。 + +### 4. Windows Authenticode + +CI 已有 PFX 导入和 `Get-AuthenticodeSignature` 门禁。本机应对齐: + +1. 导入 PFX,把 thumbprint 写入 `tauri.signing.conf.json`(`digestAlgorithm: sha256`,时间戳如 `http://timestamp.digicert.com`)。 +2. 对 `.exe` / `.msi` 都验 `Status -eq Valid`,不要只签主程序。 +3. 在一台干净 Windows 上安装:SmartScreen 不应再是「未知发布者」。EV 证书对 SmartScreen 更友好,OV 可能仍有一段时间的声誉积累——如实写,不要标「已通过 SmartScreen」。 + +### 5. 自动更新(有公钥、HTTPS、回滚才开) + +架构规则已经写死:没有签名公钥、HTTPS 更新源和回滚策略,更新必须保持关闭。接入顺序: + +1. 生成 minisign 密钥;公钥进 `tauri.conf.json` 的 `plugins.updater.pubkey`;私钥只进 Secrets。 +2. 依赖 `tauri-plugin-updater`,capabilities 只开 `updater:allow-check` 与 `updater:allow-download-and-install`。 +3. 构建打开 `createUpdaterArtifacts`,每个安装包旁有 `.sig`。 +4. 发布时生成 `latest.json`(版本、各平台 URL、签名),放到 HTTPS(Gitee/GitHub Release 附件或静态镜像,建议双 endpoint)。 +5. 前端「检查更新」:**公钥和 endpoint 未进正式包之前不要画这个入口**。 +6. 回滚:保留上一份 `latest.json`;更新失败或健康检查失败时指回上一版本;文档写清如何撤回错误版本(覆盖 manifest,而不是删掉用户已装的包)。 +7. `release.py` 除 `.dmg/.exe/.msi` 外还要上传 `.sig` 与 `latest.json`。 + +未完成前,设置页、欢迎页、菜单都不要出现「自动更新」或「已是最新」。 + +### 6. 发布工作流补齐(与签名独立,但同一闭环) + +`.github/workflows/tauri-release.yml` 现在只 `upload-artifact`(约 90 天过期),`permissions.contents: read`,不能建 GitHub Release。有凭据并公证之后再改: + +- tag 与现有 Gitee 流程对齐(不要两套版本号)。 +- `contents: write` 后创建 Release,附 SHA256SUMS、安装包、`.sig`、`latest.json`。 +- 公证步骤放在「Verify native package」之前或之中;未 staple 不得当正式附件。 + +GitHub Release 不是「已公证」的替代;没有 notary/Authenticode 时,Release 也只能标开发包。 ## 产物验证 diff --git a/docs/testing-acceptance.md b/docs/testing-acceptance.md index 891846c..b8944bf 100644 --- a/docs/testing-acceptance.md +++ b/docs/testing-acceptance.md @@ -61,7 +61,7 @@ npm run acceptance:full 8. models.dev 三个源全部失败时不影响已保存 Provider;部分成功时仍能生成可导入目录。 9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。 10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。 -11. 每一轮只接受唯一单行命令;默认均衡模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;宽松模式不允许绕过 critical 硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。 +11. 每一轮只接受唯一单行命令;默认宽松模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;critical 前后端均硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。Agent 发起的 `ssh_exec` 必须带 `fromAgent`。 12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。 13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。 14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。 diff --git a/docs/user-guide.md b/docs/user-guide.md index d811abb..e077b42 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -8,6 +8,8 @@ OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默 需要长期使用时新建保存会话,选择密码或私钥认证,并按需设置分组、标签和启动命令。未填写密码或私钥的导入会话会尝试本机 SSH Agent 和常见默认密钥。 +侧边栏分组内可上下拖拽主机调整顺序。拖到另一分组的标题上会放到该组末尾;分组展开后也可以直接拖到组内某一主机的上方或下方插入。在分组标题右键可选择「按名称排序」一次性重排。顺序会保存,新建的主机排在组末尾;搜索过滤和批量多选模式下暂不可拖拽。 + 安装版支持系统链接,例如: ```text @@ -118,7 +120,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下 - 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。 - 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。 -- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认“均衡”模式仅高风险命令确认;“严格”从中风险开始确认;“宽松”仅保留 critical 硬拒绝,适合隔离测试环境。 +- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。 - 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。 - 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。 - 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。 diff --git a/license-server/.env.example b/license-server/.env.example deleted file mode 100644 index 08ae07d..0000000 --- a/license-server/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# Waffo Pancake (NEVER commit real keys) -WAFFO_MERCHANT_ID=MER_586CtDwoHUbrnFJyudo3t4 -WAFFO_PRIVATE_KEY= -WAFFO_ENVIRONMENT=test - -# Store / product (from Pancake Dashboard) -WAFFO_STORE_ID= -WAFFO_STORE_SLUG=opsxshell-pwsi6m4n -WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5 - -# Optional: permanent hosted checkout URL (preferred when set) -WAFFO_CHECKOUT_URL=https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_0pcnzzT4nalEkkcdyIUoD5?type=onetime¤cy=USD&test=true - -# One-time Pro price used by bootstrap (USD) -WAFFO_PRODUCT_PRICE=29.00 - -# Must match desktop verify key (src-tauri license service DEFAULT / OPSX_LICENSE_HMAC) -OPSX_LICENSE_HMAC=opsxshell-dev-license-hmac-v1-change-in-prod - -PORT=8787 -SUCCESS_URL=opsxshell://pro/activated -PUBLIC_BASE_URL=http://127.0.0.1:8787 diff --git a/license-server/.gitignore b/license-server/.gitignore deleted file mode 100644 index c44e23f..0000000 --- a/license-server/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -.env -.env.local -data/ -*.log diff --git a/license-server/README.md b/license-server/README.md deleted file mode 100644 index e23404b..0000000 --- a/license-server/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# OpsXShell License Server - -> **DEPRECATED** — 桌面端已切换到 **opsx-licence**(默认 `http://127.0.0.1:9001/api`)。本目录保留供对照与临时本地调试,**请勿用于新功能开发**。新结账 / 领取 / 激活请走 opsx-licence 的 `/api/v1/*` 契约。 - -轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发(旧 HMAC `opsx1` 方案)。 - -**私钥只放本机环境变量,不要写入仓库或桌面端。** - -## 快速开始 - -```bash -cd license-server -cp .env.example .env -# 填入 WAFFO_PRIVATE_KEY(Dashboard 测试密钥) -npm install -npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env -npm run start -``` - -旧桌面端曾默认请求 `http://127.0.0.1:8787`。当前桌面默认已改为 opsx-licence:`http://127.0.0.1:9001/api`(`VITE_LICENSE_SERVER_URL` / `OPSX_LICENSE_API_BASE`)。 - -## 接口 - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/health` | 健康检查 | -| POST | `/checkout` | `{ deviceId }` → `{ checkoutUrl }`(onetime) | -| POST | `/webhooks` | Waffo webhook(`order.completed`) | -| POST | `/activate` | `{ deviceId }` 取回已签发许可证 | -| POST | `/issue-test-license` | 开发用:直接签发终身 Pro token | - -## 测试许可证(无需支付) - -```bash -npm run issue-dev-license -- --deviceId '*' -# 临时授权可加 --days 30 -``` - -将返回的 `token` 粘贴到 App「已有许可证?激活」(仅旧 HMAC 路径;新客户端需 `LIC-` 密钥)。 - -## 安全 - -- `@waffo/pancake-ts` 仅在本服务使用 -- `OPSX_LICENSE_HMAC` 与旧桌面端校验密钥一致(生产请轮换) -- 对话中若暴露过测试私钥,请在 Dashboard **轮换** diff --git a/license-server/package-lock.json b/license-server/package-lock.json deleted file mode 100644 index fc9caff..0000000 --- a/license-server/package-lock.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "opsxshell-license-server", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "opsxshell-license-server", - "version": "1.0.0", - "dependencies": { - "@waffo/pancake-ts": "^0.1.9" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@waffo/pancake-ts": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@waffo/pancake-ts/-/pancake-ts-0.1.9.tgz", - "integrity": "sha512-Gbb/Bbe6JqF4m1L+NKaAcGEA9RY4+kceVKgIA3uwZqdP8skTmi7PIUDXdPTAeRYmx9uZUqkKLhoHFJ1H2K88JA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - } - } -} diff --git a/license-server/package.json b/license-server/package.json deleted file mode 100644 index fac4641..0000000 --- a/license-server/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "opsxshell-license-server", - "version": "1.0.0", - "private": true, - "type": "module", - "description": "OpsXShell Pro license + Waffo Pancake checkout (server-side only)", - "engines": { - "node": ">=20" - }, - "scripts": { - "dev": "node --watch src/server.mjs", - "start": "node src/server.mjs", - "bootstrap": "node scripts/bootstrap-products.mjs", - "issue-dev-license": "node scripts/issue-dev-license.mjs" - }, - "dependencies": { - "@waffo/pancake-ts": "^0.1.9" - } -} diff --git a/license-server/scripts/bootstrap-products.mjs b/license-server/scripts/bootstrap-products.mjs deleted file mode 100644 index d1985a1..0000000 --- a/license-server/scripts/bootstrap-products.mjs +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -/** - * First-time bootstrap: create Store "OpsXShell" and one-time Pro product, - * then write IDs into .env (never logs the private key). - */ -import { createRequire } from 'node:module' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT = join(__dirname, '..') -const ENV_PATH = join(ROOT, '.env') - -function loadEnv() { - if (!existsSync(ENV_PATH)) { - console.error('缺少 .env — 请先 cp .env.example .env 并填入 WAFFO_PRIVATE_KEY') - process.exit(1) - } - const raw = readFileSync(ENV_PATH, 'utf8') - for (const line of raw.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eq = trimmed.indexOf('=') - if (eq <= 0) continue - const key = trimmed.slice(0, eq).trim() - let value = trimmed.slice(eq + 1).trim() - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) - } - if (!(key in process.env)) process.env[key] = value - } -} - -function upsertEnv(key, value) { - let raw = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '' - const line = `${key}=${value}` - const re = new RegExp(`^${key}=.*$`, 'm') - if (re.test(raw)) raw = raw.replace(re, line) - else raw = `${raw.trimEnd()}\n${line}\n` - writeFileSync(ENV_PATH, raw) -} - -loadEnv() - -const merchantId = process.env.WAFFO_MERCHANT_ID -const privateKey = process.env.WAFFO_PRIVATE_KEY -if (!merchantId || !privateKey) { - console.error('需要 WAFFO_MERCHANT_ID 与 WAFFO_PRIVATE_KEY') - process.exit(1) -} - -const require = createRequire(import.meta.url) -const { WaffoPancake } = require('@waffo/pancake-ts') -const client = new WaffoPancake({ - merchantId, - privateKey, - ...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}), -}) - -async function main() { - let storeId = process.env.WAFFO_STORE_ID - if (!storeId) { - console.log('Creating store OpsXShell…') - const store = await client.stores.create({ - name: 'OpsXShell', - slug: `opsxshell-${Date.now().toString(36)}`, - }) - storeId = store.id || store.storeId - if (!storeId) throw new Error(`stores.create 未返回 id: ${JSON.stringify(store)}`) - upsertEnv('WAFFO_STORE_ID', storeId) - console.log('Store:', storeId) - } else { - console.log('Using existing store:', storeId) - } - - if (process.env.WAFFO_PRODUCT_ID) { - console.log(`Using WAFFO_PRODUCT_ID=${process.env.WAFFO_PRODUCT_ID}`) - console.log('\nBootstrap complete.') - return - } - - const price = process.env.WAFFO_PRODUCT_PRICE || '29.00' - console.log(`Creating one-time product OpsXShell Pro (USD ${price})…`) - const { product } = await client.onetimeProducts.create({ - storeId, - name: 'OpsXShell Pro', - description: 'One-time unlock for OpsXShell professional features', - prices: { - USD: { amount: price, taxCategory: 'software' }, - }, - successUrl: process.env.SUCCESS_URL || 'opsxshell://pro/activated', - metadata: { app: 'opsxshell', license: 'lifetime' }, - }) - const id = product?.id - if (!id) throw new Error(`create onetime product failed: ${JSON.stringify(product)}`) - - if (client.onetimeProducts.publish) { - await client.onetimeProducts.publish({ id }).catch(() => undefined) - } - upsertEnv('WAFFO_PRODUCT_ID', id) - console.log('WAFFO_PRODUCT_ID', id) - console.log('\nBootstrap complete. Product ID written to .env') - console.log('Next: npm start') -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/license-server/scripts/issue-dev-license.mjs b/license-server/scripts/issue-dev-license.mjs deleted file mode 100644 index 77e5c4d..0000000 --- a/license-server/scripts/issue-dev-license.mjs +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -import { createHmac } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT = join(__dirname, '..') - -function loadEnv() { - const envPath = join(ROOT, '.env') - if (!existsSync(envPath)) return - for (const line of readFileSync(envPath, 'utf8').split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eq = trimmed.indexOf('=') - if (eq <= 0) continue - const key = trimmed.slice(0, eq).trim() - let value = trimmed.slice(eq + 1).trim() - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) - } - if (!(key in process.env)) process.env[key] = value - } -} - -loadEnv() - -const args = process.argv.slice(2) -function arg(name, fallback) { - const idx = args.indexOf(`--${name}`) - if (idx >= 0 && args[idx + 1]) return args[idx + 1] - return fallback -} - -const deviceId = arg('deviceId', '*') -const daysArg = arg('days', null) -const secret = - process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod' - -function b64url(buf) { - return Buffer.from(buf) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, '') -} - -const now = Math.floor(Date.now() / 1000) -const payload = { - plan: 'pro', - deviceId, - iat: now, - features: ['*'], -} -if (daysArg != null) { - payload.exp = now + Number(daysArg) * 86400 -} -const payloadB64 = b64url(JSON.stringify(payload)) -const sig = createHmac('sha256', secret).update(payloadB64).digest() -const token = `opsx1.${payloadB64}.${b64url(sig)}` - -console.log(JSON.stringify({ token, payload }, null, 2)) diff --git a/license-server/src/server.mjs b/license-server/src/server.mjs deleted file mode 100644 index 9110f2d..0000000 --- a/license-server/src/server.mjs +++ /dev/null @@ -1,333 +0,0 @@ -import { createHmac } from 'node:crypto' -import { createServer } from 'node:http' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT = join(__dirname, '..') -const DATA_DIR = join(ROOT, 'data') -const ENTITLEMENTS_PATH = join(DATA_DIR, 'entitlements.json') - -function loadEnv() { - const envPath = join(ROOT, '.env') - if (!existsSync(envPath)) return - const raw = readFileSync(envPath, 'utf8') - for (const line of raw.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eq = trimmed.indexOf('=') - if (eq <= 0) continue - const key = trimmed.slice(0, eq).trim() - let value = trimmed.slice(eq + 1).trim() - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) - } - if (!(key in process.env)) process.env[key] = value - } -} - -loadEnv() - -const require = createRequire(import.meta.url) -let WaffoPancake -let verifyWebhook -try { - const sdk = require('@waffo/pancake-ts') - WaffoPancake = sdk.WaffoPancake - verifyWebhook = sdk.verifyWebhook -} catch (error) { - console.warn('[opsxshell-license] @waffo/pancake-ts not installed yet:', error.message) -} - -const PORT = Number(process.env.PORT || 8787) -const LICENSE_HMAC = - process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod' -const SUCCESS_URL = process.env.SUCCESS_URL || 'opsxshell://pro/activated' - -function b64url(buf) { - return Buffer.from(buf) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, '') -} - -function issueLicense({ deviceId, days = null, features = ['*'] }) { - const now = Math.floor(Date.now() / 1000) - const payload = { - plan: 'pro', - deviceId: deviceId || '*', - iat: now, - features, - } - // Lifetime (one-time) licenses omit exp. Pass days for temporary/test tokens. - if (days != null && Number(days) > 0) { - payload.exp = now + Math.max(1, Number(days)) * 86400 - } - const payloadB64 = b64url(JSON.stringify(payload)) - const sig = createHmac('sha256', LICENSE_HMAC).update(payloadB64).digest() - return { token: `opsx1.${payloadB64}.${b64url(sig)}`, payload } -} - -async function loadEntitlements() { - try { - return JSON.parse(await readFile(ENTITLEMENTS_PATH, 'utf8')) - } catch { - return { byDevice: {}, byOrder: {}, pending: {} } - } -} - -async function saveEntitlements(data) { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(ENTITLEMENTS_PATH, JSON.stringify(data, null, 2)) -} - -function getClient() { - if (!WaffoPancake) throw new Error('请先在 license-server 目录 npm install @waffo/pancake-ts') - const merchantId = process.env.WAFFO_MERCHANT_ID - const privateKey = process.env.WAFFO_PRIVATE_KEY - if (!merchantId || !privateKey) { - throw new Error('缺少 WAFFO_MERCHANT_ID / WAFFO_PRIVATE_KEY(见 .env.example)') - } - return new WaffoPancake({ - merchantId, - privateKey, - ...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}), - }) -} - -function productId() { - return process.env.WAFFO_PRODUCT_ID || '' -} - -function buildHostedCheckoutUrl(deviceId) { - const success = `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}` - if (process.env.WAFFO_CHECKOUT_URL) { - const url = new URL(process.env.WAFFO_CHECKOUT_URL) - if (!url.searchParams.get('successUrl')) url.searchParams.set('successUrl', success) - url.searchParams.set('deviceId', deviceId) - return url.toString() - } - const slug = process.env.WAFFO_STORE_SLUG - const pid = productId() - if (slug && pid) { - const url = new URL(`https://pancake.waffo.ai/store/${slug}/product/${pid}`) - url.searchParams.set('type', 'onetime') - url.searchParams.set('currency', 'USD') - if ((process.env.WAFFO_ENVIRONMENT || 'test') === 'test') { - url.searchParams.set('test', 'true') - } - url.searchParams.set('successUrl', success) - url.searchParams.set('deviceId', deviceId) - return url.toString() - } - return null -} - -async function readBody(req) { - const chunks = [] - for await (const chunk of req) chunks.push(chunk) - const raw = Buffer.concat(chunks) - const text = raw.toString('utf8') - let json = null - try { - json = text ? JSON.parse(text) : {} - } catch { - json = null - } - return { text, json } -} - -function send(res, status, body) { - const payload = typeof body === 'string' ? body : JSON.stringify(body) - res.writeHead(status, { - 'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, X-Waffo-Signature', - 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', - }) - res.end(payload) -} - -const server = createServer(async (req, res) => { - if (req.method === 'OPTIONS') { - send(res, 204, '') - return - } - - const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`) - - try { - if (req.method === 'GET' && url.pathname === '/health') { - send(res, 200, { - ok: true, - hasWaffoKey: Boolean(process.env.WAFFO_PRIVATE_KEY), - sdk: Boolean(WaffoPancake), - productId: Boolean(productId()), - pricing: 'onetime', - }) - return - } - - if (req.method === 'POST' && url.pathname === '/checkout') { - const { json } = await readBody(req) - const deviceId = String(json?.deviceId || '').trim() - if (!deviceId) { - send(res, 400, { error: 'deviceId 必填' }) - return - } - - const pid = productId() - const storeId = process.env.WAFFO_STORE_ID - let checkoutUrl = null - let mode = 'hosted' - - // Prefer API session when merchant key + store/product IDs are ready - if (pid && storeId && process.env.WAFFO_PRIVATE_KEY) { - try { - const client = getClient() - const session = await client.checkout.createSession({ - storeId, - productId: pid, - productType: 'onetime', - currency: 'USD', - successUrl: `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`, - darkMode: true, - metadata: { deviceId, app: 'opsxshell', license: 'lifetime' }, - }) - checkoutUrl = session.checkoutUrl || session.url - mode = 'session' - } catch (error) { - console.warn('[checkout] createSession failed, falling back to hosted URL:', error.message) - } - } - - if (!checkoutUrl) { - checkoutUrl = buildHostedCheckoutUrl(deviceId) - } - - if (!checkoutUrl) { - send(res, 500, { - error: - '未配置结账信息。请在 license-server/.env 填写 WAFFO_PRODUCT_ID 与 WAFFO_STORE_SLUG(或 WAFFO_CHECKOUT_URL)', - }) - return - } - - const store = await loadEntitlements() - store.pending = store.pending || {} - store.pending[deviceId] = { - productId: pid || null, - createdAt: Date.now(), - mode, - } - await saveEntitlements(store) - send(res, 200, { checkoutUrl, deviceId, pricing: 'onetime', mode }) - return - } - - if (req.method === 'POST' && url.pathname === '/webhooks') { - const { text } = await readBody(req) - if (!verifyWebhook) { - send(res, 500, { error: 'SDK verifyWebhook 不可用' }) - return - } - const signature = String(req.headers['x-waffo-signature'] || '') - let event - try { - event = verifyWebhook(text, signature) - } catch (error) { - send(res, 401, { error: 'Invalid signature', detail: String(error) }) - return - } - - const eventType = String(event.eventType || event.type || '') - const data = event.data || {} - const metadata = data.metadata || {} - const deviceId = metadata.deviceId || data.deviceId - const orderId = data.orderId || data.id - const store = await loadEntitlements() - - if ( - eventType.includes('order.completed') || - eventType.includes('OrderCompleted') - ) { - if (deviceId) { - const issued = issueLicense({ deviceId }) - store.byDevice[deviceId] = { - status: 'active', - orderId, - token: issued.token, - updatedAt: Date.now(), - } - } - if (orderId) { - store.byOrder = store.byOrder || {} - store.byOrder[orderId] = { - status: 'completed', - deviceId, - updatedAt: Date.now(), - } - } - } - - await saveEntitlements(store) - send(res, 200, 'OK') - return - } - - if (req.method === 'POST' && url.pathname === '/activate') { - const { json } = await readBody(req) - const deviceId = String(json?.deviceId || '').trim() - if (!deviceId) { - send(res, 400, { error: 'deviceId 必填' }) - return - } - const store = await loadEntitlements() - const existing = store.byDevice[deviceId] - if (existing?.status === 'active' && existing.token) { - send(res, 200, { token: existing.token, status: 'active' }) - return - } - if (json?.forceIssue) { - const days = json.days != null ? Number(json.days) : null - const issued = issueLicense({ deviceId, days }) - store.byDevice[deviceId] = { - status: 'active', - token: issued.token, - updatedAt: Date.now(), - } - await saveEntitlements(store) - send(res, 200, { token: issued.token, status: 'active' }) - return - } - send(res, 404, { error: '尚未收到付款完成事件,请完成支付或使用测试许可证' }) - return - } - - if (req.method === 'POST' && url.pathname === '/issue-test-license') { - const { json } = await readBody(req) - const deviceId = String(json?.deviceId || '*').trim() || '*' - // Default: lifetime. Pass days for temporary tokens. - const days = json?.days != null ? Number(json.days) : null - send(res, 200, issueLicense({ deviceId, days })) - return - } - - send(res, 404, { error: 'not found' }) - } catch (error) { - console.error(error) - send(res, 500, { error: error instanceof Error ? error.message : String(error) }) - } -}) - -server.listen(PORT, '127.0.0.1', () => { - console.log(`[opsxshell-license] http://127.0.0.1:${PORT}`) - console.log(`[opsxshell-license] waffo key: ${Boolean(process.env.WAFFO_PRIVATE_KEY)}`) -}) diff --git a/package.json b/package.json index 152b819..4a2167a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "acceptance:quick": "node scripts/acceptance.mjs quick", "acceptance:full": "node scripts/acceptance.mjs full", "clean": "rimraf dist src-tauri/target/release release", - "prepare": "husky install || true", + "prepare": "husky", "dist": "tauri build", "dist:win": "tauri build --target x86_64-pc-windows-msvc", "dist:mac": "tauri build --target aarch64-apple-darwin", diff --git a/scripts/build-script.test.mjs b/scripts/build-script.test.mjs index 9f9eba9..76e09e0 100644 --- a/scripts/build-script.test.mjs +++ b/scripts/build-script.test.mjs @@ -17,6 +17,7 @@ test('build.sh is valid Bash and documents supported native targets', () => { assert.equal(help.status, 0, help.stderr) assert.match(help.stdout, /mac-arm64/) assert.match(help.stdout, /win-x64/) + assert.match(help.stdout, /linux-x64/) assert.match(help.stdout, /不会创建或推送 Git tag/) }) diff --git a/scripts/build.sh b/scripts/build.sh index b83a214..2f435f0 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -25,6 +25,8 @@ OpsXShell 原生安装包构建脚本 mac-arm64 macOS Apple Silicon,生成 .app 和 .dmg mac-x64 macOS Intel,生成 .app 和 .dmg win-x64 Windows x86_64,生成 NSIS/MSI 安装包 + linux-x64 Linux x86_64,生成 deb/rpm/AppImage + linux-arm64 Linux aarch64,生成 deb/rpm/AppImage 选项: --quick 构建前只运行快速门禁 @@ -39,7 +41,7 @@ OpsXShell 原生安装包构建脚本 说明: - 显式传入新版本时,会同步 package.json、package-lock.json、 Cargo.toml、Cargo.lock 和 tauri.conf.json,但不会创建或推送 Git tag。 - - Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建。 + - Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建;Linux 包必须在 Linux 上构建。 - 正式签名构建请先设置 REQUIRE_SIGNING=true 及对应签名环境变量。 EOF } @@ -65,7 +67,7 @@ while [ "$#" -gt 0 ]; do --skip-checks) CHECK_MODE="none" ;; - auto|mac-arm64|mac-x64|win-x64) + auto|mac-arm64|mac-x64|win-x64|linux-x64|linux-arm64) TARGET="$1" ;; v[0-9]*|[0-9]*) @@ -97,6 +99,8 @@ if [ "$TARGET" = "auto" ]; then Darwin/arm64|Darwin/aarch64) TARGET="mac-arm64" ;; Darwin/x86_64) TARGET="mac-x64" ;; MINGW*/*|MSYS*/*|CYGWIN*/*) TARGET="win-x64" ;; + Linux/x86_64) TARGET="linux-x64" ;; + Linux/aarch64|Linux/arm64) TARGET="linux-arm64" ;; *) fail "当前平台 $HOST_OS/$HOST_ARCH 不支持自动打包,请显式指定目标" ;; esac fi @@ -123,6 +127,18 @@ case "$TARGET" in RELEASE_PLATFORM="windows" OUTPUT_PLATFORM="windows-x64" ;; + linux-x64) + [ "$HOST_OS" = "Linux" ] || fail "linux-x64 必须在 Linux 上构建" + RUST_TARGET="x86_64-unknown-linux-gnu" + RELEASE_PLATFORM="linux" + OUTPUT_PLATFORM="linux-x64" + ;; + linux-arm64) + [ "$HOST_OS" = "Linux" ] || fail "linux-arm64 必须在 Linux 上构建" + RUST_TARGET="aarch64-unknown-linux-gnu" + RELEASE_PLATFORM="linux" + OUTPUT_PLATFORM="linux-arm64" + ;; esac if command -v rustup >/dev/null 2>&1 && ! rustup target list --installed | grep -Fxq "$RUST_TARGET"; then @@ -177,6 +193,8 @@ shopt -s nullglob COPIED_ARTIFACTS=() if [ "$RELEASE_PLATFORM" = "macos" ]; then SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/dmg/*.dmg) +elif [ "$RELEASE_PLATFORM" = "linux" ]; then + SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/deb/*.deb "$BUNDLE_ROOT"/rpm/*.rpm "$BUNDLE_ROOT"/appimage/*.AppImage) else SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/nsis/*.exe "$BUNDLE_ROOT"/msi/*.msi "$BUNDLE_ROOT"/wix/*.msi) fi diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 4cad782..5a96779 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,8 +1,8 @@ import { missingSigningVariables } from './release-tools.mjs' -const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '') +const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform === 'linux' ? 'linux' : '') if (!platform) { - console.error('[release] Only macOS and Windows release targets are supported') + console.error('[release] Only macOS, Windows, and Linux release targets are supported') process.exit(2) } diff --git a/scripts/release-tools.mjs b/scripts/release-tools.mjs index 1382f2c..04c027b 100644 --- a/scripts/release-tools.mjs +++ b/scripts/release-tools.mjs @@ -10,6 +10,7 @@ export const REQUIRED_SIGNING_ENV = { 'APPLE_TEAM_ID', ], windows: ['WINDOWS_CERTIFICATE_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD'], + linux: [], } export function missingSigningVariables(platform, env = process.env) { diff --git a/scripts/release.py b/scripts/release.py index a6bb929..854b758 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -21,7 +21,7 @@ import requests API_BASE = "https://gitee.com/api/v5" DEFAULT_REPO = "opsx-tools/opsxshell" -ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi"} +ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi", ".deb", ".rpm", ".appimage"} CHECKSUM_NAMES = {"SHA256SUMS.txt"} MAX_ASSET_BYTES = 100 * 1024 * 1024 diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index dcd3b6c..070044f 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { assertWithinSize, readPeMachine } from './release-tools.mjs' -const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '') +const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform === 'linux' ? 'linux' : '') const root = process.argv[3] ?? (platform === 'macos' ? 'src-tauri/target/aarch64-apple-darwin/release/bundle' : 'src-tauri/target/release/bundle') @@ -57,6 +57,11 @@ if (platform === 'macos') { } for (const installer of installers) assertWithinSize(installer, maxInstallerBytes) console.log(`[release] Windows x64 artifacts verified: ${root}`) +} else if (platform === 'linux') { + const installers = files.filter((path) => /\.(?:deb|rpm|appimage)$/i.test(path)) + if (installers.length === 0) fail('Linux release must contain a .deb, .rpm, or .AppImage') + for (const installer of installers) assertWithinSize(installer, maxInstallerBytes) + console.log(`[release] Linux artifacts verified: ${root}`) } else { fail(`Unsupported platform: ${platform}`) } diff --git a/shared/types/session.types.ts b/shared/types/session.types.ts index fab396b..1fa124d 100644 --- a/shared/types/session.types.ts +++ b/shared/types/session.types.ts @@ -70,11 +70,11 @@ export interface Session { authType: AuthType /** Defaults to ssh when omitted (legacy sessions). */ protocol?: SessionProtocol - password?: string // 加密存储 + password?: string // 加密存储 /** 私钥路径或 PEM 正文;落盘时由 Rust 加密 */ privateKey?: string - passphrase?: string // 私钥密码(加密存储) - proxy?: ProxyConfig // 代理配置 + passphrase?: string // 私钥密码(加密存储) + proxy?: ProxyConfig // 代理配置 /** 连接成功后自动执行的启动命令 */ startupCommand?: string /** 单跳板会话 ID(兼容) */ @@ -101,10 +101,19 @@ export interface Session { osPrettyName?: string group?: string tags?: string[] + /** Manual display order within the group; missing rows keep storage order after ordered ones. */ + sortOrder?: number createdAt?: number updatedAt?: number } +/** One row of a batch sidebar reorder: position and optional group move. `group: null` moves back to default. */ +export interface SessionReorderEntry { + id: string + sortOrder: number + group?: string | null +} + // 会话创建请求 export interface SessionCreateRequest { name: string diff --git a/src-tauri/src/commands/license.rs b/src-tauri/src/commands/license.rs index 482c1ed..749a1f1 100644 --- a/src-tauri/src/commands/license.rs +++ b/src-tauri/src/commands/license.rs @@ -2,41 +2,58 @@ use tauri::AppHandle; use crate::services::license::{self, CheckoutResult, LicenseStatus}; -#[tauri::command] -pub fn license_get(app: AppHandle) -> Result { - license::read_status(&app) +async fn license_blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(work) + .await + .map_err(|error| format!("授权任务失败: {error}"))? } #[tauri::command] -pub fn license_activate( +pub async fn license_get(app: AppHandle) -> Result { + license_blocking(move || license::read_status(&app)).await +} + +#[tauri::command] +pub async fn license_activate( app: AppHandle, token: String, email: Option, ) -> Result { - license::activate(&app, &token, email.as_deref()) + license_blocking(move || license::activate(&app, &token, email.as_deref())).await } #[tauri::command] -pub fn license_clear(app: AppHandle) -> Result { - license::clear(&app) +pub async fn license_clear(app: AppHandle) -> Result { + license_blocking(move || license::clear(&app)).await +} + +/// 周检:联网重验在线授权(验签/时钟/宽限期)并刷新本地缓存 // Logan.Li +#[tauri::command] +pub async fn license_verify(app: AppHandle) -> Result { + license_blocking(move || license::verify(&app)).await } #[tauri::command] -pub fn license_device_id(app: AppHandle) -> Result { - license::get_or_create_device_id(&app) +pub async fn license_device_id(app: AppHandle) -> Result { + license_blocking(move || license::get_or_create_device_id(&app)).await } #[tauri::command] -pub fn license_set_email(app: AppHandle, email: String) -> Result { - license::set_email(&app, &email) +pub async fn license_set_email(app: AppHandle, email: String) -> Result { + license_blocking(move || license::set_email(&app, &email)).await } #[tauri::command] -pub fn license_checkout( +pub async fn license_checkout( app: AppHandle, customer_email: String, plan_type: String, return_url: Option, ) -> Result { - license::checkout(&app, &customer_email, &plan_type, return_url.as_deref()) + license_blocking(move || { + license::checkout(&app, &customer_email, &plan_type, return_url.as_deref()) + }) + .await } diff --git a/src-tauri/src/commands/local_terminal.rs b/src-tauri/src/commands/local_terminal.rs index 581cc39..2ef6701 100644 --- a/src-tauri/src/commands/local_terminal.rs +++ b/src-tauri/src/commands/local_terminal.rs @@ -22,6 +22,17 @@ pub fn local_create_shell( .create(app, options.unwrap_or_default(), preferred_id) } +#[tauri::command] +pub async fn local_open_output( + state: State<'_, AppState>, + shell_id: String, +) -> Result, String> { + let service = state.local_terminal.clone(); + tauri::async_runtime::spawn_blocking(move || service.open_output(&shell_id)) + .await + .map_err(|error| format!("读取本地启动输出失败: {error}"))? +} + #[tauri::command] pub fn local_write( state: State<'_, AppState>, @@ -45,3 +56,8 @@ pub fn local_resize( pub fn local_kill(state: State<'_, AppState>, shell_id: String) -> Result<(), String> { state.local_terminal.kill(&shell_id) } + +#[tauri::command] +pub fn local_kill_all(state: State<'_, AppState>) -> Result { + Ok(state.local_terminal.kill_all()) +} diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 11a6c2e..73debd9 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -25,17 +25,24 @@ pub async fn ssh_connect( } #[tauri::command] -pub fn ssh_disconnect( +pub async fn ssh_disconnect( app: AppHandle, state: State<'_, AppState>, connection_id: String, retain_forwards: Option, -) -> OperationResponse { - operation( - state - .ssh - .disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)), - ) +) -> 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) + }) + .await + { + Ok(result) => Ok(operation(result)), + Err(error) => Ok(OperationResponse::error(format!( + "断开连接任务失败: {error}" + ))), + } } #[tauri::command] @@ -43,7 +50,18 @@ pub async fn ssh_exec( state: State<'_, AppState>, connection_id: String, command: String, + from_agent: Option, ) -> Result { + if from_agent.unwrap_or(false) { + if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) { + return Ok(ExecResponse { + success: false, + result: None, + exit_code: None, + error: Some(error), + }); + } + } let service = state.ssh.clone(); Ok( match tauri::async_runtime::spawn_blocking(move || { @@ -93,8 +111,15 @@ pub async fn ssh_host_metrics( } #[tauri::command] -pub fn ssh_keepalive(state: State<'_, AppState>, connection_id: String) -> OperationResponse { - operation(state.ssh.keepalive(&connection_id)) +pub async fn ssh_keepalive( + state: State<'_, AppState>, + connection_id: String, +) -> Result { + 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}"))), + } } #[tauri::command] @@ -127,10 +152,17 @@ pub async fn ssh_create_shell( } #[tauri::command] -pub fn ssh_shell_open_output(state: State<'_, AppState>, shell_id: String) -> ShellOutputResponse { - match state.ssh.shell_open_output(&shell_id) { - Ok(data) => ShellOutputResponse::ok(data), - Err(error) => ShellOutputResponse::error(error), +pub async fn ssh_shell_open_output( + state: State<'_, AppState>, + shell_id: String, +) -> Result { + let service = state.ssh.clone(); + match tauri::async_runtime::spawn_blocking(move || service.shell_open_output(&shell_id)).await { + Ok(Ok(data)) => Ok(ShellOutputResponse::ok(data)), + Ok(Err(error)) => Ok(ShellOutputResponse::error(error)), + Err(error) => Ok(ShellOutputResponse::error(format!( + "读取启动输出失败: {error}" + ))), } } diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index f286b60..eec7a09 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -116,6 +116,18 @@ pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) -> } } +#[tauri::command] +pub 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), + } +} + #[tauri::command] pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse { match state.storage.get_setting(&app, &key) { diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index d78477d..55b06a5 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -89,6 +89,9 @@ pub fn clipboard_write_text(text: String) -> Result<(), String> { #[tauri::command] pub fn toggle_devtools(app: AppHandle) -> Result { + if !cfg!(debug_assertions) { + return Err("devtools are disabled in release builds".to_string()); + } let window = app .get_webview_window("main") .ok_or_else(|| "主窗口不存在".to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef4dddf..1361cff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,9 +76,11 @@ pub fn run() { commands::deep_link::take_pending_ssh_links, commands::local_terminal::local_list_shells, commands::local_terminal::local_create_shell, + commands::local_terminal::local_open_output, commands::local_terminal::local_write, commands::local_terminal::local_resize, commands::local_terminal::local_kill, + commands::local_terminal::local_kill_all, commands::serial::serial_list_ports, commands::serial::serial_open, commands::serial::serial_write, @@ -131,6 +133,7 @@ pub fn run() { commands::storage::session_create, commands::storage::session_update, commands::storage::session_delete, + commands::storage::session_reorder, commands::storage::settings_get, commands::storage::settings_set, commands::files::show_open_dialog, @@ -153,6 +156,7 @@ pub fn run() { commands::license::license_get, commands::license::license_activate, commands::license::license_clear, + commands::license::license_verify, commands::license::license_device_id, commands::license::license_set_email, commands::license::license_checkout, @@ -226,6 +230,9 @@ pub fn run() { let _ = app.emit("menu:open-settings", ()); } "toggle-devtools" => { + if !cfg!(debug_assertions) { + return; + } if let Some(window) = app.get_webview_window("main") { if window.is_devtools_open() { window.close_devtools(); diff --git a/src-tauri/src/native_menu.rs b/src-tauri/src/native_menu.rs index 2da5cec..d296eec 100644 --- a/src-tauri/src/native_menu.rs +++ b/src-tauri/src/native_menu.rs @@ -355,18 +355,24 @@ pub fn build_menu(app: &AppHandle, locale: &str) -> tauri::Result let settings = MenuItemBuilder::with_id("open-settings", t.settings) .accelerator("CmdOrCtrl+,") .build(app)?; - let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools) - .accelerator(if cfg!(target_os = "macos") { - "CmdOrCtrl+Alt+I" - } else { - "F12" - }) - .build(app)?; - let view_menu = SubmenuBuilder::new(app, t.view) - .item(&settings) - .separator() - .item(&devtools) - .build()?; + let view_menu = { + let builder = SubmenuBuilder::new(app, t.view).item(&settings); + #[cfg(debug_assertions)] + { + let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools) + .accelerator(if cfg!(target_os = "macos") { + "CmdOrCtrl+Alt+I" + } else { + "F12" + }) + .build(app)?; + builder.separator().item(&devtools).build()? + } + #[cfg(not(debug_assertions))] + { + builder.build()? + } + }; #[cfg(any(target_os = "macos", target_os = "windows"))] let window_menu = SubmenuBuilder::new(app, t.window) diff --git a/src-tauri/src/services/ai/guardrail.rs b/src-tauri/src/services/ai/guardrail.rs new file mode 100644 index 0000000..9ad28e3 --- /dev/null +++ b/src-tauri/src/services/ai/guardrail.rs @@ -0,0 +1,55 @@ +//! Critical-command denylist for Agent-originated `ssh_exec`. +//! Interactive user shells and batch commands are not filtered here. + +pub fn reject_critical(command: &str) -> Result<(), String> { + if is_critical(command) { + return Err("agent guardrail rejected a critical command".to_string()); + } + Ok(()) +} + +fn is_critical(command: &str) -> bool { + let text = command.trim(); + if text.is_empty() { + return false; + } + CRITICAL_PATTERNS + .iter() + .any(|pattern| pattern.is_match(text)) +} + +static CRITICAL_PATTERNS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + [ + r"(?i)\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+(?:--\s+)?/(?:\s|$|\*)", + r"(?i)\brm\s+[^\n;&|]*-[a-z]*f[a-z]*r[a-z]*\s+(?:--\s+)?/(?:\s|$|\*)", + r"(?i)\brm\s+[^\n;&|]*-[a-z]*r[a-z]*f[a-z]*\s+/(?:bin|boot|etc|home|lib|lib64|proc|root|sbin|sys|usr|var)(?:/|\s|$)", + r"(?i)\bmkfs(?:\.[a-z0-9]+)?\b", + r"(?i)\bdd\s+[^\n;&|]*\bif=/dev/(?:zero|random|urandom)\b[^\n;&|]*\bof=/dev/", + r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:", + r"(?i)(?:^|[;&|]\s*)>\s*/dev/(?:sd[a-z]|hd[a-z]|nvme\d+n\d+)(?:\s|$)", + r"(?i)\bchmod\s+-R\s+(?:000|777)\s+/(?:\s|$)", + r"(?i)\bkill\s+-9\s+1(?:\s|$)", + r"(?i)\bsystemctl\s+(?:stop|disable)\s+(?:ssh|sshd)(?:\.service)?(?:\s|$)", + r"(?i)\biptables\s+(?:-F|--flush)(?:\s|$)", + ] + .into_iter() + .map(|pattern| regex::Regex::new(pattern).expect("valid critical pattern")) + .collect() +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_recursive_root_delete() { + assert!(reject_critical("rm -rf /").is_err()); + assert!(reject_critical("rm -rf /etc").is_err()); + } + + #[test] + fn allows_ordinary_commands() { + assert!(reject_critical("ls /etc").is_ok()); + assert!(reject_critical("rm -rf ./tmp").is_ok()); + } +} diff --git a/src-tauri/src/services/ai/mod.rs b/src-tauri/src/services/ai/mod.rs index 6dfeadc..e17104c 100644 --- a/src-tauri/src/services/ai/mod.rs +++ b/src-tauri/src/services/ai/mod.rs @@ -1,3 +1,4 @@ +pub mod guardrail; mod redaction; use std::time::Duration; diff --git a/src-tauri/src/services/license.rs b/src-tauri/src/services/license.rs index 7aa9dc6..e36d782 100644 --- a/src-tauri/src/services/license.rs +++ b/src-tauri/src/services/license.rs @@ -15,9 +15,15 @@ use serde_json::Value; use tauri::{AppHandle, Manager}; use uuid::Uuid; -/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li -const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api"; +/// Embedded opsx-licence API roots (must end with `/api`, never include `/v1`). +/// 双节点容灾:香港主节点 + 国内备份,编译进二进制且不在界面展示。 +/// 运行时按序尝试,网络层失败(连接/超时)才切换,业务响应不切换。 // Logan.Li +const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"]; const HTTP_TIMEOUT: Duration = Duration::from_secs(15); +/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验 // Logan.Li +const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400; +/// 时钟回拨容忍(秒):本机时间允许比最近一次服务端时间慢 5 分钟 // Logan.Li +const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300; /// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str = @@ -45,6 +51,12 @@ struct StoredLicense { plan_type: Option, #[serde(default)] last_verified_at: u64, + /// 设备激活令牌:服务端激活时一次性下发,解绑本机必须携带 // Logan.Li + #[serde(default, skip_serializing_if = "Option::is_none")] + activation_token: Option, + /// 最近一次见到的服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li + #[serde(default)] + last_server_time: u64, } fn default_plan() -> String { @@ -69,6 +81,13 @@ pub struct LicenseStatus { pub max_devices: Option, #[serde(skip_serializing_if = "Option::is_none")] pub plan_type: Option, + /// 最近一次在线验证时间(unix 秒),前端据此触发周检 // Logan.Li + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub last_verified_at: u64, +} + +fn is_zero_u64(v: &u64) -> bool { + *v == 0 } /// Normalize license API base so paths like `/v1/license/activate` resolve correctly. @@ -76,7 +95,7 @@ pub struct LicenseStatus { fn normalize_api_base(raw: &str) -> String { let mut base = raw.trim().trim_end_matches('/').to_string(); if base.is_empty() { - return DEFAULT_API_BASE.to_string(); + return DEFAULT_API_BASES[0].to_string(); } // Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li if base.starts_with("http://l123.91ai.io") { @@ -93,10 +112,12 @@ fn normalize_api_base(raw: &str) -> String { base } -fn api_base() -> String { - let raw = - std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string()); - normalize_api_base(&raw) +/// 全部可用 API 基址:环境变量覆盖时只用覆盖值(本地联调),否则用内置双节点。 // Logan.Li +fn api_bases() -> Vec { + match std::env::var("OPSX_LICENSE_API_BASE") { + Ok(raw) if !raw.trim().is_empty() => vec![normalize_api_base(&raw)], + _ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(), + } } fn data_dir(app: &AppHandle) -> Result { @@ -153,6 +174,7 @@ fn free_status(device_id: String, email: Option) -> LicenseStatus { email, max_devices: None, plan_type: None, + last_verified_at: 0, } } @@ -165,6 +187,17 @@ fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatu return free_status(device_id, stored.email.clone()); } } + // 在线证离线容忍:连续 30 天未能联网验证则降级,防"屏蔽授权服务器=永久有效"。 + // 离线证(LICOFF-)本地自验,不受此限制。 // Logan.Li + if !is_offline_license_key(&stored.license_key) + && stored.last_verified_at > 0 + && now_secs() + > stored + .last_verified_at + .saturating_add(OFFLINE_TOLERANCE_SECS) + { + return free_status(device_id, stored.email.clone()); + } LicenseStatus { plan: if stored.plan.is_empty() { "pro".to_string() @@ -183,6 +216,7 @@ fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatu email: stored.email.clone(), max_devices: stored.max_devices, plan_type: stored.plan_type.clone(), + last_verified_at: stored.last_verified_at, } } @@ -369,12 +403,13 @@ fn expires_from_days_left(days_left: Option) -> Option { Some(now_secs().saturating_add((days as u64).saturating_mul(86_400))) } -fn post_json(path: &str, body: Value) -> Result { - let url = format!("{}{path}", api_base()); +/// 单节点请求:只有网络层失败(连接/超时/DNS)才返回 Err 供上层切换节点; +/// 服务器返回了响应(含 4xx/5xx 业务错误)则原样解析返回,不切换。 // Logan.Li +fn try_post_json(url: &str, body: &Value) -> Result { let client = http_client()?; let response = client - .post(&url) - .json(&body) + .post(url) + .json(body) .send() .map_err(|error| format!("许可证服务请求失败: {error}"))?; let status = response.status(); @@ -392,27 +427,129 @@ fn post_json(path: &str, body: Value) -> Result { }) } +/// 多节点容灾请求:按内置顺序尝试全部节点。 // Logan.Li +fn post_json(path: &str, body: Value) -> Result { + let mut last_err = String::from("无可用授权节点"); + for base in api_bases() { + match try_post_json(&format!("{base}{path}"), &body) { + Ok(value) => return Ok(value), + Err(error) => last_err = error, + } + } + Err(last_err) +} + +/// 离线证验签公钥(与在线响应签名同源:服务端 OFFLINE_LICENSE_SEED 派生)。 // Logan.Li +fn offline_verifying_key() -> Result { + let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX) + .map_err(|_| "离线验签公钥配置错误".to_string())?; + VerifyingKey::from_bytes( + pub_bytes + .as_slice() + .try_into() + .map_err(|_| "离线验签公钥长度错误".to_string())?, + ) + .map_err(|_| "离线验签公钥无效".to_string()) +} + +/// 校验在线响应签名(防 hosts 劫持假服务器 / 响应篡改)。 +/// 签名原文与服务端 guard.go 一致: +/// v1|license_key|valid|status|expires_at_unix|server_time|in_grace|max_devices|plan_type +/// 旧版服务器不下发 sig(字段缺失)时放行;sig 存在但校验失败则拒绝。 // Logan.Li +fn verify_response_sig(payload: &Value) -> Result<(), String> { + let sig_hex = payload + .get("sig") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if sig_hex.is_empty() { + return Ok(()); + } + let sig_bytes = hex::decode(sig_hex).map_err(|_| "响应签名无法解码".to_string())?; + let sig = Signature::from_slice(&sig_bytes).map_err(|_| "响应签名无效".to_string())?; + let msg = format!( + "v1|{}|{}|{}|{}|{}|{}|{}|{}", + json_string_field(payload, "license_key", "licenseKey").unwrap_or_default(), + payload + .get("valid") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + json_string_field(payload, "status", "status").unwrap_or_default(), + json_i64_field(payload, "expires_at_unix", "expiresAtUnix").unwrap_or(0), + json_i64_field(payload, "server_time", "serverTime").unwrap_or(0), + payload + .get("in_grace") + .or_else(|| payload.get("inGrace")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + json_i64_field(payload, "max_devices", "maxDevices").unwrap_or(0), + json_string_field(payload, "plan_type", "planType").unwrap_or_default(), + ); + offline_verifying_key()? + .verify(msg.as_bytes(), &sig) + .map_err(|_| "授权服务器响应验签失败(可能被劫持)".to_string()) +} + struct RemoteLicenseInfo { expires_at: Option, features: Vec, max_devices: Option, plan_type: Option, + /// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li + server_time: u64, + /// 激活时一次性下发的设备令牌 // Logan.Li + activation_token: Option, } fn parse_remote_info(data: &Value) -> RemoteLicenseInfo { let days_left = json_i64_field(data, "days_left", "daysLeft"); - let expires_at = expires_from_days_left(days_left); let features = json_string_array(data, "features", "features"); let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32); let plan_type = json_string_field(data, "plan_type", "planType"); + let server_time = json_i64_field(data, "server_time", "serverTime") + .filter(|t| *t > 0) + .unwrap_or_else(|| now_secs() as i64) as u64; + let in_grace = data + .get("in_grace") + .or_else(|| data.get("inGrace")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let grace_days_left = json_i64_field(data, "grace_days_left", "graceDaysLeft") + .filter(|d| *d > 0) + .unwrap_or(0); + + // 到期时间优先级:宽限期 > expires_at_unix > 按 days_left 估算。 + // 宽限期内真实到期日已过,本地截止时间改用 服务端时间 + 宽限天数, + // 避免缓存被立即判过期(吊销/暂停不会走到这里,服务端直接 valid=false)。 // Logan.Li + let expires_at = if in_grace && grace_days_left > 0 { + Some(server_time.saturating_add((grace_days_left as u64) * 86_400)) + } else if let Some(unix) = + json_i64_field(data, "expires_at_unix", "expiresAtUnix").filter(|u| *u > 0) + { + Some(unix as u64) + } else { + expires_from_days_left(days_left) + }; + RemoteLicenseInfo { expires_at, features, max_devices, plan_type, + server_time, + activation_token: json_string_field(data, "activation_token", "activationToken"), } } +/// 在线验证失败原因:区分"服务器明确拒绝"与"网络不可达",周检降级策略不同。 // Logan.Li +#[derive(Debug)] +enum VerifyError { + /// 服务器返回 valid=false / 吊销 / 过期超宽限 —— 本地立即降级 free + Rejected(String), + /// 全部节点网络不可达 —— 离线容忍期内保留缓存 + Network(String), +} + fn remote_activate(license_key: &str, device_id: &str) -> Result { let body = serde_json::json!({ "license_key": license_key, @@ -425,15 +562,18 @@ fn remote_activate(license_key: &str, device_id: &str) -> Result Result { +fn remote_verify(license_key: &str, device_id: &str) -> Result { let body = serde_json::json!({ "license_key": license_key, "device_id": device_id, }); - let value = post_json("/v1/license/verify", body)?; + let value = post_json("/v1/license/verify", body).map_err(VerifyError::Network)?; // Bare object or envelope. let payload = if value.get("valid").is_some() { &value @@ -441,7 +581,9 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result Result) { + let mut body = serde_json::json!({ "license_key": license_key, "device_id": device_id, }); + if let Some(token) = activation_token.filter(|t| !t.trim().is_empty()) { + body["activation_token"] = Value::String(token.trim().to_string()); + } let _ = post_json("/v1/license/deactivate", body); } @@ -545,6 +693,14 @@ pub fn activate( let device_id = get_or_create_device_id(app)?; let key = license_key.trim(); + // 时钟回拨检测:本机时间明显早于最近一次服务端时间时拒绝,防改表续命 // Logan.Li + let path = license_path(app)?; + if let Some(stored) = read_stored(&path)? { + if now_secs().saturating_add(CLOCK_SKEW_TOLERANCE_SECS) < stored.last_server_time { + return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".to_string()); + } + } + if is_offline_license_key(key) { let claims = verify_offline_serial(key, &device_id)?; let email_store = email @@ -561,8 +717,9 @@ pub fn activate( max_devices: Some(claims.max_devices.max(1) as u32), plan_type: Some(claims.plan_type), last_verified_at: now_secs(), + activation_token: None, + last_server_time: 0, }; - let path = license_path(app)?; write_stored(&path, &stored)?; return read_status(app); } @@ -581,6 +738,9 @@ pub fn activate( if verify.plan_type.is_some() { info.plan_type = verify.plan_type; } + if verify.server_time > 0 { + info.server_time = verify.server_time; + } } if info.features.is_empty() { info.features = vec!["*".to_string()]; @@ -598,12 +758,69 @@ pub fn activate( max_devices: info.max_devices, plan_type: info.plan_type, last_verified_at: now_secs(), + // 激活令牌仅此一次下发,落盘供解绑使用 // Logan.Li + activation_token: info.activation_token.clone(), + last_server_time: info.server_time, }; - let path = license_path(app)?; write_stored(&path, &stored)?; read_status(app) } +/// 周检:在线证联网重验(验签 + 时钟 + 宽限期),并刷新本地缓存。 +/// 服务器明确拒绝(吊销/暂停/过期超宽限)→ 本地立即降级 free 并返回错误; +/// 网络不可达 → 离线容忍期内保留缓存照常返回状态。 // Logan.Li +pub fn verify(app: &AppHandle) -> Result { + let device_id = get_or_create_device_id(app)?; + let path = license_path(app)?; + let Some(stored) = read_stored(&path)? else { + return Ok(free_status(device_id, None)); + }; + if !stored.activated || stored.license_key.trim().is_empty() { + return Ok(free_status(device_id, stored.email.clone())); + } + // 离线证本地自验(到期/设备变更在 read_status 内生效),不联网 // Logan.Li + if is_offline_license_key(&stored.license_key) { + return read_status(app); + } + + match remote_verify(&stored.license_key, &device_id) { + Ok(info) => { + let mut updated = stored.clone(); + updated.expires_at = info.expires_at; + if !info.features.is_empty() { + updated.features = info.features; + } + if info.max_devices.is_some() { + updated.max_devices = info.max_devices; + } + if info.plan_type.is_some() { + updated.plan_type = info.plan_type; + } + updated.last_verified_at = now_secs(); + updated.last_server_time = info.server_time; + write_stored(&path, &updated)?; + read_status(app) + } + Err(VerifyError::Rejected(message)) => { + // 明确拒绝:吊销/暂停/过期超宽限立即生效,本地降级 free // Logan.Li + let mut updated = stored.clone(); + updated.activated = false; + updated.plan = "free".to_string(); + write_stored(&path, &updated)?; + Err(message) + } + Err(VerifyError::Network(message)) => { + // 离线容忍期内保留缓存;超时降级由 status_from_stored 的 30 天规则处理 // Logan.Li + let status = read_status(app)?; + if status.activated { + Ok(status) + } else { + Err(format!("无法连接授权服务器: {message}")) + } + } + } +} + pub fn set_email(app: &AppHandle, email: &str) -> Result { let path = license_path(app)?; let mut stored = read_stored(&path)?.unwrap_or(StoredLicense { @@ -616,6 +833,8 @@ pub fn set_email(app: &AppHandle, email: &str) -> Result max_devices: None, plan_type: None, last_verified_at: 0, + activation_token: None, + last_server_time: 0, }); let trimmed = email.trim(); stored.email = if trimmed.is_empty() { @@ -632,7 +851,12 @@ pub fn clear(app: &AppHandle) -> Result { let path = license_path(app)?; if let Ok(Some(stored)) = read_stored(&path) { if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) { - remote_deactivate(&stored.license_key, &device_id); + // 携带设备激活令牌解绑本机(新契约下仅凭序列号无法解绑) // Logan.Li + remote_deactivate( + &stored.license_key, + &device_id, + stored.activation_token.as_deref(), + ); } } if path.exists() { @@ -695,6 +919,8 @@ mod tests { max_devices: Some(3), plan_type: Some("perpetual".to_string()), last_verified_at: 1, + activation_token: Some("tok".to_string()), + last_server_time: 42, }; let json = serde_json::to_string(&stored).unwrap(); let parsed: StoredLicense = serde_json::from_str(&json).unwrap(); @@ -703,6 +929,98 @@ mod tests { assert!(parsed.activated); assert_eq!(parsed.max_devices, Some(3)); assert_eq!(parsed.plan_type.as_deref(), Some("perpetual")); + assert_eq!(parsed.activation_token.as_deref(), Some("tok")); + assert_eq!(parsed.last_server_time, 42); + } + + #[test] + fn legacy_stored_license_parses_with_defaults() { + let legacy = + r#"{"license_key":"LIC-OLD","plan":"pro","activated":true,"last_verified_at":5}"#; + let parsed: StoredLicense = serde_json::from_str(legacy).unwrap(); + assert_eq!(parsed.activation_token, None); + assert_eq!(parsed.last_server_time, 0); + } + + #[test] + fn response_sig_missing_passes_and_bad_sig_rejected() { + // 旧服务器不下发 sig:放行 + let no_sig = serde_json::json!({"valid": true, "license_key": "LIC-X"}); + assert!(verify_response_sig(&no_sig).is_ok()); + // sig 存在但内容与字段不匹配(伪造):拒绝 + let forged = serde_json::json!({ + "valid": true, "license_key": "LIC-X", "status": "active", + "expires_at_unix": 4_102_444_800_i64, "server_time": 1_780_000_000_i64, + "in_grace": false, "max_devices": 3, "plan_type": "yearly", + "sig": "00".repeat(64), + }); + assert!(verify_response_sig(&forged).is_err()); + } + + #[test] + fn parse_remote_info_prefers_grace_window() { + // 宽限期内:本地截止 = server_time + 宽限天数,而非已过期的真实到期日 // Logan.Li + let data = serde_json::json!({ + "valid": true, "in_grace": true, "grace_days_left": 10, + "server_time": 1_000_000, "expires_at_unix": 900_000, + }); + let info = parse_remote_info(&data); + assert_eq!(info.expires_at, Some(1_000_000 + 10 * 86_400)); + + // 正常状态:直接用 expires_at_unix + let ok = serde_json::json!({ + "valid": true, "expires_at_unix": 4_000_000, "server_time": 1_000_000, + }); + assert_eq!(parse_remote_info(&ok).expires_at, Some(4_000_000)); + } + + /// 真实端到端(默认忽略):需本地授权服务器与测试序列号。 + /// 运行方式: + /// OPSX_LICENSE_API_BASE=http://127.0.0.1:9001/api \ + /// OPSX_E2E_LICENSE_KEY=LIC-... cargo test --lib -- --ignored online_e2e // Logan.Li + #[test] + #[ignore = "需要本地 opsx-licence 服务器与 OPSX_E2E_LICENSE_KEY"] + fn online_e2e_against_local_server() { + let key = std::env::var("OPSX_E2E_LICENSE_KEY").expect("缺少 OPSX_E2E_LICENSE_KEY"); + let device = format!("e2e-{}", Uuid::new_v4()); + + // 1) 激活:真实签名响应 + 一次性令牌下发 + let info = remote_activate(&key, &device).expect("激活失败"); + assert!(info.server_time > 0, "响应应携带服务端时间"); + assert!( + info.activation_token.is_some(), + "激活应下发 activation_token" + ); + assert!(info.expires_at.is_some(), "激活应返回到期时间"); + // sig 已在 remote_activate 内部验签(伪造/缺失均已在单测覆盖) + + // 2) 周检验证:真实签名 + valid + let verified = match remote_verify(&key, &device) { + Ok(v) => v, + Err(e) => panic!("验证失败: {e:?}"), + }; + assert!(verified.server_time > 0); + + // 3) 无令牌解绑:新契约下应被服务端拒绝 + let no_token = post_json( + "/v1/license/deactivate", + serde_json::json!({"license_key": key, "device_id": device}), + ) + .expect("请求失败"); + assert_ne!( + envelope_code(&no_token), + Some(0), + "无令牌解绑应被拒绝: {no_token}" + ); + + // 4) 带令牌解绑:成功释放席位 + remote_deactivate(&key, &device, info.activation_token.as_deref()); + // 解绑后授权本身仍有效(席位已还) + match remote_verify(&key, &device) { + Ok(_) => {} + Err(VerifyError::Rejected(m)) => panic!("解绑后验证被拒: {m}"), + Err(VerifyError::Network(m)) => panic!("网络错误: {m}"), + } } #[test] @@ -710,4 +1028,19 @@ mod tests { assert_eq!(expires_from_days_left(Some(40_000)), None); assert!(expires_from_days_left(Some(30)).is_some()); } + + /// 离线证端到端(默认忽略):服务端签发的 LICOFF- 用客户端真实函数验。 + /// 运行方式:OPSX_E2E_OFFLINE_SERIAL=LICOFF-1... OPSX_E2E_OFFLINE_DEVICE= \ + /// cargo test --lib -- --ignored offline_e2e // Logan.Li + #[test] + #[ignore = "需要 OPSX_E2E_OFFLINE_SERIAL 与 OPSX_E2E_OFFLINE_DEVICE"] + fn offline_e2e_server_issued_serial() { + let serial = std::env::var("OPSX_E2E_OFFLINE_SERIAL").expect("缺少序列号"); + let device = std::env::var("OPSX_E2E_OFFLINE_DEVICE").expect("缺少设备 ID"); + let claims = verify_offline_serial(&serial, &device).expect("离线证验签失败"); + assert_eq!(claims.product_code, "opsxshell"); + assert!(claims.exp > claims.iat); + // 错误设备必须被拒(设备绑定) + assert!(verify_offline_serial(&serial, "other-device").is_err()); + } } diff --git a/src-tauri/src/services/local_terminal.rs b/src-tauri/src/services/local_terminal.rs index d90f0ea..369c792 100644 --- a/src-tauri/src/services/local_terminal.rs +++ b/src-tauri/src/services/local_terminal.rs @@ -3,8 +3,12 @@ use std::{ env, io::{Read, Write}, path::{Path, PathBuf}, - sync::Mutex, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, thread, + time::Duration, }; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; @@ -20,6 +24,8 @@ struct LocalShellSession { master: Box, writer: Box, child: Box, + output_live: Arc, + pending_output: Arc>>, } #[derive(Default)] @@ -115,6 +121,8 @@ impl LocalTerminalService { .master .take_writer() .map_err(|error| format!("failed to open PTY writer: {error}"))?; + let output_live = Arc::new(AtomicBool::new(false)); + let pending_output = Arc::new(Mutex::new(Vec::new())); self.sessions .lock() .map_err(|_| "local terminal session lock poisoned".to_string())? @@ -124,10 +132,12 @@ impl LocalTerminalService { master: pair.master, writer, child, + output_live: Arc::clone(&output_live), + pending_output: Arc::clone(&pending_output), }, ); - spawn_reader(app, shell_id.clone(), reader); + spawn_reader(app, shell_id.clone(), reader, output_live, pending_output); Ok(LocalShellCreateResponse { shell_name: shell_basename(&shell), shell_path: shell, @@ -135,6 +145,45 @@ impl LocalTerminalService { }) } + /// Flush PTY bytes buffered before the UI subscribed to `local:data`, then go live. + /// + /// bash/sh print PS1 during `create()`; without this the pane still has + /// `shellId === null` and drops that first chunk. zsh is slower and also + /// redraws on SIGWINCH, so it looked fine. Enter reprints a new prompt. + pub fn open_output(&self, shell_id: &str) -> Result, String> { + let (output_live, pending_output) = { + let sessions = self + .sessions + .lock() + .map_err(|_| "local terminal session lock poisoned".to_string())?; + let session = sessions + .get(shell_id) + .ok_or_else(|| format!("local shell not found: {shell_id}"))?; + ( + Arc::clone(&session.output_live), + Arc::clone(&session.pending_output), + ) + }; + + let deadline = std::time::Instant::now() + Duration::from_millis(200); + while std::time::Instant::now() < deadline { + let has_data = pending_output + .lock() + .map(|guard| !guard.is_empty()) + .unwrap_or(false); + if has_data { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + let mut pending = pending_output + .lock() + .map_err(|_| "local terminal pending output lock poisoned".to_string())?; + output_live.store(true, Ordering::Release); + Ok(std::mem::take(&mut *pending)) + } + pub fn write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> { let mut sessions = self .sessions @@ -186,22 +235,65 @@ impl LocalTerminalService { } Ok(()) } + + /// Kill every tracked local shell and report how many were reaped. + /// + /// Shell ids exist only in renderer memory, so a WebView reload orphans the + /// native sessions; the renderer calls this once per boot to reclaim them. + pub fn kill_all(&self) -> usize { + let mut sessions = match self.sessions.lock() { + Ok(guard) => guard, + Err(_) => return 0, + }; + let mut killed = 0; + for (_, mut session) in sessions.drain().collect::>() { + if session.child.kill().is_ok() { + killed += 1; + } + } + killed + } } -fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box) { +fn spawn_reader( + app: AppHandle, + shell_id: String, + mut reader: Box, + output_live: Arc, + pending_output: Arc>>, +) { thread::spawn(move || { let mut buffer = [0_u8; 8192]; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(length) => { - let _ = app.emit( - "local:data", - LocalShellDataEvent { - shell_id: shell_id.clone(), - data: buffer[..length].to_vec(), - }, - ); + let chunk = &buffer[..length]; + let should_emit = { + let mut pending = match pending_output.lock() { + Ok(guard) => guard, + Err(_) => break, + }; + if output_live.load(Ordering::Acquire) { + true + } else { + const MAX_PENDING: usize = 256 * 1024; + if pending.len() < MAX_PENDING { + let room = MAX_PENDING - pending.len(); + pending.extend_from_slice(&chunk[..length.min(room)]); + } + false + } + }; + if should_emit { + let _ = app.emit( + "local:data", + LocalShellDataEvent { + shell_id: shell_id.clone(), + data: chunk.to_vec(), + }, + ); + } } Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, @@ -227,7 +319,10 @@ fn apply_login_shell_args(command: &mut CommandBuilder, shell: &str) { fn login_shell_args(kind: &str) -> &'static [&'static str] { match kind { - "zsh" | "bash" | "fish" | "sh" => &["-l"], + "zsh" | "fish" => &["-l"], + // Force interactive: bash/sh can miss isatty during the first PTY tick + // and then wait for a line of stdin with no PS1. + "bash" | "sh" => &["-il"], _ => &[], } } @@ -564,9 +659,9 @@ mod tests { #[test] fn unix_shells_start_as_login_shells() { assert_eq!(login_shell_args("zsh"), &["-l"]); - assert_eq!(login_shell_args("bash"), &["-l"]); + assert_eq!(login_shell_args("bash"), &["-il"]); assert_eq!(login_shell_args("fish"), &["-l"]); - assert_eq!(login_shell_args("sh"), &["-l"]); + assert_eq!(login_shell_args("sh"), &["-il"]); assert!(login_shell_args("pwsh").is_empty()); assert!(login_shell_args("cmd").is_empty()); } diff --git a/src-tauri/src/services/storage.rs b/src-tauri/src/services/storage.rs index 29739f7..dd56b74 100644 --- a/src-tauri/src/services/storage.rs +++ b/src-tauri/src/services/storage.rs @@ -90,6 +90,41 @@ impl StorageService { write_data(app, &data) } + /// Batch sidebar reorder: set `sortOrder` (and optionally move `group`) on + /// many sessions in one locked read-modify-write. `group: null` clears the + /// group so the session returns to the default bucket. + pub fn reorder_sessions(&self, app: &AppHandle, updates: Vec) -> Result<(), String> { + let _guard = self.lock.lock().map_err(lock_error)?; + let mut data = read_data(app)?; + for update in &updates { + let id = update + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| "排序条目缺少 id".to_string())?; + let session = data + .sessions + .iter_mut() + .find(|session| session.get("id").and_then(Value::as_str) == Some(id)) + .ok_or_else(|| format!("会话不存在: {id}"))?; + let target = session + .as_object_mut() + .ok_or_else(|| "已保存的会话格式无效".to_string())?; + if let Some(order) = update.get("sortOrder") { + target.insert("sortOrder".to_string(), order.clone()); + } + match update.get("group") { + Some(Value::Null) => { + target.remove("group"); + } + Some(group) => { + target.insert("group".to_string(), group.clone()); + } + None => {} + } + } + write_data(app, &data) + } + pub fn get_setting(&self, app: &AppHandle, key: &str) -> Result, String> { let _guard = self.lock.lock().map_err(lock_error)?; Ok(read_data(app)?.settings.get(key).cloned()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6b1a2bd..bcd29b9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,22 +25,14 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420 http://127.0.0.1:9001 http://localhost:9001 http://l123.91ai.io https://l123.91ai.io; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'", - "dangerousDisableAssetCspModification": [ - "style-src" - ] + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420 http://127.0.0.1:9001 http://localhost:9001 https://l123.91ai.io; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'", + "dangerousDisableAssetCspModification": ["style-src"] } }, "bundle": { "active": true, "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": { "signingIdentity": "-" }, @@ -51,10 +43,7 @@ "plugins": { "deep-link": { "desktop": { - "schemes": [ - "ssh", - "opsxshell" - ] + "schemes": ["ssh", "opsxshell"] } } } diff --git a/src/app/App.tsx b/src/app/App.tsx index 9288906..5cafb34 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -27,11 +27,7 @@ import { parseSshConfig } from '../utils/sshConfig' import { formatSshLinkTarget } from '../utils/sshLink' import { suppressWebViewContextMenu } from '../utils/nativeContextMenu' import { encodeTextToBase64, decodeBase64ToText } from '../utils/textBase64' -import { - decryptSessionBackup, - encryptSessionBackup, - isEncryptedSessionBackup, -} from '../utils/sessionBackupCrypto' +import { decryptSessionBackup, encryptSessionBackup, isEncryptedSessionBackup } from '../utils/sessionBackupCrypto' import { usePromptDialog } from '../components/common/PromptDialog' import { claimPurchase } from '../platform/licenseApi' @@ -48,6 +44,10 @@ const CommandPalette = lazy(() => const logger = createLogger('App') +// WebView 重载后渲染层会丢失本地 shell id,原生侧残留的 PTY 无法再被引用; +// 每个页面生命周期只在启动时清理一次(模块标记避免 StrictMode 双挂载误杀新 shell)。 +let localOrphanCleanupDone = false + function App() { const { t } = useTranslation(['sessions', 'common']) const [isSessionDialogOpen, setIsSessionDialogOpen] = useState(false) @@ -84,6 +84,21 @@ function App() { void hydrateEntitlement() }, [loadSettings, hydrateEntitlement]) + useEffect(() => { + if (localOrphanCleanupDone) return + localOrphanCleanupDone = true + window.opsxApi + ?.localKillAll?.() + .then((result) => { + if (result?.killed) { + logger.info(`Reclaimed ${result.killed} orphan local shell(s) after webview reload`) + } + }) + .catch(() => { + // 浏览器/测试环境没有原生本地 Shell,忽略。 + }) + }, []) + useEffect(() => { if (!settingsLoaded) return void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language)) @@ -475,11 +490,9 @@ function App() { try { const parsed = new URL(url) const q = parsed.searchParams - const licenseKey = - q.get('licenseKey') || q.get('license_key') || q.get('token') || '' + const licenseKey = q.get('licenseKey') || q.get('license_key') || q.get('token') || '' const orderNo = q.get('orderNo') || q.get('order_no') || '' - const emailFromLink = - q.get('email') || q.get('customer_email') || q.get('customerEmail') || '' + const emailFromLink = q.get('email') || q.get('customer_email') || q.get('customerEmail') || '' const store = useEntitlementStore.getState() const deviceId = store.deviceId || @@ -561,7 +574,7 @@ function App() { window.opsxApi.removeAllListeners('app:pro-link') } } - }, [handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect, toast]) + }, [t, handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect, toast]) return ( void handleImportSessions()} onOpenLauncher={() => setIsLauncherOpen(true)} onEditSession={openEditSession} > diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 6610bf8..245b15b 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -33,6 +33,7 @@ export interface AppShellProps { onQuickConnect: () => void onRdpConnect?: () => void onVncConnect?: () => void + onImportSessions?: () => void onOpenLauncher: () => void onEditSession: (session: Session) => void children?: ReactNode @@ -50,7 +51,7 @@ function TitleBar() { if (event.button !== 0) return void startWindowDragging() }} - className="relative z-20 bg-dark-900/95 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" >
@@ -81,7 +82,7 @@ function ExpandSidebarButton({ onClick }: { onClick: () => void }) { ) })} +
-
@@ -452,9 +542,7 @@ export default function AiTerminalPanel({
{messages.length === 0 && liveSteps.length === 0 && !running && ( -

- {t('panel.emptyHint')} -

+

{t('panel.emptyHint')}

)} {messages.map((message) => ( @@ -504,7 +592,10 @@ export default function AiTerminalPanel({ )} {error && ( -
+
{error}
)} @@ -534,13 +625,29 @@ export default function AiTerminalPanel({ : t('panel.hintSteps', { max: MAX_AGENT_STEPS })} {running ? ( - + ) : ( - + <> + + + )}

{t('panel.footerNote')}

@@ -553,11 +660,7 @@ export default function AiTerminalPanel({ function StepCard({ step }: { step: AgentStep }) { const { t } = useTranslation('ai') const statusLabel = - step.status === 'awaiting' - ? t('step.awaiting') - : step.status === 'running' - ? t('step.running') - : t('step.done') + step.status === 'awaiting' ? t('step.awaiting') : step.status === 'running' ? t('step.running') : t('step.done') return (
diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index ba35ab8..0deb8bf 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -8,20 +8,15 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { - Search, - Globe, - Code2, - LogIn, - Plus, - X, - ChevronRight, - Settings, - Laptop, - PanelLeft, -} from 'lucide-react' +import { Search, Globe, Code2, LogIn, Plus, X, ChevronRight, Settings, Laptop, PanelLeft } from 'lucide-react' import { useTabStore } from '../../stores/tabStore' -import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores' +import { + useSessionStore, + useSnippetStore, + useCommandPaletteStore, + useEntitlementStore, + type Session, +} from '../../stores' import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab' import { buildOpenTabPayload } from '../../utils/sessionProtocol' @@ -62,12 +57,7 @@ const TYPE_BADGE_KEY: Record = { // ─── 组件 ────────────────────────────────────────────────────────────── -export function CommandPalette({ - onNewSession, - onQuickConnect, - onOpenSettings, - onEditSession, -}: CommandPaletteProps) { +export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, onEditSession }: CommandPaletteProps) { const { t } = useTranslation('common') const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) @@ -210,9 +200,7 @@ export function CommandPalette({ id: `snippet:${snippet.id}`, type: 'snippet', label: snippet.name, - description: snippet.content.length > 60 - ? snippet.content.slice(0, 60) + '…' - : snippet.content, + description: snippet.content.length > 60 ? snippet.content.slice(0, 60) + '…' : snippet.content, icon: , action: () => { // 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展) @@ -270,15 +258,11 @@ export function CommandPalette({ switch (e.key) { case 'ArrowDown': e.preventDefault() - setSelectedIndex((prev) => - prev < filteredItems.length - 1 ? prev + 1 : 0 - ) + setSelectedIndex((prev) => (prev < filteredItems.length - 1 ? prev + 1 : 0)) break case 'ArrowUp': e.preventDefault() - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : filteredItems.length - 1 - ) + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredItems.length - 1)) break case 'Enter': e.preventDefault() @@ -331,40 +315,50 @@ export function CommandPalette({ onKeyDown={handleKeyDown} > {/* Backdrop */} -
+
{/* Palette container */}
{/* Search input */} -
- +
+ 0 ? `palette-item-${selectedIndex}` : undefined} value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('palette.placeholder')} - className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm" + className="flex-1 bg-transparent text-sm text-content-primary placeholder-ph outline-none" autoComplete="off" spellCheck={false} /> - + ESC
{/* Results list */} -
+
{filteredItems.length === 0 ? (

{t('palette.empty')}

-

{t('palette.emptyHint')}

+

{t('palette.emptyHint')}

) : ( groupedItems.map((group) => { @@ -374,13 +368,13 @@ export function CommandPalette({ return (
{/* Group header */} -
+
{t(TYPE_BADGE_KEY[group.type])} - + {group.items.length}
@@ -391,14 +385,15 @@ export function CommandPalette({ return (
{ item.action() close() @@ -407,7 +402,7 @@ export function CommandPalette({ > {/* Icon */} @@ -415,28 +410,19 @@ export function CommandPalette({ {/* Label + description */} -
-
{item.label}
+
+
{item.label}
{item.description && ( -
- {item.description} -
+
{item.description}
)}
{/* Shortcut */} {item.shortcut && ( - + {item.shortcut} )} - - {/* Type badge */} - - {t(TYPE_BADGE_KEY[item.type])} -
) })} @@ -447,24 +433,22 @@ export function CommandPalette({
{/* Footer hint */} -
+
- ↑↓ + ↑↓ {t('palette.hint.nav')} - ↵ + ↵ {t('palette.hint.run')} - esc + esc {t('palette.hint.close')}
- - {t('palette.resultCount', { count: filteredItems.length })} - + {t('palette.resultCount', { count: filteredItems.length })}
diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/common/ConfirmDialog.tsx index c78e184..63a7276 100644 --- a/src/components/common/ConfirmDialog.tsx +++ b/src/components/common/ConfirmDialog.tsx @@ -31,8 +31,8 @@ export interface ConfirmOptions { cancelText?: string } -/** Agent 高风险确认:Reject / Once / Always(Always 仅会话内存) */ -export type AgentRiskChoice = 'reject' | 'once' | 'always' +/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */ +export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert' export interface AgentRiskConfirmOptions { title: string @@ -155,11 +155,7 @@ export function useConfirmDialog() { cancelText={state.cancelText} isDanger={state.danger} size="sm" - icon={ - state.danger ? ( - - ) : undefined - } + icon={state.danger ? : undefined} >

{state.message}

@@ -171,12 +167,15 @@ export function useConfirmDialog() { showConfirm={false} size="sm" disableBackdropClose - icon={} + icon={} footer={
+ )} @@ -168,10 +132,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con )} @@ -179,7 +142,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con {/* Error message */} {conn.error && ( -

+

{conn.error}

)} @@ -208,7 +171,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) { return (
{items.map((item) => ( -
+
{item.value}
{item.label}
@@ -225,11 +188,7 @@ export default function ConnectionPanel() { const close = useConnectionPanelStore((s) => s.close) const connections = usePoolStore((s) => s.connections) - const { - resumeConnection, - disconnectConnection, - getStats, - } = useConnectionStore() + const { resumeConnection, disconnectConnection, getStats } = useConnectionStore() const [reconnectingId, setReconnectingId] = useState(null) @@ -250,14 +209,14 @@ export default function ConnectionPanel() { setReconnectingId(null) } }, - [resumeConnection], + [resumeConnection] ) const handleDisconnect = useCallback( async (connectionId: string) => { await disconnectConnection(connectionId) }, - [disconnectConnection], + [disconnectConnection] ) if (!isOpen) return null @@ -265,34 +224,27 @@ export default function ConnectionPanel() { return ( <> {/* Backdrop */} -
+
{/* Panel */} -
+
{/* Header */} -
-

- +
+

+ {t('connectionPanel.title')}

{/* Stats Overview */} -
+
{/* Connection List */} -
+
{connList.length === 0 ? ( state.tabs.find((item) => item.id === tabId)) const sessions = useSessionStore((state) => state.sessions) const updateTabStatus = useTabStore((state) => state.updateTabStatus) - const fullscreenActive = useDesktopFullscreenStore( - (state) => state.active && state.tabId === tabId - ) + const fullscreenActive = useDesktopFullscreenStore((state) => state.active && state.tabId === tabId) const enterFullscreen = useDesktopFullscreenStore((state) => state.enter) const exitFullscreen = useDesktopFullscreenStore((state) => state.exit) const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle) @@ -126,18 +116,14 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi const local = sessions.find((item) => item.id === tab.sessionId) if (local) { if (!cancelled) { - setResolvedConfig( - protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local) - ) + setResolvedConfig(protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)) } return } const response = await window.opsxApi?.sessionGetById?.(tab.sessionId) if (!cancelled && response?.success && response.session) { setResolvedConfig( - protocol === 'rdp' - ? sessionToRdpConfig(response.session) - : sessionToVncConfig(response.session) + protocol === 'rdp' ? sessionToRdpConfig(response.session) : sessionToVncConfig(response.session) ) } } @@ -548,11 +534,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi event.preventDefault() const isPasteChord = - pressed && - (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) { // Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V. @@ -597,10 +579,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi } const Icon = protocol === 'rdp' ? Monitor : Cast - const hostLabel = - resolvedConfig && 'host' in resolvedConfig - ? resolvedConfig.host - : tab?.title + const hostLabel = resolvedConfig && 'host' in resolvedConfig ? resolvedConfig.host : tab?.title const connected = tab?.connectionStatus === 'connected' const toolBtn = @@ -610,8 +589,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
{ if (fullscreenActive) { @@ -636,11 +615,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi {tab?.connectionStatus === 'connecting' ? ( ) : ( - + )} {statusMessage} {error && {error}} @@ -739,7 +714,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi event.preventDefault()} onMouseMove={(event) => mapPointer(event)} onMouseDown={(event) => mapPointer(event, true)} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e431fc1..8f941fa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,8 @@ * 支持宽度拖拽调整 */ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' import { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useSessionStore, Session, useEntitlementStore } from '../../stores' @@ -18,8 +19,11 @@ import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol' import { DEFAULT_SESSION_GROUP, groupDisplayName, + groupSessionsInDisplayOrder, normalizeGroupId, } from '../../utils/sessionGroups' +import { localeCompareText } from '../../i18n/format' +import type { SessionReorderEntry } from '../../../shared/types/session.types' const MIN_SIDEBAR_WIDTH = 180 const MAX_SIDEBAR_WIDTH = 400 @@ -33,12 +37,12 @@ interface SidebarProps { export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) { const { t } = useTranslation(['sessions', 'common']) - const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore() + const { sessions, setSessions, adoptSession, removeSession, updateSession } = useSessionStore() const { settings, setSetting } = useSettingsStore() const { addTab } = useTabStore() const { confirm, ConfirmDialogComponent } = useConfirmDialog() const { prompt, PromptDialogComponent } = usePromptDialog() - + const [searchTerm, setSearchTerm] = useState('') const [expandedGroups, setExpandedGroups] = useState>(new Set(['default'])) const [selectedSessionId, setSelectedSessionId] = useState(null) @@ -54,7 +58,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o const [batchMode, setBatchMode] = useState(false) const [batchSelection, setBatchSelection] = useState>(new Set()) const requirePro = useEntitlementStore((s) => s.requirePro) - + const contextMenuRef = useRef(null) const sidebarRef = useRef(null) @@ -67,7 +71,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o // Load sessions useEffect(() => { - window.opsxApi?.sessionGetAll() + window.opsxApi + ?.sessionGetAll() .then((result) => { if (result.success && result.sessions) setSessions(result.sessions) }) @@ -94,36 +99,58 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o } }, [contextMenu]) + // Hug content (`w-max`); keep the compact menu inside the viewport. + useLayoutEffect(() => { + const el = contextMenuRef.current + if (!el || !contextMenu) return + const margin = 8 + const width = el.offsetWidth + const height = el.offsetHeight + let left = contextMenu.x + let top = contextMenu.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` + }, [contextMenu]) + // Resize handler - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault() - setIsResizing(true) + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) - const startX = e.clientX - const startWidth = sidebarWidth + const startX = e.clientX + const startWidth = sidebarWidth - const handleMouseMove = (moveEvent: MouseEvent) => { - const delta = moveEvent.clientX - startX - const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta)) - setSidebarWidth(newWidth) - } + const handleMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX + const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta)) + setSidebarWidth(newWidth) + } - const handleMouseUp = () => { - setIsResizing(false) - document.body.style.userSelect = '' - document.body.style.cursor = '' - // Save to settings on drag end - setSetting('sidebarWidth', sidebarWidth) - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } + const handleMouseUp = () => { + setIsResizing(false) + document.body.style.userSelect = '' + document.body.style.cursor = '' + // Save to settings on drag end + setSetting('sidebarWidth', sidebarWidth) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } - // Prevent text selection during drag - document.body.style.userSelect = 'none' - document.body.style.cursor = 'col-resize' - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - }, [sidebarWidth, setSetting]) + // Prevent text selection during drag + document.body.style.userSelect = 'none' + document.body.style.cursor = 'col-resize' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, + [sidebarWidth, setSetting] + ) // Save final width on unmount or when resizing ends (we save in handleMouseUp) useEffect(() => { @@ -171,16 +198,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o }, []) /** Batch mode only lists SSH targets (RDP / VNC are unsupported). */ - const batchableSessions = useMemo( - () => sessions.filter((session) => isSshSession(session)), - [sessions] - ) + const batchableSessions = useMemo(() => sessions.filter((session) => isSshSession(session)), [sessions]) const visibleSessions = batchableSessions.filter((session) => { const query = searchTerm.trim().toLowerCase() - return !query || session.name.toLowerCase().includes(query) || + return ( + !query || + session.name.toLowerCase().includes(query) || session.host.toLowerCase().includes(query) || session.username?.toLowerCase().includes(query) + ) }) const toggleSelectVisible = useCallback(() => { @@ -244,9 +271,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o const persistSessionGroups = useCallback( async (next: string[]) => { - const cleaned = next - .map((item) => normalizeGroupId(item)) - .filter((item) => item !== DEFAULT_SESSION_GROUP) + const cleaned = next.map((item) => normalizeGroupId(item)).filter((item) => item !== DEFAULT_SESSION_GROUP) const unique = Array.from(new Set(cleaned)) await setSetting('sessionGroups', unique) }, @@ -317,6 +342,71 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o [confirm, persistSessionGroups, sessions, settings.sessionGroups, t] ) + /** + * Persist a batch of sidebar order entries optimistically. On a failed write, + * reload from storage so the tree reflects the real persisted order. + */ + const applyReorder = useCallback( + async (entries: SessionReorderEntry[]) => { + const all = useSessionStore.getState().sessions + const byId = new Map(entries.map((entry) => [entry.id, entry])) + setSessions( + all.map((session) => { + const entry = byId.get(session.id) + if (!entry) return session + return { + ...session, + sortOrder: entry.sortOrder, + ...(entry.group !== undefined ? { group: entry.group === null ? undefined : entry.group } : {}), + } + }) + ) + const result = await window.opsxApi?.sessionReorder(entries) + if (result && !result.success) { + const fresh = await window.opsxApi?.sessionGetAll() + if (fresh?.success && fresh.sessions) setSessions(fresh.sessions) + } + }, + [setSessions] + ) + + /** Drag commit: place `dragId` at `targetIndex` (display order) of `targetGroup`. */ + const handleReorderSessions = useCallback( + (dragId: string, targetGroup: string, targetIndex: number) => { + const all = useSessionStore.getState().sessions + const dragged = all.find((session) => session.id === dragId) + if (!dragged) return + const members = groupSessionsInDisplayOrder(all, targetGroup).filter((session) => session.id !== dragId) + const index = Math.max(0, Math.min(targetIndex, members.length)) + members.splice(index, 0, dragged) + const nextGroup = targetGroup === DEFAULT_SESSION_GROUP ? null : targetGroup + const changedGroup = (dragged.group || DEFAULT_SESSION_GROUP) !== targetGroup + // Skip the write when the drop didn't change the visible order. + const visibleIds = (list: Session[]) => list.map((session) => session.id).join('\u0000') + if (!changedGroup && visibleIds(members) === visibleIds(groupSessionsInDisplayOrder(all, targetGroup))) { + return + } + const entries: SessionReorderEntry[] = members.map((session, order) => ({ + id: session.id, + sortOrder: order, + ...(session.id === dragId && changedGroup ? { group: nextGroup } : {}), + })) + void applyReorder(entries) + }, + [applyReorder] + ) + + /** One-shot alphabetical rewrite of one group's manual order. */ + const sortGroupByName = useCallback( + (group: string) => { + const all = useSessionStore.getState().sessions + const members = groupSessionsInDisplayOrder(all, group).sort((a, b) => localeCompareText(a.name, b.name)) + if (members.length < 2) return + void applyReorder(members.map((session, order) => ({ id: session.id, sortOrder: order }))) + }, + [applyReorder] + ) + const handleContextMenuAction = async (action: string) => { if (!contextMenu) return @@ -331,6 +421,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o const group = contextMenu.group if (action === 'new-session') onNewSession(group) if (action === 'new-group') await createGroup() + if (action === 'sort-by-name') sortGroupByName(group) if (action === 'rename-group') await renameGroup(group) if (action === 'delete-group') await deleteEmptyGroup(group) setContextMenu(null) @@ -350,14 +441,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o break case 'duplicate': { const { id: _id, ...sessionWithoutId } = session - window.opsxApi?.sessionCreate({ - ...sessionWithoutId, - name: `${session.name} (${t('common:action.clone')})`, - }).then((result) => { - if (result?.success && result.session) { - addSession(result.session as Session) - } - }) + window.opsxApi + ?.sessionCreate({ + ...sessionWithoutId, + name: `${session.name} (${t('common:action.clone')})`, + }) + .then((result) => { + if (result?.success && result.session) { + adoptSession(result.session as Session) + } + }) break } case 'delete': { @@ -379,79 +472,76 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o return ( <> -
-
- {/* Header */} -
-
- OpsXShell -
-
- OpsXShell +
+
+ {/* Header */} +
+
+ OpsXShell +
+
OpsXShell
+
{t('common:sidebar.brandSub')}
-
{t('common:sidebar.brandSub')}
+ {onCollapse && ( + + )}
- {onCollapse && ( + +
- )} + +
-
- - -
-
- - <> -
+ <> +
- +
{batchMode && (
- + {t('sidebar.batchSelected', { count: batchSelection.size })} {sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''} -
)} - + - {/* Footer */} -
- + {/* Footer */} +
+ +
+ + {/* Context Menu — portaled so sidebar overflow cannot stretch/clip it */} + {contextMenu && + createPortal( +
e.stopPropagation()} + > + {(contextMenu.kind === 'blank' + ? [ + { label: t('contextMenu.blank.newGroup'), action: 'new-group' }, + { label: t('contextMenu.blank.newSession'), action: 'new-session' }, + ] + : contextMenu.kind === 'group' + ? [ + { label: t('contextMenu.group.newSessionHere'), action: 'new-session' }, + { label: t('contextMenu.group.newGroup'), action: 'new-group' }, + { label: t('contextMenu.group.sortByName'), action: 'sort-by-name' }, + ...(contextMenu.group !== DEFAULT_SESSION_GROUP + ? [ + { label: t('contextMenu.group.renameGroup'), action: 'rename-group' }, + { + label: t('contextMenu.group.deleteEmptyGroup'), + action: 'delete-group', + danger: true, + }, + ] + : []), + ] + : [ + { label: t('contextMenu.session.open'), action: 'connect' }, + ...(isSshSession(contextMenu.session) + ? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }] + : []), + { label: t('contextMenu.session.edit'), action: 'edit' }, + { label: t('contextMenu.session.duplicate'), action: 'duplicate' }, + { label: t('contextMenu.session.delete'), action: 'delete', danger: true }, + ] + ).map((item) => ( + + ))} +
, + document.body + )}
- {/* Context Menu */} - {contextMenu && ( -
e.stopPropagation()} - > - {(contextMenu.kind === 'blank' - ? [ - { label: t('contextMenu.blank.newGroup'), action: 'new-group' }, - { label: t('contextMenu.blank.newSession'), action: 'new-session' }, - ] - : contextMenu.kind === 'group' - ? [ - { label: t('contextMenu.group.newSessionHere'), action: 'new-session' }, - { label: t('contextMenu.group.newGroup'), action: 'new-group' }, - ...(contextMenu.group !== DEFAULT_SESSION_GROUP - ? [ - { label: t('contextMenu.group.renameGroup'), action: 'rename-group' }, - { label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true }, - ] - : []), - ] - : [ - { label: t('contextMenu.session.open'), action: 'connect' }, - ...(isSshSession(contextMenu.session) - ? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }] - : []), - { label: t('contextMenu.session.edit'), action: 'edit' }, - { label: t('contextMenu.session.duplicate'), action: 'duplicate' }, - { label: t('contextMenu.session.delete'), action: 'delete', danger: true }, - ] - ).map((item) => ( - - ))} -
- )} + {/* Resize Handle — 双击也可折叠 */} +
{ + e.preventDefault() + onCollapse?.() + }} + title={t('sidebar.resizeHint')} + className={`absolute bottom-0 right-0 top-0 w-1 cursor-col-resize transition-all duration-150 hover:w-1.5 hover:bg-accent-blue/50 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)] active:bg-accent-blue/70 ${isResizing ? 'w-1.5 bg-accent-blue/70 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''} `} + />
- - {/* Resize Handle — 双击也可折叠 */} -
{ - e.preventDefault() - onCollapse?.() - }} - title={t('sidebar.resizeHint')} - className={` - absolute top-0 right-0 bottom-0 w-1 cursor-col-resize - hover:bg-accent-blue/50 active:bg-accent-blue/70 - hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)] - transition-all duration-150 - ${isResizing ? 'bg-accent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''} - `} - /> -
- {ConfirmDialogComponent} - {PromptDialogComponent} + {ConfirmDialogComponent} + {PromptDialogComponent} ) } diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index feb9e0e..62fbafa 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -22,13 +22,7 @@ interface TabBarProps { function TabBar({ onOpenLauncher }: TabBarProps) { const { t } = useTranslation('common') - const { - tabs, - activeTabId, - activateTab, - addTab, - moveTab, - } = useTabStore() + const { tabs, activeTabId, activateTab, addTab, moveTab, updateTabTitle } = useTabStore() // Drag state const [dragFromIndex, setDragFromIndex] = useState(null) @@ -115,16 +109,28 @@ function TabBar({ onOpenLauncher }: TabBarProps) { const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null return ( -
+
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
{ + if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return + const index = tabs.findIndex((tab) => tab.id === activeTabId) + if (index < 0 || tabs.length === 0) return + event.preventDefault() + let next = index + if (event.key === 'ArrowRight') next = (index + 1) % tabs.length + if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length + if (event.key === 'Home') next = 0 + if (event.key === 'End') next = tabs.length - 1 + activateTab(tabs[next].id) + requestAnimationFrame(() => { + document.querySelector('[data-tab-item][aria-selected="true"]')?.focus() + }) + }} > {tabs.map((tab, index) => ( updateTabTitle(tab.id, title) : undefined + } /> ))}
@@ -151,7 +160,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
-
+
{groupSessions.length === 0 ? ( -
{t('tree.emptyTitle')}
+
+ {t('tree.emptyTitle')} +
) : ( - groupSessions.map((session) => { - const selected = multiSelect - ? selectedSessionIds.has(session.id) - : selectedSessionId === session.id + groupSessions.map((session, index) => { + const selected = multiSelect ? selectedSessionIds.has(session.id) : selectedSessionId === session.id + const showInsertBefore = dropTarget?.group === group && dropTarget.index === index + const showInsertAfter = + dropTarget?.group === group && + dropTarget.index === index + 1 && + index === groupSessions.length - 1 return (
startPointerDrag(session, e)} + onPointerMove={movePointerDrag} + onPointerUp={finishPointerDrag} + onPointerCancel={finishPointerDrag} onClick={() => { + if (suppressClickRef.current) return if (multiSelect) onToggleSelection?.(session) else onSelectSession(session) }} onDoubleClick={() => { - if (!multiSelect) onConnectSession(session) + if (suppressClickRef.current || multiSelect) return + onConnectSession(session) }} onKeyDown={(e) => { if (e.key === 'Enter' || (multiSelect && e.key === ' ')) { @@ -167,22 +391,24 @@ export function SessionTree({ ? t('tree.selectSession') : `${session.username}@${session.host}:${session.port || 22}` } - className={` - group flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer - transition-colors duration-150 pl-9 pr-2 - ${ + 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' : ''} ${ selected - ? 'bg-accent-blue/10 border-l-2 border-accent-blue' - : 'border-l-2 border-transparent hover:bg-dark-700/40' - } - `} + ? 'border-l-2 border-accent-blue bg-accent-blue/10' + : 'hover:bg-dark-700/40 border-l-2 border-transparent' + } `} > + {(showInsertBefore || showInsertAfter) && ( + + )} {multiSelect ? ( @@ -210,11 +436,7 @@ export function SessionTree({ })} {filteredSessions.length === 0 && query && ( - + )} {sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && ( @@ -222,11 +444,21 @@ export function SessionTree({ icon={ServerOff} title={t('tree.emptyTitle')} description={t('tree.emptyDesc')} - action={ - onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined - } + action={onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined} /> )} + + {preview && + createPortal( +
+ {preview.name} +
, + document.body + )}
) } diff --git a/src/components/layout/tabbar/TabItem.tsx b/src/components/layout/tabbar/TabItem.tsx index 6a8631c..7006400 100644 --- a/src/components/layout/tabbar/TabItem.tsx +++ b/src/components/layout/tabbar/TabItem.tsx @@ -2,12 +2,14 @@ * TabItem — local shell uses the same Terminal icon as the launcher (+) */ +import { useEffect, useRef, useState } from 'react' import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { Tab } from '../../../stores/tabStore' import { useSessionStore } from '../../../stores/sessionStore' import HostDistroIcon from '../../common/HostDistroIcon' import { resolveTabTitle } from '../../../utils/tabTitle' +import StatusDot from '../../common/StatusDot' interface TabItemProps { tab: Tab @@ -22,6 +24,8 @@ interface TabItemProps { onDragOver: (e: React.DragEvent, index: number) => void onDrop: (e: React.DragEvent, index: number) => void onDragEnd: () => void + /** 双击标签内联重命名;welcome/settings 等固定标签不传。 */ + onRename?: (title: string) => void } export function TabItem({ @@ -37,6 +41,7 @@ export function TabItem({ onDragOver, onDrop, onDragEnd, + onRename, }: TabItemProps) { const { t } = useTranslation(['terminal', 'common']) const displayTitle = resolveTabTitle(tab) @@ -44,6 +49,29 @@ export function TabItem({ const session = useSessionStore((state) => tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined ) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const renameInputRef = useRef(null) + + useEffect(() => { + if (editing) { + renameInputRef.current?.focus() + renameInputRef.current?.select() + } + }, [editing]) + + const startRename = () => { + setDraft(tab.title) + setEditing(true) + } + + const commitRename = () => { + setEditing(false) + const next = draft.trim() + if (next && next !== tab.title) { + onRename?.(next) + } + } const getIcon = () => { if (isLocalTerm) { @@ -57,13 +85,8 @@ export function TabItem({ if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) { return ( ) @@ -88,12 +111,10 @@ export function TabItem({ const showRightIndicator = dragOverIndex === index + 1 && !isDragging const statusDot = - tab.connectionStatus === 'connecting' ? ( - - ) : tab.connectionStatus === 'connected' ? ( - - ) : tab.connectionStatus === 'error' ? ( - + tab.connectionStatus === 'connecting' || + tab.connectionStatus === 'connected' || + tab.connectionStatus === 'error' ? ( + ) : null return ( @@ -101,7 +122,8 @@ export function TabItem({ data-tab-item role="tab" aria-selected={isActive} - tabIndex={0} + aria-controls={`tabpanel-${tab.id}`} + tabIndex={isActive ? 0 : -1} draggable onClick={onActivate} onAuxClick={(e) => { @@ -126,50 +148,66 @@ export function TabItem({ onDragOver={(e) => onDragOver(e, index)} onDrop={(e) => onDrop(e, index)} onDragEnd={onDragEnd} - className={` - group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2 - border-r border-dark-700/20 px-3 py-2 titlebar-no-drag transition-all duration-150 - ${ - isActive - ? 'bg-dark-900 text-content-primary' - : 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted' - } - ${isDragging ? 'opacity-50' : 'opacity-100'} - `} + className={`border-dark-700/20 titlebar-no-drag group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2 border-r px-3 py-2 transition-all duration-150 ${ + isActive + ? 'bg-dark-900 text-content-primary' + : 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted' + } ${isDragging ? 'opacity-50' : 'opacity-100'} `} > - {showLeftIndicator && ( -
- )} - {showRightIndicator && ( -
- )} + {showLeftIndicator &&
} + {showRightIndicator &&
} {statusDot} {getIcon()} - - {displayTitle} - + {editing ? ( + setDraft(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + commitRename() + } + if (e.key === 'Escape') { + e.preventDefault() + setEditing(false) + } + }} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + aria-label={t('common:tab.renameAria', { title: displayTitle })} + className="ui-input-sm min-w-0 flex-1 px-1 py-0 text-sm" + /> + ) : ( + { + if (!onRename) return + e.stopPropagation() + startRename() + }} + > + {displayTitle} + + )}
) diff --git a/src/components/sessions/SessionDialog.tsx b/src/components/sessions/SessionDialog.tsx index 5292672..ba71914 100644 --- a/src/components/sessions/SessionDialog.tsx +++ b/src/components/sessions/SessionDialog.tsx @@ -4,9 +4,7 @@ import { useState, useEffect, useMemo, type ComponentType } from 'react' import { useTranslation } from 'react-i18next' -import { - X, Server, Key, Lock, Tag, Folder, ChevronDown, Check, -} from 'lucide-react' +import { X, Server, Key, Lock, Tag, Folder, ChevronDown, Check } from 'lucide-react' import { useSessionStore, Session, useEntitlementStore } from '../../stores' import type { AuthType, ProxyType, SessionProtocol } from '../../../shared/types/session.types' import { createLogger } from '../../utils/logger' @@ -22,6 +20,8 @@ import { useSettingsStore } from '../../stores/settingsStore' import { usePromptDialog } from '../common/PromptDialog' import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker' import { getDisplayResolution } from '../../utils/desktopResolution' +import { useToast } from '../../hooks/useToast' +import { translateErrorMessage } from '../../i18n/format' const logger = createLogger('SessionDialog') @@ -84,7 +84,8 @@ export default function SessionDialog({ editSession, defaultGroup = DEFAULT_SESSION_GROUP, }: SessionDialogProps) { - const { t } = useTranslation(['sessions', 'common']) + const { t } = useTranslation(['sessions', 'common', 'errors']) + const { toast } = useToast() const { addSession, updateSession, sessions } = useSessionStore() const { requirePro } = useEntitlementStore() const sessionGroups = useSettingsStore((s) => s.settings.sessionGroups) @@ -96,10 +97,7 @@ export default function SessionDialog({ const isEditing = !!editSession const isSsh = formData.protocol === 'ssh' - const knownGroups = useMemo( - () => collectSessionGroups(sessions, sessionGroups), - [sessions, sessionGroups] - ) + const knownGroups = useMemo(() => collectSessionGroups(sessions, sessionGroups), [sessions, sessionGroups]) const jumpCandidates = sessions.filter( (session) => session.id !== editSession?.id && (session.protocol ?? 'ssh') === 'ssh' ) @@ -122,9 +120,7 @@ export default function SessionDialog({ name: editSession.name || '', host: editSession.host || '', port: editSession.port || defaultPortForProtocol(protocol), - username: - editSession.username || - (protocol === 'rdp' ? 'Administrator' : protocol === 'vnc' ? 'vnc' : 'root'), + username: editSession.username || (protocol === 'rdp' ? 'Administrator' : protocol === 'vnc' ? 'vnc' : 'root'), authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password', protocol, password: editSession.password || '', @@ -187,9 +183,7 @@ export default function SessionDialog({ ? 'root' : prev.username const nextPort = - prev.port === defaultPortForProtocol(prev.protocol) - ? defaultPortForProtocol(protocol) - : prev.port + prev.port === defaultPortForProtocol(prev.protocol) ? defaultPortForProtocol(protocol) : prev.port const display = getDisplayResolution() const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh' return { @@ -264,9 +258,7 @@ export default function SessionDialog({ const proxyReady = formData.proxy.type === 'none' || - (formData.proxy.host.trim().length > 0 && - formData.proxy.port > 0 && - formData.proxy.port <= 65535) + (formData.proxy.host.trim().length > 0 && formData.proxy.port > 0 && formData.proxy.port <= 65535) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -328,9 +320,7 @@ export default function SessionDialog({ : undefined const sessionData = { - name: - formData.name.trim() || - (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`), + name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`), host, port, username, @@ -343,8 +333,7 @@ export default function SessionDialog({ ? formData.privateKeyText.trim() || undefined : formData.privateKeyPath.trim() || DEFAULT_KEY_PATH : undefined, - passphrase: - isSsh && formData.authType === 'key' ? formData.passphrase || undefined : undefined, + passphrase: isSsh && formData.authType === 'key' ? formData.passphrase || undefined : undefined, proxy: proxyConfig, startupCommand: isSsh ? formData.startupCommand.trim() || undefined : undefined, jumpSessionIds: @@ -388,6 +377,7 @@ export default function SessionDialog({ onClose() } catch (error) { logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error) + toast.error(translateErrorMessage(error instanceof Error ? error.message : t('errors:session.saveFailed'))) } finally { setIsSubmitting(false) } @@ -405,7 +395,7 @@ export default function SessionDialog({ const groupField = (
@@ -448,546 +436,557 @@ export default function SessionDialog({ return ( <> -
- {/* Backdrop */} -
+
+ {/* Backdrop */} +
- {/* Drawer */} -