chore: 保存 Agent 安全优化前基线
This commit is contained in:
1 parent
08696896f9
commit
ff016347cd
116 files changed
+3858
-2485
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ __pycache__/
|
||||
|
||||
# Tauri / Rust
|
||||
src-tauri/target/
|
||||
src-tauri/tauri.signing.conf.json
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
+2
-1
@@ -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 --"]
|
||||
}
|
||||
@@ -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 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
|
||||
@@ -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 / 文件系统 / 加密。
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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、把结账/签发服务堆回桌面进程。
|
||||
@@ -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
@@ -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
@@ -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 工具使用,不引入第二套目标和凭据边界 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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 也只能标开发包。
|
||||
|
||||
## 产物验证
|
||||
|
||||
|
||||
@@ -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
@@ -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 在发给云模型前做常见敏感信息脱敏。
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Waffo Pancake (NEVER commit real keys)
|
||||
WAFFO_MERCHANT_ID=MER_586CtDwoHUbrnFJyudo3t4
|
||||
WAFFO_PRIVATE_KEY=
|
||||
WAFFO_ENVIRONMENT=test
|
||||
|
||||
# Store / product (from Pancake Dashboard)
|
||||
WAFFO_STORE_ID=
|
||||
WAFFO_STORE_SLUG=opsxshell-pwsi6m4n
|
||||
WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5
|
||||
|
||||
# Optional: permanent hosted checkout URL (preferred when set)
|
||||
WAFFO_CHECKOUT_URL=https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_0pcnzzT4nalEkkcdyIUoD5?type=onetime¤cy=USD&test=true
|
||||
|
||||
# One-time Pro price used by bootstrap (USD)
|
||||
WAFFO_PRODUCT_PRICE=29.00
|
||||
|
||||
# Must match desktop verify key (src-tauri license service DEFAULT / OPSX_LICENSE_HMAC)
|
||||
OPSX_LICENSE_HMAC=opsxshell-dev-license-hmac-v1-change-in-prod
|
||||
|
||||
PORT=8787
|
||||
SUCCESS_URL=opsxshell://pro/activated
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8787
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
*.log
|
||||
@@ -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 **轮换**
|
||||
Generated
-27
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod guardrail;
|
||||
mod redaction;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+36
-15
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "(出力なし)",
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"closeAll": "すべて閉じる",
|
||||
"clone": "タブを複製",
|
||||
"openSftp": "SFTP を開く"
|
||||
}
|
||||
},
|
||||
"renameAria": "タブ {{title}} の名前を変更",
|
||||
"renameHint": "ダブルクリックで名前変更"
|
||||
},
|
||||
"sidebar": {
|
||||
"brandSub": "SSH / SFTP ターミナル",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
"connecting": "接続中…",
|
||||
"reconnecting": "自動再接続中{{attempt}}…",
|
||||
"reconnectingAttempt": " ({{count}})",
|
||||
"localShellExited": "ローカルシェルが終了しました"
|
||||
"localShellExited": "ローカルシェルが終了しました",
|
||||
"localDisconnected": "ローカル端末は終了しました",
|
||||
"localDisconnectedHint": "ローカルシェルが終了しました。下のボタンで再び開始して作業を続けられます。",
|
||||
"localConnectFailedHint": "ローカルシェルを起動できませんでした。選択したシェルが存在するか確認してください。",
|
||||
"localShellClosedHint": "シェルを再開するとローカル端末を引き続き利用できます。"
|
||||
},
|
||||
"hostKey": {
|
||||
"titleNew": "ホスト鍵を確認",
|
||||
|
||||
@@ -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": "(빈 출력)",
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"closeAll": "모두 닫기",
|
||||
"clone": "탭 복제",
|
||||
"openSftp": "SFTP 열기"
|
||||
}
|
||||
},
|
||||
"renameAria": "탭 {{title}} 이름 바꾸기",
|
||||
"renameHint": "더블 클릭해 이름 변경"
|
||||
},
|
||||
"sidebar": {
|
||||
"brandSub": "SSH / SFTP 터미널",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
"connecting": "연결 중…",
|
||||
"reconnecting": "자동 재연결 중{{attempt}}…",
|
||||
"reconnectingAttempt": " ({{count}})",
|
||||
"localShellExited": "로컬 셸이 종료됨"
|
||||
"localShellExited": "로컬 셸이 종료됨",
|
||||
"localDisconnected": "로컬 터미널 종료됨",
|
||||
"localDisconnectedHint": "로컬 셸이 종료되었습니다. 아래를 누르면 다시 시작해 작업을 이어갑니다.",
|
||||
"localConnectFailedHint": "로컬 셸을 시작하지 못했습니다. 선택한 셸이 존재하는지 확인하세요.",
|
||||
"localShellClosedHint": "셸을 다시 열면 로컬 터미널을 계속 사용할 수 있습니다."
|
||||
},
|
||||
"hostKey": {
|
||||
"titleNew": "호스트 키 확인",
|
||||
|
||||
@@ -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": "(пустой вывод)",
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"closeAll": "Закрыть все",
|
||||
"clone": "Дублировать вкладку",
|
||||
"openSftp": "Открыть SFTP"
|
||||
}
|
||||
},
|
||||
"renameAria": "Переименовать вкладку {{title}}",
|
||||
"renameHint": "Двойной клик — переименовать"
|
||||
},
|
||||
"sidebar": {
|
||||
"brandSub": "Терминал SSH / SFTP",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
"connecting": "Подключение…",
|
||||
"reconnecting": "Автопереподключение{{attempt}}…",
|
||||
"reconnectingAttempt": " ({{count}})",
|
||||
"localShellExited": "Локальная оболочка завершилась"
|
||||
"localShellExited": "Локальная оболочка завершилась",
|
||||
"localDisconnected": "Локальный терминал закрыт",
|
||||
"localDisconnectedHint": "Локальная оболочка завершилась. Один клик ниже — и она запустится снова.",
|
||||
"localConnectFailedHint": "Не удалось запустить локальную оболочку. Убедитесь, что выбранная оболочка существует.",
|
||||
"localShellClosedHint": "Откройте оболочку снова, чтобы продолжить работу в локальном терминале."
|
||||
},
|
||||
"hostKey": {
|
||||
"titleNew": "Проверка ключа хоста",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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ủ",
|
||||
|
||||
@@ -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": "(空输出)",
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"closeAll": "关闭全部",
|
||||
"clone": "克隆标签",
|
||||
"openSftp": "打开 SFTP"
|
||||
}
|
||||
},
|
||||
"renameAria": "重命名标签 {{title}}",
|
||||
"renameHint": "双击重命名"
|
||||
},
|
||||
"sidebar": {
|
||||
"brandSub": "SSH / SFTP 终端",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
"connecting": "正在连接…",
|
||||
"reconnecting": "自动重连中{{attempt}}…",
|
||||
"reconnectingAttempt": " ({{count}})",
|
||||
"localShellExited": "本地 Shell 已退出"
|
||||
"localShellExited": "本地 Shell 已退出",
|
||||
"localDisconnected": "本地终端已关闭",
|
||||
"localDisconnectedHint": "本地 Shell 会话已结束。点下方一键重新开启,即可继续使用。",
|
||||
"localConnectFailedHint": "本地 Shell 启动失败,请确认所选 Shell 仍然存在",
|
||||
"localShellClosedHint": "重新打开 Shell 即可继续使用本地终端"
|
||||
},
|
||||
"hostKey": {
|
||||
"titleNew": "确认主机密钥",
|
||||
|
||||
@@ -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": "(空輸出)",
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"closeAll": "關閉全部",
|
||||
"clone": "克隆分頁",
|
||||
"openSftp": "開啟 SFTP"
|
||||
}
|
||||
},
|
||||
"renameAria": "重新命名標籤 {{title}}",
|
||||
"renameHint": "雙擊重新命名"
|
||||
},
|
||||
"sidebar": {
|
||||
"brandSub": "SSH / SFTP 終端機",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user