chore: 保存 Agent 安全优化前基线
This commit is contained in:
1 parent
08696896f9
commit
ff016347cd
116 files changed
+3044
-1671
No files matched your search
@@ -74,7 +74,12 @@ jobs:
|
|||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: rustfmt
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache Rust
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -82,6 +87,12 @@ jobs:
|
|||||||
- name: Run foundation acceptance
|
- name: Run foundation acceptance
|
||||||
run: npm run acceptance:quick
|
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:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -98,6 +109,21 @@ jobs:
|
|||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Tauri / Rust
|
# Tauri / Rust
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/tauri.signing.conf.json
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
"*.{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`。
|
- `test/`:Vitest 单元/组件测试与基础巡检(`test/patrol/`:SSH / SFTP / RDP / VNC);改功能后优先 `npm run test:patrol`。
|
||||||
- `scripts/`:本地启动、验收、版本同步与原生安装包构建/发布脚本(入口 `./run.sh`、`./scripts/build.sh`)。
|
- `scripts/`:本地启动、验收、版本同步与原生安装包构建/发布脚本(入口 `./run.sh`、`./scripts/build.sh`)。
|
||||||
- `docs/`:使用、架构、验收与发布文档;路线图与任务见根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`。
|
- `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`。
|
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。
|
- `src/platform/` 与 Tauri command 是本应用内部边界,不是给第三方调用的公共 API。
|
||||||
- `license-server/` 独立于客户端;不要把结账/授权服务逻辑堆回桌面进程。
|
- 结账与在线授权由独立的 opsx-licence 服务承担;不要把结账/签发逻辑堆回桌面进程。桌面端只验 `LIC-`(在线)和 `LICOFF-`(离线 Ed25519)。
|
||||||
- 不把 CLI、MCP、插件宿主或堡垒机审批做成默认能力。
|
- 不把 CLI、MCP、插件宿主或堡垒机审批做成默认能力。
|
||||||
- 转发 Listener、SSH 会话、RDP/VNC worker 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
|
- 转发 Listener、SSH 会话、RDP/VNC worker 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`)
|
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`)
|
||||||
- `docs/`:使用 / 架构 / 验收 / 发布
|
- `docs/`:使用 / 架构 / 验收 / 发布
|
||||||
- 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`
|
- 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`
|
||||||
- `license-server/`:授权与结账服务(独立包)
|
|
||||||
|
|
||||||
数据流:UI → Zustand → `src/platform/*` → Tauri command → Rust 服务。组件不直接 `invoke`,也不直接碰 SSH / 文件系统 / 加密。
|
数据流: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 自动尝试、主机指纹校验
|
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
|
||||||
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
|
- 会话管理:分组、标签、快速连接、多标签、终端分屏、命令片段;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
|
||||||
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC)
|
- SFTP:双栏、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限)
|
||||||
- SFTP:双栏、中间传输箭头、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限,超限/二进制友好提示)
|
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段(批量清单导入属专业版)
|
||||||
- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网
|
|
||||||
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
|
|
||||||
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
|
||||||
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
|
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
|
||||||
- 远程桌面:原生 RDP(IronRDP)与 VNC(vnc-rs);剪贴板与共享盘(RDP)
|
- 本地终端:系统 Shell
|
||||||
- 本地终端与串口:系统 Shell、串口设备与波特率配置
|
|
||||||
- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;自然语言驱动最多 8 步的执行→观察循环,三级安全模式、五级风险和破坏性命令硬拒绝
|
|
||||||
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
|
- 本地安全: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、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
|
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
|
||||||
@@ -32,7 +39,7 @@ OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程
|
|||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
| ---------- | -------------------------------- |
|
| ---------- | ------------------------------------------------- |
|
||||||
| 桌面运行时 | Tauri 2 |
|
| 桌面运行时 | Tauri 2 |
|
||||||
| 原生后端 | Rust 2021 |
|
| 原生后端 | Rust 2021 |
|
||||||
| SSH / SFTP | `ssh2 0.9` / libssh2 |
|
| SSH / SFTP | `ssh2 0.9` / libssh2 |
|
||||||
@@ -81,7 +88,6 @@ test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
|
|||||||
scripts/ 启动、验收、构建与发布脚本
|
scripts/ 启动、验收、构建与发布脚本
|
||||||
docs/ 使用 / 架构 / 验收 / 发布文档
|
docs/ 使用 / 架构 / 验收 / 发布文档
|
||||||
docs/assets/ 品牌与设计资源
|
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 冲突确认;可选内容校验和与失败任务恢复
|
2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复
|
||||||
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
|
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
|
||||||
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
|
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
|
||||||
5. **发布闭环**:Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新;用户可导出的脱敏崩溃包
|
5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新。步骤见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成)。
|
||||||
6. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出)
|
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),不要假装全接口已开
|
- [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开
|
||||||
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
|
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
|
||||||
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新
|
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成))
|
||||||
|
|
||||||
## 稍后
|
## 稍后
|
||||||
|
|
||||||
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
|
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
|
||||||
- [ ] SFTP 校验和与失败任务恢复
|
- [ ] SFTP 校验和与失败任务恢复
|
||||||
- [ ] RDP/VNC 生产级证书校验
|
- [ ] 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)
|
- [ ] AI 执行运行时从 React 面板抽离(取消、单步超时、原生 tool calling)
|
||||||
- [ ] 主机排障 Agent eval 集
|
- [ ] 主机排障 Agent eval 集
|
||||||
|
|||||||
+3
-2
@@ -3,12 +3,13 @@
|
|||||||
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
|
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| -------------------------------------------- | ------------------------------------------------- |
|
| ------------------------------------------ | ----------------------------------------------------- |
|
||||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||||
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
||||||
|
| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) |
|
||||||
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
||||||
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
||||||
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
|
| [发布指南](./release.md) | 构建与产物;签名/公证/自动更新的做法(当前未完成) |
|
||||||
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
|
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
|
||||||
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
|
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
|
||||||
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
|
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
|
||||||
|
|||||||
+9
-9
@@ -51,15 +51,15 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
|
|
||||||
### 安全边界
|
### 安全边界
|
||||||
|
|
||||||
| 等级 | 示例 | 严格模式 | 均衡模式(默认) | 宽松模式 |
|
| 等级 | 示例 | 严格模式 | 宽松模式(默认) |
|
||||||
| -------- | --------------------------------- | -------- | ---------------- | -------- |
|
| -------- | --------------------------------- | -------- | ---------------- |
|
||||||
| safe | `df -h`、`journalctl -u nginx` | 自动执行 | 自动执行 | 自动执行 |
|
| safe | `df -h`、`journalctl -u nginx` | 自动执行 | 自动执行 |
|
||||||
| low | 未知自定义查询命令 | 自动执行 | 自动执行 | 自动执行 |
|
| low | 未知自定义查询命令 | 自动执行 | 自动执行 |
|
||||||
| medium | 文件写入、普通权限变更 | 先确认 | 自动执行 | 自动执行 |
|
| medium | 文件写入、普通权限变更 | 先确认 | 自动执行 |
|
||||||
| high | 服务重启、删除、包安装、K8s 变更 | 先确认 | 先确认 | 自动执行 |
|
| high | 服务重启、删除、包安装、K8s 变更 | 先确认 | 先确认 |
|
||||||
| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝 | 硬拒绝 | 硬拒绝 |
|
| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝 | 硬拒绝 |
|
||||||
|
|
||||||
安全模式在任务开始时锁定,运行中不能切换;需要确认时,对话框必须显示本次锁定模式、目标、风险和完整单行命令。宽松模式明确标注“仅适合隔离测试环境”,它不会降低 critical 硬红线。
|
安全模式在任务开始时锁定,运行中不能切换;需要确认时,对话框必须显示本次锁定模式、目标、风险和完整单行命令。critical 在前端与 Rust `guardrail` 都会硬拒绝。旧文档里的「均衡 / 宽松第三级」已合并为宽松。
|
||||||
|
|
||||||
终端输出被标记为不可信数据,不得将日志中的“忽略规则”等文字当作指令。常见密码、Token、Authorization Header、AWS Key 和私钥块在 Rust 发送 Provider 前脱敏。
|
终端输出被标记为不可信数据,不得将日志中的“忽略规则”等文字当作指令。常见密码、Token、Authorization Header、AWS Key 和私钥块在 Rust 发送 Provider 前脱敏。
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 |
|
| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 |
|
||||||
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
|
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
|
||||||
| 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 |
|
| 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 |
|
||||||
| 三级安全模式 | 已适配 | 严格从 medium、均衡从 high 确认;宽松仍不可绕过 critical |
|
| 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 |
|
||||||
| WebSocket 事件流 | 不照搬 | 本地 React/Tauri 没有跨程服务器会话,使用内存状态即可 |
|
| WebSocket 事件流 | 不照搬 | 本地 React/Tauri 没有跨程服务器会话,使用内存状态即可 |
|
||||||
| 审计、审批、RBAC | 不引入 | 属于 aiops/堡垒机,不是本地 SSH 客户端职责 |
|
| 审计、审批、RBAC | 不引入 | 属于 aiops/堡垒机,不是本地 SSH 客户端职责 |
|
||||||
| podexec/K8s 专用工具 | 不引入 | `kubectl/helm` 可沿远程 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。
|
||||||
@@ -52,7 +52,7 @@ services/
|
|||||||
## 原生服务
|
## 原生服务
|
||||||
|
|
||||||
| 能力 | 实现 |
|
| 能力 | 实现 |
|
||||||
| -------------------- | ------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------------- |
|
||||||
| SSH/SFTP | `ssh2 0.9` / libssh2 |
|
| SSH/SFTP | `ssh2 0.9` / libssh2 |
|
||||||
| HTTP/SOCKS5 连接代理 | Rust TCP 握手后交给 libssh2 |
|
| HTTP/SOCKS5 连接代理 | Rust TCP 握手后交给 libssh2 |
|
||||||
| ProxyJump | libssh2 direct-tcpip + loopback relay,最多 8 跳 |
|
| ProxyJump | libssh2 direct-tcpip + loopback relay,最多 8 跳 |
|
||||||
@@ -94,7 +94,7 @@ XTerm 当前焦点分屏
|
|||||||
- 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。
|
- 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。
|
||||||
- 模型返回内容按不可信文本处理;终端输出也标记为不可信,其中的指令不能覆盖 system Prompt。
|
- 模型返回内容按不可信文本处理;终端输出也标记为不可信,其中的指令不能覆盖 system Prompt。
|
||||||
- Agent 只接受唯一单行命令,目标固定为当前活跃 SSH connection;本次运行开始后安全模式锁定,确认框必须显示目标、命令、风险和锁定模式。
|
- Agent 只接受唯一单行命令,目标固定为当前活跃 SSH connection;本次运行开始后安全模式锁定,确认框必须显示目标、命令、风险和锁定模式。
|
||||||
- 命令风险为 safe/low/medium/high/critical 五级。严格模式从 medium 起确认;默认均衡模式从 high 起确认;宽松模式仅适合隔离测试环境。格式化磁盘、删除根目录等 critical 操作在任何模式下都硬拒绝。
|
- 命令风险为 safe/low/medium/high/critical 五级。严格模式从 medium 起确认;默认宽松模式从 high 起确认。格式化磁盘、删除根目录等 critical 操作在前端与 Rust guardrail 都会硬拒绝。
|
||||||
- 相同命令第 3 次在执行前拦截;连续失败时要求更换排查路径。长输出先经本地 `analyze_output` 保留错误行和首尾。
|
- 相同命令第 3 次在执行前拦截;连续失败时要求更换排查路径。长输出先经本地 `analyze_output` 保留错误行和首尾。
|
||||||
- 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。
|
- 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。
|
||||||
|
|
||||||
@@ -160,7 +160,8 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验,
|
|||||||
- 不持久化终端命令历史或批量命令输出。
|
- 不持久化终端命令历史或批量命令输出。
|
||||||
- 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。
|
- 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。
|
||||||
- 未实现能力不能在界面中显示为可用。
|
- 未实现能力不能在界面中显示为可用。
|
||||||
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。
|
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新未完成)。
|
||||||
|
- 五层目录补全、specta、EventSink 是单独轨道,见 [架构重构](./architecture-refactor.md);未开始前继续按本文件的 commands → services → platform 扩展。
|
||||||
- 应用不能静默绕过 changed host key 或 known_hosts 不匹配。
|
- 应用不能静默绕过 changed host key 或 known_hosts 不匹配。
|
||||||
|
|
||||||
## 体积预算
|
## 体积预算
|
||||||
|
|||||||
+84
-13
@@ -87,25 +87,96 @@ lsof +D /Volumes/<临时卷名>
|
|||||||
|
|
||||||
先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。
|
先正常退出该 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`
|
- `REQUIRE_SIGNING=true` 时,`scripts/release-preflight.mjs` 检查环境变量齐全,`scripts/verify-release.mjs` 拒绝 macOS ad-hoc、要求 Windows Authenticode `Valid`。
|
||||||
- `APPLE_CERTIFICATE_PASSWORD`
|
- 未设该变量时,`./scripts/build.sh` 仍可打开发包,验证只检查架构和体积。
|
||||||
- `APPLE_SIGNING_IDENTITY`
|
- CI 工作流 `.github/workflows/tauri-release.yml` 已能在 Secrets 齐全时写入 `src-tauri/tauri.signing.conf.json` 再构建;**本地 `build.sh` 还不会生成这份配置**,所以本机即使导出了同样的环境变量,也不会走出 ad-hoc。
|
||||||
- `APPLE_ID`
|
- 全仓库没有 `notarytool` / `staple`。Developer ID 签过但未公证的 `.app`,用户机器上仍会被 Gatekeeper 拦。
|
||||||
- `APPLE_PASSWORD`
|
- `scripts/release.py` 仍提示「右键打开」——这是 ad-hoc 包的权宜说明,正式公证后必须删掉。
|
||||||
- `APPLE_TEAM_ID`
|
|
||||||
|
|
||||||
Windows:
|
下面是有凭据之后的做法。没齐之前不要改 `signingIdentity`、不要接 updater 插件、不要在设置里放「检查更新」。
|
||||||
|
|
||||||
- `WINDOWS_CERTIFICATE_BASE64`
|
### 1. 凭据(先买/申请,再接线)
|
||||||
- `WINDOWS_CERTIFICATE_PASSWORD`
|
|
||||||
|
|
||||||
缺少凭据时只能生成开发构建,不能标记为正式签名或已公证版本。
|
| 平台 | 要什么 | 放到哪里 |
|
||||||
|
| -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 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;部分成功时仍能生成可导入目录。
|
8. models.dev 三个源全部失败时不影响已保存 Provider;部分成功时仍能生成可导入目录。
|
||||||
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
|
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
|
||||||
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
|
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
|
||||||
11. 每一轮只接受唯一单行命令;默认均衡模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;宽松模式不允许绕过 critical 硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。
|
11. 每一轮只接受唯一单行命令;默认宽松模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;critical 前后端均硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。Agent 发起的 `ssh_exec` 必须带 `fromAgent`。
|
||||||
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。
|
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。
|
||||||
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
|
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
|
||||||
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
|
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,8 @@ OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默
|
|||||||
|
|
||||||
需要长期使用时新建保存会话,选择密码或私钥认证,并按需设置分组、标签和启动命令。未填写密码或私钥的导入会话会尝试本机 SSH Agent 和常见默认密钥。
|
需要长期使用时新建保存会话,选择密码或私钥认证,并按需设置分组、标签和启动命令。未填写密码或私钥的导入会话会尝试本机 SSH Agent 和常见默认密钥。
|
||||||
|
|
||||||
|
侧边栏分组内可上下拖拽主机调整顺序。拖到另一分组的标题上会放到该组末尾;分组展开后也可以直接拖到组内某一主机的上方或下方插入。在分组标题右键可选择「按名称排序」一次性重排。顺序会保存,新建的主机排在组末尾;搜索过滤和批量多选模式下暂不可拖拽。
|
||||||
|
|
||||||
安装版支持系统链接,例如:
|
安装版支持系统链接,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -118,7 +120,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下
|
|||||||
|
|
||||||
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
|
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
|
||||||
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
|
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
|
||||||
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认“均衡”模式仅高风险命令确认;“严格”从中风险开始确认;“宽松”仅保留 critical 硬拒绝,适合隔离测试环境。
|
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。
|
||||||
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
|
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
|
||||||
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
|
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
|
||||||
- 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。
|
- 长输出由本地 `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:quick": "node scripts/acceptance.mjs quick",
|
||||||
"acceptance:full": "node scripts/acceptance.mjs full",
|
"acceptance:full": "node scripts/acceptance.mjs full",
|
||||||
"clean": "rimraf dist src-tauri/target/release release",
|
"clean": "rimraf dist src-tauri/target/release release",
|
||||||
"prepare": "husky install || true",
|
"prepare": "husky",
|
||||||
"dist": "tauri build",
|
"dist": "tauri build",
|
||||||
"dist:win": "tauri build --target x86_64-pc-windows-msvc",
|
"dist:win": "tauri build --target x86_64-pc-windows-msvc",
|
||||||
"dist:mac": "tauri build --target aarch64-apple-darwin",
|
"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.equal(help.status, 0, help.stderr)
|
||||||
assert.match(help.stdout, /mac-arm64/)
|
assert.match(help.stdout, /mac-arm64/)
|
||||||
assert.match(help.stdout, /win-x64/)
|
assert.match(help.stdout, /win-x64/)
|
||||||
|
assert.match(help.stdout, /linux-x64/)
|
||||||
assert.match(help.stdout, /不会创建或推送 Git tag/)
|
assert.match(help.stdout, /不会创建或推送 Git tag/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -25,6 +25,8 @@ OpsXShell 原生安装包构建脚本
|
|||||||
mac-arm64 macOS Apple Silicon,生成 .app 和 .dmg
|
mac-arm64 macOS Apple Silicon,生成 .app 和 .dmg
|
||||||
mac-x64 macOS Intel,生成 .app 和 .dmg
|
mac-x64 macOS Intel,生成 .app 和 .dmg
|
||||||
win-x64 Windows x86_64,生成 NSIS/MSI 安装包
|
win-x64 Windows x86_64,生成 NSIS/MSI 安装包
|
||||||
|
linux-x64 Linux x86_64,生成 deb/rpm/AppImage
|
||||||
|
linux-arm64 Linux aarch64,生成 deb/rpm/AppImage
|
||||||
|
|
||||||
选项:
|
选项:
|
||||||
--quick 构建前只运行快速门禁
|
--quick 构建前只运行快速门禁
|
||||||
@@ -39,7 +41,7 @@ OpsXShell 原生安装包构建脚本
|
|||||||
说明:
|
说明:
|
||||||
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
||||||
Cargo.toml、Cargo.lock 和 tauri.conf.json,但不会创建或推送 Git tag。
|
Cargo.toml、Cargo.lock 和 tauri.conf.json,但不会创建或推送 Git tag。
|
||||||
- Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建。
|
- Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建;Linux 包必须在 Linux 上构建。
|
||||||
- 正式签名构建请先设置 REQUIRE_SIGNING=true 及对应签名环境变量。
|
- 正式签名构建请先设置 REQUIRE_SIGNING=true 及对应签名环境变量。
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ while [ "$#" -gt 0 ]; do
|
|||||||
--skip-checks)
|
--skip-checks)
|
||||||
CHECK_MODE="none"
|
CHECK_MODE="none"
|
||||||
;;
|
;;
|
||||||
auto|mac-arm64|mac-x64|win-x64)
|
auto|mac-arm64|mac-x64|win-x64|linux-x64|linux-arm64)
|
||||||
TARGET="$1"
|
TARGET="$1"
|
||||||
;;
|
;;
|
||||||
v[0-9]*|[0-9]*)
|
v[0-9]*|[0-9]*)
|
||||||
@@ -97,6 +99,8 @@ if [ "$TARGET" = "auto" ]; then
|
|||||||
Darwin/arm64|Darwin/aarch64) TARGET="mac-arm64" ;;
|
Darwin/arm64|Darwin/aarch64) TARGET="mac-arm64" ;;
|
||||||
Darwin/x86_64) TARGET="mac-x64" ;;
|
Darwin/x86_64) TARGET="mac-x64" ;;
|
||||||
MINGW*/*|MSYS*/*|CYGWIN*/*) TARGET="win-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 不支持自动打包,请显式指定目标" ;;
|
*) fail "当前平台 $HOST_OS/$HOST_ARCH 不支持自动打包,请显式指定目标" ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
@@ -123,6 +127,18 @@ case "$TARGET" in
|
|||||||
RELEASE_PLATFORM="windows"
|
RELEASE_PLATFORM="windows"
|
||||||
OUTPUT_PLATFORM="windows-x64"
|
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
|
esac
|
||||||
|
|
||||||
if command -v rustup >/dev/null 2>&1 && ! rustup target list --installed | grep -Fxq "$RUST_TARGET"; then
|
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=()
|
COPIED_ARTIFACTS=()
|
||||||
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
||||||
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/dmg/*.dmg)
|
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
|
else
|
||||||
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/nsis/*.exe "$BUNDLE_ROOT"/msi/*.msi "$BUNDLE_ROOT"/wix/*.msi)
|
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/nsis/*.exe "$BUNDLE_ROOT"/msi/*.msi "$BUNDLE_ROOT"/wix/*.msi)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { missingSigningVariables } from './release-tools.mjs'
|
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) {
|
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)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const REQUIRED_SIGNING_ENV = {
|
|||||||
'APPLE_TEAM_ID',
|
'APPLE_TEAM_ID',
|
||||||
],
|
],
|
||||||
windows: ['WINDOWS_CERTIFICATE_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD'],
|
windows: ['WINDOWS_CERTIFICATE_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD'],
|
||||||
|
linux: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function missingSigningVariables(platform, env = process.env) {
|
export function missingSigningVariables(platform, env = process.env) {
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import requests
|
|||||||
|
|
||||||
API_BASE = "https://gitee.com/api/v5"
|
API_BASE = "https://gitee.com/api/v5"
|
||||||
DEFAULT_REPO = "opsx-tools/opsxshell"
|
DEFAULT_REPO = "opsx-tools/opsxshell"
|
||||||
ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi"}
|
ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi", ".deb", ".rpm", ".appimage"}
|
||||||
CHECKSUM_NAMES = {"SHA256SUMS.txt"}
|
CHECKSUM_NAMES = {"SHA256SUMS.txt"}
|
||||||
MAX_ASSET_BYTES = 100 * 1024 * 1024
|
MAX_ASSET_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { assertWithinSize, readPeMachine } from './release-tools.mjs'
|
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'
|
const root = process.argv[3] ?? (platform === 'macos'
|
||||||
? 'src-tauri/target/aarch64-apple-darwin/release/bundle'
|
? 'src-tauri/target/aarch64-apple-darwin/release/bundle'
|
||||||
: 'src-tauri/target/release/bundle')
|
: 'src-tauri/target/release/bundle')
|
||||||
@@ -57,6 +57,11 @@ if (platform === 'macos') {
|
|||||||
}
|
}
|
||||||
for (const installer of installers) assertWithinSize(installer, maxInstallerBytes)
|
for (const installer of installers) assertWithinSize(installer, maxInstallerBytes)
|
||||||
console.log(`[release] Windows x64 artifacts verified: ${root}`)
|
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 {
|
} else {
|
||||||
fail(`Unsupported platform: ${platform}`)
|
fail(`Unsupported platform: ${platform}`)
|
||||||
}
|
}
|
||||||
@@ -101,10 +101,19 @@ export interface Session {
|
|||||||
osPrettyName?: string
|
osPrettyName?: string
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
/** Manual display order within the group; missing rows keep storage order after ordered ones. */
|
||||||
|
sortOrder?: number
|
||||||
createdAt?: number
|
createdAt?: number
|
||||||
updatedAt?: 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 {
|
export interface SessionCreateRequest {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -2,41 +2,58 @@ use tauri::AppHandle;
|
|||||||
|
|
||||||
use crate::services::license::{self, CheckoutResult, LicenseStatus};
|
use crate::services::license::{self, CheckoutResult, LicenseStatus};
|
||||||
|
|
||||||
#[tauri::command]
|
async fn license_blocking<T: Send + 'static>(
|
||||||
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
work: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||||
license::read_status(&app)
|
) -> Result<T, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(work)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("授权任务失败: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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,
|
app: AppHandle,
|
||||||
token: String,
|
token: String,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
) -> Result<LicenseStatus, String> {
|
) -> Result<LicenseStatus, String> {
|
||||||
license::activate(&app, &token, email.as_deref())
|
license_blocking(move || license::activate(&app, &token, email.as_deref())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
pub async fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||||
license::clear(&app)
|
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]
|
#[tauri::command]
|
||||||
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
pub async fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||||
license::get_or_create_device_id(&app)
|
license_blocking(move || license::get_or_create_device_id(&app)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
pub async fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
||||||
license::set_email(&app, &email)
|
license_blocking(move || license::set_email(&app, &email)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn license_checkout(
|
pub async fn license_checkout(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
customer_email: String,
|
customer_email: String,
|
||||||
plan_type: String,
|
plan_type: String,
|
||||||
return_url: Option<String>,
|
return_url: Option<String>,
|
||||||
) -> Result<CheckoutResult, String> {
|
) -> Result<CheckoutResult, String> {
|
||||||
|
license_blocking(move || {
|
||||||
license::checkout(&app, &customer_email, &plan_type, return_url.as_deref())
|
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)
|
.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]
|
#[tauri::command]
|
||||||
pub fn local_write(
|
pub fn local_write(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -45,3 +56,8 @@ pub fn local_resize(
|
|||||||
pub fn local_kill(state: State<'_, AppState>, shell_id: String) -> Result<(), String> {
|
pub fn local_kill(state: State<'_, AppState>, shell_id: String) -> Result<(), String> {
|
||||||
state.local_terminal.kill(&shell_id)
|
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]
|
#[tauri::command]
|
||||||
pub fn ssh_disconnect(
|
pub async fn ssh_disconnect(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
retain_forwards: Option<bool>,
|
retain_forwards: Option<bool>,
|
||||||
) -> OperationResponse {
|
) -> Result<OperationResponse, String> {
|
||||||
operation(
|
let service = state.ssh.clone();
|
||||||
state
|
let retain = retain_forwards.unwrap_or(false);
|
||||||
.ssh
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
|
service.disconnect(&app, &connection_id, retain)
|
||||||
)
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => Ok(operation(result)),
|
||||||
|
Err(error) => Ok(OperationResponse::error(format!(
|
||||||
|
"断开连接任务失败: {error}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -43,7 +50,18 @@ pub async fn ssh_exec(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
command: String,
|
command: String,
|
||||||
|
from_agent: Option<bool>,
|
||||||
) -> Result<ExecResponse, String> {
|
) -> 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();
|
let service = state.ssh.clone();
|
||||||
Ok(
|
Ok(
|
||||||
match tauri::async_runtime::spawn_blocking(move || {
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
@@ -93,8 +111,15 @@ pub async fn ssh_host_metrics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn ssh_keepalive(state: State<'_, AppState>, connection_id: String) -> OperationResponse {
|
pub async fn ssh_keepalive(
|
||||||
operation(state.ssh.keepalive(&connection_id))
|
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]
|
#[tauri::command]
|
||||||
@@ -127,10 +152,17 @@ pub async fn ssh_create_shell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn ssh_shell_open_output(state: State<'_, AppState>, shell_id: String) -> ShellOutputResponse {
|
pub async fn ssh_shell_open_output(
|
||||||
match state.ssh.shell_open_output(&shell_id) {
|
state: State<'_, AppState>,
|
||||||
Ok(data) => ShellOutputResponse::ok(data),
|
shell_id: String,
|
||||||
Err(error) => ShellOutputResponse::error(error),
|
) -> 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]
|
#[tauri::command]
|
||||||
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse {
|
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse {
|
||||||
match state.storage.get_setting(&app, &key) {
|
match state.storage.get_setting(&app, &key) {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ pub fn clipboard_write_text(text: String) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn toggle_devtools(app: AppHandle) -> Result<bool, String> {
|
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
|
let window = app
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
.ok_or_else(|| "主窗口不存在".to_string())?;
|
.ok_or_else(|| "主窗口不存在".to_string())?;
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ pub fn run() {
|
|||||||
commands::deep_link::take_pending_ssh_links,
|
commands::deep_link::take_pending_ssh_links,
|
||||||
commands::local_terminal::local_list_shells,
|
commands::local_terminal::local_list_shells,
|
||||||
commands::local_terminal::local_create_shell,
|
commands::local_terminal::local_create_shell,
|
||||||
|
commands::local_terminal::local_open_output,
|
||||||
commands::local_terminal::local_write,
|
commands::local_terminal::local_write,
|
||||||
commands::local_terminal::local_resize,
|
commands::local_terminal::local_resize,
|
||||||
commands::local_terminal::local_kill,
|
commands::local_terminal::local_kill,
|
||||||
|
commands::local_terminal::local_kill_all,
|
||||||
commands::serial::serial_list_ports,
|
commands::serial::serial_list_ports,
|
||||||
commands::serial::serial_open,
|
commands::serial::serial_open,
|
||||||
commands::serial::serial_write,
|
commands::serial::serial_write,
|
||||||
@@ -131,6 +133,7 @@ pub fn run() {
|
|||||||
commands::storage::session_create,
|
commands::storage::session_create,
|
||||||
commands::storage::session_update,
|
commands::storage::session_update,
|
||||||
commands::storage::session_delete,
|
commands::storage::session_delete,
|
||||||
|
commands::storage::session_reorder,
|
||||||
commands::storage::settings_get,
|
commands::storage::settings_get,
|
||||||
commands::storage::settings_set,
|
commands::storage::settings_set,
|
||||||
commands::files::show_open_dialog,
|
commands::files::show_open_dialog,
|
||||||
@@ -153,6 +156,7 @@ pub fn run() {
|
|||||||
commands::license::license_get,
|
commands::license::license_get,
|
||||||
commands::license::license_activate,
|
commands::license::license_activate,
|
||||||
commands::license::license_clear,
|
commands::license::license_clear,
|
||||||
|
commands::license::license_verify,
|
||||||
commands::license::license_device_id,
|
commands::license::license_device_id,
|
||||||
commands::license::license_set_email,
|
commands::license::license_set_email,
|
||||||
commands::license::license_checkout,
|
commands::license::license_checkout,
|
||||||
@@ -226,6 +230,9 @@ pub fn run() {
|
|||||||
let _ = app.emit("menu:open-settings", ());
|
let _ = app.emit("menu:open-settings", ());
|
||||||
}
|
}
|
||||||
"toggle-devtools" => {
|
"toggle-devtools" => {
|
||||||
|
if !cfg!(debug_assertions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if window.is_devtools_open() {
|
if window.is_devtools_open() {
|
||||||
window.close_devtools();
|
window.close_devtools();
|
||||||
|
|||||||
@@ -355,6 +355,10 @@ pub fn build_menu<R: Runtime>(app: &AppHandle<R>, locale: &str) -> tauri::Result
|
|||||||
let settings = MenuItemBuilder::with_id("open-settings", t.settings)
|
let settings = MenuItemBuilder::with_id("open-settings", t.settings)
|
||||||
.accelerator("CmdOrCtrl+,")
|
.accelerator("CmdOrCtrl+,")
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
let view_menu = {
|
||||||
|
let builder = SubmenuBuilder::new(app, t.view).item(&settings);
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
||||||
.accelerator(if cfg!(target_os = "macos") {
|
.accelerator(if cfg!(target_os = "macos") {
|
||||||
"CmdOrCtrl+Alt+I"
|
"CmdOrCtrl+Alt+I"
|
||||||
@@ -362,11 +366,13 @@ pub fn build_menu<R: Runtime>(app: &AppHandle<R>, locale: &str) -> tauri::Result
|
|||||||
"F12"
|
"F12"
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
let view_menu = SubmenuBuilder::new(app, t.view)
|
builder.separator().item(&devtools).build()?
|
||||||
.item(&settings)
|
}
|
||||||
.separator()
|
#[cfg(not(debug_assertions))]
|
||||||
.item(&devtools)
|
{
|
||||||
.build()?;
|
builder.build()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
let window_menu = SubmenuBuilder::new(app, t.window)
|
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;
|
mod redaction;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ use serde_json::Value;
|
|||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
|
/// Embedded opsx-licence API roots (must end with `/api`, never include `/v1`).
|
||||||
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
|
/// 双节点容灾:香港主节点 + 国内备份,编译进二进制且不在界面展示。
|
||||||
|
/// 运行时按序尝试,网络层失败(连接/超时)才切换,业务响应不切换。 // 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);
|
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
|
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
|
||||||
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||||
@@ -45,6 +51,12 @@ struct StoredLicense {
|
|||||||
plan_type: Option<String>,
|
plan_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
last_verified_at: u64,
|
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 {
|
fn default_plan() -> String {
|
||||||
@@ -69,6 +81,13 @@ pub struct LicenseStatus {
|
|||||||
pub max_devices: Option<u32>,
|
pub max_devices: Option<u32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub plan_type: Option<String>,
|
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.
|
/// 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 {
|
fn normalize_api_base(raw: &str) -> String {
|
||||||
let mut base = raw.trim().trim_end_matches('/').to_string();
|
let mut base = raw.trim().trim_end_matches('/').to_string();
|
||||||
if base.is_empty() {
|
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
|
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
|
||||||
if base.starts_with("http://l123.91ai.io") {
|
if base.starts_with("http://l123.91ai.io") {
|
||||||
@@ -93,10 +112,12 @@ fn normalize_api_base(raw: &str) -> String {
|
|||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_base() -> String {
|
/// 全部可用 API 基址:环境变量覆盖时只用覆盖值(本地联调),否则用内置双节点。 // Logan.Li
|
||||||
let raw =
|
fn api_bases() -> Vec<String> {
|
||||||
std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
|
match std::env::var("OPSX_LICENSE_API_BASE") {
|
||||||
normalize_api_base(&raw)
|
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> {
|
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
@@ -153,6 +174,7 @@ fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
|
|||||||
email,
|
email,
|
||||||
max_devices: None,
|
max_devices: None,
|
||||||
plan_type: 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());
|
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 {
|
LicenseStatus {
|
||||||
plan: if stored.plan.is_empty() {
|
plan: if stored.plan.is_empty() {
|
||||||
"pro".to_string()
|
"pro".to_string()
|
||||||
@@ -183,6 +216,7 @@ fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatu
|
|||||||
email: stored.email.clone(),
|
email: stored.email.clone(),
|
||||||
max_devices: stored.max_devices,
|
max_devices: stored.max_devices,
|
||||||
plan_type: stored.plan_type.clone(),
|
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)))
|
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
/// 单节点请求:只有网络层失败(连接/超时/DNS)才返回 Err 供上层切换节点;
|
||||||
let url = format!("{}{path}", api_base());
|
/// 服务器返回了响应(含 4xx/5xx 业务错误)则原样解析返回,不切换。 // Logan.Li
|
||||||
|
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
|
||||||
let client = http_client()?;
|
let client = http_client()?;
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(url)
|
||||||
.json(&body)
|
.json(body)
|
||||||
.send()
|
.send()
|
||||||
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
||||||
let status = response.status();
|
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 {
|
struct RemoteLicenseInfo {
|
||||||
expires_at: Option<u64>,
|
expires_at: Option<u64>,
|
||||||
features: Vec<String>,
|
features: Vec<String>,
|
||||||
max_devices: Option<u32>,
|
max_devices: Option<u32>,
|
||||||
plan_type: Option<String>,
|
plan_type: Option<String>,
|
||||||
|
/// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
|
||||||
|
server_time: u64,
|
||||||
|
/// 激活时一次性下发的设备令牌 // Logan.Li
|
||||||
|
activation_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
|
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
|
||||||
let days_left = json_i64_field(data, "days_left", "daysLeft");
|
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 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 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 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 {
|
RemoteLicenseInfo {
|
||||||
expires_at,
|
expires_at,
|
||||||
features,
|
features,
|
||||||
max_devices,
|
max_devices,
|
||||||
plan_type,
|
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> {
|
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"license_key": license_key,
|
"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()));
|
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!({
|
let body = serde_json::json!({
|
||||||
"license_key": license_key,
|
"license_key": license_key,
|
||||||
"device_id": device_id,
|
"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.
|
// Bare object or envelope.
|
||||||
let payload = if value.get("valid").is_some() {
|
let payload = if value.get("valid").is_some() {
|
||||||
&value
|
&value
|
||||||
@@ -441,7 +581,9 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo
|
|||||||
envelope_data(&value)
|
envelope_data(&value)
|
||||||
} else if let Some(code) = envelope_code(&value) {
|
} else if let Some(code) = envelope_code(&value) {
|
||||||
if code != 0 {
|
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)
|
envelope_data(&value)
|
||||||
} else {
|
} else {
|
||||||
@@ -453,19 +595,25 @@ fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if !valid {
|
if !valid {
|
||||||
return Err(envelope_message(payload)
|
return Err(VerifyError::Rejected(
|
||||||
|
envelope_message(payload)
|
||||||
.or_else(|| json_string_field(payload, "message", "message"))
|
.or_else(|| json_string_field(payload, "message", "message"))
|
||||||
.unwrap_or_else(|| "许可证无效".to_string()));
|
.unwrap_or_else(|| "许可证无效".to_string()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
verify_response_sig(payload).map_err(VerifyError::Rejected)?;
|
||||||
|
|
||||||
Ok(parse_remote_info(payload))
|
Ok(parse_remote_info(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_deactivate(license_key: &str, device_id: &str) {
|
fn remote_deactivate(license_key: &str, device_id: &str, activation_token: Option<&str>) {
|
||||||
let body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"license_key": license_key,
|
"license_key": license_key,
|
||||||
"device_id": device_id,
|
"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);
|
let _ = post_json("/v1/license/deactivate", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +693,14 @@ pub fn activate(
|
|||||||
let device_id = get_or_create_device_id(app)?;
|
let device_id = get_or_create_device_id(app)?;
|
||||||
let key = license_key.trim();
|
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) {
|
if is_offline_license_key(key) {
|
||||||
let claims = verify_offline_serial(key, &device_id)?;
|
let claims = verify_offline_serial(key, &device_id)?;
|
||||||
let email_store = email
|
let email_store = email
|
||||||
@@ -561,8 +717,9 @@ pub fn activate(
|
|||||||
max_devices: Some(claims.max_devices.max(1) as u32),
|
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||||
plan_type: Some(claims.plan_type),
|
plan_type: Some(claims.plan_type),
|
||||||
last_verified_at: now_secs(),
|
last_verified_at: now_secs(),
|
||||||
|
activation_token: None,
|
||||||
|
last_server_time: 0,
|
||||||
};
|
};
|
||||||
let path = license_path(app)?;
|
|
||||||
write_stored(&path, &stored)?;
|
write_stored(&path, &stored)?;
|
||||||
return read_status(app);
|
return read_status(app);
|
||||||
}
|
}
|
||||||
@@ -581,6 +738,9 @@ pub fn activate(
|
|||||||
if verify.plan_type.is_some() {
|
if verify.plan_type.is_some() {
|
||||||
info.plan_type = verify.plan_type;
|
info.plan_type = verify.plan_type;
|
||||||
}
|
}
|
||||||
|
if verify.server_time > 0 {
|
||||||
|
info.server_time = verify.server_time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if info.features.is_empty() {
|
if info.features.is_empty() {
|
||||||
info.features = vec!["*".to_string()];
|
info.features = vec!["*".to_string()];
|
||||||
@@ -598,12 +758,69 @@ pub fn activate(
|
|||||||
max_devices: info.max_devices,
|
max_devices: info.max_devices,
|
||||||
plan_type: info.plan_type,
|
plan_type: info.plan_type,
|
||||||
last_verified_at: now_secs(),
|
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)?;
|
write_stored(&path, &stored)?;
|
||||||
read_status(app)
|
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> {
|
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
|
||||||
let path = license_path(app)?;
|
let path = license_path(app)?;
|
||||||
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
|
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,
|
max_devices: None,
|
||||||
plan_type: None,
|
plan_type: None,
|
||||||
last_verified_at: 0,
|
last_verified_at: 0,
|
||||||
|
activation_token: None,
|
||||||
|
last_server_time: 0,
|
||||||
});
|
});
|
||||||
let trimmed = email.trim();
|
let trimmed = email.trim();
|
||||||
stored.email = if trimmed.is_empty() {
|
stored.email = if trimmed.is_empty() {
|
||||||
@@ -632,7 +851,12 @@ pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
|||||||
let path = license_path(app)?;
|
let path = license_path(app)?;
|
||||||
if let Ok(Some(stored)) = read_stored(&path) {
|
if let Ok(Some(stored)) = read_stored(&path) {
|
||||||
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
|
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() {
|
if path.exists() {
|
||||||
@@ -695,6 +919,8 @@ mod tests {
|
|||||||
max_devices: Some(3),
|
max_devices: Some(3),
|
||||||
plan_type: Some("perpetual".to_string()),
|
plan_type: Some("perpetual".to_string()),
|
||||||
last_verified_at: 1,
|
last_verified_at: 1,
|
||||||
|
activation_token: Some("tok".to_string()),
|
||||||
|
last_server_time: 42,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&stored).unwrap();
|
let json = serde_json::to_string(&stored).unwrap();
|
||||||
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
||||||
@@ -703,6 +929,98 @@ mod tests {
|
|||||||
assert!(parsed.activated);
|
assert!(parsed.activated);
|
||||||
assert_eq!(parsed.max_devices, Some(3));
|
assert_eq!(parsed.max_devices, Some(3));
|
||||||
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
|
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]
|
#[test]
|
||||||
@@ -710,4 +1028,19 @@ mod tests {
|
|||||||
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
||||||
assert!(expires_from_days_left(Some(30)).is_some());
|
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,
|
env,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Mutex,
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
thread,
|
thread,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||||
@@ -20,6 +24,8 @@ struct LocalShellSession {
|
|||||||
master: Box<dyn MasterPty + Send>,
|
master: Box<dyn MasterPty + Send>,
|
||||||
writer: Box<dyn Write + Send>,
|
writer: Box<dyn Write + Send>,
|
||||||
child: Box<dyn Child + Send + Sync>,
|
child: Box<dyn Child + Send + Sync>,
|
||||||
|
output_live: Arc<AtomicBool>,
|
||||||
|
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -115,6 +121,8 @@ impl LocalTerminalService {
|
|||||||
.master
|
.master
|
||||||
.take_writer()
|
.take_writer()
|
||||||
.map_err(|error| format!("failed to open PTY writer: {error}"))?;
|
.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
|
self.sessions
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "local terminal session lock poisoned".to_string())?
|
.map_err(|_| "local terminal session lock poisoned".to_string())?
|
||||||
@@ -124,10 +132,12 @@ impl LocalTerminalService {
|
|||||||
master: pair.master,
|
master: pair.master,
|
||||||
writer,
|
writer,
|
||||||
child,
|
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 {
|
Ok(LocalShellCreateResponse {
|
||||||
shell_name: shell_basename(&shell),
|
shell_name: shell_basename(&shell),
|
||||||
shell_path: 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> {
|
pub fn write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> {
|
||||||
let mut sessions = self
|
let mut sessions = self
|
||||||
.sessions
|
.sessions
|
||||||
@@ -186,23 +235,66 @@ impl LocalTerminalService {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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 || {
|
thread::spawn(move || {
|
||||||
let mut buffer = [0_u8; 8192];
|
let mut buffer = [0_u8; 8192];
|
||||||
loop {
|
loop {
|
||||||
match reader.read(&mut buffer) {
|
match reader.read(&mut buffer) {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(length) => {
|
Ok(length) => {
|
||||||
|
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(
|
let _ = app.emit(
|
||||||
"local:data",
|
"local:data",
|
||||||
LocalShellDataEvent {
|
LocalShellDataEvent {
|
||||||
shell_id: shell_id.clone(),
|
shell_id: shell_id.clone(),
|
||||||
data: buffer[..length].to_vec(),
|
data: chunk.to_vec(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
Err(_) => break,
|
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] {
|
fn login_shell_args(kind: &str) -> &'static [&'static str] {
|
||||||
match kind {
|
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]
|
#[test]
|
||||||
fn unix_shells_start_as_login_shells() {
|
fn unix_shells_start_as_login_shells() {
|
||||||
assert_eq!(login_shell_args("zsh"), &["-l"]);
|
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("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("pwsh").is_empty());
|
||||||
assert!(login_shell_args("cmd").is_empty());
|
assert!(login_shell_args("cmd").is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,41 @@ impl StorageService {
|
|||||||
write_data(app, &data)
|
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> {
|
pub fn get_setting(&self, app: &AppHandle, key: &str) -> Result<Option<Value>, String> {
|
||||||
let _guard = self.lock.lock().map_err(lock_error)?;
|
let _guard = self.lock.lock().map_err(lock_error)?;
|
||||||
Ok(read_data(app)?.settings.get(key).cloned())
|
Ok(read_data(app)?.settings.get(key).cloned())
|
||||||
|
|||||||
@@ -25,22 +25,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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'",
|
"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": [
|
"dangerousDisableAssetCspModification": ["style-src"]
|
||||||
"style-src"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
],
|
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"signingIdentity": "-"
|
"signingIdentity": "-"
|
||||||
},
|
},
|
||||||
@@ -51,10 +43,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
"desktop": {
|
"desktop": {
|
||||||
"schemes": [
|
"schemes": ["ssh", "opsxshell"]
|
||||||
"ssh",
|
|
||||||
"opsxshell"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-10
@@ -27,11 +27,7 @@ import { parseSshConfig } from '../utils/sshConfig'
|
|||||||
import { formatSshLinkTarget } from '../utils/sshLink'
|
import { formatSshLinkTarget } from '../utils/sshLink'
|
||||||
import { suppressWebViewContextMenu } from '../utils/nativeContextMenu'
|
import { suppressWebViewContextMenu } from '../utils/nativeContextMenu'
|
||||||
import { encodeTextToBase64, decodeBase64ToText } from '../utils/textBase64'
|
import { encodeTextToBase64, decodeBase64ToText } from '../utils/textBase64'
|
||||||
import {
|
import { decryptSessionBackup, encryptSessionBackup, isEncryptedSessionBackup } from '../utils/sessionBackupCrypto'
|
||||||
decryptSessionBackup,
|
|
||||||
encryptSessionBackup,
|
|
||||||
isEncryptedSessionBackup,
|
|
||||||
} from '../utils/sessionBackupCrypto'
|
|
||||||
import { usePromptDialog } from '../components/common/PromptDialog'
|
import { usePromptDialog } from '../components/common/PromptDialog'
|
||||||
import { claimPurchase } from '../platform/licenseApi'
|
import { claimPurchase } from '../platform/licenseApi'
|
||||||
|
|
||||||
@@ -48,6 +44,10 @@ const CommandPalette = lazy(() =>
|
|||||||
|
|
||||||
const logger = createLogger('App')
|
const logger = createLogger('App')
|
||||||
|
|
||||||
|
// WebView 重载后渲染层会丢失本地 shell id,原生侧残留的 PTY 无法再被引用;
|
||||||
|
// 每个页面生命周期只在启动时清理一次(模块标记避免 StrictMode 双挂载误杀新 shell)。
|
||||||
|
let localOrphanCleanupDone = false
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation(['sessions', 'common'])
|
const { t } = useTranslation(['sessions', 'common'])
|
||||||
const [isSessionDialogOpen, setIsSessionDialogOpen] = useState(false)
|
const [isSessionDialogOpen, setIsSessionDialogOpen] = useState(false)
|
||||||
@@ -84,6 +84,21 @@ function App() {
|
|||||||
void hydrateEntitlement()
|
void hydrateEntitlement()
|
||||||
}, [loadSettings, 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(() => {
|
useEffect(() => {
|
||||||
if (!settingsLoaded) return
|
if (!settingsLoaded) return
|
||||||
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
||||||
@@ -475,11 +490,9 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
const q = parsed.searchParams
|
const q = parsed.searchParams
|
||||||
const licenseKey =
|
const licenseKey = q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
|
||||||
q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
|
|
||||||
const orderNo = q.get('orderNo') || q.get('order_no') || ''
|
const orderNo = q.get('orderNo') || q.get('order_no') || ''
|
||||||
const emailFromLink =
|
const emailFromLink = q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
|
||||||
q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
|
|
||||||
const store = useEntitlementStore.getState()
|
const store = useEntitlementStore.getState()
|
||||||
const deviceId =
|
const deviceId =
|
||||||
store.deviceId ||
|
store.deviceId ||
|
||||||
@@ -561,7 +574,7 @@ function App() {
|
|||||||
window.opsxApi.removeAllListeners('app:pro-link')
|
window.opsxApi.removeAllListeners('app:pro-link')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect, toast])
|
}, [t, handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect, toast])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -582,6 +595,7 @@ function App() {
|
|||||||
if (!requirePro('vnc')) return
|
if (!requirePro('vnc')) return
|
||||||
setDesktopConnectProtocol('vnc')
|
setDesktopConnectProtocol('vnc')
|
||||||
}}
|
}}
|
||||||
|
onImportSessions={() => void handleImportSessions()}
|
||||||
onOpenLauncher={() => setIsLauncherOpen(true)}
|
onOpenLauncher={() => setIsLauncherOpen(true)}
|
||||||
onEditSession={openEditSession}
|
onEditSession={openEditSession}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface AppShellProps {
|
|||||||
onQuickConnect: () => void
|
onQuickConnect: () => void
|
||||||
onRdpConnect?: () => void
|
onRdpConnect?: () => void
|
||||||
onVncConnect?: () => void
|
onVncConnect?: () => void
|
||||||
|
onImportSessions?: () => void
|
||||||
onOpenLauncher: () => void
|
onOpenLauncher: () => void
|
||||||
onEditSession: (session: Session) => void
|
onEditSession: (session: Session) => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@@ -50,7 +51,7 @@ function TitleBar() {
|
|||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
void startWindowDragging()
|
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="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" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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')}
|
title={t('sidebar.expand')}
|
||||||
aria-label={t('sidebar.expandAria')}
|
aria-label={t('sidebar.expandAria')}
|
||||||
>
|
>
|
||||||
@@ -102,6 +103,7 @@ export default function AppShell({
|
|||||||
onQuickConnect,
|
onQuickConnect,
|
||||||
onRdpConnect,
|
onRdpConnect,
|
||||||
onVncConnect,
|
onVncConnect,
|
||||||
|
onImportSessions,
|
||||||
onOpenLauncher,
|
onOpenLauncher,
|
||||||
onEditSession,
|
onEditSession,
|
||||||
children,
|
children,
|
||||||
@@ -161,9 +163,7 @@ export default function AppShell({
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main
|
<main className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}>
|
||||||
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
|
|
||||||
>
|
|
||||||
{!desktopFullscreen && (
|
{!desktopFullscreen && (
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
||||||
@@ -181,6 +181,7 @@ export default function AppShell({
|
|||||||
onQuickConnect={onQuickConnect}
|
onQuickConnect={onQuickConnect}
|
||||||
onRdpConnect={onRdpConnect}
|
onRdpConnect={onRdpConnect}
|
||||||
onVncConnect={onVncConnect}
|
onVncConnect={onVncConnect}
|
||||||
|
onImportSessions={onImportSessions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Send,
|
Send,
|
||||||
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
|
TextSelect,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
type AgentSafetyMode,
|
type AgentSafetyMode,
|
||||||
} from '../../utils/agentCommandRisk'
|
} from '../../utils/agentCommandRisk'
|
||||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||||
|
import Button from '../common/Button'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
|
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
|
||||||
import i18n from '../../i18n'
|
import i18n from '../../i18n'
|
||||||
@@ -41,6 +44,8 @@ interface AiTerminalPanelProps {
|
|||||||
connectionId?: string | null
|
connectionId?: string | null
|
||||||
targetLabel?: string
|
targetLabel?: string
|
||||||
isLocal?: boolean
|
isLocal?: boolean
|
||||||
|
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
|
||||||
|
insertToTerminal?: (text: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStep {
|
interface AgentStep {
|
||||||
@@ -146,12 +151,12 @@ export default function AiTerminalPanel({
|
|||||||
connectionId = null,
|
connectionId = null,
|
||||||
targetLabel = '当前 SSH 会话',
|
targetLabel = '当前 SSH 会话',
|
||||||
isLocal = false,
|
isLocal = false,
|
||||||
|
insertToTerminal,
|
||||||
}: AiTerminalPanelProps) {
|
}: AiTerminalPanelProps) {
|
||||||
const { t } = useTranslation('ai')
|
const { t } = useTranslation('ai')
|
||||||
const store = useAiProviderStore()
|
const store = useAiProviderStore()
|
||||||
const safetyMode = normalizeAgentSafetyMode(
|
const safetyMode = normalizeAgentSafetyMode(useSettingsStore((state) => state.settings.agentSafetyMode))
|
||||||
useSettingsStore((state) => state.settings.agentSafetyMode)
|
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
|
||||||
)
|
|
||||||
const setSetting = useSettingsStore((state) => state.setSetting)
|
const setSetting = useSettingsStore((state) => state.setSetting)
|
||||||
const [prompt, setPrompt] = useState('')
|
const [prompt, setPrompt] = useState('')
|
||||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||||
@@ -231,6 +236,7 @@ export default function AiTerminalPanel({
|
|||||||
setRunning(true)
|
setRunning(true)
|
||||||
setLiveSteps([])
|
setLiveSteps([])
|
||||||
const runSafetyMode = safetyMode
|
const runSafetyMode = safetyMode
|
||||||
|
const runConfirmAll = confirmAllCommands
|
||||||
|
|
||||||
const completedSteps: AgentStep[] = []
|
const completedSteps: AgentStep[] = []
|
||||||
const commandCounts = new Map<string, number>()
|
const commandCounts = new Map<string, number>()
|
||||||
@@ -293,7 +299,7 @@ export default function AiTerminalPanel({
|
|||||||
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
|
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
|
||||||
}
|
}
|
||||||
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
||||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
|
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
|
||||||
const fingerprint = agentCommandAllowFingerprint(command, risk)
|
const fingerprint = agentCommandAllowFingerprint(command, risk)
|
||||||
if (!sessionAllowRef.current.has(fingerprint)) {
|
if (!sessionAllowRef.current.has(fingerprint)) {
|
||||||
const awaiting = { ...proposed, status: 'awaiting' as const }
|
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||||
@@ -308,11 +314,33 @@ export default function AiTerminalPanel({
|
|||||||
danger: risk === 'high',
|
danger: risk === 'high',
|
||||||
})
|
})
|
||||||
if (runIdRef.current !== runId) return
|
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') {
|
if (choice === 'reject') {
|
||||||
setLiveSteps([])
|
setLiveSteps([])
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...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
|
return
|
||||||
}
|
}
|
||||||
@@ -323,7 +351,7 @@ export default function AiTerminalPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLiveSteps([...completedSteps, proposed])
|
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 (runIdRef.current !== runId) return
|
||||||
if (!executed.success && /已终止/.test(executed.error || '')) {
|
if (!executed.success && /已终止/.test(executed.error || '')) {
|
||||||
return
|
return
|
||||||
@@ -371,6 +399,50 @@ export default function AiTerminalPanel({
|
|||||||
window.setTimeout(() => setCopiedId(''), 1200)
|
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 = () => {
|
const stopAgent = () => {
|
||||||
runIdRef.current += 1
|
runIdRef.current += 1
|
||||||
dismissAgentRisk()
|
dismissAgentRisk()
|
||||||
@@ -398,7 +470,7 @@ export default function AiTerminalPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||||
@@ -433,6 +505,19 @@ export default function AiTerminalPanel({
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -444,7 +529,12 @@ export default function AiTerminalPanel({
|
|||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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 && (
|
{messages.length === 0 && liveSteps.length === 0 && !running && (
|
||||||
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
|
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">{t('panel.emptyHint')}</p>
|
||||||
{t('panel.emptyHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
@@ -504,7 +592,10 @@ export default function AiTerminalPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -534,13 +625,29 @@ export default function AiTerminalPanel({
|
|||||||
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
|
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
|
||||||
</span>
|
</span>
|
||||||
{running ? (
|
{running ? (
|
||||||
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
|
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
|
||||||
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
|
{t('panel.stop')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
|
<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 }) {
|
function StepCard({ step }: { step: AgentStep }) {
|
||||||
const { t } = useTranslation('ai')
|
const { t } = useTranslation('ai')
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
step.status === 'awaiting'
|
step.status === 'awaiting' ? t('step.awaiting') : step.status === 'running' ? t('step.running') : t('step.done')
|
||||||
? t('step.awaiting')
|
|
||||||
: step.status === 'running'
|
|
||||||
? t('step.running')
|
|
||||||
: t('step.done')
|
|
||||||
return (
|
return (
|
||||||
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
|
<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">
|
<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 { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { Search, Globe, Code2, LogIn, Plus, X, ChevronRight, Settings, Laptop, PanelLeft } from 'lucide-react'
|
||||||
Search,
|
|
||||||
Globe,
|
|
||||||
Code2,
|
|
||||||
LogIn,
|
|
||||||
Plus,
|
|
||||||
X,
|
|
||||||
ChevronRight,
|
|
||||||
Settings,
|
|
||||||
Laptop,
|
|
||||||
PanelLeft,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
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 { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
|
||||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||||
|
|
||||||
@@ -62,12 +57,7 @@ const TYPE_BADGE_KEY: Record<CommandPaletteItemType, string> = {
|
|||||||
|
|
||||||
// ─── 组件 ──────────────────────────────────────────────────────────────
|
// ─── 组件 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CommandPalette({
|
export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, onEditSession }: CommandPaletteProps) {
|
||||||
onNewSession,
|
|
||||||
onQuickConnect,
|
|
||||||
onOpenSettings,
|
|
||||||
onEditSession,
|
|
||||||
}: CommandPaletteProps) {
|
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
@@ -210,9 +200,7 @@ export function CommandPalette({
|
|||||||
id: `snippet:${snippet.id}`,
|
id: `snippet:${snippet.id}`,
|
||||||
type: 'snippet',
|
type: 'snippet',
|
||||||
label: snippet.name,
|
label: snippet.name,
|
||||||
description: snippet.content.length > 60
|
description: snippet.content.length > 60 ? snippet.content.slice(0, 60) + '…' : snippet.content,
|
||||||
? snippet.content.slice(0, 60) + '…'
|
|
||||||
: snippet.content,
|
|
||||||
icon: <Code2 size={16} />,
|
icon: <Code2 size={16} />,
|
||||||
action: () => {
|
action: () => {
|
||||||
// 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展)
|
// 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展)
|
||||||
@@ -270,15 +258,11 @@ export function CommandPalette({
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex((prev) =>
|
setSelectedIndex((prev) => (prev < filteredItems.length - 1 ? prev + 1 : 0))
|
||||||
prev < filteredItems.length - 1 ? prev + 1 : 0
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex((prev) =>
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredItems.length - 1))
|
||||||
prev > 0 ? prev - 1 : filteredItems.length - 1
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -331,40 +315,50 @@ export function CommandPalette({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* 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 */}
|
{/* Palette container */}
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('palette.aria')}
|
aria-label={t('palette.aria')}
|
||||||
>
|
>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
|
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
|
||||||
<Search size={18} className="text-txt-muted flex-shrink-0" />
|
<Search size={18} className="flex-shrink-0 text-txt-muted" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="palette-listbox"
|
||||||
|
aria-activedescendant={filteredItems.length > 0 ? `palette-item-${selectedIndex}` : undefined}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t('palette.placeholder')}
|
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"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
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
|
ESC
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results list */}
|
{/* 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 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
|
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
|
||||||
<Search size={32} className="mb-2 opacity-30" />
|
<Search size={32} className="mb-2 opacity-30" />
|
||||||
<p className="text-sm">{t('palette.empty')}</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
groupedItems.map((group) => {
|
groupedItems.map((group) => {
|
||||||
@@ -374,13 +368,13 @@ export function CommandPalette({
|
|||||||
return (
|
return (
|
||||||
<div key={group.type}>
|
<div key={group.type}>
|
||||||
{/* Group header */}
|
{/* 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
|
<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])}
|
{t(TYPE_BADGE_KEY[group.type])}
|
||||||
</span>
|
</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>
|
<span className="text-txt-faint">{group.items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -391,14 +385,15 @@ export function CommandPalette({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
id={`palette-item-${globalIndex}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
data-index={globalIndex}
|
data-index={globalIndex}
|
||||||
className={`
|
className={`flex cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-75 ${
|
||||||
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
|
isSelected
|
||||||
${isSelected
|
|
||||||
? 'bg-accent-blue/15 text-content-primary'
|
? 'bg-accent-blue/15 text-content-primary'
|
||||||
: 'text-content-secondary hover:bg-dark-700'
|
: 'text-content-secondary hover:bg-dark-700'
|
||||||
}
|
} `}
|
||||||
`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.action()
|
item.action()
|
||||||
close()
|
close()
|
||||||
@@ -407,7 +402,7 @@ export function CommandPalette({
|
|||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<span
|
<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'
|
isSelected ? 'text-accent-blue' : 'text-txt-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -415,28 +410,19 @@ export function CommandPalette({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Label + description */}
|
{/* Label + description */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm truncate">{item.label}</div>
|
<div className="truncate text-sm">{item.label}</div>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<div className="text-[11px] text-txt-muted truncate">
|
<div className="truncate text-[11px] text-txt-muted">{item.description}</div>
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shortcut */}
|
{/* Shortcut */}
|
||||||
{item.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}
|
{item.shortcut}
|
||||||
</kbd>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -447,24 +433,22 @@ export function CommandPalette({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer hint */}
|
{/* 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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
<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')}
|
{t('palette.hint.nav')}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<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')}
|
{t('palette.hint.run')}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<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')}
|
{t('palette.hint.close')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
|
||||||
{t('palette.resultCount', { count: filteredItems.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export interface ConfirmOptions {
|
|||||||
cancelText?: string
|
cancelText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent 高风险确认:Reject / Once / Always(Always 仅会话内存) */
|
/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */
|
||||||
export type AgentRiskChoice = 'reject' | 'once' | 'always'
|
export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert'
|
||||||
|
|
||||||
export interface AgentRiskConfirmOptions {
|
export interface AgentRiskConfirmOptions {
|
||||||
title: string
|
title: string
|
||||||
@@ -155,11 +155,7 @@ export function useConfirmDialog() {
|
|||||||
cancelText={state.cancelText}
|
cancelText={state.cancelText}
|
||||||
isDanger={state.danger}
|
isDanger={state.danger}
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={
|
icon={state.danger ? <AlertTriangle className="h-5 w-5 text-red-400" /> : undefined}
|
||||||
state.danger ? (
|
|
||||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -171,12 +167,15 @@ export function useConfirmDialog() {
|
|||||||
showConfirm={false}
|
showConfirm={false}
|
||||||
size="sm"
|
size="sm"
|
||||||
disableBackdropClose
|
disableBackdropClose
|
||||||
icon={<AlertTriangle className="w-5 h-5 text-red-400" />}
|
icon={<AlertTriangle className="h-5 w-5 text-red-400" />}
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
|
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
|
||||||
{t('ai:confirm.reject')}
|
{t('ai:confirm.reject')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
|
||||||
|
{t('ai:confirm.insert')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={agentRisk.danger ? 'danger' : 'primary'}
|
variant={agentRisk.danger ? 'danger' : 'primary'}
|
||||||
size="sm"
|
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 { useState, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { Wifi, WifiOff, RefreshCw, X, Clock, HardDrive, Activity } from 'lucide-react'
|
||||||
Wifi,
|
|
||||||
WifiOff,
|
|
||||||
RefreshCw,
|
|
||||||
X,
|
|
||||||
Clock,
|
|
||||||
HardDrive,
|
|
||||||
Activity,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useConnectionStore } from '../../stores/connection'
|
import { useConnectionStore } from '../../stores/connection'
|
||||||
import { usePoolStore } from '../../stores/connection/poolStore'
|
import { usePoolStore } from '../../stores/connection/poolStore'
|
||||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
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 EmptyState from '../common/EmptyState'
|
||||||
|
import StatusDot from '../common/StatusDot'
|
||||||
|
|
||||||
// ============== Helpers ==============
|
// ============== Helpers ==============
|
||||||
|
|
||||||
@@ -43,30 +36,6 @@ function formatDuration(ms: number): string {
|
|||||||
return `${hours}h ${minutes % 60}m`
|
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 ==============
|
// ============== Connection Card ==============
|
||||||
|
|
||||||
interface ConnectionCardProps {
|
interface ConnectionCardProps {
|
||||||
@@ -79,26 +48,24 @@ interface ConnectionCardProps {
|
|||||||
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
|
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const isReconnecting = reconnectingId === conn.connectionId
|
const isReconnecting = reconnectingId === conn.connectionId
|
||||||
const canReconnect =
|
const canReconnect = conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
|
||||||
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
|
const canDisconnect = conn.state !== 'disconnected' && conn.state !== 'error'
|
||||||
const canDisconnect =
|
|
||||||
conn.state !== 'disconnected' && conn.state !== 'error'
|
|
||||||
|
|
||||||
const durationMs = Date.now() - conn.stats.connectedAt
|
const durationMs = Date.now() - conn.stats.connectedAt
|
||||||
const sessionCount = conn.shellIds.size + conn.sftpIds.size
|
const sessionCount = conn.shellIds.size + conn.sftpIds.size
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header: host + status */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<StatusDot state={conn.state} />
|
<StatusDot state={conn.state} className="h-2 w-2" />
|
||||||
<span className="text-sm text-content-primary truncate font-medium">
|
<span className="truncate text-sm font-medium text-content-primary">
|
||||||
{conn.config.username}@{conn.config.host}
|
{conn.config.username}@{conn.config.host}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
conn.state === 'connected'
|
conn.state === 'connected'
|
||||||
? 'bg-feedback-success/10 text-feedback-success'
|
? 'bg-feedback-success/10 text-feedback-success'
|
||||||
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
: 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">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-txt-muted">
|
||||||
{/* Port */}
|
{/* Port */}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Activity className="w-3 h-3" />
|
<Activity className="h-3 w-3" />:{conn.config.port ?? 22}
|
||||||
:{conn.config.port ?? 22}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Duration */}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{formatDuration(durationMs)}
|
{formatDuration(durationMs)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Transfer */}
|
{/* Transfer */}
|
||||||
{conn.stats.bytesTransferred > 0 && (
|
{conn.stats.bytesTransferred > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<HardDrive className="w-3 h-3" />
|
<HardDrive className="h-3 w-3" />
|
||||||
{formatBytes(conn.stats.bytesTransferred)}
|
{formatBytes(conn.stats.bytesTransferred)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -156,11 +122,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReconnect(conn.connectionId)}
|
onClick={() => onReconnect(conn.connectionId)}
|
||||||
disabled={isReconnecting}
|
disabled={isReconnecting}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
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"
|
||||||
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
<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')}
|
{isReconnecting ? t('connectionPanel.reconnecting') : t('connectionPanel.reconnect')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -168,10 +132,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDisconnect(conn.connectionId)}
|
onClick={() => onDisconnect(conn.connectionId)}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
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"
|
||||||
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
|
|
||||||
>
|
>
|
||||||
<WifiOff className="w-3 h-3" />
|
<WifiOff className="h-3 w-3" />
|
||||||
{t('connectionPanel.disconnect')}
|
{t('connectionPanel.disconnect')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +142,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
|||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{conn.error && (
|
{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}
|
{conn.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -208,7 +171,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{items.map((item) => (
|
{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-lg font-semibold ${item.color}`}>{item.value}</div>
|
||||||
<div className="text-[10px] text-txt-muted">{item.label}</div>
|
<div className="text-[10px] text-txt-muted">{item.label}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,11 +188,7 @@ export default function ConnectionPanel() {
|
|||||||
const close = useConnectionPanelStore((s) => s.close)
|
const close = useConnectionPanelStore((s) => s.close)
|
||||||
|
|
||||||
const connections = usePoolStore((s) => s.connections)
|
const connections = usePoolStore((s) => s.connections)
|
||||||
const {
|
const { resumeConnection, disconnectConnection, getStats } = useConnectionStore()
|
||||||
resumeConnection,
|
|
||||||
disconnectConnection,
|
|
||||||
getStats,
|
|
||||||
} = useConnectionStore()
|
|
||||||
|
|
||||||
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -250,14 +209,14 @@ export default function ConnectionPanel() {
|
|||||||
setReconnectingId(null)
|
setReconnectingId(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resumeConnection],
|
[resumeConnection]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDisconnect = useCallback(
|
const handleDisconnect = useCallback(
|
||||||
async (connectionId: string) => {
|
async (connectionId: string) => {
|
||||||
await disconnectConnection(connectionId)
|
await disconnectConnection(connectionId)
|
||||||
},
|
},
|
||||||
[disconnectConnection],
|
[disconnectConnection]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@@ -265,34 +224,27 @@ export default function ConnectionPanel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
|
||||||
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
|
|
||||||
onClick={close}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
|
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||||
<h2 className="text-sm font-semibold text-content-primary flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
|
||||||
<Wifi className="w-4 h-4 text-accent-blue" />
|
<Wifi className="h-4 w-4 text-accent-blue" />
|
||||||
{t('connectionPanel.title')}
|
{t('connectionPanel.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{/* 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
|
<StatsOverview
|
||||||
total={stats.total}
|
total={stats.total}
|
||||||
connected={stats.connected}
|
connected={stats.connected}
|
||||||
@@ -302,7 +254,7 @@ export default function ConnectionPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection List */}
|
{/* 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 ? (
|
{connList.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={WifiOff}
|
icon={WifiOff}
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
|
||||||
Cast,
|
|
||||||
ClipboardPaste,
|
|
||||||
FolderInput,
|
|
||||||
FolderOpen,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
Monitor,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||||||
|
import StatusDot from '../common/StatusDot'
|
||||||
import type {
|
import type {
|
||||||
DesktopFrameEvent,
|
DesktopFrameEvent,
|
||||||
DesktopPointerButton,
|
DesktopPointerButton,
|
||||||
@@ -96,9 +88,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||||
const sessions = useSessionStore((state) => state.sessions)
|
const sessions = useSessionStore((state) => state.sessions)
|
||||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||||
const fullscreenActive = useDesktopFullscreenStore(
|
const fullscreenActive = useDesktopFullscreenStore((state) => state.active && state.tabId === tabId)
|
||||||
(state) => state.active && state.tabId === tabId
|
|
||||||
)
|
|
||||||
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||||||
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||||||
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
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)
|
const local = sessions.find((item) => item.id === tab.sessionId)
|
||||||
if (local) {
|
if (local) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResolvedConfig(
|
setResolvedConfig(protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local))
|
||||||
protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
|
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
|
||||||
if (!cancelled && response?.success && response.session) {
|
if (!cancelled && response?.success && response.session) {
|
||||||
setResolvedConfig(
|
setResolvedConfig(
|
||||||
protocol === 'rdp'
|
protocol === 'rdp' ? sessionToRdpConfig(response.session) : sessionToVncConfig(response.session)
|
||||||
? sessionToRdpConfig(response.session)
|
|
||||||
: sessionToVncConfig(response.session)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,11 +534,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const isPasteChord =
|
const isPasteChord =
|
||||||
pressed &&
|
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.code === 'KeyV' &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.shiftKey
|
|
||||||
|
|
||||||
if (isPasteChord) {
|
if (isPasteChord) {
|
||||||
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
|
// 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 Icon = protocol === 'rdp' ? Monitor : Cast
|
||||||
const hostLabel =
|
const hostLabel = resolvedConfig && 'host' in resolvedConfig ? resolvedConfig.host : tab?.title
|
||||||
resolvedConfig && 'host' in resolvedConfig
|
|
||||||
? resolvedConfig.host
|
|
||||||
: tab?.title
|
|
||||||
const connected = tab?.connectionStatus === 'connected'
|
const connected = tab?.connectionStatus === 'connected'
|
||||||
|
|
||||||
const toolBtn =
|
const toolBtn =
|
||||||
@@ -610,8 +589,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||||
fullscreenActive
|
fullscreenActive
|
||||||
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
|
? 'border-dark-700/60 bg-dark-900/95 rounded-b-lg border border-t-0 shadow-lg shadow-black/40 backdrop-blur'
|
||||||
: 'border-b border-dark-700/50 bg-dark-800/80'
|
: 'border-dark-700/50 bg-dark-800/80 border-b'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (fullscreenActive) {
|
if (fullscreenActive) {
|
||||||
@@ -636,11 +615,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
{tab?.connectionStatus === 'connecting' ? (
|
{tab?.connectionStatus === 'connecting' ? (
|
||||||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||||||
) : (
|
) : (
|
||||||
<span
|
<StatusDot state={connected ? 'connected' : tab?.connectionStatus === 'error' ? 'error' : 'disconnected'} />
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
<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>}
|
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||||||
@@ -739,7 +714,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
tabIndex={0}
|
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()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
onMouseMove={(event) => mapPointer(event)}
|
onMouseMove={(event) => mapPointer(event)}
|
||||||
onMouseDown={(event) => mapPointer(event, true)}
|
onMouseDown={(event) => mapPointer(event, true)}
|
||||||
|
|||||||
@@ -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 { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||||
@@ -18,8 +19,11 @@ import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol'
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SESSION_GROUP,
|
DEFAULT_SESSION_GROUP,
|
||||||
groupDisplayName,
|
groupDisplayName,
|
||||||
|
groupSessionsInDisplayOrder,
|
||||||
normalizeGroupId,
|
normalizeGroupId,
|
||||||
} from '../../utils/sessionGroups'
|
} from '../../utils/sessionGroups'
|
||||||
|
import { localeCompareText } from '../../i18n/format'
|
||||||
|
import type { SessionReorderEntry } from '../../../shared/types/session.types'
|
||||||
|
|
||||||
const MIN_SIDEBAR_WIDTH = 180
|
const MIN_SIDEBAR_WIDTH = 180
|
||||||
const MAX_SIDEBAR_WIDTH = 400
|
const MAX_SIDEBAR_WIDTH = 400
|
||||||
@@ -33,7 +37,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
|
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
|
||||||
const { t } = useTranslation(['sessions', 'common'])
|
const { t } = useTranslation(['sessions', 'common'])
|
||||||
const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore()
|
const { sessions, setSessions, adoptSession, removeSession, updateSession } = useSessionStore()
|
||||||
const { settings, setSetting } = useSettingsStore()
|
const { settings, setSetting } = useSettingsStore()
|
||||||
const { addTab } = useTabStore()
|
const { addTab } = useTabStore()
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
@@ -67,7 +71,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
|
|
||||||
// Load sessions
|
// Load sessions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.opsxApi?.sessionGetAll()
|
window.opsxApi
|
||||||
|
?.sessionGetAll()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.success && result.sessions) setSessions(result.sessions)
|
if (result.success && result.sessions) setSessions(result.sessions)
|
||||||
})
|
})
|
||||||
@@ -94,8 +99,28 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
}
|
}
|
||||||
}, [contextMenu])
|
}, [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
|
// Resize handler
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
|
|
||||||
@@ -123,7 +148,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
document.body.style.cursor = 'col-resize'
|
document.body.style.cursor = 'col-resize'
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
}, [sidebarWidth, setSetting])
|
},
|
||||||
|
[sidebarWidth, setSetting]
|
||||||
|
)
|
||||||
|
|
||||||
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,16 +198,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
|
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
|
||||||
const batchableSessions = useMemo(
|
const batchableSessions = useMemo(() => sessions.filter((session) => isSshSession(session)), [sessions])
|
||||||
() => sessions.filter((session) => isSshSession(session)),
|
|
||||||
[sessions]
|
|
||||||
)
|
|
||||||
|
|
||||||
const visibleSessions = batchableSessions.filter((session) => {
|
const visibleSessions = batchableSessions.filter((session) => {
|
||||||
const query = searchTerm.trim().toLowerCase()
|
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.host.toLowerCase().includes(query) ||
|
||||||
session.username?.toLowerCase().includes(query)
|
session.username?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleSelectVisible = useCallback(() => {
|
const toggleSelectVisible = useCallback(() => {
|
||||||
@@ -244,9 +271,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
|
|
||||||
const persistSessionGroups = useCallback(
|
const persistSessionGroups = useCallback(
|
||||||
async (next: string[]) => {
|
async (next: string[]) => {
|
||||||
const cleaned = next
|
const cleaned = next.map((item) => normalizeGroupId(item)).filter((item) => item !== DEFAULT_SESSION_GROUP)
|
||||||
.map((item) => normalizeGroupId(item))
|
|
||||||
.filter((item) => item !== DEFAULT_SESSION_GROUP)
|
|
||||||
const unique = Array.from(new Set(cleaned))
|
const unique = Array.from(new Set(cleaned))
|
||||||
await setSetting('sessionGroups', unique)
|
await setSetting('sessionGroups', unique)
|
||||||
},
|
},
|
||||||
@@ -317,6 +342,71 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
[confirm, persistSessionGroups, sessions, settings.sessionGroups, t]
|
[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) => {
|
const handleContextMenuAction = async (action: string) => {
|
||||||
if (!contextMenu) return
|
if (!contextMenu) return
|
||||||
|
|
||||||
@@ -331,6 +421,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
const group = contextMenu.group
|
const group = contextMenu.group
|
||||||
if (action === 'new-session') onNewSession(group)
|
if (action === 'new-session') onNewSession(group)
|
||||||
if (action === 'new-group') await createGroup()
|
if (action === 'new-group') await createGroup()
|
||||||
|
if (action === 'sort-by-name') sortGroupByName(group)
|
||||||
if (action === 'rename-group') await renameGroup(group)
|
if (action === 'rename-group') await renameGroup(group)
|
||||||
if (action === 'delete-group') await deleteEmptyGroup(group)
|
if (action === 'delete-group') await deleteEmptyGroup(group)
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
@@ -350,12 +441,14 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
break
|
break
|
||||||
case 'duplicate': {
|
case 'duplicate': {
|
||||||
const { id: _id, ...sessionWithoutId } = session
|
const { id: _id, ...sessionWithoutId } = session
|
||||||
window.opsxApi?.sessionCreate({
|
window.opsxApi
|
||||||
|
?.sessionCreate({
|
||||||
...sessionWithoutId,
|
...sessionWithoutId,
|
||||||
name: `${session.name} (${t('common:action.clone')})`,
|
name: `${session.name} (${t('common:action.clone')})`,
|
||||||
}).then((result) => {
|
})
|
||||||
|
.then((result) => {
|
||||||
if (result?.success && result.session) {
|
if (result?.success && result.session) {
|
||||||
addSession(result.session as Session)
|
adoptSession(result.session as Session)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
@@ -381,13 +474,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
|
className="bg-dark-800/95 border-dark-700/40 relative flex h-full flex-col border-r"
|
||||||
style={{ width: sidebarWidth }}
|
style={{ width: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-3.5 border-b border-dark-700/30">
|
<div className="border-dark-700/30 border-b p-3.5">
|
||||||
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
|
<div className="mb-3.5 flex items-center gap-2.5 px-0.5">
|
||||||
<img
|
<img
|
||||||
src={appIconUrl}
|
src={appIconUrl}
|
||||||
alt="OpsXShell"
|
alt="OpsXShell"
|
||||||
@@ -397,10 +490,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
|
<div className="text-sm font-semibold leading-none tracking-tight text-content-primary">OpsXShell</div>
|
||||||
OpsXShell
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{onCollapse && (
|
{onCollapse && (
|
||||||
<button
|
<button
|
||||||
@@ -408,9 +499,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
onClick={onCollapse}
|
onClick={onCollapse}
|
||||||
title={t('common:sidebar.collapse')}
|
title={t('common:sidebar.collapse')}
|
||||||
aria-label={t('common:sidebar.collapseAria')}
|
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"
|
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} />
|
<ChevronsLeft
|
||||||
|
className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +514,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onQuickConnect}
|
onClick={onQuickConnect}
|
||||||
title={t('sidebar.quickConnect')}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
|
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
|
||||||
{t('sidebar.quickConnectShort')}
|
{t('sidebar.quickConnectShort')}
|
||||||
@@ -429,7 +523,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNewSession()}
|
onClick={() => onNewSession()}
|
||||||
title={t('sidebar.newSession')}
|
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"
|
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} />
|
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||||
{t('sidebar.newSessionShort')}
|
{t('sidebar.newSessionShort')}
|
||||||
@@ -438,20 +532,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
</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="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SearchBox
|
<SearchBox value={searchTerm} onChange={setSearchTerm} placeholder={t('sidebar.searchPlaceholder')} />
|
||||||
value={searchTerm}
|
|
||||||
onChange={setSearchTerm}
|
|
||||||
placeholder={t('sidebar.searchPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
|
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
|
||||||
aria-label={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 ${
|
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
|
||||||
batchMode
|
batchMode
|
||||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||||
@@ -463,11 +553,15 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
</div>
|
</div>
|
||||||
{batchMode && (
|
{batchMode && (
|
||||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
<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 })}
|
{t('sidebar.batchSelected', { count: batchSelection.size })}
|
||||||
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
|
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
|
||||||
</span>
|
</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))
|
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
|
||||||
? t('sidebar.deselectAllVisible')
|
? t('sidebar.deselectAllVisible')
|
||||||
: t('sidebar.selectAllVisible')}
|
: t('sidebar.selectAllVisible')}
|
||||||
@@ -486,6 +580,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onGroupContextMenu={handleGroupContextMenu}
|
onGroupContextMenu={handleGroupContextMenu}
|
||||||
onBlankContextMenu={handleBlankContextMenu}
|
onBlankContextMenu={handleBlankContextMenu}
|
||||||
|
onReorderSessions={handleReorderSessions}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
loading={sessionsLoading}
|
loading={sessionsLoading}
|
||||||
onNewSession={() => onNewSession()}
|
onNewSession={() => onNewSession()}
|
||||||
@@ -494,7 +589,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
onToggleSelection={toggleBatchSelection}
|
onToggleSelection={toggleBatchSelection}
|
||||||
/>
|
/>
|
||||||
{batchMode && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={selectedSessions.length === 0}
|
disabled={selectedSessions.length === 0}
|
||||||
@@ -509,22 +604,23 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
</>
|
</>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-3 border-t border-dark-700/20">
|
<div className="border-dark-700/20 border-t p-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
|
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"
|
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="w-4 h-4" />
|
<Settings className="h-4 w-4" />
|
||||||
<span>{t('sidebar.settings')}</span>
|
<span>{t('sidebar.settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu — portaled so sidebar overflow cannot stretch/clip it */}
|
||||||
{contextMenu && (
|
{contextMenu &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
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]"
|
className="fixed z-[200] w-max rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{(contextMenu.kind === 'blank'
|
{(contextMenu.kind === 'blank'
|
||||||
@@ -536,10 +632,15 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
? [
|
? [
|
||||||
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
|
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
|
||||||
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
|
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
|
||||||
|
{ label: t('contextMenu.group.sortByName'), action: 'sort-by-name' },
|
||||||
...(contextMenu.group !== DEFAULT_SESSION_GROUP
|
...(contextMenu.group !== DEFAULT_SESSION_GROUP
|
||||||
? [
|
? [
|
||||||
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
|
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
|
||||||
{ label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true },
|
{
|
||||||
|
label: t('contextMenu.group.deleteEmptyGroup'),
|
||||||
|
action: 'delete-group',
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
@@ -555,20 +656,17 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
).map((item) => (
|
).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.action}
|
key={item.action}
|
||||||
|
type="button"
|
||||||
onClick={() => handleContextMenuAction(item.action)}
|
onClick={() => handleContextMenuAction(item.action)}
|
||||||
className={`
|
className={`block w-full whitespace-nowrap px-2.5 py-1.5 text-left text-xs transition-colors ${
|
||||||
w-full px-4 py-2 text-left text-sm transition-colors
|
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
|
||||||
${
|
}`}
|
||||||
item.danger
|
|
||||||
? 'text-red-400 hover:bg-red-500/20'
|
|
||||||
: 'text-content-secondary hover:bg-dark-700'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -580,13 +678,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
onCollapse?.()
|
onCollapse?.()
|
||||||
}}
|
}}
|
||||||
title={t('sidebar.resizeHint')}
|
title={t('sidebar.resizeHint')}
|
||||||
className={`
|
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)]' : ''} `}
|
||||||
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>
|
</div>
|
||||||
{ConfirmDialogComponent}
|
{ConfirmDialogComponent}
|
||||||
|
|||||||
@@ -22,13 +22,7 @@ interface TabBarProps {
|
|||||||
|
|
||||||
function TabBar({ onOpenLauncher }: TabBarProps) {
|
function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const {
|
const { tabs, activeTabId, activateTab, addTab, moveTab, updateTabTitle } = useTabStore()
|
||||||
tabs,
|
|
||||||
activeTabId,
|
|
||||||
activateTab,
|
|
||||||
addTab,
|
|
||||||
moveTab,
|
|
||||||
} = useTabStore()
|
|
||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
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
|
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
|
||||||
data-tauri-drag-region
|
|
||||||
className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"
|
|
||||||
>
|
|
||||||
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
|
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
|
||||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
|
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={t('tab.listAria')}
|
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) => (
|
{tabs.map((tab, index) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
@@ -144,6 +150,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onRename={
|
||||||
|
tab.type !== 'welcome' && tab.type !== 'settings' ? (title) => updateTabTitle(tab.id, title) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +160,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenLauncher}
|
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')}
|
title={t('launcher.title')}
|
||||||
aria-label={t('launcher.title')}
|
aria-label={t('launcher.title')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,15 +17,10 @@ interface TabContentProps {
|
|||||||
onQuickConnect?: () => void
|
onQuickConnect?: () => void
|
||||||
onRdpConnect?: () => void
|
onRdpConnect?: () => void
|
||||||
onVncConnect?: () => void
|
onVncConnect?: () => void
|
||||||
|
onImportSessions?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProGate({
|
function ProGate({ feature, children }: { feature: ProFeature; children: React.ReactNode }) {
|
||||||
feature,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
feature: ProFeature
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const canUse = useEntitlementStore((s) => s.canUse(feature))
|
const canUse = useEntitlementStore((s) => s.canUse(feature))
|
||||||
if (!canUse) {
|
if (!canUse) {
|
||||||
return <UpgradeView feature={feature} />
|
return <UpgradeView feature={feature} />
|
||||||
@@ -39,11 +34,12 @@ function TabContent({
|
|||||||
onQuickConnect,
|
onQuickConnect,
|
||||||
onRdpConnect,
|
onRdpConnect,
|
||||||
onVncConnect,
|
onVncConnect,
|
||||||
|
onImportSessions,
|
||||||
}: TabContentProps) {
|
}: TabContentProps) {
|
||||||
const tabs = useTabStore((state) => state.tabs)
|
const tabs = useTabStore((state) => state.tabs)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative h-full w-full">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.id === activeTabId
|
const isActive = tab.id === activeTabId
|
||||||
|
|
||||||
@@ -89,10 +85,7 @@ function TabContent({
|
|||||||
break
|
break
|
||||||
case 'upgrade':
|
case 'upgrade':
|
||||||
content = (
|
content = (
|
||||||
<UpgradeView
|
<UpgradeView feature={tab.upgradeFeature as ProFeature | undefined} comingSoon={tab.upgradeComingSoon} />
|
||||||
feature={tab.upgradeFeature as ProFeature | undefined}
|
|
||||||
comingSoon={tab.upgradeComingSoon}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'batch':
|
case 'batch':
|
||||||
@@ -110,6 +103,7 @@ function TabContent({
|
|||||||
onQuickConnect={onQuickConnect}
|
onQuickConnect={onQuickConnect}
|
||||||
onRdpConnect={onRdpConnect}
|
onRdpConnect={onRdpConnect}
|
||||||
onVncConnect={onVncConnect}
|
onVncConnect={onVncConnect}
|
||||||
|
onImportSessions={onImportSessions}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,12 +111,12 @@ function TabContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
id={`tabpanel-${tab.id}`}
|
||||||
|
role="tabpanel"
|
||||||
aria-hidden={!isActive}
|
aria-hidden={!isActive}
|
||||||
{...(!isActive ? { inert: '' } : {})}
|
{...(!isActive ? { inert: '' } : {})}
|
||||||
className={`absolute inset-0 transition-opacity duration-150 ${
|
className={`absolute inset-0 transition-opacity duration-150 ${
|
||||||
isActive
|
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
|
||||||
? 'visible opacity-100 z-10'
|
|
||||||
: 'invisible opacity-0 z-0 pointer-events-none'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* SessionTree — 分组会话列表
|
* SessionTree — 分组会话列表
|
||||||
* 单击选中 · 双击打开终端 · 右键更多操作
|
* 单击选中 · 双击打开终端 · 右键更多操作 · 拖拽调整组内顺序或移动分组
|
||||||
|
*
|
||||||
|
* macOS WKWebView 的 HTML5 drop 不可靠(SFTP 已改指针拖),侧栏同样用 pointer 拖拽。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Check, ChevronRight, Folder, ServerOff } from 'lucide-react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { SessionTreeSkeleton } from '../../common/Skeleton'
|
import { SessionTreeSkeleton } from '../../common/Skeleton'
|
||||||
import HostDistroIcon from '../../common/HostDistroIcon'
|
import HostDistroIcon from '../../common/HostDistroIcon'
|
||||||
import type { Session } from '../../../stores/sessionStore'
|
import type { Session } from '../../../stores/sessionStore'
|
||||||
import EmptyState from '../../common/EmptyState'
|
import EmptyState from '../../common/EmptyState'
|
||||||
import { collectSessionGroups, groupDisplayName } from '../../../utils/sessionGroups'
|
import { collectSessionGroups, compareBySortOrder, groupDisplayName } from '../../../utils/sessionGroups'
|
||||||
import { localeCompareText } from '../../../i18n/format'
|
import { localeCompareText } from '../../../i18n/format'
|
||||||
|
|
||||||
|
const POINTER_DRAG_THRESHOLD = 6
|
||||||
|
const AUTO_EXPAND_MS = 400
|
||||||
|
const DRAG_BODY_CLASS = 'session-tree-dragging'
|
||||||
|
|
||||||
interface SessionTreeProps {
|
interface SessionTreeProps {
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
/** Persisted empty groups + any extras to always show. */
|
/** Persisted empty groups + any extras to always show. */
|
||||||
@@ -24,6 +32,8 @@ interface SessionTreeProps {
|
|||||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||||
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
|
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
|
||||||
onBlankContextMenu?: (e: React.MouseEvent) => 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
|
searchTerm: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
onNewSession?: () => void
|
onNewSession?: () => void
|
||||||
@@ -32,6 +42,68 @@ interface SessionTreeProps {
|
|||||||
onToggleSelection?: (session: Session) => void
|
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({
|
export function SessionTree({
|
||||||
sessions,
|
sessions,
|
||||||
extraGroups = [],
|
extraGroups = [],
|
||||||
@@ -43,6 +115,7 @@ export function SessionTree({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
onGroupContextMenu,
|
onGroupContextMenu,
|
||||||
onBlankContextMenu,
|
onBlankContextMenu,
|
||||||
|
onReorderSessions,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
loading = false,
|
loading = false,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
@@ -51,6 +124,26 @@ export function SessionTree({
|
|||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const { t } = useTranslation('sessions')
|
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) {
|
if (loading) {
|
||||||
return (
|
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) => {
|
const groupKeys = Object.keys(grouped).sort((a, b) => {
|
||||||
if (a === 'default') return -1
|
if (a === 'default') return -1
|
||||||
if (b === 'default') return 1
|
if (b === 'default') return 1
|
||||||
return localeCompareText(a, b)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
|
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
|
||||||
@@ -103,11 +305,13 @@ export function SessionTree({
|
|||||||
{groupKeys.map((group) => {
|
{groupKeys.map((group) => {
|
||||||
const groupSessions = grouped[group]
|
const groupSessions = grouped[group]
|
||||||
const isExpanded = expandedGroups.has(group)
|
const isExpanded = expandedGroups.has(group)
|
||||||
|
const groupDropActive = dropTarget?.group === group && dropTarget.index === groupSessions.length
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-group-row
|
data-group-row
|
||||||
|
data-group={group}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-controls={`session-group-${group}`}
|
aria-controls={`session-group-${group}`}
|
||||||
onClick={() => onToggleGroup(group)}
|
onClick={() => onToggleGroup(group)}
|
||||||
@@ -116,15 +320,17 @@ export function SessionTree({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onGroupContextMenu?.(e, group)
|
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
|
<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)' }}
|
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="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>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -133,26 +339,44 @@ export function SessionTree({
|
|||||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||||
aria-hidden={!isExpanded}
|
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 ? (
|
{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) => {
|
groupSessions.map((session, index) => {
|
||||||
const selected = multiSelect
|
const selected = multiSelect ? selectedSessionIds.has(session.id) : selectedSessionId === session.id
|
||||||
? selectedSessionIds.has(session.id)
|
const showInsertBefore = dropTarget?.group === group && dropTarget.index === index
|
||||||
: selectedSessionId === session.id
|
const showInsertAfter =
|
||||||
|
dropTarget?.group === group &&
|
||||||
|
dropTarget.index === index + 1 &&
|
||||||
|
index === groupSessions.length - 1
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
data-session-row
|
data-session-row
|
||||||
|
data-group={group}
|
||||||
|
data-index={index}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={isExpanded ? 0 : -1}
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
|
onPointerDown={(e) => startPointerDrag(session, e)}
|
||||||
|
onPointerMove={movePointerDrag}
|
||||||
|
onPointerUp={finishPointerDrag}
|
||||||
|
onPointerCancel={finishPointerDrag}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (suppressClickRef.current) return
|
||||||
if (multiSelect) onToggleSelection?.(session)
|
if (multiSelect) onToggleSelection?.(session)
|
||||||
else onSelectSession(session)
|
else onSelectSession(session)
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
if (!multiSelect) onConnectSession(session)
|
if (suppressClickRef.current || multiSelect) return
|
||||||
|
onConnectSession(session)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
|
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
|
||||||
@@ -167,22 +391,24 @@ export function SessionTree({
|
|||||||
? t('tree.selectSession')
|
? t('tree.selectSession')
|
||||||
: `${session.username}@${session.host}:${session.port || 22}`
|
: `${session.username}@${session.host}:${session.port || 22}`
|
||||||
}
|
}
|
||||||
className={`
|
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' : ''} ${
|
||||||
group flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer
|
|
||||||
transition-colors duration-150 pl-9 pr-2
|
|
||||||
${
|
|
||||||
selected
|
selected
|
||||||
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
|
? 'border-l-2 border-accent-blue bg-accent-blue/10'
|
||||||
: 'border-l-2 border-transparent hover:bg-dark-700/40'
|
: '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 ? (
|
{multiSelect ? (
|
||||||
<span
|
<span
|
||||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
|
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
|
||||||
selected
|
selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-900'
|
||||||
? 'border-accent-blue bg-accent-blue text-white'
|
|
||||||
: 'border-dark-500 bg-dark-900'
|
|
||||||
}`}
|
}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@@ -210,11 +436,7 @@ export function SessionTree({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{filteredSessions.length === 0 && query && (
|
{filteredSessions.length === 0 && query && (
|
||||||
<EmptyState
|
<EmptyState icon={ServerOff} title={t('tree.noMatchTitle')} description={t('tree.noMatchDesc')} />
|
||||||
icon={ServerOff}
|
|
||||||
title={t('tree.noMatchTitle')}
|
|
||||||
description={t('tree.noMatchDesc')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && (
|
{sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && (
|
||||||
@@ -222,11 +444,21 @@ export function SessionTree({
|
|||||||
icon={ServerOff}
|
icon={ServerOff}
|
||||||
title={t('tree.emptyTitle')}
|
title={t('tree.emptyTitle')}
|
||||||
description={t('tree.emptyDesc')}
|
description={t('tree.emptyDesc')}
|
||||||
action={
|
action={onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined}
|
||||||
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
* TabItem — local shell uses the same Terminal icon as the launcher (+)
|
* 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 { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Tab } from '../../../stores/tabStore'
|
import type { Tab } from '../../../stores/tabStore'
|
||||||
import { useSessionStore } from '../../../stores/sessionStore'
|
import { useSessionStore } from '../../../stores/sessionStore'
|
||||||
import HostDistroIcon from '../../common/HostDistroIcon'
|
import HostDistroIcon from '../../common/HostDistroIcon'
|
||||||
import { resolveTabTitle } from '../../../utils/tabTitle'
|
import { resolveTabTitle } from '../../../utils/tabTitle'
|
||||||
|
import StatusDot from '../../common/StatusDot'
|
||||||
|
|
||||||
interface TabItemProps {
|
interface TabItemProps {
|
||||||
tab: Tab
|
tab: Tab
|
||||||
@@ -22,6 +24,8 @@ interface TabItemProps {
|
|||||||
onDragOver: (e: React.DragEvent, index: number) => void
|
onDragOver: (e: React.DragEvent, index: number) => void
|
||||||
onDrop: (e: React.DragEvent, index: number) => void
|
onDrop: (e: React.DragEvent, index: number) => void
|
||||||
onDragEnd: () => void
|
onDragEnd: () => void
|
||||||
|
/** 双击标签内联重命名;welcome/settings 等固定标签不传。 */
|
||||||
|
onRename?: (title: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabItem({
|
export function TabItem({
|
||||||
@@ -37,6 +41,7 @@ export function TabItem({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
onRename,
|
||||||
}: TabItemProps) {
|
}: TabItemProps) {
|
||||||
const { t } = useTranslation(['terminal', 'common'])
|
const { t } = useTranslation(['terminal', 'common'])
|
||||||
const displayTitle = resolveTabTitle(tab)
|
const displayTitle = resolveTabTitle(tab)
|
||||||
@@ -44,6 +49,29 @@ export function TabItem({
|
|||||||
const session = useSessionStore((state) =>
|
const session = useSessionStore((state) =>
|
||||||
tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined
|
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 = () => {
|
const getIcon = () => {
|
||||||
if (isLocalTerm) {
|
if (isLocalTerm) {
|
||||||
@@ -57,13 +85,8 @@ export function TabItem({
|
|||||||
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
|
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
|
||||||
return (
|
return (
|
||||||
<HostDistroIcon
|
<HostDistroIcon
|
||||||
distro={
|
distro={session?.distro ?? (tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)}
|
||||||
session?.distro ??
|
protocol={tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : (session?.protocol ?? 'ssh')}
|
||||||
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
|
|
||||||
}
|
|
||||||
protocol={
|
|
||||||
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
|
|
||||||
}
|
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -88,12 +111,10 @@ export function TabItem({
|
|||||||
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
||||||
|
|
||||||
const statusDot =
|
const statusDot =
|
||||||
tab.connectionStatus === 'connecting' ? (
|
tab.connectionStatus === 'connecting' ||
|
||||||
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
|
tab.connectionStatus === 'connected' ||
|
||||||
) : tab.connectionStatus === 'connected' ? (
|
tab.connectionStatus === 'error' ? (
|
||||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
|
<StatusDot state={tab.connectionStatus} />
|
||||||
) : tab.connectionStatus === 'error' ? (
|
|
||||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
|
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +122,8 @@ export function TabItem({
|
|||||||
data-tab-item
|
data-tab-item
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
tabIndex={0}
|
aria-controls={`tabpanel-${tab.id}`}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
draggable
|
draggable
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
onAuxClick={(e) => {
|
onAuxClick={(e) => {
|
||||||
@@ -126,50 +148,66 @@ export function TabItem({
|
|||||||
onDragOver={(e) => onDragOver(e, index)}
|
onDragOver={(e) => onDragOver(e, index)}
|
||||||
onDrop={(e) => onDrop(e, index)}
|
onDrop={(e) => onDrop(e, index)}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
className={`
|
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 ${
|
||||||
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
|
isActive
|
||||||
? 'bg-dark-900 text-content-primary'
|
? 'bg-dark-900 text-content-primary'
|
||||||
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
||||||
}
|
} ${isDragging ? 'opacity-50' : 'opacity-100'} `}
|
||||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{showLeftIndicator && (
|
{showLeftIndicator && <div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />}
|
||||||
<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" />}
|
||||||
)}
|
|
||||||
{showRightIndicator && (
|
|
||||||
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{statusDot}
|
{statusDot}
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
|
|
||||||
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
|
{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}
|
{displayTitle}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
||||||
className={`
|
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' : ''} `}
|
||||||
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' : ''}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`
|
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'} `}
|
||||||
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react'
|
import { useState, useEffect, useMemo, type ComponentType } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { X, Server, Key, Lock, Tag, Folder, ChevronDown, Check } from 'lucide-react'
|
||||||
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||||
import type { AuthType, ProxyType, SessionProtocol } from '../../../shared/types/session.types'
|
import type { AuthType, ProxyType, SessionProtocol } from '../../../shared/types/session.types'
|
||||||
import { createLogger } from '../../utils/logger'
|
import { createLogger } from '../../utils/logger'
|
||||||
@@ -22,6 +20,8 @@ import { useSettingsStore } from '../../stores/settingsStore'
|
|||||||
import { usePromptDialog } from '../common/PromptDialog'
|
import { usePromptDialog } from '../common/PromptDialog'
|
||||||
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
||||||
import { getDisplayResolution } from '../../utils/desktopResolution'
|
import { getDisplayResolution } from '../../utils/desktopResolution'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
import { translateErrorMessage } from '../../i18n/format'
|
||||||
|
|
||||||
const logger = createLogger('SessionDialog')
|
const logger = createLogger('SessionDialog')
|
||||||
|
|
||||||
@@ -84,7 +84,8 @@ export default function SessionDialog({
|
|||||||
editSession,
|
editSession,
|
||||||
defaultGroup = DEFAULT_SESSION_GROUP,
|
defaultGroup = DEFAULT_SESSION_GROUP,
|
||||||
}: SessionDialogProps) {
|
}: SessionDialogProps) {
|
||||||
const { t } = useTranslation(['sessions', 'common'])
|
const { t } = useTranslation(['sessions', 'common', 'errors'])
|
||||||
|
const { toast } = useToast()
|
||||||
const { addSession, updateSession, sessions } = useSessionStore()
|
const { addSession, updateSession, sessions } = useSessionStore()
|
||||||
const { requirePro } = useEntitlementStore()
|
const { requirePro } = useEntitlementStore()
|
||||||
const sessionGroups = useSettingsStore((s) => s.settings.sessionGroups)
|
const sessionGroups = useSettingsStore((s) => s.settings.sessionGroups)
|
||||||
@@ -96,10 +97,7 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
const isEditing = !!editSession
|
const isEditing = !!editSession
|
||||||
const isSsh = formData.protocol === 'ssh'
|
const isSsh = formData.protocol === 'ssh'
|
||||||
const knownGroups = useMemo(
|
const knownGroups = useMemo(() => collectSessionGroups(sessions, sessionGroups), [sessions, sessionGroups])
|
||||||
() => collectSessionGroups(sessions, sessionGroups),
|
|
||||||
[sessions, sessionGroups]
|
|
||||||
)
|
|
||||||
const jumpCandidates = sessions.filter(
|
const jumpCandidates = sessions.filter(
|
||||||
(session) => session.id !== editSession?.id && (session.protocol ?? 'ssh') === 'ssh'
|
(session) => session.id !== editSession?.id && (session.protocol ?? 'ssh') === 'ssh'
|
||||||
)
|
)
|
||||||
@@ -122,9 +120,7 @@ export default function SessionDialog({
|
|||||||
name: editSession.name || '',
|
name: editSession.name || '',
|
||||||
host: editSession.host || '',
|
host: editSession.host || '',
|
||||||
port: editSession.port || defaultPortForProtocol(protocol),
|
port: editSession.port || defaultPortForProtocol(protocol),
|
||||||
username:
|
username: editSession.username || (protocol === 'rdp' ? 'Administrator' : protocol === 'vnc' ? 'vnc' : 'root'),
|
||||||
editSession.username ||
|
|
||||||
(protocol === 'rdp' ? 'Administrator' : protocol === 'vnc' ? 'vnc' : 'root'),
|
|
||||||
authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password',
|
authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password',
|
||||||
protocol,
|
protocol,
|
||||||
password: editSession.password || '',
|
password: editSession.password || '',
|
||||||
@@ -187,9 +183,7 @@ export default function SessionDialog({
|
|||||||
? 'root'
|
? 'root'
|
||||||
: prev.username
|
: prev.username
|
||||||
const nextPort =
|
const nextPort =
|
||||||
prev.port === defaultPortForProtocol(prev.protocol)
|
prev.port === defaultPortForProtocol(prev.protocol) ? defaultPortForProtocol(protocol) : prev.port
|
||||||
? defaultPortForProtocol(protocol)
|
|
||||||
: prev.port
|
|
||||||
const display = getDisplayResolution()
|
const display = getDisplayResolution()
|
||||||
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
|
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
|
||||||
return {
|
return {
|
||||||
@@ -264,9 +258,7 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
const proxyReady =
|
const proxyReady =
|
||||||
formData.proxy.type === 'none' ||
|
formData.proxy.type === 'none' ||
|
||||||
(formData.proxy.host.trim().length > 0 &&
|
(formData.proxy.host.trim().length > 0 && formData.proxy.port > 0 && formData.proxy.port <= 65535)
|
||||||
formData.proxy.port > 0 &&
|
|
||||||
formData.proxy.port <= 65535)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -328,9 +320,7 @@ export default function SessionDialog({
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const sessionData = {
|
const sessionData = {
|
||||||
name:
|
name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
|
||||||
formData.name.trim() ||
|
|
||||||
(formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
|
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
@@ -343,8 +333,7 @@ export default function SessionDialog({
|
|||||||
? formData.privateKeyText.trim() || undefined
|
? formData.privateKeyText.trim() || undefined
|
||||||
: formData.privateKeyPath.trim() || DEFAULT_KEY_PATH
|
: formData.privateKeyPath.trim() || DEFAULT_KEY_PATH
|
||||||
: undefined,
|
: undefined,
|
||||||
passphrase:
|
passphrase: isSsh && formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
||||||
isSsh && formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
|
||||||
proxy: proxyConfig,
|
proxy: proxyConfig,
|
||||||
startupCommand: isSsh ? formData.startupCommand.trim() || undefined : undefined,
|
startupCommand: isSsh ? formData.startupCommand.trim() || undefined : undefined,
|
||||||
jumpSessionIds:
|
jumpSessionIds:
|
||||||
@@ -388,6 +377,7 @@ export default function SessionDialog({
|
|||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
||||||
|
toast.error(translateErrorMessage(error instanceof Error ? error.message : t('errors:session.saveFailed')))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -405,7 +395,7 @@ export default function SessionDialog({
|
|||||||
const groupField = (
|
const groupField = (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-group" className="ui-label">
|
<label htmlFor="session-group" className="ui-label">
|
||||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
<Folder className="mr-0.5 inline h-3 w-3" />
|
||||||
{t('dialog.group')}
|
{t('dialog.group')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -436,9 +426,7 @@ export default function SessionDialog({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||||
<option value={normalizeGroupId(formData.group)}>
|
<option value={normalizeGroupId(formData.group)}>{groupDisplayName(formData.group)}</option>
|
||||||
{groupDisplayName(formData.group)}
|
|
||||||
</option>
|
|
||||||
)}
|
)}
|
||||||
<option value="__new__">{t('groups.newOption')}</option>
|
<option value="__new__">{t('groups.newOption')}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -450,43 +438,40 @@ export default function SessionDialog({
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
|
||||||
className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drawer */}
|
{/* Drawer */}
|
||||||
<aside
|
<aside
|
||||||
className="relative w-full max-w-[400px] h-full bg-dark-800 border-l border-dark-700/70 shadow-2xl flex flex-col animate-slide-in-right"
|
className="border-dark-700/70 animate-slide-in-right relative flex h-full w-full max-w-[400px] flex-col border-l bg-dark-800 shadow-2xl"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="session-drawer-title"
|
aria-labelledby="session-drawer-title"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-dark-700/50 flex-shrink-0">
|
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center text-accent-blue">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
|
||||||
<Server className="w-4.5 h-4.5 w-4 h-4" />
|
<Server className="w-4.5 h-4.5 h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
||||||
{isEditing ? t('dialog.edit') : t('dialog.create')}
|
{isEditing ? t('dialog.edit') : t('dialog.create')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[11px] text-txt-faint mt-0.5">{t('dialog.subtitle')}</p>
|
<p className="mt-0.5 text-[11px] text-txt-faint">{t('dialog.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700"
|
className="rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-content-primary"
|
||||||
aria-label={t('common:action.close')}
|
aria-label={t('common:action.close')}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||||
{/* 基本:名称 + 目标 */}
|
{/* 基本:名称 + 目标 */}
|
||||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||||
{t('dialog.basic')}
|
{t('dialog.basic')}
|
||||||
</div>
|
</div>
|
||||||
@@ -529,7 +514,9 @@ export default function SessionDialog({
|
|||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-target" className="ui-label">{t('dialog.host')}</label>
|
<label htmlFor="session-target" className="ui-label">
|
||||||
|
{t('dialog.host')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-target"
|
id="session-target"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -543,7 +530,9 @@ export default function SessionDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-port" className="ui-label">{t('dialog.port')}</label>
|
<label htmlFor="session-port" className="ui-label">
|
||||||
|
{t('dialog.port')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-port"
|
id="session-port"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -570,13 +559,13 @@ export default function SessionDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-txt-faint">
|
<p className="mt-1 text-[11px] text-txt-faint">{t('dialog.hostHint')}</p>
|
||||||
{t('dialog.hostHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{formData.protocol !== 'vnc' && (
|
{formData.protocol !== 'vnc' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-username" className="ui-label">{t('dialog.username')}</label>
|
<label htmlFor="session-username" className="ui-label">
|
||||||
|
{t('dialog.username')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-username"
|
id="session-username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -595,7 +584,11 @@ export default function SessionDialog({
|
|||||||
: prev.name,
|
: prev.name,
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
placeholder={formData.protocol === 'rdp' ? t('dialog.usernamePlaceholderRdp') : t('dialog.usernamePlaceholderSsh')}
|
placeholder={
|
||||||
|
formData.protocol === 'rdp'
|
||||||
|
? t('dialog.usernamePlaceholderRdp')
|
||||||
|
: t('dialog.usernamePlaceholderSsh')
|
||||||
|
}
|
||||||
className="ui-input font-mono"
|
className="ui-input font-mono"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
@@ -603,7 +596,9 @@ export default function SessionDialog({
|
|||||||
)}
|
)}
|
||||||
{formData.protocol === 'rdp' && (
|
{formData.protocol === 'rdp' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
<label htmlFor="session-password" className="ui-label">
|
||||||
|
{t('dialog.password')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-password"
|
id="session-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -628,12 +623,12 @@ export default function SessionDialog({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 凭据 */}
|
{/* 凭据 */}
|
||||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||||
{t('dialog.login')}
|
{t('dialog.login')}
|
||||||
</div>
|
</div>
|
||||||
{isSsh && (
|
{isSsh && (
|
||||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||||
{authTypes.map((type) => {
|
{authTypes.map((type) => {
|
||||||
const Icon = type.icon
|
const Icon = type.icon
|
||||||
const active = formData.authType === type.value
|
const active = formData.authType === type.value
|
||||||
@@ -642,13 +637,13 @@ export default function SessionDialog({
|
|||||||
key={type.value}
|
key={type.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, authType: type.value })}
|
onClick={() => setFormData({ ...formData, authType: type.value })}
|
||||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg text-xs font-medium transition-colors ${
|
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||||
: 'text-txt-muted hover:text-content-secondary'
|
: 'text-txt-muted hover:text-content-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
{type.label}
|
{type.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -658,7 +653,9 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
|
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
<label htmlFor="session-password" className="ui-label">
|
||||||
|
{t('dialog.password')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-password"
|
id="session-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -673,7 +670,9 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
{formData.protocol === 'rdp' && (
|
{formData.protocol === 'rdp' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label" htmlFor="session-domain">{t('dialog.domain')}</label>
|
<label className="ui-label" htmlFor="session-domain">
|
||||||
|
{t('dialog.domain')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-domain"
|
id="session-domain"
|
||||||
value={formData.domain}
|
value={formData.domain}
|
||||||
@@ -688,7 +687,7 @@ export default function SessionDialog({
|
|||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div>
|
<div>
|
||||||
<div className="ui-label">{t('dialog.keySource')}</div>
|
<div className="ui-label">{t('dialog.keySource')}</div>
|
||||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['file', t('dialog.keyFile')],
|
['file', t('dialog.keyFile')],
|
||||||
@@ -701,7 +700,7 @@ export default function SessionDialog({
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, keyInputMode: value })}
|
onClick={() => setFormData({ ...formData, keyInputMode: value })}
|
||||||
className={`flex items-center justify-center py-2 rounded-lg text-xs font-medium transition-colors ${
|
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||||
: 'text-txt-muted hover:text-content-secondary'
|
: 'text-txt-muted hover:text-content-secondary'
|
||||||
@@ -716,21 +715,21 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
{formData.keyInputMode === 'file' ? (
|
{formData.keyInputMode === 'file' ? (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-private-key" className="ui-label">{t('dialog.privateKeyPath')}</label>
|
<label htmlFor="session-private-key" className="ui-label">
|
||||||
|
{t('dialog.privateKeyPath')}
|
||||||
|
</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
id="session-private-key"
|
id="session-private-key"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.privateKeyPath}
|
value={formData.privateKeyPath}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
|
||||||
setFormData({ ...formData, privateKeyPath: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={DEFAULT_KEY_PATH}
|
placeholder={DEFAULT_KEY_PATH}
|
||||||
className="ui-input flex-1"
|
className="ui-input flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-2.5 text-xs rounded-lg bg-dark-700 border border-dark-600 text-content-secondary hover:bg-dark-600"
|
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const result = await window.opsxApi?.showOpenDialog({
|
const result = await window.opsxApi?.showOpenDialog({
|
||||||
title: t('dialog.pickKeyDialogTitle'),
|
title: t('dialog.pickKeyDialogTitle'),
|
||||||
@@ -751,26 +750,26 @@ export default function SessionDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-private-key-text" className="ui-label">{t('dialog.privateKeyText')}</label>
|
<label htmlFor="session-private-key-text" className="ui-label">
|
||||||
|
{t('dialog.privateKeyText')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="session-private-key-text"
|
id="session-private-key-text"
|
||||||
value={formData.privateKeyText}
|
value={formData.privateKeyText}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
|
||||||
setFormData({ ...formData, privateKeyText: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
|
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
|
||||||
rows={6}
|
rows={6}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="ui-input font-mono text-[11px] leading-relaxed resize-y min-h-[7.5rem]"
|
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-txt-faint">
|
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
|
||||||
{t('dialog.privateKeyTextHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-passphrase" className="ui-label">{t('dialog.passphrase')}</label>
|
<label htmlFor="session-passphrase" className="ui-label">
|
||||||
|
{t('dialog.passphrase')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="session-passphrase"
|
id="session-passphrase"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -787,27 +786,25 @@ export default function SessionDialog({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 高级 */}
|
{/* 高级 */}
|
||||||
<div className="rounded-xl border border-dark-700/50 overflow-hidden">
|
<div className="border-dark-700/50 overflow-hidden rounded-xl border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAdvanced((v) => !v)}
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
className="w-full flex items-center justify-between px-3.5 py-3 text-left bg-dark-900/20 hover:bg-dark-900/40 transition-colors"
|
className="bg-dark-900/20 hover:bg-dark-900/40 flex w-full items-center justify-between px-3.5 py-3 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
|
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
|
||||||
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
||||||
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
|
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
|
||||||
<ChevronDown
|
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
||||||
className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="px-3.5 pb-3.5 space-y-3 border-t border-dark-700/40 bg-dark-900/20">
|
<div className="border-dark-700/40 bg-dark-900/20 space-y-3 border-t px-3.5 pb-3.5">
|
||||||
<div className="grid grid-cols-1 gap-2 pt-3">
|
<div className="grid grid-cols-1 gap-2 pt-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label">
|
<label className="ui-label">
|
||||||
<Tag className="inline w-3 h-3 mr-0.5" />
|
<Tag className="mr-0.5 inline h-3 w-3" />
|
||||||
{t('dialog.tags')}
|
{t('dialog.tags')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -833,7 +830,7 @@ export default function SessionDialog({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label">{t('dialog.proxy')}</label>
|
<label className="ui-label">{t('dialog.proxy')}</label>
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="mb-2 flex gap-1">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ value: 'none', label: t('dialog.proxyNone') },
|
{ value: 'none', label: t('dialog.proxyNone') },
|
||||||
@@ -860,7 +857,7 @@ export default function SessionDialog({
|
|||||||
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className={`flex-1 py-1.5 rounded-lg text-[11px] border transition-colors ${
|
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
|
||||||
formData.proxy.type === t.value
|
formData.proxy.type === t.value
|
||||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||||
@@ -934,7 +931,9 @@ export default function SessionDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="session-jump-host" className="ui-label">{t('dialog.jumpHost')}</label>
|
<label htmlFor="session-jump-host" className="ui-label">
|
||||||
|
{t('dialog.jumpHost')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
id="session-jump-host"
|
id="session-jump-host"
|
||||||
value={formData.jumpSessionIds[0] || ''}
|
value={formData.jumpSessionIds[0] || ''}
|
||||||
@@ -964,7 +963,7 @@ export default function SessionDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-dark-700/50 flex-shrink-0 bg-dark-800">
|
<div className="border-dark-700/50 flex-shrink-0 border-t bg-dark-800 p-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
@@ -972,13 +971,13 @@ export default function SessionDialog({
|
|||||||
!(formData.host.trim() || target.trim().replace(/^.*@/, '').replace(/:\d+$/, '')) ||
|
!(formData.host.trim() || target.trim().replace(/^.*@/, '').replace(/:\d+$/, '')) ||
|
||||||
!proxyReady
|
!proxyReady
|
||||||
}
|
}
|
||||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-45 text-white text-sm font-medium transition-colors shadow-sm shadow-accent-blue/25"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue py-2.5 text-sm font-medium text-white shadow-sm shadow-accent-blue/25 transition-colors hover:bg-accent-blue-hover disabled:opacity-45"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
t('dialog.saving')
|
t('dialog.saving')
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4" />
|
<Check className="h-4 w-4" />
|
||||||
{isEditing ? t('dialog.save') : t('dialog.createAndSave')}
|
{isEditing ? t('dialog.save') : t('dialog.createAndSave')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,22 +10,19 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
|
|||||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||||
import {
|
import { buildOpenTabPayload, defaultPortForProtocol, sessionProtocol } from '../../utils/sessionProtocol'
|
||||||
buildOpenTabPayload,
|
|
||||||
defaultPortForProtocol,
|
|
||||||
sessionProtocol,
|
|
||||||
} from '../../utils/sessionProtocol'
|
|
||||||
|
|
||||||
interface WelcomeViewProps {
|
interface WelcomeViewProps {
|
||||||
onNewSession?: () => void
|
onNewSession?: () => void
|
||||||
onQuickConnect?: () => void
|
onQuickConnect?: () => void
|
||||||
onRdpConnect?: () => void
|
onRdpConnect?: () => void
|
||||||
onVncConnect?: () => void
|
onVncConnect?: () => void
|
||||||
|
onImportSessions?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Kbd({ children }: { children: string }) {
|
function Kbd({ children }: { children: string }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</kbd>
|
</kbd>
|
||||||
)
|
)
|
||||||
@@ -36,6 +33,7 @@ export default function WelcomeView({
|
|||||||
onQuickConnect,
|
onQuickConnect,
|
||||||
onRdpConnect,
|
onRdpConnect,
|
||||||
onVncConnect,
|
onVncConnect,
|
||||||
|
onImportSessions,
|
||||||
}: WelcomeViewProps) {
|
}: WelcomeViewProps) {
|
||||||
const { t } = useTranslation('sessions')
|
const { t } = useTranslation('sessions')
|
||||||
const { addTab } = useTabStore()
|
const { addTab } = useTabStore()
|
||||||
@@ -71,15 +69,11 @@ export default function WelcomeView({
|
|||||||
}, [shellMenuOpen])
|
}, [shellMenuOpen])
|
||||||
|
|
||||||
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
|
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
|
||||||
const defaultShellLabel = defaultShell
|
const defaultShellLabel = defaultShell ? shellDisplayFromPath(defaultShell.path || defaultShell.name).title : 'Shell'
|
||||||
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
|
|
||||||
: 'Shell'
|
|
||||||
|
|
||||||
const openDefaultLocal = useCallback(() => {
|
const openDefaultLocal = useCallback(() => {
|
||||||
openLocalShellTab(
|
openLocalShellTab(
|
||||||
defaultShell
|
defaultShell ? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name } : undefined
|
||||||
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
|
|
||||||
: undefined
|
|
||||||
)
|
)
|
||||||
}, [defaultShell])
|
}, [defaultShell])
|
||||||
|
|
||||||
@@ -112,7 +106,7 @@ export default function WelcomeView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onQuickConnect?.()}
|
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">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNewSession?.()}
|
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">
|
<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">
|
<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>
|
</button>
|
||||||
|
|
||||||
<div ref={shellMenuRef} className="relative">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openDefaultLocal}
|
onClick={openDefaultLocal}
|
||||||
@@ -158,9 +152,7 @@ export default function WelcomeView({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</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">
|
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">{defaultShellLabel}</div>
|
||||||
{defaultShellLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -168,14 +160,14 @@ export default function WelcomeView({
|
|||||||
aria-label={t('welcome.localShell.selectShell')}
|
aria-label={t('welcome.localShell.selectShell')}
|
||||||
aria-expanded={shellMenuOpen}
|
aria-expanded={shellMenuOpen}
|
||||||
onClick={() => setShellMenuOpen((v) => !v)}
|
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' : ''}`} />
|
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shellMenuOpen && shells.length > 0 && (
|
{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) => (
|
{shells.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.path}
|
key={s.path}
|
||||||
@@ -184,7 +176,7 @@ export default function WelcomeView({
|
|||||||
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
|
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
|
||||||
setShellMenuOpen(false)
|
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="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>
|
<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">
|
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
@@ -210,7 +204,7 @@ export default function WelcomeView({
|
|||||||
if (!requirePro('rdp')) return
|
if (!requirePro('rdp')) return
|
||||||
onRdpConnect?.()
|
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">
|
<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" />
|
<Monitor className="h-4 w-4" />
|
||||||
@@ -226,7 +220,7 @@ export default function WelcomeView({
|
|||||||
if (!requirePro('vnc')) return
|
if (!requirePro('vnc')) return
|
||||||
onVncConnect?.()
|
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">
|
<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" />
|
<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">
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||||
{t('welcome.sectionRecent')}
|
{t('welcome.sectionRecent')}
|
||||||
</h2>
|
</h2>
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && <span className="text-[11px] text-txt-dim">{recentSessions.length}</span>}
|
||||||
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentSessions.length === 0 ? (
|
{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
|
<EmptyState
|
||||||
icon={Server}
|
icon={Server}
|
||||||
title={t('welcome.recentEmpty.title')}
|
title={t('welcome.recentEmpty.title')}
|
||||||
description={t('welcome.recentEmpty.desc')}
|
description={t('welcome.recentEmpty.desc')}
|
||||||
|
action={onImportSessions ? { label: t('welcome.import.title'), onClick: onImportSessions } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) => (
|
{recentSessions.map((session) => (
|
||||||
<li key={session.id}>
|
<li key={session.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSessionClick(session)}
|
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">
|
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
|
||||||
<HostDistroIcon
|
<HostDistroIcon distro={session.distro} protocol={session.protocol} size="md" />
|
||||||
distro={session.distro}
|
|
||||||
protocol={session.protocol}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||||
@@ -282,9 +271,7 @@ export default function WelcomeView({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{session.group ? (
|
{session.group ? (
|
||||||
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
|
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">{session.group}</span>
|
||||||
{session.group}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||||
import {
|
import {
|
||||||
AI_PROVIDER_FAMILY_ORDER,
|
AI_PROVIDER_FAMILY_ORDER,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveProviderFamily,
|
resolveProviderFamily,
|
||||||
} from '../../ai/providerPresets'
|
} from '../../ai/providerPresets'
|
||||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||||
|
import Button from '../common/Button'
|
||||||
|
|
||||||
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
||||||
|
|
||||||
@@ -133,7 +134,15 @@ export default function AiProviderSettings() {
|
|||||||
}
|
}
|
||||||
const id = `manual-${Date.now().toString(36)}`
|
const id = `manual-${Date.now().toString(36)}`
|
||||||
updateDraft({
|
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,
|
defaultModelId: id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -208,13 +217,15 @@ export default function AiProviderSettings() {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Plus className="h-3.5 w-3.5" />}
|
||||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
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')}
|
{t('ai.add')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
|
<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 ${
|
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||||
selectedId === provider.id
|
selectedId === provider.id
|
||||||
? 'bg-accent-blue/12 text-accent-blue'
|
? 'bg-accent-blue/12 text-accent-blue'
|
||||||
: 'text-content-secondary hover:bg-dark-700/50'
|
: 'hover:bg-dark-700/50 text-content-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<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="max-w-xl space-y-3.5">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label={t('ai.name')}>
|
<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>
|
||||||
<Field label={t('ai.model')}>
|
<Field label={t('ai.model')}>
|
||||||
{manualModel || draft.models.length === 0 ? (
|
{manualModel || draft.models.length === 0 ? (
|
||||||
@@ -286,7 +301,11 @@ export default function AiProviderSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manualModel && draft.models.length > 0 && (
|
{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')}
|
{t('ai.backToList')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -298,7 +317,13 @@ export default function AiProviderSettings() {
|
|||||||
value={apiKey}
|
value={apiKey}
|
||||||
disabled={draft.authStyle === 'none'}
|
disabled={draft.authStyle === 'none'}
|
||||||
onChange={(event) => setApiKey(event.target.value)}
|
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"
|
className="ui-input-sm"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -330,7 +355,9 @@ export default function AiProviderSettings() {
|
|||||||
<Field label={t('ai.protocol')}>
|
<Field label={t('ai.protocol')}>
|
||||||
<select
|
<select
|
||||||
value={draft.protocol}
|
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"
|
className="ui-input-sm"
|
||||||
>
|
>
|
||||||
<option value="openai-chat">OpenAI Chat</option>
|
<option value="openai-chat">OpenAI Chat</option>
|
||||||
@@ -341,7 +368,9 @@ export default function AiProviderSettings() {
|
|||||||
<Field label={t('ai.auth')}>
|
<Field label={t('ai.auth')}>
|
||||||
<select
|
<select
|
||||||
value={draft.authStyle}
|
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"
|
className="ui-input-sm"
|
||||||
>
|
>
|
||||||
<option value="bearer">Bearer</option>
|
<option value="bearer">Bearer</option>
|
||||||
@@ -362,31 +391,45 @@ export default function AiProviderSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void testConnection()}
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={!canTest}
|
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')}
|
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
disabled={!verified || saving}
|
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')}
|
{t('common:action.save')}
|
||||||
</button>
|
</Button>
|
||||||
{isStored && store.activeProviderId !== draft.id && (
|
{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')}
|
{t('ai.setActive')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export default function RemoteTextEditor({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('editor.title', { name: fileName })}
|
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 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">
|
<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" />
|
<FileCode className="h-4 w-4" />
|
||||||
@@ -216,9 +216,7 @@ export default function RemoteTextEditor({
|
|||||||
disabled={!dirty || saving || loading}
|
disabled={!dirty || saving || loading}
|
||||||
onClick={() => void handleSave()}
|
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 ${
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||||
dirty
|
dirty ? 'bg-accent-blue text-white hover:bg-blue-500' : 'bg-dark-700 text-txt-muted'
|
||||||
? 'bg-accent-blue text-white hover:bg-blue-500'
|
|
||||||
: 'bg-dark-700 text-txt-muted'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
@@ -242,7 +240,7 @@ export default function RemoteTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
|
<div className={`min-h-0 flex-1 ${effective === 'light' ? 'bg-white' : 'bg-[#1e1e1e]'}`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||||
{t('common:status.loading')}
|
{t('common:status.loading')}
|
||||||
@@ -271,7 +269,7 @@ export default function RemoteTextEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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.footer')}</span>
|
||||||
<span>
|
<span>
|
||||||
{t('editor.charCount', { count: content.length.toLocaleString() })}
|
{t('editor.charCount', { count: content.length.toLocaleString() })}
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ export interface ShellClosedOverlayProps {
|
|||||||
reason: string
|
reason: string
|
||||||
idleTimeout: boolean
|
idleTimeout: boolean
|
||||||
restarting?: boolean
|
restarting?: boolean
|
||||||
|
isLocal?: boolean
|
||||||
onReopen: () => void
|
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')
|
const { t } = useTranslation('terminal')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
|
<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="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 ? (
|
{idleTimeout ? (
|
||||||
<Clock3 className="h-6 w-6 text-accent-blue" />
|
<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')}
|
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
|
<div className="mb-2 text-xs leading-relaxed text-slate-400">{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={restarting}
|
disabled={restarting}
|
||||||
@@ -48,6 +57,7 @@ export interface TerminalOverlayProps {
|
|||||||
showSuspendedOverlay: boolean
|
showSuspendedOverlay: boolean
|
||||||
needsReconnect: boolean
|
needsReconnect: boolean
|
||||||
reconnectAttempt?: number
|
reconnectAttempt?: number
|
||||||
|
isLocal?: boolean
|
||||||
onReconnect: () => void
|
onReconnect: () => void
|
||||||
onResume: () => void
|
onResume: () => void
|
||||||
}
|
}
|
||||||
@@ -58,6 +68,7 @@ function TerminalOverlay({
|
|||||||
showSuspendedOverlay,
|
showSuspendedOverlay,
|
||||||
needsReconnect,
|
needsReconnect,
|
||||||
reconnectAttempt = 0,
|
reconnectAttempt = 0,
|
||||||
|
isLocal = false,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onResume,
|
onResume,
|
||||||
}: TerminalOverlayProps) {
|
}: TerminalOverlayProps) {
|
||||||
@@ -69,17 +80,21 @@ function TerminalOverlay({
|
|||||||
{showDisconnectBanner && (
|
{showDisconnectBanner && (
|
||||||
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
|
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
|
||||||
<div className="max-w-sm px-6 text-center">
|
<div className="max-w-sm px-6 text-center">
|
||||||
<div className="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" />
|
<WifiOff className="h-7 w-7 text-accent-blue" />
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onReconnect}
|
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" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
{t('overlay.reconnect')}
|
{t('overlay.reconnect')}
|
||||||
@@ -121,7 +136,7 @@ function TerminalOverlay({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</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">
|
<div className="mb-5 text-sm leading-relaxed text-slate-400">
|
||||||
{statusMessage || t('overlay.connectFailedHint')}
|
{statusMessage || t(isLocal ? 'overlay.localConnectFailedHint' : 'overlay.connectFailedHint')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,9 +157,7 @@ function TerminalOverlay({
|
|||||||
<TerminalIcon className="h-4 w-4" />
|
<TerminalIcon className="h-4 w-4" />
|
||||||
{connectionState === 'reconnecting'
|
{connectionState === 'reconnecting'
|
||||||
? t('overlay.reconnecting', {
|
? t('overlay.reconnecting', {
|
||||||
attempt: reconnectAttempt
|
attempt: reconnectAttempt ? t('overlay.reconnectingAttempt', { count: reconnectAttempt }) : '',
|
||||||
? t('overlay.reconnectingAttempt', { count: reconnectAttempt })
|
|
||||||
: '',
|
|
||||||
})
|
})
|
||||||
: t('overlay.connecting')}
|
: t('overlay.connecting')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import { createLogger } from '../../utils/logger'
|
|||||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||||
import {
|
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
|
||||||
ZmodemSftpBridge,
|
|
||||||
type ZmodemBridgeStatus,
|
|
||||||
} from '../../utils/zmodemSftpBridge'
|
|
||||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||||
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
||||||
import TerminalOverlay from './TerminalOverlay'
|
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" />
|
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
|
||||||
|
|
||||||
{zmodemBanner && (
|
{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}
|
{zmodemBanner}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -337,6 +334,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||||
}
|
}
|
||||||
reconnectAttempt={reconnectAttempt}
|
reconnectAttempt={reconnectAttempt}
|
||||||
|
isLocal={!!currentTab?.isLocal}
|
||||||
onReconnect={handleReconnect}
|
onReconnect={handleReconnect}
|
||||||
onResume={resumeConnection}
|
onResume={resumeConnection}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useEntitlementStore } from '../../stores/entitlementStore'
|
|||||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||||
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
|
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
|
||||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
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 { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||||
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
||||||
@@ -48,10 +48,7 @@ import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
|||||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||||
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
||||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||||
import {
|
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
|
||||||
ZmodemSftpBridge,
|
|
||||||
type ZmodemBridgeStatus,
|
|
||||||
} from '../../utils/zmodemSftpBridge'
|
|
||||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||||
import TerminalContextMenu from './TerminalContextMenu'
|
import TerminalContextMenu from './TerminalContextMenu'
|
||||||
import i18n from '../../i18n'
|
import i18n from '../../i18n'
|
||||||
@@ -78,6 +75,8 @@ interface PaneRuntime {
|
|||||||
pasteClipboard: () => void
|
pasteClipboard: () => void
|
||||||
selectAll: () => void
|
selectAll: () => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
|
/** 写入命令文本但不带回车:插入到当前输入行,由用户自行检查后执行。 */
|
||||||
|
insertText: (text: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureTerminal(term: Terminal): TerminalAiContext {
|
function captureTerminal(term: Terminal): TerminalAiContext {
|
||||||
@@ -220,11 +219,7 @@ function PaneTerminal({
|
|||||||
const handleContextMenu = (event: MouseEvent) => {
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onContextMenuRef.current(
|
onContextMenuRef.current(paneId, { x: event.clientX, y: event.clientY }, term.hasSelection())
|
||||||
paneId,
|
|
||||||
{ x: event.clientX, y: event.clientY },
|
|
||||||
term.hasSelection()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
container.addEventListener('contextmenu', handleContextMenu)
|
container.addEventListener('contextmenu', handleContextMenu)
|
||||||
|
|
||||||
@@ -250,8 +245,7 @@ function PaneTerminal({
|
|||||||
},
|
},
|
||||||
onError: (error) => logger.warn('终端输入失败', error),
|
onError: (error) => logger.warn('终端输入失败', error),
|
||||||
})
|
})
|
||||||
const zmodem =
|
const zmodem = isLocal
|
||||||
isLocal
|
|
||||||
? null
|
? null
|
||||||
: new ZmodemSftpBridge({
|
: new ZmodemSftpBridge({
|
||||||
writeToTerminal: (data) => {
|
writeToTerminal: (data) => {
|
||||||
@@ -305,9 +299,17 @@ function PaneTerminal({
|
|||||||
getTerminal: () => xtermRef.current,
|
getTerminal: () => xtermRef.current,
|
||||||
getSearchAddon: () => searchAddonRef.current,
|
getSearchAddon: () => searchAddonRef.current,
|
||||||
copySelection,
|
copySelection,
|
||||||
pasteClipboard: () => { void pasteClipboard() },
|
pasteClipboard: () => {
|
||||||
|
void pasteClipboard()
|
||||||
|
},
|
||||||
selectAll: () => term.selectAll(),
|
selectAll: () => term.selectAll(),
|
||||||
clear: () => safeTerminalClear(term),
|
clear: () => safeTerminalClear(term),
|
||||||
|
insertText: (text) => {
|
||||||
|
if (!shellIdRef.current || !text) return false
|
||||||
|
// xterm input() 走 onData → pipeline,与用户键入同一条路径且不带回车。
|
||||||
|
term.input(text, true)
|
||||||
|
return true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -356,6 +358,7 @@ function PaneTerminal({
|
|||||||
const pendingId = isLocal
|
const pendingId = isLocal
|
||||||
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
shellIdRef.current = pendingId
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const cols = xtermRef.current?.cols || 80
|
const cols = xtermRef.current?.cols || 80
|
||||||
@@ -377,18 +380,17 @@ function PaneTerminal({
|
|||||||
}
|
}
|
||||||
shellIdRef.current = result.shellId
|
shellIdRef.current = result.shellId
|
||||||
onShellReady(paneId, result.shellId)
|
onShellReady(paneId, result.shellId)
|
||||||
// Secondary panes must open output or MOTD stays buffered and the
|
// Open buffered PTY output or the first PS1 / MOTD is dropped.
|
||||||
// reader never emits ssh:shell-data (blank cursor forever).
|
|
||||||
if (!isLocal) {
|
|
||||||
try {
|
try {
|
||||||
const boot = await flushSshShellBootstrap(result.shellId)
|
const boot = await flushShellBootstrap(result.shellId)
|
||||||
if (boot && xtermRef.current) xtermRef.current.write(boot)
|
if (boot && xtermRef.current) xtermRef.current.write(boot)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to flush split-pane shell bootstrap', err)
|
logger.warn('Failed to flush split-pane shell bootstrap', err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
logger.error('create shell failed', e)
|
logger.error('create shell failed', e)
|
||||||
@@ -419,10 +421,10 @@ function PaneTerminal({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
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" />
|
<div ref={containerRef} className="terminal-surface h-full w-full" />
|
||||||
{zmodemBanner && (
|
{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}
|
{zmodemBanner}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,15 +463,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
const broadcastTargetCount = useTerminalBroadcastStore(
|
const broadcastTargetCount = useTerminalBroadcastStore(
|
||||||
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
|
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
|
||||||
)
|
)
|
||||||
const readyPaneCount = useMemo(
|
const readyPaneCount = useMemo(() => panes.filter((pane) => !!pane.shellId).length, [panes])
|
||||||
() => panes.filter((pane) => !!pane.shellId).length,
|
|
||||||
[panes]
|
|
||||||
)
|
|
||||||
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
|
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
|
||||||
const effectiveBroadcastCount =
|
const effectiveBroadcastCount =
|
||||||
broadcastScope === 'current-tab'
|
broadcastScope === 'current-tab' ? Math.max(broadcastTargetCount, readyPaneCount) : broadcastTargetCount
|
||||||
? Math.max(broadcastTargetCount, readyPaneCount)
|
|
||||||
: broadcastTargetCount
|
|
||||||
const canEnableBroadcast = effectiveBroadcastCount >= 2
|
const canEnableBroadcast = effectiveBroadcastCount >= 2
|
||||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||||
@@ -546,34 +543,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
})
|
})
|
||||||
}, [tabId, direction, panes.length, ratio, updateTab])
|
}, [tabId, direction, panes.length, ratio, updateTab])
|
||||||
|
|
||||||
// Sync primary shell from connection hook → first pane
|
// Sync primary shell from connection hook → first pane.
|
||||||
|
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionState === 'connected' && shellIdRef.current) {
|
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
|
||||||
|
if (!sid) return
|
||||||
|
if (connectionState !== 'connected' && connectionState !== 'connecting') return
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev
|
||||||
if (prev[0].shellId === shellIdRef.current) return prev
|
if (prev[0].shellId === sid) return prev
|
||||||
return prev.map((p, i) => (i === 0 ? { ...p, shellId: shellIdRef.current } : p))
|
if (prev[0].shellId) return prev
|
||||||
|
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
|
||||||
})
|
})
|
||||||
}
|
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
|
||||||
}, [connectionState, shellIdRef])
|
|
||||||
|
|
||||||
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
|
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const connectionId = connectionIdRef.current
|
const connectionId = connectionIdRef.current
|
||||||
const targets = connectionState === 'connected' && connectionId
|
const targets =
|
||||||
? panes.flatMap((pane) => pane.shellId
|
connectionState === 'connected' && connectionId
|
||||||
? [{
|
? panes.flatMap((pane) =>
|
||||||
|
pane.shellId
|
||||||
|
? [
|
||||||
|
{
|
||||||
tabId,
|
tabId,
|
||||||
paneId: pane.id,
|
paneId: pane.id,
|
||||||
shellId: pane.shellId,
|
shellId: pane.shellId,
|
||||||
connectionId,
|
connectionId,
|
||||||
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
|
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
|
||||||
sessionId: currentTab?.sessionId,
|
sessionId: currentTab?.sessionId,
|
||||||
}]
|
},
|
||||||
: [])
|
]
|
||||||
|
: []
|
||||||
|
)
|
||||||
: []
|
: []
|
||||||
registerBroadcastWorkspace({ tabId, targets })
|
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])
|
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
|
||||||
|
|
||||||
@@ -721,7 +736,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
const unregisterShellHandler = useCallback((paneId: string) => {
|
const unregisterShellHandler = useCallback((paneId: string) => {
|
||||||
shellHandlersRef.current.delete(paneId)
|
shellHandlersRef.current.delete(paneId)
|
||||||
}, [])
|
}, [])
|
||||||
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
|
const registerRuntime = useCallback(
|
||||||
|
(paneId: string, runtime: PaneRuntime) => {
|
||||||
paneRuntimesRef.current.set(paneId, runtime)
|
paneRuntimesRef.current.set(paneId, runtime)
|
||||||
if (panesRef.current[0]?.id === paneId) {
|
if (panesRef.current[0]?.id === paneId) {
|
||||||
primaryXtermRef.current = runtime.getTerminal()
|
primaryXtermRef.current = runtime.getTerminal()
|
||||||
@@ -731,7 +747,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
focusedXtermRef.current = runtime.getTerminal()
|
focusedXtermRef.current = runtime.getTerminal()
|
||||||
focusedSearchAddonRef.current = runtime.getSearchAddon()
|
focusedSearchAddonRef.current = runtime.getSearchAddon()
|
||||||
}
|
}
|
||||||
}, [armInitialConnect, focusedPaneId])
|
},
|
||||||
|
[armInitialConnect, focusedPaneId]
|
||||||
|
)
|
||||||
const unregisterRuntime = useCallback((paneId: string) => {
|
const unregisterRuntime = useCallback((paneId: string) => {
|
||||||
paneRuntimesRef.current.delete(paneId)
|
paneRuntimesRef.current.delete(paneId)
|
||||||
if (panesRef.current[0]?.id === paneId) {
|
if (panesRef.current[0]?.id === paneId) {
|
||||||
@@ -802,7 +820,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
preferredId
|
preferredId
|
||||||
)
|
)
|
||||||
if (!result.success || !result.shellId) {
|
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) {
|
if (!isLocal) {
|
||||||
getConnectionStore().addShell(connectionId, result.shellId)
|
getConnectionStore().addShell(connectionId, result.shellId)
|
||||||
@@ -819,15 +842,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
shellId: result.shellId,
|
shellId: result.shellId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!isLocal && result.shellId) {
|
|
||||||
try {
|
try {
|
||||||
const boot = await flushSshShellBootstrap(result.shellId)
|
const boot = await flushShellBootstrap(result.shellId)
|
||||||
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
|
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
|
||||||
if (boot && term) term.write(boot)
|
if (boot && term) term.write(boot)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to flush reopened shell bootstrap', err)
|
logger.warn('Failed to flush reopened shell bootstrap', err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
setPanes((current) =>
|
setPanes((current) =>
|
||||||
@@ -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(
|
const handlePaneInput = useCallback(
|
||||||
(sourcePaneId: string, data: string) => {
|
(sourcePaneId: string, data: string) => {
|
||||||
const shouldBroadcast = shouldBroadcastTerminalInput(
|
const shouldBroadcast = shouldBroadcastTerminalInput(broadcastInput, currentTab?.isLocal, connectionIdRef.current)
|
||||||
broadcastInput,
|
|
||||||
currentTab?.isLocal,
|
|
||||||
connectionIdRef.current
|
|
||||||
)
|
|
||||||
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
|
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
|
||||||
const targets = shouldBroadcast
|
const targets = shouldBroadcast
|
||||||
? broadcastTargets.map((target) => target.shellId)
|
? broadcastTargets.map((target) => target.shellId)
|
||||||
@@ -919,7 +945,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
||||||
}, [addPane, direction, isActive, setSplitDirection])
|
}, [addPane, direction, isActive, setSplitDirection])
|
||||||
|
|
||||||
const closePane = useCallback((paneId: string) => {
|
const closePane = useCallback(
|
||||||
|
(paneId: string) => {
|
||||||
if (panesRef.current.length <= 1) return
|
if (panesRef.current.length <= 1) return
|
||||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||||
const connectionId = connectionIdRef.current
|
const connectionId = connectionIdRef.current
|
||||||
@@ -951,9 +978,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
|
},
|
||||||
|
[connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
|
||||||
|
)
|
||||||
|
|
||||||
const closePaneOrTab = useCallback((paneId: string) => {
|
const closePaneOrTab = useCallback(
|
||||||
|
(paneId: string) => {
|
||||||
if (panesRef.current.length > 1) {
|
if (panesRef.current.length > 1) {
|
||||||
closePane(paneId)
|
closePane(paneId)
|
||||||
return
|
return
|
||||||
@@ -962,7 +992,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
|
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
|
||||||
|
|
||||||
const connectionId = connectionIdRef.current
|
const connectionId = connectionIdRef.current
|
||||||
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
const paneShells = panesRef.current.flatMap((pane) => (pane.shellId ? [pane.shellId] : []))
|
||||||
void (async () => {
|
void (async () => {
|
||||||
for (const paneShellId of paneShells) {
|
for (const paneShellId of paneShells) {
|
||||||
const result = await shellKill(paneShellId).catch((error) => ({
|
const result = await shellKill(paneShellId).catch((error) => ({
|
||||||
@@ -982,13 +1012,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
}
|
}
|
||||||
closeTab(tabId)
|
closeTab(tabId)
|
||||||
})()
|
})()
|
||||||
}, [closePane, closeTab, connectionIdRef, tabId, tabs])
|
},
|
||||||
|
[closePane, closeTab, connectionIdRef, tabId, tabs]
|
||||||
|
)
|
||||||
|
|
||||||
const openTerminalContextMenu = useCallback((
|
const openTerminalContextMenu = useCallback(
|
||||||
paneId: string,
|
(paneId: string, position: { x: number; y: number }, hasSelection: boolean) => {
|
||||||
position: { x: number; y: number },
|
|
||||||
hasSelection: boolean
|
|
||||||
) => {
|
|
||||||
setFocusedPaneId(paneId)
|
setFocusedPaneId(paneId)
|
||||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||||
if (pane?.shellId && connectionIdRef.current) {
|
if (pane?.shellId && connectionIdRef.current) {
|
||||||
@@ -999,7 +1028,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
setContextMenu({ paneId, position, hasSelection })
|
setContextMenu({ paneId, position, hasSelection })
|
||||||
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
|
},
|
||||||
|
[connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
||||||
@@ -1183,7 +1214,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
if (!requirePro('ai')) return
|
if (!requirePro('ai')) return
|
||||||
setAiPanelOpen(true)
|
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')}
|
<Sparkles className="h-3.5 w-3.5" /> {t('toolbar.aiAssistant')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1256,6 +1287,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
reason={pane.closed.reason}
|
reason={pane.closed.reason}
|
||||||
idleTimeout={pane.closed.idleTimeout}
|
idleTimeout={pane.closed.idleTimeout}
|
||||||
restarting={pane.closed.restarting}
|
restarting={pane.closed.restarting}
|
||||||
|
isLocal={isLocal}
|
||||||
onReopen={() => void reopenPaneShell(pane.id)}
|
onReopen={() => void reopenPaneShell(pane.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1281,6 +1313,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||||
}
|
}
|
||||||
reconnectAttempt={reconnectAttempt}
|
reconnectAttempt={reconnectAttempt}
|
||||||
|
isLocal={isLocal}
|
||||||
onReconnect={() => {
|
onReconnect={() => {
|
||||||
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
|
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
|
||||||
setPanes(Array.from({ length: count }, () => ({ id: newPaneId(), shellId: null })))
|
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}
|
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
|
||||||
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
|
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
|
||||||
isLocal={false}
|
isLocal={false}
|
||||||
|
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1315,7 +1349,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
|
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
|
||||||
onSplitRight={() => setSplitDirection('horizontal')}
|
onSplitRight={() => setSplitDirection('horizontal')}
|
||||||
onSplitDown={() => setSplitDirection('vertical')}
|
onSplitDown={() => setSplitDirection('vertical')}
|
||||||
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
|
onToggleMaximize={() => setMaximizedPaneId((current) => (current ? null : contextMenu.paneId))}
|
||||||
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
|
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
|
||||||
onOpenSftp={
|
onOpenSftp={
|
||||||
!isLocal && currentTab?.sessionId
|
!isLocal && currentTab?.sessionId
|
||||||
|
|||||||
Vendored
+36
-15
@@ -22,21 +22,18 @@ export interface DesktopApiContract {
|
|||||||
) => Promise<{ success: boolean; error?: string }>
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
sshExec: (
|
sshExec: (
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
command: string
|
command: string,
|
||||||
|
options?: { fromAgent?: boolean }
|
||||||
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
|
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
|
||||||
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
sshHostMetrics: (
|
sshHostMetrics: (connectionId: string) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
|
||||||
connectionId: string
|
|
||||||
) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
|
|
||||||
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
sshCreateShell: (
|
sshCreateShell: (
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
|
options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
|
||||||
shellId?: string
|
shellId?: string
|
||||||
) => Promise<{ success: boolean; shellId?: string; error?: string }>
|
) => Promise<{ success: boolean; shellId?: string; error?: string }>
|
||||||
sshShellOpenOutput: (
|
sshShellOpenOutput: (shellId: string) => Promise<{ success: boolean; data?: number[] | Uint8Array; error?: string }>
|
||||||
shellId: string
|
|
||||||
) => Promise<{ success: boolean; data?: number[] | Uint8Array; error?: string }>
|
|
||||||
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; 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 }>
|
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
|
||||||
sshCloseShell: (shellId: string) => Promise<{ success: boolean; error?: string }>
|
sshCloseShell: (shellId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -56,9 +53,11 @@ export interface DesktopApiContract {
|
|||||||
shellName?: string
|
shellName?: string
|
||||||
error?: 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 }>
|
localWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
|
||||||
localResize: (shellId: string, cols: number, rows: number) => 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 }>
|
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
|
onLocalData: (callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void) => void
|
||||||
onLocalExit: (callback: (event: unknown, shellId: string) => void) => void
|
onLocalExit: (callback: (event: unknown, shellId: string) => void) => void
|
||||||
sshForwardLocal: (
|
sshForwardLocal: (
|
||||||
@@ -102,15 +101,26 @@ export interface DesktopApiContract {
|
|||||||
updates: Partial<import('../shared/types/session.types').Session>
|
updates: Partial<import('../shared/types/session.types').Session>
|
||||||
) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
|
) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
|
||||||
sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
|
sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
sessionReorder: (
|
||||||
|
updates: Array<import('../shared/types/session.types').SessionReorderEntry>
|
||||||
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
||||||
// SFTP
|
// SFTP
|
||||||
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
|
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
|
||||||
sftpDisconnect: (sftpId: string) => Promise<{ success: boolean; error?: string }>
|
sftpDisconnect: (sftpId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; 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 }>
|
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 }>
|
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 }>
|
sftpCancelTransfer: (sftpId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
sftpRename: (sftpId: string, oldPath: string, newPath: 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 }>
|
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -164,6 +174,19 @@ export interface DesktopApiContract {
|
|||||||
planType?: string
|
planType?: string
|
||||||
}>
|
}>
|
||||||
licenseDeviceId: () => Promise<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<{
|
licenseSetEmail: (email: string) => Promise<{
|
||||||
plan: string
|
plan: string
|
||||||
deviceId: string
|
deviceId: string
|
||||||
@@ -176,11 +199,7 @@ export interface DesktopApiContract {
|
|||||||
planType?: string
|
planType?: string
|
||||||
}>
|
}>
|
||||||
/** Create Waffo checkout order via Rust → opsx-licence (avoids WebView CSP). */
|
/** Create Waffo checkout order via Rust → opsx-licence (avoids WebView CSP). */
|
||||||
licenseCheckout: (input: {
|
licenseCheckout: (input: { customerEmail: string; planType: string; returnUrl?: string }) => Promise<{
|
||||||
customerEmail: string
|
|
||||||
planType: string
|
|
||||||
returnUrl?: string
|
|
||||||
}) => Promise<{
|
|
||||||
orderNo: string
|
orderNo: string
|
||||||
checkoutUrl: string
|
checkoutUrl: string
|
||||||
amount?: number
|
amount?: number
|
||||||
@@ -247,7 +266,9 @@ export interface DesktopApiContract {
|
|||||||
onMenuOpenSettings: (callback: () => void) => void
|
onMenuOpenSettings: (callback: () => void) => void
|
||||||
onSshLinkAvailable: (callback: () => void) => void
|
onSshLinkAvailable: (callback: () => void) => void
|
||||||
onProLink: (callback: (url: string) => 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: (
|
onShellClosed: (
|
||||||
callback: (
|
callback: (
|
||||||
event: unknown,
|
event: unknown,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSessionConfig, getConnectionStore, type ConnectionState } from '../s
|
|||||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { expandSnippetVariables } from '../utils/snippetVariables'
|
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 { shellDisplayFromPath } from '../utils/shellName'
|
||||||
import { normalizeTerminalInject } from '../utils/platformShells'
|
import { normalizeTerminalInject } from '../utils/platformShells'
|
||||||
import { createLogger } from '../utils/logger'
|
import { createLogger } from '../utils/logger'
|
||||||
@@ -49,10 +49,7 @@ export interface UseTerminalConnectionReturn {
|
|||||||
setShowSuspendedOverlay: React.Dispatch<React.SetStateAction<boolean>>
|
setShowSuspendedOverlay: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTerminalConnection({
|
export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnectionParams): UseTerminalConnectionReturn {
|
||||||
tabId,
|
|
||||||
xtermRef,
|
|
||||||
}: UseTerminalConnectionParams): UseTerminalConnectionReturn {
|
|
||||||
const connectionIdRef = useRef<string | null>(null)
|
const connectionIdRef = useRef<string | null>(null)
|
||||||
const shellIdRef = useRef<string | null>(null)
|
const shellIdRef = useRef<string | null>(null)
|
||||||
const hasConnectedRef = useRef(false)
|
const hasConnectedRef = useRef(false)
|
||||||
@@ -105,8 +102,7 @@ export function useTerminalConnection({
|
|||||||
return true
|
return true
|
||||||
}, [tabId, xtermRef])
|
}, [tabId, xtermRef])
|
||||||
|
|
||||||
const sendStartupCommand = useCallback(
|
const sendStartupCommand = useCallback(async (config: SSHConnectionConfig, shellId: string) => {
|
||||||
async (config: SSHConnectionConfig, shellId: string) => {
|
|
||||||
const cmd = config.startupCommand?.trim()
|
const cmd = config.startupCommand?.trim()
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
const expanded = expandSnippetVariables(cmd, {
|
const expanded = expandSnippetVariables(cmd, {
|
||||||
@@ -122,9 +118,7 @@ export function useTerminalConnection({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to send startup command', err)
|
logger.warn('Failed to send startup command', err)
|
||||||
}
|
}
|
||||||
},
|
}, [])
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 连接管理
|
// 连接管理
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
@@ -159,6 +153,11 @@ export function useTerminalConnection({
|
|||||||
const pendingShellId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
const pendingShellId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
shellIdRef.current = pendingShellId
|
shellIdRef.current = pendingShellId
|
||||||
connectionIdRef.current = 'local'
|
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(
|
const shellResult = await shellCreate(
|
||||||
true,
|
true,
|
||||||
@@ -182,15 +181,19 @@ export function useTerminalConnection({
|
|||||||
connectionId: 'local',
|
connectionId: 'local',
|
||||||
shellId: confirmedShellId,
|
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')
|
setConnectionState('connected')
|
||||||
updateTabStatus(tabId, 'connected')
|
updateTabStatus(tabId, 'connected')
|
||||||
reconnectAttemptRef.current = 0
|
reconnectAttemptRef.current = 0
|
||||||
setReconnectAttempt(0)
|
setReconnectAttempt(0)
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
// Title / badge from actual shell (zsh / bash / …)
|
// Title / badge from actual shell (zsh / bash / …)
|
||||||
const display = shellDisplayFromPath(
|
const display = shellDisplayFromPath(shellResult.shellPath || shellResult.shellName)
|
||||||
shellResult.shellPath || shellResult.shellName
|
|
||||||
)
|
|
||||||
updateTab(tabId, {
|
updateTab(tabId, {
|
||||||
title: display.title,
|
title: display.title,
|
||||||
localShellKind: display.kind,
|
localShellKind: display.kind,
|
||||||
@@ -236,9 +239,7 @@ export function useTerminalConnection({
|
|||||||
setConnectionState(isAutoRetry ? 'reconnecting' : 'connecting')
|
setConnectionState(isAutoRetry ? 'reconnecting' : 'connecting')
|
||||||
updateTabStatus(tabId, isAutoRetry ? 'connecting' : 'connecting')
|
updateTabStatus(tabId, isAutoRetry ? 'connecting' : 'connecting')
|
||||||
if (isAutoRetry) {
|
if (isAutoRetry) {
|
||||||
setStatusMessage(
|
setStatusMessage(i18n.t('terminal:connection.autoReconnecting', { attempt: reconnectAttemptRef.current }))
|
||||||
i18n.t('terminal:connection.autoReconnecting', { attempt: reconnectAttemptRef.current })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -286,10 +287,15 @@ export function useTerminalConnection({
|
|||||||
shellIdRef.current = pendingShellId
|
shellIdRef.current = pendingShellId
|
||||||
|
|
||||||
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
||||||
const shellResult = await shellCreate(false, connectionId, {
|
const shellResult = await shellCreate(
|
||||||
|
false,
|
||||||
|
connectionId,
|
||||||
|
{
|
||||||
cols: xtermRef.current?.cols || 80,
|
cols: xtermRef.current?.cols || 80,
|
||||||
rows: xtermRef.current?.rows || 24,
|
rows: xtermRef.current?.rows || 24,
|
||||||
}, pendingShellId)
|
},
|
||||||
|
pendingShellId
|
||||||
|
)
|
||||||
|
|
||||||
if (!shellResult?.success || !shellResult.shellId) {
|
if (!shellResult?.success || !shellResult.shellId) {
|
||||||
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
|
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.
|
// Flush MOTD / lastlog buffered before the UI event listener was ready.
|
||||||
try {
|
try {
|
||||||
const boot = await flushSshShellBootstrap(confirmedShellId)
|
const boot = await flushShellBootstrap(confirmedShellId)
|
||||||
if (boot) xtermRef.current?.write(boot)
|
if (boot) xtermRef.current?.write(boot)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to flush shell bootstrap output', err)
|
logger.warn('Failed to flush shell bootstrap output', err)
|
||||||
@@ -341,7 +347,6 @@ export function useTerminalConnection({
|
|||||||
void sendStartupCommand(config, confirmedShellId)
|
void sendStartupCommand(config, confirmedShellId)
|
||||||
|
|
||||||
xtermRef.current?.focus()
|
xtermRef.current?.focus()
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
setConnectionState('error')
|
setConnectionState('error')
|
||||||
@@ -370,9 +375,7 @@ export function useTerminalConnection({
|
|||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
i18n.t('terminal:connection.autoReconnectLimit', {
|
i18n.t('terminal:connection.autoReconnectLimit', {
|
||||||
max: maxAttempts,
|
max: maxAttempts,
|
||||||
reasonSuffix: reason
|
reasonSuffix: reason ? i18n.t('terminal:connection.autoReconnectLimitReason', { reason }) : '',
|
||||||
? i18n.t('terminal:connection.autoReconnectLimitReason', { reason })
|
|
||||||
: '',
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
setConnectionState('error')
|
setConnectionState('error')
|
||||||
@@ -432,18 +435,13 @@ export function useTerminalConnection({
|
|||||||
shellIdRef.current = null
|
shellIdRef.current = null
|
||||||
setConnectionState('disconnected')
|
setConnectionState('disconnected')
|
||||||
updateTabStatus(tabId, 'disconnected')
|
updateTabStatus(tabId, 'disconnected')
|
||||||
xtermRef.current?.writeln(
|
xtermRef.current?.writeln(`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`)
|
||||||
`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`
|
|
||||||
)
|
|
||||||
scheduleAutoReconnectRef.current?.(reason)
|
scheduleAutoReconnectRef.current?.(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.opsxApi?.onConnectionClosed?.(handler)
|
window.opsxApi?.onConnectionClosed?.(handler)
|
||||||
return () => {
|
return () => {
|
||||||
window.opsxApi?.removeListener?.(
|
window.opsxApi?.removeListener?.('ssh:connection-closed', handler as (...args: unknown[]) => void)
|
||||||
'ssh:connection-closed',
|
|
||||||
handler as (...args: unknown[]) => void
|
|
||||||
)
|
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
}
|
}
|
||||||
}, [tabId, updateTabStatus, clearReconnectTimer, xtermRef])
|
}, [tabId, updateTabStatus, clearReconnectTimer, xtermRef])
|
||||||
@@ -461,10 +459,14 @@ export function useTerminalConnection({
|
|||||||
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
shellIdRef.current = newShellId
|
shellIdRef.current = newShellId
|
||||||
|
|
||||||
const shellResult = await window.opsxApi?.sshCreateShell(connectionIdRef.current, {
|
const shellResult = await window.opsxApi?.sshCreateShell(
|
||||||
|
connectionIdRef.current,
|
||||||
|
{
|
||||||
cols: xtermRef.current?.cols || 80,
|
cols: xtermRef.current?.cols || 80,
|
||||||
rows: xtermRef.current?.rows || 24,
|
rows: xtermRef.current?.rows || 24,
|
||||||
}, newShellId)
|
},
|
||||||
|
newShellId
|
||||||
|
)
|
||||||
|
|
||||||
if (shellResult?.success && shellResult.shellId) {
|
if (shellResult?.success && shellResult.shellId) {
|
||||||
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
|
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
|
||||||
@@ -475,7 +477,7 @@ export function useTerminalConnection({
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const boot = await flushSshShellBootstrap(shellResult.shellId)
|
const boot = await flushShellBootstrap(shellResult.shellId)
|
||||||
if (boot) xtermRef.current?.write(boot)
|
if (boot) xtermRef.current?.write(boot)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to flush resumed shell bootstrap', err)
|
logger.warn('Failed to flush resumed shell bootstrap', err)
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "Follow-ups include this thread's context",
|
"hintFollowUp": "Follow-ups include this thread's context",
|
||||||
"hintSelection": "Will include {{lines}} selected lines",
|
"hintSelection": "Will include {{lines}} selected lines",
|
||||||
"hintSteps": "Up to {{max}} steps · nothing persisted",
|
"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": {
|
"safety": {
|
||||||
"aria": "Agent safety mode",
|
"aria": "Agent safety mode",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "Relaxed",
|
"label": "Relaxed",
|
||||||
"short": "Confirm high risk only",
|
"short": "Confirm high risk only",
|
||||||
"description": "Read-only and routine edits run automatically; deletes, restarts, installs, etc. need confirmation."
|
"description": "Read-only and routine edits run automatically; deletes, restarts, installs, etc. need confirmation."
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "Confirm every command (read-only whitelist excepted)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "Safe",
|
"safe": "Safe",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "Step {{round}} command rejected.",
|
"rejected": "Step {{round}} command rejected.",
|
||||||
"terminated": "Terminated",
|
"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": {
|
"confirm": {
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"once": "Once",
|
"once": "Once",
|
||||||
"always": "Always",
|
"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": {
|
"error": {
|
||||||
"noProvider": "Configure an AI provider in Settings → AI first",
|
"noProvider": "Configure an AI provider in Settings → AI first",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "ssh-exec failed: {{error}}",
|
"execFailed": "ssh-exec failed: {{error}}",
|
||||||
"execUnknown": "Unknown error",
|
"execUnknown": "Unknown error",
|
||||||
"execNoOutput": "Command finished with no output",
|
"execNoOutput": "Command finished with no output",
|
||||||
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry"
|
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
|
||||||
|
"noOutput": "No terminal output to summarize"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(empty output)",
|
"empty": "(empty output)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "Close all",
|
"closeAll": "Close all",
|
||||||
"clone": "Duplicate tab",
|
"clone": "Duplicate tab",
|
||||||
"openSftp": "Open SFTP"
|
"openSftp": "Open SFTP"
|
||||||
}
|
},
|
||||||
|
"renameAria": "Rename tab {{title}}",
|
||||||
|
"renameHint": "Double-click to rename"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "SSH / SFTP terminal",
|
"brandSub": "SSH / SFTP terminal",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "New session here",
|
"newSessionHere": "New session here",
|
||||||
"newGroup": "New group",
|
"newGroup": "New group",
|
||||||
|
"sortByName": "Sort by name",
|
||||||
"renameGroup": "Rename group",
|
"renameGroup": "Rename group",
|
||||||
"deleteEmptyGroup": "Delete empty group"
|
"deleteEmptyGroup": "Delete empty group"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "VNC remote desktop",
|
"title": "VNC remote desktop",
|
||||||
"desc": "RFB · cross-platform graphical session"
|
"desc": "RFB · cross-platform graphical session"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "Import sessions",
|
||||||
|
"desc": "Import hosts from OpenSSH config or an export file"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "No saved sessions",
|
"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": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
"reconnecting": "Auto-reconnecting{{attempt}}…",
|
"reconnecting": "Auto-reconnecting{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"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": {
|
"hostKey": {
|
||||||
"titleNew": "Verify host key",
|
"titleNew": "Verify host key",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます",
|
"hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます",
|
||||||
"hintSelection": "選択した {{lines}} 行を含めます",
|
"hintSelection": "選択した {{lines}} 行を含めます",
|
||||||
"hintSteps": "最大 {{max}} ステップ · 何も保存しません",
|
"hintSteps": "最大 {{max}} ステップ · 何も保存しません",
|
||||||
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません"
|
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません",
|
||||||
|
"summarize": "出力を要約",
|
||||||
|
"summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません",
|
||||||
|
"summarizeRequest": "現在の端末出力を要約"
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"aria": "エージェントの安全モード",
|
"aria": "エージェントの安全モード",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "緩和",
|
"label": "緩和",
|
||||||
"short": "高リスクのみ確認",
|
"short": "高リスクのみ確認",
|
||||||
"description": "読み取り専用や日常の編集は自動実行 · 削除、再起動、インストール等は確認が必要です。"
|
"description": "読み取り専用や日常の編集は自動実行 · 削除、再起動、インストール等は確認が必要です。"
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "すべてのコマンドを確認(読み取り専用ホワイトリストを除く)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "安全",
|
"safe": "安全",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "ステップ {{round}} のコマンドは拒否されました。",
|
"rejected": "ステップ {{round}} のコマンドは拒否されました。",
|
||||||
"terminated": "終了しました",
|
"terminated": "終了しました",
|
||||||
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。"
|
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。",
|
||||||
|
"inserted": "ラウンド {{round}} のコマンドを入力行に挿入しました(未実行)。確認してから Enter で実行してください。",
|
||||||
|
"insertedClipboard": "ラウンド {{round}} のコマンドをクリップボードにコピーしました(端末に書き込めません)。貼り付けて確認してから実行してください。"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"reject": "拒否",
|
"reject": "拒否",
|
||||||
"once": "1 回のみ",
|
"once": "1 回のみ",
|
||||||
"always": "常に",
|
"always": "常に",
|
||||||
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ"
|
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ",
|
||||||
|
"insert": "挿入のみ"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください",
|
"noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "ssh-exec 失敗: {{error}}",
|
"execFailed": "ssh-exec 失敗: {{error}}",
|
||||||
"execUnknown": "不明なエラー",
|
"execUnknown": "不明なエラー",
|
||||||
"execNoOutput": "コマンドは出力なしで完了",
|
"execNoOutput": "コマンドは出力なしで完了",
|
||||||
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください"
|
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
|
||||||
|
"noOutput": "要約できる端末出力がありません"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(出力なし)",
|
"empty": "(出力なし)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "すべて閉じる",
|
"closeAll": "すべて閉じる",
|
||||||
"clone": "タブを複製",
|
"clone": "タブを複製",
|
||||||
"openSftp": "SFTP を開く"
|
"openSftp": "SFTP を開く"
|
||||||
}
|
},
|
||||||
|
"renameAria": "タブ {{title}} の名前を変更",
|
||||||
|
"renameHint": "ダブルクリックで名前変更"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "SSH / SFTP ターミナル",
|
"brandSub": "SSH / SFTP ターミナル",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "ここに新規セッション",
|
"newSessionHere": "ここに新規セッション",
|
||||||
"newGroup": "新規グループ",
|
"newGroup": "新規グループ",
|
||||||
|
"sortByName": "名前で並べ替え",
|
||||||
"renameGroup": "グループ名を変更",
|
"renameGroup": "グループ名を変更",
|
||||||
"deleteEmptyGroup": "空のグループを削除"
|
"deleteEmptyGroup": "空のグループを削除"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "VNC リモートデスクトップ",
|
"title": "VNC リモートデスクトップ",
|
||||||
"desc": "RFB · クロスプラットフォームのグラフィカルセッション"
|
"desc": "RFB · クロスプラットフォームのグラフィカルセッション"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "セッションをインポート",
|
||||||
|
"desc": "OpenSSH 設定またはエクスポートファイルからホストを取り込みます"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "保存済みセッションはありません",
|
"title": "保存済みセッションはありません",
|
||||||
"desc": "クイック接続を試すか、後で再利用するためのセッションを作成"
|
"desc": "クイック接続、新規作成、または既存ホストのインポート"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"launcher": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "接続中…",
|
"connecting": "接続中…",
|
||||||
"reconnecting": "自動再接続中{{attempt}}…",
|
"reconnecting": "自動再接続中{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"reconnectingAttempt": " ({{count}})",
|
||||||
"localShellExited": "ローカルシェルが終了しました"
|
"localShellExited": "ローカルシェルが終了しました",
|
||||||
|
"localDisconnected": "ローカル端末は終了しました",
|
||||||
|
"localDisconnectedHint": "ローカルシェルが終了しました。下のボタンで再び開始して作業を続けられます。",
|
||||||
|
"localConnectFailedHint": "ローカルシェルを起動できませんでした。選択したシェルが存在するか確認してください。",
|
||||||
|
"localShellClosedHint": "シェルを再開するとローカル端末を引き続き利用できます。"
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"titleNew": "ホスト鍵を確認",
|
"titleNew": "ホスト鍵を確認",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다",
|
"hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다",
|
||||||
"hintSelection": "선택한 {{lines}}줄을 포함합니다",
|
"hintSelection": "선택한 {{lines}}줄을 포함합니다",
|
||||||
"hintSteps": "최대 {{max}}단계 · 저장되지 않음",
|
"hintSteps": "최대 {{max}}단계 · 저장되지 않음",
|
||||||
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함"
|
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함",
|
||||||
|
"summarize": "출력 요약",
|
||||||
|
"summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다",
|
||||||
|
"summarizeRequest": "현재 터미널 출력 요약"
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"aria": "에이전트 안전 모드",
|
"aria": "에이전트 안전 모드",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "완화",
|
"label": "완화",
|
||||||
"short": "고위험만 확인",
|
"short": "고위험만 확인",
|
||||||
"description": "읽기 전용 및 일상적인 편집은 자동으로 실행됩니다; 삭제, 재시작, 설치 등은 확인이 필요합니다."
|
"description": "읽기 전용 및 일상적인 편집은 자동으로 실행됩니다; 삭제, 재시작, 설치 등은 확인이 필요합니다."
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "모든 명령 확인(읽기 전용 화이트리스트 제외)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "안전",
|
"safe": "안전",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "{{round}}단계 명령이 거부되었습니다.",
|
"rejected": "{{round}}단계 명령이 거부되었습니다.",
|
||||||
"terminated": "종료됨",
|
"terminated": "종료됨",
|
||||||
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다."
|
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다.",
|
||||||
|
"inserted": "{{round}}번째 명령을 입력 줄에 삽입했지만 실행하지 않았습니다. 확인 후 직접 Enter를 눌러 실행하세요.",
|
||||||
|
"insertedClipboard": "{{round}}번째 명령을 클립보드에 복사했습니다(터미널에 쓸 수 없음). 붙여넣고 확인한 뒤 실행하세요."
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"reject": "거부",
|
"reject": "거부",
|
||||||
"once": "1회",
|
"once": "1회",
|
||||||
"always": "항상",
|
"always": "항상",
|
||||||
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기"
|
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기",
|
||||||
|
"insert": "삽입만"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요",
|
"noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "ssh-exec 실패: {{error}}",
|
"execFailed": "ssh-exec 실패: {{error}}",
|
||||||
"execUnknown": "알 수 없는 오류",
|
"execUnknown": "알 수 없는 오류",
|
||||||
"execNoOutput": "명령이 출력 없이 완료됨",
|
"execNoOutput": "명령이 출력 없이 완료됨",
|
||||||
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요"
|
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
|
||||||
|
"noOutput": "요약할 터미널 출력이 없습니다"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(빈 출력)",
|
"empty": "(빈 출력)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "모두 닫기",
|
"closeAll": "모두 닫기",
|
||||||
"clone": "탭 복제",
|
"clone": "탭 복제",
|
||||||
"openSftp": "SFTP 열기"
|
"openSftp": "SFTP 열기"
|
||||||
}
|
},
|
||||||
|
"renameAria": "탭 {{title}} 이름 바꾸기",
|
||||||
|
"renameHint": "더블 클릭해 이름 변경"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "SSH / SFTP 터미널",
|
"brandSub": "SSH / SFTP 터미널",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "여기에 새 세션",
|
"newSessionHere": "여기에 새 세션",
|
||||||
"newGroup": "새 그룹",
|
"newGroup": "새 그룹",
|
||||||
|
"sortByName": "이름순 정렬",
|
||||||
"renameGroup": "그룹 이름 변경",
|
"renameGroup": "그룹 이름 변경",
|
||||||
"deleteEmptyGroup": "빈 그룹 삭제"
|
"deleteEmptyGroup": "빈 그룹 삭제"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "VNC 원격 데스크톱",
|
"title": "VNC 원격 데스크톱",
|
||||||
"desc": "RFB · 크로스 플랫폼 그래픽 세션"
|
"desc": "RFB · 크로스 플랫폼 그래픽 세션"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "세션 가져오기",
|
||||||
|
"desc": "OpenSSH 설정 또는 내보내기 파일에서 호스트를 가져옵니다"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "저장된 세션 없음",
|
"title": "저장된 세션 없음",
|
||||||
"desc": "빠른 연결을 시도하거나, 나중에 재사용할 세션을 생성하세요"
|
"desc": "빠른 연결, 세션 생성, 또는 기존 호스트 가져오기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"launcher": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "연결 중…",
|
"connecting": "연결 중…",
|
||||||
"reconnecting": "자동 재연결 중{{attempt}}…",
|
"reconnecting": "자동 재연결 중{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"reconnectingAttempt": " ({{count}})",
|
||||||
"localShellExited": "로컬 셸이 종료됨"
|
"localShellExited": "로컬 셸이 종료됨",
|
||||||
|
"localDisconnected": "로컬 터미널 종료됨",
|
||||||
|
"localDisconnectedHint": "로컬 셸이 종료되었습니다. 아래를 누르면 다시 시작해 작업을 이어갑니다.",
|
||||||
|
"localConnectFailedHint": "로컬 셸을 시작하지 못했습니다. 선택한 셸이 존재하는지 확인하세요.",
|
||||||
|
"localShellClosedHint": "셸을 다시 열면 로컬 터미널을 계속 사용할 수 있습니다."
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"titleNew": "호스트 키 확인",
|
"titleNew": "호스트 키 확인",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "Уточнения включают контекст этой ветки",
|
"hintFollowUp": "Уточнения включают контекст этой ветки",
|
||||||
"hintSelection": "Будет включено {{lines}} выбранных строк",
|
"hintSelection": "Будет включено {{lines}} выбранных строк",
|
||||||
"hintSteps": "До {{max}} шагов · ничего не сохраняется",
|
"hintSteps": "До {{max}} шагов · ничего не сохраняется",
|
||||||
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется"
|
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется",
|
||||||
|
"summarize": "Сводка",
|
||||||
|
"summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются",
|
||||||
|
"summarizeRequest": "Суммируйте текущий вывод терминала"
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"aria": "Режим безопасности агента",
|
"aria": "Режим безопасности агента",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "Свободный",
|
"label": "Свободный",
|
||||||
"short": "Подтверждать только высокий риск",
|
"short": "Подтверждать только высокий риск",
|
||||||
"description": "Команды только для чтения и обычные правки выполняются автоматически; удаления, перезапуски, установки и т. п. требуют подтверждения."
|
"description": "Команды только для чтения и обычные правки выполняются автоматически; удаления, перезапуски, установки и т. п. требуют подтверждения."
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "Подтверждать каждую команду (кроме белого списка только для чтения)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "Безопасно",
|
"safe": "Безопасно",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "Команда шага {{round}} отклонена.",
|
"rejected": "Команда шага {{round}} отклонена.",
|
||||||
"terminated": "Прервано",
|
"terminated": "Прервано",
|
||||||
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды."
|
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды.",
|
||||||
|
"inserted": "Команда раунда {{round}} вставлена в строку ввода терминала без выполнения; проверьте и нажмите Enter сами.",
|
||||||
|
"insertedClipboard": "Команда раунда {{round}} скопирована в буфер обмена (терминал недоступен для записи); вставьте, проверьте и выполните."
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"reject": "Отклонить",
|
"reject": "Отклонить",
|
||||||
"once": "Один раз",
|
"once": "Один раз",
|
||||||
"always": "Всегда",
|
"always": "Всегда",
|
||||||
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе"
|
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе",
|
||||||
|
"insert": "Только вставить"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noProvider": "Сначала настройте AI-провайдера в Настройки → AI",
|
"noProvider": "Сначала настройте AI-провайдера в Настройки → AI",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "Сбой ssh-exec: {{error}}",
|
"execFailed": "Сбой ssh-exec: {{error}}",
|
||||||
"execUnknown": "Неизвестная ошибка",
|
"execUnknown": "Неизвестная ошибка",
|
||||||
"execNoOutput": "Команда завершилась без вывода",
|
"execNoOutput": "Команда завершилась без вывода",
|
||||||
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите"
|
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
|
||||||
|
"noOutput": "Нет вывода терминала для сводки"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(пустой вывод)",
|
"empty": "(пустой вывод)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "Закрыть все",
|
"closeAll": "Закрыть все",
|
||||||
"clone": "Дублировать вкладку",
|
"clone": "Дублировать вкладку",
|
||||||
"openSftp": "Открыть SFTP"
|
"openSftp": "Открыть SFTP"
|
||||||
}
|
},
|
||||||
|
"renameAria": "Переименовать вкладку {{title}}",
|
||||||
|
"renameHint": "Двойной клик — переименовать"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "Терминал SSH / SFTP",
|
"brandSub": "Терминал SSH / SFTP",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "Новый сеанс здесь",
|
"newSessionHere": "Новый сеанс здесь",
|
||||||
"newGroup": "Новая группа",
|
"newGroup": "Новая группа",
|
||||||
|
"sortByName": "Сортировать по имени",
|
||||||
"renameGroup": "Переименовать группу",
|
"renameGroup": "Переименовать группу",
|
||||||
"deleteEmptyGroup": "Удалить пустую группу"
|
"deleteEmptyGroup": "Удалить пустую группу"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "Удалённый рабочий стол VNC",
|
"title": "Удалённый рабочий стол VNC",
|
||||||
"desc": "RFB · кроссплатформенный графический сеанс"
|
"desc": "RFB · кроссплатформенный графический сеанс"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "Импорт сеансов",
|
||||||
|
"desc": "Импорт хостов из OpenSSH config или файла экспорта"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "Сохранённых сеансов нет",
|
"title": "Сохранённых сеансов нет",
|
||||||
"desc": "Попробуйте быстрое подключение или создайте сеанс для повторного использования"
|
"desc": "Быстрое подключение, новый сеанс или импорт существующих хостов"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"launcher": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "Подключение…",
|
"connecting": "Подключение…",
|
||||||
"reconnecting": "Автопереподключение{{attempt}}…",
|
"reconnecting": "Автопереподключение{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"reconnectingAttempt": " ({{count}})",
|
||||||
"localShellExited": "Локальная оболочка завершилась"
|
"localShellExited": "Локальная оболочка завершилась",
|
||||||
|
"localDisconnected": "Локальный терминал закрыт",
|
||||||
|
"localDisconnectedHint": "Локальная оболочка завершилась. Один клик ниже — и она запустится снова.",
|
||||||
|
"localConnectFailedHint": "Не удалось запустить локальную оболочку. Убедитесь, что выбранная оболочка существует.",
|
||||||
|
"localShellClosedHint": "Откройте оболочку снова, чтобы продолжить работу в локальном терминале."
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"titleNew": "Проверка ключа хоста",
|
"titleNew": "Проверка ключа хоста",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "Các theo dõi bao gồm ngữ cảnh của luồng này",
|
"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",
|
"hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn",
|
||||||
"hintSteps": "Tối đa {{max}} bước · không lưu gì",
|
"hintSteps": "Tối đa {{max}} bước · 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": {
|
"safety": {
|
||||||
"aria": "Chế độ an toàn của agent",
|
"aria": "Chế độ an toàn của agent",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "Thoải mái",
|
"label": "Thoải mái",
|
||||||
"short": "Chỉ xác nhận rủi ro cao",
|
"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."
|
"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": {
|
"risk": {
|
||||||
"safe": "An toàn",
|
"safe": "An toàn",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "Lệnh bước {{round}} đã bị từ chối.",
|
"rejected": "Lệnh bước {{round}} đã bị từ chối.",
|
||||||
"terminated": "Đã chấm dứt",
|
"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": {
|
"confirm": {
|
||||||
"reject": "Từ chối",
|
"reject": "Từ chối",
|
||||||
"once": "Một lần",
|
"once": "Một lần",
|
||||||
"always": "Luôn luô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": {
|
"error": {
|
||||||
"noProvider": "Cấu hình nhà cung cấp AI trong Cài đặt → AI trước tiên",
|
"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}}",
|
"execFailed": "ssh-exec thất bại: {{error}}",
|
||||||
"execUnknown": "Lỗi không xác định",
|
"execUnknown": "Lỗi không xác định",
|
||||||
"execNoOutput": "Lệnh hoàn tất không có đầu ra",
|
"execNoOutput": "Lệnh hoàn tất không có đầu ra",
|
||||||
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại"
|
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
|
||||||
|
"noOutput": "Không có đầu ra terminal để tóm tắt"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(đầu ra rỗng)",
|
"empty": "(đầu ra rỗng)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "Đóng tất cả",
|
"closeAll": "Đóng tất cả",
|
||||||
"clone": "Nhân đôi tab",
|
"clone": "Nhân đôi tab",
|
||||||
"openSftp": "Mở SFTP"
|
"openSftp": "Mở SFTP"
|
||||||
}
|
},
|
||||||
|
"renameAria": "Đổi tên thẻ {{title}}",
|
||||||
|
"renameHint": "Nhấn đúp để đổi tên"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "Terminal SSH / SFTP",
|
"brandSub": "Terminal SSH / SFTP",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "Phiên mới tại đây",
|
"newSessionHere": "Phiên mới tại đây",
|
||||||
"newGroup": "Nhóm mới",
|
"newGroup": "Nhóm mới",
|
||||||
|
"sortByName": "Sắp xếp theo tên",
|
||||||
"renameGroup": "Đổi tên nhóm",
|
"renameGroup": "Đổi tên nhóm",
|
||||||
"deleteEmptyGroup": "Xóa nhóm rỗng"
|
"deleteEmptyGroup": "Xóa nhóm rỗng"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "Màn hình từ xa VNC",
|
"title": "Màn hình từ xa VNC",
|
||||||
"desc": "RFB · phiên đồ họa đa nền tảng"
|
"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": {
|
"recentEmpty": {
|
||||||
"title": "Chưa có phiên đã lưu",
|
"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": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "Đang kết nối…",
|
"connecting": "Đang kết nối…",
|
||||||
"reconnecting": "Đang tự kết nối lại{{attempt}}…",
|
"reconnecting": "Đang tự kết nối lại{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"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": {
|
"hostKey": {
|
||||||
"titleNew": "Xác minh khóa máy chủ",
|
"titleNew": "Xác minh khóa máy chủ",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "继续追问将带上本轮上下文",
|
"hintFollowUp": "继续追问将带上本轮上下文",
|
||||||
"hintSelection": "将附带选中 {{lines}} 行",
|
"hintSelection": "将附带选中 {{lines}} 行",
|
||||||
"hintSteps": "最多 {{max}} 步 · 不落盘",
|
"hintSteps": "最多 {{max}} 步 · 不落盘",
|
||||||
"footerNote": "高风险需确认 · critical 拒绝 · 会话不落盘"
|
"footerNote": "高风险需确认 · critical 拒绝 · 会话不落盘",
|
||||||
|
"summarize": "总结输出",
|
||||||
|
"summarizeTitle": "用 AI 总结当前终端可见输出(优先使用选中文本),不执行任何命令",
|
||||||
|
"summarizeRequest": "总结当前终端输出"
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"aria": "Agent 安全模式",
|
"aria": "Agent 安全模式",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "宽松",
|
"label": "宽松",
|
||||||
"short": "仅高风险确认",
|
"short": "仅高风险确认",
|
||||||
"description": "查看和普通修改自动执行;删除、重启、安装等高风险操作需要确认。"
|
"description": "查看和普通修改自动执行;删除、重启、安装等高风险操作需要确认。"
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "所有命令都需确认(只读白名单除外)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "安全",
|
"safe": "安全",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "已拒绝第 {{round}} 步命令。",
|
"rejected": "已拒绝第 {{round}} 步命令。",
|
||||||
"terminated": "已终止",
|
"terminated": "已终止",
|
||||||
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。"
|
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。",
|
||||||
|
"inserted": "第 {{round}} 轮命令已插入终端输入行,未执行;请检查后自行回车运行。",
|
||||||
|
"insertedClipboard": "第 {{round}} 轮命令已复制到剪贴板(当前终端不可写入);粘贴后请检查再运行。"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"once": "Once",
|
"once": "Once",
|
||||||
"always": "Always",
|
"always": "Always",
|
||||||
"hint": "Enter · Once Esc · Reject Always · 本会话同类命令不再确认"
|
"hint": "Enter · Once Esc · Reject Always · 本会话同类命令不再确认",
|
||||||
|
"insert": "插入不执行"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noProvider": "请先在设置 → 大模型配置 Provider",
|
"noProvider": "请先在设置 → 大模型配置 Provider",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "ssh-exec 失败:{{error}}",
|
"execFailed": "ssh-exec 失败:{{error}}",
|
||||||
"execUnknown": "未知错误",
|
"execUnknown": "未知错误",
|
||||||
"execNoOutput": "命令执行完成,无输出",
|
"execNoOutput": "命令执行完成,无输出",
|
||||||
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试"
|
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试",
|
||||||
|
"noOutput": "终端没有可总结的输出"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(空输出)",
|
"empty": "(空输出)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "关闭全部",
|
"closeAll": "关闭全部",
|
||||||
"clone": "克隆标签",
|
"clone": "克隆标签",
|
||||||
"openSftp": "打开 SFTP"
|
"openSftp": "打开 SFTP"
|
||||||
}
|
},
|
||||||
|
"renameAria": "重命名标签 {{title}}",
|
||||||
|
"renameHint": "双击重命名"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "SSH / SFTP 终端",
|
"brandSub": "SSH / SFTP 终端",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "在此新建会话",
|
"newSessionHere": "在此新建会话",
|
||||||
"newGroup": "新建分组",
|
"newGroup": "新建分组",
|
||||||
|
"sortByName": "按名称排序",
|
||||||
"renameGroup": "重命名分组",
|
"renameGroup": "重命名分组",
|
||||||
"deleteEmptyGroup": "删除空分组"
|
"deleteEmptyGroup": "删除空分组"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "VNC 远程桌面",
|
"title": "VNC 远程桌面",
|
||||||
"desc": "RFB · 跨平台图形会话"
|
"desc": "RFB · 跨平台图形会话"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "导入会话",
|
||||||
|
"desc": "从 OpenSSH 配置或导出文件导入主机"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "暂无已保存会话",
|
"title": "暂无已保存会话",
|
||||||
"desc": "用快速连接试连,或新建会话以便复用"
|
"desc": "用快速连接试连,新建会话,或导入已有主机"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"launcher": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "正在连接…",
|
"connecting": "正在连接…",
|
||||||
"reconnecting": "自动重连中{{attempt}}…",
|
"reconnecting": "自动重连中{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"reconnectingAttempt": " ({{count}})",
|
||||||
"localShellExited": "本地 Shell 已退出"
|
"localShellExited": "本地 Shell 已退出",
|
||||||
|
"localDisconnected": "本地终端已关闭",
|
||||||
|
"localDisconnectedHint": "本地 Shell 会话已结束。点下方一键重新开启,即可继续使用。",
|
||||||
|
"localConnectFailedHint": "本地 Shell 启动失败,请确认所选 Shell 仍然存在",
|
||||||
|
"localShellClosedHint": "重新打开 Shell 即可继续使用本地终端"
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"titleNew": "确认主机密钥",
|
"titleNew": "确认主机密钥",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"hintFollowUp": "繼續追問將帶上本輪上下文",
|
"hintFollowUp": "繼續追問將帶上本輪上下文",
|
||||||
"hintSelection": "將附帶選中 {{lines}} 行",
|
"hintSelection": "將附帶選中 {{lines}} 行",
|
||||||
"hintSteps": "最多 {{max}} 步 · 不落盤",
|
"hintSteps": "最多 {{max}} 步 · 不落盤",
|
||||||
"footerNote": "高風險需確認 · critical 拒絕 · 連線不落盤"
|
"footerNote": "高風險需確認 · critical 拒絕 · 連線不落盤",
|
||||||
|
"summarize": "摘要輸出",
|
||||||
|
"summarizeTitle": "用 AI 摘要目前終端機可見輸出(優先使用選取文字),不執行任何命令",
|
||||||
|
"summarizeRequest": "摘要目前終端機輸出"
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"aria": "Agent 安全模式",
|
"aria": "Agent 安全模式",
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"label": "寬鬆",
|
"label": "寬鬆",
|
||||||
"short": "僅高風險確認",
|
"short": "僅高風險確認",
|
||||||
"description": "查看和一般修改自動執行;刪除、重啟、安裝等高風險操作需要確認。"
|
"description": "查看和一般修改自動執行;刪除、重啟、安裝等高風險操作需要確認。"
|
||||||
}
|
},
|
||||||
|
"confirmAllTitle": "所有命令都需確認(唯讀白名單除外)"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"safe": "安全",
|
"safe": "安全",
|
||||||
@@ -50,13 +54,16 @@
|
|||||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||||
"rejected": "已拒絕第 {{round}} 步命令。",
|
"rejected": "已拒絕第 {{round}} 步命令。",
|
||||||
"terminated": "已終止",
|
"terminated": "已終止",
|
||||||
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。"
|
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。",
|
||||||
|
"inserted": "第 {{round}} 輪命令已插入終端機輸入行,未執行;請檢查後自行按 Enter 執行。",
|
||||||
|
"insertedClipboard": "第 {{round}} 輪命令已複製到剪貼簿(目前終端機不可寫入);貼上後請檢查再執行。"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"once": "Once",
|
"once": "Once",
|
||||||
"always": "Always",
|
"always": "Always",
|
||||||
"hint": "Enter · Once Esc · Reject Always · 本連線同類命令不再確認"
|
"hint": "Enter · Once Esc · Reject Always · 本連線同類命令不再確認",
|
||||||
|
"insert": "插入不執行"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noProvider": "請先在設定 → 大模型設定 Provider",
|
"noProvider": "請先在設定 → 大模型設定 Provider",
|
||||||
@@ -73,7 +80,8 @@
|
|||||||
"execFailed": "ssh-exec 失敗:{{error}}",
|
"execFailed": "ssh-exec 失敗:{{error}}",
|
||||||
"execUnknown": "未知錯誤",
|
"execUnknown": "未知錯誤",
|
||||||
"execNoOutput": "命令執行完成,無輸出",
|
"execNoOutput": "命令執行完成,無輸出",
|
||||||
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試"
|
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試",
|
||||||
|
"noOutput": "終端機沒有可摘要的輸出"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"empty": "(空輸出)",
|
"empty": "(空輸出)",
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"closeAll": "關閉全部",
|
"closeAll": "關閉全部",
|
||||||
"clone": "克隆分頁",
|
"clone": "克隆分頁",
|
||||||
"openSftp": "開啟 SFTP"
|
"openSftp": "開啟 SFTP"
|
||||||
}
|
},
|
||||||
|
"renameAria": "重新命名標籤 {{title}}",
|
||||||
|
"renameHint": "雙擊重新命名"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"brandSub": "SSH / SFTP 終端機",
|
"brandSub": "SSH / SFTP 終端機",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"group": {
|
"group": {
|
||||||
"newSessionHere": "在此新增連線",
|
"newSessionHere": "在此新增連線",
|
||||||
"newGroup": "新增群組",
|
"newGroup": "新增群組",
|
||||||
|
"sortByName": "依名稱排序",
|
||||||
"renameGroup": "重新命名群組",
|
"renameGroup": "重新命名群組",
|
||||||
"deleteEmptyGroup": "刪除空群組"
|
"deleteEmptyGroup": "刪除空群組"
|
||||||
},
|
},
|
||||||
@@ -94,9 +95,13 @@
|
|||||||
"title": "VNC 遠端桌面",
|
"title": "VNC 遠端桌面",
|
||||||
"desc": "RFB · 跨平台圖形連線"
|
"desc": "RFB · 跨平台圖形連線"
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "匯入連線",
|
||||||
|
"desc": "從 OpenSSH 設定或匯出檔匯入主機"
|
||||||
|
},
|
||||||
"recentEmpty": {
|
"recentEmpty": {
|
||||||
"title": "暫無已儲存連線",
|
"title": "暫無已儲存連線",
|
||||||
"desc": "用快速連線試連,或新增連線以便重用"
|
"desc": "用快速連線試連,新增連線,或匯入既有主機"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"launcher": {
|
"launcher": {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
"connecting": "正在連線…",
|
"connecting": "正在連線…",
|
||||||
"reconnecting": "自動重連中{{attempt}}…",
|
"reconnecting": "自動重連中{{attempt}}…",
|
||||||
"reconnectingAttempt": " ({{count}})",
|
"reconnectingAttempt": " ({{count}})",
|
||||||
"localShellExited": "本機 Shell 已結束"
|
"localShellExited": "本機 Shell 已結束",
|
||||||
|
"localDisconnected": "本機終端機已關閉",
|
||||||
|
"localDisconnectedHint": "本機 Shell 會話已結束。點下方一鍵重新開啟,即可繼續使用。",
|
||||||
|
"localConnectFailedHint": "本機 Shell 啟動失敗,請確認所選 Shell 仍然存在",
|
||||||
|
"localShellClosedHint": "重新開啟 Shell 即可繼續使用本機終端機"
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"titleNew": "確認主機金鑰",
|
"titleNew": "確認主機金鑰",
|
||||||
|
|||||||
+101
-29
@@ -72,13 +72,13 @@
|
|||||||
/* Border — soft hairlines (not bright slate blocks) */
|
/* Border — soft hairlines (not bright slate blocks) */
|
||||||
--color-border: rgba(148, 163, 184, 0.08);
|
--color-border: rgba(148, 163, 184, 0.08);
|
||||||
--color-border-subtle: rgba(148, 163, 184, 0.06);
|
--color-border-subtle: rgba(148, 163, 184, 0.06);
|
||||||
--color-border-default: rgba(148, 163, 184, 0.10);
|
--color-border-default: rgba(148, 163, 184, 0.1);
|
||||||
--color-border-strong: rgba(148, 163, 184, 0.16);
|
--color-border-strong: rgba(148, 163, 184, 0.16);
|
||||||
--color-border-focus: #3b82f6;
|
--color-border-focus: #3b82f6;
|
||||||
|
|
||||||
/* Legacy border-dark-* remaps (components still use dark-600/700 borders) */
|
/* Legacy border-dark-* remaps (components still use dark-600/700 borders) */
|
||||||
--hairline-soft: rgba(148, 163, 184, 0.06);
|
--hairline-soft: rgba(148, 163, 184, 0.06);
|
||||||
--hairline: rgba(148, 163, 184, 0.10);
|
--hairline: rgba(148, 163, 184, 0.1);
|
||||||
--hairline-strong: rgba(148, 163, 184, 0.16);
|
--hairline-strong: rgba(148, 163, 184, 0.16);
|
||||||
|
|
||||||
/* Accent (+ RGB channels for Tailwind opacity modifiers) */
|
/* Accent (+ RGB channels for Tailwind opacity modifiers) */
|
||||||
@@ -212,10 +212,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
body {
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
font-family:
|
||||||
sans-serif;
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||||
|
'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: var(--color-surface-base) !important;
|
background-color: var(--color-surface-base) !important;
|
||||||
@@ -252,6 +253,14 @@ body.sftp-pointer-dragging * {
|
|||||||
cursor: grabbing !important;
|
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 */
|
/* Buttons/inputs inside drag regions stay clickable */
|
||||||
.titlebar-drag button,
|
.titlebar-drag button,
|
||||||
.titlebar-drag a,
|
.titlebar-drag a,
|
||||||
@@ -339,28 +348,57 @@ body.sftp-pointer-dragging * {
|
|||||||
|
|
||||||
/* Slide animations */
|
/* Slide animations */
|
||||||
@keyframes slideInRight {
|
@keyframes slideInRight {
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
from {
|
||||||
to { transform: translateX(0); opacity: 1; }
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideOutRight {
|
@keyframes slideOutRight {
|
||||||
from { transform: translateX(0); opacity: 1; }
|
from {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInUp {
|
@keyframes slideInUp {
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scaleIn {
|
@keyframes scaleIn {
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
from {
|
||||||
to { transform: scale(1); opacity: 1; }
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-subtle {
|
@keyframes pulse-subtle {
|
||||||
0%, 100% { opacity: 1; }
|
0%,
|
||||||
50% { opacity: 0.7; }
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pfDashFlow {
|
@keyframes pfDashFlow {
|
||||||
@@ -385,11 +423,21 @@ body.sftp-pointer-dragging * {
|
|||||||
animation: pfTunnelPulse 1.35s ease-in-out infinite;
|
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-in-right {
|
||||||
.animate-slide-out-right { animation: slideOutRight 0.2s ease-in forwards; }
|
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
.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-slide-out-right {
|
||||||
.animate-pulse-subtle { animation: pulse-subtle 2s ease-in-out infinite; }
|
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 */
|
/* Focus ring */
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
@@ -411,7 +459,12 @@ button:active:not(:disabled) {
|
|||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
button {
|
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) ── */
|
/* ── 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);
|
background-color: var(--color-surface-base);
|
||||||
border: 1px solid var(--color-border-default);
|
border: 1px solid var(--color-border-default);
|
||||||
color: var(--color-content-primary);
|
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,
|
.ui-input,
|
||||||
@@ -537,7 +592,9 @@ button {
|
|||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
text-align: left;
|
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);
|
color: var(--color-text-muted);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
@@ -632,8 +689,14 @@ button {
|
|||||||
|
|
||||||
/* Drawer enter from right */
|
/* Drawer enter from right */
|
||||||
@keyframes slideInRightDrawer {
|
@keyframes slideInRightDrawer {
|
||||||
from { transform: translateX(100%); opacity: 0.6; }
|
from {
|
||||||
to { transform: translateX(0); opacity: 1; }
|
transform: translateX(100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.animate-slide-in-right {
|
.animate-slide-in-right {
|
||||||
animation: slideInRightDrawer 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: slideInRightDrawer 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
@@ -641,11 +704,20 @@ button {
|
|||||||
|
|
||||||
/* Skeleton shimmer */
|
/* Skeleton shimmer */
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% {
|
||||||
100% { background-position: 200% 0; }
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.animate-shimmer {
|
.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%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -97,7 +97,8 @@ export const desktopApi: DesktopApi = {
|
|||||||
connectionId,
|
connectionId,
|
||||||
retainForwards: options?.retainForwards ?? false,
|
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 }),
|
sshExecCancel: (connectionId) => call('ssh_exec_cancel', { connectionId }),
|
||||||
sshHostMetrics: (connectionId) => call('ssh_host_metrics', { connectionId }),
|
sshHostMetrics: (connectionId) => call('ssh_host_metrics', { connectionId }),
|
||||||
sshKeepalive: (connectionId) => call('ssh_keepalive', { connectionId }),
|
sshKeepalive: (connectionId) => call('ssh_keepalive', { connectionId }),
|
||||||
@@ -125,6 +126,14 @@ export const desktopApi: DesktopApi = {
|
|||||||
return { success: false, error: String(error) }
|
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) => {
|
localWrite: async (shellId, data) => {
|
||||||
await call('local_write', { shellId, data: bytes(data) })
|
await call('local_write', { shellId, data: bytes(data) })
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -137,6 +146,14 @@ export const desktopApi: DesktopApi = {
|
|||||||
await call('local_kill', { shellId })
|
await call('local_kill', { shellId })
|
||||||
return { success: true }
|
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) =>
|
onLocalData: (callback) =>
|
||||||
addListener<{ shellId: string; data: number[] }>('local:data', callback as Listener, (payload) =>
|
addListener<{ shellId: string; data: number[] }>('local:data', callback as Listener, (payload) =>
|
||||||
callback({} as never, 'local', payload.shellId, new Uint8Array(payload.data) as never)
|
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 }),
|
sessionCreate: (session) => call('session_create', { session }),
|
||||||
sessionUpdate: (id, updates) => call('session_update', { id, updates }),
|
sessionUpdate: (id, updates) => call('session_update', { id, updates }),
|
||||||
sessionDelete: (id) => call('session_delete', { id }),
|
sessionDelete: (id) => call('session_delete', { id }),
|
||||||
|
sessionReorder: (updates) => call('session_reorder', { updates }),
|
||||||
|
|
||||||
sftpConnect: (connectionId) => call('sftp_connect', { connectionId }),
|
sftpConnect: (connectionId) => call('sftp_connect', { connectionId }),
|
||||||
sftpDisconnect: (sftpId) => call('sftp_disconnect', { sftpId }),
|
sftpDisconnect: (sftpId) => call('sftp_disconnect', { sftpId }),
|
||||||
@@ -174,6 +192,8 @@ export const desktopApi: DesktopApi = {
|
|||||||
licenseActivate: (token, email) => call('license_activate', { token, email }),
|
licenseActivate: (token, email) => call('license_activate', { token, email }),
|
||||||
licenseClear: () => call('license_clear'),
|
licenseClear: () => call('license_clear'),
|
||||||
licenseDeviceId: () => call('license_device_id'),
|
licenseDeviceId: () => call('license_device_id'),
|
||||||
|
// 周检:联网重验在线授权并刷新缓存 // Logan.Li
|
||||||
|
licenseVerify: () => call('license_verify'),
|
||||||
licenseSetEmail: (email) => call('license_set_email', { email }),
|
licenseSetEmail: (email) => call('license_set_email', { email }),
|
||||||
licenseCheckout: (input) =>
|
licenseCheckout: (input) =>
|
||||||
call('license_checkout', {
|
call('license_checkout', {
|
||||||
@@ -286,8 +306,7 @@ export const desktopApi: DesktopApi = {
|
|||||||
onMenuExportSessions: (callback) => addListener('menu:export-sessions', callback, callback),
|
onMenuExportSessions: (callback) => addListener('menu:export-sessions', callback, callback),
|
||||||
onMenuOpenSettings: (callback) => addListener('menu:open-settings', callback, callback),
|
onMenuOpenSettings: (callback) => addListener('menu:open-settings', callback, callback),
|
||||||
onSshLinkAvailable: (callback) => addListener('app:ssh-link-available', callback, callback),
|
onSshLinkAvailable: (callback) => addListener('app:ssh-link-available', callback, callback),
|
||||||
onProLink: (callback) =>
|
onProLink: (callback) => addListener<string>('app:pro-link', callback as Listener, (payload) => callback(payload)),
|
||||||
addListener<string>('app:pro-link', callback as Listener, (payload) => callback(payload)),
|
|
||||||
removeListener: (channel, callback) => removeListener(channel, callback),
|
removeListener: (channel, callback) => removeListener(channel, callback),
|
||||||
removeAllListeners,
|
removeAllListeners,
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-35
@@ -54,6 +54,29 @@ function baseUrl(): string {
|
|||||||
return licenseServerBaseUrl().replace(/\/$/, '')
|
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> {
|
async function parseJson(res: Response): Promise<unknown> {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
@@ -67,9 +90,7 @@ async function parseJson(res: Response): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
|
||||||
? (value as Record<string, unknown>)
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapData<T>(payload: unknown): T {
|
function unwrapData<T>(payload: unknown): T {
|
||||||
@@ -78,10 +99,7 @@ function unwrapData<T>(payload: unknown): T {
|
|||||||
throw new Error('Unexpected license API response')
|
throw new Error('Unexpected license API response')
|
||||||
}
|
}
|
||||||
if (typeof root.code === 'number' && root.code !== 0) {
|
if (typeof root.code === 'number' && root.code !== 0) {
|
||||||
const message =
|
const message = typeof root.message === 'string' && root.message ? root.message : `License API error (${root.code})`
|
||||||
typeof root.message === 'string' && root.message
|
|
||||||
? root.message
|
|
||||||
: `License API error (${root.code})`
|
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
if ('data' in root && root.data !== undefined && root.data !== null) {
|
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> {
|
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)
|
const payload = await parseJson(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
(root && typeof root.message === 'string' && root.message) ||
|
(root && typeof root.message === 'string' && root.message) || `Product fetch failed (${res.status})`
|
||||||
`Product fetch failed (${res.status})`
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return unwrapData<ProductWithPlans>(payload)
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -151,8 +168,7 @@ export async function checkout(input: {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
(root && typeof root.message === 'string' && root.message) ||
|
(root && typeof root.message === 'string' && root.message) || `Checkout unavailable (${res.status})`
|
||||||
`Checkout unavailable (${res.status})`
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const data = asRecord(unwrapData(payload)) || {}
|
const data = asRecord(unwrapData(payload)) || {}
|
||||||
@@ -175,7 +191,7 @@ export async function claimPurchase(input: {
|
|||||||
orderNo?: string
|
orderNo?: string
|
||||||
deviceId: string
|
deviceId: string
|
||||||
}): Promise<ClaimResult> {
|
}): Promise<ClaimResult> {
|
||||||
const res = await fetch(`${baseUrl()}/v1/purchase/claim`, {
|
const res = await fetchWithFallback(`/v1/purchase/claim`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -188,10 +204,7 @@ export async function claimPurchase(input: {
|
|||||||
const payload = await parseJson(res)
|
const payload = await parseJson(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
throw new Error(
|
throw new Error((root && typeof root.message === 'string' && root.message) || `Claim failed (${res.status})`)
|
||||||
(root && typeof root.message === 'string' && root.message) ||
|
|
||||||
`Claim failed (${res.status})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const data = asRecord(unwrapData(payload)) || {}
|
const data = asRecord(unwrapData(payload)) || {}
|
||||||
const licenseKey = pickString(data, 'license_key', 'licenseKey')
|
const licenseKey = pickString(data, 'license_key', 'licenseKey')
|
||||||
@@ -223,7 +236,7 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
|
|||||||
maxDevices: number
|
maxDevices: number
|
||||||
used: number
|
used: number
|
||||||
}> {
|
}> {
|
||||||
const res = await fetch(`${baseUrl()}/v1/license/devices`, {
|
const res = await fetchWithFallback(`/v1/license/devices`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ license_key: licenseKey }),
|
body: JSON.stringify({ license_key: licenseKey }),
|
||||||
@@ -231,10 +244,7 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
|
|||||||
const payload = await parseJson(res)
|
const payload = await parseJson(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
throw new Error(
|
throw new Error((root && typeof root.message === 'string' && root.message) || `List devices failed (${res.status})`)
|
||||||
(root && typeof root.message === 'string' && root.message) ||
|
|
||||||
`List devices failed (${res.status})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const data = asRecord(unwrapData(payload)) || {}
|
const data = asRecord(unwrapData(payload)) || {}
|
||||||
const rawDevices = Array.isArray(data.devices) ? data.devices : []
|
const rawDevices = Array.isArray(data.devices) ? data.devices : []
|
||||||
@@ -262,11 +272,8 @@ export async function listLicenseDevices(licenseKey: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivateLicenseDevice(
|
export async function deactivateLicenseDevice(licenseKey: string, deviceId: string): Promise<void> {
|
||||||
licenseKey: string,
|
const res = await fetchWithFallback(`/v1/license/deactivate`, {
|
||||||
deviceId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await fetch(`${baseUrl()}/v1/license/deactivate`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -277,16 +284,11 @@ export async function deactivateLicenseDevice(
|
|||||||
const payload = await parseJson(res)
|
const payload = await parseJson(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
throw new Error(
|
throw new Error((root && typeof root.message === 'string' && root.message) || `Deactivate failed (${res.status})`)
|
||||||
(root && typeof root.message === 'string' && root.message) ||
|
|
||||||
`Deactivate failed (${res.status})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const root = asRecord(payload)
|
const root = asRecord(payload)
|
||||||
if (root && typeof root.code === 'number' && root.code !== 0) {
|
if (root && typeof root.code === 'number' && root.code !== 0) {
|
||||||
throw new Error(
|
throw new Error((typeof root.message === 'string' && root.message) || 'Deactivate failed')
|
||||||
(typeof root.message === 'string' && root.message) || 'Deactivate failed'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,19 +48,17 @@ export async function createTauriLocalShell(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeTauriLocalShell(
|
export async function openTauriLocalShellOutput(shellId: string): Promise<Uint8Array> {
|
||||||
shellId: string,
|
const data = await invoke<number[]>('local_open_output', { shellId })
|
||||||
data: string | Uint8Array
|
return new Uint8Array(data ?? [])
|
||||||
): Promise<void> {
|
}
|
||||||
|
|
||||||
|
export async function writeTauriLocalShell(shellId: string, data: string | Uint8Array): Promise<void> {
|
||||||
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
|
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
|
||||||
await invoke('local_write', { shellId, data: Array.from(bytes) })
|
await invoke('local_write', { shellId, data: Array.from(bytes) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resizeTauriLocalShell(
|
export async function resizeTauriLocalShell(shellId: string, cols: number, rows: number): Promise<void> {
|
||||||
shellId: string,
|
|
||||||
cols: number,
|
|
||||||
rows: number
|
|
||||||
): Promise<void> {
|
|
||||||
await invoke('local_resize', { shellId, cols, rows })
|
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