chore: 保存 Agent 安全优化前基线

This commit is contained in:
Logan.Li committed 2026-09-02 23:31:38 +08:00
1 parent 08696896f9
commit ff016347cd
116 files changed
+3858 -2485

No files matched your search

+27 -1
View File
@@ -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
+1
View File
@@ -46,6 +46,7 @@ __pycache__/
# Tauri / Rust
src-tauri/target/
src-tauri/tauri.signing.conf.json
# Database
*.db
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+2 -1
View File
@@ -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 --"]
}
+1 -2
View File
@@ -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 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
-1
View File
@@ -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 / 文件系统 / 加密。
+21
View File
@@ -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.
+29 -23
View File
@@ -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)。
+4 -3
View File
@@ -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、把结账/签发服务堆回桌面进程。
+4 -3
View File
@@ -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 集
+11 -10
View File
@@ -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) 维护。
+9 -9
View File
@@ -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 工具使用,不引入第二套目标和凭据边界 |
+76
View File
@@ -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<dyn EventSink>`。
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。
+17 -16
View File
@@ -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 不匹配。
## 体积预算
+84 -13
View File
@@ -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 也只能标开发包。
## 产物验证
+1 -1
View File
@@ -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 必须要求更换路径。
+3 -1
View File
@@ -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 在发给云模型前做常见敏感信息脱敏。
-22
View File
@@ -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&currency=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
-5
View File
@@ -1,5 +0,0 @@
node_modules/
.env
.env.local
data/
*.log
-45
View File
@@ -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 **轮换**
-27
View File
@@ -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"
}
}
}
}
-19
View File
@@ -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"
}
}
@@ -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)
})
@@ -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))
-333
View File
@@ -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)}`)
})
+1 -1
View File
@@ -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",
+1
View File
@@ -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/)
})
+20 -2
View File
@@ -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
+2 -2
View File
@@ -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)
}
+1
View File
@@ -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) {
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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}`)
}
+12 -3
View File
@@ -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
+30 -13
View File
@@ -2,41 +2,58 @@ use tauri::AppHandle;
use crate::services::license::{self, CheckoutResult, LicenseStatus};
#[tauri::command]
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
license::read_status(&app)
async fn license_blocking<T: Send + 'static>(
work: impl FnOnce() -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
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<LicenseStatus, String> {
license_blocking(move || license::read_status(&app)).await
}
#[tauri::command]
pub async fn license_activate(
app: AppHandle,
token: String,
email: Option<String>,
) -> Result<LicenseStatus, String> {
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<LicenseStatus, String> {
license::clear(&app)
pub async fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
license_blocking(move || license::clear(&app)).await
}
/// 周检:联网重验在线授权(验签/时钟/宽限期)并刷新本地缓存 // Logan.Li
#[tauri::command]
pub async fn license_verify(app: AppHandle) -> Result<LicenseStatus, String> {
license_blocking(move || license::verify(&app)).await
}
#[tauri::command]
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
license::get_or_create_device_id(&app)
pub async fn license_device_id(app: AppHandle) -> Result<String, String> {
license_blocking(move || license::get_or_create_device_id(&app)).await
}
#[tauri::command]
pub fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
license::set_email(&app, &email)
pub async fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
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<String>,
) -> Result<CheckoutResult, String> {
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
}
+16
View File
@@ -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<Vec<u8>, 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<usize, String> {
Ok(state.local_terminal.kill_all())
}
+45 -13
View File
@@ -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<bool>,
) -> OperationResponse {
operation(
state
.ssh
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
)
) -> Result<OperationResponse, String> {
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<bool>,
) -> Result<ExecResponse, String> {
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<OperationResponse, String> {
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<ShellOutputResponse, String> {
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}"
))),
}
}
+12
View File
@@ -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<Value>,
) -> 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) {
+3
View File
@@ -89,6 +89,9 @@ pub fn clipboard_write_text(text: String) -> Result<(), String> {
#[tauri::command]
pub fn toggle_devtools(app: AppHandle) -> Result<bool, String> {
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())?;
+7
View File
@@ -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();
+18 -12
View File
@@ -355,18 +355,24 @@ pub fn build_menu<R: Runtime>(app: &AppHandle<R>, 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)
+55
View File
@@ -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<Vec<regex::Regex>> = 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());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod guardrail;
mod redaction;
use std::time::Duration;
+357 -24
View File
@@ -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<String>,
#[serde(default)]
last_verified_at: u64,
/// 设备激活令牌:服务端激活时一次性下发,解绑本机必须携带 // Logan.Li
#[serde(default, skip_serializing_if = "Option::is_none")]
activation_token: Option<String>,
/// 最近一次见到的服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
#[serde(default)]
last_server_time: u64,
}
fn default_plan() -> String {
@@ -69,6 +81,13 @@ pub struct LicenseStatus {
pub max_devices: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_type: Option<String>,
/// 最近一次在线验证时间(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<String> {
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<PathBuf, String> {
@@ -153,6 +174,7 @@ fn free_status(device_id: String, email: Option<String>) -> 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<i64>) -> Option<u64> {
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
}
fn post_json(path: &str, body: Value) -> Result<Value, String> {
let url = format!("{}{path}", api_base());
/// 单节点请求:只有网络层失败(连接/超时/DNS)才返回 Err 供上层切换节点;
/// 服务器返回了响应(含 4xx/5xx 业务错误)则原样解析返回,不切换。 // Logan.Li
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
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<Value, String> {
})
}
/// 多节点容灾请求:按内置顺序尝试全部节点。 // Logan.Li
fn post_json(path: &str, body: Value) -> Result<Value, String> {
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<VerifyingKey, String> {
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<u64>,
features: Vec<String>,
max_devices: Option<u32>,
plan_type: Option<String>,
/// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
server_time: u64,
/// 激活时一次性下发的设备令牌 // Logan.Li
activation_token: Option<String>,
}
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<RemoteLicenseInfo, String> {
let body = serde_json::json!({
"license_key": license_key,
@@ -425,15 +562,18 @@ fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseIn
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
}
}
Ok(parse_remote_info(envelope_data(&value)))
let data = envelope_data(&value);
// 激活响应签名校验(防假服务器在激活环节伪造成功) // Logan.Li
verify_response_sig(data)?;
Ok(parse_remote_info(data))
}
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, VerifyError> {
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<RemoteLicenseInfo
envelope_data(&value)
} else if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
return Err(VerifyError::Rejected(
envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()),
));
}
envelope_data(&value)
} else {
@@ -453,19 +595,25 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !valid {
return Err(envelope_message(payload)
.or_else(|| json_string_field(payload, "message", "message"))
.unwrap_or_else(|| "许可证无效".to_string()));
return Err(VerifyError::Rejected(
envelope_message(payload)
.or_else(|| json_string_field(payload, "message", "message"))
.unwrap_or_else(|| "许可证无效".to_string()),
));
}
verify_response_sig(payload).map_err(VerifyError::Rejected)?;
Ok(parse_remote_info(payload))
}
fn remote_deactivate(license_key: &str, device_id: &str) {
let body = serde_json::json!({
fn remote_deactivate(license_key: &str, device_id: &str, activation_token: Option<&str>) {
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<LicenseStatus, String> {
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<LicenseStatus, String> {
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<LicenseStatus, String>
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<LicenseStatus, String> {
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=<device_id> \
/// 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());
}
}
+108 -13
View File
@@ -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<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn Child + Send + Sync>,
output_live: Arc<AtomicBool>,
pending_output: Arc<Mutex<Vec<u8>>>,
}
#[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<Vec<u8>, 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::<Vec<_>>() {
if session.child.kill().is_ok() {
killed += 1;
}
}
killed
}
}
fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box<dyn Read + Send>) {
fn spawn_reader(
app: AppHandle,
shell_id: String,
mut reader: Box<dyn Read + Send>,
output_live: Arc<AtomicBool>,
pending_output: Arc<Mutex<Vec<u8>>>,
) {
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());
}
+35
View File
@@ -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<Value>) -> 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<Option<Value>, String> {
let _guard = self.lock.lock().map_err(lock_error)?;
Ok(read_data(app)?.settings.get(key).cloned())
+4 -15
View File
@@ -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"]
}
}
}
+24 -10
View File
@@ -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 (
<AppShell
@@ -582,6 +595,7 @@ function App() {
if (!requirePro('vnc')) return
setDesktopConnectProtocol('vnc')
}}
onImportSessions={() => void handleImportSessions()}
onOpenLauncher={() => setIsLauncherOpen(true)}
onEditSession={openEditSession}
>
+6 -5
View File
@@ -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"
>
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2" />
@@ -81,7 +82,7 @@ function ExpandSidebarButton({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="titlebar-no-drag group flex h-10 w-9 flex-shrink-0 items-center justify-center border-r border-dark-700/30 text-txt-faint transition-colors hover:bg-dark-700/50 hover:text-txt-muted"
className="titlebar-no-drag border-dark-700/30 hover:bg-dark-700/50 group flex h-10 w-9 flex-shrink-0 items-center justify-center border-r text-txt-faint transition-colors hover:text-txt-muted"
title={t('sidebar.expand')}
aria-label={t('sidebar.expandAria')}
>
@@ -102,6 +103,7 @@ export default function AppShell({
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
onOpenLauncher,
onEditSession,
children,
@@ -161,9 +163,7 @@ export default function AppShell({
</aside>
)}
<main
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
>
<main className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}>
{!desktopFullscreen && (
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
@@ -181,6 +181,7 @@ export default function AppShell({
onQuickConnect={onQuickConnect}
onRdpConnect={onRdpConnect}
onVncConnect={onVncConnect}
onImportSessions={onImportSessions}
/>
</div>
</main>
+126 -23
View File
@@ -7,9 +7,11 @@ import {
Loader2,
Plus,
Send,
ShieldAlert,
Sparkles,
Square,
TerminalSquare,
TextSelect,
X,
} from 'lucide-react'
import { useAiProviderStore } from '../../stores/aiProviderStore'
@@ -25,6 +27,7 @@ import {
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
@@ -41,6 +44,8 @@ interface AiTerminalPanelProps {
connectionId?: string | null
targetLabel?: string
isLocal?: boolean
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
insertToTerminal?: (text: string) => boolean
}
interface AgentStep {
@@ -146,12 +151,12 @@ export default function AiTerminalPanel({
connectionId = null,
targetLabel = '当前 SSH 会话',
isLocal = false,
insertToTerminal,
}: AiTerminalPanelProps) {
const { t } = useTranslation('ai')
const store = useAiProviderStore()
const safetyMode = normalizeAgentSafetyMode(
useSettingsStore((state) => state.settings.agentSafetyMode)
)
const safetyMode = normalizeAgentSafetyMode(useSettingsStore((state) => state.settings.agentSafetyMode))
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<ThreadMessage[]>([])
@@ -231,6 +236,7 @@ export default function AiTerminalPanel({
setRunning(true)
setLiveSteps([])
const runSafetyMode = safetyMode
const runConfirmAll = confirmAllCommands
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
@@ -293,7 +299,7 @@ export default function AiTerminalPanel({
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
}
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
const fingerprint = agentCommandAllowFingerprint(command, risk)
if (!sessionAllowRef.current.has(fingerprint)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
@@ -308,11 +314,33 @@ export default function AiTerminalPanel({
danger: risk === 'high',
})
if (runIdRef.current !== runId) return
if (choice === 'insert') {
const inserted = insertToTerminal?.(command) || false
if (!inserted) {
await writeClipboardText(command)
}
setLiveSteps([])
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }),
steps: [...completedSteps],
},
])
return
}
if (choice === 'reject') {
setLiveSteps([])
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
{
id: messageId(),
role: 'assistant',
content: t('step.rejected', { round }),
steps: [...completedSteps],
},
])
return
}
@@ -323,7 +351,7 @@ export default function AiTerminalPanel({
}
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
return
@@ -371,6 +399,50 @@ export default function AiTerminalPanel({
window.setTimeout(() => setCopiedId(''), 1200)
}
/** ④ 对当前终端可见输出做一次只读总结(不进入 Agent 循环、不执行命令)。 */
const summarizeViewport = async () => {
setError('')
if (!provider || !model) {
setError(t('error.noProvider'))
return
}
if (!provider.hasApiKey && provider.authStyle !== 'none') {
setError(t('error.noApiKey', { provider: provider.name }))
return
}
const context = capture().selection.trim() || capture().viewport.trim()
if (!context) {
setError(t('error.noOutput'))
return
}
setMessages((prev) => [...prev, { id: messageId(), role: 'user', content: t('panel.summarizeRequest') }])
const runId = runIdRef.current + 1
runIdRef.current = runId
setRunning(true)
try {
const completion = await window.opsxApi.aiComplete({
provider,
model,
action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
}
setMessages((prev) => [...prev, { id: messageId(), role: 'assistant', content: completion.text!.trim() }])
} catch (caught) {
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
setError(message)
} finally {
if (runIdRef.current === runId) {
setRunning(false)
}
}
}
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
@@ -398,7 +470,7 @@ export default function AiTerminalPanel({
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850">
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent-blue" />
@@ -433,6 +505,19 @@ export default function AiTerminalPanel({
</button>
)
})}
<button
type="button"
aria-pressed={confirmAllCommands}
disabled={running}
title={t('safety.confirmAllTitle')}
aria-label={t('safety.confirmAllTitle')}
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
className={`ml-1 rounded p-1 disabled:opacity-50 ${
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
}`}
>
<ShieldAlert className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
@@ -444,7 +529,12 @@ export default function AiTerminalPanel({
>
<Plus className="h-4 w-4" />
</button>
<button type="button" title={t('panel.close')} onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<button
type="button"
title={t('panel.close')}
onClick={onClose}
className="rounded p-1 text-txt-muted hover:bg-dark-700"
>
<X className="h-4 w-4" />
</button>
</div>
@@ -452,9 +542,7 @@ export default function AiTerminalPanel({
<div ref={timelineRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && liveSteps.length === 0 && !running && (
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
{t('panel.emptyHint')}
</p>
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">{t('panel.emptyHint')}</p>
)}
{messages.map((message) => (
@@ -504,7 +592,10 @@ export default function AiTerminalPanel({
)}
{error && (
<div role="alert" className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300">
<div
role="alert"
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)}
@@ -534,13 +625,29 @@ export default function AiTerminalPanel({
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
</span>
{running ? (
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
</button>
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
{t('panel.stop')}
</Button>
) : (
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex items-center gap-1.5">
<Send className="h-3.5 w-3.5" /> {t('panel.send')}
</button>
<>
<Button
variant="ghost"
size="sm"
icon={<TextSelect className="h-3.5 w-3.5" />}
title={t('panel.summarizeTitle')}
onClick={() => void summarizeViewport()}
>
{t('panel.summarize')}
</Button>
<Button
variant="primary"
size="sm"
icon={<Send className="h-3.5 w-3.5" />}
onClick={() => void runAgent()}
>
{t('panel.send')}
</Button>
</>
)}
</div>
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
@@ -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 (
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
+49 -65
View File
@@ -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<CommandPaletteItemType, string> = {
// ─── 组件 ──────────────────────────────────────────────────────────────
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: <Code2 size={16} />,
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 */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" />
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
{/* Palette container */}
<div
className="relative w-[600px] max-h-[460px] bg-dark-800 border border-dark-700 rounded-lg shadow-2xl overflow-hidden flex flex-col animate-scale-in"
className="relative flex max-h-[460px] w-[600px] animate-scale-in flex-col overflow-hidden rounded-lg border border-dark-700 bg-dark-800 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={t('palette.aria')}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
<Search size={18} className="text-txt-muted flex-shrink-0" />
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
<Search size={18} className="flex-shrink-0 text-txt-muted" />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="palette-listbox"
aria-activedescendant={filteredItems.length > 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}
/>
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] text-txt-muted bg-dark-700 rounded border border-dark-600">
<kbd className="hidden items-center gap-0.5 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[10px] text-txt-muted sm:inline-flex">
ESC
</kbd>
</div>
{/* Results list */}
<div ref={listRef} className="flex-1 overflow-y-auto py-1">
<div
ref={listRef}
id="palette-listbox"
role="listbox"
aria-label={t('palette.aria')}
className="flex-1 overflow-y-auto py-1"
>
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
<Search size={32} className="mb-2 opacity-30" />
<p className="text-sm">{t('palette.empty')}</p>
<p className="text-xs text-txt-faint mt-1">{t('palette.emptyHint')}</p>
<p className="mt-1 text-xs text-txt-faint">{t('palette.emptyHint')}</p>
</div>
) : (
groupedItems.map((group) => {
@@ -374,13 +368,13 @@ export function CommandPalette({
return (
<div key={group.type}>
{/* Group header */}
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium text-txt-muted uppercase tracking-wider">
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium uppercase tracking-wider text-txt-muted">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
>
{t(TYPE_BADGE_KEY[group.type])}
</span>
<span className="flex-1 h-px bg-dark-700" />
<span className="h-px flex-1 bg-dark-700" />
<span className="text-txt-faint">{group.items.length}</span>
</div>
@@ -391,14 +385,15 @@ export function CommandPalette({
return (
<div
key={item.id}
id={`palette-item-${globalIndex}`}
role="option"
aria-selected={isSelected}
data-index={globalIndex}
className={`
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
${isSelected
className={`flex cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-75 ${
isSelected
? 'bg-accent-blue/15 text-content-primary'
: 'text-content-secondary hover:bg-dark-700'
}
`}
} `}
onClick={() => {
item.action()
close()
@@ -407,7 +402,7 @@ export function CommandPalette({
>
{/* Icon */}
<span
className={`flex-shrink-0 w-5 h-5 flex items-center justify-center ${
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center ${
isSelected ? 'text-accent-blue' : 'text-txt-muted'
}`}
>
@@ -415,28 +410,19 @@ export function CommandPalette({
</span>
{/* Label + description */}
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{item.label}</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{item.label}</div>
{item.description && (
<div className="text-[11px] text-txt-muted truncate">
{item.description}
</div>
<div className="truncate text-[11px] text-txt-muted">{item.description}</div>
)}
</div>
{/* Shortcut */}
{item.shortcut && (
<kbd className="flex-shrink-0 text-[11px] text-txt-muted bg-dark-700 px-1.5 py-0.5 rounded border border-dark-600">
<kbd className="flex-shrink-0 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[11px] text-txt-muted">
{item.shortcut}
</kbd>
)}
{/* Type badge */}
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[item.type]}`}
>
{t(TYPE_BADGE_KEY[item.type])}
</span>
</div>
)
})}
@@ -447,24 +433,22 @@ export function CommandPalette({
</div>
{/* Footer hint */}
<div className="flex items-center justify-between px-4 py-2 border-t border-dark-700 text-[11px] text-txt-faint">
<div className="flex items-center justify-between border-t border-dark-700 px-4 py-2 text-[11px] text-txt-faint">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↑↓</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↑↓</kbd>
{t('palette.hint.nav')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↵</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↵</kbd>
{t('palette.hint.run')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">esc</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">esc</kbd>
{t('palette.hint.close')}
</span>
</div>
<span>
{t('palette.resultCount', { count: filteredItems.length })}
</span>
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
</div>
</div>
</div>
+7 -8
View File
@@ -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 ? (
<AlertTriangle className="w-5 h-5 text-red-400" />
) : undefined
}
icon={state.danger ? <AlertTriangle className="h-5 w-5 text-red-400" /> : undefined}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
</Modal>
@@ -171,12 +167,15 @@ export function useConfirmDialog() {
showConfirm={false}
size="sm"
disableBackdropClose
icon={<AlertTriangle className="w-5 h-5 text-red-400" />}
icon={<AlertTriangle className="h-5 w-5 text-red-400" />}
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
{t('ai:confirm.reject')}
</Button>
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
{t('ai:confirm.insert')}
</Button>
<Button
variant={agentRisk.danger ? 'danger' : 'primary'}
size="sm"
+25
View File
@@ -0,0 +1,25 @@
import type { ConnectionState } from '../../../shared/types/connection.types'
const STATUS_DOT: Record<ConnectionState, string> = {
connected: 'bg-feedback-success',
connecting: 'bg-feedback-warning animate-pulse',
reconnecting: 'bg-feedback-warning animate-pulse',
error: 'bg-feedback-error',
suspended: 'bg-surface-active',
disconnected: 'bg-surface-active',
background: 'bg-feedback-success opacity-60',
}
export default function StatusDot({
state,
className = 'h-1.5 w-1.5',
}: {
state: ConnectionState
className?: string
}) {
return (
<span
className={`inline-block flex-shrink-0 rounded-full ${STATUS_DOT[state] ?? 'bg-surface-active'} ${className}`}
/>
)
}
+31 -79
View File
@@ -6,20 +6,13 @@
import { useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Wifi,
WifiOff,
RefreshCw,
X,
Clock,
HardDrive,
Activity,
} from 'lucide-react'
import { Wifi, WifiOff, RefreshCw, X, Clock, HardDrive, Activity } from 'lucide-react'
import { useConnectionStore } from '../../stores/connection'
import { usePoolStore } from '../../stores/connection/poolStore'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import type { PooledConnection, ConnectionState } from '../../stores/connection/types'
import type { PooledConnection } from '../../stores/connection/types'
import EmptyState from '../common/EmptyState'
import StatusDot from '../common/StatusDot'
// ============== Helpers ==============
@@ -43,30 +36,6 @@ function formatDuration(ms: number): string {
return `${hours}h ${minutes % 60}m`
}
// ============== Status Dot ==============
interface StatusDotProps {
state: ConnectionState
}
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
connected: 'bg-green-400',
connecting: 'bg-yellow-400 animate-pulse',
background: 'bg-green-400 opacity-60',
suspended: 'bg-surface-active',
error: 'bg-red-400',
disconnected: 'bg-surface-active',
reconnecting: 'bg-yellow-400 animate-pulse',
}
function StatusDot({ state }: StatusDotProps) {
return (
<span
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-surface-active'}`}
/>
)
}
// ============== Connection Card ==============
interface ConnectionCardProps {
@@ -79,26 +48,24 @@ interface ConnectionCardProps {
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
const { t } = useTranslation('common')
const isReconnecting = reconnectingId === conn.connectionId
const canReconnect =
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect =
conn.state !== 'disconnected' && conn.state !== 'error'
const canReconnect = conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect = conn.state !== 'disconnected' && conn.state !== 'error'
const durationMs = Date.now() - conn.stats.connectedAt
const sessionCount = conn.shellIds.size + conn.sftpIds.size
return (
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
<div className="space-y-2.5 rounded-lg bg-dark-900 p-3">
{/* Header: host + status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<StatusDot state={conn.state} />
<span className="text-sm text-content-primary truncate font-medium">
<div className="flex min-w-0 items-center gap-2">
<StatusDot state={conn.state} className="h-2 w-2" />
<span className="truncate text-sm font-medium text-content-primary">
{conn.config.username}@{conn.config.host}
</span>
</div>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
className={`rounded px-1.5 py-0.5 text-xs ${
conn.state === 'connected'
? 'bg-feedback-success/10 text-feedback-success'
: conn.state === 'connecting' || conn.state === 'reconnecting'
@@ -120,20 +87,19 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-txt-muted">
{/* Port */}
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
:{conn.config.port ?? 22}
<Activity className="h-3 w-3" />:{conn.config.port ?? 22}
</span>
{/* Duration */}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<Clock className="h-3 w-3" />
{formatDuration(durationMs)}
</span>
{/* Transfer */}
{conn.stats.bytesTransferred > 0 && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
<HardDrive className="h-3 w-3" />
{formatBytes(conn.stats.bytesTransferred)}
</span>
)}
@@ -156,11 +122,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
type="button"
onClick={() => onReconnect(conn.connectionId)}
disabled={isReconnecting}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1 rounded bg-accent-blue/10 px-2 py-1 text-xs text-accent-blue transition-colors hover:bg-accent-blue/20 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-3 w-3 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? t('connectionPanel.reconnecting') : t('connectionPanel.reconnect')}
</button>
)}
@@ -168,10 +132,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<button
type="button"
onClick={() => onDisconnect(conn.connectionId)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
className="flex items-center gap-1 rounded bg-red-400/10 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-400/20"
>
<WifiOff className="w-3 h-3" />
<WifiOff className="h-3 w-3" />
{t('connectionPanel.disconnect')}
</button>
)}
@@ -179,7 +142,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
{/* Error message */}
{conn.error && (
<p className="text-xs text-red-400 truncate" title={conn.error}>
<p className="truncate text-xs text-red-400" title={conn.error}>
{conn.error}
</p>
)}
@@ -208,7 +171,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
return (
<div className="grid grid-cols-4 gap-2">
{items.map((item) => (
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
<div key={item.label} className="rounded-lg bg-dark-900 p-2 text-center">
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
<div className="text-[10px] text-txt-muted">{item.label}</div>
</div>
@@ -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<string | null>(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 */}
<div
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
onClick={close}
/>
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
{/* Panel */}
<div
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
flex flex-col shadow-xl animate-slide-in-right"
>
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right fixed bottom-0 right-0 top-0 z-50 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
<h2 className="text-sm font-semibold text-content-primary flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" />
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
<Wifi className="h-4 w-4 text-accent-blue" />
{t('connectionPanel.title')}
</h2>
<button
type="button"
onClick={close}
className="p-1 rounded hover:bg-dark-700 text-txt-muted hover:text-content-primary transition-colors"
className="rounded p-1 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
{/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
<div className="border-dark-700/30 shrink-0 border-b px-4 py-3">
<StatsOverview
total={stats.total}
connected={stats.connected}
@@ -302,7 +254,7 @@ export default function ConnectionPanel() {
</div>
{/* Connection List */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
{connList.length === 0 ? (
<EmptyState
icon={WifiOff}
+11 -36
View File
@@ -1,18 +1,10 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Maximize2,
Minimize2,
Monitor,
RefreshCw,
} from 'lucide-react'
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import StatusDot from '../common/StatusDot'
import type {
DesktopFrameEvent,
DesktopPointerButton,
@@ -96,9 +88,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const tab = useTabStore((state) => 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
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
fullscreenActive
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
: 'border-b border-dark-700/50 bg-dark-800/80'
? 'border-dark-700/60 bg-dark-900/95 rounded-b-lg border border-t-0 shadow-lg shadow-black/40 backdrop-blur'
: 'border-dark-700/50 bg-dark-800/80 border-b'
}`}
onMouseEnter={() => {
if (fullscreenActive) {
@@ -636,11 +615,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
<StatusDot state={connected ? 'connected' : tab?.connectionStatus === 'error' ? 'error' : 'disconnected'} />
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
@@ -739,7 +714,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
<canvas
ref={canvasRef}
tabIndex={0}
className="cursor-default outline-none shadow-lg shadow-black/40"
className="cursor-default shadow-lg shadow-black/40 outline-none"
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
+280 -188
View File
@@ -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<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
@@ -54,7 +58,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const [batchMode, setBatchMode] = useState(false)
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
const requirePro = useEntitlementStore((s) => s.requirePro)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={sidebarRef}
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
style={{ width: sidebarWidth }}
>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Header */}
<div className="p-3.5 border-b border-dark-700/30">
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
<div
ref={sidebarRef}
className="bg-dark-800/95 border-dark-700/40 relative flex h-full flex-col border-r"
style={{ width: sidebarWidth }}
>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="border-dark-700/30 border-b p-3.5">
<div className="mb-3.5 flex items-center gap-2.5 px-0.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold leading-none tracking-tight text-content-primary">OpsXShell</div>
<div className="mt-1 text-[10px] text-txt-faint">{t('common:sidebar.brandSub')}</div>
</div>
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="hover:bg-dark-700/70 group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:text-txt-muted"
>
<ChevronsLeft
className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100"
strokeWidth={1.75}
/>
</button>
)}
</div>
{onCollapse && (
<div className="flex gap-1.5">
<button
type="button"
onClick={onCollapse}
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:bg-dark-700/70 hover:text-txt-muted"
onClick={onQuickConnect}
title={t('sidebar.quickConnect')}
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<ChevronsLeft className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
)}
<button
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
<div className="flex gap-1.5">
<button
type="button"
onClick={onQuickConnect}
title={t('sidebar.quickConnect')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
<button
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
<>
<div className="p-3 border-b border-dark-700/20 space-y-2">
<>
<div className="border-dark-700/20 space-y-2 border-b p-3">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder={t('sidebar.searchPlaceholder')}
/>
<SearchBox value={searchTerm} onChange={setSearchTerm} placeholder={t('sidebar.searchPlaceholder')} />
</div>
<button
type="button"
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
aria-label={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
onClick={() => (batchMode ? finishBatchMode() : setBatchMode(true))}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
batchMode
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
@@ -463,11 +553,15 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
</div>
{batchMode && (
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-txt-muted truncate">
<span className="truncate text-txt-muted">
{t('sidebar.batchSelected', { count: batchSelection.size })}
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
</span>
<button type="button" onClick={toggleSelectVisible} className="flex-shrink-0 text-accent-blue hover:underline">
<button
type="button"
onClick={toggleSelectVisible}
className="flex-shrink-0 text-accent-blue hover:underline"
>
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
? t('sidebar.deselectAllVisible')
: t('sidebar.selectAllVisible')}
@@ -486,6 +580,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
onContextMenu={handleContextMenu}
onGroupContextMenu={handleGroupContextMenu}
onBlankContextMenu={handleBlankContextMenu}
onReorderSessions={handleReorderSessions}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={() => onNewSession()}
@@ -494,7 +589,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
onToggleSelection={toggleBatchSelection}
/>
{batchMode && (
<div className="grid grid-cols-1 gap-2 border-t border-dark-700/30 p-3">
<div className="border-dark-700/30 grid grid-cols-1 gap-2 border-t p-3">
<button
type="button"
disabled={selectedSessions.length === 0}
@@ -506,91 +601,88 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
</button>
</div>
)}
</>
</>
{/* Footer */}
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>{t('sidebar.settings')}</span>
</button>
{/* Footer */}
<div className="border-dark-700/20 border-t p-3">
<button
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm text-txt-muted transition-colors hover:bg-dark-700"
>
<Settings className="h-4 w-4" />
<span>{t('sidebar.settings')}</span>
</button>
</div>
{/* Context Menu — portaled so sidebar overflow cannot stretch/clip it */}
{contextMenu &&
createPortal(
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-[200] w-max rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
onPointerDown={(e) => 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) => (
<button
key={item.action}
type="button"
onClick={() => handleContextMenuAction(item.action)}
className={`block w-full whitespace-nowrap px-2.5 py-1.5 text-left text-xs transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
}`}
>
{item.label}
</button>
))}
</div>,
document.body
)}
</div>
{/* Context Menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
onPointerDown={(e) => 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) => (
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${
item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
}
`}
>
{item.label}
</button>
))}
</div>
)}
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
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)]' : ''} `}
/>
</div>
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
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)]' : ''}
`}
/>
</div>
{ConfirmDialogComponent}
{PromptDialogComponent}
{ConfirmDialogComponent}
{PromptDialogComponent}
</>
)
}
+21 -12
View File
@@ -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<number | null>(null)
@@ -115,16 +109,28 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
return (
<div
data-tauri-drag-region
className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"
>
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist"
aria-label={t('tab.listAria')}
onKeyDown={(event) => {
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<HTMLElement>('[data-tab-item][aria-selected="true"]')?.focus()
})
}}
>
{tabs.map((tab, index) => (
<TabItem
@@ -144,6 +150,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onRename={
tab.type !== 'welcome' && tab.type !== 'settings' ? (title) => updateTabTitle(tab.id, title) : undefined
}
/>
))}
</div>
@@ -151,7 +160,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
<button
type="button"
onClick={onOpenLauncher}
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-surface-overlay text-content-secondary transition-colors hover:bg-accent-blue hover:text-white"
title={t('launcher.title')}
aria-label={t('launcher.title')}
>
+9 -15
View File
@@ -17,15 +17,10 @@ interface TabContentProps {
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
onImportSessions?: () => void
}
function ProGate({
feature,
children,
}: {
feature: ProFeature
children: React.ReactNode
}) {
function ProGate({ feature, children }: { feature: ProFeature; children: React.ReactNode }) {
const canUse = useEntitlementStore((s) => s.canUse(feature))
if (!canUse) {
return <UpgradeView feature={feature} />
@@ -39,11 +34,12 @@ function TabContent({
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
}: TabContentProps) {
const tabs = useTabStore((state) => state.tabs)
return (
<div className="relative w-full h-full">
<div className="relative h-full w-full">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
@@ -89,10 +85,7 @@ function TabContent({
break
case 'upgrade':
content = (
<UpgradeView
feature={tab.upgradeFeature as ProFeature | undefined}
comingSoon={tab.upgradeComingSoon}
/>
<UpgradeView feature={tab.upgradeFeature as ProFeature | undefined} comingSoon={tab.upgradeComingSoon} />
)
break
case 'batch':
@@ -110,6 +103,7 @@ function TabContent({
onQuickConnect={onQuickConnect}
onRdpConnect={onRdpConnect}
onVncConnect={onVncConnect}
onImportSessions={onImportSessions}
/>
)
}
@@ -117,12 +111,12 @@ function TabContent({
return (
<div
key={tab.id}
id={`tabpanel-${tab.id}`}
role="tabpanel"
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
className={`absolute inset-0 transition-opacity duration-150 ${
isActive
? 'visible opacity-100 z-10'
: 'invisible opacity-0 z-0 pointer-events-none'
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
}`}
>
{content}
+265 -33
View File
@@ -1,17 +1,25 @@
/**
* SessionTree — 分组会话列表
* 单击选中 · 双击打开终端 · 右键更多操作
* 单击选中 · 双击打开终端 · 右键更多操作 · 拖拽调整组内顺序或移动分组
*
* macOS WKWebView 的 HTML5 drop 不可靠(SFTP 已改指针拖),侧栏同样用 pointer 拖拽。
*/
import { Check, ChevronRight, Folder, ServerOff } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { SessionTreeSkeleton } from '../../common/Skeleton'
import HostDistroIcon from '../../common/HostDistroIcon'
import type { Session } from '../../../stores/sessionStore'
import EmptyState from '../../common/EmptyState'
import { collectSessionGroups, groupDisplayName } from '../../../utils/sessionGroups'
import { collectSessionGroups, compareBySortOrder, groupDisplayName } from '../../../utils/sessionGroups'
import { localeCompareText } from '../../../i18n/format'
const POINTER_DRAG_THRESHOLD = 6
const AUTO_EXPAND_MS = 400
const DRAG_BODY_CLASS = 'session-tree-dragging'
interface SessionTreeProps {
sessions: Session[]
/** Persisted empty groups + any extras to always show. */
@@ -24,6 +32,8 @@ interface SessionTreeProps {
onContextMenu: (e: React.MouseEvent, session: Session) => void
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
onBlankContextMenu?: (e: React.MouseEvent) => void
/** Commit a drag: place `dragId` at `targetIndex` (display order) of `targetGroup`. */
onReorderSessions: (dragId: string, targetGroup: string, targetIndex: number) => void
searchTerm: string
loading?: boolean
onNewSession?: () => void
@@ -32,6 +42,68 @@ interface SessionTreeProps {
onToggleSelection?: (session: Session) => void
}
interface DropTarget {
group: string
index: number
}
interface PointerDragState {
pointerId: number
sessionId: string
sessionName: string
startX: number
startY: number
active: boolean
}
function insertIndexFromY(clientY: number, row: HTMLElement): number {
const rect = row.getBoundingClientRect()
return clientY < rect.top + rect.height / 2 ? 0 : 1
}
/** Skip the insert line when the drop would leave the row where it already is. */
function quietIfNoOp(
group: string,
insertAt: number,
dragId: string,
grouped: Record<string, Session[]>
): DropTarget | null {
const list = grouped[group] || []
const dragIndex = list.findIndex((session) => session.id === dragId)
if (dragIndex >= 0 && (insertAt === dragIndex || insertAt === dragIndex + 1)) {
return null
}
return { group, index: insertAt }
}
export function resolveSessionDropTarget(
clientX: number,
clientY: number,
dragId: string,
grouped: Record<string, Session[]>
): DropTarget | null {
const hit = document.elementFromPoint?.(clientX, clientY)
if (!hit || !(hit instanceof Element)) return null
const row = hit.closest('[data-session-row]') as HTMLElement | null
if (row?.dataset.group != null && row.dataset.index != null) {
const group = row.dataset.group
const index = Number(row.dataset.index)
if (Number.isNaN(index)) return null
return quietIfNoOp(group, index + insertIndexFromY(clientY, row), dragId, grouped)
}
const zone = (hit.closest('[data-group-row]') ||
hit.closest('[data-group-empty]') ||
hit.closest('[data-group-body]')) as HTMLElement | null
if (zone?.dataset.group) {
const group = zone.dataset.group
return quietIfNoOp(group, grouped[group]?.length ?? 0, dragId, grouped)
}
return null
}
export function SessionTree({
sessions,
extraGroups = [],
@@ -43,6 +115,7 @@ export function SessionTree({
onContextMenu,
onGroupContextMenu,
onBlankContextMenu,
onReorderSessions,
searchTerm,
loading = false,
onNewSession,
@@ -51,6 +124,26 @@ export function SessionTree({
onToggleSelection,
}: SessionTreeProps) {
const { t } = useTranslation('sessions')
const [dragId, setDragId] = useState<string | null>(null)
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
const [preview, setPreview] = useState<{ x: number; y: number; name: string } | null>(null)
const dragRef = useRef<PointerDragState | null>(null)
const groupedRef = useRef<Record<string, Session[]>>({})
const expandedRef = useRef(expandedGroups)
const scrollerRef = useRef<HTMLDivElement>(null)
const suppressClickRef = useRef(false)
const expandTimerRef = useRef<number | null>(null)
const hoverGroupRef = useRef<string | null>(null)
expandedRef.current = expandedGroups
useEffect(() => {
return () => {
document.body.classList.remove(DRAG_BODY_CLASS)
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
}
}, [])
if (loading) {
return (
@@ -86,14 +179,123 @@ export function SessionTree({
}
}
for (const key of Object.keys(grouped)) {
grouped[key].sort(compareBySortOrder)
}
groupedRef.current = grouped
const groupKeys = Object.keys(grouped).sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return localeCompareText(a, b)
})
// Dragging is a manual-order gesture: disabled while filtering (the visible
// list is not the persisted order) and in batch multi-select mode.
const dragEnabled = !multiSelect && !query
const clearExpandTimer = () => {
hoverGroupRef.current = null
if (expandTimerRef.current != null) {
window.clearTimeout(expandTimerRef.current)
expandTimerRef.current = null
}
}
const maybeExpandGroup = (group: string) => {
if (hoverGroupRef.current === group) return
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
hoverGroupRef.current = group
if (expandedRef.current.has(group)) {
expandTimerRef.current = null
return
}
expandTimerRef.current = window.setTimeout(() => {
if (!expandedRef.current.has(group)) onToggleGroup(group)
}, AUTO_EXPAND_MS)
}
const resetDrag = () => {
dragRef.current = null
setDragId(null)
setDropTarget(null)
setPreview(null)
clearExpandTimer()
document.body.classList.remove(DRAG_BODY_CLASS)
}
const autoScroll = (clientY: number) => {
const scroller = scrollerRef.current
if (!scroller) return
const rect = scroller.getBoundingClientRect()
const edge = 28
if (clientY < rect.top + edge) scroller.scrollTop -= 10
else if (clientY > rect.bottom - edge) scroller.scrollTop += 10
}
const applyPointerHover = (clientX: number, clientY: number, sessionId: string) => {
const target = resolveSessionDropTarget(clientX, clientY, sessionId, groupedRef.current)
setDropTarget(target)
if (target) maybeExpandGroup(target.group)
else clearExpandTimer()
autoScroll(clientY)
}
const startPointerDrag = (session: Session, event: React.PointerEvent<HTMLElement>) => {
if (!dragEnabled || event.button !== 0) return
dragRef.current = {
pointerId: event.pointerId,
sessionId: session.id,
sessionName: session.name,
startX: event.clientX,
startY: event.clientY,
active: false,
}
event.currentTarget.setPointerCapture?.(event.pointerId)
}
const movePointerDrag = (event: React.PointerEvent<HTMLElement>) => {
const state = dragRef.current
if (!state || state.pointerId !== event.pointerId) return
if (!state.active) {
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
if (distance < POINTER_DRAG_THRESHOLD) return
state.active = true
suppressClickRef.current = true
setDragId(state.sessionId)
document.body.classList.add(DRAG_BODY_CLASS)
window.getSelection()?.removeAllRanges()
}
event.preventDefault()
setPreview({ x: event.clientX, y: event.clientY, name: state.sessionName })
applyPointerHover(event.clientX, event.clientY, state.sessionId)
}
const finishPointerDrag = (event: React.PointerEvent<HTMLElement>) => {
const state = dragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
if (state.active) {
event.preventDefault()
const target = resolveSessionDropTarget(event.clientX, event.clientY, state.sessionId, groupedRef.current)
if (target) {
onReorderSessions(state.sessionId, target.group, target.index)
if (!expandedRef.current.has(target.group)) onToggleGroup(target.group)
}
window.setTimeout(() => {
suppressClickRef.current = false
}, 0)
}
resetDrag()
}
return (
<div
ref={scrollerRef}
className="flex-1 overflow-y-auto"
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
@@ -103,11 +305,13 @@ export function SessionTree({
{groupKeys.map((group) => {
const groupSessions = grouped[group]
const isExpanded = expandedGroups.has(group)
const groupDropActive = dropTarget?.group === group && dropTarget.index === groupSessions.length
return (
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
<div key={group} className="border-dark-700/20 border-b last:border-b-0">
<button
type="button"
data-group-row
data-group={group}
aria-expanded={isExpanded}
aria-controls={`session-group-${group}`}
onClick={() => onToggleGroup(group)}
@@ -116,15 +320,17 @@ export function SessionTree({
e.stopPropagation()
onGroupContextMenu?.(e, group)
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-txt-muted hover:bg-dark-700/50 transition-colors"
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
}`}
>
<ChevronRight
className="w-4 h-4 transition-transform duration-200 ease-in-out"
className="h-4 w-4 transition-transform duration-200 ease-in-out"
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
<Folder className="w-4 h-4" />
<Folder className="h-4 w-4" />
<span className="flex-1 text-left">{groupDisplayName(group)}</span>
<span className="text-xs text-txt-faint tabular-nums">{groupSessions.length}</span>
<span className="text-xs tabular-nums text-txt-faint">{groupSessions.length}</span>
</button>
<div
@@ -133,26 +339,44 @@ export function SessionTree({
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
aria-hidden={!isExpanded}
>
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
<div
className="overflow-hidden"
data-group-body={group}
data-group={group}
{...(!isExpanded ? { inert: '' } : {})}
>
{groupSessions.length === 0 ? (
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
<div className="px-9 py-2 text-[11px] text-txt-faint" data-group-empty data-group={group}>
{t('tree.emptyTitle')}
</div>
) : (
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 (
<div
key={session.id}
data-session-row
data-group={group}
data-index={index}
role="button"
tabIndex={isExpanded ? 0 : -1}
onPointerDown={(e) => 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) && (
<span
aria-hidden
className={`pointer-events-none absolute inset-x-1 ${
showInsertBefore ? 'top-0' : 'bottom-0'
} h-0.5 rounded-full bg-accent-blue`}
/>
)}
{multiSelect ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
selected
? 'border-accent-blue bg-accent-blue text-white'
: 'border-dark-500 bg-dark-900'
selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-900'
}`}
aria-hidden
>
@@ -210,11 +436,7 @@ export function SessionTree({
})}
{filteredSessions.length === 0 && query && (
<EmptyState
icon={ServerOff}
title={t('tree.noMatchTitle')}
description={t('tree.noMatchDesc')}
/>
<EmptyState icon={ServerOff} title={t('tree.noMatchTitle')} description={t('tree.noMatchDesc')} />
)}
{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(
<div
aria-hidden
className="bg-dark-800/95 pointer-events-none fixed z-[180] max-w-[12rem] truncate rounded-md border border-accent-blue/40 px-2 py-1 text-xs text-content-primary shadow-lg"
style={{ left: preview.x + 12, top: preview.y + 12 }}
>
{preview.name}
</div>,
document.body
)}
</div>
)
}
+81 -43
View File
@@ -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<HTMLInputElement>(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 (
<HostDistroIcon
distro={
session?.distro ??
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
}
protocol={
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
}
distro={session?.distro ?? (tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)}
protocol={tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : (session?.protocol ?? 'ssh')}
size="xs"
/>
)
@@ -88,12 +111,10 @@ export function TabItem({
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
const statusDot =
tab.connectionStatus === 'connecting' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
) : tab.connectionStatus === 'connected' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
) : tab.connectionStatus === 'error' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
tab.connectionStatus === 'connecting' ||
tab.connectionStatus === 'connected' ||
tab.connectionStatus === 'error' ? (
<StatusDot state={tab.connectionStatus} />
) : 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 && (
<div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showRightIndicator && (
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showLeftIndicator && <div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />}
{showRightIndicator && <div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />}
{statusDot}
{getIcon()}
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
{displayTitle}
</span>
{editing ? (
<input
ref={renameInputRef}
value={draft}
onChange={(e) => 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"
/>
) : (
<span
className="flex-1 truncate text-sm font-medium"
title={onRename ? t('common:tab.renameHint') : displayTitle}
onDoubleClick={(e) => {
if (!onRename) return
e.stopPropagation()
startRename()
}}
>
{displayTitle}
</span>
)}
<button
type="button"
onClick={onClose}
aria-label={t('common:tab.closeAria', { title: displayTitle })}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
${isActive ? 'opacity-100' : ''}
`}
className={`rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 ${isActive ? 'opacity-100' : ''} `}
>
<X className="h-3.5 w-3.5" />
</button>
<div
className={`
absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue
transition-[opacity,transform] duration-150
${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'}
`}
className={`absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue transition-[opacity,transform] duration-150 ${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'} `}
/>
</div>
)
File diff suppressed because it is too large. Load diff
+25 -38
View File
@@ -10,22 +10,19 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
import { buildOpenTabPayload, defaultPortForProtocol, sessionProtocol } from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
onImportSessions?: () => void
}
function Kbd({ children }: { children: string }) {
return (
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
<kbd className="border-dark-600/80 bg-dark-900/80 rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
{children}
</kbd>
)
@@ -36,6 +33,7 @@ export default function WelcomeView({
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
}: WelcomeViewProps) {
const { t } = useTranslation('sessions')
const { addTab } = useTabStore()
@@ -71,15 +69,11 @@ export default function WelcomeView({
}, [shellMenuOpen])
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
const defaultShellLabel = defaultShell
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
: 'Shell'
const defaultShellLabel = defaultShell ? shellDisplayFromPath(defaultShell.path || defaultShell.name).title : 'Shell'
const openDefaultLocal = useCallback(() => {
openLocalShellTab(
defaultShell
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
: undefined
defaultShell ? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name } : undefined
)
}, [defaultShell])
@@ -112,7 +106,7 @@ export default function WelcomeView({
<button
type="button"
onClick={() => onQuickConnect?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
@@ -129,7 +123,7 @@ export default function WelcomeView({
<button
type="button"
onClick={() => onNewSession?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
@@ -144,7 +138,7 @@ export default function WelcomeView({
</button>
<div ref={shellMenuRef} className="relative">
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
<div className="border-dark-700/60 bg-dark-800/50 flex h-full overflow-hidden rounded-xl border transition-colors hover:border-dark-600 hover:bg-dark-800">
<button
type="button"
onClick={openDefaultLocal}
@@ -158,9 +152,7 @@ export default function WelcomeView({
</div>
<div>
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
{defaultShellLabel}
</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">{defaultShellLabel}</div>
</div>
</button>
<button
@@ -168,14 +160,14 @@ export default function WelcomeView({
aria-label={t('welcome.localShell.selectShell')}
aria-expanded={shellMenuOpen}
onClick={() => setShellMenuOpen((v) => !v)}
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
className="border-dark-700/60 hover:bg-dark-700/40 flex w-9 flex-shrink-0 items-center justify-center border-l text-txt-faint transition-colors hover:text-content-secondary"
>
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
</button>
</div>
{shellMenuOpen && shells.length > 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
<div className="border-dark-600/80 absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border bg-dark-800 py-1 shadow-xl">
{shells.map((s) => (
<button
key={s.path}
@@ -184,7 +176,7 @@ export default function WelcomeView({
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
setShellMenuOpen(false)
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
className="hover:bg-dark-700/70 flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors"
>
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
@@ -201,7 +193,9 @@ export default function WelcomeView({
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('welcome.sectionDesktop')}</h2>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionDesktop')}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
@@ -210,7 +204,7 @@ export default function WelcomeView({
if (!requirePro('rdp')) return
onRdpConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Monitor className="h-4 w-4" />
@@ -226,7 +220,7 @@ export default function WelcomeView({
if (!requirePro('vnc')) return
onVncConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Cast className="h-4 w-4" />
@@ -245,34 +239,29 @@ export default function WelcomeView({
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionRecent')}
</h2>
{recentSessions.length > 0 && (
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
)}
{recentSessions.length > 0 && <span className="text-[11px] text-txt-dim">{recentSessions.length}</span>}
</div>
{recentSessions.length === 0 ? (
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
<div className="border-dark-700/70 rounded-xl border border-dashed px-4 py-10">
<EmptyState
icon={Server}
title={t('welcome.recentEmpty.title')}
description={t('welcome.recentEmpty.desc')}
action={onImportSessions ? { label: t('welcome.import.title'), onClick: onImportSessions } : undefined}
/>
</div>
) : (
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
<ul className="divide-dark-700/40 border-dark-700/50 divide-y overflow-hidden rounded-xl border">
{recentSessions.map((session) => (
<li key={session.id}>
<button
type="button"
onClick={() => handleSessionClick(session)}
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
className="hover:bg-dark-800/80 group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<HostDistroIcon
distro={session.distro}
protocol={session.protocol}
size="md"
/>
<HostDistroIcon distro={session.distro} protocol={session.protocol} size="md" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
@@ -282,9 +271,7 @@ export default function WelcomeView({
</span>
</span>
{session.group ? (
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
{session.group}
</span>
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">{session.group}</span>
) : null}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
</button>
+68 -25
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import { Check, CheckCircle2, ChevronDown, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_FAMILY_ORDER,
@@ -10,6 +10,7 @@ import {
resolveProviderFamily,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
import Button from '../common/Button'
type TestState = 'idle' | 'testing' | 'success' | 'error'
@@ -133,7 +134,15 @@ export default function AiProviderSettings() {
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
models: [
{
id,
model: modelId,
label: modelId || t('ai.customModel'),
capability: 'chat',
billing: defaultManualBilling(),
},
],
defaultModelId: id,
})
}
@@ -208,13 +217,15 @@ export default function AiProviderSettings() {
</optgroup>
))}
</select>
<button
<Button
type="button"
variant="primary"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
className="ui-btn-primary flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
</button>
{t('ai.add')}
</Button>
</div>
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
@@ -233,7 +244,7 @@ export default function AiProviderSettings() {
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue'
: 'text-content-secondary hover:bg-dark-700/50'
: 'hover:bg-dark-700/50 text-content-secondary'
}`}
>
<div className="flex items-center gap-1.5">
@@ -256,7 +267,11 @@ export default function AiProviderSettings() {
<div className="max-w-xl space-y-3.5">
<div className="grid grid-cols-2 gap-3">
<Field label={t('ai.name')}>
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
<input
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
className="ui-input-sm"
/>
</Field>
<Field label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
@@ -286,7 +301,11 @@ export default function AiProviderSettings() {
</div>
{manualModel && draft.models.length > 0 && (
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
)}
@@ -298,7 +317,13 @@ export default function AiProviderSettings() {
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
placeholder={
draft.hasApiKey
? t('ai.savedKeepBlank')
: draft.authStyle === 'none'
? t('ai.notRequired')
: 'sk-...'
}
className="ui-input-sm"
/>
</Field>
@@ -330,7 +355,9 @@ export default function AiProviderSettings() {
<Field label={t('ai.protocol')}>
<select
value={draft.protocol}
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
onChange={(event) =>
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
}
className="ui-input-sm"
>
<option value="openai-chat">OpenAI Chat</option>
@@ -341,7 +368,9 @@ export default function AiProviderSettings() {
<Field label={t('ai.auth')}>
<select
value={draft.authStyle}
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
onChange={(event) =>
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
}
className="ui-input-sm"
>
<option value="bearer">Bearer</option>
@@ -362,31 +391,45 @@ export default function AiProviderSettings() {
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
<Button
type="button"
onClick={() => void testConnection()}
variant="secondary"
size="sm"
disabled={!canTest}
className="ui-btn-secondary flex items-center gap-1.5"
loading={testState === 'testing'}
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
onClick={() => void testConnection()}
>
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
</button>
<button
</Button>
<Button
type="button"
onClick={() => void save()}
variant="primary"
size="sm"
disabled={!verified || saving}
className="ui-btn-primary flex items-center gap-1.5"
loading={saving}
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
onClick={() => void save()}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{t('common:action.save')}
</button>
</Button>
{isStored && store.activeProviderId !== draft.id && (
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void store.setActiveProvider(draft.id)}
>
{t('ai.setActive')}
</button>
</Button>
)}
<div className="flex-1" />
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300">
<button
type="button"
title={t('ai.deleteTitle')}
onClick={() => void remove()}
className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
+4 -6
View File
@@ -177,7 +177,7 @@ export default function RemoteTextEditor({
aria-modal="true"
aria-label={t('editor.title', { name: fileName })}
>
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
<header className="border-dark-700/60 flex flex-shrink-0 items-center gap-3 border-b bg-dark-800 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<FileCode className="h-4 w-4" />
@@ -216,9 +216,7 @@ export default function RemoteTextEditor({
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
dirty
? 'bg-accent-blue text-white hover:bg-blue-500'
: 'bg-dark-700 text-txt-muted'
dirty ? 'bg-accent-blue text-white hover:bg-blue-500' : 'bg-dark-700 text-txt-muted'
}`}
>
<Save className="h-3.5 w-3.5" />
@@ -242,7 +240,7 @@ export default function RemoteTextEditor({
</div>
)}
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
<div className={`min-h-0 flex-1 ${effective === 'light' ? 'bg-white' : 'bg-[#1e1e1e]'}`}>
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('common:status.loading')}
@@ -271,7 +269,7 @@ export default function RemoteTextEditor({
)}
</div>
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<footer className="border-dark-700/60 flex flex-shrink-0 items-center justify-between gap-3 border-t bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
+24 -11
View File
@@ -7,16 +7,23 @@ export interface ShellClosedOverlayProps {
reason: string
idleTimeout: boolean
restarting?: boolean
isLocal?: boolean
onReopen: () => void
}
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
export function ShellClosedOverlay({
reason,
idleTimeout,
restarting = false,
isLocal = false,
onReopen,
}: ShellClosedOverlayProps) {
const { t } = useTranslation('terminal')
return (
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
{idleTimeout ? (
<Clock3 className="h-6 w-6 text-accent-blue" />
) : (
@@ -27,7 +34,9 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
<div className="mb-4 text-[11px] text-accent-green">{t('overlay.shellClosedHint')}</div>
<div className="mb-4 text-[11px] text-accent-green">
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
</div>
<button
type="button"
disabled={restarting}
@@ -48,6 +57,7 @@ export interface TerminalOverlayProps {
showSuspendedOverlay: boolean
needsReconnect: boolean
reconnectAttempt?: number
isLocal?: boolean
onReconnect: () => void
onResume: () => void
}
@@ -58,6 +68,7 @@ function TerminalOverlay({
showSuspendedOverlay,
needsReconnect,
reconnectAttempt = 0,
isLocal = false,
onReconnect,
onResume,
}: TerminalOverlayProps) {
@@ -69,17 +80,21 @@ function TerminalOverlay({
{showDisconnectBanner && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="mb-1.5 text-base font-medium text-slate-100">{t('overlay.disconnected')}</div>
<div className="mb-1.5 text-base font-medium text-slate-100">
{t(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')}
</div>
<div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
{statusMessage?.trim()
? statusMessage
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')}
</div>
<button
type="button"
onClick={onReconnect}
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm shadow-accent-blue/20 transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="h-4 w-4" />
{t('overlay.reconnect')}
@@ -121,7 +136,7 @@ function TerminalOverlay({
</div>
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
<div className="mb-5 text-sm leading-relaxed text-slate-400">
{statusMessage || t('overlay.connectFailedHint')}
{statusMessage || t(isLocal ? 'overlay.localConnectFailedHint' : 'overlay.connectFailedHint')}
</div>
<button
type="button"
@@ -142,9 +157,7 @@ function TerminalOverlay({
<TerminalIcon className="h-4 w-4" />
{connectionState === 'reconnecting'
? t('overlay.reconnecting', {
attempt: reconnectAttempt
? t('overlay.reconnectingAttempt', { count: reconnectAttempt })
: '',
attempt: reconnectAttempt ? t('overlay.reconnectingAttempt', { count: reconnectAttempt }) : '',
})
: t('overlay.connecting')}
</div>
+3 -5
View File
@@ -15,10 +15,7 @@ import { createLogger } from '../../utils/logger'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
import TerminalOverlay from './TerminalOverlay'
@@ -311,7 +308,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
@@ -337,6 +334,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
isLocal={!!currentTab?.isLocal}
onReconnect={handleReconnect}
onResume={resumeConnection}
/>
+205 -171
View File
@@ -36,7 +36,7 @@ import { useEntitlementStore } from '../../stores/entitlementStore'
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
import { shellWrite, shellResize, shellCreate, shellKill, flushShellBootstrap } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
@@ -48,10 +48,7 @@ import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import TerminalContextMenu from './TerminalContextMenu'
import i18n from '../../i18n'
@@ -78,6 +75,8 @@ interface PaneRuntime {
pasteClipboard: () => void
selectAll: () => void
clear: () => void
/** 写入命令文本但不带回车:插入到当前输入行,由用户自行检查后执行。 */
insertText: (text: string) => boolean
}
function captureTerminal(term: Terminal): TerminalAiContext {
@@ -220,11 +219,7 @@ function PaneTerminal({
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
onContextMenuRef.current(
paneId,
{ x: event.clientX, y: event.clientY },
term.hasSelection()
)
onContextMenuRef.current(paneId, { x: event.clientX, y: event.clientY }, term.hasSelection())
}
container.addEventListener('contextmenu', handleContextMenu)
@@ -250,22 +245,21 @@ function PaneTerminal({
},
onError: (error) => logger.warn('终端输入失败', error),
})
const zmodem =
isLocal
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
const sid = shellIdRef.current
if (!sid) return
return shellWrite(sid, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
const zmodem = isLocal
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
const sid = shellIdRef.current
if (!sid) return
return shellWrite(sid, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
term.onData((data) => {
if (disposed) return
zmodem?.noteInput(data)
@@ -305,9 +299,17 @@ function PaneTerminal({
getTerminal: () => xtermRef.current,
getSearchAddon: () => searchAddonRef.current,
copySelection,
pasteClipboard: () => { void pasteClipboard() },
pasteClipboard: () => {
void pasteClipboard()
},
selectAll: () => term.selectAll(),
clear: () => safeTerminalClear(term),
insertText: (text) => {
if (!shellIdRef.current || !text) return false
// xterm input() 走 onData → pipeline,与用户键入同一条路径且不带回车。
term.input(text, true)
return true
},
})
return () => {
@@ -356,6 +358,7 @@ function PaneTerminal({
const pendingId = isLocal
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
shellIdRef.current = pendingId
void (async () => {
try {
const cols = xtermRef.current?.cols || 80
@@ -377,18 +380,17 @@ function PaneTerminal({
}
shellIdRef.current = result.shellId
onShellReady(paneId, result.shellId)
// Secondary panes must open output or MOTD stays buffered and the
// reader never emits ssh:shell-data (blank cursor forever).
if (!isLocal) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
if (boot && xtermRef.current) xtermRef.current.write(boot)
} catch (err) {
logger.warn('Failed to flush split-pane shell bootstrap', err)
}
// Open buffered PTY output or the first PS1 / MOTD is dropped.
try {
const boot = await flushShellBootstrap(result.shellId)
if (boot && xtermRef.current) xtermRef.current.write(boot)
} catch (err) {
logger.warn('Failed to flush split-pane shell bootstrap', err)
}
} else {
xtermRef.current?.writeln(`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`)
xtermRef.current?.writeln(
`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`
)
}
} catch (e) {
logger.error('create shell failed', e)
@@ -419,10 +421,10 @@ function PaneTerminal({
: null
return (
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
<div className={`relative h-full w-full ${isFocused ? 'ring-1 ring-accent-blue/40' : ''}`} onMouseDown={onFocus}>
<div ref={containerRef} className="terminal-surface h-full w-full" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
@@ -461,15 +463,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const broadcastTargetCount = useTerminalBroadcastStore(
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
)
const readyPaneCount = useMemo(
() => panes.filter((pane) => !!pane.shellId).length,
[panes]
)
const readyPaneCount = useMemo(() => panes.filter((pane) => !!pane.shellId).length, [panes])
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
const effectiveBroadcastCount =
broadcastScope === 'current-tab'
? Math.max(broadcastTargetCount, readyPaneCount)
: broadcastTargetCount
broadcastScope === 'current-tab' ? Math.max(broadcastTargetCount, readyPaneCount) : broadcastTargetCount
const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
@@ -546,34 +543,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
})
}, [tabId, direction, panes.length, ratio, updateTab])
// Sync primary shell from connection hook → first pane
// Sync primary shell from connection hook → first pane.
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
useEffect(() => {
if (connectionState === 'connected' && shellIdRef.current) {
setPanes((prev) => {
if (prev.length === 0) return prev
if (prev[0].shellId === shellIdRef.current) return prev
return prev.map((p, i) => (i === 0 ? { ...p, shellId: shellIdRef.current } : p))
})
}
}, [connectionState, shellIdRef])
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
if (!sid) return
if (connectionState !== 'connected' && connectionState !== 'connecting') return
setPanes((prev) => {
if (prev.length === 0) return prev
if (prev[0].shellId === sid) return prev
if (prev[0].shellId) return prev
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
})
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
useEffect(() => {
const connectionId = connectionIdRef.current
const targets = connectionState === 'connected' && connectionId
? panes.flatMap((pane) => pane.shellId
? [{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
sessionId: currentTab?.sessionId,
}]
: [])
: []
const targets =
connectionState === 'connected' && connectionId
? panes.flatMap((pane) =>
pane.shellId
? [
{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
sessionId: currentTab?.sessionId,
},
]
: []
)
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId, t])
}, [
connectionIdRef,
connectionState,
currentTab?.isLocal,
currentTab?.sessionId,
currentTab?.title,
panes,
registerBroadcastWorkspace,
tabId,
t,
])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
@@ -721,17 +736,20 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const unregisterShellHandler = useCallback((paneId: string) => {
shellHandlersRef.current.delete(paneId)
}, [])
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
}, [armInitialConnect, focusedPaneId])
const registerRuntime = useCallback(
(paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
},
[armInitialConnect, focusedPaneId]
)
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
if (panesRef.current[0]?.id === paneId) {
@@ -802,7 +820,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
preferredId
)
if (!result.success || !result.shellId) {
throw new Error(result.error || (isLocal ? i18n.t('terminal:zmodem.createLocalShellFailed') : i18n.t('terminal:zmodem.createRemoteShellFailed')))
throw new Error(
result.error ||
(isLocal
? i18n.t('terminal:zmodem.createLocalShellFailed')
: i18n.t('terminal:zmodem.createRemoteShellFailed'))
)
}
if (!isLocal) {
getConnectionStore().addShell(connectionId, result.shellId)
@@ -819,14 +842,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
shellId: result.shellId,
})
}
if (!isLocal && result.shellId) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
if (boot && term) term.write(boot)
} catch (err) {
logger.warn('Failed to flush reopened shell bootstrap', err)
}
try {
const boot = await flushShellBootstrap(result.shellId)
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
if (boot && term) term.write(boot)
} catch (err) {
logger.warn('Failed to flush reopened shell bootstrap', err)
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
@@ -839,16 +860,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)
}
},
[connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.localShellPath, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
[
connectionIdRef,
connectionState,
currentTab?.isLocal,
currentTab?.localShellPath,
focusedPaneId,
shellIdRef,
tabId,
updateTabConnectionInstance,
]
)
const handlePaneInput = useCallback(
(sourcePaneId: string, data: string) => {
const shouldBroadcast = shouldBroadcastTerminalInput(
broadcastInput,
currentTab?.isLocal,
connectionIdRef.current
)
const shouldBroadcast = shouldBroadcastTerminalInput(broadcastInput, currentTab?.isLocal, connectionIdRef.current)
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
const targets = shouldBroadcast
? broadcastTargets.map((target) => target.shellId)
@@ -919,87 +945,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
}, [addPane, direction, isActive, setSplitDirection])
const closePane = useCallback((paneId: string) => {
if (panesRef.current.length <= 1) return
const pane = panesRef.current.find((item) => item.id === paneId)
const connectionId = connectionIdRef.current
if (pane?.shellId) {
const closingShellId = pane.shellId
void shellKill(closingShellId)
.then((result) => {
const closePane = useCallback(
(paneId: string) => {
if (panesRef.current.length <= 1) return
const pane = panesRef.current.find((item) => item.id === paneId)
const connectionId = connectionIdRef.current
if (pane?.shellId) {
const closingShellId = pane.shellId
void shellKill(closingShellId)
.then((result) => {
if (!result.success) {
logger.warn('关闭分屏 Shell 失败', {
shellId: closingShellId,
error: result.error || 'unknown',
})
}
})
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
if (connectionId && connectionId !== 'local') {
getConnectionStore().removeShell(connectionId, pane.shellId)
}
}
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
setPanes((prev) => {
const next = prev.filter((p) => p.id !== paneId)
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
setFocusedPaneId(next[0].id)
if (next[0].shellId) {
shellIdRef.current = next[0].shellId
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
}
}
return next
})
},
[connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
)
const closePaneOrTab = useCallback(
(paneId: string) => {
if (panesRef.current.length > 1) {
closePane(paneId)
return
}
const onlyTab = tabs.length === 1 ? tabs[0] : null
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
const connectionId = connectionIdRef.current
const paneShells = panesRef.current.flatMap((pane) => (pane.shellId ? [pane.shellId] : []))
void (async () => {
for (const paneShellId of paneShells) {
const result = await shellKill(paneShellId).catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}))
if (!result.success) {
logger.warn('关闭分屏 Shell 失败', {
shellId: closingShellId,
logger.warn('关闭标签 Shell 失败', {
shellId: paneShellId,
error: result.error || 'unknown',
})
}
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
}
if (connectionId && connectionId !== 'local') {
await getConnectionStore().releaseConnection(connectionId, tabId)
}
closeTab(tabId)
})()
},
[closePane, closeTab, connectionIdRef, tabId, tabs]
)
const openTerminalContextMenu = useCallback(
(paneId: string, position: { x: number; y: number }, hasSelection: boolean) => {
setFocusedPaneId(paneId)
const pane = panesRef.current.find((item) => item.id === paneId)
if (pane?.shellId && connectionIdRef.current) {
shellIdRef.current = pane.shellId
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: pane.shellId,
})
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
if (connectionId && connectionId !== 'local') {
getConnectionStore().removeShell(connectionId, pane.shellId)
}
}
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
setPanes((prev) => {
const next = prev.filter((p) => p.id !== paneId)
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
setFocusedPaneId(next[0].id)
if (next[0].shellId) {
shellIdRef.current = next[0].shellId
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
}
}
return next
})
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
const closePaneOrTab = useCallback((paneId: string) => {
if (panesRef.current.length > 1) {
closePane(paneId)
return
}
const onlyTab = tabs.length === 1 ? tabs[0] : null
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
const connectionId = connectionIdRef.current
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
void (async () => {
for (const paneShellId of paneShells) {
const result = await shellKill(paneShellId).catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}))
if (!result.success) {
logger.warn('关闭标签 Shell 失败', {
shellId: paneShellId,
error: result.error || 'unknown',
})
}
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
}
if (connectionId && connectionId !== 'local') {
await getConnectionStore().releaseConnection(connectionId, tabId)
}
closeTab(tabId)
})()
}, [closePane, closeTab, connectionIdRef, tabId, tabs])
const openTerminalContextMenu = useCallback((
paneId: string,
position: { x: number; y: number },
hasSelection: boolean
) => {
setFocusedPaneId(paneId)
const pane = panesRef.current.find((item) => item.id === paneId)
if (pane?.shellId && connectionIdRef.current) {
shellIdRef.current = pane.shellId
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: pane.shellId,
})
}
setContextMenu({ paneId, position, hasSelection })
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
setContextMenu({ paneId, position, hasSelection })
},
[connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance]
)
useEffect(() => {
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
@@ -1183,7 +1214,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (!requirePro('ai')) return
setAiPanelOpen(true)
}}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 text-accent-blue hover:border-accent-blue/40 hover:bg-accent-blue/15'}`}
>
<Sparkles className="h-3.5 w-3.5" /> {t('toolbar.aiAssistant')}
</button>
@@ -1256,6 +1287,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
reason={pane.closed.reason}
idleTimeout={pane.closed.idleTimeout}
restarting={pane.closed.restarting}
isLocal={isLocal}
onReopen={() => void reopenPaneShell(pane.id)}
/>
)}
@@ -1281,6 +1313,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
isLocal={isLocal}
onReconnect={() => {
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
setPanes(Array.from({ length: count }, () => ({ id: newPaneId(), shellId: null })))
@@ -1296,6 +1329,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
isLocal={false}
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
/>
)}
</div>
@@ -1315,7 +1349,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
onSplitRight={() => setSplitDirection('horizontal')}
onSplitDown={() => setSplitDirection('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onToggleMaximize={() => setMaximizedPaneId((current) => (current ? null : contextMenu.paneId))}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
onOpenSftp={
!isLocal && currentTab?.sessionId
+36 -15
View File
@@ -22,21 +22,18 @@ export interface DesktopApiContract {
) => Promise<{ success: boolean; error?: string }>
sshExec: (
connectionId: string,
command: string
command: string,
options?: { fromAgent?: boolean }
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshHostMetrics: (
connectionId: string
) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
sshHostMetrics: (connectionId: string) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshCreateShell: (
connectionId: string,
options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
shellId?: string
) => Promise<{ success: boolean; shellId?: string; error?: string }>
sshShellOpenOutput: (
shellId: string
) => Promise<{ success: boolean; data?: number[] | Uint8Array; error?: string }>
sshShellOpenOutput: (shellId: string) => Promise<{ success: boolean; data?: number[] | Uint8Array; error?: string }>
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
sshCloseShell: (shellId: string) => Promise<{ success: boolean; error?: string }>
@@ -56,9 +53,11 @@ export interface DesktopApiContract {
shellName?: string
error?: string
}>
localOpenOutput: (shellId: string) => Promise<{ success: boolean; data?: number[] | Uint8Array; error?: string }>
localWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
localResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
localKill: (shellId: string) => Promise<{ success: boolean; error?: string }>
localKillAll: () => Promise<{ success: boolean; killed?: number; error?: string }>
onLocalData: (callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void) => void
onLocalExit: (callback: (event: unknown, shellId: string) => void) => void
sshForwardLocal: (
@@ -102,15 +101,26 @@ export interface DesktopApiContract {
updates: Partial<import('../shared/types/session.types').Session>
) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
sessionReorder: (
updates: Array<import('../shared/types/session.types').SessionReorderEntry>
) => Promise<{ success: boolean; error?: string }>
// SFTP
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
sftpDisconnect: (sftpId: string) => Promise<{ success: boolean; error?: string }>
sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; error?: string }>
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpUploadDir: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpUploadDir: (
sftpId: string,
localPath: string,
remotePath: string
) => Promise<{ success: boolean; error?: string }>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpDownloadDir: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpDownloadDir: (
sftpId: string,
remotePath: string,
localPath: string
) => Promise<{ success: boolean; error?: string }>
sftpCancelTransfer: (sftpId: string) => Promise<{ success: boolean; error?: string }>
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
@@ -164,6 +174,19 @@ export interface DesktopApiContract {
planType?: string
}>
licenseDeviceId: () => Promise<string>
/** 周检:联网重验在线授权(验签/时钟/宽限期)并刷新本地缓存;离线证本地自验。 */
licenseVerify: () => Promise<{
plan: string
deviceId: string
activated: boolean
expiresAt?: number
features: string[]
token?: string
email?: string
maxDevices?: number
planType?: string
lastVerifiedAt?: number
}>
licenseSetEmail: (email: string) => Promise<{
plan: string
deviceId: string
@@ -176,11 +199,7 @@ export interface DesktopApiContract {
planType?: string
}>
/** Create Waffo checkout order via Rust → opsx-licence (avoids WebView CSP). */
licenseCheckout: (input: {
customerEmail: string
planType: string
returnUrl?: string
}) => Promise<{
licenseCheckout: (input: { customerEmail: string; planType: string; returnUrl?: string }) => Promise<{
orderNo: string
checkoutUrl: string
amount?: number
@@ -247,7 +266,9 @@ export interface DesktopApiContract {
onMenuOpenSettings: (callback: () => void) => void
onSshLinkAvailable: (callback: () => void) => void
onProLink: (callback: (url: string) => void) => void
onShellData: (callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void) => void | Promise<void>
onShellData: (
callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void
) => void | Promise<void>
onShellClosed: (
callback: (
event: unknown,
+54 -52
View File
@@ -5,7 +5,7 @@ import { getSessionConfig, getConnectionStore, type ConnectionState } from '../s
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
import { useSettingsStore } from '../stores/settingsStore'
import { expandSnippetVariables } from '../utils/snippetVariables'
import { shellCreate, shellKill, flushSshShellBootstrap } from '../utils/shellIO'
import { shellCreate, shellKill, flushShellBootstrap } from '../utils/shellIO'
import { shellDisplayFromPath } from '../utils/shellName'
import { normalizeTerminalInject } from '../utils/platformShells'
import { createLogger } from '../utils/logger'
@@ -49,10 +49,7 @@ export interface UseTerminalConnectionReturn {
setShowSuspendedOverlay: React.Dispatch<React.SetStateAction<boolean>>
}
export function useTerminalConnection({
tabId,
xtermRef,
}: UseTerminalConnectionParams): UseTerminalConnectionReturn {
export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnectionParams): UseTerminalConnectionReturn {
const connectionIdRef = useRef<string | null>(null)
const shellIdRef = useRef<string | null>(null)
const hasConnectedRef = useRef(false)
@@ -105,26 +102,23 @@ export function useTerminalConnection({
return true
}, [tabId, xtermRef])
const sendStartupCommand = useCallback(
async (config: SSHConnectionConfig, shellId: string) => {
const cmd = config.startupCommand?.trim()
if (!cmd) return
const expanded = expandSnippetVariables(cmd, {
host: config.host,
port: config.port,
username: config.username,
user: config.username,
})
// slight delay so login banner can finish
await new Promise((r) => setTimeout(r, 400))
try {
await window.opsxApi?.sshShellWrite(shellId, normalizeTerminalInject(expanded, '\r'))
} catch (err) {
logger.warn('Failed to send startup command', err)
}
},
[]
)
const sendStartupCommand = useCallback(async (config: SSHConnectionConfig, shellId: string) => {
const cmd = config.startupCommand?.trim()
if (!cmd) return
const expanded = expandSnippetVariables(cmd, {
host: config.host,
port: config.port,
username: config.username,
user: config.username,
})
// slight delay so login banner can finish
await new Promise((r) => setTimeout(r, 400))
try {
await window.opsxApi?.sshShellWrite(shellId, normalizeTerminalInject(expanded, '\r'))
} catch (err) {
logger.warn('Failed to send startup command', err)
}
}, [])
// 连接管理
const connect = useCallback(async () => {
@@ -159,6 +153,11 @@ export function useTerminalConnection({
const pendingShellId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
shellIdRef.current = pendingShellId
connectionIdRef.current = 'local'
// Publish the id before create so the pane can accept the first PS1.
updateTabConnectionInstance(tabId, {
connectionId: 'local',
shellId: pendingShellId,
})
const shellResult = await shellCreate(
true,
@@ -182,15 +181,19 @@ export function useTerminalConnection({
connectionId: 'local',
shellId: confirmedShellId,
})
try {
const boot = await flushShellBootstrap(confirmedShellId)
if (boot) xtermRef.current?.write(boot)
} catch (err) {
logger.warn('Failed to flush local shell bootstrap', err)
}
setConnectionState('connected')
updateTabStatus(tabId, 'connected')
reconnectAttemptRef.current = 0
setReconnectAttempt(0)
setStatusMessage('')
// Title / badge from actual shell (zsh / bash / …)
const display = shellDisplayFromPath(
shellResult.shellPath || shellResult.shellName
)
const display = shellDisplayFromPath(shellResult.shellPath || shellResult.shellName)
updateTab(tabId, {
title: display.title,
localShellKind: display.kind,
@@ -236,9 +239,7 @@ export function useTerminalConnection({
setConnectionState(isAutoRetry ? 'reconnecting' : 'connecting')
updateTabStatus(tabId, isAutoRetry ? 'connecting' : 'connecting')
if (isAutoRetry) {
setStatusMessage(
i18n.t('terminal:connection.autoReconnecting', { attempt: reconnectAttemptRef.current })
)
setStatusMessage(i18n.t('terminal:connection.autoReconnecting', { attempt: reconnectAttemptRef.current }))
}
try {
@@ -286,10 +287,15 @@ export function useTerminalConnection({
shellIdRef.current = pendingShellId
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
const shellResult = await shellCreate(false, connectionId, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, pendingShellId)
const shellResult = await shellCreate(
false,
connectionId,
{
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
},
pendingShellId
)
if (!shellResult?.success || !shellResult.shellId) {
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
@@ -314,7 +320,7 @@ export function useTerminalConnection({
// Flush MOTD / lastlog buffered before the UI event listener was ready.
try {
const boot = await flushSshShellBootstrap(confirmedShellId)
const boot = await flushShellBootstrap(confirmedShellId)
if (boot) xtermRef.current?.write(boot)
} catch (err) {
logger.warn('Failed to flush shell bootstrap output', err)
@@ -341,7 +347,6 @@ export function useTerminalConnection({
void sendStartupCommand(config, confirmedShellId)
xtermRef.current?.focus()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
setConnectionState('error')
@@ -370,9 +375,7 @@ export function useTerminalConnection({
setStatusMessage(
i18n.t('terminal:connection.autoReconnectLimit', {
max: maxAttempts,
reasonSuffix: reason
? i18n.t('terminal:connection.autoReconnectLimitReason', { reason })
: '',
reasonSuffix: reason ? i18n.t('terminal:connection.autoReconnectLimitReason', { reason }) : '',
})
)
setConnectionState('error')
@@ -432,18 +435,13 @@ export function useTerminalConnection({
shellIdRef.current = null
setConnectionState('disconnected')
updateTabStatus(tabId, 'disconnected')
xtermRef.current?.writeln(
`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`
)
xtermRef.current?.writeln(`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`)
scheduleAutoReconnectRef.current?.(reason)
}
window.opsxApi?.onConnectionClosed?.(handler)
return () => {
window.opsxApi?.removeListener?.(
'ssh:connection-closed',
handler as (...args: unknown[]) => void
)
window.opsxApi?.removeListener?.('ssh:connection-closed', handler as (...args: unknown[]) => void)
clearReconnectTimer()
}
}, [tabId, updateTabStatus, clearReconnectTimer, xtermRef])
@@ -461,10 +459,14 @@ export function useTerminalConnection({
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
shellIdRef.current = newShellId
const shellResult = await window.opsxApi?.sshCreateShell(connectionIdRef.current, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, newShellId)
const shellResult = await window.opsxApi?.sshCreateShell(
connectionIdRef.current,
{
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
},
newShellId
)
if (shellResult?.success && shellResult.shellId) {
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
@@ -475,7 +477,7 @@ export function useTerminalConnection({
})
try {
const boot = await flushSshShellBootstrap(shellResult.shellId)
const boot = await flushShellBootstrap(shellResult.shellId)
if (boot) xtermRef.current?.write(boot)
} catch (err) {
logger.warn('Failed to flush resumed shell bootstrap', err)
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "Follow-ups include this thread's context",
"hintSelection": "Will include {{lines}} selected lines",
"hintSteps": "Up to {{max}} steps · nothing persisted",
"footerNote": "High risk requires confirmation · critical is blocked · session not saved"
"footerNote": "High risk requires confirmation · critical is blocked · session not saved",
"summarize": "Summarize",
"summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands",
"summarizeRequest": "Summarize the current terminal output"
},
"safety": {
"aria": "Agent safety mode",
@@ -32,7 +35,8 @@
"label": "Relaxed",
"short": "Confirm high risk only",
"description": "Read-only and routine edits run automatically; deletes, restarts, installs, etc. need confirmation."
}
},
"confirmAllTitle": "Confirm every command (read-only whitelist excepted)"
},
"risk": {
"safe": "Safe",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Step {{round}} command rejected.",
"terminated": "Terminated",
"stoppedMessage": "Stopped. Interrupt requested for the running remote command."
"stoppedMessage": "Stopped. Interrupt requested for the running remote command.",
"inserted": "Round {{round}} command was inserted into the terminal input line without running; review it and press Enter yourself.",
"insertedClipboard": "Round {{round}} command was copied to the clipboard (terminal not writable); paste, review, then run."
},
"confirm": {
"reject": "Reject",
"once": "Once",
"always": "Always",
"hint": "Enter · Once Esc · Reject Always · skip similar confirmations this session"
"hint": "Enter · Once Esc · Reject Always · skip similar confirmations this session",
"insert": "Insert only"
},
"error": {
"noProvider": "Configure an AI provider in Settings → AI first",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec failed: {{error}}",
"execUnknown": "Unknown error",
"execNoOutput": "Command finished with no output",
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry"
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
"noOutput": "No terminal output to summarize"
},
"output": {
"empty": "(empty output)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "Close all",
"clone": "Duplicate tab",
"openSftp": "Open SFTP"
}
},
"renameAria": "Rename tab {{title}}",
"renameHint": "Double-click to rename"
},
"sidebar": {
"brandSub": "SSH / SFTP terminal",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "New session here",
"newGroup": "New group",
"sortByName": "Sort by name",
"renameGroup": "Rename group",
"deleteEmptyGroup": "Delete empty group"
},
@@ -94,9 +95,13 @@
"title": "VNC remote desktop",
"desc": "RFB · cross-platform graphical session"
},
"import": {
"title": "Import sessions",
"desc": "Import hosts from OpenSSH config or an export file"
},
"recentEmpty": {
"title": "No saved sessions",
"desc": "Try quick connect, or create a session to reuse later"
"desc": "Try quick connect, create a session, or import existing hosts"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "Connecting…",
"reconnecting": "Auto-reconnecting{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "Local shell exited"
"localShellExited": "Local shell exited",
"localDisconnected": "Local terminal closed",
"localDisconnectedHint": "The local shell has exited. One click below starts it again.",
"localConnectFailedHint": "Failed to start the local shell. Make sure the selected shell still exists.",
"localShellClosedHint": "Reopen the shell to keep using the local terminal."
},
"hostKey": {
"titleNew": "Verify host key",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます",
"hintSelection": "選択した {{lines}} 行を含めます",
"hintSteps": "最大 {{max}} ステップ · 何も保存しません",
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません"
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません",
"summarize": "出力を要約",
"summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません",
"summarizeRequest": "現在の端末出力を要約"
},
"safety": {
"aria": "エージェントの安全モード",
@@ -32,7 +35,8 @@
"label": "緩和",
"short": "高リスクのみ確認",
"description": "読み取り専用や日常の編集は自動実行 · 削除、再起動、インストール等は確認が必要です。"
}
},
"confirmAllTitle": "すべてのコマンドを確認(読み取り専用ホワイトリストを除く)"
},
"risk": {
"safe": "安全",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "ステップ {{round}} のコマンドは拒否されました。",
"terminated": "終了しました",
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。"
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。",
"inserted": "ラウンド {{round}} のコマンドを入力行に挿入しました(未実行)。確認してから Enter で実行してください。",
"insertedClipboard": "ラウンド {{round}} のコマンドをクリップボードにコピーしました(端末に書き込めません)。貼り付けて確認してから実行してください。"
},
"confirm": {
"reject": "拒否",
"once": "1 回のみ",
"always": "常に",
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ"
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ",
"insert": "挿入のみ"
},
"error": {
"noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec 失敗: {{error}}",
"execUnknown": "不明なエラー",
"execNoOutput": "コマンドは出力なしで完了",
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください"
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
"noOutput": "要約できる端末出力がありません"
},
"output": {
"empty": "(出力なし)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "すべて閉じる",
"clone": "タブを複製",
"openSftp": "SFTP を開く"
}
},
"renameAria": "タブ {{title}} の名前を変更",
"renameHint": "ダブルクリックで名前変更"
},
"sidebar": {
"brandSub": "SSH / SFTP ターミナル",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "ここに新規セッション",
"newGroup": "新規グループ",
"sortByName": "名前で並べ替え",
"renameGroup": "グループ名を変更",
"deleteEmptyGroup": "空のグループを削除"
},
@@ -94,9 +95,13 @@
"title": "VNC リモートデスクトップ",
"desc": "RFB · クロスプラットフォームのグラフィカルセッション"
},
"import": {
"title": "セッションをインポート",
"desc": "OpenSSH 設定またはエクスポートファイルからホストを取り込みます"
},
"recentEmpty": {
"title": "保存済みセッションはありません",
"desc": "クイック接続を試すか、後で再利用するためのセッションを作成"
"desc": "クイック接続、新規作成、または既存ホストのインポート"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "接続中…",
"reconnecting": "自動再接続中{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "ローカルシェルが終了しました"
"localShellExited": "ローカルシェルが終了しました",
"localDisconnected": "ローカル端末は終了しました",
"localDisconnectedHint": "ローカルシェルが終了しました。下のボタンで再び開始して作業を続けられます。",
"localConnectFailedHint": "ローカルシェルを起動できませんでした。選択したシェルが存在するか確認してください。",
"localShellClosedHint": "シェルを再開するとローカル端末を引き続き利用できます。"
},
"hostKey": {
"titleNew": "ホスト鍵を確認",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다",
"hintSelection": "선택한 {{lines}}줄을 포함합니다",
"hintSteps": "최대 {{max}}단계 · 저장되지 않음",
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함"
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함",
"summarize": "출력 요약",
"summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다",
"summarizeRequest": "현재 터미널 출력 요약"
},
"safety": {
"aria": "에이전트 안전 모드",
@@ -32,7 +35,8 @@
"label": "완화",
"short": "고위험만 확인",
"description": "읽기 전용 및 일상적인 편집은 자동으로 실행됩니다; 삭제, 재시작, 설치 등은 확인이 필요합니다."
}
},
"confirmAllTitle": "모든 명령 확인(읽기 전용 화이트리스트 제외)"
},
"risk": {
"safe": "안전",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "{{round}}단계 명령이 거부되었습니다.",
"terminated": "종료됨",
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다."
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다.",
"inserted": "{{round}}번째 명령을 입력 줄에 삽입했지만 실행하지 않았습니다. 확인 후 직접 Enter를 눌러 실행하세요.",
"insertedClipboard": "{{round}}번째 명령을 클립보드에 복사했습니다(터미널에 쓸 수 없음). 붙여넣고 확인한 뒤 실행하세요."
},
"confirm": {
"reject": "거부",
"once": "1회",
"always": "항상",
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기"
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기",
"insert": "삽입만"
},
"error": {
"noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec 실패: {{error}}",
"execUnknown": "알 수 없는 오류",
"execNoOutput": "명령이 출력 없이 완료됨",
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요"
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
"noOutput": "요약할 터미널 출력이 없습니다"
},
"output": {
"empty": "(빈 출력)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "모두 닫기",
"clone": "탭 복제",
"openSftp": "SFTP 열기"
}
},
"renameAria": "탭 {{title}} 이름 바꾸기",
"renameHint": "더블 클릭해 이름 변경"
},
"sidebar": {
"brandSub": "SSH / SFTP 터미널",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "여기에 새 세션",
"newGroup": "새 그룹",
"sortByName": "이름순 정렬",
"renameGroup": "그룹 이름 변경",
"deleteEmptyGroup": "빈 그룹 삭제"
},
@@ -94,9 +95,13 @@
"title": "VNC 원격 데스크톱",
"desc": "RFB · 크로스 플랫폼 그래픽 세션"
},
"import": {
"title": "세션 가져오기",
"desc": "OpenSSH 설정 또는 내보내기 파일에서 호스트를 가져옵니다"
},
"recentEmpty": {
"title": "저장된 세션 없음",
"desc": "빠른 연결을 시도하거나, 나중에 재사용할 세션을 생성하세요"
"desc": "빠른 연결, 세션 생성, 또는 기존 호스트 가져오기"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "연결 중…",
"reconnecting": "자동 재연결 중{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "로컬 셸이 종료됨"
"localShellExited": "로컬 셸이 종료됨",
"localDisconnected": "로컬 터미널 종료됨",
"localDisconnectedHint": "로컬 셸이 종료되었습니다. 아래를 누르면 다시 시작해 작업을 이어갑니다.",
"localConnectFailedHint": "로컬 셸을 시작하지 못했습니다. 선택한 셸이 존재하는지 확인하세요.",
"localShellClosedHint": "셸을 다시 열면 로컬 터미널을 계속 사용할 수 있습니다."
},
"hostKey": {
"titleNew": "호스트 키 확인",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "Уточнения включают контекст этой ветки",
"hintSelection": "Будет включено {{lines}} выбранных строк",
"hintSteps": "До {{max}} шагов · ничего не сохраняется",
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется"
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется",
"summarize": "Сводка",
"summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются",
"summarizeRequest": "Суммируйте текущий вывод терминала"
},
"safety": {
"aria": "Режим безопасности агента",
@@ -32,7 +35,8 @@
"label": "Свободный",
"short": "Подтверждать только высокий риск",
"description": "Команды только для чтения и обычные правки выполняются автоматически; удаления, перезапуски, установки и т. п. требуют подтверждения."
}
},
"confirmAllTitle": "Подтверждать каждую команду (кроме белого списка только для чтения)"
},
"risk": {
"safe": "Безопасно",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Команда шага {{round}} отклонена.",
"terminated": "Прервано",
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды."
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды.",
"inserted": "Команда раунда {{round}} вставлена в строку ввода терминала без выполнения; проверьте и нажмите Enter сами.",
"insertedClipboard": "Команда раунда {{round}} скопирована в буфер обмена (терминал недоступен для записи); вставьте, проверьте и выполните."
},
"confirm": {
"reject": "Отклонить",
"once": "Один раз",
"always": "Всегда",
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе"
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе",
"insert": "Только вставить"
},
"error": {
"noProvider": "Сначала настройте AI-провайдера в Настройки → AI",
@@ -73,7 +80,8 @@
"execFailed": "Сбой ssh-exec: {{error}}",
"execUnknown": "Неизвестная ошибка",
"execNoOutput": "Команда завершилась без вывода",
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите"
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
"noOutput": "Нет вывода терминала для сводки"
},
"output": {
"empty": "(пустой вывод)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "Закрыть все",
"clone": "Дублировать вкладку",
"openSftp": "Открыть SFTP"
}
},
"renameAria": "Переименовать вкладку {{title}}",
"renameHint": "Двойной клик — переименовать"
},
"sidebar": {
"brandSub": "Терминал SSH / SFTP",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "Новый сеанс здесь",
"newGroup": "Новая группа",
"sortByName": "Сортировать по имени",
"renameGroup": "Переименовать группу",
"deleteEmptyGroup": "Удалить пустую группу"
},
@@ -94,9 +95,13 @@
"title": "Удалённый рабочий стол VNC",
"desc": "RFB · кроссплатформенный графический сеанс"
},
"import": {
"title": "Импорт сеансов",
"desc": "Импорт хостов из OpenSSH config или файла экспорта"
},
"recentEmpty": {
"title": "Сохранённых сеансов нет",
"desc": "Попробуйте быстрое подключение или создайте сеанс для повторного использования"
"desc": "Быстрое подключение, новый сеанс или импорт существующих хостов"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "Подключение…",
"reconnecting": "Автопереподключение{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "Локальная оболочка завершилась"
"localShellExited": "Локальная оболочка завершилась",
"localDisconnected": "Локальный терминал закрыт",
"localDisconnectedHint": "Локальная оболочка завершилась. Один клик ниже — и она запустится снова.",
"localConnectFailedHint": "Не удалось запустить локальную оболочку. Убедитесь, что выбранная оболочка существует.",
"localShellClosedHint": "Откройте оболочку снова, чтобы продолжить работу в локальном терминале."
},
"hostKey": {
"titleNew": "Проверка ключа хоста",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "Các theo dõi bao gồm ngữ cảnh của luồng này",
"hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn",
"hintSteps": "Tối đa {{max}} bước · không lưu gì",
"footerNote": "Rủi ro cao cần xác nhận · rủi ro nghiêm trọng bị chặn · phiên không được lưu"
"footerNote": "Rủi ro cao cần xác nhận · rủi ro nghiêm trọng bị chặn · phiên không được lưu",
"summarize": "Tóm tắt",
"summarizeTitle": "AI tóm tắt đầu ra terminal đang hiển thị (ưu tiên vùng chọn), không chạy lệnh nào",
"summarizeRequest": "Tóm tắt đầu ra terminal hiện tại"
},
"safety": {
"aria": "Chế độ an toàn của agent",
@@ -32,7 +35,8 @@
"label": "Thoải mái",
"short": "Chỉ xác nhận rủi ro cao",
"description": "Chỉ đọc và chỉnh sửa thông thường chạy tự động; xóa, khởi động lại, cài đặt, v.v. cần xác nhận."
}
},
"confirmAllTitle": "Xác nhận mọi lệnh (trừ danh sách trắng chỉ đọc)"
},
"risk": {
"safe": "An toàn",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "Lệnh bước {{round}} đã bị từ chối.",
"terminated": "Đã chấm dứt",
"stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy."
"stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy.",
"inserted": "Lệnh vòng {{round}} đã được chèn vào dòng nhập của terminal, chưa chạy; hãy kiểm tra rồi tự nhấn Enter.",
"insertedClipboard": "Lệnh vòng {{round}} đã được sao chép vào clipboard (terminal không ghi được); dán, kiểm tra rồi mới chạy."
},
"confirm": {
"reject": "Từ chối",
"once": "Một lần",
"always": "Luôn luôn",
"hint": "Enter · Một lần Esc · Từ chối Luôn luôn · bỏ qua các xác nhận tương tự trong phiên này"
"hint": "Enter · Một lần Esc · Từ chối Luôn luôn · bỏ qua các xác nhận tương tự trong phiên này",
"insert": "Chỉ chèn"
},
"error": {
"noProvider": "Cấu hình nhà cung cấp AI trong Cài đặt → AI trước tiên",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec thất bại: {{error}}",
"execUnknown": "Lỗi không xác định",
"execNoOutput": "Lệnh hoàn tất không có đầu ra",
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại"
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
"noOutput": "Không có đầu ra terminal để tóm tắt"
},
"output": {
"empty": "(đầu ra rỗng)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "Đóng tất cả",
"clone": "Nhân đôi tab",
"openSftp": "Mở SFTP"
}
},
"renameAria": "Đổi tên thẻ {{title}}",
"renameHint": "Nhấn đúp để đổi tên"
},
"sidebar": {
"brandSub": "Terminal SSH / SFTP",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "Phiên mới tại đây",
"newGroup": "Nhóm mới",
"sortByName": "Sắp xếp theo tên",
"renameGroup": "Đổi tên nhóm",
"deleteEmptyGroup": "Xóa nhóm rỗng"
},
@@ -94,9 +95,13 @@
"title": "Màn hình từ xa VNC",
"desc": "RFB · phiên đồ họa đa nền tảng"
},
"import": {
"title": "Nhập phiên",
"desc": "Nhập máy chủ từ cấu hình OpenSSH hoặc tệp xuất"
},
"recentEmpty": {
"title": "Chưa có phiên đã lưu",
"desc": "Thử kết nối nhanh, hoặc tạo phiên để dùng lại sau"
"desc": "Thử kết nối nhanh, tạo phiên, hoặc nhập máy chủ có sẵn"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "Đang kết nối…",
"reconnecting": "Đang tự kết nối lại{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "Shell cục bộ đã thoát"
"localShellExited": "Shell cục bộ đã thoát",
"localDisconnected": "Terminal cục bộ đã đóng",
"localDisconnectedHint": "Shell cục bộ đã thoát. Một chạm bên dưới để mở lại và tiếp tục.",
"localConnectFailedHint": "Không thể khởi động shell cục bộ. Hãy kiểm tra shell đã chọn còn tồn tại.",
"localShellClosedHint": "Mở lại shell để tiếp tục dùng terminal cục bộ."
},
"hostKey": {
"titleNew": "Xác minh khóa máy chủ",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "继续追问将带上本轮上下文",
"hintSelection": "将附带选中 {{lines}} 行",
"hintSteps": "最多 {{max}} 步 · 不落盘",
"footerNote": "高风险需确认 · critical 拒绝 · 会话不落盘"
"footerNote": "高风险需确认 · critical 拒绝 · 会话不落盘",
"summarize": "总结输出",
"summarizeTitle": "用 AI 总结当前终端可见输出(优先使用选中文本),不执行任何命令",
"summarizeRequest": "总结当前终端输出"
},
"safety": {
"aria": "Agent 安全模式",
@@ -32,7 +35,8 @@
"label": "宽松",
"short": "仅高风险确认",
"description": "查看和普通修改自动执行;删除、重启、安装等高风险操作需要确认。"
}
},
"confirmAllTitle": "所有命令都需确认(只读白名单除外)"
},
"risk": {
"safe": "安全",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "已拒绝第 {{round}} 步命令。",
"terminated": "已终止",
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。"
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。",
"inserted": "第 {{round}} 轮命令已插入终端输入行,未执行;请检查后自行回车运行。",
"insertedClipboard": "第 {{round}} 轮命令已复制到剪贴板(当前终端不可写入);粘贴后请检查再运行。"
},
"confirm": {
"reject": "Reject",
"once": "Once",
"always": "Always",
"hint": "Enter · Once Esc · Reject Always · 本会话同类命令不再确认"
"hint": "Enter · Once Esc · Reject Always · 本会话同类命令不再确认",
"insert": "插入不执行"
},
"error": {
"noProvider": "请先在设置 → 大模型配置 Provider",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec 失败:{{error}}",
"execUnknown": "未知错误",
"execNoOutput": "命令执行完成,无输出",
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试"
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试",
"noOutput": "终端没有可总结的输出"
},
"output": {
"empty": "(空输出)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "关闭全部",
"clone": "克隆标签",
"openSftp": "打开 SFTP"
}
},
"renameAria": "重命名标签 {{title}}",
"renameHint": "双击重命名"
},
"sidebar": {
"brandSub": "SSH / SFTP 终端",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "在此新建会话",
"newGroup": "新建分组",
"sortByName": "按名称排序",
"renameGroup": "重命名分组",
"deleteEmptyGroup": "删除空分组"
},
@@ -94,9 +95,13 @@
"title": "VNC 远程桌面",
"desc": "RFB · 跨平台图形会话"
},
"import": {
"title": "导入会话",
"desc": "从 OpenSSH 配置或导出文件导入主机"
},
"recentEmpty": {
"title": "暂无已保存会话",
"desc": "用快速连接试连,或新建会话以便复用"
"desc": "用快速连接试连,新建会话,或导入已有主机"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "正在连接…",
"reconnecting": "自动重连中{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "本地 Shell 已退出"
"localShellExited": "本地 Shell 已退出",
"localDisconnected": "本地终端已关闭",
"localDisconnectedHint": "本地 Shell 会话已结束。点下方一键重新开启,即可继续使用。",
"localConnectFailedHint": "本地 Shell 启动失败,请确认所选 Shell 仍然存在",
"localShellClosedHint": "重新打开 Shell 即可继续使用本地终端"
},
"hostKey": {
"titleNew": "确认主机密钥",
+13 -5
View File
@@ -19,7 +19,10 @@
"hintFollowUp": "繼續追問將帶上本輪上下文",
"hintSelection": "將附帶選中 {{lines}} 行",
"hintSteps": "最多 {{max}} 步 · 不落盤",
"footerNote": "高風險需確認 · critical 拒絕 · 連線不落盤"
"footerNote": "高風險需確認 · critical 拒絕 · 連線不落盤",
"summarize": "摘要輸出",
"summarizeTitle": "用 AI 摘要目前終端機可見輸出(優先使用選取文字),不執行任何命令",
"summarizeRequest": "摘要目前終端機輸出"
},
"safety": {
"aria": "Agent 安全模式",
@@ -32,7 +35,8 @@
"label": "寬鬆",
"short": "僅高風險確認",
"description": "查看和一般修改自動執行;刪除、重啟、安裝等高風險操作需要確認。"
}
},
"confirmAllTitle": "所有命令都需確認(唯讀白名單除外)"
},
"risk": {
"safe": "安全",
@@ -50,13 +54,16 @@
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
"rejected": "已拒絕第 {{round}} 步命令。",
"terminated": "已終止",
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。"
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。",
"inserted": "第 {{round}} 輪命令已插入終端機輸入行,未執行;請檢查後自行按 Enter 執行。",
"insertedClipboard": "第 {{round}} 輪命令已複製到剪貼簿(目前終端機不可寫入);貼上後請檢查再執行。"
},
"confirm": {
"reject": "Reject",
"once": "Once",
"always": "Always",
"hint": "Enter · Once Esc · Reject Always · 本連線同類命令不再確認"
"hint": "Enter · Once Esc · Reject Always · 本連線同類命令不再確認",
"insert": "插入不執行"
},
"error": {
"noProvider": "請先在設定 → 大模型設定 Provider",
@@ -73,7 +80,8 @@
"execFailed": "ssh-exec 失敗:{{error}}",
"execUnknown": "未知錯誤",
"execNoOutput": "命令執行完成,無輸出",
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試"
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試",
"noOutput": "終端機沒有可摘要的輸出"
},
"output": {
"empty": "(空輸出)",
+3 -1
View File
@@ -109,7 +109,9 @@
"closeAll": "關閉全部",
"clone": "克隆分頁",
"openSftp": "開啟 SFTP"
}
},
"renameAria": "重新命名標籤 {{title}}",
"renameHint": "雙擊重新命名"
},
"sidebar": {
"brandSub": "SSH / SFTP 終端機",
+6 -1
View File
@@ -31,6 +31,7 @@
"group": {
"newSessionHere": "在此新增連線",
"newGroup": "新增群組",
"sortByName": "依名稱排序",
"renameGroup": "重新命名群組",
"deleteEmptyGroup": "刪除空群組"
},
@@ -94,9 +95,13 @@
"title": "VNC 遠端桌面",
"desc": "RFB · 跨平台圖形連線"
},
"import": {
"title": "匯入連線",
"desc": "從 OpenSSH 設定或匯出檔匯入主機"
},
"recentEmpty": {
"title": "暫無已儲存連線",
"desc": "用快速連線試連,或新增連線以便重用"
"desc": "用快速連線試連,新增連線,或匯入既有主機"
}
},
"launcher": {
+5 -1
View File
@@ -45,7 +45,11 @@
"connecting": "正在連線…",
"reconnecting": "自動重連中{{attempt}}…",
"reconnectingAttempt": " ({{count}})",
"localShellExited": "本機 Shell 已結束"
"localShellExited": "本機 Shell 已結束",
"localDisconnected": "本機終端機已關閉",
"localDisconnectedHint": "本機 Shell 會話已結束。點下方一鍵重新開啟,即可繼續使用。",
"localConnectFailedHint": "本機 Shell 啟動失敗,請確認所選 Shell 仍然存在",
"localShellClosedHint": "重新開啟 Shell 即可繼續使用本機終端機"
},
"hostKey": {
"titleNew": "確認主機金鑰",
+151 -79
View File
@@ -49,75 +49,75 @@
--color-dark-600: #475569;
/* Semantic surface tokens */
--color-surface-base: #0f172a;
--color-surface-raised: #1e293b;
--color-surface-base: #0f172a;
--color-surface-raised: #1e293b;
--color-surface-overlay: #334155;
--color-surface-inset: #475569;
--color-surface-hover: #374151;
--color-surface-active: #4b5563;
--color-surface-inset: #475569;
--color-surface-hover: #374151;
--color-surface-active: #4b5563;
/* Text */
--color-text-primary: #f1f5f9;
--color-text-primary: #f1f5f9;
--color-text-secondary: #cbd5e1;
--color-text-muted: #94a3b8;
--color-text-faint: #64748b;
--color-text-dim: #475569;
--color-text-muted: #94a3b8;
--color-text-faint: #64748b;
--color-text-dim: #475569;
/* Content */
--color-content-primary: #f1f5f9;
--color-content-primary: #f1f5f9;
--color-content-secondary: #cbd5e1;
--color-content-tertiary: #94a3b8;
--color-content-inverse: #0f172a;
--color-content-tertiary: #94a3b8;
--color-content-inverse: #0f172a;
/* Border — soft hairlines (not bright slate blocks) */
--color-border: rgba(148, 163, 184, 0.08);
--color-border-subtle: rgba(148, 163, 184, 0.06);
--color-border-default: rgba(148, 163, 184, 0.10);
--color-border-strong: rgba(148, 163, 184, 0.16);
--color-border-focus: #3b82f6;
--color-border: rgba(148, 163, 184, 0.08);
--color-border-subtle: rgba(148, 163, 184, 0.06);
--color-border-default: rgba(148, 163, 184, 0.1);
--color-border-strong: rgba(148, 163, 184, 0.16);
--color-border-focus: #3b82f6;
/* Legacy border-dark-* remaps (components still use dark-600/700 borders) */
--hairline-soft: rgba(148, 163, 184, 0.06);
--hairline: rgba(148, 163, 184, 0.10);
--hairline-soft: rgba(148, 163, 184, 0.06);
--hairline: rgba(148, 163, 184, 0.1);
--hairline-strong: rgba(148, 163, 184, 0.16);
/* Accent (+ RGB channels for Tailwind opacity modifiers) */
--color-accent-blue: #3b82f6;
--color-accent-blue-rgb: 59 130 246;
--color-accent-blue-hover: #60a5fa;
--color-accent-blue: #3b82f6;
--color-accent-blue-rgb: 59 130 246;
--color-accent-blue-hover: #60a5fa;
--color-accent-blue-hover-rgb: 96 165 250;
--color-accent-green: #10b981;
--color-accent-green-rgb: 16 185 129;
--color-accent-purple: #8b5cf6;
--color-accent-purple-rgb: 139 92 246;
--color-accent-orange: #f97316;
--color-accent-orange-rgb: 249 115 22;
--color-accent-red: #ef4444;
--color-accent-red-rgb: 239 68 68;
--color-accent-green: #10b981;
--color-accent-green-rgb: 16 185 129;
--color-accent-purple: #8b5cf6;
--color-accent-purple-rgb: 139 92 246;
--color-accent-orange: #f97316;
--color-accent-orange-rgb: 249 115 22;
--color-accent-red: #ef4444;
--color-accent-red-rgb: 239 68 68;
/* Feedback (+ RGB for Tailwind opacity) */
--color-feedback-success: #10b981;
--color-feedback-success-rgb: 16 185 129;
--color-feedback-success-bg: #064e3b;
--color-feedback-warning: #f59e0b;
--color-feedback-warning-rgb: 245 158 11;
--color-feedback-warning-bg: #78350f;
--color-feedback-error: #ef4444;
--color-feedback-error-rgb: 239 68 68;
--color-feedback-error-bg: #7f1d1d;
--color-feedback-info: #3b82f6;
--color-feedback-info-rgb: 59 130 246;
--color-feedback-info-bg: #1e3a5f;
--color-feedback-success: #10b981;
--color-feedback-success-rgb: 16 185 129;
--color-feedback-success-bg: #064e3b;
--color-feedback-warning: #f59e0b;
--color-feedback-warning-rgb: 245 158 11;
--color-feedback-warning-bg: #78350f;
--color-feedback-error: #ef4444;
--color-feedback-error-rgb: 239 68 68;
--color-feedback-error-bg: #7f1d1d;
--color-feedback-info: #3b82f6;
--color-feedback-info-rgb: 59 130 246;
--color-feedback-info-bg: #1e3a5f;
/* Placeholder */
--color-placeholder: #64748b;
--color-placeholder: #64748b;
--color-placeholder-disabled: #475569;
/* UI chrome */
--color-scrollbar-track: #1e293b;
--color-scrollbar-thumb: #475569;
--color-scrollbar-thumb-h: #64748b;
--color-shadow-color: rgba(0, 0, 0, 0.4);
--color-scrollbar-track: #1e293b;
--color-scrollbar-thumb: #475569;
--color-scrollbar-thumb-h: #64748b;
--color-shadow-color: rgba(0, 0, 0, 0.4);
/* Terminal */
--color-terminal-bg: #0f172a;
@@ -131,12 +131,12 @@
/* Light theme hairlines + chrome tweaks (useTheme also sets tokens) */
[data-theme='light'] {
--color-border: rgba(15, 23, 42, 0.06);
--color-border-subtle: rgba(15, 23, 42, 0.04);
--color-border: rgba(15, 23, 42, 0.06);
--color-border-subtle: rgba(15, 23, 42, 0.04);
--color-border-default: rgba(15, 23, 42, 0.08);
--color-border-strong: rgba(15, 23, 42, 0.12);
--hairline-soft: rgba(15, 23, 42, 0.05);
--hairline: rgba(15, 23, 42, 0.08);
--color-border-strong: rgba(15, 23, 42, 0.12);
--hairline-soft: rgba(15, 23, 42, 0.05);
--hairline: rgba(15, 23, 42, 0.08);
--hairline-strong: rgba(15, 23, 42, 0.12);
/* Terminal chrome stays dark regardless of UI theme */
--color-terminal-bg: #0f172a;
@@ -212,10 +212,11 @@
}
}
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
html,
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-surface-base) !important;
@@ -252,6 +253,14 @@ body.sftp-pointer-dragging * {
cursor: grabbing !important;
}
/* Sidebar session reorder — same WKWebView constraint as SFTP */
body.session-tree-dragging,
body.session-tree-dragging * {
-webkit-user-select: none !important;
user-select: none !important;
cursor: grabbing !important;
}
/* Buttons/inputs inside drag regions stay clickable */
.titlebar-drag button,
.titlebar-drag a,
@@ -339,28 +348,57 @@ body.sftp-pointer-dragging * {
/* Slide animations */
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slideInUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes pfDashFlow {
@@ -385,11 +423,21 @@ body.sftp-pointer-dragging * {
animation: pfTunnelPulse 1.35s ease-in-out infinite;
}
.animate-slide-in-right { animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-slide-out-right { animation: slideOutRight 0.2s ease-in forwards; }
.animate-slide-in-up { animation: slideInUp 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-scale-in { animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-pulse-subtle { animation: pulse-subtle 2s ease-in-out infinite; }
.animate-slide-in-right {
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-slide-out-right {
animation: slideOutRight 0.2s ease-in forwards;
}
.animate-slide-in-up {
animation: slideInUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-scale-in {
animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Focus ring */
button:focus-visible,
@@ -411,7 +459,12 @@ button:active:not(:disabled) {
transform: scale(0.98);
}
button {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease, transform 0.1s ease;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease,
opacity 0.15s ease,
transform 0.1s ease;
}
/* ── Form controls (use plain CSS: CSS-var colors don't support /opacity in @apply) ── */
@@ -434,7 +487,9 @@ button {
background-color: var(--color-surface-base);
border: 1px solid var(--color-border-default);
color: var(--color-content-primary);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.ui-input,
@@ -537,7 +592,9 @@ button {
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
text-align: left;
transition: background-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
color: var(--color-text-muted);
border: 1px solid transparent;
}
@@ -632,8 +689,14 @@ button {
/* Drawer enter from right */
@keyframes slideInRightDrawer {
from { transform: translateX(100%); opacity: 0.6; }
to { transform: translateX(0); opacity: 1; }
from {
transform: translateX(100%);
opacity: 0.6;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slideInRightDrawer 0.22s cubic-bezier(0.16, 1, 0.3, 1);
@@ -641,11 +704,20 @@ button {
/* Skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(90deg, var(--color-surface-raised) 25%, var(--color-surface-overlay) 50%, var(--color-surface-raised) 75%);
background: linear-gradient(
90deg,
var(--color-surface-raised) 25%,
var(--color-surface-overlay) 50%,
var(--color-surface-raised) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
+22 -3
View File
@@ -97,7 +97,8 @@ export const desktopApi: DesktopApi = {
connectionId,
retainForwards: options?.retainForwards ?? false,
}),
sshExec: (connectionId, command) => call('ssh_exec', { connectionId, command }),
sshExec: (connectionId, command, options) =>
call('ssh_exec', { connectionId, command, fromAgent: options?.fromAgent }),
sshExecCancel: (connectionId) => call('ssh_exec_cancel', { connectionId }),
sshHostMetrics: (connectionId) => call('ssh_host_metrics', { connectionId }),
sshKeepalive: (connectionId) => call('ssh_keepalive', { connectionId }),
@@ -125,6 +126,14 @@ export const desktopApi: DesktopApi = {
return { success: false, error: String(error) }
}
},
localOpenOutput: async (shellId) => {
try {
const data = await call<number[]>('local_open_output', { shellId })
return { success: true, data }
} catch (error) {
return { success: false, error: String(error) }
}
},
localWrite: async (shellId, data) => {
await call('local_write', { shellId, data: bytes(data) })
return { success: true }
@@ -137,6 +146,14 @@ export const desktopApi: DesktopApi = {
await call('local_kill', { shellId })
return { success: true }
},
localKillAll: async () => {
try {
const killed = await call<number>('local_kill_all')
return { success: true, killed }
} catch (error) {
return { success: false, error: String(error) }
}
},
onLocalData: (callback) =>
addListener<{ shellId: string; data: number[] }>('local:data', callback as Listener, (payload) =>
callback({} as never, 'local', payload.shellId, new Uint8Array(payload.data) as never)
@@ -151,6 +168,7 @@ export const desktopApi: DesktopApi = {
sessionCreate: (session) => call('session_create', { session }),
sessionUpdate: (id, updates) => call('session_update', { id, updates }),
sessionDelete: (id) => call('session_delete', { id }),
sessionReorder: (updates) => call('session_reorder', { updates }),
sftpConnect: (connectionId) => call('sftp_connect', { connectionId }),
sftpDisconnect: (sftpId) => call('sftp_disconnect', { sftpId }),
@@ -174,6 +192,8 @@ export const desktopApi: DesktopApi = {
licenseActivate: (token, email) => call('license_activate', { token, email }),
licenseClear: () => call('license_clear'),
licenseDeviceId: () => call('license_device_id'),
// 周检:联网重验在线授权并刷新缓存 // Logan.Li
licenseVerify: () => call('license_verify'),
licenseSetEmail: (email) => call('license_set_email', { email }),
licenseCheckout: (input) =>
call('license_checkout', {
@@ -286,8 +306,7 @@ export const desktopApi: DesktopApi = {
onMenuExportSessions: (callback) => addListener('menu:export-sessions', callback, callback),
onMenuOpenSettings: (callback) => addListener('menu:open-settings', callback, callback),
onSshLinkAvailable: (callback) => addListener('app:ssh-link-available', callback, callback),
onProLink: (callback) =>
addListener<string>('app:pro-link', callback as Listener, (payload) => callback(payload)),
onProLink: (callback) => addListener<string>('app:pro-link', callback as Listener, (payload) => callback(payload)),
removeListener: (channel, callback) => removeListener(channel, callback),
removeAllListeners,
}
+37 -35
View File
@@ -54,6 +54,29 @@ function baseUrl(): string {
return licenseServerBaseUrl().replace(/\/$/, '')
}
/** 双节点容灾:与 Rust 层一致(香港主 + 国内备)。环境变量覆盖时只用覆盖值。 */
function allBaseUrls(): string[] {
const primary = baseUrl()
const env = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_LICENSE_SERVER_URL
if (env && env.trim()) return [primary]
const backup = 'https://licence.rlwj.cn/api'
return primary === backup ? [primary] : [primary, backup]
}
/** fetchWithFallback 多节点请求:网络层失败才切换节点,拿到响应(含业务错误)即返回。 */
async function fetchWithFallback(path: string, init?: RequestInit): Promise<Response> {
const bases = allBaseUrls()
let lastError: unknown = null
for (const base of bases) {
try {
return await fetch(`${base}${path}`, init)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error ? lastError : new Error('无可用授权节点')
}
async function parseJson(res: Response): Promise<unknown> {
const text = await res.text()
if (!text.trim()) {
@@ -67,9 +90,7 @@ async function parseJson(res: Response): Promise<unknown> {
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
}
function unwrapData<T>(payload: unknown): T {
@@ -78,10 +99,7 @@ function unwrapData<T>(payload: unknown): T {
throw new Error('Unexpected license API response')
}
if (typeof root.code === 'number' && root.code !== 0) {
const message =
typeof root.message === 'string' && root.message
? root.message
: `License API error (${root.code})`
const message = typeof root.message === 'string' && root.message ? root.message : `License API error (${root.code})`
throw new Error(message)
}
if ('data' in root && root.data !== undefined && root.data !== null) {
@@ -99,13 +117,12 @@ function pickString(obj: Record<string, unknown>, ...keys: string[]): string {
}
export async function fetchProduct(code = PRODUCT_CODE): Promise<ProductWithPlans> {
const res = await fetch(`${baseUrl()}/v1/products/code/${encodeURIComponent(code)}`)
const res = await fetchWithFallback(`/v1/products/code/${encodeURIComponent(code)}`)
const payload = await parseJson(res)
if (!res.ok) {
const root = asRecord(payload)
throw new Error(
(root && typeof root.message === 'string' && root.message) ||
`Product fetch failed (${res.status})`
(root && typeof root.message === 'string' && root.message) || `Product fetch failed (${res.status})`
)
}
return unwrapData<ProductWithPlans>(payload)
@@ -136,7 +153,7 @@ export async function checkout(input: {
}
}
const res = await fetch(`${baseUrl()}/v1/checkout`, {
const res = await fetchWithFallback(`/v1/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -151,8 +168,7 @@ export async function checkout(input: {
if (!res.ok) {
const root = asRecord(payload)
throw new Error(
(root && typeof root.message === 'string' && root.message) ||
`Checkout unavailable (${res.status})`
(root && typeof root.message === 'string' && root.message) || `Checkout unavailable (${res.status})`
)
}
const data = asRecord(unwrapData(payload)) || {}
@@ -175,7 +191,7 @@ export async function claimPurchase(input: {
orderNo?: string
deviceId: string
}): Promise<ClaimResult> {
const res = await fetch(`${baseUrl()}/v1/purchase/claim`, {
const res = await fetchWithFallback(`/v1/purchase/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -188,10 +204,7 @@ export async function claimPurchase(input: {
const payload = await parseJson(res)
if (!res.ok) {
const root = asRecord(payload)
throw new Error(
(root && typeof root.message === 'string' && root.message) ||
`Claim failed (${res.status})`
)
throw new Error((root && typeof root.message === 'string' && root.message) || `Claim failed (${res.status})`)
}
const data = asRecord(unwrapData(payload)) || {}
const licenseKey = pickString(data, 'license_key', 'licenseKey')
@@ -223,7 +236,7 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
maxDevices: number
used: number
}> {
const res = await fetch(`${baseUrl()}/v1/license/devices`, {
const res = await fetchWithFallback(`/v1/license/devices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ license_key: licenseKey }),
@@ -231,10 +244,7 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
const payload = await parseJson(res)
if (!res.ok) {
const root = asRecord(payload)
throw new Error(
(root && typeof root.message === 'string' && root.message) ||
`List devices failed (${res.status})`
)
throw new Error((root && typeof root.message === 'string' && root.message) || `List devices failed (${res.status})`)
}
const data = asRecord(unwrapData(payload)) || {}
const rawDevices = Array.isArray(data.devices) ? data.devices : []
@@ -262,11 +272,8 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
}
}
export async function deactivateLicenseDevice(
licenseKey: string,
deviceId: string
): Promise<void> {
const res = await fetch(`${baseUrl()}/v1/license/deactivate`, {
export async function deactivateLicenseDevice(licenseKey: string, deviceId: string): Promise<void> {
const res = await fetchWithFallback(`/v1/license/deactivate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -277,16 +284,11 @@ export async function deactivateLicenseDevice(
const payload = await parseJson(res)
if (!res.ok) {
const root = asRecord(payload)
throw new Error(
(root && typeof root.message === 'string' && root.message) ||
`Deactivate failed (${res.status})`
)
throw new Error((root && typeof root.message === 'string' && root.message) || `Deactivate failed (${res.status})`)
}
const root = asRecord(payload)
if (root && typeof root.code === 'number' && root.code !== 0) {
throw new Error(
(typeof root.message === 'string' && root.message) || 'Deactivate failed'
)
throw new Error((typeof root.message === 'string' && root.message) || 'Deactivate failed')
}
}
+7 -9
View File
@@ -48,19 +48,17 @@ export async function createTauriLocalShell(
})
}
export async function writeTauriLocalShell(
shellId: string,
data: string | Uint8Array
): Promise<void> {
export async function openTauriLocalShellOutput(shellId: string): Promise<Uint8Array> {
const data = await invoke<number[]>('local_open_output', { shellId })
return new Uint8Array(data ?? [])
}
export async function writeTauriLocalShell(shellId: string, data: string | Uint8Array): Promise<void> {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
await invoke('local_write', { shellId, data: Array.from(bytes) })
}
export async function resizeTauriLocalShell(
shellId: string,
cols: number,
rows: number
): Promise<void> {
export async function resizeTauriLocalShell(shellId: string, cols: number, rows: number): Promise<void> {
await invoke('local_resize', { shellId, cols, rows })
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more