feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13151
-3081
No files matched your search
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npm run:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(npm config:*)",
|
|
||||||
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install electron --save-dev)",
|
|
||||||
"Bash(pkill -f \"electron\")",
|
|
||||||
"Bash(pkill -f \"vite\")",
|
|
||||||
"Bash(chmod +x:*)",
|
|
||||||
"Bash(./start.sh:*)",
|
|
||||||
"Bash(pkill -f \"electron.*opsxshell\")",
|
|
||||||
"Bash(pkill -f \"vite.*5173\")",
|
|
||||||
"Bash(npx @electron/rebuild -f -w better-sqlite3)",
|
|
||||||
"Bash(npx:*)",
|
|
||||||
"Bash(lsof -i:5173)",
|
|
||||||
"Bash(pkill -f \"esbuild\")",
|
|
||||||
"Bash(npm search:*)",
|
|
||||||
"Bash(npm view:*)",
|
|
||||||
"WebFetch(domain:127.0.0.1)",
|
|
||||||
"Bash(npm rebuild:*)",
|
|
||||||
"Bash(ls -la /Users/loganli/devops/desktop-ui/opsxshell/*.md)",
|
|
||||||
"Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx)",
|
|
||||||
"Bash(npm test:*)",
|
|
||||||
"Bash(pkill -f \"Electron\")",
|
|
||||||
"Bash(./run.sh)",
|
|
||||||
"Bash(pkill -f \"node.*5173\")",
|
|
||||||
"Bash(curl -s http://localhost:5173)",
|
|
||||||
"Bash(pkill -f electron)",
|
|
||||||
"Bash(pkill -f vite)",
|
|
||||||
"Bash(curl -s http://localhost:9223/json)",
|
|
||||||
"Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(f'{i[\\\\\"title\\\\\"]}: {i[\\\\\"url\\\\\"]}'\\) for i in data]\")",
|
|
||||||
"Bash(./run.sh restart:*)",
|
|
||||||
"Read(//tmp/**)",
|
|
||||||
"Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx /Users/loganli/devops/desktop-ui/opsxshell/src/stores/*.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/main.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/services/*.ts)",
|
|
||||||
"Bash(git pull *)",
|
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git checkout *)",
|
|
||||||
"Bash(git add *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-1
@@ -52,5 +52,6 @@ src-tauri/target/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# ZCode workspace
|
# Local agent / IDE workspace
|
||||||
.zcode/
|
.zcode/
|
||||||
|
.claude/
|
||||||
@@ -5,7 +5,7 @@ OpsXShell 是基于 Tauri 2、Rust、React 和 TypeScript 的轻量运维终端
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
- `src/`:React 界面、Zustand 状态和 XTerm.js 终端。
|
- `src/`:React 界面、Zustand 状态和 XTerm.js 终端。
|
||||||
- `src/i18n/`:界面文案(zh-CN / en);新增可见字符串必须双语同步,并用 `test/i18n/localeKeys.test.ts` 校验 key 对齐。
|
- `src/i18n/`:界面文案(zh-CN / zh-TW / en / ja / ko / vi / ru);新增可见字符串必须同步全部语种,并用 `test/i18n/localeKeys.test.ts` 校验 key 对齐。
|
||||||
- `src/platform/`:唯一的 Tauri 命令与事件适配入口。
|
- `src/platform/`:唯一的 Tauri 命令与事件适配入口。
|
||||||
- `src/platform/sftp.ts`:固定 `localPath`/`remotePath` 方向,区分远程文本读写与文件传输。
|
- `src/platform/sftp.ts`:固定 `localPath`/`remotePath` 方向,区分远程文本读写与文件传输。
|
||||||
- `src/platform/rdp.ts` / `src/platform/vnc.ts`:远程桌面会话适配;运行态只在原生侧内存中。
|
- `src/platform/rdp.ts` / `src/platform/vnc.ts`:远程桌面会话适配;运行态只在原生侧内存中。
|
||||||
@@ -14,11 +14,22 @@ OpsXShell 是基于 Tauri 2、Rust、React 和 TypeScript 的轻量运维终端
|
|||||||
- `src-tauri/src/services/`:SSH、SFTP、隧道、PTY、串口、桌面、存储和凭据服务。转发 Listener 必须由 `TunnelManager` 统一持有和取消。
|
- `src-tauri/src/services/`:SSH、SFTP、隧道、PTY、串口、桌面、存储和凭据服务。转发 Listener 必须由 `TunnelManager` 统一持有和取消。
|
||||||
- `src-tauri/src/services/ssh/`:认证、known_hosts、代理/跳板、路径和 SFTP,禁止重新堆回 `ssh.rs`。
|
- `src-tauri/src/services/ssh/`:认证、known_hosts、代理/跳板、路径和 SFTP,禁止重新堆回 `ssh.rs`。
|
||||||
- `src-tauri/src/services/tunnel/`:运行模型、SOCKS5 协议和 Relay;运行态只能由 `TunnelManager` 持有。
|
- `src-tauri/src/services/tunnel/`:运行模型、SOCKS5 协议和 Relay;运行态只能由 `TunnelManager` 持有。
|
||||||
- `src-tauri/src/services/desktop/`:RDP(IronRDP)与 VNC(vnc-rs);帧、输入、剪贴板经 Tauri 事件交给 Canvas,断开即取消 worker。
|
- `src-tauri/src/services/desktop/`:RDP(IronRDP)与 VNC(vnc-rs);帧、输入、剪贴板经 Tauri 事件交给 Canvas,断开即取消 worker。RDP 批量压缩优先 RDP5(MPPC/K64),避免 XCRUSH 在分辨率切换后解压失败。
|
||||||
- `shared/types/`:渲染层共享 TypeScript 契约。
|
- `shared/types/`:渲染层共享 TypeScript 契约。
|
||||||
- `test/`:Vitest 基础测试与验收。
|
- `test/`:Vitest 单元/组件测试与基础巡检(`test/patrol/`:SSH / SFTP / RDP / VNC);改功能后优先 `npm run test:patrol`。
|
||||||
|
- `scripts/`:本地启动、验收、版本同步与原生安装包构建/发布脚本(入口 `./run.sh`、`./scripts/build.sh`)。
|
||||||
|
- `docs/`:使用、架构、验收与发布文档;路线图与任务见根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`。
|
||||||
|
- `license-server/`:授权与结账服务(独立 Node 包)。
|
||||||
|
|
||||||
React 不直接访问 SSH、文件系统或加密实现。新增原生能力时先定义 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`。
|
||||||
|
|
||||||
|
## 怎么扩展
|
||||||
|
|
||||||
|
1. 领域契约:先改 `shared/types/` 或 Rust domain,再改 command。
|
||||||
|
2. 原生实现:能力放 `src-tauri/src/services/`,command 只做参数边界和统一响应。
|
||||||
|
3. 渲染适配:只经 `src/platform/`;组件和 store 不直接 `invoke`。
|
||||||
|
4. 界面:Zustand 持有资源 ID 与 UI 状态;可见文案同步七语种。
|
||||||
|
5. 测试:功能改动优先 `npm run test:patrol`,再补对应单测。
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
@@ -30,7 +41,9 @@ React 不直接访问 SSH、文件系统或加密实现。新增原生能力时
|
|||||||
- 保持常用流程简短,高级配置默认收起。
|
- 保持常用流程简短,高级配置默认收起。
|
||||||
- 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。
|
- 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。
|
||||||
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
|
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
|
||||||
|
- SSH 断开 / keepalive 失败 / 空闲挂起 / 同窗重连:未点「停止」的转发 Listener 应保留;按 `user@host:port` 指纹 orphan-adopt;同窗用进程内 `forwardIntent` 自动恢复;关标签不得自动再启。空闲挂起应先断开原生会话并 `retainForwards`,避免僵尸 TCP。
|
||||||
- 动态转发(SOCKS5)默认仅本机 `127.0.0.1:1080`;流量经当前 SSH 服务器出口 IP 访问外网。每台 SSH 主机仅保留一条动态规则。状态栏「转发」打开抽屉管理,右键复制 `socks5://127.0.0.1:1080`。
|
- 动态转发(SOCKS5)默认仅本机 `127.0.0.1:1080`;流量经当前 SSH 服务器出口 IP 访问外网。每台 SSH 主机仅保留一条动态规则。状态栏「转发」打开抽屉管理,右键复制 `socks5://127.0.0.1:1080`。
|
||||||
|
- 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。
|
||||||
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
|
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
|
||||||
- RDP / VNC 仅在 Tauri 原生应用中可用;会话运行态不落盘,断开即清理。批量命令排除桌面会话。
|
- RDP / VNC 仅在 Tauri 原生应用中可用;会话运行态不落盘,断开即清理。批量命令排除桌面会话。
|
||||||
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
|
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
|
||||||
@@ -40,10 +53,18 @@ React 不直接访问 SSH、文件系统或加密实现。新增原生能力时
|
|||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
./run.sh # 开发启动(scripts/run.sh)
|
||||||
|
npm run test:patrol
|
||||||
npm run acceptance:quick
|
npm run acceptance:quick
|
||||||
npm run acceptance:full
|
npm run acceptance:full
|
||||||
npx tauri build --bundles app
|
./scripts/build.sh # 当前平台安装包(macOS → .app + .dmg)
|
||||||
```
|
```
|
||||||
|
|
||||||
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。
|
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。
|
||||||
|
|
||||||
|
## 评审边界
|
||||||
|
|
||||||
|
- `src/platform/` 与 Tauri command 是本应用内部边界,不是给第三方调用的公共 API。
|
||||||
|
- `license-server/` 独立于客户端;不要把结账/授权服务逻辑堆回桌面进程。
|
||||||
|
- 不把 CLI、MCP、插件宿主或堡垒机审批做成默认能力。
|
||||||
|
- 转发 Listener、SSH 会话、RDP/VNC worker 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
|
||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
请使用中文沟通。项目当前架构为 Tauri 2 + Rust + React + TypeScript,开发规则和命令以 [AGENTS.md](./AGENTS.md) 为准。
|
请使用中文沟通。项目当前架构为 Tauri 2 + Rust + React + TypeScript,开发规则和命令以 [AGENTS.md](./AGENTS.md) 为准。
|
||||||
|
|
||||||
核心产品范围:SSH、SFTP、HTTP/SOCKS5 代理、端口转发(本地 / 远程 / 动态 SOCKS5)、RDP / VNC 远程桌面、串口、本地终端、多标签、分屏、批量命令,以及可选的终端 AI Agent。界面文案走 `src/i18n`(zh-CN / en)。
|
核心产品范围:SSH、SFTP、HTTP/SOCKS5 代理、端口转发(本地 / 远程 / 动态 SOCKS5)、RDP / VNC 远程桌面、串口、本地终端、多标签、分屏、批量命令,以及可选的终端 AI Agent。界面文案走 `src/i18n`(多语言;key 对齐用 `test/i18n/localeKeys.test.ts`)。
|
||||||
|
|
||||||
不要恢复旧桌面构建链、终端命令历史或堡垒机审计能力。RDP / VNC 是原生 IronRDP / vnc-rs 会话,不是旧 Electron 桌面壳。
|
## 目录要点
|
||||||
|
|
||||||
|
- `src/`:React UI;`src-tauri/`:Rust / Tauri 原生后端
|
||||||
|
- `shared/`:前后端共享 TypeScript 契约
|
||||||
|
- `test/`:Vitest;基础巡检在 `test/patrol/`(优先 `npm run test:patrol`)
|
||||||
|
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`)
|
||||||
|
- `docs/`:使用 / 架构 / 验收 / 发布
|
||||||
|
- 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`
|
||||||
|
- `license-server/`:授权与结账服务(独立包)
|
||||||
|
|
||||||
|
数据流:UI → Zustand → `src/platform/*` → Tauri command → Rust 服务。组件不直接 `invoke`,也不直接碰 SSH / 文件系统 / 加密。
|
||||||
|
|
||||||
|
## 与 AGENTS.md 的关系
|
||||||
|
|
||||||
|
- **AGENTS.md**:完整开发规则(安全、转发、SFTP、RDP、AI、扩展步骤、命令)。
|
||||||
|
- **CLAUDE.md**:本文件,给 Claude/Cursor 的短入口;细节一律以 AGENTS.md 为准,避免双份漂移。
|
||||||
|
|
||||||
|
不要恢复旧桌面构建链、终端命令历史或堡垒机审计能力。RDP / VNC 是原生 IronRDP / vnc-rs 会话,不是旧 Electron 桌面壳。转发运行态不落盘;未点停止的 Listener 在同窗重连时可 retain / orphan-adopt。
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# OpsXShell
|
# OpsXShell
|
||||||
|
|
||||||
轻量原生的跨平台运维终端。产品目标是让常用远程操作更直接,不做审计平台,也不依赖插件才能完成基础工作。
|
简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。
|
||||||
|
|
||||||
## 已支持
|
## 已支持
|
||||||
|
|
||||||
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
|
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
|
||||||
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面中英双语(zh-CN / en)
|
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
|
||||||
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC)
|
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC)
|
||||||
- SFTP:双栏文件管理、断点续传、完整后原子替换、重命名、权限、远程文本编辑
|
- SFTP:双栏、中间传输箭头、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限,超限/二进制友好提示)
|
||||||
- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网
|
- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网
|
||||||
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
|
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
|
||||||
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
|
|
||||||
## 产品边界
|
## 产品边界
|
||||||
|
|
||||||
OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内。
|
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
|
||||||
|
|
||||||
当前仍需补齐的专业能力:
|
仍在打磨、不影响「先用起来」的方向:
|
||||||
|
|
||||||
- Agent Forwarding、OpenSSH 证书与 FIDO2
|
- Agent Forwarding、OpenSSH 证书与 FIDO2
|
||||||
- 断网重连、休眠恢复和大量标签切换的持续压测
|
- 断网重连、休眠恢复和大量标签切换的持续压测
|
||||||
@@ -42,7 +42,7 @@ OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终
|
|||||||
| 前端 | React 18 + TypeScript 5 + Vite 5 |
|
| 前端 | React 18 + TypeScript 5 + Vite 5 |
|
||||||
| 状态管理 | Zustand 4.4+ |
|
| 状态管理 | Zustand 4.4+ |
|
||||||
| 终端 | XTerm.js 5.5+ |
|
| 终端 | XTerm.js 5.5+ |
|
||||||
| 国际化 | i18next(zh-CN / en) |
|
| 国际化 | i18next(zh-CN / zh-TW / en / ja / ko / vi / ru) |
|
||||||
| 测试 | Vitest + Rust test |
|
| 测试 | Vitest + Rust test |
|
||||||
|
|
||||||
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
|
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
|
||||||
@@ -61,32 +61,27 @@ npm run dev
|
|||||||
```bash
|
```bash
|
||||||
npm run acceptance:quick
|
npm run acceptance:quick
|
||||||
npm run acceptance:full
|
npm run acceptance:full
|
||||||
./build.sh v2.1.1 mac-arm64
|
./scripts/build.sh v2.1.1 mac-arm64
|
||||||
build.bat v2.1.1 win-x64
|
scripts\build.bat v2.1.1 win-x64
|
||||||
./release.py v2.1.1 --dry-run
|
./scripts/release.py v2.1.1 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh`(macOS/Git Bash)和 `build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
`scripts/build.sh`(macOS/Git Bash)和 `scripts/build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`scripts/release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/ React 界面与状态管理
|
src/ React 界面与状态管理
|
||||||
src/i18n/ 界面文案(zh-CN / en)
|
src/i18n/ 界面文案(多语言)
|
||||||
src/platform/ Tauri 命令和事件适配层
|
src/platform/ Tauri 命令和事件适配层
|
||||||
src/platform/sftp.ts 本地/远程路径强类型传输边界
|
|
||||||
src/platform/rdp.ts RDP 会话适配
|
|
||||||
src/platform/vnc.ts VNC 会话适配
|
|
||||||
src/utils/terminalPipeline.ts 终端输入、输出和背压管线
|
|
||||||
shared/types/ 前后端共享 TypeScript 契约
|
shared/types/ 前后端共享 TypeScript 契约
|
||||||
src-tauri/src/commands/ 精简的 Tauri 命令边界
|
src-tauri/ Rust / Tauri 原生后端
|
||||||
src-tauri/src/domain/ 原生领域模型
|
test/ 前端单元、组件与巡检测试
|
||||||
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、桌面、AI、存储服务
|
test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
|
||||||
src-tauri/src/services/ssh/ 认证、known_hosts、代理/跳板和 SFTP 子模块
|
scripts/ 启动、验收、构建与发布脚本
|
||||||
src-tauri/src/services/tunnel/ 转发运行模型、SOCKS5 协议和双向 Relay
|
docs/ 使用 / 架构 / 验收 / 发布文档
|
||||||
src-tauri/src/services/desktop/ RDP(IronRDP)与 VNC(vnc-rs)
|
docs/assets/ 品牌与设计资源
|
||||||
test/ 前端测试与基础验收
|
license-server/ 授权与结账服务
|
||||||
scripts/ 构建和验收脚本
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
@@ -105,9 +100,11 @@ scripts/ 构建和验收脚本
|
|||||||
- [使用指南](docs/user-guide.md)
|
- [使用指南](docs/user-guide.md)
|
||||||
- [架构说明](docs/architecture.md)
|
- [架构说明](docs/architecture.md)
|
||||||
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
||||||
- [产品体验与基础能力评估](docs/ux-foundation-audit.md)
|
|
||||||
- [测试与验收](docs/testing-acceptance.md)
|
- [测试与验收](docs/testing-acceptance.md)
|
||||||
- [发布指南](docs/release.md)
|
- [发布指南](docs/release.md)
|
||||||
|
- [路线图](ROADMAP.md)
|
||||||
|
- [当前任务](TASKS.md)
|
||||||
|
- [测试计划](TDD_PLAN.md)
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+27
-31
@@ -1,38 +1,34 @@
|
|||||||
# OpsXShell Roadmap
|
# OpsXShell 路线图
|
||||||
|
|
||||||
## 已完成基线
|
> 最后更新:2026-08-16
|
||||||
|
> 版本基线:v2.1.1(Tauri 2 原生)
|
||||||
|
> 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。
|
||||||
|
|
||||||
- Rust `TunnelManager`:本地、远程和动态 SOCKS5 转发拥有独立 ID、真实启停、连接清理和运行状态
|
## 产品定位
|
||||||
- 转发规则按保存会话持久化,不持久化运行中状态
|
|
||||||
- SOCKS5 一键启动、代理地址复制、活动连接数和上下行字节统计
|
|
||||||
- 转发规则的创建、编辑、复制、删除与运行实例完全分离
|
|
||||||
- `TerminalPipeline` 统一输入错误、UTF-8 分片解码、输出背压和流量统计
|
|
||||||
- SFTP 本地/远程路径使用不同类型;read/write 与 upload/download 使用不同接口
|
|
||||||
- `ssh.rs` 已拆出认证、known_hosts、代理/ProxyJump、路径和 SFTP 模块
|
|
||||||
- `tunnel.rs` 已拆出运行模型、SOCKS5 协议与 Relay 模块
|
|
||||||
- 注册 `ssh://` 系统链接,支持冷启动队列和运行中单实例唤起
|
|
||||||
- SSH Agent CLI 单任务入口、8 步执行观察循环、三级安全模式、五级风险、critical 硬拒绝、输出分析和重复命令拦截
|
|
||||||
- 全终端广播、批量主机选择与执行进度、终端标准右键菜单和远端单 Shell 关闭
|
|
||||||
- 远程 Shell EOF/TMOUT 与 SSH 连接生命周期分离,支持单分屏一键重开
|
|
||||||
|
|
||||||
## 当前重点
|
轻量运维终端:连上就能用 SSH、SFTP、转发、RDP/VNC、串口和本地终端。短路径、好用实用。不做审计、录像、审批或堡垒机。
|
||||||
|
|
||||||
- SSH/SFTP/PTY/串口稳定性和断网恢复
|
## 当前基线(已落地,不要重做)
|
||||||
- 隧道专用 SSH 连接复用、保活、断线重建和可选自动启动
|
|
||||||
- 继续抽离 SSH connection/shell 生命周期,并为隧道增加专用连接 Multiplexer
|
|
||||||
- 完成 Windows 安装包的 `ssh://` 注册与运行中单实例实机验收
|
|
||||||
- 从 URL、IP 和远程路径识别中抽取安全点击策略
|
|
||||||
- Agent Forwarding、OpenSSH 证书和 FIDO2
|
|
||||||
- SFTP 内容校验和、显式冲突选择及任务恢复
|
|
||||||
- Windows x86_64 实机安装与功能验收
|
|
||||||
- Agent Runtime 从 React 面板抽离,补齐取消、单步超时、Rust 硬红线和原生 tool calling
|
|
||||||
- 建立主机排障 Agent eval 集,覆盖非零退出、连续失败、循环、Prompt Injection 和敏感输出
|
|
||||||
|
|
||||||
## 发布闭环
|
- SSH:密码 / 私钥 / Agent、known_hosts、HTTP·SOCKS 代理、最多 8 跳 ProxyJump、`ssh://` 有界确认队列
|
||||||
|
- 工作区:多标签、分屏、广播、批量命令(排除桌面会话)、七语种 i18n
|
||||||
|
- SFTP:双栏、拖拽、断点、原子替换、远程文本编辑(约 2MB)
|
||||||
|
- 转发:本地 / 远程 / 动态 SOCKS5;规则可持久化,运行态不落盘;`TunnelManager` 持有 Listener
|
||||||
|
- 转发恢复:未点停止则 retain;`user@host:port` orphan-adopt;同窗 `forwardIntent`;关标签不自动再启;空闲挂起先断原生并 `retainForwards`
|
||||||
|
- 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存
|
||||||
|
- 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards
|
||||||
|
- AI Agent:当前 SSH connection、strict/relaxed、critical 硬拒绝、不落命令历史
|
||||||
|
- 打包:macOS arm64 `.dmg` 可由 `./scripts/build.sh` 产出(无签名凭据时仅为 ad-hoc,未公证)
|
||||||
|
|
||||||
- macOS Developer ID 签名与公证
|
## 下一阶段
|
||||||
- Windows Authenticode 签名
|
|
||||||
- 带签名清单和回滚策略的自动更新
|
|
||||||
- 可由用户主动导出的脱敏崩溃诊断包
|
|
||||||
|
|
||||||
审计、录像、审批和命令历史不在产品范围内。
|
1. **可靠性**:断网 / 休眠 / 大量标签切换的持续压测;keepalive 失败原因不要盖成「用户关闭」;SFTP 与终端共享连接时的有限自动重连
|
||||||
|
2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复
|
||||||
|
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
|
||||||
|
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
|
||||||
|
5. **发布闭环**:Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新;用户可导出的脱敏崩溃包
|
||||||
|
6. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出)
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
审计、录像、审批、命令历史、插件宿主、对外 MCP/CLI 公共 API、把 `license-server` 堆回桌面进程。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# OpsXShell 当前任务
|
||||||
|
|
||||||
|
> 最后更新:2026-08-16
|
||||||
|
> 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。
|
||||||
|
|
||||||
|
## 本周期已完成(勿回退)
|
||||||
|
|
||||||
|
- [x] 本地/远程转发:端口优先 + 高级绑定;远程默认 `127.0.0.1`;`0.0.0.0` 提示 GatewayPorts
|
||||||
|
- [x] 转发 retain / orphan-adopt / `forwardIntent`;空闲挂起断开原生并 retain Listener
|
||||||
|
- [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab
|
||||||
|
- [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出
|
||||||
|
- [x] `AGENTS.md` / `CLAUDE.md` 对齐当前架构与命令
|
||||||
|
- [x] macOS arm64 v2.1.1 安装包(ad-hoc,未公证)
|
||||||
|
- [x] 删除过时 `docs/ux-foundation-audit.md`
|
||||||
|
|
||||||
|
## 现在可做
|
||||||
|
|
||||||
|
按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。
|
||||||
|
|
||||||
|
- [ ] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」
|
||||||
|
- [ ] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录)
|
||||||
|
- [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖
|
||||||
|
- [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」
|
||||||
|
- [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开
|
||||||
|
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
|
||||||
|
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新
|
||||||
|
|
||||||
|
## 稍后
|
||||||
|
|
||||||
|
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
|
||||||
|
- [ ] SFTP 校验和与失败任务恢复
|
||||||
|
- [ ] RDP/VNC 生产级证书校验
|
||||||
|
- [ ] macOS Developer ID + 公证;Windows Authenticode
|
||||||
|
- [ ] 自动更新(签名清单 + 回滚)
|
||||||
|
- [ ] 用户可导出的脱敏崩溃诊断包
|
||||||
|
- [ ] AI 执行运行时从 React 面板抽离(取消、单步超时、原生 tool calling)
|
||||||
|
- [ ] 主机排障 Agent eval 集
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不引入审计/审批/堡垒机。
|
||||||
|
- 未由 Rust 实现的能力不能在界面显示为可用。
|
||||||
|
- 可见字符串同步七语种;`test/i18n/localeKeys.test.ts` 必须过。
|
||||||
|
- 转发运行态不落盘;停止必须关掉真实 Listener。
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
# OpsXShell 测试计划
|
||||||
|
|
||||||
|
> 最后更新:2026-08-16
|
||||||
|
> 持续开发默认测试先行:先锁定失败用例或巡检,再改实现。门禁细节见 [docs/testing-acceptance.md](./docs/testing-acceptance.md)。
|
||||||
|
|
||||||
|
## 原则
|
||||||
|
|
||||||
|
- 改 SSH / SFTP / 转发 / RDP / VNC 后,先跑 `npm run test:patrol`。
|
||||||
|
- 渲染层只测 `src/platform` 契约与 UI 状态;真实 TCP/SSH 不进默认 Vitest。
|
||||||
|
- 原生新 command 必须同步 `src/desktop.d.ts`、`src/platform/*.ts`、`test/setup.ts`,否则 `typecheck:test` 应失败。
|
||||||
|
- 七语种 key 对齐是硬门槛:`test/i18n/localeKeys.test.ts`。
|
||||||
|
- 没有签名凭据的构建测试不得断言「已公证」。
|
||||||
|
|
||||||
|
## 日常命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:patrol # 基础巡检(优先)
|
||||||
|
npm run acceptance:quick # typecheck + 全量 Vitest + Rust fmt/check
|
||||||
|
npm run acceptance:full # 发包:再加 lint、clippy -D warnings、Rust test、生产构建
|
||||||
|
./scripts/build.sh # 当前平台安装包;内部会跑完整门禁
|
||||||
|
```
|
||||||
|
|
||||||
|
真实探针(需显式主机,默认 ignored):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPSX_TEST_SSH_HOST=… OPSX_TEST_SSH_PORT=… OPSX_TEST_SSH_USER=… \
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml real_ssh_exec_and_sftp_probe -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动化分层
|
||||||
|
|
||||||
|
| 层 | 路径 | 覆盖 |
|
||||||
|
|----|------|------|
|
||||||
|
| 巡检 | `test/patrol/` | SSH 开终端、SFTP 双栏/拖拽/冲突、RDP·VNC 对话框、平台契约 |
|
||||||
|
| 单元 | `test/`、`src-tauri` tests | 转发规则、forwardIntent、resume 回写 tab、错误文案映射、隧道 orphan |
|
||||||
|
| 门禁 | `scripts/acceptance.mjs` | ESLint、tsc、Vitest、rustfmt、clippy、cargo test |
|
||||||
|
| 发布 | `scripts/build.sh`、`scripts/release.py --dry-run` | 产物、SHA-256;不自动宣称签名 |
|
||||||
|
|
||||||
|
## 下一任务对应的测试(先写再改)
|
||||||
|
|
||||||
|
1. **断开原因**
|
||||||
|
keepalive 失败 → 事件/文案不是「用户关闭」。覆盖 `sshDisconnect` reason 与终端/SFTP 回收。
|
||||||
|
|
||||||
|
2. **SFTP 自动重连**
|
||||||
|
mock `onConnectionClosed` 后活跃 SFTP tab 有限次重连,并 `loadRemoteDirectory(lastBrowsePath)`。
|
||||||
|
|
||||||
|
3. **远程文本冲突**
|
||||||
|
open 记 mtime;save 前 stat 变化 → 确认对话框;取消则不调用 write。
|
||||||
|
|
||||||
|
4. **目录冲突文案**
|
||||||
|
同名目录覆盖 vs 跳过;断言文案含「合并/内部同名」,且七语种 key 存在。
|
||||||
|
|
||||||
|
5. **远程 bind 展示**
|
||||||
|
请求 `0.0.0.0` 但运行态 bind 为 `127.0.0.1` 时,列表不得显示为全接口已开。
|
||||||
|
|
||||||
|
6. **RDP 缩放**
|
||||||
|
回归:K64 协商;DeactivateAll 后 pending 脏区被清空;解压失败不直接 Terminate。
|
||||||
|
|
||||||
|
## 发布门槛
|
||||||
|
|
||||||
|
1. 本项对应测试或巡检通过。
|
||||||
|
2. `npm run acceptance:full` 通过后再打安装包。
|
||||||
|
3. 平台包必须在目标 OS 实机点开验证;macOS 不能替代 Windows。
|
||||||
|
4. 无 `APPLE_*` / Authenticode 时,文档只写 ad-hoc / 未签名。
|
||||||
+4
-2
@@ -7,8 +7,10 @@
|
|||||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||||
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
||||||
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
||||||
| [产品体验全面评估](./ux-foundation-audit.md) | 全产品评分、大众操作习惯、P0/P1/P2 与统一回归清单 |
|
|
||||||
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
||||||
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
|
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
|
||||||
|
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
|
||||||
|
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
|
||||||
|
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
|
||||||
|
|
||||||
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP](../ROADMAP.md) 维护。
|
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP.md](../ROADMAP.md) / [TASKS.md](../TASKS.md) 维护。
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## 产品边界
|
## 产品边界
|
||||||
|
|
||||||
OpsXShell 是轻量原生的运维终端,核心范围是连接、终端、文件传输、端口转发、代理、RDP / VNC 远程桌面、串口和多会话操作。审计、录像、审批和堡垒机能力不在客户端范围内。
|
OpsXShell 是简单好用的原生运维终端:短路径完成连接、终端、传文件、转发和远程桌面。审计、录像、审批和堡垒机能力不在客户端范围内。
|
||||||
|
|
||||||
## 目录与职责
|
## 目录与职责
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/ React 界面、工作区和 Zustand 状态
|
src/ React 界面、工作区和 Zustand 状态
|
||||||
src/i18n/ 界面文案(zh-CN / en)与 locale 检测
|
src/i18n/ 界面文案(zh-CN / zh-TW / en / ja / ko / vi / ru)与 locale 检测
|
||||||
src/platform/ Tauri command/event 适配层
|
src/platform/ Tauri command/event 适配层
|
||||||
shared/types/ 渲染层 TypeScript 契约
|
shared/types/ 渲染层 TypeScript 契约
|
||||||
src-tauri/src/commands/ 参数边界和统一响应
|
src-tauri/src/commands/ 参数边界和统一响应
|
||||||
@@ -65,7 +65,7 @@ services/
|
|||||||
| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` |
|
| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` |
|
||||||
| 主机信任 | OpenSSH `~/.ssh/known_hosts` |
|
| 主机信任 | OpenSSH `~/.ssh/known_hosts` |
|
||||||
| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 |
|
| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 |
|
||||||
| 界面语言 | i18next;zh-CN / en,首启按系统语言检测 |
|
| 界面语言 | i18next;zh-CN / zh-TW / en / ja / ko / vi / ru,首启按系统语言检测 |
|
||||||
|
|
||||||
## SSH Agent CLI 边界
|
## SSH Agent CLI 边界
|
||||||
|
|
||||||
@@ -106,9 +106,11 @@ XTerm 当前焦点分屏
|
|||||||
|
|
||||||
- `ForwardRule` 由 React 管理并按会话 / 标签 / 连接维度写入设置,只保存类型、端口和目标。
|
- `ForwardRule` 由 React 管理并按会话 / 标签 / 连接维度写入设置,只保存类型、端口和目标。
|
||||||
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
|
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
|
||||||
- 每个运行实例有唯一 `forwardId` 和取消标志。停止、删除正在运行的规则或断开 SSH 连接时,Rust 必须关闭真实 Listener。
|
- 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。
|
||||||
|
- 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。
|
||||||
|
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。
|
||||||
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
|
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
|
||||||
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。
|
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。
|
||||||
- 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。
|
- 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。
|
||||||
|
|
||||||
当前动态 SOCKS5 默认仅绑定 `127.0.0.1`,不对局域网暴露。客户端仅支持 SOCKS5 CONNECT 和无认证方式。流量经当前 SSH 服务器出口 IP 访问外网。
|
当前动态 SOCKS5 默认仅绑定 `127.0.0.1`,不对局域网暴露。客户端仅支持 SOCKS5 CONNECT 和无认证方式。流量经当前 SSH 服务器出口 IP 访问外网。
|
||||||
|
|||||||
File renamed without changes.
+8
-8
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 推荐:当前平台完整门禁、打包、验证和 SHA-256
|
# 推荐:当前平台完整门禁、打包、验证和 SHA-256
|
||||||
./build.sh
|
./scripts/build.sh
|
||||||
|
|
||||||
# macOS Apple Silicon 指定版本
|
# macOS Apple Silicon 指定版本
|
||||||
./build.sh v2.1.1 mac-arm64
|
./scripts/build.sh v2.1.1 mac-arm64
|
||||||
|
|
||||||
# Windows x86_64(推荐在 Windows CMD/PowerShell 中执行)
|
# Windows x86_64(推荐在 Windows CMD/PowerShell 中执行)
|
||||||
build.bat v2.1.1 win-x64
|
scripts\build.bat v2.1.1 win-x64
|
||||||
|
|
||||||
# 本地重复构建时缩短门禁;正式发布不要跳过
|
# 本地重复构建时缩短门禁;正式发布不要跳过
|
||||||
./build.sh v2.1.1 mac-arm64 --quick
|
./scripts/build.sh v2.1.1 mac-arm64 --quick
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh` 与 `build.bat` 面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它们只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 输出到:
|
`scripts/build.sh` 与 `scripts/build.bat` 面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它们只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 输出到:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
release/v2.1.1/macos-arm64/
|
release/v2.1.1/macos-arm64/
|
||||||
@@ -41,7 +41,7 @@ release/v2.1.1/windows-x64/
|
|||||||
rustup default stable-x86_64-pc-windows-msvc
|
rustup default stable-x86_64-pc-windows-msvc
|
||||||
rustup target add x86_64-pc-windows-msvc
|
rustup target add x86_64-pc-windows-msvc
|
||||||
npm ci
|
npm ci
|
||||||
build.bat v2.1.1 win-x64
|
scripts\build.bat v2.1.1 win-x64
|
||||||
```
|
```
|
||||||
|
|
||||||
默认执行完整门禁;`--quick` 适合开发期重复构建,正式发布不要使用 `--skip-checks`。输出位于 `release\v2.1.1\windows-x64\`。没有代码签名证书时可以生成开发安装包,但 Windows SmartScreen 可能提示未知发布者。
|
默认执行完整门禁;`--quick` 适合开发期重复构建,正式发布不要使用 `--skip-checks`。输出位于 `release\v2.1.1\windows-x64\`。没有代码签名证书时可以生成开发安装包,但 Windows SmartScreen 可能提示未知发布者。
|
||||||
@@ -52,10 +52,10 @@ build.bat v2.1.1 win-x64
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 只检查附件,不访问 Gitee API
|
# 只检查附件,不访问 Gitee API
|
||||||
./release.py v2.1.1 --dry-run
|
./scripts/release.py v2.1.1 --dry-run
|
||||||
|
|
||||||
# 创建或更新 Release,上传安装包和 SHA256SUMS.txt,并核验远程附件
|
# 创建或更新 Release,上传安装包和 SHA256SUMS.txt,并核验远程附件
|
||||||
./release.py v2.1.1
|
./scripts/release.py v2.1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
发布凭据从 `GITEE_TOKEN` 或 `~/.netrc` 的 `gitee.com` 条目读取,脚本不会输出 Token。脚本只读取 `release/v<版本>/` 下的 `.dmg`、`.exe`、`.msi` 和 `SHA256SUMS.txt`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
|
发布凭据从 `GITEE_TOKEN` 或 `~/.netrc` 的 `gitee.com` 条目读取,脚本不会输出 Token。脚本只读取 `release/v<版本>/` 下的 `.dmg`、`.exe`、`.msi` 和 `SHA256SUMS.txt`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 路线图
|
||||||
|
|
||||||
|
权威副本在仓库根目录:
|
||||||
|
|
||||||
|
- [ROADMAP.md](../ROADMAP.md) — 产品方向与下一阶段
|
||||||
|
- [TASKS.md](../TASKS.md) — 当前可勾选任务(Hermes / OpenClaw / Cursor)
|
||||||
|
- [TDD_PLAN.md](../TDD_PLAN.md) — 测试先行与门禁
|
||||||
|
|
||||||
|
开发规则以 [AGENTS.md](../AGENTS.md) 为准。不要在本文件再维护一份进度,避免漂移。
|
||||||
@@ -29,7 +29,7 @@ npm run acceptance:full
|
|||||||
- HTTP CONNECT、SOCKS5 和断点复制
|
- HTTP CONNECT、SOCKS5 和断点复制
|
||||||
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 SSH 断开时清理
|
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 SSH 断开时清理
|
||||||
- 状态栏「转发」抽屉启停动态 `127.0.0.1:1080`,右键复制 SOCKS 地址;每台 SSH 主机仅一条动态规则
|
- 状态栏「转发」抽屉启停动态 `127.0.0.1:1080`,右键复制 SOCKS 地址;每台 SSH 主机仅一条动态规则
|
||||||
- i18n:zh-CN / en 的 namespace key 对齐(`test/i18n/localeKeys.test.ts`)
|
- i18n:全部 APP_LANGUAGES(zh-CN / zh-TW / en / ja / ko / vi / ru)的 namespace key 对齐(`test/i18n/localeKeys.test.ts`)
|
||||||
- RDP / VNC 仅原生可用;非 Tauri 环境不得显示为可连接;桌面会话不参与批量命令
|
- RDP / VNC 仅原生可用;非 Tauri 环境不得显示为可连接;桌面会话不参与批量命令
|
||||||
- SFTP 本地/远程路径参数方向、原生路径上传、选中文件下载、双栏双向拖拽、目录落点、远程文本读写和错误反馈
|
- SFTP 本地/远程路径参数方向、原生路径上传、选中文件下载、双栏双向拖拽、目录落点、远程文本读写和错误反馈
|
||||||
- 终端分屏、剪贴板、快捷键、分片 UTF-8 解码、高输出背压和异步输入错误
|
- 终端分屏、剪贴板、快捷键、分片 UTF-8 解码、高输出背压和异步输入错误
|
||||||
|
|||||||
+16
-9
@@ -1,5 +1,7 @@
|
|||||||
# 使用指南
|
# 使用指南
|
||||||
|
|
||||||
|
OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默认收起。不做堡垒审计。
|
||||||
|
|
||||||
## 快速连接
|
## 快速连接
|
||||||
|
|
||||||
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
|
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
|
||||||
@@ -35,10 +37,12 @@ socks5://127.0.0.1:1080
|
|||||||
|
|
||||||
- 动态转发每台 SSH 主机仅一条,端口固定 `1080`;在抽屉中直接启停。
|
- 动态转发每台 SSH 主机仅一条,端口固定 `1080`;在抽屉中直接启停。
|
||||||
- 状态栏「转发」右键可复制 `socks5://127.0.0.1:1080`。需要远程 DNS 解析的命令行工具应使用 `socks5h`,例如 `curl --proxy socks5h://127.0.0.1:1080 https://example.com`。
|
- 状态栏「转发」右键可复制 `socks5://127.0.0.1:1080`。需要远程 DNS 解析的命令行工具应使用 `socks5h`,例如 `curl --proxy socks5h://127.0.0.1:1080 https://example.com`。
|
||||||
|
- 本地转发:本机监听默认只需填端口(绑定 `127.0.0.1` / localhost,等同 `ssh -L 8080:…`);需要局域网访问时再展开高级项改为内网 IP 或 `0.0.0.0`。目标主机相对当前 SSH 服务器(常用 `127.0.0.1` 或内网 IP),不是会话页主机名,且不能填 `0.0.0.0`。
|
||||||
- 本地 / 远程规则在抽屉列表中添加、启停和删除;“停止”会关闭 Rust Listener,不是只改界面图标。
|
- 本地 / 远程规则在抽屉列表中添加、启停和删除;“停止”会关闭 Rust Listener,不是只改界面图标。
|
||||||
|
- 远程转发(`-R`):远端监听填 `127.0.0.1:端口`(仅 SSH 服务器本机可访问)或 `0.0.0.0:端口`(远端内网其它主机也可访问,需 sshd `GatewayPorts yes` 或 `clientspecified`)。目标为本机或内网 `host:port`(不能填 `0.0.0.0`)。服务器还需 `AllowTcpForwarding yes` 或 `remote`;监听端口不能已被占用;`<1024` 通常需要 root。可用 `ssh -R [bind:]port:host:port user@host` 对照。
|
||||||
- “复制规则”会创建独立的新规则,便于只修改端口或目标;不会复制原规则的运行状态。
|
- “复制规则”会创建独立的新规则,便于只修改端口或目标;不会复制原规则的运行状态。
|
||||||
- 运行中的规则选择“编辑”时,会先提示停止真实监听,再允许保存新配置。
|
- 运行中的规则选择“编辑”时,会先提示停止真实监听,再允许保存新配置。
|
||||||
- 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。
|
- 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。同一窗口内只要没点「停止」,SSH 断线、保活失败、空闲挂起或重连都会继续监听(本地 / SOCKS 端口不拆,远程转发会自动重听)。点停止、关闭标签、连接面板手动断开,或退出应用,才会关闭真实 Listener。关闭标签后再开同一会话不会自动拉起转发。
|
||||||
- SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
|
- SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
|
||||||
|
|
||||||
## 远程桌面(RDP / VNC)
|
## 远程桌面(RDP / VNC)
|
||||||
@@ -67,15 +71,18 @@ socks5://127.0.0.1:1080
|
|||||||
|
|
||||||
## SFTP
|
## SFTP
|
||||||
|
|
||||||
SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时文件,中断后再次传输同一路径会从已有字节继续。传输完成并通过大小校验后才替换正式文件,因此失败传输不会伪装成完整文件。
|
SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下载先写入 `.opsx-part` 临时文件,中断后再次传输同一路径会从已有字节继续。传输完成并通过大小校验后才替换正式文件,因此失败传输不会伪装成完整文件。
|
||||||
|
|
||||||
- 上传:在左侧本地栏选中文件后点击“上传”;未选择文件时点击“上传”会打开系统原生文件选择器。
|
- 上传:在左侧本地栏选中文件后点中间「上传」箭头;未选择文件时会打开系统原生文件选择器。
|
||||||
- 下载:在右侧远程栏选中文件后点击“下载”。下载按钮在未选择远程文件时保持禁用。
|
- 下载:在右侧远程栏选中文件后点中间「下载」箭头;未选择远程文件时下载保持禁用。
|
||||||
- 文件右键菜单也提供对应的“上传”或“下载”操作。
|
- 文件右键菜单也提供对应的「上传」或「下载」操作。
|
||||||
- 可直接把左侧本地文件拖到右侧远程栏上传,也可把右侧远程文件拖到左侧本地栏下载;拖到目录行时以该目录为目标,并显示落点高亮。
|
- 可把左侧本地文件拖到右侧远程栏上传,也可把右侧远程文件拖到左侧本地栏下载;拖到目录行时以该目录为目标,并显示落点高亮。系统未提供拖拽路径时,请改用中间箭头或文件选择器。
|
||||||
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
|
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
|
||||||
- 远程文本编辑的“打开/保存”直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。
|
- 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。
|
||||||
- 传输失败会在传输队列和界面通知中显示 Rust/SFTP 返回的具体原因。
|
- 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。
|
||||||
|
- SSH 断线后,SFTP 一键重连会优先回到断线前的远程目录;进行中的传输会标为失败,可重试。终端标签本身也支持一键重新连接(及可选自动重连)。
|
||||||
|
- 刷新时状态栏显示「正在刷新…」,传输忙时浏览类操作会立即返回忙提示而不是卡住界面。
|
||||||
|
- 传输失败会在传输队列和界面通知中显示经多语言映射后的原因。
|
||||||
|
|
||||||
当前目标文件采用覆盖语义。内容哈希校验和显式冲突选择仍在路线图中。
|
当前目标文件采用覆盖语义。内容哈希校验和显式冲突选择仍在路线图中。
|
||||||
|
|
||||||
@@ -122,7 +129,7 @@ SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时
|
|||||||
|
|
||||||
## 界面语言
|
## 界面语言
|
||||||
|
|
||||||
设置中可切换中文(zh-CN)与 English。首次启动按系统语言自动选择;验收与单元测试默认中文文案。
|
设置中可切换:简体中文(zh-CN)、繁体中文(zh-TW)、English、日本語、한국어、Tiếng Việt、Русский。首次启动按系统语言自动选择;新增可见文案须同步全部语种,并由 `test/i18n/localeKeys.test.ts` 校验 key 对齐。验收与单元测试默认中文文案。
|
||||||
|
|
||||||
## 本地数据
|
## 本地数据
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
# 产品体验与基础能力全面评估
|
|
||||||
|
|
||||||
> 更新:2026-07-31
|
|
||||||
> 目标:OpsXShell 做成“麻雀虽小、五脏俱全”的轻量 SSH/SFTP 工具。常用操作直接、结果明确、失败可恢复,不用花哨概念增加学习成本。
|
|
||||||
|
|
||||||
## 评估结论
|
|
||||||
|
|
||||||
当前开发分支已具备 SSH、SFTP、串口、分屏、批量命令、SOCKS5、跳板和 AI Agent 的完整产品骨架,已经不是功能演示,但距离可长期替代 Tabby、Netcatty、Xshell 或 WindTerm 仍有一段可靠性和跨平台发布差距。
|
|
||||||
|
|
||||||
综合评分为 **7.1/10**。评分针对当前代码和自动化基线,不等于正式安装包质量;Windows 实机、macOS 安装版、断网/休眠和真实大文件传输未统一验收前,不能按成熟商业终端评价。
|
|
||||||
|
|
||||||
| 维度 | 分数 | 判断 |
|
|
||||||
| ------------------ | ---: | ---------------------------------------------------------------------------------- |
|
|
||||||
| 产品聚焦 | 8.5 | SSH/SFTP/代理/串口边界清楚,没有堡垒机、审计和 CMDB 噪音 |
|
|
||||||
| 首次连接与会话管理 | 7.8 | 快速连接和保存会话路径短,支持密码、私钥、代理和跳板 |
|
|
||||||
| 终端基础体验 | 7.2 | 复制粘贴、右键、分屏、广播和 Shell 生命周期已补齐;搜索和高压稳定性仍不足 |
|
|
||||||
| SFTP | 6.8 | 双栏、双向拖拽、上传下载、权限和远程编辑已具备;冲突、重试和任务恢复不足 |
|
|
||||||
| 隧道与 SOCKS5 | 7.5 | 规则与运行实例分离、真实启停和状态统计方向正确;重连和复用还需压测 |
|
|
||||||
| 批量运维 | 7.0 | 多主机选择、并发、逐机结果清楚;取消和独立超时未完成 |
|
|
||||||
| AI Agent | 6.8 | 单入口、当前 SSH 执行、风险模式和 ReAct 基线可用;执行内核仍在 React,真正取消不足 |
|
|
||||||
| 性能与可靠性 | 6.2 | Tauri/Rust 基础占优,但断网、休眠、大输出和频繁切换仍缺持续压测 |
|
|
||||||
| 跨平台发布 | 5.8 | macOS/Windows 构建脚本已具备;Windows 实机、签名、公证、更新和崩溃诊断未闭环 |
|
|
||||||
| 文档与自动化 | 7.8 | 有契约、组件、Rust 和真实探针分层;仍需把实机矩阵纳入每次候选发布 |
|
|
||||||
|
|
||||||
## 产品主旨
|
|
||||||
|
|
||||||
OpsXShell 的默认界面只围绕七件事:连接主机、管理会话、操作终端、传输文件、开隧道、批量执行、必要时让 AI 在当前主机协助处理。
|
|
||||||
|
|
||||||
必须坚持:
|
|
||||||
|
|
||||||
1. 高频动作一步可达,高级选项默认收起。
|
|
||||||
2. 当前操作目标始终可见,尤其是广播、批量命令、SFTP 和 AI 执行。
|
|
||||||
3. 成功、进行中、失败、断开是四种不同状态,不能只靠颜色或开发日志表达。
|
|
||||||
4. 空闲操作不滥用确认框;删除、覆盖、关闭运行任务和高风险 AI 命令才确认。
|
|
||||||
5. 不保存终端命令、广播输入、批量命令、AI 对话或工具输出。
|
|
||||||
6. 不加入审计、录像、审批、CMDB、独立 podexec 或 Kubernetes 凭据。
|
|
||||||
|
|
||||||
## 大众操作习惯基线
|
|
||||||
|
|
||||||
### 鼠标与窗口
|
|
||||||
|
|
||||||
- 单击负责选择和聚焦,双击负责打开会话/目录或切换窗口最大化,不能让一次双击触发两次相反操作。
|
|
||||||
- 右键必须显示与当前位置相关的菜单;普通区域不得出现 WebView 的 Reload/Inspect Element。
|
|
||||||
- macOS 使用原生红黄绿窗口控制,Windows 使用系统最小化/最大化/关闭;标题栏空白区可拖动。
|
|
||||||
- 拖拽必须显示源、目标和允许/禁止状态;文件落到目录行时传入该目录,落到空白区时传入当前目录。
|
|
||||||
|
|
||||||
### 键盘
|
|
||||||
|
|
||||||
| 动作 | macOS | Windows/Linux |
|
|
||||||
| -------------------- | -------------- | ------------------------------------------------- |
|
|
||||||
| 复制/粘贴终端 | `Cmd+C/V` | `Ctrl+Shift+C/V` |
|
|
||||||
| 普通输入框复制/粘贴 | `Cmd+C/V` | `Ctrl+C/V` |
|
|
||||||
| 新建会话 | `Cmd+N` | `Ctrl+N` |
|
|
||||||
| 快速连接 | `Cmd+K` | `Ctrl+K` |
|
|
||||||
| 折叠/展开侧栏 | `Cmd+B` | `Ctrl+B` |
|
|
||||||
| 关闭标签 | `Cmd+W` | `Ctrl+W` |
|
|
||||||
| 分屏 | `Cmd+D` | `Ctrl+D`,但必须避免与远端 EOF 语义冲突并允许改键 |
|
|
||||||
| 终端搜索 | `Cmd+F` | `Ctrl+F` |
|
|
||||||
| 开发工具(开发构建) | `Cmd+Option+I` | `F12` |
|
|
||||||
|
|
||||||
- 侧栏折叠采用**完全隐藏**(专心终端):折叠后不保留图标窄轨。会话树是主内容而非 IDE 工具切换条;折叠后靠 `Cmd/Ctrl+B`、标签栏左侧展开按钮恢复。快速连接继续走 `Cmd/Ctrl+K`,不依赖侧栏可见。
|
|
||||||
- 快捷键不能抢占输入框的复制、粘贴、全选、撤销和输入法组合状态。
|
|
||||||
- 菜单和提示必须显示当前平台实际快捷键,不能同时展示两套组合键让用户猜。
|
|
||||||
- `Escape` 关闭当前最上层弹框/菜单;`Enter` 触发主动作,文本域需要明确换行组合键。
|
|
||||||
|
|
||||||
### 表单
|
|
||||||
|
|
||||||
- SSH 目标允许直接粘贴 `user@host:port`,端口默认 22。
|
|
||||||
- 保存会话只展示“密码/密钥”两个常用认证入口;代理、跳板、标签和启动命令放在高级区。
|
|
||||||
- 端口、路径和 Endpoint 在失焦或提交时给出就地错误,不能无提示恢复默认值。
|
|
||||||
- 取消或重新打开快速连接、批量命令、AI 面板时,敏感输入必须清空。
|
|
||||||
- 按钮文案说明结果,如“连接”“创建并保存”“执行到 3 台”,避免只显示含义不明的图标。
|
|
||||||
|
|
||||||
### 状态与错误
|
|
||||||
|
|
||||||
- 错误必须包含“什么失败、作用于谁、如何恢复”,例如“Host A 命令超时,可重试”,而不是 `unknown`。
|
|
||||||
- 剪贴板失败、广播目标变为 0、Shell 已退出、隧道端口占用和 SFTP 冲突必须在界面提示。
|
|
||||||
- 重试不能重复创建连接、Shell、SFTP 通道或 Listener;状态必须以 Rust 真实资源为准。
|
|
||||||
- 加载超过 300ms 显示进行中,超过设置超时显示具体阶段;不能无限转圈。
|
|
||||||
|
|
||||||
## 核心用户旅程
|
|
||||||
|
|
||||||
| 旅程 | 目标操作长度 | 当前状态 | 验收标准 |
|
|
||||||
| ----------- | ----------------------------: | -------------- | ------------------------------------------------------- |
|
|
||||||
| 临时连接 | 快捷键 → 粘贴 → 回车 | 已实现 | `user@host:port` 可粘贴;取消重开后密码为空 |
|
|
||||||
| 保存会话 | 新建 → 目标/凭据 → 保存 | 已实现 | 默认 22;常用项不超过一屏;错误就地显示 |
|
|
||||||
| 重复连接 | 双击侧栏会话 | 已实现 | 单击只选择,双击只打开一个终端标签 |
|
|
||||||
| 终端操作 | 右键或快捷键 | 已实现 | 复制、粘贴、全选、清屏、分屏、最大化、关闭均明确 |
|
|
||||||
| 多终端广播 | 开广播 → 输入 | 已实现,待增强 | 覆盖所有打开 SSH Shell,显示目标数,本地 Shell 永不参与 |
|
|
||||||
| 批量命令 | 选主机 → 命令 → 执行 | 已实现,待增强 | 最多并发 5,逐机结果和耗时;关闭后命令销毁 |
|
|
||||||
| SFTP | 会话右键 → SFTP 文件 | 已实现,待实机 | 本地↔远程拖拽、按钮和右键都走明确 upload/download 方向 |
|
|
||||||
| SOCKS5 | 会话 → 转发 → 启动 | 已实现,待压测 | 默认 `127.0.0.1:1080`;停止后真实端口不可连接 |
|
|
||||||
| 串口 | 快速启动 → 串口 → 参数 → 连接 | 已实现,待实机 | 可枚举设备,默认常用参数,断开和重连状态明确 |
|
|
||||||
| AI 处理任务 | 当前 SSH → AI → 描述 → 执行 | 实验可用 | 目标固定、安全模式明确、critical 硬拒、关闭后上下文销毁 |
|
|
||||||
|
|
||||||
## 当前已完成的基础修正
|
|
||||||
|
|
||||||
- 全局广播覆盖所有打开且已连接的 SSH 标签与分屏,排除本地 Shell;目标不足两个时自动关闭。
|
|
||||||
- 终端右键菜单包含复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭。
|
|
||||||
- 关闭分屏会调用 Rust `ssh_close_shell`,远端关闭失败会记录明确错误,不只移除前端格子。
|
|
||||||
- 远端 Shell 的 EOF、`TMOUT` 和 `Ctrl+D` 不再等同整个 SSH 连接断开,可只重开当前 Shell。
|
|
||||||
- 批量命令支持搜索、全选当前结果、调整目标、最大并发 5、逐机状态/输出/耗时和总进度。
|
|
||||||
- 快速连接和批量命令重新打开会清空敏感输入,结果不落盘。
|
|
||||||
- Agent 支持严格、均衡、宽松三级安全模式;默认均衡只确认 high,critical 永久硬拒绝。
|
|
||||||
- 普通 WebView 区域禁止 Reload/Inspect Element,文本输入保留系统编辑菜单。
|
|
||||||
- 应用版本由 `package.json` 构建时注入,不再在状态栏硬编码旧版本。
|
|
||||||
|
|
||||||
## P0:下一候选版本必须完成
|
|
||||||
|
|
||||||
| ID | 优化项 | 当前缺口 | 完成标准 |
|
|
||||||
| ----- | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------- |
|
|
||||||
| P0-01 | 连接生命周期压测 | 断网、睡眠唤醒、快速切标签和连接复用证据不足 | 自动覆盖连接/重连/挂起/恢复;100 次切换无重复连接和监听器泄漏 |
|
|
||||||
| P0-02 | 终端搜索接通 | `SearchAddon` 已加载,但分屏工作区没有可用搜索 UI | `Cmd/Ctrl+F` 搜索当前焦点分屏,支持上一项/下一项/关闭 |
|
|
||||||
| P0-03 | 终端高输出稳定性 | 仍出现过 CPU 高占用和操作卡顿 | 持续输出 10 分钟 UI 可操作;后台标签限流;CPU/内存有基准阈值 |
|
|
||||||
| P0-04 | 用户可见 I/O 错误 | 部分剪贴板、关闭 Shell 和事件错误只写日志 | toast/覆盖层说明失败对象和恢复动作;不得产生未处理 Promise |
|
|
||||||
| P0-05 | SFTP 基础传输实机门禁 | 自动测试有方向契约和探针,但真实安装版仍需重复验证 | 上传、下载、覆盖、目录落点、拖拽、取消后临时文件处理全部通过 |
|
|
||||||
| P0-06 | SFTP 冲突规则 | 同名文件处理不够显式 | 每次任务可选择覆盖/跳过/重命名;目录递归不静默覆盖 |
|
|
||||||
| P0-07 | 批量任务超时与取消 | 当前只能等待 worker 完成 | 区分连接/命令超时;停止后不再启动新主机,当前命令标为等待完成 |
|
|
||||||
| P0-08 | 隧道重连与清理 | Listener 真实启停已做,网络恢复策略不足 | 主连接断开立即清理;重连后按用户选择恢复;端口冲突显示占用地址 |
|
|
||||||
| P0-09 | 最小窗口适配 | 960×720 基础可用,AI 打开后终端区域偏窄 | 960×640 无遮挡;打开 AI 时可自动收起侧栏或提供一键扩展 |
|
|
||||||
| P0-10 | 双平台安装验收 | macOS 结果不能代表 Windows | macOS arm64 与 Windows x86_64 分别完成安装、升级、卸载和核心冒烟 |
|
|
||||||
|
|
||||||
## P1:提升日常效率
|
|
||||||
|
|
||||||
| ID | 优化项 | 建议 |
|
|
||||||
| ----- | --------------- | ----------------------------------------------------------------------------- |
|
|
||||||
| P1-01 | 广播目标预览 | 点击目标数查看主机/分屏,允许本次临时排除;默认仍是全部打开 SSH |
|
|
||||||
| P1-02 | 批量失败重试 | 一键仅重试失败主机,可从分组/标签建立目标集合 |
|
|
||||||
| P1-03 | 终端标题与路径 | 安全解析 OSC 标题/当前目录,允许锁定会话名,防止远端伪装安全提示 |
|
|
||||||
| P1-04 | 关闭保护 | 仅当前台有运行任务时提示关闭会结束远端 Shell;空闲时一步关闭 |
|
|
||||||
| P1-05 | SFTP 任务中心 | 统一暂停/继续/取消/重试,显示速度、剩余时间和失败原因;重启后可恢复未完成任务 |
|
|
||||||
| P1-06 | SFTP 易用性 | 多选拖拽、复制路径、在终端打开目录、远程编辑冲突提示、校验和验证 |
|
|
||||||
| P1-07 | SSH Config 导入 | 展示将导入字段、冲突处理和不可支持项;支持 ProxyJump 链预览 |
|
|
||||||
| P1-08 | 隧道便捷入口 | 状态栏一键查看/复制 SOCKS5 地址;复制规则永不复制运行态 |
|
|
||||||
| P1-09 | Agent Runtime | 从 React 抽离状态机,增加单步超时、真正取消、结构化 tool calling 和 eval |
|
|
||||||
| P1-10 | 无障碍 | 所有图标按钮有名称;焦点圈清楚;弹框焦点锁定/返回;状态不只靠颜色 |
|
|
||||||
|
|
||||||
## P2:专业兼容与发布闭环
|
|
||||||
|
|
||||||
- Agent Forwarding、OpenSSH 证书、FIDO2 和更完整的算法策略。
|
|
||||||
- 保存常用分屏布局,但不保存 Shell ID、命令或终端输出。
|
|
||||||
- 隧道使用明确的共享连接/Multiplexer 策略,避免每个请求重新握手。
|
|
||||||
- macOS Developer ID 签名与公证、Windows Authenticode、自动更新清单和回滚。
|
|
||||||
- 用户主动导出的脱敏诊断包:版本、平台、错误码、资源统计,不包含命令、密码和终端输出。
|
|
||||||
- 可选主题和键位导入;不为了生态而引入重量级插件框架。
|
|
||||||
|
|
||||||
## 明确不做
|
|
||||||
|
|
||||||
- 不恢复命令历史,不持久化广播、批量命令或 Agent 输入输出。
|
|
||||||
- 不增加堡垒机审计、审批流、录像、CMDB、告警大盘或独立 podexec。
|
|
||||||
- 不使用“有选区右键复制、无选区右键直接粘贴”这类隐藏行为。
|
|
||||||
- 不让本地 Shell 加入远程广播,不允许 `ssh://` 携带密码或静默连接。
|
|
||||||
- 不为“专业感”堆无用途状态卡、动画、统计图和多级配置。
|
|
||||||
|
|
||||||
## 统一回归场景
|
|
||||||
|
|
||||||
1. 快速连接粘贴 `root@opsbase.cn:55555`,取消后重开,目标和密码为空;通过 `ssh://` 唤起时只预填安全目标。
|
|
||||||
2. 新建会话默认端口 22,密码/密钥切换不会把另一种凭据落盘;编辑高级配置不影响基础字段。
|
|
||||||
3. 打开两个 SSH 标签,其中一个分两屏;广播输入 `date`,三个远端各收到一次,本地 Shell 不收到。
|
|
||||||
4. 关闭一个分屏,Rust Shell 释放,广播目标立即减少,兄弟分屏、SFTP 和隧道保持可用。
|
|
||||||
5. 终端右键复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭均可用,无 Reload/Inspect Element。
|
|
||||||
6. `Cmd/Ctrl+F` 只搜索当前焦点分屏;切换分屏后结果随焦点切换。
|
|
||||||
7. 批量选择两台执行 `date`,逐机显示耗时和结果;停止后不启动剩余主机;关闭重开命令为空。
|
|
||||||
8. SFTP 按钮上传、右键上传、本地拖到远程、远程拖到本地结果一致;文件逐字节校验成功。
|
|
||||||
9. 同名文件明确选择覆盖/跳过/重命名;失败不留下被误认为完整文件的目标。
|
|
||||||
10. 启动 `127.0.0.1:1080` SOCKS5,代理请求成功且统计变化;停止后端口不可连接,重启应用显示未运行。
|
|
||||||
11. 远端 `TMOUT` 只关闭当前 Shell,一键重开可用,SSH connection 和其他资源不被误断。
|
|
||||||
12. Agent 均衡模式自动执行 `df -h`,`systemctl restart nginx` 先确认;严格模式修改文件先确认;宽松仍拒绝 `rm -rf /`。
|
|
||||||
13. 断网后状态由“已连接”进入“重连中/失败”,恢复后不重复创建标签、连接、Shell 或 Listener。
|
|
||||||
14. macOS 窗口可拖动,双击标题栏只切换一次最大化;Windows 系统按钮、快捷键和安装卸载符合平台习惯。
|
|
||||||
15. 960×640、125%/150% Windows 缩放以及长主机名、中文分组下,无按钮遮挡、水平溢出或不可达操作。
|
|
||||||
|
|
||||||
## 发版判定
|
|
||||||
|
|
||||||
自动化全绿只代表代码门禁通过。候选发布还必须分别在 macOS arm64 和 Windows x86_64 完成上述核心旅程,并记录版本、安装包哈希、测试平台、失败项和回归结果。任何“只能偶现”“重试一般能好”的连接、终端输入、SFTP 或隧道问题都按未通过处理。
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# OpsXShell License Server
|
# OpsXShell License Server
|
||||||
|
|
||||||
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发。
|
> **DEPRECATED** — 桌面端已切换到 **opsx-licence**(默认 `http://127.0.0.1:9001/api`)。本目录保留供对照与临时本地调试,**请勿用于新功能开发**。新结账 / 领取 / 激活请走 opsx-licence 的 `/api/v1/*` 契约。
|
||||||
|
|
||||||
|
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发(旧 HMAC `opsx1` 方案)。
|
||||||
|
|
||||||
**私钥只放本机环境变量,不要写入仓库或桌面端。**
|
**私钥只放本机环境变量,不要写入仓库或桌面端。**
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
桌面端默认请求 `http://127.0.0.1:8787`。可用 `VITE_LICENSE_SERVER_URL` 覆盖。
|
旧桌面端曾默认请求 `http://127.0.0.1:8787`。当前桌面默认已改为 opsx-licence:`http://127.0.0.1:9001/api`(`VITE_LICENSE_SERVER_URL` / `OPSX_LICENSE_API_BASE`)。
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
@@ -34,10 +36,10 @@ npm run issue-dev-license -- --deviceId '*'
|
|||||||
# 临时授权可加 --days 30
|
# 临时授权可加 --days 30
|
||||||
```
|
```
|
||||||
|
|
||||||
将返回的 `token` 粘贴到 App「已有许可证?激活」。
|
将返回的 `token` 粘贴到 App「已有许可证?激活」(仅旧 HMAC 路径;新客户端需 `LIC-` 密钥)。
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
- `@waffo/pancake-ts` 仅在本服务使用
|
- `@waffo/pancake-ts` 仅在本服务使用
|
||||||
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
|
- `OPSX_LICENSE_HMAC` 与旧桌面端校验密钥一致(生产请轮换)
|
||||||
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
|
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
|
||||||
+5
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "opsxshell-gui",
|
"name": "opsxshell-gui",
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"description": "轻量原生的跨平台运维终端 · SSH / SFTP / 转发 / RDP·VNC · Tauri 2 + Rust",
|
"description": "简单好用的原生运维终端 · SSH / SFTP / 转发 / RDP·VNC · 短路径、不堆堡垒",
|
||||||
"author": "OpsXShell Team",
|
"author": "OpsXShell Team",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:patrol": "vitest run test/patrol",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:release": "node --test scripts/*.test.mjs",
|
"test:release": "node --test scripts/*.test.mjs",
|
||||||
"acceptance": "npm run acceptance:quick",
|
"acceptance": "npm run acceptance:quick",
|
||||||
@@ -35,7 +36,9 @@
|
|||||||
"release:preflight:win": "node scripts/release-preflight.mjs windows",
|
"release:preflight:win": "node scripts/release-preflight.mjs windows",
|
||||||
"release:verify:mac": "node scripts/verify-release.mjs macos",
|
"release:verify:mac": "node scripts/verify-release.mjs macos",
|
||||||
"release:verify:win": "node scripts/verify-release.mjs windows",
|
"release:verify:win": "node scripts/verify-release.mjs windows",
|
||||||
"dist:linux": "tauri build --target x86_64-unknown-linux-gnu"
|
"dist:linux": "tauri build --target x86_64-unknown-linux-gnu",
|
||||||
|
"start": "bash scripts/run.sh",
|
||||||
|
"build:native": "bash scripts/build.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
|||||||
@@ -1,43 +1,2 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
exec "$(cd "$(dirname "$0")" && pwd)/scripts/run.sh" "$@"
|
||||||
|
|
||||||
# 颜色定义
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
PID_FILE="/tmp/opsxshell.pid"
|
|
||||||
LOG_FILE="/tmp/opsxshell.log"
|
|
||||||
|
|
||||||
echo -e "${CYAN}🦐 启动 OpsXShell...${NC}"
|
|
||||||
|
|
||||||
# 1. 清理旧进程
|
|
||||||
echo -e "${YELLOW}🧹 清理旧进程...${NC}"
|
|
||||||
pkill -f "vite" 2>/dev/null || true
|
|
||||||
pkill -f "opsxshell" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# 2. 检查依赖
|
|
||||||
if [ ! -d "node_modules" ]; then
|
|
||||||
echo -e "${YELLOW}📦 安装依赖...${NC}"
|
|
||||||
npm install
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. 启动 Tauri 开发应用
|
|
||||||
echo -e "${GREEN}⚡ 启动 OpsXShell...${NC}"
|
|
||||||
npm run dev > "$LOG_FILE" 2>&1 &
|
|
||||||
VITE_PID=$!
|
|
||||||
echo $VITE_PID > "$PID_FILE"
|
|
||||||
|
|
||||||
# 4. 等待启动
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
|
||||||
echo -e "${CYAN}📄 日志: tail -f ${LOG_FILE}${NC}"
|
|
||||||
echo -e "${CYAN}🔍 DevTools: 菜单「视图 → 开发者工具」,或 Cmd+Option+I / F12${NC}"
|
|
||||||
|
|
||||||
# 等待子进程
|
|
||||||
wait $VITE_PID 2>/dev/null || true
|
|
||||||
@@ -17,7 +17,7 @@ const manifest = 'src-tauri/Cargo.toml'
|
|||||||
const quickSteps = [
|
const quickSteps = [
|
||||||
['Renderer type contract', npm, ['run', 'typecheck:ui']],
|
['Renderer type contract', npm, ['run', 'typecheck:ui']],
|
||||||
['Test and mock contracts', npm, ['run', 'typecheck:test']],
|
['Test and mock contracts', npm, ['run', 'typecheck:test']],
|
||||||
['Unit and foundation smoke tests', npm, ['test']],
|
['Unit + SSH/SFTP/RDP/VNC patrol', npm, ['test']],
|
||||||
['Rust formatting', cargo, ['fmt', '--manifest-path', manifest, '--', '--check']],
|
['Rust formatting', cargo, ['fmt', '--manifest-path', manifest, '--', '--check']],
|
||||||
['Rust compile check', cargo, ['check', '--manifest-path', manifest]],
|
['Rust compile check', cargo, ['check', '--manifest-path', manifest]],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { inspectVersions, setVersion } from './set-version.mjs'
|
|||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
test('build.sh is valid Bash and documents supported native targets', () => {
|
test('build.sh is valid Bash and documents supported native targets', () => {
|
||||||
const syntax = spawnSync('bash', ['-n', 'build.sh'], { cwd: root, encoding: 'utf8' })
|
const syntax = spawnSync('bash', ['-n', 'scripts/build.sh'], { cwd: root, encoding: 'utf8' })
|
||||||
assert.equal(syntax.status, 0, syntax.stderr)
|
assert.equal(syntax.status, 0, syntax.stderr)
|
||||||
|
|
||||||
const help = spawnSync('bash', ['build.sh', '--help'], { cwd: root, encoding: 'utf8' })
|
const help = spawnSync('bash', ['scripts/build.sh', '--help'], { cwd: root, encoding: 'utf8' })
|
||||||
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/)
|
||||||
@@ -21,7 +21,7 @@ test('build.sh is valid Bash and documents supported native targets', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('build.bat provides an equivalent native Windows x64 release path', () => {
|
test('build.bat provides an equivalent native Windows x64 release path', () => {
|
||||||
const script = readFileSync(resolve(root, 'build.bat'), 'utf8')
|
const script = readFileSync(resolve(root, 'scripts/build.bat'), 'utf8')
|
||||||
assert.match(script, /x86_64-pc-windows-msvc/)
|
assert.match(script, /x86_64-pc-windows-msvc/)
|
||||||
assert.match(script, /acceptance:full/)
|
assert.match(script, /acceptance:full/)
|
||||||
assert.match(script, /release-preflight\.mjs windows/)
|
assert.match(script, /release-preflight\.mjs windows/)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ chcp 65001 >nul
|
|||||||
rem OpsXShell v2.1.x native Windows release builder.
|
rem OpsXShell v2.1.x native Windows release builder.
|
||||||
rem Run this script from Windows x64 with the MSVC Rust toolchain installed.
|
rem Run this script from Windows x64 with the MSVC Rust toolchain installed.
|
||||||
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0.."
|
||||||
set "VERSION_ARG="
|
set "VERSION_ARG="
|
||||||
set "CHECK_MODE=full"
|
set "CHECK_MODE=full"
|
||||||
set "RUST_TARGET=x86_64-pc-windows-msvc"
|
set "RUST_TARGET=x86_64-pc-windows-msvc"
|
||||||
@@ -33,7 +33,7 @@ if /I "!ARG!"=="win-x64" (
|
|||||||
)
|
)
|
||||||
if /I "!ARG:~0,4!"=="v2.1" goto set_version_arg
|
if /I "!ARG:~0,4!"=="v2.1" goto set_version_arg
|
||||||
if /I "!ARG:~0,4!"=="2.1." goto set_version_arg
|
if /I "!ARG:~0,4!"=="2.1." goto set_version_arg
|
||||||
call :fail "未知参数:!ARG!(使用 build.bat --help 查看用法)"
|
call :fail "未知参数:!ARG!(使用 scripts\build.bat --help 查看用法)"
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|
||||||
:set_version_arg
|
:set_version_arg
|
||||||
@@ -181,7 +181,7 @@ exit /b 0
|
|||||||
echo OpsXShell Windows 原生安装包构建脚本
|
echo OpsXShell Windows 原生安装包构建脚本
|
||||||
echo.
|
echo.
|
||||||
echo 用法:
|
echo 用法:
|
||||||
echo build.bat [v2.1.x] [win-x64] [选项]
|
echo scripts\build.bat [v2.1.x] [win-x64] [选项]
|
||||||
echo.
|
echo.
|
||||||
echo 选项:
|
echo 选项:
|
||||||
echo --quick 构建前只运行快速门禁
|
echo --quick 构建前只运行快速门禁
|
||||||
@@ -189,9 +189,9 @@ echo --skip-checks 跳过门禁,仅用于本地重复打包
|
|||||||
echo -h, --help 显示帮助
|
echo -h, --help 显示帮助
|
||||||
echo.
|
echo.
|
||||||
echo 示例:
|
echo 示例:
|
||||||
echo build.bat
|
echo scripts\build.bat
|
||||||
echo build.bat v2.1.1
|
echo scripts\build.bat v2.1.1
|
||||||
echo build.bat v2.1.2 win-x64 --quick
|
echo scripts\build.bat v2.1.2 win-x64 --quick
|
||||||
echo.
|
echo.
|
||||||
echo 环境要求:
|
echo 环境要求:
|
||||||
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
|
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
VERSION_ARG=""
|
VERSION_ARG=""
|
||||||
@@ -18,7 +18,7 @@ usage() {
|
|||||||
OpsXShell 原生安装包构建脚本
|
OpsXShell 原生安装包构建脚本
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
./build.sh [v2.1.x] [目标] [选项]
|
./scripts/build.sh [v2.1.x] [目标] [选项]
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
auto 根据当前系统自动选择(默认)
|
auto 根据当前系统自动选择(默认)
|
||||||
@@ -32,9 +32,9 @@ OpsXShell 原生安装包构建脚本
|
|||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
./build.sh
|
./scripts/build.sh
|
||||||
./build.sh v2.1.0 mac-arm64
|
./scripts/build.sh v2.1.0 mac-arm64
|
||||||
./build.sh v2.1.1 mac-arm64 --quick
|
./scripts/build.sh v2.1.1 mac-arm64 --quick
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
||||||
@@ -73,7 +73,7 @@ while [ "$#" -gt 0 ]; do
|
|||||||
VERSION_ARG="${1#v}"
|
VERSION_ARG="${1#v}"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
fail "未知参数:$1(使用 ./build.sh --help 查看用法)"
|
fail "未知参数:$1(使用 ./scripts/build.sh --help 查看用法)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
@@ -242,7 +242,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser(description="发布 OpsXShell 原生安装包到 Gitee")
|
parser = argparse.ArgumentParser(description="发布 OpsXShell 原生安装包到 Gitee")
|
||||||
parser.add_argument("version", help="版本号,例如 v2.1.1")
|
parser.add_argument("version", help="版本号,例如 v2.1.1")
|
||||||
parser.add_argument("--repo", default=detect_repo(), help="Gitee 仓库,默认从 origin 检测")
|
parser.add_argument("--repo", default=detect_repo(), help="Gitee 仓库,默认从 origin 检测")
|
||||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent)
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
|
||||||
parser.add_argument("--dry-run", action="store_true", help="只检查并列出待上传附件")
|
parser.add_argument("--dry-run", action="store_true", help="只检查并列出待上传附件")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
chcp 65001 >nul
|
||||||
|
|
||||||
|
rem OpsXShell local GUI launcher (Windows).
|
||||||
|
rem App log path matches src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||||
|
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
set "PID_FILE=%TEMP%\opsxshell.pid"
|
||||||
|
set "APP_LOG=%LOCALAPPDATA%\opsxshell\logs\opsxshell.log"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [opsxshell] starting...
|
||||||
|
|
||||||
|
if not exist "node_modules\" (
|
||||||
|
echo [opsxshell] npm install...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [opsxshell] npm run dev
|
||||||
|
echo [opsxshell] app log: %APP_LOG% ^(max 10MB x 3 files^)
|
||||||
|
start /b "" cmd /c "npm run dev"
|
||||||
|
|
||||||
|
echo %DATE% %TIME% > "%PID_FILE%"
|
||||||
|
|
||||||
|
echo [opsxshell] started. Tail app log with:
|
||||||
|
echo powershell -Command "Get-Content -Path '%APP_LOG%' -Wait -Tail 50"
|
||||||
|
echo.
|
||||||
|
exit /b 0
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
PID_FILE="/tmp/opsxshell.pid"
|
||||||
|
|
||||||
|
# Same path as src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||||
|
if [ "$(uname -s)" = "Darwin" ]; then
|
||||||
|
APP_LOG="$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||||
|
else
|
||||||
|
APP_LOG="${XDG_DATA_HOME:-$HOME/.local/share}/opsxshell/logs/opsxshell.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}🦐 启动 OpsXShell...${NC}"
|
||||||
|
|
||||||
|
# 1. 清理旧进程
|
||||||
|
echo -e "${YELLOW}🧹 清理旧进程...${NC}"
|
||||||
|
pkill -f "vite" 2>/dev/null || true
|
||||||
|
pkill -f "opsxshell" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# 2. 检查依赖
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo -e "${YELLOW}📦 安装依赖...${NC}"
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 启动(原生 tracing 写入应用日志目录;Vite 输出留在本终端)
|
||||||
|
echo -e "${GREEN}⚡ 启动 OpsXShell...${NC}"
|
||||||
|
npm run dev &
|
||||||
|
VITE_PID=$!
|
||||||
|
echo $VITE_PID >"$PID_FILE"
|
||||||
|
|
||||||
|
# 4. 等待启动
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
||||||
|
echo -e "${CYAN}📄 应用日志: tail -f \"${APP_LOG}\"${NC}"
|
||||||
|
echo -e "${CYAN}📦 轮转: 单文件 ≤10MB,最多 opsxshell.log + .1 + .2(正式版同路径)${NC}"
|
||||||
|
echo -e "${CYAN}🔍 DevTools: 菜单「视图 → 开发者工具」,或 Cmd+Option+I / F12(前端 console)${NC}"
|
||||||
|
|
||||||
|
# 等待子进程
|
||||||
|
wait $VITE_PID 2>/dev/null || true
|
||||||
@@ -31,9 +31,9 @@ export interface ConnectionStats {
|
|||||||
export interface PoolConfig {
|
export interface PoolConfig {
|
||||||
maxConnections: number // 最大连接数 (默认 10)
|
maxConnections: number // 最大连接数 (默认 10)
|
||||||
maxBackgroundConnections: number // 最大后台连接数 (默认 5)
|
maxBackgroundConnections: number // 最大后台连接数 (默认 5)
|
||||||
idleTimeoutMs: number // 空闲超时 (默认 5 分钟)
|
idleTimeoutMs: number // 空闲超时 (默认 30 分钟)
|
||||||
suspendTimeoutMs: number // 挂起超时 (默认 30 分钟)
|
suspendTimeoutMs: number // 挂起超时 (默认 60 分钟)
|
||||||
keepaliveIntervalMs: number // 保活间隔 (默认 60 秒)
|
keepaliveIntervalMs: number // 保活间隔 (默认 15 秒)
|
||||||
maxReconnectAttempts: number // 最大重连次数 (默认 3)
|
maxReconnectAttempts: number // 最大重连次数 (默认 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +66,8 @@ export interface PooledConnection {
|
|||||||
// 重连
|
// 重连
|
||||||
reconnectAttempts: number // 重连尝试次数
|
reconnectAttempts: number // 重连尝试次数
|
||||||
maxReconnectAttempts: number // 最大重连次数
|
maxReconnectAttempts: number // 最大重连次数
|
||||||
|
/** Consecutive keepalive failures before tearing down (ClientAliveCountMax-like). */
|
||||||
|
keepaliveFailures?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接结果
|
// 连接结果
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface TransferTask {
|
|||||||
type: 'upload' | 'download'
|
type: 'upload' | 'download'
|
||||||
fileName: string
|
fileName: string
|
||||||
filePath: string
|
filePath: string
|
||||||
|
/** Destination directory (remote for upload, local for download). */
|
||||||
|
destinationDir?: string
|
||||||
status: 'pending' | 'transferring' | 'completed' | 'failed' | 'canceled'
|
status: 'pending' | 'transferring' | 'completed' | 'failed' | 'canceled'
|
||||||
progress: number
|
progress: number
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
Generated
+84
-14
@@ -1158,6 +1158,22 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"fiat-crypto 0.2.9",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "5.0.0-rc.1"
|
version = "5.0.0-rc.1"
|
||||||
@@ -1168,7 +1184,7 @@ dependencies = [
|
|||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"fiat-crypto",
|
"fiat-crypto 0.3.0",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -1506,18 +1522,42 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"rfc6979",
|
"rfc6979",
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
"spki 0.8.0",
|
"spki 0.8.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8 0.10.2",
|
||||||
|
"signature 2.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek 4.1.3",
|
||||||
|
"ed25519 2.2.3",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1526,8 +1566,8 @@ version = "3.0.0-rc.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
|
checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek 5.0.0-rc.1",
|
||||||
"ed25519",
|
"ed25519 3.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1549,7 +1589,7 @@ dependencies = [
|
|||||||
"hkdf",
|
"hkdf",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"pkcs8",
|
"pkcs8 0.11.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"sec1",
|
"sec1",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1681,6 +1721,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -2673,6 +2719,8 @@ dependencies = [
|
|||||||
"ironrdp-cliprdr",
|
"ironrdp-cliprdr",
|
||||||
"ironrdp-connector",
|
"ironrdp-connector",
|
||||||
"ironrdp-core",
|
"ironrdp-core",
|
||||||
|
"ironrdp-displaycontrol",
|
||||||
|
"ironrdp-dvc",
|
||||||
"ironrdp-graphics",
|
"ironrdp-graphics",
|
||||||
"ironrdp-input",
|
"ironrdp-input",
|
||||||
"ironrdp-pdu",
|
"ironrdp-pdu",
|
||||||
@@ -3757,10 +3805,13 @@ dependencies = [
|
|||||||
"arboard",
|
"arboard",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"ed25519-dalek 2.2.0",
|
||||||
|
"hex",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"image",
|
"image",
|
||||||
"ironrdp",
|
"ironrdp",
|
||||||
"ironrdp-blocking",
|
"ironrdp-blocking",
|
||||||
|
"ironrdp-bulk",
|
||||||
"open",
|
"open",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
@@ -3836,7 +3887,7 @@ checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ecdsa",
|
"ecdsa",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"fiat-crypto",
|
"fiat-crypto 0.3.0",
|
||||||
"primefield",
|
"primefield",
|
||||||
"primeorder",
|
"primeorder",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -4002,11 +4053,11 @@ dependencies = [
|
|||||||
"crypto-bigint",
|
"crypto-bigint",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctr 0.10.1",
|
"ctr 0.10.1",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek 5.0.0-rc.1",
|
||||||
"des",
|
"des",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"ecdsa",
|
"ecdsa",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek 3.0.0-rc.1",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"http",
|
"http",
|
||||||
@@ -4149,6 +4200,16 @@ dependencies = [
|
|||||||
"spki 0.8.0",
|
"spki 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der 0.7.10",
|
||||||
|
"spki 0.7.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -4795,9 +4856,9 @@ dependencies = [
|
|||||||
"crypto-primes",
|
"crypto-primes",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"pkcs1 0.8.0-rc.4",
|
"pkcs1 0.8.0-rc.4",
|
||||||
"pkcs8",
|
"pkcs8 0.11.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
"spki 0.8.0",
|
"spki 0.8.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -5421,6 +5482,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@@ -5575,8 +5645,8 @@ dependencies = [
|
|||||||
"crypto-bigint",
|
"crypto-bigint",
|
||||||
"crypto-mac",
|
"crypto-mac",
|
||||||
"cryptoki",
|
"cryptoki",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek 5.0.0-rc.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek 3.0.0-rc.1",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
@@ -7735,7 +7805,7 @@ version = "3.0.0-rc.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e"
|
checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek 5.0.0-rc.1",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ tauri-build = { version = "2.4", features = [] }
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
|
||||||
|
hex = "0.4"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
open = "5.3"
|
open = "5.3"
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.11"
|
regex = "1.11"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] }
|
||||||
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -42,8 +44,9 @@ uuid = { version = "1.11", features = ["v4"] }
|
|||||||
arboard = { version = "3.6.1", default-features = false }
|
arboard = { version = "3.6.1", default-features = false }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
# Remote desktop protocol engines (IronRDP / RFB)
|
# Remote desktop protocol engines (IronRDP / RFB)
|
||||||
ironrdp = { version = "0.17", default-features = false, features = ["connector", "session", "graphics", "input", "pdu", "core", "svc", "cliprdr", "rdpdr", "rdpsnd"] }
|
ironrdp = { version = "0.17", default-features = false, features = ["connector", "session", "graphics", "input", "pdu", "core", "svc", "cliprdr", "rdpdr", "rdpsnd", "dvc", "displaycontrol"] }
|
||||||
ironrdp-blocking = "0.10"
|
ironrdp-blocking = "0.10"
|
||||||
|
ironrdp-bulk = "0.1"
|
||||||
sspi = { version = "0.21", features = ["network_client"] }
|
sspi = { version = "0.21", features = ["network_client"] }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "aws-lc-rs"] }
|
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "aws-lc-rs"] }
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"core:window:allow-start-dragging"
|
"core:window:default",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:webview:allow-internal-toggle-devtools"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","remote":{"urls":["http://127.0.0.1:1420","http://127.0.0.1:1420/*","http://localhost:1420","http://localhost:1420/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:allow-start-dragging"]}}
|
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","remote":{"urls":["http://127.0.0.1:1420","http://127.0.0.1:1420/*","http://localhost:1420","http://localhost:1420/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:webview:allow-internal-toggle-devtools"]}}
|
||||||
@@ -60,6 +60,16 @@ pub fn rdp_upload_file(
|
|||||||
state.rdp.upload_file(&session_id, name, data)
|
state.rdp.upload_file(&session_id, name, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn rdp_resize(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
session_id: String,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.rdp.resize(&session_id, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_disconnect(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
|
pub fn rdp_disconnect(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
|
||||||
state.rdp.disconnect(&session_id)
|
state.rdp.disconnect(&session_id)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
|||||||
let retained = input
|
let retained = input
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| {
|
.filter(|line| {
|
||||||
line.split_whitespace().next().map_or(true, |hosts| {
|
line.split_whitespace().next().is_none_or(|hosts| {
|
||||||
!hosts
|
!hosts
|
||||||
.split(',')
|
.split(',')
|
||||||
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
use crate::services::license::{self, LicenseStatus};
|
use crate::services::license::{self, CheckoutResult, LicenseStatus};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||||
@@ -8,8 +8,12 @@ pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn license_activate(app: AppHandle, token: String) -> Result<LicenseStatus, String> {
|
pub fn license_activate(
|
||||||
license::activate(&app, &token)
|
app: AppHandle,
|
||||||
|
token: String,
|
||||||
|
email: Option<String>,
|
||||||
|
) -> Result<LicenseStatus, String> {
|
||||||
|
license::activate(&app, &token, email.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -21,3 +25,18 @@ pub fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
|||||||
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||||
license::get_or_create_device_id(&app)
|
license::get_or_create_device_id(&app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
||||||
|
license::set_email(&app, &email)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn license_checkout(
|
||||||
|
app: AppHandle,
|
||||||
|
customer_email: String,
|
||||||
|
plan_type: String,
|
||||||
|
return_url: Option<String>,
|
||||||
|
) -> Result<CheckoutResult, String> {
|
||||||
|
license::checkout(&app, &customer_email, &plan_type, return_url.as_deref())
|
||||||
|
}
|
||||||
+162
-36
@@ -29,8 +29,13 @@ pub fn ssh_disconnect(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
|
retain_forwards: Option<bool>,
|
||||||
) -> OperationResponse {
|
) -> OperationResponse {
|
||||||
operation(state.ssh.disconnect(&app, &connection_id))
|
operation(
|
||||||
|
state
|
||||||
|
.ssh
|
||||||
|
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -154,11 +159,34 @@ pub fn ssh_close_shell(state: State<'_, AppState>, shell_id: String) -> Operatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
|
pub async fn sftp_connect(
|
||||||
match state.ssh.create_sftp(&connection_id) {
|
state: State<'_, AppState>,
|
||||||
Ok(id) => IdResponse::sftp(id),
|
connection_id: String,
|
||||||
Err(error) => IdResponse::error(error),
|
) -> Result<IdResponse, String> {
|
||||||
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.create_sftp(&connection_id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(id)) => IdResponse::sftp(id),
|
||||||
|
Ok(Err(error)) => IdResponse::error(error),
|
||||||
|
Err(error) => IdResponse::error(format!("创建 SFTP 任务失败: {error}")),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_disconnect(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
sftp_id: String,
|
||||||
|
) -> Result<OperationResponse, String> {
|
||||||
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.close_sftp(&sftp_id)).await {
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -171,23 +199,32 @@ pub struct SftpListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_list_directory(
|
pub async fn sftp_list_directory(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> SftpListResponse {
|
) -> Result<SftpListResponse, String> {
|
||||||
match state.ssh.sftp_list(&sftp_id, &path) {
|
let service = state.ssh.clone();
|
||||||
Ok(files) => SftpListResponse {
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.sftp_list(&sftp_id, &path)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(files)) => SftpListResponse {
|
||||||
success: true,
|
success: true,
|
||||||
files: Some(files),
|
files: Some(files),
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
Err(error) => SftpListResponse {
|
Ok(Err(error)) => SftpListResponse {
|
||||||
success: false,
|
success: false,
|
||||||
files: None,
|
files: None,
|
||||||
error: Some(error),
|
error: Some(error),
|
||||||
},
|
},
|
||||||
}
|
Err(error) => SftpListResponse {
|
||||||
|
success: false,
|
||||||
|
files: None,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -253,6 +290,14 @@ pub async fn sftp_download(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_cancel_transfer(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
sftp_id: String,
|
||||||
|
) -> Result<OperationResponse, String> {
|
||||||
|
Ok(operation(state.ssh.cancel_sftp_transfer(&sftp_id)))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sftp_download_dir(
|
pub async fn sftp_download_dir(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -275,28 +320,60 @@ pub async fn sftp_download_dir(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_rename(
|
pub async fn sftp_rename(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
old_path: String,
|
old_path: String,
|
||||||
new_path: String,
|
new_path: String,
|
||||||
) -> OperationResponse {
|
) -> Result<OperationResponse, String> {
|
||||||
operation(state.ssh.sftp_rename(&sftp_id, &old_path, &new_path))
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
service.sftp_rename(&sftp_id, &old_path, &new_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_delete(
|
pub async fn sftp_delete(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
is_directory: bool,
|
is_directory: bool,
|
||||||
) -> OperationResponse {
|
) -> Result<OperationResponse, String> {
|
||||||
operation(state.ssh.sftp_delete(&sftp_id, &path, is_directory))
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
service.sftp_delete(&sftp_id, &path, is_directory)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_mkdir(state: State<'_, AppState>, sftp_id: String, path: String) -> OperationResponse {
|
pub async fn sftp_mkdir(
|
||||||
operation(state.ssh.sftp_mkdir(&sftp_id, &path))
|
state: State<'_, AppState>,
|
||||||
|
sftp_id: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<OperationResponse, String> {
|
||||||
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.sftp_mkdir(&sftp_id, &path))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -322,9 +399,16 @@ pub struct SftpStat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_stat(state: State<'_, AppState>, sftp_id: String, path: String) -> SftpStatResponse {
|
pub async fn sftp_stat(
|
||||||
match state.ssh.sftp_stat(&sftp_id, &path) {
|
state: State<'_, AppState>,
|
||||||
Ok(stat) => {
|
sftp_id: String,
|
||||||
|
path: String,
|
||||||
|
) -> Result<SftpStatResponse, String> {
|
||||||
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.sftp_stat(&sftp_id, &path)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(stat)) => {
|
||||||
let mode = stat.perm.unwrap_or(0);
|
let mode = stat.perm.unwrap_or(0);
|
||||||
SftpStatResponse {
|
SftpStatResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -340,22 +424,38 @@ pub fn sftp_stat(state: State<'_, AppState>, sftp_id: String, path: String) -> S
|
|||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => SftpStatResponse {
|
Ok(Err(error)) => SftpStatResponse {
|
||||||
success: false,
|
success: false,
|
||||||
stats: None,
|
stats: None,
|
||||||
error: Some(error),
|
error: Some(error),
|
||||||
},
|
},
|
||||||
}
|
Err(error) => SftpStatResponse {
|
||||||
|
success: false,
|
||||||
|
stats: None,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_chmod(
|
pub async fn sftp_chmod(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
mode: u32,
|
mode: u32,
|
||||||
) -> OperationResponse {
|
) -> Result<OperationResponse, String> {
|
||||||
operation(state.ssh.sftp_chmod(&sftp_id, &path, mode))
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
service.sftp_chmod(&sftp_id, &path, mode)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -372,37 +472,59 @@ pub struct SftpReadTextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_read_text(
|
pub async fn sftp_read_text(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> SftpReadTextResponse {
|
) -> Result<SftpReadTextResponse, String> {
|
||||||
match state.ssh.sftp_read_text(&sftp_id, &path) {
|
let service = state.ssh.clone();
|
||||||
Ok((content, size)) => SftpReadTextResponse {
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || service.sftp_read_text(&sftp_id, &path))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok((content, size))) => SftpReadTextResponse {
|
||||||
success: true,
|
success: true,
|
||||||
content: Some(content),
|
content: Some(content),
|
||||||
size: Some(size),
|
size: Some(size),
|
||||||
encoding: Some("utf-8"),
|
encoding: Some("utf-8"),
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
Err(error) => SftpReadTextResponse {
|
Ok(Err(error)) => SftpReadTextResponse {
|
||||||
success: false,
|
success: false,
|
||||||
content: None,
|
content: None,
|
||||||
size: None,
|
size: None,
|
||||||
encoding: None,
|
encoding: None,
|
||||||
error: Some(error),
|
error: Some(error),
|
||||||
},
|
},
|
||||||
}
|
Err(error) => SftpReadTextResponse {
|
||||||
|
success: false,
|
||||||
|
content: None,
|
||||||
|
size: None,
|
||||||
|
encoding: None,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_write_text(
|
pub async fn sftp_write_text(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
sftp_id: String,
|
sftp_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
content: String,
|
content: String,
|
||||||
) -> OperationResponse {
|
) -> Result<OperationResponse, String> {
|
||||||
operation(state.ssh.sftp_write_text(&sftp_id, &path, &content))
|
let service = state.ssh.clone();
|
||||||
|
Ok(
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
service.sftp_write_text(&sftp_id, &path, &content)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => operation(result),
|
||||||
|
Err(error) => OperationResponse::error(error.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -410,6 +532,7 @@ pub fn ssh_forward_local(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
|
local_bind_host: Option<String>,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
remote_host: String,
|
remote_host: String,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
@@ -417,6 +540,7 @@ pub fn ssh_forward_local(
|
|||||||
forward(state.ssh.forward_local(
|
forward(state.ssh.forward_local(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
forward_id,
|
forward_id,
|
||||||
|
local_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||||
local_port,
|
local_port,
|
||||||
remote_host,
|
remote_host,
|
||||||
remote_port,
|
remote_port,
|
||||||
@@ -428,6 +552,7 @@ pub fn ssh_forward_remote(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
|
remote_bind_host: Option<String>,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
local_host: String,
|
local_host: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
@@ -435,6 +560,7 @@ pub fn ssh_forward_remote(
|
|||||||
forward(state.ssh.forward_remote(
|
forward(state.ssh.forward_remote(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
forward_id,
|
forward_id,
|
||||||
|
remote_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||||
remote_port,
|
remote_port,
|
||||||
local_host,
|
local_host,
|
||||||
local_port,
|
local_port,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub struct AppRuntimeInfo {
|
|||||||
ssh_backend: String,
|
ssh_backend: String,
|
||||||
data_path: String,
|
data_path: String,
|
||||||
credential_path: String,
|
credential_path: String,
|
||||||
|
log_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -46,6 +47,9 @@ pub fn app_runtime_info(app: AppHandle) -> Result<AppRuntimeInfo, String> {
|
|||||||
ssh_backend: "libssh2 / ssh2 0.9".to_string(),
|
ssh_backend: "libssh2 / ssh2 0.9".to_string(),
|
||||||
data_path: data_path.display().to_string(),
|
data_path: data_path.display().to_string(),
|
||||||
credential_path,
|
credential_path,
|
||||||
|
log_path: crate::infrastructure::file_log::log_file_path()
|
||||||
|
.display()
|
||||||
|
.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +63,18 @@ pub async fn toggle_maximize(window: WebviewWindow) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_fullscreen(window: WebviewWindow, fullscreen: bool) -> Result<(), String> {
|
||||||
|
window
|
||||||
|
.set_fullscreen(fullscreen)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_fullscreen(window: WebviewWindow) -> Result<bool, String> {
|
||||||
|
window.is_fullscreen().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn clipboard_read_text() -> Result<String, String> {
|
pub fn clipboard_read_text() -> Result<String, String> {
|
||||||
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
||||||
@@ -84,3 +100,8 @@ pub fn toggle_devtools(app: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn apply_native_menu_locale(app: AppHandle, locale: String) -> Result<String, String> {
|
||||||
|
crate::native_menu::apply_locale(&app, &locale)
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Size-capped application log file for release (and optional dev dual-write).
|
||||||
|
//!
|
||||||
|
//! Path: `{data_local}/opsxshell/logs/opsxshell.log`
|
||||||
|
//! Rotate at 10 MiB; keep `.1` and `.2` (3 files total).
|
||||||
|
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
const MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
const BACKUPS: usize = 2;
|
||||||
|
|
||||||
|
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Directory + active log file path used by this process.
|
||||||
|
pub fn log_file_path() -> PathBuf {
|
||||||
|
LOG_PATH
|
||||||
|
.get()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(default_log_file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_log_dir() -> PathBuf {
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.or_else(dirs::home_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("opsxshell")
|
||||||
|
.join("logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_file_path() -> PathBuf {
|
||||||
|
default_log_dir().join("opsxshell.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install tracing to a rolling file (and stderr in debug builds).
|
||||||
|
/// Safe to call once at process start; subsequent calls are no-ops if already set.
|
||||||
|
pub fn init() {
|
||||||
|
if tracing::dispatcher::has_been_set() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = default_log_file_path();
|
||||||
|
let _ = LOG_PATH.set(path.clone());
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
tracing_subscriber::EnvFilter::new("info")
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::EnvFilter::new("warn")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let file = match SizeRollingWriter::open(&path, MAX_BYTES, BACKUPS) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(error) => {
|
||||||
|
// Last resort: stderr only so the app still starts.
|
||||||
|
eprintln!(
|
||||||
|
"opsxshell: failed to open log file {}: {error}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(cfg!(debug_assertions))
|
||||||
|
.init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(false);
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
use tracing_subscriber::fmt::writer::MakeWriterExt;
|
||||||
|
subscriber.with_writer(std::io::stderr.and(file)).init();
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
subscriber.with_writer(file).init();
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(path = %path.display(), "application log ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SizeRollingWriter {
|
||||||
|
state: Arc<Mutex<RollingState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RollingState {
|
||||||
|
path: PathBuf,
|
||||||
|
file: Option<File>,
|
||||||
|
len: u64,
|
||||||
|
max_bytes: u64,
|
||||||
|
backups: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SizeRollingWriter {
|
||||||
|
fn open(path: &Path, max_bytes: u64, backups: usize) -> io::Result<Self> {
|
||||||
|
let file = open_append(path)?;
|
||||||
|
let len = file.metadata()?.len();
|
||||||
|
Ok(Self {
|
||||||
|
state: Arc::new(Mutex::new(RollingState {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
file: Some(file),
|
||||||
|
len,
|
||||||
|
max_bytes,
|
||||||
|
backups,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_append(path: &Path) -> io::Result<File> {
|
||||||
|
OpenOptions::new().create(true).append(true).open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RollingState {
|
||||||
|
fn rotate(&mut self) -> io::Result<()> {
|
||||||
|
if let Some(mut file) = self.file.take() {
|
||||||
|
file.flush()?;
|
||||||
|
drop(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift: .(n-1) -> .n … then current -> .1
|
||||||
|
for i in (1..=self.backups).rev() {
|
||||||
|
let from = if i == 1 {
|
||||||
|
self.path.clone()
|
||||||
|
} else {
|
||||||
|
backup_path(&self.path, i - 1)
|
||||||
|
};
|
||||||
|
let to = backup_path(&self.path, i);
|
||||||
|
if from.exists() {
|
||||||
|
let _ = fs::remove_file(&to);
|
||||||
|
let _ = fs::rename(&from, &to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file = Some(open_append(&self.path)?);
|
||||||
|
self.len = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writer(&mut self) -> io::Result<&mut File> {
|
||||||
|
if self.file.is_none() {
|
||||||
|
self.file = Some(open_append(&self.path)?);
|
||||||
|
self.len = self
|
||||||
|
.file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| f.metadata().ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
self.file
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| io::Error::other("log file unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backup_path(path: &Path, index: usize) -> PathBuf {
|
||||||
|
let mut s = path.as_os_str().to_os_string();
|
||||||
|
s.push(format!(".{index}"));
|
||||||
|
PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for SizeRollingWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("log lock poisoned"))?;
|
||||||
|
if state.len > 0 && state.len.saturating_add(buf.len() as u64) > state.max_bytes {
|
||||||
|
state.rotate()?;
|
||||||
|
}
|
||||||
|
let n = state.writer()?.write(buf)?;
|
||||||
|
state.len = state.len.saturating_add(n as u64);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("log lock poisoned"))?;
|
||||||
|
if let Some(file) = state.file.as_mut() {
|
||||||
|
file.flush()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MakeWriter<'a> for SizeRollingWriter {
|
||||||
|
type Writer = SizeRollingWriter;
|
||||||
|
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotates_when_over_max_bytes() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"opsxshell-log-test-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("opsxshell.log");
|
||||||
|
|
||||||
|
let mut writer = SizeRollingWriter::open(&path, 64, 2).unwrap();
|
||||||
|
writer
|
||||||
|
.write_all(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(b"bbbb").unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(backup_path(&path, 1).exists());
|
||||||
|
let current = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(current.contains("bbbb"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
//! Adapters for filesystem, SQLite, keychain, SSH transport, SFTP and PTY.
|
//! Adapters for filesystem, SQLite, keychain, SSH transport, SFTP and PTY.
|
||||||
//! They must not be called directly by Tauri commands.
|
//! They must not be called directly by Tauri commands.
|
||||||
|
|
||||||
|
pub mod file_log;
|
||||||
+19
-83
@@ -1,14 +1,13 @@
|
|||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
mod application;
|
mod application;
|
||||||
mod commands;
|
mod commands;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
mod infrastructure;
|
mod infrastructure;
|
||||||
|
mod native_menu;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
use application::AppState;
|
use application::AppState;
|
||||||
use tauri::{
|
use tauri::{Emitter, LogicalSize, Manager, Size};
|
||||||
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
|
||||||
Emitter, LogicalSize, Manager, Size,
|
|
||||||
};
|
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
||||||
/// Main window floor — keeps sidebar + settings + content usable.
|
/// Main window floor — keeps sidebar + settings + content usable.
|
||||||
@@ -45,10 +44,7 @@ pub fn run() {
|
|||||||
// Without an explicit process default, TLS (RDP CredSSP) panics on ClientConfig::builder().
|
// Without an explicit process default, TLS (RDP CredSSP) panics on ClientConfig::builder().
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
infrastructure::file_log::init();
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
||||||
.with_target(false)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
@@ -71,9 +67,12 @@ pub fn run() {
|
|||||||
commands::system::app_version,
|
commands::system::app_version,
|
||||||
commands::system::app_runtime_info,
|
commands::system::app_runtime_info,
|
||||||
commands::system::toggle_maximize,
|
commands::system::toggle_maximize,
|
||||||
|
commands::system::set_fullscreen,
|
||||||
|
commands::system::is_fullscreen,
|
||||||
commands::system::clipboard_read_text,
|
commands::system::clipboard_read_text,
|
||||||
commands::system::clipboard_write_text,
|
commands::system::clipboard_write_text,
|
||||||
commands::system::toggle_devtools,
|
commands::system::toggle_devtools,
|
||||||
|
commands::system::apply_native_menu_locale,
|
||||||
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,
|
||||||
@@ -89,6 +88,7 @@ pub fn run() {
|
|||||||
commands::desktop::rdp_input_key,
|
commands::desktop::rdp_input_key,
|
||||||
commands::desktop::rdp_clipboard_set,
|
commands::desktop::rdp_clipboard_set,
|
||||||
commands::desktop::rdp_upload_file,
|
commands::desktop::rdp_upload_file,
|
||||||
|
commands::desktop::rdp_resize,
|
||||||
commands::desktop::rdp_disconnect,
|
commands::desktop::rdp_disconnect,
|
||||||
commands::desktop::vnc_connect,
|
commands::desktop::vnc_connect,
|
||||||
commands::desktop::vnc_input_pointer,
|
commands::desktop::vnc_input_pointer,
|
||||||
@@ -107,11 +107,13 @@ pub fn run() {
|
|||||||
commands::ssh::ssh_shell_resize,
|
commands::ssh::ssh_shell_resize,
|
||||||
commands::ssh::ssh_close_shell,
|
commands::ssh::ssh_close_shell,
|
||||||
commands::ssh::sftp_connect,
|
commands::ssh::sftp_connect,
|
||||||
|
commands::ssh::sftp_disconnect,
|
||||||
commands::ssh::sftp_list_directory,
|
commands::ssh::sftp_list_directory,
|
||||||
commands::ssh::sftp_upload,
|
commands::ssh::sftp_upload,
|
||||||
commands::ssh::sftp_upload_dir,
|
commands::ssh::sftp_upload_dir,
|
||||||
commands::ssh::sftp_download,
|
commands::ssh::sftp_download,
|
||||||
commands::ssh::sftp_download_dir,
|
commands::ssh::sftp_download_dir,
|
||||||
|
commands::ssh::sftp_cancel_transfer,
|
||||||
commands::ssh::sftp_rename,
|
commands::ssh::sftp_rename,
|
||||||
commands::ssh::sftp_delete,
|
commands::ssh::sftp_delete,
|
||||||
commands::ssh::sftp_mkdir,
|
commands::ssh::sftp_mkdir,
|
||||||
@@ -152,11 +154,18 @@ pub fn run() {
|
|||||||
commands::license::license_activate,
|
commands::license::license_activate,
|
||||||
commands::license::license_clear,
|
commands::license::license_clear,
|
||||||
commands::license::license_device_id,
|
commands::license::license_device_id,
|
||||||
|
commands::license::license_set_email,
|
||||||
|
commands::license::license_checkout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let builder = builder
|
let builder = builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
crate::services::ssh::bind_app_handle(app.handle().clone());
|
crate::services::ssh::bind_app_handle(app.handle().clone());
|
||||||
|
// `.menu()` ran before PathResolver was ready; re-apply from saved UI language.
|
||||||
|
let locale = native_menu::resolve_saved_or_os_locale(app.handle());
|
||||||
|
if let Err(error) = native_menu::apply_locale(app.handle(), &locale) {
|
||||||
|
tracing::warn!("应用原生菜单语言失败: {error}");
|
||||||
|
}
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let min = LogicalSize::new(MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_MIN_HEIGHT);
|
let min = LogicalSize::new(MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_MIN_HEIGHT);
|
||||||
if let Err(error) = window.set_min_size(Some(Size::Logical(min))) {
|
if let Err(error) = window.set_min_size(Some(Size::Logical(min))) {
|
||||||
@@ -194,81 +203,8 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.menu(|app| {
|
.menu(|app| {
|
||||||
#[cfg(target_os = "macos")]
|
let locale = native_menu::resolve_startup_locale(app);
|
||||||
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
native_menu::build_menu(app, &locale)
|
||||||
.about(None)
|
|
||||||
.separator()
|
|
||||||
.services_with_text("服务")
|
|
||||||
.separator()
|
|
||||||
.hide_with_text("隐藏 OpsXShell")
|
|
||||||
.hide_others_with_text("隐藏其他")
|
|
||||||
.show_all_with_text("全部显示")
|
|
||||||
.separator()
|
|
||||||
.quit()
|
|
||||||
.build()?;
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
|
||||||
.about(None)
|
|
||||||
.separator()
|
|
||||||
.quit()
|
|
||||||
.build()?;
|
|
||||||
let new_session = MenuItemBuilder::with_id("new-session", "新建会话")
|
|
||||||
.accelerator("CmdOrCtrl+N")
|
|
||||||
.build(app)?;
|
|
||||||
let quick_connect = MenuItemBuilder::with_id("quick-connect", "快速连接")
|
|
||||||
.accelerator("CmdOrCtrl+K")
|
|
||||||
.build(app)?;
|
|
||||||
let local_terminal = MenuItemBuilder::with_id("local-terminal", "本地终端")
|
|
||||||
.accelerator("CmdOrCtrl+Shift+T")
|
|
||||||
.build(app)?;
|
|
||||||
let import_sessions =
|
|
||||||
MenuItemBuilder::with_id("import-sessions", "导入会话").build(app)?;
|
|
||||||
let export_sessions =
|
|
||||||
MenuItemBuilder::with_id("export-sessions", "导出会话").build(app)?;
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "文件")
|
|
||||||
.items(&[&new_session, &quick_connect, &local_terminal])
|
|
||||||
.separator()
|
|
||||||
.items(&[&import_sessions, &export_sessions])
|
|
||||||
.build()?;
|
|
||||||
// Keep native edit actions in the application menu. On macOS,
|
|
||||||
// WKWebView resolves Cmd+C/X/V/A through these selectors; omitting
|
|
||||||
// the menu makes ordinary React inputs appear unable to paste.
|
|
||||||
let edit_menu = SubmenuBuilder::new(app, "编辑")
|
|
||||||
.undo_with_text("撤销")
|
|
||||||
.redo_with_text("重做")
|
|
||||||
.separator()
|
|
||||||
.cut_with_text("剪切")
|
|
||||||
.copy_with_text("复制")
|
|
||||||
.paste_with_text("粘贴")
|
|
||||||
.select_all_with_text("全选")
|
|
||||||
.build()?;
|
|
||||||
let settings = MenuItemBuilder::with_id("open-settings", "设置")
|
|
||||||
.accelerator("CmdOrCtrl+,")
|
|
||||||
.build(app)?;
|
|
||||||
let devtools = MenuItemBuilder::with_id("toggle-devtools", "开发者工具")
|
|
||||||
.accelerator(if cfg!(target_os = "macos") {
|
|
||||||
"CmdOrCtrl+Alt+I"
|
|
||||||
} else {
|
|
||||||
"F12"
|
|
||||||
})
|
|
||||||
.build(app)?;
|
|
||||||
let view_menu = SubmenuBuilder::new(app, "视图")
|
|
||||||
.item(&settings)
|
|
||||||
.separator()
|
|
||||||
.item(&devtools)
|
|
||||||
.build()?;
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
let window_menu = SubmenuBuilder::new(app, "窗口")
|
|
||||||
.minimize_with_text("最小化")
|
|
||||||
.maximize_with_text("缩放")
|
|
||||||
.separator()
|
|
||||||
.close_window_with_text("关闭窗口")
|
|
||||||
.build()?;
|
|
||||||
let menu =
|
|
||||||
MenuBuilder::new(app).items(&[&app_menu, &file_menu, &edit_menu, &view_menu]);
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
let menu = menu.item(&window_menu);
|
|
||||||
menu.build()
|
|
||||||
})
|
})
|
||||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
"new-session" => {
|
"new-session" => {
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
//! Native application menu labels (File / Edit / View / …).
|
||||||
|
//!
|
||||||
|
//! React i18n does not reach the Tauri menu bar, so strings live here and are
|
||||||
|
//! rebuilt when the UI language changes or when the process starts.
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
||||||
|
AppHandle, Manager, Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::application::AppState;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct MenuCopy {
|
||||||
|
file: &'static str,
|
||||||
|
edit: &'static str,
|
||||||
|
view: &'static str,
|
||||||
|
window: &'static str,
|
||||||
|
new_session: &'static str,
|
||||||
|
quick_connect: &'static str,
|
||||||
|
local_terminal: &'static str,
|
||||||
|
import_sessions: &'static str,
|
||||||
|
export_sessions: &'static str,
|
||||||
|
settings: &'static str,
|
||||||
|
devtools: &'static str,
|
||||||
|
undo: &'static str,
|
||||||
|
redo: &'static str,
|
||||||
|
cut: &'static str,
|
||||||
|
copy: &'static str,
|
||||||
|
paste: &'static str,
|
||||||
|
select_all: &'static str,
|
||||||
|
minimize: &'static str,
|
||||||
|
maximize: &'static str,
|
||||||
|
close_window: &'static str,
|
||||||
|
services: &'static str,
|
||||||
|
hide: &'static str,
|
||||||
|
hide_others: &'static str,
|
||||||
|
show_all: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN: MenuCopy = MenuCopy {
|
||||||
|
file: "File",
|
||||||
|
edit: "Edit",
|
||||||
|
view: "View",
|
||||||
|
window: "Window",
|
||||||
|
new_session: "New Session",
|
||||||
|
quick_connect: "Quick Connect",
|
||||||
|
local_terminal: "Local Terminal",
|
||||||
|
import_sessions: "Import Sessions",
|
||||||
|
export_sessions: "Export Sessions",
|
||||||
|
settings: "Settings",
|
||||||
|
devtools: "Developer Tools",
|
||||||
|
undo: "Undo",
|
||||||
|
redo: "Redo",
|
||||||
|
cut: "Cut",
|
||||||
|
copy: "Copy",
|
||||||
|
paste: "Paste",
|
||||||
|
select_all: "Select All",
|
||||||
|
minimize: "Minimize",
|
||||||
|
maximize: "Zoom",
|
||||||
|
close_window: "Close Window",
|
||||||
|
services: "Services",
|
||||||
|
hide: "Hide OpsXShell",
|
||||||
|
hide_others: "Hide Others",
|
||||||
|
show_all: "Show All",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZH_CN: MenuCopy = MenuCopy {
|
||||||
|
file: "文件",
|
||||||
|
edit: "编辑",
|
||||||
|
view: "视图",
|
||||||
|
window: "窗口",
|
||||||
|
new_session: "新建会话",
|
||||||
|
quick_connect: "快速连接",
|
||||||
|
local_terminal: "本地终端",
|
||||||
|
import_sessions: "导入会话",
|
||||||
|
export_sessions: "导出会话",
|
||||||
|
settings: "设置",
|
||||||
|
devtools: "开发者工具",
|
||||||
|
undo: "撤销",
|
||||||
|
redo: "重做",
|
||||||
|
cut: "剪切",
|
||||||
|
copy: "复制",
|
||||||
|
paste: "粘贴",
|
||||||
|
select_all: "全选",
|
||||||
|
minimize: "最小化",
|
||||||
|
maximize: "缩放",
|
||||||
|
close_window: "关闭窗口",
|
||||||
|
services: "服务",
|
||||||
|
hide: "隐藏 OpsXShell",
|
||||||
|
hide_others: "隐藏其他",
|
||||||
|
show_all: "全部显示",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZH_TW: MenuCopy = MenuCopy {
|
||||||
|
file: "檔案",
|
||||||
|
edit: "編輯",
|
||||||
|
view: "檢視",
|
||||||
|
window: "視窗",
|
||||||
|
new_session: "新建工作階段",
|
||||||
|
quick_connect: "快速連線",
|
||||||
|
local_terminal: "本機終端機",
|
||||||
|
import_sessions: "匯入工作階段",
|
||||||
|
export_sessions: "匯出工作階段",
|
||||||
|
settings: "設定",
|
||||||
|
devtools: "開發者工具",
|
||||||
|
undo: "復原",
|
||||||
|
redo: "重做",
|
||||||
|
cut: "剪下",
|
||||||
|
copy: "複製",
|
||||||
|
paste: "貼上",
|
||||||
|
select_all: "全選",
|
||||||
|
minimize: "最小化",
|
||||||
|
maximize: "縮放",
|
||||||
|
close_window: "關閉視窗",
|
||||||
|
services: "服務",
|
||||||
|
hide: "隱藏 OpsXShell",
|
||||||
|
hide_others: "隱藏其他",
|
||||||
|
show_all: "全部顯示",
|
||||||
|
};
|
||||||
|
|
||||||
|
const JA: MenuCopy = MenuCopy {
|
||||||
|
file: "ファイル",
|
||||||
|
edit: "編集",
|
||||||
|
view: "表示",
|
||||||
|
window: "ウィンドウ",
|
||||||
|
new_session: "新しいセッション",
|
||||||
|
quick_connect: "クイック接続",
|
||||||
|
local_terminal: "ローカルターミナル",
|
||||||
|
import_sessions: "セッションをインポート",
|
||||||
|
export_sessions: "セッションをエクスポート",
|
||||||
|
settings: "設定",
|
||||||
|
devtools: "開発者ツール",
|
||||||
|
undo: "元に戻す",
|
||||||
|
redo: "やり直す",
|
||||||
|
cut: "切り取り",
|
||||||
|
copy: "コピー",
|
||||||
|
paste: "貼り付け",
|
||||||
|
select_all: "すべて選択",
|
||||||
|
minimize: "最小化",
|
||||||
|
maximize: "ズーム",
|
||||||
|
close_window: "ウィンドウを閉じる",
|
||||||
|
services: "サービス",
|
||||||
|
hide: "OpsXShell を隠す",
|
||||||
|
hide_others: "ほかを隠す",
|
||||||
|
show_all: "すべてを表示",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KO: MenuCopy = MenuCopy {
|
||||||
|
file: "파일",
|
||||||
|
edit: "편집",
|
||||||
|
view: "보기",
|
||||||
|
window: "창",
|
||||||
|
new_session: "새 세션",
|
||||||
|
quick_connect: "빠른 연결",
|
||||||
|
local_terminal: "로컬 터미널",
|
||||||
|
import_sessions: "세션 가져오기",
|
||||||
|
export_sessions: "세션 내보내기",
|
||||||
|
settings: "설정",
|
||||||
|
devtools: "개발자 도구",
|
||||||
|
undo: "실행 취소",
|
||||||
|
redo: "다시 실행",
|
||||||
|
cut: "잘라내기",
|
||||||
|
copy: "복사",
|
||||||
|
paste: "붙여넣기",
|
||||||
|
select_all: "모두 선택",
|
||||||
|
minimize: "최소화",
|
||||||
|
maximize: "확대/축소",
|
||||||
|
close_window: "창 닫기",
|
||||||
|
services: "서비스",
|
||||||
|
hide: "OpsXShell 가리기",
|
||||||
|
hide_others: "기타 가리기",
|
||||||
|
show_all: "모두 보기",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VI: MenuCopy = MenuCopy {
|
||||||
|
file: "Tệp",
|
||||||
|
edit: "Chỉnh sửa",
|
||||||
|
view: "Xem",
|
||||||
|
window: "Cửa sổ",
|
||||||
|
new_session: "Phiên mới",
|
||||||
|
quick_connect: "Kết nối nhanh",
|
||||||
|
local_terminal: "Terminal cục bộ",
|
||||||
|
import_sessions: "Nhập phiên",
|
||||||
|
export_sessions: "Xuất phiên",
|
||||||
|
settings: "Cài đặt",
|
||||||
|
devtools: "Công cụ nhà phát triển",
|
||||||
|
undo: "Hoàn tác",
|
||||||
|
redo: "Làm lại",
|
||||||
|
cut: "Cắt",
|
||||||
|
copy: "Sao chép",
|
||||||
|
paste: "Dán",
|
||||||
|
select_all: "Chọn tất cả",
|
||||||
|
minimize: "Thu nhỏ",
|
||||||
|
maximize: "Phóng to",
|
||||||
|
close_window: "Đóng cửa sổ",
|
||||||
|
services: "Dịch vụ",
|
||||||
|
hide: "Ẩn OpsXShell",
|
||||||
|
hide_others: "Ẩn ứng dụng khác",
|
||||||
|
show_all: "Hiện tất cả",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RU: MenuCopy = MenuCopy {
|
||||||
|
file: "Файл",
|
||||||
|
edit: "Правка",
|
||||||
|
view: "Вид",
|
||||||
|
window: "Окно",
|
||||||
|
new_session: "Новый сеанс",
|
||||||
|
quick_connect: "Быстрое подключение",
|
||||||
|
local_terminal: "Локальный терминал",
|
||||||
|
import_sessions: "Импорт сеансов",
|
||||||
|
export_sessions: "Экспорт сеансов",
|
||||||
|
settings: "Настройки",
|
||||||
|
devtools: "Инструменты разработчика",
|
||||||
|
undo: "Отменить",
|
||||||
|
redo: "Повторить",
|
||||||
|
cut: "Вырезать",
|
||||||
|
copy: "Копировать",
|
||||||
|
paste: "Вставить",
|
||||||
|
select_all: "Выделить всё",
|
||||||
|
minimize: "Свернуть",
|
||||||
|
maximize: "Масштаб",
|
||||||
|
close_window: "Закрыть окно",
|
||||||
|
services: "Службы",
|
||||||
|
hide: "Скрыть OpsXShell",
|
||||||
|
hide_others: "Скрыть остальные",
|
||||||
|
show_all: "Показать все",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn normalize_locale(raw: &str) -> &'static str {
|
||||||
|
let lower = raw.trim().to_ascii_lowercase().replace('_', "-");
|
||||||
|
if lower.starts_with("zh-tw")
|
||||||
|
|| lower.starts_with("zh-hk")
|
||||||
|
|| lower.starts_with("zh-mo")
|
||||||
|
|| lower.contains("hant")
|
||||||
|
{
|
||||||
|
return "zh-TW";
|
||||||
|
}
|
||||||
|
if lower.starts_with("zh") {
|
||||||
|
return "zh-CN";
|
||||||
|
}
|
||||||
|
if lower.starts_with("ja") {
|
||||||
|
return "ja";
|
||||||
|
}
|
||||||
|
if lower.starts_with("ko") {
|
||||||
|
return "ko";
|
||||||
|
}
|
||||||
|
if lower.starts_with("vi") {
|
||||||
|
return "vi";
|
||||||
|
}
|
||||||
|
if lower.starts_with("ru") {
|
||||||
|
return "ru";
|
||||||
|
}
|
||||||
|
if lower.starts_with("en") {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
"en"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_for(locale: &str) -> MenuCopy {
|
||||||
|
match normalize_locale(locale) {
|
||||||
|
"zh-CN" => ZH_CN,
|
||||||
|
"zh-TW" => ZH_TW,
|
||||||
|
"ja" => JA,
|
||||||
|
"ko" => KO,
|
||||||
|
"vi" => VI,
|
||||||
|
"ru" => RU,
|
||||||
|
_ => EN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OS locale only — settings storage is unavailable while `.menu()` runs
|
||||||
|
/// (PathResolver is not managed yet).
|
||||||
|
pub fn resolve_startup_locale(_app: &AppHandle) -> String {
|
||||||
|
detect_os_locale()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After setup: prefer saved UI language, else keep OS detection.
|
||||||
|
pub fn resolve_saved_or_os_locale(app: &AppHandle) -> String {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
if let Ok(Some(value)) = state.storage.get_setting(app, "language") {
|
||||||
|
if let Some(raw) = value.as_str() {
|
||||||
|
return normalize_locale(raw).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detect_os_locale()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_os_locale() -> String {
|
||||||
|
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||||
|
if let Ok(value) = std::env::var(key) {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() && trimmed != "C" && trimmed != "POSIX" {
|
||||||
|
return normalize_locale(trimmed).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"en".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_menu<R: Runtime>(app: &AppHandle<R>, locale: &str) -> tauri::Result<Menu<R>> {
|
||||||
|
let t = copy_for(locale);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
||||||
|
.about(None)
|
||||||
|
.separator()
|
||||||
|
.services_with_text(t.services)
|
||||||
|
.separator()
|
||||||
|
.hide_with_text(t.hide)
|
||||||
|
.hide_others_with_text(t.hide_others)
|
||||||
|
.show_all_with_text(t.show_all)
|
||||||
|
.separator()
|
||||||
|
.quit()
|
||||||
|
.build()?;
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
||||||
|
.about(None)
|
||||||
|
.separator()
|
||||||
|
.quit()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let new_session = MenuItemBuilder::with_id("new-session", t.new_session)
|
||||||
|
.accelerator("CmdOrCtrl+N")
|
||||||
|
.build(app)?;
|
||||||
|
let quick_connect = MenuItemBuilder::with_id("quick-connect", t.quick_connect)
|
||||||
|
.accelerator("CmdOrCtrl+K")
|
||||||
|
.build(app)?;
|
||||||
|
let local_terminal = MenuItemBuilder::with_id("local-terminal", t.local_terminal)
|
||||||
|
.accelerator("CmdOrCtrl+Shift+T")
|
||||||
|
.build(app)?;
|
||||||
|
let import_sessions =
|
||||||
|
MenuItemBuilder::with_id("import-sessions", t.import_sessions).build(app)?;
|
||||||
|
let export_sessions =
|
||||||
|
MenuItemBuilder::with_id("export-sessions", t.export_sessions).build(app)?;
|
||||||
|
let file_menu = SubmenuBuilder::new(app, t.file)
|
||||||
|
.items(&[&new_session, &quick_connect, &local_terminal])
|
||||||
|
.separator()
|
||||||
|
.items(&[&import_sessions, &export_sessions])
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Keep native edit actions. On macOS WKWebView resolves Cmd+C/X/V/A through
|
||||||
|
// these selectors; omitting the menu makes ordinary React inputs unable to paste.
|
||||||
|
let edit_menu = SubmenuBuilder::new(app, t.edit)
|
||||||
|
.undo_with_text(t.undo)
|
||||||
|
.redo_with_text(t.redo)
|
||||||
|
.separator()
|
||||||
|
.cut_with_text(t.cut)
|
||||||
|
.copy_with_text(t.copy)
|
||||||
|
.paste_with_text(t.paste)
|
||||||
|
.select_all_with_text(t.select_all)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let settings = MenuItemBuilder::with_id("open-settings", t.settings)
|
||||||
|
.accelerator("CmdOrCtrl+,")
|
||||||
|
.build(app)?;
|
||||||
|
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
||||||
|
.accelerator(if cfg!(target_os = "macos") {
|
||||||
|
"CmdOrCtrl+Alt+I"
|
||||||
|
} else {
|
||||||
|
"F12"
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
let view_menu = SubmenuBuilder::new(app, t.view)
|
||||||
|
.item(&settings)
|
||||||
|
.separator()
|
||||||
|
.item(&devtools)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
let window_menu = SubmenuBuilder::new(app, t.window)
|
||||||
|
.minimize_with_text(t.minimize)
|
||||||
|
.maximize_with_text(t.maximize)
|
||||||
|
.separator()
|
||||||
|
.close_window_with_text(t.close_window)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let menu = MenuBuilder::new(app).items(&[&app_menu, &file_menu, &edit_menu, &view_menu]);
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
let menu = menu.item(&window_menu);
|
||||||
|
menu.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_locale(app: &AppHandle, locale: &str) -> Result<String, String> {
|
||||||
|
let normalized = normalize_locale(locale).to_string();
|
||||||
|
let menu = build_menu(app, &normalized).map_err(|error| error.to_string())?;
|
||||||
|
app.set_menu(menu).map_err(|error| error.to_string())?;
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_os_and_ui_tags() {
|
||||||
|
assert_eq!(normalize_locale("en_US.UTF-8"), "en");
|
||||||
|
assert_eq!(normalize_locale("zh_CN.UTF-8"), "zh-CN");
|
||||||
|
assert_eq!(normalize_locale("zh-TW"), "zh-TW");
|
||||||
|
assert_eq!(normalize_locale("ja_JP"), "ja");
|
||||||
|
assert_eq!(normalize_locale("pt-BR"), "en");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,7 +315,7 @@ fn validate_provider(provider: &AiProviderConfig, api_key: Option<&str>) -> Resu
|
|||||||
if provider.id.trim().is_empty() || provider.name.trim().is_empty() {
|
if provider.id.trim().is_empty() || provider.name.trim().is_empty() {
|
||||||
return Err("Provider 配置无效".to_string());
|
return Err("Provider 配置无效".to_string());
|
||||||
}
|
}
|
||||||
if provider.auth_style != "none" && api_key.map_or(true, str::is_empty) {
|
if provider.auth_style != "none" && api_key.is_none_or(str::is_empty) {
|
||||||
return Err(format!("Provider「{}」未保存 API Key", provider.name));
|
return Err(format!("Provider「{}」未保存 API Key", provider.name));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub enum DesktopInput {
|
|||||||
ClipboardSet { text: String },
|
ClipboardSet { text: String },
|
||||||
/// Place a file into the host Downloads folder for RDP RDPDR.
|
/// Place a file into the host Downloads folder for RDP RDPDR.
|
||||||
UploadFile { name: String, data: Vec<u8> },
|
UploadFile { name: String, data: Vec<u8> },
|
||||||
|
/// Request remote desktop resize via RDP Display Control (ignored by VNC).
|
||||||
|
Resize { width: u16, height: u16 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coalesce dirty rects and rate-limit IPC emits (~30 FPS).
|
/// Coalesce dirty rects and rate-limit IPC emits (~30 FPS).
|
||||||
@@ -69,6 +71,11 @@ impl FrameEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop coalesced dirty rects (e.g. after DisplayControl resize / new framebuffer).
|
||||||
|
pub fn clear_pending(&mut self) {
|
||||||
|
self.pending = None;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn flush_if_due(
|
pub fn flush_if_due(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
@@ -86,6 +93,16 @@ impl FrameEmitter {
|
|||||||
let Some((left, top, right, bottom)) = self.pending.take() else {
|
let Some((left, top, right, bottom)) = self.pending.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Clamp to current framebuffer — stale rects after resize must not be extracted.
|
||||||
|
if fb_width == 0 || fb_height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_x = fb_width.saturating_sub(1);
|
||||||
|
let max_y = fb_height.saturating_sub(1);
|
||||||
|
let left = left.min(max_x);
|
||||||
|
let top = top.min(max_y);
|
||||||
|
let right = right.min(max_x).max(left);
|
||||||
|
let bottom = bottom.min(max_y).max(top);
|
||||||
let width = right.saturating_sub(left).saturating_add(1);
|
let width = right.saturating_sub(left).saturating_add(1);
|
||||||
let height = bottom.saturating_sub(top).saturating_add(1);
|
let height = bottom.saturating_sub(top).saturating_add(1);
|
||||||
let Some(rgba) = rgba_extractor(left, top, width, height) else {
|
let Some(rgba) = rgba_extractor(left, top, width, height) else {
|
||||||
@@ -206,11 +223,15 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
|
|||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(queued.len());
|
let mut out = Vec::with_capacity(queued.len());
|
||||||
let mut pending_move: Option<DesktopInput> = None;
|
let mut pending_move: Option<DesktopInput> = None;
|
||||||
|
let mut pending_resize: Option<DesktopInput> = None;
|
||||||
for event in queued.drain(..) {
|
for event in queued.drain(..) {
|
||||||
match &event {
|
match &event {
|
||||||
DesktopInput::Pointer { moving: true, .. } => {
|
DesktopInput::Pointer { moving: true, .. } => {
|
||||||
pending_move = Some(event);
|
pending_move = Some(event);
|
||||||
}
|
}
|
||||||
|
DesktopInput::Resize { .. } => {
|
||||||
|
pending_resize = Some(event);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(mv) = pending_move.take() {
|
if let Some(mv) = pending_move.take() {
|
||||||
out.push(mv);
|
out.push(mv);
|
||||||
@@ -222,6 +243,9 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
|
|||||||
if let Some(mv) = pending_move {
|
if let Some(mv) = pending_move {
|
||||||
out.push(mv);
|
out.push(mv);
|
||||||
}
|
}
|
||||||
|
if let Some(resize) = pending_resize {
|
||||||
|
out.push(resize);
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ impl RdpService {
|
|||||||
self.push_input(session_id, DesktopInput::UploadFile { name, data })
|
self.push_input(session_id, DesktopInput::UploadFile { name, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
|
||||||
|
if !(200..=8192).contains(&width) || !(200..=8192).contains(&height) {
|
||||||
|
return Err("分辨率超出允许范围 (200–8192)".into());
|
||||||
|
}
|
||||||
|
self.push_input(session_id, DesktopInput::Resize { width, height })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||||
if let Some(session) = self
|
if let Some(session) = self
|
||||||
.sessions
|
.sessions
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ use std::{
|
|||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use ironrdp::cliprdr::backend::ClipboardMessage;
|
use ironrdp::cliprdr::backend::ClipboardMessage;
|
||||||
use ironrdp::cliprdr::{Cliprdr, CliprdrClient};
|
use ironrdp::cliprdr::{Cliprdr, CliprdrClient};
|
||||||
use ironrdp::connector::{self, ConnectionResult, Credentials};
|
use ironrdp::connector::connection_activation::{
|
||||||
|
ConnectionActivationFactory, ConnectionActivationState,
|
||||||
|
};
|
||||||
|
use ironrdp::connector::{self, ConnectionResult, Credentials, Sequence as _};
|
||||||
use ironrdp::core::WriteBuf;
|
use ironrdp::core::WriteBuf;
|
||||||
|
use ironrdp::displaycontrol::client::DisplayControlClient;
|
||||||
|
use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
|
||||||
|
use ironrdp::dvc::DrdynvcClient;
|
||||||
use ironrdp::pdu::gcc::KeyboardType;
|
use ironrdp::pdu::gcc::KeyboardType;
|
||||||
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
|
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
|
||||||
use ironrdp::pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags};
|
use ironrdp::pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags};
|
||||||
@@ -25,8 +31,10 @@ use ironrdp::pdu::rdp::headers::ShareDataPdu;
|
|||||||
use ironrdp::pdu::rdp::suppress_output::SuppressOutputPdu;
|
use ironrdp::pdu::rdp::suppress_output::SuppressOutputPdu;
|
||||||
use ironrdp::rdpdr::Rdpdr;
|
use ironrdp::rdpdr::Rdpdr;
|
||||||
use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
|
use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
|
||||||
|
use ironrdp::session::fast_path::ProcessorBuilder;
|
||||||
use ironrdp::session::image::DecodedImage;
|
use ironrdp::session::image::DecodedImage;
|
||||||
use ironrdp::session::{ActiveStage, ActiveStageBuilder, ActiveStageOutput};
|
use ironrdp::session::{ActiveStage, ActiveStageBuilder, ActiveStageOutput};
|
||||||
|
use ironrdp_bulk::BulkCompressor;
|
||||||
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
|
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio_rustls::rustls;
|
use tokio_rustls::rustls;
|
||||||
@@ -174,8 +182,15 @@ fn run_session_inner(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let fb_width = connection_result.desktop_size.width;
|
let mut fb_width = connection_result.desktop_size.width;
|
||||||
let fb_height = connection_result.desktop_size.height;
|
let mut fb_height = connection_result.desktop_size.height;
|
||||||
|
let io_channel_id = connection_result.io_channel_id;
|
||||||
|
let user_channel_id = connection_result.user_channel_id;
|
||||||
|
let compression_type = connection_result.compression_type;
|
||||||
|
let mut share_id = connection_result.share_id;
|
||||||
|
let activation_factory = connection_result.activation_factory;
|
||||||
|
let mut enable_server_pointer = connection_result.enable_server_pointer;
|
||||||
|
let mut pointer_software_rendering = connection_result.pointer_software_rendering;
|
||||||
|
|
||||||
let mut image = DecodedImage::new(
|
let mut image = DecodedImage::new(
|
||||||
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
|
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
|
||||||
@@ -185,13 +200,13 @@ fn run_session_inner(
|
|||||||
|
|
||||||
let mut active_stage = ActiveStageBuilder {
|
let mut active_stage = ActiveStageBuilder {
|
||||||
static_channels: connection_result.static_channels,
|
static_channels: connection_result.static_channels,
|
||||||
user_channel_id: connection_result.user_channel_id,
|
user_channel_id,
|
||||||
io_channel_id: connection_result.io_channel_id,
|
io_channel_id,
|
||||||
message_channel_id: connection_result.message_channel_id,
|
message_channel_id: connection_result.message_channel_id,
|
||||||
share_id: connection_result.share_id,
|
share_id,
|
||||||
compression_type: connection_result.compression_type,
|
compression_type,
|
||||||
enable_server_pointer: connection_result.enable_server_pointer,
|
enable_server_pointer,
|
||||||
pointer_software_rendering: connection_result.pointer_software_rendering,
|
pointer_software_rendering,
|
||||||
}
|
}
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -219,6 +234,7 @@ fn run_session_inner(
|
|||||||
|
|
||||||
// Avoid shipping a full black framebuffer over IPC; wait for real GraphicsUpdate.
|
// Avoid shipping a full black framebuffer over IPC; wait for real GraphicsUpdate.
|
||||||
let mut frame_emitter = FrameEmitter::new("rdp:frame", session_id);
|
let mut frame_emitter = FrameEmitter::new("rdp:frame", session_id);
|
||||||
|
let mut pending_resize: Option<(u16, u16)> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if stop.load(Ordering::Relaxed) {
|
if stop.load(Ordering::Relaxed) {
|
||||||
@@ -236,6 +252,23 @@ fn run_session_inner(
|
|||||||
&clip_outbound,
|
&clip_outbound,
|
||||||
&want_paste_inject,
|
&want_paste_inject,
|
||||||
&mut frame_emitter,
|
&mut frame_emitter,
|
||||||
|
&mut fb_width,
|
||||||
|
&mut fb_height,
|
||||||
|
&mut share_id,
|
||||||
|
&mut enable_server_pointer,
|
||||||
|
&mut pointer_software_rendering,
|
||||||
|
&activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
|
&mut pending_resize,
|
||||||
|
)?;
|
||||||
|
try_pending_resize(
|
||||||
|
&mut active_stage,
|
||||||
|
&mut framed,
|
||||||
|
&mut pending_resize,
|
||||||
|
fb_width,
|
||||||
|
fb_height,
|
||||||
)?;
|
)?;
|
||||||
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
||||||
if pending_paste_inject.swap(false, Ordering::Relaxed) {
|
if pending_paste_inject.swap(false, Ordering::Relaxed) {
|
||||||
@@ -246,6 +279,15 @@ fn run_session_inner(
|
|||||||
&mut image,
|
&mut image,
|
||||||
&mut framed,
|
&mut framed,
|
||||||
&mut frame_emitter,
|
&mut frame_emitter,
|
||||||
|
&mut fb_width,
|
||||||
|
&mut fb_height,
|
||||||
|
&mut share_id,
|
||||||
|
&mut enable_server_pointer,
|
||||||
|
&mut pointer_software_rendering,
|
||||||
|
&activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
drive_cliprdr_timeouts(&mut active_stage, &mut framed)?;
|
drive_cliprdr_timeouts(&mut active_stage, &mut framed)?;
|
||||||
@@ -264,9 +306,31 @@ fn run_session_inner(
|
|||||||
Err(error) => return Err(anyhow::Error::new(error).context("读取 RDP 帧失败")),
|
Err(error) => return Err(anyhow::Error::new(error).context("读取 RDP 帧失败")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let outputs = active_stage
|
let outputs = match active_stage.process(&mut image, action, &payload) {
|
||||||
.process(&mut image, action, &payload)
|
Ok(outputs) => outputs,
|
||||||
.map_err(|e| anyhow::anyhow!("处理 RDP 帧失败: {e}"))?;
|
Err(error) => {
|
||||||
|
let text = error.to_string();
|
||||||
|
if text.contains("bulk decompression failed") {
|
||||||
|
// Prefer surviving a single bad compressed PDU over killing the session
|
||||||
|
// (history desync after resize is the usual trigger).
|
||||||
|
warn!(%text, "RDP bulk decompression failed — resetting FastPath decompressor");
|
||||||
|
frame_emitter.clear_pending();
|
||||||
|
active_stage.set_fastpath_processor(
|
||||||
|
ProcessorBuilder {
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
bulk_decompressor: build_bulk_decompressor(compression_type),
|
||||||
|
}
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(map_fast_path_error(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Backend callbacks during `process` may queue CLIPRDR replies (paste / format data).
|
// Backend callbacks during `process` may queue CLIPRDR replies (paste / format data).
|
||||||
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
||||||
@@ -278,6 +342,15 @@ fn run_session_inner(
|
|||||||
&mut image,
|
&mut image,
|
||||||
&mut framed,
|
&mut framed,
|
||||||
&mut frame_emitter,
|
&mut frame_emitter,
|
||||||
|
&mut fb_width,
|
||||||
|
&mut fb_height,
|
||||||
|
&mut share_id,
|
||||||
|
&mut enable_server_pointer,
|
||||||
|
&mut pointer_software_rendering,
|
||||||
|
&activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +358,17 @@ fn run_session_inner(
|
|||||||
app,
|
app,
|
||||||
session_id,
|
session_id,
|
||||||
&mut framed,
|
&mut framed,
|
||||||
&image,
|
&mut active_stage,
|
||||||
fb_width,
|
&mut image,
|
||||||
fb_height,
|
&mut fb_width,
|
||||||
|
&mut fb_height,
|
||||||
|
&mut share_id,
|
||||||
|
&mut enable_server_pointer,
|
||||||
|
&mut pointer_software_rendering,
|
||||||
|
&activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
outputs,
|
outputs,
|
||||||
&mut frame_emitter,
|
&mut frame_emitter,
|
||||||
)? {
|
)? {
|
||||||
@@ -368,6 +449,16 @@ fn drain_and_send_input(
|
|||||||
clip_outbound: &Mutex<Vec<ClipboardMessage>>,
|
clip_outbound: &Mutex<Vec<ClipboardMessage>>,
|
||||||
want_paste_inject: &AtomicBool,
|
want_paste_inject: &AtomicBool,
|
||||||
frame_emitter: &mut FrameEmitter,
|
frame_emitter: &mut FrameEmitter,
|
||||||
|
fb_width: &mut u16,
|
||||||
|
fb_height: &mut u16,
|
||||||
|
share_id: &mut u32,
|
||||||
|
enable_server_pointer: &mut bool,
|
||||||
|
pointer_software_rendering: &mut bool,
|
||||||
|
activation_factory: &ConnectionActivationFactory,
|
||||||
|
io_channel_id: u16,
|
||||||
|
user_channel_id: u16,
|
||||||
|
compression_type: Option<CompressionType>,
|
||||||
|
pending_resize: &mut Option<(u16, u16)>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let queued = {
|
let queued = {
|
||||||
let mut guard = input
|
let mut guard = input
|
||||||
@@ -379,8 +470,6 @@ fn drain_and_send_input(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let fb_width = image.width();
|
|
||||||
let fb_height = image.height();
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for event in queued {
|
for event in queued {
|
||||||
match event {
|
match event {
|
||||||
@@ -403,8 +492,8 @@ fn drain_and_send_input(
|
|||||||
session_id,
|
session_id,
|
||||||
"connected",
|
"connected",
|
||||||
Some(error),
|
Some(error),
|
||||||
Some(fb_width),
|
Some(*fb_width),
|
||||||
Some(fb_height),
|
Some(*fb_height),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
emit_status(
|
emit_status(
|
||||||
@@ -413,13 +502,24 @@ fn drain_and_send_input(
|
|||||||
session_id,
|
session_id,
|
||||||
"connected",
|
"connected",
|
||||||
Some("已放入共享盘「下载」,远端此电脑可打开".into()),
|
Some("已放入共享盘「下载」,远端此电脑可打开".into()),
|
||||||
Some(fb_width),
|
Some(*fb_width),
|
||||||
Some(fb_height),
|
Some(*fb_height),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DesktopInput::Resize { width, height } => {
|
||||||
|
queue_or_send_resize(
|
||||||
|
active_stage,
|
||||||
|
framed,
|
||||||
|
pending_resize,
|
||||||
|
*fb_width,
|
||||||
|
*fb_height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
if let Some(fast) = map_desktop_input(other) {
|
if let Some(fast) = map_desktop_input(other) {
|
||||||
events.push(fast);
|
events.push(fast);
|
||||||
@@ -438,9 +538,17 @@ fn drain_and_send_input(
|
|||||||
app,
|
app,
|
||||||
session_id,
|
session_id,
|
||||||
framed,
|
framed,
|
||||||
|
active_stage,
|
||||||
image,
|
image,
|
||||||
fb_width,
|
fb_width,
|
||||||
fb_height,
|
fb_height,
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
outputs,
|
outputs,
|
||||||
frame_emitter,
|
frame_emitter,
|
||||||
)?;
|
)?;
|
||||||
@@ -454,6 +562,15 @@ fn inject_remote_paste(
|
|||||||
image: &mut DecodedImage,
|
image: &mut DecodedImage,
|
||||||
framed: &mut UpgradedFramed,
|
framed: &mut UpgradedFramed,
|
||||||
frame_emitter: &mut FrameEmitter,
|
frame_emitter: &mut FrameEmitter,
|
||||||
|
fb_width: &mut u16,
|
||||||
|
fb_height: &mut u16,
|
||||||
|
share_id: &mut u32,
|
||||||
|
enable_server_pointer: &mut bool,
|
||||||
|
pointer_software_rendering: &mut bool,
|
||||||
|
activation_factory: &ConnectionActivationFactory,
|
||||||
|
io_channel_id: u16,
|
||||||
|
user_channel_id: u16,
|
||||||
|
compression_type: Option<CompressionType>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Left Ctrl (0x1D) + V (0x2F) — Windows paste after CLIPRDR FormatList accepted.
|
// Left Ctrl (0x1D) + V (0x2F) — Windows paste after CLIPRDR FormatList accepted.
|
||||||
let events = [
|
let events = [
|
||||||
@@ -462,8 +579,6 @@ fn inject_remote_paste(
|
|||||||
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x2F),
|
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x2F),
|
||||||
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x1D),
|
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x1D),
|
||||||
];
|
];
|
||||||
let fb_width = image.width();
|
|
||||||
let fb_height = image.height();
|
|
||||||
let outputs = active_stage
|
let outputs = active_stage
|
||||||
.process_fastpath_input(image, &events)
|
.process_fastpath_input(image, &events)
|
||||||
.map_err(|e| anyhow::anyhow!("注入粘贴失败: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("注入粘贴失败: {e}"))?;
|
||||||
@@ -471,9 +586,17 @@ fn inject_remote_paste(
|
|||||||
app,
|
app,
|
||||||
session_id,
|
session_id,
|
||||||
framed,
|
framed,
|
||||||
|
active_stage,
|
||||||
image,
|
image,
|
||||||
fb_width,
|
fb_width,
|
||||||
fb_height,
|
fb_height,
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
outputs,
|
outputs,
|
||||||
frame_emitter,
|
frame_emitter,
|
||||||
)?;
|
)?;
|
||||||
@@ -535,7 +658,9 @@ fn map_desktop_input(event: DesktopInput) -> Option<FastPathInputEvent> {
|
|||||||
}
|
}
|
||||||
Some(FastPathInputEvent::KeyboardEvent(flags, scan))
|
Some(FastPathInputEvent::KeyboardEvent(flags, scan))
|
||||||
}
|
}
|
||||||
DesktopInput::ClipboardSet { .. } | DesktopInput::UploadFile { .. } => None,
|
DesktopInput::ClipboardSet { .. }
|
||||||
|
| DesktopInput::UploadFile { .. }
|
||||||
|
| DesktopInput::Resize { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,13 +753,22 @@ fn apply_outputs(
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
framed: &mut UpgradedFramed,
|
framed: &mut UpgradedFramed,
|
||||||
image: &DecodedImage,
|
active_stage: &mut ActiveStage,
|
||||||
fb_width: u16,
|
image: &mut DecodedImage,
|
||||||
fb_height: u16,
|
fb_width: &mut u16,
|
||||||
|
fb_height: &mut u16,
|
||||||
|
share_id: &mut u32,
|
||||||
|
enable_server_pointer: &mut bool,
|
||||||
|
pointer_software_rendering: &mut bool,
|
||||||
|
activation_factory: &ConnectionActivationFactory,
|
||||||
|
io_channel_id: u16,
|
||||||
|
user_channel_id: u16,
|
||||||
|
compression_type: Option<CompressionType>,
|
||||||
outputs: Vec<ActiveStageOutput>,
|
outputs: Vec<ActiveStageOutput>,
|
||||||
frame_emitter: &mut FrameEmitter,
|
frame_emitter: &mut FrameEmitter,
|
||||||
) -> anyhow::Result<bool> {
|
) -> anyhow::Result<bool> {
|
||||||
let mut terminate = false;
|
let mut terminate = false;
|
||||||
|
let mut need_reactivate = false;
|
||||||
for out in outputs {
|
for out in outputs {
|
||||||
match out {
|
match out {
|
||||||
ActiveStageOutput::ResponseFrame(frame) => {
|
ActiveStageOutput::ResponseFrame(frame) => {
|
||||||
@@ -649,15 +783,239 @@ fn apply_outputs(
|
|||||||
terminate = true;
|
terminate = true;
|
||||||
}
|
}
|
||||||
ActiveStageOutput::DeactivateAll => {
|
ActiveStageOutput::DeactivateAll => {
|
||||||
warn!(session_id, "RDP DeactivateAll received");
|
need_reactivate = true;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = (app, image, fb_width, fb_height);
|
if need_reactivate {
|
||||||
|
frame_emitter.clear_pending();
|
||||||
|
reactivate_after_deactivate(
|
||||||
|
app,
|
||||||
|
session_id,
|
||||||
|
framed,
|
||||||
|
active_stage,
|
||||||
|
image,
|
||||||
|
fb_width,
|
||||||
|
fb_height,
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
activation_factory,
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
compression_type,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(terminate)
|
Ok(terminate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn queue_or_send_resize(
|
||||||
|
active_stage: &mut ActiveStage,
|
||||||
|
framed: &mut UpgradedFramed,
|
||||||
|
pending_resize: &mut Option<(u16, u16)>,
|
||||||
|
fb_width: u16,
|
||||||
|
fb_height: u16,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (adj_w, adj_h) =
|
||||||
|
MonitorLayoutEntry::adjust_display_size(u32::from(width), u32::from(height));
|
||||||
|
let width = u16::try_from(adj_w).unwrap_or(fb_width);
|
||||||
|
let height = u16::try_from(adj_h).unwrap_or(fb_height);
|
||||||
|
if width == fb_width && height == fb_height {
|
||||||
|
*pending_resize = None;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match active_stage.encode_resize(u32::from(width), u32::from(height), Some(100), None) {
|
||||||
|
Some(Ok(frame)) => {
|
||||||
|
*pending_resize = None;
|
||||||
|
if !frame.is_empty() {
|
||||||
|
framed
|
||||||
|
.write_all(&frame)
|
||||||
|
.context("写入 DisplayControl 分辨率请求失败")?;
|
||||||
|
}
|
||||||
|
info!(width, height, "RDP DisplayControl resize requested");
|
||||||
|
}
|
||||||
|
Some(Err(error)) => {
|
||||||
|
return Err(anyhow::anyhow!("编码 DisplayControl 分辨率失败: {error}"));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!(width, height, "DisplayControl not ready — defer resize");
|
||||||
|
*pending_resize = Some((width, height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_pending_resize(
|
||||||
|
active_stage: &mut ActiveStage,
|
||||||
|
framed: &mut UpgradedFramed,
|
||||||
|
pending_resize: &mut Option<(u16, u16)>,
|
||||||
|
fb_width: u16,
|
||||||
|
fb_height: u16,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some((width, height)) = *pending_resize else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
queue_or_send_resize(
|
||||||
|
active_stage,
|
||||||
|
framed,
|
||||||
|
pending_resize,
|
||||||
|
fb_width,
|
||||||
|
fb_height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_fast_path_error(error: impl std::fmt::Display) -> anyhow::Error {
|
||||||
|
let text = error.to_string();
|
||||||
|
if text.contains("bulk decompression failed") {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"处理 RDP 帧失败: 远端图像压缩解压异常(常见于分辨率切换后)。请断开后重连。"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Drop crate source paths like `[FastPath @ /Users/.../fast_path.rs:110]`.
|
||||||
|
let short = text
|
||||||
|
.split(" @ ")
|
||||||
|
.next()
|
||||||
|
.unwrap_or(text.as_str())
|
||||||
|
.trim_end_matches([']', ' ']);
|
||||||
|
anyhow::anyhow!("处理 RDP 帧失败: {short}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bulk_decompressor(compression_type: Option<CompressionType>) -> Option<BulkCompressor> {
|
||||||
|
compression_type.and_then(|ct| {
|
||||||
|
let bulk_ct = match ct {
|
||||||
|
CompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4,
|
||||||
|
CompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5,
|
||||||
|
CompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6,
|
||||||
|
CompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61,
|
||||||
|
};
|
||||||
|
match BulkCompressor::new(bulk_ct) {
|
||||||
|
Ok(compressor) => Some(compressor),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "Failed to recreate bulk decompressor after resize");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reactivate_after_deactivate(
|
||||||
|
app: &AppHandle,
|
||||||
|
session_id: &str,
|
||||||
|
framed: &mut UpgradedFramed,
|
||||||
|
active_stage: &mut ActiveStage,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
fb_width: &mut u16,
|
||||||
|
fb_height: &mut u16,
|
||||||
|
share_id: &mut u32,
|
||||||
|
enable_server_pointer: &mut bool,
|
||||||
|
pointer_software_rendering: &mut bool,
|
||||||
|
activation_factory: &ConnectionActivationFactory,
|
||||||
|
io_channel_id: u16,
|
||||||
|
user_channel_id: u16,
|
||||||
|
compression_type: Option<CompressionType>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
info!(session_id, "RDP DeactivateAll — running reactivation");
|
||||||
|
set_stream_timeouts(
|
||||||
|
framed.get_inner_mut().0,
|
||||||
|
HANDSHAKE_TIMEOUT,
|
||||||
|
HANDSHAKE_TIMEOUT,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut sequence = activation_factory.create();
|
||||||
|
let mut buf = WriteBuf::new();
|
||||||
|
loop {
|
||||||
|
if matches!(
|
||||||
|
sequence.connection_activation_state(),
|
||||||
|
ConnectionActivationState::Finalized { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
let written = if let Some(hint) = sequence.next_pdu_hint() {
|
||||||
|
let pdu = framed
|
||||||
|
.read_by_hint(hint)
|
||||||
|
.context("读取 Deactivation-Reactivation PDU 失败")?;
|
||||||
|
sequence
|
||||||
|
.step(&pdu, &mut buf)
|
||||||
|
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
|
||||||
|
} else {
|
||||||
|
sequence
|
||||||
|
.step_no_input(&mut buf)
|
||||||
|
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
|
||||||
|
};
|
||||||
|
if let Some(response_len) = written.size() {
|
||||||
|
framed
|
||||||
|
.write_all(&buf[..response_len])
|
||||||
|
.context("写入 Deactivation-Reactivation 响应失败")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ConnectionActivationState::Finalized {
|
||||||
|
desktop_size,
|
||||||
|
share_id: new_share_id,
|
||||||
|
enable_server_pointer: new_enable_server_pointer,
|
||||||
|
pointer_software_rendering: new_pointer_software_rendering,
|
||||||
|
} = sequence.connection_activation_state()
|
||||||
|
else {
|
||||||
|
return Err(anyhow::anyhow!("Deactivation-Reactivation 未完成"));
|
||||||
|
};
|
||||||
|
|
||||||
|
*fb_width = desktop_size.width;
|
||||||
|
*fb_height = desktop_size.height;
|
||||||
|
*share_id = new_share_id;
|
||||||
|
*enable_server_pointer = new_enable_server_pointer;
|
||||||
|
*pointer_software_rendering = new_pointer_software_rendering;
|
||||||
|
*image = DecodedImage::new(
|
||||||
|
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
|
||||||
|
*fb_width,
|
||||||
|
*fb_height,
|
||||||
|
);
|
||||||
|
active_stage.set_share_id(*share_id);
|
||||||
|
active_stage.set_enable_server_pointer(*enable_server_pointer);
|
||||||
|
active_stage.set_fastpath_processor(
|
||||||
|
ProcessorBuilder {
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
share_id: *share_id,
|
||||||
|
enable_server_pointer: *enable_server_pointer,
|
||||||
|
pointer_software_rendering: *pointer_software_rendering,
|
||||||
|
bulk_decompressor: build_bulk_decompressor(compression_type),
|
||||||
|
}
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
send_allow_display_updates(active_stage, framed, *fb_width, *fb_height)
|
||||||
|
.context("重激活后发送 SuppressOutput 失败")?;
|
||||||
|
set_stream_timeouts(
|
||||||
|
framed.get_inner_mut().0,
|
||||||
|
ACTIVE_READ_TIMEOUT,
|
||||||
|
ACTIVE_WRITE_TIMEOUT,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
emit_status(
|
||||||
|
app,
|
||||||
|
"rdp:status",
|
||||||
|
session_id,
|
||||||
|
"connected",
|
||||||
|
Some(format!("分辨率已更新 {}×{}", fb_width, fb_height)),
|
||||||
|
Some(*fb_width),
|
||||||
|
Some(*fb_height),
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
session_id,
|
||||||
|
width = *fb_width,
|
||||||
|
height = *fb_height,
|
||||||
|
"RDP reactivation complete"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn flush_pending_frames(
|
fn flush_pending_frames(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
frame_emitter: &mut FrameEmitter,
|
frame_emitter: &mut FrameEmitter,
|
||||||
@@ -678,7 +1036,7 @@ fn flush_pending_frames(
|
|||||||
right: left.saturating_add(width.saturating_sub(1)),
|
right: left.saturating_add(width.saturating_sub(1)),
|
||||||
bottom: top.saturating_add(height.saturating_sub(1)),
|
bottom: top.saturating_add(height.saturating_sub(1)),
|
||||||
};
|
};
|
||||||
Some(extract_rgba_rect(image, &rect))
|
extract_rgba_rect(image, &rect)
|
||||||
},
|
},
|
||||||
fb_width,
|
fb_width,
|
||||||
fb_height,
|
fb_height,
|
||||||
@@ -686,19 +1044,36 @@ fn flush_pending_frames(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Vec<u8> {
|
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Option<Vec<u8>> {
|
||||||
let w = usize::from(rect.width());
|
let fb_w = usize::from(image.width());
|
||||||
let h = usize::from(rect.height());
|
let fb_h = usize::from(image.height());
|
||||||
|
if fb_w == 0 || fb_h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let left = usize::from(rect.left).min(fb_w.saturating_sub(1));
|
||||||
|
let top = usize::from(rect.top).min(fb_h.saturating_sub(1));
|
||||||
|
let right = usize::from(rect.right)
|
||||||
|
.min(fb_w.saturating_sub(1))
|
||||||
|
.max(left);
|
||||||
|
let bottom = usize::from(rect.bottom)
|
||||||
|
.min(fb_h.saturating_sub(1))
|
||||||
|
.max(top);
|
||||||
|
let w = right - left + 1;
|
||||||
|
let h = bottom - top + 1;
|
||||||
let bpp = image.bytes_per_pixel();
|
let bpp = image.bytes_per_pixel();
|
||||||
let stride = image.stride();
|
let stride = image.stride();
|
||||||
let data = image.data();
|
let data = image.data();
|
||||||
let mut out = Vec::with_capacity(w * h * bpp);
|
let mut out = Vec::with_capacity(w * h * bpp);
|
||||||
for row in 0..h {
|
for row in 0..h {
|
||||||
let y = usize::from(rect.top) + row;
|
let y = top + row;
|
||||||
let start = y * stride + usize::from(rect.left) * bpp;
|
let start = y * stride + left * bpp;
|
||||||
out.extend_from_slice(&data[start..start + w * bpp]);
|
let end = start + w * bpp;
|
||||||
|
if end > data.len() {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
out
|
out.extend_from_slice(&data[start..end]);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_config(
|
fn build_config(
|
||||||
@@ -748,7 +1123,11 @@ fn build_config(
|
|||||||
request_data: None,
|
request_data: None,
|
||||||
autologon: false,
|
autologon: false,
|
||||||
enable_audio_playback: false,
|
enable_audio_playback: false,
|
||||||
compression_type: Some(CompressionType::Rdp61),
|
// Prefer RDP5 MPPC (64 KB) over RDP6.1 XCRUSH. IronRDP's XCRUSH path is stateful and
|
||||||
|
// commonly fails after DisplayControl resize / DeactivateAll ("match output offset out
|
||||||
|
// of order"), dropping the whole session. MPPC is what IronRDP itself recommends until
|
||||||
|
// slow-path bulk decompression is fully wired.
|
||||||
|
compression_type: Some(CompressionType::K64),
|
||||||
// Hardware/server pointer + CSS cursor on the canvas reduces framebuffer churn.
|
// Hardware/server pointer + CSS cursor on the canvas reduces framebuffer churn.
|
||||||
pointer_software_rendering: false,
|
pointer_software_rendering: false,
|
||||||
multitransport_flags: None,
|
multitransport_flags: None,
|
||||||
@@ -811,8 +1190,17 @@ fn connect(
|
|||||||
)
|
)
|
||||||
.with_drives(Some(vec![(1, super::rdpdr_fs::DRIVE_NAME.into())]));
|
.with_drives(Some(vec![(1, super::rdpdr_fs::DRIVE_NAME.into())]));
|
||||||
let rdpsnd = Rdpsnd::new(Box::new(NoopRdpsndBackend));
|
let rdpsnd = Rdpsnd::new(Box::new(NoopRdpsndBackend));
|
||||||
|
let display_control = DisplayControlClient::new(|caps| {
|
||||||
|
debug!(
|
||||||
|
max_area = caps.max_monitor_area(),
|
||||||
|
"RDP DisplayControl capabilities"
|
||||||
|
);
|
||||||
|
Ok(Vec::new())
|
||||||
|
});
|
||||||
|
let drdynvc = DrdynvcClient::new().with_dynamic_channel(display_control);
|
||||||
|
|
||||||
let mut connector = connector::ClientConnector::new(config, client_addr)
|
let mut connector = connector::ClientConnector::new(config, client_addr)
|
||||||
|
.with_static_channel(drdynvc)
|
||||||
.with_static_channel(rdpsnd)
|
.with_static_channel(rdpsnd)
|
||||||
.with_static_channel(rdpdr)
|
.with_static_channel(rdpdr)
|
||||||
.with_static_channel(cliprdr);
|
.with_static_channel(cliprdr);
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ async fn drain_input(
|
|||||||
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
|
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
|
||||||
inject_remote_paste(vnc).await?;
|
inject_remote_paste(vnc).await?;
|
||||||
}
|
}
|
||||||
DesktopInput::UploadFile { .. } => {}
|
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+605
-109
@@ -1,35 +1,54 @@
|
|||||||
//! Device-bound Pro license helpers (HMAC signed tokens).
|
//! Pro license persistence + thin-client activate against opsx-licence.
|
||||||
//! Signing secret stays on license-server; desktop embeds the matching
|
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
|
||||||
//! verify key for offline activate (rotate for production).
|
//! offline `LICOFF-` serials are Ed25519 self-contained.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::Engine;
|
||||||
use hmac::{Hmac, Mac};
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use serde_json::Value;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
|
||||||
|
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
|
||||||
|
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
|
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
|
||||||
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
|
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||||
|
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LicensePayload {
|
struct StoredLicense {
|
||||||
pub plan: String,
|
|
||||||
pub device_id: String,
|
|
||||||
pub iat: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub exp: Option<u64>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub features: Vec<String>,
|
license_key: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
email: Option<String>,
|
||||||
|
#[serde(default = "default_plan")]
|
||||||
|
plan: String,
|
||||||
|
#[serde(default)]
|
||||||
|
activated: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expires_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
features: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
max_devices: Option<u32>,
|
||||||
|
/// yearly | perpetual | trial | …
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
plan_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_verified_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_plan() -> String {
|
||||||
|
"pro".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -41,12 +60,43 @@ pub struct LicenseStatus {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
/// License key string (compat field name for existing frontend).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_devices: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plan_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hmac_secret() -> String {
|
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
|
||||||
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
|
/// Accepts `https://host`, `https://host/api`, or mistaken `…/api/v1`. // Logan.Li
|
||||||
|
fn normalize_api_base(raw: &str) -> String {
|
||||||
|
let mut base = raw.trim().trim_end_matches('/').to_string();
|
||||||
|
if base.is_empty() {
|
||||||
|
return DEFAULT_API_BASE.to_string();
|
||||||
|
}
|
||||||
|
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
|
||||||
|
if base.starts_with("http://l123.91ai.io") {
|
||||||
|
base = format!("https{}", &base["http".len()..]);
|
||||||
|
}
|
||||||
|
if let Some(stripped) = base.strip_suffix("/api/v1") {
|
||||||
|
base = format!("{stripped}/api");
|
||||||
|
} else if base.ends_with("/v1") && !base.ends_with("/api/v1") {
|
||||||
|
// e.g. https://l123.91ai.io/v1 → https://l123.91ai.io/api
|
||||||
|
base = format!("{}/api", base.trim_end_matches("/v1").trim_end_matches('/'));
|
||||||
|
} else if !base.ends_with("/api") {
|
||||||
|
base = format!("{base}/api");
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_base() -> String {
|
||||||
|
let raw =
|
||||||
|
std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
|
||||||
|
normalize_api_base(&raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
@@ -70,6 +120,13 @@ fn ensure_parent(path: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||||
let path = device_id_path(app)?;
|
let path = device_id_path(app)?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
|
fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
LicenseStatus {
|
||||||
.map_err(|error| format!("HMAC 初始化失败: {error}"))?;
|
plan: "free".to_string(),
|
||||||
mac.update(payload_b64.as_bytes());
|
device_id,
|
||||||
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
|
activated: false,
|
||||||
|
expires_at: None,
|
||||||
|
features: vec![],
|
||||||
|
token: None,
|
||||||
|
email,
|
||||||
|
max_devices: None,
|
||||||
|
plan_type: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow helpers used by unit tests / future issue endpoints.
|
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
|
||||||
#[allow(dead_code)]
|
if !stored.activated || stored.license_key.trim().is_empty() {
|
||||||
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
|
return free_status(device_id, stored.email.clone());
|
||||||
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
|
}
|
||||||
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
|
if let Some(exp) = stored.expires_at {
|
||||||
let sig = sign_payload(&payload_b64, secret)?;
|
if exp > 0 && exp < now_secs() {
|
||||||
Ok(format!("opsx1.{payload_b64}.{sig}"))
|
return free_status(device_id, stored.email.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LicenseStatus {
|
||||||
|
plan: if stored.plan.is_empty() {
|
||||||
|
"pro".to_string()
|
||||||
|
} else {
|
||||||
|
stored.plan.clone()
|
||||||
|
},
|
||||||
|
device_id,
|
||||||
|
activated: true,
|
||||||
|
expires_at: stored.expires_at,
|
||||||
|
features: if stored.features.is_empty() {
|
||||||
|
vec!["*".to_string()]
|
||||||
|
} else {
|
||||||
|
stored.features.clone()
|
||||||
|
},
|
||||||
|
token: Some(stored.license_key.clone()),
|
||||||
|
email: stored.email.clone(),
|
||||||
|
max_devices: stored.max_devices,
|
||||||
|
plan_type: stored.plan_type.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
|
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
|
||||||
let trimmed = token.trim();
|
if !path.exists() {
|
||||||
let parts: Vec<&str> = trimmed.split('.').collect();
|
return Ok(None);
|
||||||
if parts.len() != 3 || parts[0] != "opsx1" {
|
|
||||||
return Err("许可证格式无效".to_string());
|
|
||||||
}
|
}
|
||||||
let expected = sign_payload(parts[1], secret)?;
|
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||||
if expected != parts[2] {
|
let trimmed = raw.trim();
|
||||||
return Err("许可证签名无效".to_string());
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let bytes = URL_SAFE_NO_PAD
|
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
|
||||||
.decode(parts[1])
|
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
|
||||||
.map_err(|_| "许可证载荷损坏".to_string())?;
|
return Ok(None);
|
||||||
let payload: LicensePayload =
|
|
||||||
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
|
|
||||||
if payload.plan != "pro" {
|
|
||||||
return Err("许可证计划无效".to_string());
|
|
||||||
}
|
}
|
||||||
if let Some(exp) = payload.exp {
|
let stored: StoredLicense =
|
||||||
let now = SystemTime::now()
|
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
|
||||||
.duration_since(UNIX_EPOCH)
|
Ok(Some(stored))
|
||||||
.map(|d| d.as_secs())
|
}
|
||||||
.unwrap_or(0);
|
|
||||||
if now > exp {
|
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), String> {
|
||||||
return Err("许可证已过期".to_string());
|
ensure_parent(path)?;
|
||||||
|
let json = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||||
|
fs::write(path, json).map_err(|error| format!("保存许可证失败: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_legacy_key(license_key: &str) -> Result<(), String> {
|
||||||
|
let trimmed = license_key.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("请输入许可证密钥".to_string());
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("opsx1.") || trimmed.contains(".opsx1.") {
|
||||||
|
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".to_string());
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("MII") {
|
||||||
|
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_offline_license_key(license_key: &str) -> bool {
|
||||||
|
license_key.trim().starts_with("LICOFF-")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OfflineClaims {
|
||||||
|
#[serde(default)]
|
||||||
|
v: i32,
|
||||||
|
#[serde(rename = "pid")]
|
||||||
|
product_code: String,
|
||||||
|
#[serde(rename = "did")]
|
||||||
|
device_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
email: Option<String>,
|
||||||
|
#[serde(rename = "pt")]
|
||||||
|
plan_type: String,
|
||||||
|
iat: i64,
|
||||||
|
exp: i64,
|
||||||
|
#[serde(rename = "md", default)]
|
||||||
|
max_devices: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
|
||||||
|
let trimmed = serial.trim();
|
||||||
|
let body = trimmed
|
||||||
|
.strip_prefix("LICOFF-1.")
|
||||||
|
.ok_or_else(|| "不是合法的离线序列号(需 LICOFF-1. 前缀)".to_string())?;
|
||||||
|
let (payload_b64, sig_b64) = body
|
||||||
|
.rsplit_once('.')
|
||||||
|
.ok_or_else(|| "离线序列号格式错误".to_string())?;
|
||||||
|
|
||||||
|
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
let sig_bytes = engine
|
||||||
|
.decode(sig_b64)
|
||||||
|
.map_err(|_| "离线序列号签名无法解码".to_string())?;
|
||||||
|
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "离线序列号签名无效".to_string())?;
|
||||||
|
|
||||||
|
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
|
||||||
|
.map_err(|_| "离线验签公钥配置错误".to_string())?;
|
||||||
|
let verifying_key = VerifyingKey::from_bytes(
|
||||||
|
pub_bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "离线验签公钥长度错误".to_string())?,
|
||||||
|
)
|
||||||
|
.map_err(|_| "离线验签公钥无效".to_string())?;
|
||||||
|
verifying_key
|
||||||
|
.verify(payload_b64.as_bytes(), &sig)
|
||||||
|
.map_err(|_| "离线序列号签名校验失败".to_string())?;
|
||||||
|
|
||||||
|
let payload = engine
|
||||||
|
.decode(payload_b64)
|
||||||
|
.map_err(|_| "离线序列号载荷无法解码".to_string())?;
|
||||||
|
let claims: OfflineClaims =
|
||||||
|
serde_json::from_slice(&payload).map_err(|_| "离线序列号载荷无法解析".to_string())?;
|
||||||
|
|
||||||
|
if claims.v != 0 && claims.v != 1 {
|
||||||
|
return Err("不支持的离线证版本".to_string());
|
||||||
|
}
|
||||||
|
if claims.product_code != "opsxshell" {
|
||||||
|
return Err("离线证产品不匹配".to_string());
|
||||||
|
}
|
||||||
|
if claims.device_id != device_id {
|
||||||
|
return Err("离线证与当前设备不匹配".to_string());
|
||||||
|
}
|
||||||
|
let plan = claims.plan_type.as_str();
|
||||||
|
if plan != "trial" && plan != "yearly" {
|
||||||
|
return Err("离线证计划无效(仅体验/包年)".to_string());
|
||||||
|
}
|
||||||
|
let now = now_secs() as i64;
|
||||||
|
if claims.exp <= now {
|
||||||
|
return Err("离线许可证已过期".to_string());
|
||||||
|
}
|
||||||
|
if claims.exp - claims.iat > 365 * 86400 + 3600 {
|
||||||
|
return Err("离线许可证有效期非法".to_string());
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_client() -> Result<reqwest::blocking::Client, String> {
|
||||||
|
reqwest::blocking::Client::builder()
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.user_agent("OpsXShell/2")
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope_message(value: &Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope_code(value: &Value) -> Option<i64> {
|
||||||
|
value.get("code").and_then(|v| v.as_i64())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope_data(value: &Value) -> &Value {
|
||||||
|
value.get("data").unwrap_or(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string_field(value: &Value, snake: &str, camel: &str) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get(snake)
|
||||||
|
.or_else(|| value.get(camel))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_i64_field(value: &Value, snake: &str, camel: &str) -> Option<i64> {
|
||||||
|
value
|
||||||
|
.get(snake)
|
||||||
|
.or_else(|| value.get(camel))
|
||||||
|
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string_array(value: &Value, snake: &str, camel: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.get(snake)
|
||||||
|
.or_else(|| value.get(camel))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
|
||||||
|
let days = days_left?;
|
||||||
|
if days < 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
// Treat very long remaining windows as perpetual.
|
||||||
|
if days > 36_500 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||||
|
let url = format!("{}{path}", api_base());
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.map_err(|error| format!("读取许可证服务响应失败: {error}"))?;
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return Err(format!("许可证服务返回空响应 ({status})"));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&text).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"许可证服务响应无法解析 ({status}): {error}; body={}",
|
||||||
|
&text[..text.len().min(200)]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoteLicenseInfo {
|
||||||
|
expires_at: Option<u64>,
|
||||||
|
features: Vec<String>,
|
||||||
|
max_devices: Option<u32>,
|
||||||
|
plan_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
|
||||||
|
let days_left = json_i64_field(data, "days_left", "daysLeft");
|
||||||
|
let expires_at = expires_from_days_left(days_left);
|
||||||
|
let features = json_string_array(data, "features", "features");
|
||||||
|
let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32);
|
||||||
|
let plan_type = json_string_field(data, "plan_type", "planType");
|
||||||
|
RemoteLicenseInfo {
|
||||||
|
expires_at,
|
||||||
|
features,
|
||||||
|
max_devices,
|
||||||
|
plan_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(payload)
|
|
||||||
|
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"license_key": license_key,
|
||||||
|
"device_id": device_id,
|
||||||
|
"device_name": "OpsXShell",
|
||||||
|
});
|
||||||
|
let value = post_json("/v1/license/activate", body)?;
|
||||||
|
if let Some(code) = envelope_code(&value) {
|
||||||
|
if code != 0 {
|
||||||
|
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(parse_remote_info(envelope_data(&value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"license_key": license_key,
|
||||||
|
"device_id": device_id,
|
||||||
|
});
|
||||||
|
let value = post_json("/v1/license/verify", body)?;
|
||||||
|
// Bare object or envelope.
|
||||||
|
let payload = if value.get("valid").is_some() {
|
||||||
|
&value
|
||||||
|
} else if envelope_code(&value) == Some(0) {
|
||||||
|
envelope_data(&value)
|
||||||
|
} else if let Some(code) = envelope_code(&value) {
|
||||||
|
if code != 0 {
|
||||||
|
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
|
||||||
|
}
|
||||||
|
envelope_data(&value)
|
||||||
|
} else {
|
||||||
|
&value
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid = payload
|
||||||
|
.get("valid")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
if !valid {
|
||||||
|
return Err(envelope_message(payload)
|
||||||
|
.or_else(|| json_string_field(payload, "message", "message"))
|
||||||
|
.unwrap_or_else(|| "许可证无效".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parse_remote_info(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_deactivate(license_key: &str, device_id: &str) {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"license_key": license_key,
|
||||||
|
"device_id": device_id,
|
||||||
|
});
|
||||||
|
let _ = post_json("/v1/license/deactivate", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CheckoutResult {
|
||||||
|
pub order_no: String,
|
||||||
|
pub checkout_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub amount: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub currency: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a pending order via opsx-licence and return the Waffo hosted checkout URL. // Logan.Li
|
||||||
|
pub fn checkout(
|
||||||
|
app: &AppHandle,
|
||||||
|
customer_email: &str,
|
||||||
|
plan_type: &str,
|
||||||
|
return_url: Option<&str>,
|
||||||
|
) -> Result<CheckoutResult, String> {
|
||||||
|
let email = customer_email.trim();
|
||||||
|
if email.is_empty() || !email.contains('@') {
|
||||||
|
return Err("请先填写有效邮箱".to_string());
|
||||||
|
}
|
||||||
|
let plan = match plan_type.trim() {
|
||||||
|
"yearly" | "perpetual" => plan_type.trim(),
|
||||||
|
other if !other.is_empty() => other,
|
||||||
|
_ => "perpetual",
|
||||||
|
};
|
||||||
|
let device_id = get_or_create_device_id(app)?;
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"customer_email": email,
|
||||||
|
"product_code": "opsxshell",
|
||||||
|
"plan_type": plan,
|
||||||
|
"device_id": device_id,
|
||||||
|
"return_url": return_url.unwrap_or("https://l123.91ai.io/api/v1/purchase/success"),
|
||||||
|
});
|
||||||
|
let value = post_json("/v1/checkout", body)?;
|
||||||
|
if let Some(code) = envelope_code(&value) {
|
||||||
|
if code != 0 {
|
||||||
|
return Err(envelope_message(&value).unwrap_or_else(|| "创建结账订单失败".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let data = envelope_data(&value);
|
||||||
|
let checkout_url = json_string_field(data, "checkout_url", "checkoutUrl")
|
||||||
|
.ok_or_else(|| "结账链接缺失".to_string())?;
|
||||||
|
let order_no = json_string_field(data, "order_no", "orderNo").unwrap_or_default();
|
||||||
|
Ok(CheckoutResult {
|
||||||
|
order_no,
|
||||||
|
checkout_url,
|
||||||
|
amount: data
|
||||||
|
.get("amount")
|
||||||
|
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
|
||||||
|
currency: json_string_field(data, "currency", "currency"),
|
||||||
|
status: json_string_field(data, "status", "status"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||||
let device_id = get_or_create_device_id(app)?;
|
let device_id = get_or_create_device_id(app)?;
|
||||||
let path = license_path(app)?;
|
let path = license_path(app)?;
|
||||||
if !path.exists() {
|
match read_stored(&path)? {
|
||||||
return Ok(LicenseStatus {
|
None => Ok(free_status(device_id, None)),
|
||||||
plan: "free".to_string(),
|
Some(stored) => Ok(status_from_stored(device_id, &stored)),
|
||||||
device_id,
|
|
||||||
activated: false,
|
|
||||||
expires_at: None,
|
|
||||||
features: vec![],
|
|
||||||
token: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
|
||||||
match verify_token(&token, &hmac_secret()) {
|
|
||||||
Ok(payload) => {
|
|
||||||
if payload.device_id != "*" && payload.device_id != device_id {
|
|
||||||
return Ok(LicenseStatus {
|
|
||||||
plan: "free".to_string(),
|
|
||||||
device_id,
|
|
||||||
activated: false,
|
|
||||||
expires_at: None,
|
|
||||||
features: vec![],
|
|
||||||
token: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(LicenseStatus {
|
|
||||||
plan: payload.plan,
|
|
||||||
device_id,
|
|
||||||
activated: true,
|
|
||||||
expires_at: payload.exp,
|
|
||||||
features: payload.features,
|
|
||||||
token: Some(token.trim().to_string()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(_) => Ok(LicenseStatus {
|
|
||||||
plan: "free".to_string(),
|
|
||||||
device_id,
|
|
||||||
activated: false,
|
|
||||||
expires_at: None,
|
|
||||||
features: vec![],
|
|
||||||
token: None,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
|
pub fn activate(
|
||||||
|
app: &AppHandle,
|
||||||
|
license_key: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<LicenseStatus, String> {
|
||||||
|
reject_legacy_key(license_key)?;
|
||||||
let device_id = get_or_create_device_id(app)?;
|
let device_id = get_or_create_device_id(app)?;
|
||||||
let payload = verify_token(token, &hmac_secret())?;
|
let key = license_key.trim();
|
||||||
if payload.device_id != "*" && payload.device_id != device_id {
|
|
||||||
return Err("许可证与当前设备不匹配".to_string());
|
if is_offline_license_key(key) {
|
||||||
}
|
let claims = verify_offline_serial(key, &device_id)?;
|
||||||
|
let email_store = email
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.or(claims.email.clone());
|
||||||
|
let stored = StoredLicense {
|
||||||
|
license_key: key.to_string(),
|
||||||
|
email: email_store,
|
||||||
|
plan: "pro".to_string(),
|
||||||
|
activated: true,
|
||||||
|
expires_at: Some(claims.exp as u64),
|
||||||
|
features: vec!["*".to_string()],
|
||||||
|
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||||
|
plan_type: Some(claims.plan_type),
|
||||||
|
last_verified_at: now_secs(),
|
||||||
|
};
|
||||||
let path = license_path(app)?;
|
let path = license_path(app)?;
|
||||||
ensure_parent(&path)?;
|
write_stored(&path, &stored)?;
|
||||||
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
|
return read_status(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut info = remote_activate(key, &device_id)?;
|
||||||
|
if let Ok(verify) = remote_verify(key, &device_id) {
|
||||||
|
if verify.expires_at.is_some() {
|
||||||
|
info.expires_at = verify.expires_at;
|
||||||
|
}
|
||||||
|
if !verify.features.is_empty() {
|
||||||
|
info.features = verify.features;
|
||||||
|
}
|
||||||
|
if verify.max_devices.is_some() {
|
||||||
|
info.max_devices = verify.max_devices;
|
||||||
|
}
|
||||||
|
if verify.plan_type.is_some() {
|
||||||
|
info.plan_type = verify.plan_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.features.is_empty() {
|
||||||
|
info.features = vec!["*".to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = StoredLicense {
|
||||||
|
license_key: key.to_string(),
|
||||||
|
email: email
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty()),
|
||||||
|
plan: "pro".to_string(),
|
||||||
|
activated: true,
|
||||||
|
expires_at: info.expires_at,
|
||||||
|
features: info.features,
|
||||||
|
max_devices: info.max_devices,
|
||||||
|
plan_type: info.plan_type,
|
||||||
|
last_verified_at: now_secs(),
|
||||||
|
};
|
||||||
|
let path = license_path(app)?;
|
||||||
|
write_stored(&path, &stored)?;
|
||||||
|
read_status(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
|
||||||
|
let path = license_path(app)?;
|
||||||
|
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
|
||||||
|
license_key: String::new(),
|
||||||
|
email: None,
|
||||||
|
plan: "free".to_string(),
|
||||||
|
activated: false,
|
||||||
|
expires_at: None,
|
||||||
|
features: vec![],
|
||||||
|
max_devices: None,
|
||||||
|
plan_type: None,
|
||||||
|
last_verified_at: 0,
|
||||||
|
});
|
||||||
|
let trimmed = email.trim();
|
||||||
|
stored.email = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
};
|
||||||
|
write_stored(&path, &stored)?;
|
||||||
read_status(app)
|
read_status(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||||
|
let device_id = get_or_create_device_id(app)?;
|
||||||
let path = license_path(app)?;
|
let path = license_path(app)?;
|
||||||
|
if let Ok(Some(stored)) = read_stored(&path) {
|
||||||
|
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
|
||||||
|
remote_deactivate(&stored.license_key, &device_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
||||||
}
|
}
|
||||||
@@ -202,16 +646,68 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip_token() {
|
fn normalize_api_base_accepts_host_and_strips_v1() {
|
||||||
let payload = LicensePayload {
|
assert_eq!(
|
||||||
|
normalize_api_base("https://l123.91ai.io"),
|
||||||
|
"https://l123.91ai.io/api"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_api_base("https://l123.91ai.io/api/"),
|
||||||
|
"https://l123.91ai.io/api"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_api_base("https://l123.91ai.io/api/v1"),
|
||||||
|
"https://l123.91ai.io/api"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_api_base("http://l123.91ai.io/api"),
|
||||||
|
"https://l123.91ai.io/api"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_api_base("http://127.0.0.1:9001/api"),
|
||||||
|
"http://127.0.0.1:9001/api"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_opsx1_and_private_key() {
|
||||||
|
assert!(reject_legacy_key("opsx1.abc.def").is_err());
|
||||||
|
assert!(reject_legacy_key("MIIEowIBAAK...").is_err());
|
||||||
|
assert!(reject_legacy_key("LIC-TEST-KEY").is_ok());
|
||||||
|
assert!(reject_legacy_key("LICOFF-1.abc.def").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_offline_prefix() {
|
||||||
|
assert!(is_offline_license_key("LICOFF-1.x.y"));
|
||||||
|
assert!(!is_offline_license_key("LIC-2026-AAAA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_license_roundtrip_json() {
|
||||||
|
let stored = StoredLicense {
|
||||||
|
license_key: "LIC-ABC".to_string(),
|
||||||
|
email: Some("a@b.com".to_string()),
|
||||||
plan: "pro".to_string(),
|
plan: "pro".to_string(),
|
||||||
device_id: "dev-1".to_string(),
|
activated: true,
|
||||||
iat: 1,
|
expires_at: None,
|
||||||
exp: None,
|
|
||||||
features: vec!["*".to_string()],
|
features: vec!["*".to_string()],
|
||||||
|
max_devices: Some(3),
|
||||||
|
plan_type: Some("perpetual".to_string()),
|
||||||
|
last_verified_at: 1,
|
||||||
};
|
};
|
||||||
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
|
let json = serde_json::to_string(&stored).unwrap();
|
||||||
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
|
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed.device_id, "dev-1");
|
assert_eq!(parsed.license_key, "LIC-ABC");
|
||||||
|
assert_eq!(parsed.email.as_deref(), Some("a@b.com"));
|
||||||
|
assert!(parsed.activated);
|
||||||
|
assert_eq!(parsed.max_devices, Some(3));
|
||||||
|
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expires_from_days_left_perpetual() {
|
||||||
|
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
||||||
|
assert!(expires_from_days_left(Some(30)).is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,7 @@ impl LocalTerminalService {
|
|||||||
.map_err(|error| format!("failed to open local PTY: {error}"))?;
|
.map_err(|error| format!("failed to open local PTY: {error}"))?;
|
||||||
let mut command = CommandBuilder::new(&shell);
|
let mut command = CommandBuilder::new(&shell);
|
||||||
command.cwd(cwd);
|
command.cwd(cwd);
|
||||||
|
apply_login_shell_args(&mut command, &shell);
|
||||||
apply_terminal_env(&mut command);
|
apply_terminal_env(&mut command);
|
||||||
let child = pair
|
let child = pair
|
||||||
.slave
|
.slave
|
||||||
@@ -216,8 +217,27 @@ fn local_home_dir() -> Option<PathBuf> {
|
|||||||
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
|
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Match Terminal.app / VS Code so a Finder/Dock-launched GUI process still
|
||||||
|
/// sees Homebrew, `/usr/local/bin` (Ollama), and other login-profile tools.
|
||||||
|
fn apply_login_shell_args(command: &mut CommandBuilder, shell: &str) {
|
||||||
|
for arg in login_shell_args(&shell_kind(shell)) {
|
||||||
|
command.arg(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_shell_args(kind: &str) -> &'static [&'static str] {
|
||||||
|
match kind {
|
||||||
|
"zsh" | "bash" | "fish" | "sh" => &["-l"],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Match VS Code integrated terminal env so local shells (and nested `ssh`)
|
/// Match VS Code integrated terminal env so local shells (and nested `ssh`)
|
||||||
/// get a color-capable, UTF-8 locale. See vscode `addTerminalEnvironmentKeys`.
|
/// get a color-capable, UTF-8 locale. See vscode `addTerminalEnvironmentKeys`.
|
||||||
|
///
|
||||||
|
/// macOS GUI apps inherit a stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||||
|
/// VS Code repairs this by resolving a login-shell env; we do the same by
|
||||||
|
/// starting a login shell and prepending the dirs `path_helper` would add.
|
||||||
fn apply_terminal_env(command: &mut CommandBuilder) {
|
fn apply_terminal_env(command: &mut CommandBuilder) {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -237,8 +257,99 @@ fn apply_terminal_env(command: &mut CommandBuilder) {
|
|||||||
if should_set_utf8_lang() {
|
if should_set_utf8_lang() {
|
||||||
command.env("LANG", utf8_lang());
|
command.env("LANG", utf8_lang());
|
||||||
}
|
}
|
||||||
|
let current = env::var("PATH").unwrap_or_default();
|
||||||
|
let enriched = enrich_inherited_path(¤t);
|
||||||
|
if enriched != current {
|
||||||
|
command.env("PATH", enriched);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_separator() -> char {
|
||||||
|
if cfg!(windows) {
|
||||||
|
';'
|
||||||
|
} else {
|
||||||
|
':'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepend missing user/system bin dirs onto a GUI-minimal PATH.
|
||||||
|
/// Existing entries keep their order; duplicates are not added.
|
||||||
|
fn merge_path(current: &str, extras: &[String]) -> String {
|
||||||
|
let sep = path_separator();
|
||||||
|
let existing: Vec<&str> = current.split(sep).filter(|part| !part.is_empty()).collect();
|
||||||
|
let mut seen: HashSet<&str> = existing.iter().copied().collect();
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
for dir in extras {
|
||||||
|
if dir.is_empty() || !seen.insert(dir.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prefix.push(dir.as_str());
|
||||||
|
}
|
||||||
|
prefix
|
||||||
|
.into_iter()
|
||||||
|
.chain(existing)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(&sep.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn extra_path_dirs() -> Vec<String> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(home) = local_home_dir() {
|
||||||
|
for rel in [".local/bin", ".cargo/bin", "bin"] {
|
||||||
|
dirs.push(home.join(rel).to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs.extend([
|
||||||
|
"/opt/homebrew/bin".into(),
|
||||||
|
"/opt/homebrew/sbin".into(),
|
||||||
|
"/usr/local/bin".into(),
|
||||||
|
"/usr/local/sbin".into(),
|
||||||
|
]);
|
||||||
|
dirs.extend(macos_path_helper_dirs());
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
dirs.extend(["/usr/local/bin".into(), "/usr/local/sbin".into()]);
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_path_helper_dirs() -> Vec<String> {
|
||||||
|
let mut dirs = read_path_file(Path::new("/etc/paths"));
|
||||||
|
if let Ok(entries) = std::fs::read_dir("/etc/paths.d") {
|
||||||
|
let mut files: Vec<_> = entries.filter_map(|entry| entry.ok()).collect();
|
||||||
|
files.sort_by_key(|entry| entry.file_name());
|
||||||
|
for file in files {
|
||||||
|
dirs.extend(read_path_file(&file.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn read_path_file(path: &Path) -> Vec<String> {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn enrich_inherited_path(current: &str) -> String {
|
||||||
|
let extras: Vec<String> = extra_path_dirs()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|dir| Path::new(dir).is_dir())
|
||||||
|
.collect();
|
||||||
|
merge_path(current, &extras)
|
||||||
|
}
|
||||||
|
|
||||||
fn should_set_utf8_lang() -> bool {
|
fn should_set_utf8_lang() -> bool {
|
||||||
match env::var("LANG") {
|
match env::var("LANG") {
|
||||||
@@ -449,4 +560,40 @@ mod tests {
|
|||||||
assert!(!shell.is_empty());
|
assert!(!shell.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_shells_start_as_login_shells() {
|
||||||
|
assert_eq!(login_shell_args("zsh"), &["-l"]);
|
||||||
|
assert_eq!(login_shell_args("bash"), &["-l"]);
|
||||||
|
assert_eq!(login_shell_args("fish"), &["-l"]);
|
||||||
|
assert_eq!(login_shell_args("sh"), &["-l"]);
|
||||||
|
assert!(login_shell_args("pwsh").is_empty());
|
||||||
|
assert!(login_shell_args("cmd").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_path_prepends_missing_dirs_without_duplicates() {
|
||||||
|
let merged = merge_path(
|
||||||
|
"/usr/bin:/bin:/usr/sbin:/sbin",
|
||||||
|
&[
|
||||||
|
"/usr/local/bin".into(),
|
||||||
|
"/opt/homebrew/bin".into(),
|
||||||
|
"/usr/bin".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged,
|
||||||
|
"/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn enriches_gui_path_with_usr_local_bin() {
|
||||||
|
let enriched = enrich_inherited_path("/usr/bin:/bin:/usr/sbin:/sbin");
|
||||||
|
assert!(
|
||||||
|
enriched.split(':').any(|part| part == "/usr/local/bin"),
|
||||||
|
"expected /usr/local/bin in {enriched}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, HashSet},
|
||||||
io::{self, Read, Write},
|
io::{self, Read, Write},
|
||||||
net::{Shutdown, TcpStream},
|
net::{Shutdown, TcpStream},
|
||||||
sync::{
|
sync::{
|
||||||
@@ -19,7 +19,7 @@ use crate::domain::ssh::{
|
|||||||
ShellDataEvent, SshConnectRequest,
|
ShellDataEvent, SshConnectRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::tunnel::TunnelManager;
|
use super::tunnel::{connection_fingerprint, TunnelManager};
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod host_metrics;
|
mod host_metrics;
|
||||||
@@ -50,13 +50,27 @@ struct Shell {
|
|||||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dedicated SSH transport + persistent SFTP channel for one workload.
|
||||||
|
/// Each UI SFTP tab owns a browsing channel and may lazily open a separate
|
||||||
|
/// transfer channel; neither shares the pooled session used by exec/keepalive.
|
||||||
|
struct ManagedSftp {
|
||||||
|
connection_id: String,
|
||||||
|
session: Session,
|
||||||
|
sftp: ssh2::Sftp,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct SshService {
|
pub struct SshService {
|
||||||
connections: Mutex<HashMap<String, Connection>>,
|
connections: Mutex<HashMap<String, Connection>>,
|
||||||
shells: Mutex<HashMap<String, Shell>>,
|
shells: Mutex<HashMap<String, Shell>>,
|
||||||
sftps: Mutex<HashMap<String, String>>,
|
/// Interactive browsing channel for each SFTP tab.
|
||||||
|
sftps: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||||
|
/// Lazily-created transfer channel, isolated from browsing/file operations.
|
||||||
|
sftp_transfers: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||||
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
|
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
|
||||||
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||||
|
/// Per-SFTP-session cancel for the active upload/download copy loop.
|
||||||
|
sftp_transfer_cancels: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||||
tunnels: TunnelManager,
|
tunnels: TunnelManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +79,30 @@ impl SshService {
|
|||||||
validate_config(&config)?;
|
validate_config(&config)?;
|
||||||
let session = establish_session(&config)?;
|
let session = establish_session(&config)?;
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
self.connections
|
let fingerprint = connection_fingerprint(&config);
|
||||||
.lock()
|
let live = {
|
||||||
.map_err(lock_error)?
|
let mut connections = self.connections.lock().map_err(lock_error)?;
|
||||||
.insert(id.clone(), Connection { session, config });
|
connections.insert(id.clone(), Connection { session, config });
|
||||||
|
connections.keys().cloned().collect::<HashSet<_>>()
|
||||||
|
};
|
||||||
|
// Reattach listeners that survived a reconnect (same SSH identity,
|
||||||
|
// previous connection id is gone). Runtime is never persisted.
|
||||||
|
self.tunnels.adopt_orphans(&id, &fingerprint, &live)?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
|
pub fn disconnect(
|
||||||
self.tunnels.stop_connection(connection_id)?;
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
retain_forwards: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Dedicated tunnel sessions keep listening across terminal flaps when
|
||||||
|
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
|
||||||
|
// must still close real Listeners.
|
||||||
|
if !retain_forwards {
|
||||||
|
let _ = self.tunnels.stop_connection(connection_id);
|
||||||
|
}
|
||||||
if let Some(flag) = self
|
if let Some(flag) = self
|
||||||
.active_execs
|
.active_execs
|
||||||
.lock()
|
.lock()
|
||||||
@@ -97,10 +126,22 @@ impl SshService {
|
|||||||
let _ = shell.session.disconnect(None, "closed by user", None);
|
let _ = shell.session.disconnect(None, "closed by user", None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.sftps
|
let sftp_ids = {
|
||||||
|
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||||
|
sftps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, handle)| {
|
||||||
|
handle
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(lock_error)?
|
.ok()
|
||||||
.retain(|_, id| id != connection_id);
|
.filter(|sftp| sftp.connection_id == connection_id)
|
||||||
|
.map(|_| id.clone())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for sftp_id in sftp_ids {
|
||||||
|
let _ = self.close_sftp(&sftp_id);
|
||||||
|
}
|
||||||
if let Some(connection) = self
|
if let Some(connection) = self
|
||||||
.connections
|
.connections
|
||||||
.lock()
|
.lock()
|
||||||
@@ -256,8 +297,8 @@ impl SshService {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|error| format!("SSH 保活失败: {error}"))?;
|
.map_err(|error| format!("SSH 保活失败: {error}"))?;
|
||||||
|
|
||||||
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
|
// Interactive panes / SFTP use dedicated SSH sessions so a busy
|
||||||
// channel cannot stall terminal IO. Keep those transports alive too.
|
// SFTP/exec channel cannot stall terminal IO. Keep those alive too.
|
||||||
let shell_sessions = self
|
let shell_sessions = self
|
||||||
.shells
|
.shells
|
||||||
.lock()
|
.lock()
|
||||||
@@ -271,6 +312,21 @@ impl SshService {
|
|||||||
.keepalive_send()
|
.keepalive_send()
|
||||||
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
|
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
|
||||||
}
|
}
|
||||||
|
let sftp_sessions = {
|
||||||
|
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||||
|
sftps
|
||||||
|
.values()
|
||||||
|
.filter_map(|handle| {
|
||||||
|
let guard = handle.lock().ok()?;
|
||||||
|
(guard.connection_id == connection_id).then(|| guard.session.clone())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for session in sftp_sessions {
|
||||||
|
session
|
||||||
|
.keepalive_send()
|
||||||
|
.map_err(|error| format!("SFTP SSH 保活失败: {error}"))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +449,7 @@ impl SshService {
|
|||||||
&self,
|
&self,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
|
local_bind_host: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
remote_host: String,
|
remote_host: String,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
@@ -402,6 +459,7 @@ impl SshService {
|
|||||||
forward_id,
|
forward_id,
|
||||||
connection_id,
|
connection_id,
|
||||||
config,
|
config,
|
||||||
|
local_bind_host,
|
||||||
local_port,
|
local_port,
|
||||||
remote_host,
|
remote_host,
|
||||||
remote_port,
|
remote_port,
|
||||||
@@ -423,6 +481,7 @@ impl SshService {
|
|||||||
&self,
|
&self,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
|
remote_bind_host: String,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
local_host: String,
|
local_host: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
@@ -432,6 +491,7 @@ impl SshService {
|
|||||||
forward_id,
|
forward_id,
|
||||||
connection_id,
|
connection_id,
|
||||||
config,
|
config,
|
||||||
|
remote_bind_host,
|
||||||
remote_port,
|
remote_port,
|
||||||
local_host,
|
local_host,
|
||||||
local_port,
|
local_port,
|
||||||
@@ -481,7 +541,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
|
|||||||
session
|
session
|
||||||
.handshake()
|
.handshake()
|
||||||
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
|
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
|
||||||
session.set_keepalive(config.keep_alive.unwrap_or(true), 30);
|
session.set_keepalive(config.keep_alive.unwrap_or(true), 15);
|
||||||
verify_host_key(&session, &config.host, config.port)?;
|
verify_host_key(&session, &config.host, config.port)?;
|
||||||
authenticate(&session, config)?;
|
authenticate(&session, config)?;
|
||||||
Ok(session)
|
Ok(session)
|
||||||
@@ -782,7 +842,10 @@ mod tests {
|
|||||||
target.seek(SeekFrom::Start(3)).unwrap();
|
target.seek(SeekFrom::Start(3)).unwrap();
|
||||||
let mut progress = Vec::new();
|
let mut progress = Vec::new();
|
||||||
|
|
||||||
copy_with_progress(&mut source, &mut target, 3, 6, |value| progress.push(value)).unwrap();
|
copy_with_progress(&mut source, &mut target, 3, 6, None, |value| {
|
||||||
|
progress.push(value)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(target.into_inner(), b"abcdef");
|
assert_eq!(target.into_inner(), b"abcdef");
|
||||||
assert_eq!(progress.first(), Some(&3));
|
assert_eq!(progress.first(), Some(&3));
|
||||||
@@ -835,23 +898,23 @@ mod tests {
|
|||||||
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
|
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
|
||||||
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
|
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
|
||||||
fs::write(&local_source, &payload).expect("write local SFTP fixture");
|
fs::write(&local_source, &payload).expect("write local SFTP fixture");
|
||||||
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
|
let transfer_result = service.with_sftp(&sftp_id, |sftp| {
|
||||||
let transfer_result = (|| -> Result<(), String> {
|
upload_file(sftp, &local_source, &remote_path, None, |_, _| {})?;
|
||||||
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
|
download_file(sftp, &remote_path, &local_download, None, |_, _| {})?;
|
||||||
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
|
|
||||||
let downloaded = fs::read(&local_download)
|
let downloaded = fs::read(&local_download)
|
||||||
.map_err(|error| format!("read downloaded probe: {error}"))?;
|
.map_err(|error| format!("read downloaded probe: {error}"))?;
|
||||||
if downloaded != payload {
|
if downloaded != payload {
|
||||||
return Err("SFTP round-trip content mismatch".to_string());
|
return Err("SFTP round-trip content mismatch".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
let _ = sftp.unlink(Path::new(&remote_path));
|
let _ = sftp.unlink(Path::new(&remote_path));
|
||||||
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
let _ = fs::remove_file(&local_source);
|
let _ = fs::remove_file(&local_source);
|
||||||
let _ = fs::remove_file(&local_download);
|
let _ = fs::remove_file(&local_download);
|
||||||
let _ = fs::remove_file(local_partial_path(&local_download));
|
let _ = fs::remove_file(local_partial_path(&local_download));
|
||||||
transfer_result.expect("SFTP upload/download round trip");
|
transfer_result.expect("SFTP upload/download round trip");
|
||||||
|
service.close_sftp(&sftp_id).expect("close SFTP");
|
||||||
let connection = service.connection(&connection_id).unwrap();
|
let connection = service.connection(&connection_id).unwrap();
|
||||||
let shell_session = establish_session(&connection.config).expect("shell SSH session");
|
let shell_session = establish_session(&connection.config).expect("shell SSH session");
|
||||||
let mut shell = shell_session.channel_session().expect("shell channel");
|
let mut shell = shell_session.channel_session().expect("shell channel");
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ fn remove_known_host_entries(host: &str, port: u16) -> Result<(), String> {
|
|||||||
let retained = input
|
let retained = input
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| {
|
.filter(|line| {
|
||||||
line.split_whitespace().next().map_or(true, |hosts| {
|
line.split_whitespace().next().is_none_or(|hosts| {
|
||||||
!hosts
|
!hosts
|
||||||
.split(',')
|
.split(',')
|
||||||
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use std::{
|
|||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
io::{Read, Seek, SeekFrom, Write},
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
|
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
|
||||||
@@ -10,25 +15,88 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::domain::ssh::{SftpDirEntry, SftpFileAttrs, SftpProgressEvent};
|
use crate::domain::ssh::{SftpDirEntry, SftpFileAttrs, SftpProgressEvent};
|
||||||
|
|
||||||
use super::{lock_error, path::expand_home, SshService};
|
use super::{establish_session, lock_error, path::expand_home, ManagedSftp, SshService};
|
||||||
|
|
||||||
|
/// Soft cap for in-memory remote text editing (matches UI copy).
|
||||||
|
const MAX_REMOTE_TEXT_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
const TEXT_READ_CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
impl SshService {
|
impl SshService {
|
||||||
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
|
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
|
||||||
let connection = self.connection(connection_id)?;
|
let connection = self.connection(connection_id)?;
|
||||||
connection
|
// Keep browsing off the pooled connection. Upload/download lazily create
|
||||||
.session
|
// their own transport so a long copy cannot block directory operations.
|
||||||
|
let session = establish_session(&connection.config)?;
|
||||||
|
let sftp = session
|
||||||
.sftp()
|
.sftp()
|
||||||
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
|
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
|
||||||
let id = format!("sftp-{}", Uuid::new_v4());
|
let id = format!("sftp-{}", Uuid::new_v4());
|
||||||
self.sftps
|
self.sftps.lock().map_err(lock_error)?.insert(
|
||||||
.lock()
|
id.clone(),
|
||||||
.map_err(lock_error)?
|
Arc::new(Mutex::new(ManagedSftp {
|
||||||
.insert(id.clone(), connection_id.to_string());
|
connection_id: connection_id.to_string(),
|
||||||
|
session,
|
||||||
|
sftp,
|
||||||
|
})),
|
||||||
|
);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn close_sftp(&self, sftp_id: &str) -> Result<(), String> {
|
||||||
|
let _ = self.cancel_sftp_transfer(sftp_id);
|
||||||
|
let _ = self
|
||||||
|
.sftp_transfer_cancels
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.remove(sftp_id);
|
||||||
|
let browse = self.sftps.lock().map_err(lock_error)?.remove(sftp_id);
|
||||||
|
let transfer = self
|
||||||
|
.sftp_transfers
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.remove(sftp_id);
|
||||||
|
disconnect_managed_sftp(browse, "sftp browsing closed");
|
||||||
|
disconnect_managed_sftp(transfer, "sftp transfer closed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the in-flight copy loop for this SFTP session to stop at the next chunk.
|
||||||
|
pub fn cancel_sftp_transfer(&self, sftp_id: &str) -> Result<(), String> {
|
||||||
|
tracing::info!(sftp_id = %sftp_id, "SFTP transfer cancellation requested");
|
||||||
|
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||||
|
let flag = cancels
|
||||||
|
.entry(sftp_id.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(AtomicBool::new(true)));
|
||||||
|
flag.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_sftp_transfer(&self, sftp_id: &str) -> Result<Arc<AtomicBool>, String> {
|
||||||
|
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||||
|
Ok(Arc::clone(
|
||||||
|
cancels
|
||||||
|
.entry(sftp_id.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(AtomicBool::new(false))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
sftp_id: &str,
|
||||||
|
completed_flag: &Arc<AtomicBool>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||||
|
if cancels
|
||||||
|
.get(sftp_id)
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(current, completed_flag))
|
||||||
|
{
|
||||||
|
cancels.remove(sftp_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sftp_list(&self, sftp_id: &str, remote_path: &str) -> Result<Vec<SftpDirEntry>, String> {
|
pub fn sftp_list(&self, sftp_id: &str, remote_path: &str) -> Result<Vec<SftpDirEntry>, String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
let mut entries = sftp
|
let mut entries = sftp
|
||||||
.readdir(Path::new(remote_path))
|
.readdir(Path::new(remote_path))
|
||||||
.map_err(|error| format!("读取远程目录失败: {error}"))?
|
.map_err(|error| format!("读取远程目录失败: {error}"))?
|
||||||
@@ -43,6 +111,7 @@ impl SshService {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
entries.sort_by_key(|entry| entry.filename.to_lowercase());
|
entries.sort_by_key(|entry| entry.filename.to_lowercase());
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_upload(
|
pub fn sftp_upload(
|
||||||
@@ -52,13 +121,21 @@ impl SshService {
|
|||||||
local_path: &str,
|
local_path: &str,
|
||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
tracing::info!(sftp_id = %sftp_id, operation = "upload", "SFTP transfer started");
|
||||||
|
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||||
|
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||||
|
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||||
upload_file(
|
upload_file(
|
||||||
&sftp,
|
sftp,
|
||||||
&expand_home(local_path),
|
&expand_home(local_path),
|
||||||
remote_path,
|
remote_path,
|
||||||
|transferred, total| emit_progress(app, sftp_id, transferred, total),
|
Some(cancel.as_ref()),
|
||||||
|
|transferred, total| progress.emit(transferred, total),
|
||||||
)
|
)
|
||||||
|
});
|
||||||
|
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||||
|
trace_transfer_result(sftp_id, "upload", &result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_download(
|
pub fn sftp_download(
|
||||||
@@ -68,11 +145,22 @@ impl SshService {
|
|||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
local_path: &str,
|
local_path: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
tracing::info!(sftp_id = %sftp_id, operation = "download", "SFTP transfer started");
|
||||||
|
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||||
|
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||||
|
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||||
let local_path = expand_home(local_path);
|
let local_path = expand_home(local_path);
|
||||||
download_file(&sftp, remote_path, &local_path, |transferred, total| {
|
download_file(
|
||||||
emit_progress(app, sftp_id, transferred, total)
|
sftp,
|
||||||
})
|
remote_path,
|
||||||
|
&local_path,
|
||||||
|
Some(cancel.as_ref()),
|
||||||
|
|transferred, total| progress.emit(transferred, total),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||||
|
trace_transfer_result(sftp_id, "download", &result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_upload_dir(
|
pub fn sftp_upload_dir(
|
||||||
@@ -82,13 +170,21 @@ impl SshService {
|
|||||||
local_dir: &str,
|
local_dir: &str,
|
||||||
remote_dir: &str,
|
remote_dir: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
tracing::info!(sftp_id = %sftp_id, operation = "upload_dir", "SFTP transfer started");
|
||||||
|
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||||
|
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||||
|
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||||
upload_dir(
|
upload_dir(
|
||||||
&sftp,
|
sftp,
|
||||||
&expand_home(local_dir),
|
&expand_home(local_dir),
|
||||||
remote_dir,
|
remote_dir,
|
||||||
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
|
Some(cancel.as_ref()),
|
||||||
|
&mut |transferred, total| progress.emit(transferred, total),
|
||||||
)
|
)
|
||||||
|
});
|
||||||
|
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||||
|
trace_transfer_result(sftp_id, "upload_dir", &result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_download_dir(
|
pub fn sftp_download_dir(
|
||||||
@@ -98,11 +194,22 @@ impl SshService {
|
|||||||
remote_dir: &str,
|
remote_dir: &str,
|
||||||
local_dir: &str,
|
local_dir: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
tracing::info!(sftp_id = %sftp_id, operation = "download_dir", "SFTP transfer started");
|
||||||
|
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||||
|
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||||
|
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||||
let local_dir = expand_home(local_dir);
|
let local_dir = expand_home(local_dir);
|
||||||
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
|
download_dir(
|
||||||
emit_progress(app, sftp_id, transferred, total)
|
sftp,
|
||||||
})
|
remote_dir,
|
||||||
|
&local_dir,
|
||||||
|
Some(cancel.as_ref()),
|
||||||
|
&mut |transferred, total| progress.emit(transferred, total),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||||
|
trace_transfer_result(sftp_id, "download_dir", &result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_rename(
|
pub fn sftp_rename(
|
||||||
@@ -111,9 +218,10 @@ impl SshService {
|
|||||||
old_remote_path: &str,
|
old_remote_path: &str,
|
||||||
new_remote_path: &str,
|
new_remote_path: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
self.sftp(sftp_id)?
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
|
sftp.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
|
||||||
.map_err(|error| format!("远程重命名失败: {error}"))
|
.map_err(|error| format!("远程重命名失败: {error}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_delete(
|
pub fn sftp_delete(
|
||||||
@@ -122,7 +230,7 @@ impl SshService {
|
|||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
directory: bool,
|
directory: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
if directory {
|
if directory {
|
||||||
sftp.rmdir(Path::new(remote_path))
|
sftp.rmdir(Path::new(remote_path))
|
||||||
.map_err(|error| format!("删除远程目录失败: {error}"))
|
.map_err(|error| format!("删除远程目录失败: {error}"))
|
||||||
@@ -130,23 +238,26 @@ impl SshService {
|
|||||||
sftp.unlink(Path::new(remote_path))
|
sftp.unlink(Path::new(remote_path))
|
||||||
.map_err(|error| format!("删除远程文件失败: {error}"))
|
.map_err(|error| format!("删除远程文件失败: {error}"))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
|
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
|
||||||
self.sftp(sftp_id)?
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
.mkdir(Path::new(remote_path), 0o755)
|
sftp.mkdir(Path::new(remote_path), 0o755)
|
||||||
.map_err(|error| format!("创建远程目录失败: {error}"))
|
.map_err(|error| format!("创建远程目录失败: {error}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
|
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
|
||||||
self.sftp(sftp_id)?
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
.lstat(Path::new(remote_path))
|
sftp.lstat(Path::new(remote_path))
|
||||||
.map_err(|error| format!("读取远程属性失败: {error}"))
|
.map_err(|error| format!("读取远程属性失败: {error}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
|
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
|
||||||
self.sftp(sftp_id)?
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
.setstat(
|
sftp.setstat(
|
||||||
Path::new(remote_path),
|
Path::new(remote_path),
|
||||||
FileStat {
|
FileStat {
|
||||||
size: None,
|
size: None,
|
||||||
@@ -158,6 +269,7 @@ impl SshService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|error| format!("修改远程权限失败: {error}"))
|
.map_err(|error| format!("修改远程权限失败: {error}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_read_text(
|
pub fn sftp_read_text(
|
||||||
@@ -165,15 +277,9 @@ impl SshService {
|
|||||||
sftp_id: &str,
|
sftp_id: &str,
|
||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
) -> Result<(String, u64), String> {
|
) -> Result<(String, u64), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
let mut file = sftp
|
read_remote_text_capped(sftp, Path::new(remote_path), MAX_REMOTE_TEXT_BYTES)
|
||||||
.open(Path::new(remote_path))
|
})
|
||||||
.map_err(|error| format!("打开远程文件失败: {error}"))?;
|
|
||||||
let mut content = String::new();
|
|
||||||
file.read_to_string(&mut content)
|
|
||||||
.map_err(|error| format!("远程文件不是有效 UTF-8 文本: {error}"))?;
|
|
||||||
let size = content.len() as u64;
|
|
||||||
Ok((content, size))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sftp_write_text(
|
pub fn sftp_write_text(
|
||||||
@@ -182,26 +288,199 @@ impl SshService {
|
|||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sftp = self.sftp(sftp_id)?;
|
if content.len() as u64 > MAX_REMOTE_TEXT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"文件过大({}),编辑上限约 2MB",
|
||||||
|
format_byte_size(content.len() as u64)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.with_browse_sftp(sftp_id, |sftp| {
|
||||||
let mut file = sftp
|
let mut file = sftp
|
||||||
.create(Path::new(remote_path))
|
.create(Path::new(remote_path))
|
||||||
.map_err(|error| format!("创建远程文件失败: {error}"))?;
|
.map_err(|error| format!("创建远程文件失败: {error}"))?;
|
||||||
file.write_all(content.as_bytes())
|
file.write_all(content.as_bytes())
|
||||||
.map_err(|error| format!("写入远程文件失败: {error}"))
|
.map_err(|error| format!("写入远程文件失败: {error}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn sftp(&self, sftp_id: &str) -> Result<ssh2::Sftp, String> {
|
/// Blocking access to the browsing channel for native integration tests.
|
||||||
let connection_id = self
|
#[cfg(test)]
|
||||||
|
pub(super) fn with_sftp<R>(
|
||||||
|
&self,
|
||||||
|
sftp_id: &str,
|
||||||
|
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||||
|
) -> Result<R, String> {
|
||||||
|
let handle = self
|
||||||
.sftps
|
.sftps
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(lock_error)?
|
.map_err(lock_error)?
|
||||||
.get(sftp_id)
|
.get(sftp_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||||
self.connection(&connection_id)?
|
let guard = handle.lock().map_err(lock_error)?;
|
||||||
.session
|
f(&guard.sftp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer-only channel. It is opened lazily and then reused by the serial
|
||||||
|
/// transfer pump, so large copies never hold the browsing channel lock.
|
||||||
|
fn with_transfer_sftp<R>(
|
||||||
|
&self,
|
||||||
|
sftp_id: &str,
|
||||||
|
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||||
|
) -> Result<R, String> {
|
||||||
|
let existing = self
|
||||||
|
.sftp_transfers
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.get(sftp_id)
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
let handle = match existing {
|
||||||
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
let browse = self
|
||||||
|
.sftps
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.get(sftp_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||||
|
let connection_id = browse.lock().map_err(lock_error)?.connection_id.clone();
|
||||||
|
let config = self.connection(&connection_id)?.config;
|
||||||
|
let session = establish_session(&config)?;
|
||||||
|
let sftp = session
|
||||||
.sftp()
|
.sftp()
|
||||||
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
|
.map_err(|error| format!("初始化 SFTP 传输通道失败: {error}"))?;
|
||||||
|
let created = Arc::new(Mutex::new(ManagedSftp {
|
||||||
|
connection_id,
|
||||||
|
session,
|
||||||
|
sftp,
|
||||||
|
}));
|
||||||
|
tracing::info!(sftp_id = %sftp_id, "SFTP transfer channel created");
|
||||||
|
self.sftp_transfers
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.entry(sftp_id.to_string())
|
||||||
|
.or_insert_with(|| Arc::clone(&created))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = handle.lock().map_err(lock_error)?;
|
||||||
|
f(&guard.sftp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize short browsing/file operations on their dedicated background channel.
|
||||||
|
/// Tauri commands call this from `spawn_blocking`, so waiting here never blocks the UI.
|
||||||
|
pub(super) fn with_browse_sftp<R>(
|
||||||
|
&self,
|
||||||
|
sftp_id: &str,
|
||||||
|
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||||
|
) -> Result<R, String> {
|
||||||
|
let handle = self
|
||||||
|
.sftps
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.get(sftp_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||||
|
let guard = handle.lock().map_err(lock_error)?;
|
||||||
|
f(&guard.sftp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect_managed_sftp(handle: Option<Arc<Mutex<ManagedSftp>>>, reason: &str) {
|
||||||
|
if let Some(handle) = handle {
|
||||||
|
if let Ok(guard) = handle.lock() {
|
||||||
|
let _ = guard.session.disconnect(None, reason, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_byte_size(bytes: u64) -> String {
|
||||||
|
const KB: f64 = 1024.0;
|
||||||
|
const MB: f64 = KB * 1024.0;
|
||||||
|
let value = bytes as f64;
|
||||||
|
if value >= MB {
|
||||||
|
format!("{:.1} MB", value / MB)
|
||||||
|
} else if value >= KB {
|
||||||
|
format!("{:.1} KB", value / KB)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_binary(sample: &[u8]) -> bool {
|
||||||
|
if sample.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// NUL in the first chunk is a strong binary signal for text editors.
|
||||||
|
sample.contains(&0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_remote_text_capped(
|
||||||
|
sftp: &ssh2::Sftp,
|
||||||
|
remote_path: &Path,
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<(String, u64), String> {
|
||||||
|
if let Ok(stat) = sftp.stat(remote_path) {
|
||||||
|
if let Some(size) = stat.size {
|
||||||
|
if size > max_bytes {
|
||||||
|
return Err(format!(
|
||||||
|
"文件过大({}),编辑上限约 2MB",
|
||||||
|
format_byte_size(size)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = sftp
|
||||||
|
.open(remote_path)
|
||||||
|
.map_err(|error| format!("打开远程文件失败: {error}"))?;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut chunk = vec![0u8; TEXT_READ_CHUNK];
|
||||||
|
let mut sniffed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let read = file
|
||||||
|
.read(&mut chunk)
|
||||||
|
.map_err(|error| format!("读取远程文件失败: {error}"))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !sniffed {
|
||||||
|
sniffed = true;
|
||||||
|
if looks_like_binary(&chunk[..read]) {
|
||||||
|
return Err("该文件像是二进制内容,无法用文本编辑器打开".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.len() as u64).saturating_add(read as u64) > max_bytes {
|
||||||
|
return Err(format!(
|
||||||
|
"文件过大(超过 {}),编辑上限约 2MB",
|
||||||
|
format_byte_size(max_bytes)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = buffer.len() as u64;
|
||||||
|
let content =
|
||||||
|
String::from_utf8(buffer).map_err(|_| "远程文件不是有效 UTF-8 文本".to_string())?;
|
||||||
|
Ok((content, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace_transfer_result(sftp_id: &str, operation: &str, result: &Result<(), String>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer completed"),
|
||||||
|
Err(error) if error.contains("传输已取消") => {
|
||||||
|
tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer canceled")
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!(
|
||||||
|
sftp_id = %sftp_id,
|
||||||
|
operation,
|
||||||
|
error = %error,
|
||||||
|
"SFTP transfer failed"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +514,28 @@ fn permission_string(mode: u32) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn transfer_cancelled(cancel: Option<&AtomicBool>) -> bool {
|
||||||
|
cancel
|
||||||
|
.map(|flag| flag.load(Ordering::Relaxed))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_not_cancelled(cancel: Option<&AtomicBool>) -> Result<(), String> {
|
||||||
|
if transfer_cancelled(cancel) {
|
||||||
|
Err("传输已取消".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn upload_file(
|
pub(super) fn upload_file(
|
||||||
sftp: &ssh2::Sftp,
|
sftp: &ssh2::Sftp,
|
||||||
local_path: &Path,
|
local_path: &Path,
|
||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
|
cancel: Option<&AtomicBool>,
|
||||||
mut progress: impl FnMut(u64, u64),
|
mut progress: impl FnMut(u64, u64),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let mut source = fs::File::open(local_path)
|
let mut source = fs::File::open(local_path)
|
||||||
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
|
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
|
||||||
let total = source
|
let total = source
|
||||||
@@ -271,9 +566,14 @@ pub(super) fn upload_file(
|
|||||||
target
|
target
|
||||||
.seek(SeekFrom::Start(offset))
|
.seek(SeekFrom::Start(offset))
|
||||||
.map_err(|error| format!("定位远程断点失败: {error}"))?;
|
.map_err(|error| format!("定位远程断点失败: {error}"))?;
|
||||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
copy_with_progress(
|
||||||
progress(transferred, total)
|
&mut source,
|
||||||
})?;
|
&mut target,
|
||||||
|
offset,
|
||||||
|
total,
|
||||||
|
cancel,
|
||||||
|
|transferred| progress(transferred, total),
|
||||||
|
)?;
|
||||||
drop(target);
|
drop(target);
|
||||||
let uploaded = sftp
|
let uploaded = sftp
|
||||||
.stat(Path::new(&partial_path))
|
.stat(Path::new(&partial_path))
|
||||||
@@ -292,8 +592,10 @@ pub(super) fn download_file(
|
|||||||
sftp: &ssh2::Sftp,
|
sftp: &ssh2::Sftp,
|
||||||
remote_path: &str,
|
remote_path: &str,
|
||||||
local_path: &Path,
|
local_path: &Path,
|
||||||
|
cancel: Option<&AtomicBool>,
|
||||||
mut progress: impl FnMut(u64, u64),
|
mut progress: impl FnMut(u64, u64),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let mut source = sftp
|
let mut source = sftp
|
||||||
.open(Path::new(remote_path))
|
.open(Path::new(remote_path))
|
||||||
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
|
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
|
||||||
@@ -319,9 +621,14 @@ pub(super) fn download_file(
|
|||||||
target
|
target
|
||||||
.seek(SeekFrom::Start(offset))
|
.seek(SeekFrom::Start(offset))
|
||||||
.map_err(|error| format!("定位本地断点失败: {error}"))?;
|
.map_err(|error| format!("定位本地断点失败: {error}"))?;
|
||||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
copy_with_progress(
|
||||||
progress(transferred, total)
|
&mut source,
|
||||||
})?;
|
&mut target,
|
||||||
|
offset,
|
||||||
|
total,
|
||||||
|
cancel,
|
||||||
|
|transferred| progress(transferred, total),
|
||||||
|
)?;
|
||||||
target
|
target
|
||||||
.sync_all()
|
.sync_all()
|
||||||
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
|
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
|
||||||
@@ -397,8 +704,10 @@ fn upload_dir(
|
|||||||
sftp: &ssh2::Sftp,
|
sftp: &ssh2::Sftp,
|
||||||
local_dir: &Path,
|
local_dir: &Path,
|
||||||
remote_dir: &str,
|
remote_dir: &str,
|
||||||
|
cancel: Option<&AtomicBool>,
|
||||||
progress: &mut dyn FnMut(u64, u64),
|
progress: &mut dyn FnMut(u64, u64),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let meta = fs::metadata(local_dir)
|
let meta = fs::metadata(local_dir)
|
||||||
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
|
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
|
||||||
if !meta.is_dir() {
|
if !meta.is_dir() {
|
||||||
@@ -413,6 +722,7 @@ fn upload_dir(
|
|||||||
entries.sort_by_key(|entry| entry.file_name());
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let file_type = entry
|
let file_type = entry
|
||||||
.file_type()
|
.file_type()
|
||||||
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
|
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
|
||||||
@@ -423,11 +733,9 @@ fn upload_dir(
|
|||||||
let local_child = entry.path();
|
let local_child = entry.path();
|
||||||
let remote_child = join_remote(remote_dir, &name);
|
let remote_child = join_remote(remote_dir, &name);
|
||||||
if file_type.is_dir() {
|
if file_type.is_dir() {
|
||||||
upload_dir(sftp, &local_child, &remote_child, progress)?;
|
upload_dir(sftp, &local_child, &remote_child, cancel, progress)?;
|
||||||
} else if file_type.is_file() {
|
} else if file_type.is_file() {
|
||||||
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
|
upload_file(sftp, &local_child, &remote_child, cancel, &mut *progress)?;
|
||||||
progress(transferred, total)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
// skip symlinks / special files for safety
|
// skip symlinks / special files for safety
|
||||||
}
|
}
|
||||||
@@ -438,8 +746,10 @@ fn download_dir(
|
|||||||
sftp: &ssh2::Sftp,
|
sftp: &ssh2::Sftp,
|
||||||
remote_dir: &str,
|
remote_dir: &str,
|
||||||
local_dir: &Path,
|
local_dir: &Path,
|
||||||
|
cancel: Option<&AtomicBool>,
|
||||||
progress: &mut dyn FnMut(u64, u64),
|
progress: &mut dyn FnMut(u64, u64),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let stat = sftp
|
let stat = sftp
|
||||||
.lstat(Path::new(remote_dir))
|
.lstat(Path::new(remote_dir))
|
||||||
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
|
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
|
||||||
@@ -455,6 +765,7 @@ fn download_dir(
|
|||||||
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
|
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
|
||||||
|
|
||||||
for (path, entry_stat) in entries {
|
for (path, entry_stat) in entries {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let name = match path.file_name() {
|
let name = match path.file_name() {
|
||||||
Some(value) => value.to_string_lossy().to_string(),
|
Some(value) => value.to_string_lossy().to_string(),
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -465,11 +776,9 @@ fn download_dir(
|
|||||||
let remote_child = join_remote(remote_dir, &name);
|
let remote_child = join_remote(remote_dir, &name);
|
||||||
let local_child = local_dir.join(&name);
|
let local_child = local_dir.join(&name);
|
||||||
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
|
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
|
||||||
download_dir(sftp, &remote_child, &local_child, progress)?;
|
download_dir(sftp, &remote_child, &local_child, cancel, progress)?;
|
||||||
} else {
|
} else {
|
||||||
download_file(sftp, &remote_child, &local_child, |transferred, total| {
|
download_file(sftp, &remote_child, &local_child, cancel, &mut *progress)?;
|
||||||
progress(transferred, total)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -480,12 +789,15 @@ pub(super) fn copy_with_progress(
|
|||||||
writer: &mut impl Write,
|
writer: &mut impl Write,
|
||||||
initial: u64,
|
initial: u64,
|
||||||
total: u64,
|
total: u64,
|
||||||
|
cancel: Option<&AtomicBool>,
|
||||||
mut progress: impl FnMut(u64),
|
mut progress: impl FnMut(u64),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut buffer = [0_u8; 64 * 1024];
|
// 256 KiB chunks: fewer syscalls / progress callbacks on large files.
|
||||||
|
let mut buffer = [0_u8; 256 * 1024];
|
||||||
let mut transferred = initial;
|
let mut transferred = initial;
|
||||||
progress(transferred);
|
progress(transferred);
|
||||||
loop {
|
loop {
|
||||||
|
ensure_not_cancelled(cancel)?;
|
||||||
let length = reader
|
let length = reader
|
||||||
.read(&mut buffer)
|
.read(&mut buffer)
|
||||||
.map_err(|error| format!("读取传输数据失败: {error}"))?;
|
.map_err(|error| format!("读取传输数据失败: {error}"))?;
|
||||||
@@ -535,19 +847,78 @@ pub(super) fn local_partial_path(destination: &Path) -> PathBuf {
|
|||||||
PathBuf::from(value)
|
PathBuf::from(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_progress(app: &AppHandle, sftp_id: &str, transferred: u64, total: u64) {
|
/// Throttle IPC progress so a large file does not flood the WebView/React thread.
|
||||||
let percentage = transferred
|
struct ProgressEmitter {
|
||||||
|
app: AppHandle,
|
||||||
|
sftp_id: String,
|
||||||
|
last_emit: Instant,
|
||||||
|
last_percent: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressEmitter {
|
||||||
|
fn new(app: AppHandle, sftp_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
app,
|
||||||
|
sftp_id: sftp_id.to_string(),
|
||||||
|
// Force first emit after start.
|
||||||
|
last_emit: Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(1))
|
||||||
|
.unwrap_or_else(Instant::now),
|
||||||
|
last_percent: 255,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&mut self, transferred: u64, total: u64) {
|
||||||
|
let percentage = if total == 0 {
|
||||||
|
100
|
||||||
|
} else {
|
||||||
|
transferred
|
||||||
.saturating_mul(100)
|
.saturating_mul(100)
|
||||||
.checked_div(total)
|
.checked_div(total)
|
||||||
.unwrap_or(100)
|
.unwrap_or(100)
|
||||||
.min(100) as u8;
|
.min(100) as u8
|
||||||
let _ = app.emit(
|
};
|
||||||
|
let done = total > 0 && transferred >= total;
|
||||||
|
// Cap IPC to ~1 Hz (plus start/done). Faster % ticks were freezing the WebView.
|
||||||
|
if !done && self.last_percent != 255 && self.last_emit.elapsed() < Duration::from_secs(1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.last_emit = Instant::now();
|
||||||
|
self.last_percent = percentage;
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
let sid = if self.sftp_id.len() > 12 {
|
||||||
|
&self.sftp_id[self.sftp_id.len() - 12..]
|
||||||
|
} else {
|
||||||
|
self.sftp_id.as_str()
|
||||||
|
};
|
||||||
|
eprintln!("[sftp-xfer] emit sid=…{sid} {percentage}% {transferred}/{total} bytes");
|
||||||
|
}
|
||||||
|
let _ = self.app.emit(
|
||||||
"sftp:progress",
|
"sftp:progress",
|
||||||
SftpProgressEvent {
|
SftpProgressEvent {
|
||||||
sftp_id: sftp_id.to_string(),
|
sftp_id: self.sftp_id.clone(),
|
||||||
transferred,
|
transferred,
|
||||||
total,
|
total,
|
||||||
percentage,
|
percentage,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cancellation_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_requested_before_worker_start_is_not_lost() {
|
||||||
|
let service = SshService::default();
|
||||||
|
service.cancel_sftp_transfer("sftp-test").unwrap();
|
||||||
|
|
||||||
|
let flag = service.begin_sftp_transfer("sftp-test").unwrap();
|
||||||
|
assert!(flag.load(Ordering::Acquire));
|
||||||
|
|
||||||
|
service.finish_sftp_transfer("sftp-test", &flag).unwrap();
|
||||||
|
let next = service.begin_sftp_transfer("sftp-test").unwrap();
|
||||||
|
assert!(!next.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
}
|
||||||
+471
-123
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, HashSet},
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
net::{TcpListener, TcpStream},
|
net::{TcpListener, TcpStream},
|
||||||
sync::{
|
sync::{
|
||||||
@@ -7,7 +7,7 @@ use std::{
|
|||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ssh2::ErrorCode;
|
use ssh2::ErrorCode;
|
||||||
@@ -19,10 +19,12 @@ use super::ssh::establish_session;
|
|||||||
|
|
||||||
mod model;
|
mod model;
|
||||||
mod relay;
|
mod relay;
|
||||||
|
mod session;
|
||||||
mod socks5;
|
mod socks5;
|
||||||
|
|
||||||
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
|
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
|
||||||
use relay::relay_channel;
|
use relay::relay_channel;
|
||||||
|
use session::TunnelSession;
|
||||||
use socks5::read_target;
|
use socks5::read_target;
|
||||||
|
|
||||||
enum IdClaim {
|
enum IdClaim {
|
||||||
@@ -43,16 +45,18 @@ impl TunnelManager {
|
|||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
config: SshConnectRequest,
|
config: SshConnectRequest,
|
||||||
|
local_bind_host: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
remote_host: String,
|
remote_host: String,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
) -> Result<ForwardStatus, String> {
|
) -> Result<ForwardStatus, String> {
|
||||||
validate_target(&remote_host, remote_port)?;
|
let remote_host = validate_target(&remote_host, remote_port)?;
|
||||||
|
let bind_host = validate_bind_host(&local_bind_host)?;
|
||||||
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
|
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
|
||||||
IdClaim::Existing(status) => return Ok(status),
|
IdClaim::Existing(status) => return Ok(status),
|
||||||
IdClaim::Fresh(id) => id,
|
IdClaim::Fresh(id) => id,
|
||||||
};
|
};
|
||||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
let listener = TcpListener::bind((bind_host.as_str(), local_port))
|
||||||
.map_err(|error| format!("监听本地端口失败: {error}"))?;
|
.map_err(|error| format!("监听本地端口失败: {error}"))?;
|
||||||
listener
|
listener
|
||||||
.set_nonblocking(true)
|
.set_nonblocking(true)
|
||||||
@@ -65,7 +69,7 @@ impl TunnelManager {
|
|||||||
&forward_id,
|
&forward_id,
|
||||||
connection_id,
|
connection_id,
|
||||||
"local",
|
"local",
|
||||||
"127.0.0.1",
|
&bind_host,
|
||||||
bound_port,
|
bound_port,
|
||||||
Some(remote_host.clone()),
|
Some(remote_host.clone()),
|
||||||
Some(remote_port),
|
Some(remote_port),
|
||||||
@@ -76,42 +80,25 @@ impl TunnelManager {
|
|||||||
let thread_cancel = cancel.clone();
|
let thread_cancel = cancel.clone();
|
||||||
let thread_running = running.clone();
|
let thread_running = running.clone();
|
||||||
let thread_counters = counters.clone();
|
let thread_counters = counters.clone();
|
||||||
|
let fingerprint = connection_fingerprint(&config);
|
||||||
let listener_thread = thread::spawn(move || {
|
let listener_thread = thread::spawn(move || {
|
||||||
while !thread_cancel.load(Ordering::Acquire) {
|
run_direct_listener(
|
||||||
match listener.accept() {
|
listener,
|
||||||
Ok((socket, _)) => {
|
config,
|
||||||
let config = config.clone();
|
DirectTarget::Fixed {
|
||||||
let host = remote_host.clone();
|
host: remote_host,
|
||||||
let cancel = thread_cancel.clone();
|
port: remote_port,
|
||||||
let counters = thread_counters.clone();
|
},
|
||||||
thread::spawn(move || {
|
thread_cancel,
|
||||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
thread_running,
|
||||||
else {
|
thread_counters,
|
||||||
return;
|
);
|
||||||
};
|
|
||||||
let Ok(session) = establish_session(&config) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(channel) =
|
|
||||||
session.channel_direct_tcpip(&host, remote_port, None)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
relay_channel(session, channel, socket, cancel, counters);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
|
||||||
thread::sleep(Duration::from_millis(25));
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread_running.store(false, Ordering::Release);
|
|
||||||
});
|
});
|
||||||
self.register(
|
self.register(
|
||||||
forward_id,
|
forward_id,
|
||||||
TunnelRuntime {
|
TunnelRuntime {
|
||||||
descriptor,
|
descriptor,
|
||||||
|
fingerprint,
|
||||||
cancel,
|
cancel,
|
||||||
running,
|
running,
|
||||||
counters,
|
counters,
|
||||||
@@ -155,60 +142,22 @@ impl TunnelManager {
|
|||||||
let thread_cancel = cancel.clone();
|
let thread_cancel = cancel.clone();
|
||||||
let thread_running = running.clone();
|
let thread_running = running.clone();
|
||||||
let thread_counters = counters.clone();
|
let thread_counters = counters.clone();
|
||||||
|
let fingerprint = connection_fingerprint(&config);
|
||||||
let listener_thread = thread::spawn(move || {
|
let listener_thread = thread::spawn(move || {
|
||||||
while !thread_cancel.load(Ordering::Acquire) {
|
run_direct_listener(
|
||||||
match listener.accept() {
|
listener,
|
||||||
Ok((mut socket, _)) => {
|
config,
|
||||||
let config = config.clone();
|
DirectTarget::Dynamic,
|
||||||
let cancel = thread_cancel.clone();
|
thread_cancel,
|
||||||
let counters = thread_counters.clone();
|
thread_running,
|
||||||
thread::spawn(move || {
|
thread_counters,
|
||||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
);
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (host, port) = match read_target(&mut socket) {
|
|
||||||
Ok(target) => target,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!("SOCKS5 请求解析失败: {error}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if cancel.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let session = match establish_session(&config) {
|
|
||||||
Ok(session) => session,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!("SOCKS5 专用 SSH 连接失败: {error}");
|
|
||||||
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let channel = match session.channel_direct_tcpip(&host, port, None) {
|
|
||||||
Ok(channel) => channel,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!("SOCKS5 目标 {host}:{port} 连接失败: {error}");
|
|
||||||
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
|
|
||||||
relay_channel(session, channel, socket, cancel, counters);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
|
||||||
thread::sleep(Duration::from_millis(25));
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread_running.store(false, Ordering::Release);
|
|
||||||
});
|
});
|
||||||
self.register(
|
self.register(
|
||||||
forward_id,
|
forward_id,
|
||||||
TunnelRuntime {
|
TunnelRuntime {
|
||||||
descriptor,
|
descriptor,
|
||||||
|
fingerprint,
|
||||||
cancel,
|
cancel,
|
||||||
running,
|
running,
|
||||||
counters,
|
counters,
|
||||||
@@ -222,67 +171,65 @@ impl TunnelManager {
|
|||||||
forward_id: Option<String>,
|
forward_id: Option<String>,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
config: SshConnectRequest,
|
config: SshConnectRequest,
|
||||||
|
remote_bind_host: String,
|
||||||
remote_port: u16,
|
remote_port: u16,
|
||||||
local_host: String,
|
local_host: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
) -> Result<ForwardStatus, String> {
|
) -> Result<ForwardStatus, String> {
|
||||||
validate_target(&local_host, local_port)?;
|
let local_host = validate_target(&local_host, local_port)?;
|
||||||
|
let remote_bind_host = validate_remote_bind_host(&remote_bind_host)?;
|
||||||
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
|
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
|
||||||
IdClaim::Existing(status) => return Ok(status),
|
IdClaim::Existing(status) => return Ok(status),
|
||||||
IdClaim::Fresh(id) => id,
|
IdClaim::Fresh(id) => id,
|
||||||
};
|
};
|
||||||
let session = establish_session(&config)?;
|
let session = establish_session(&config)?;
|
||||||
let (mut listener, bound_port) = session
|
let want_all_interfaces = is_all_interfaces_bind(&remote_bind_host);
|
||||||
.channel_forward_listen(remote_port, None, None)
|
let (listener, bound_port, bind_host) =
|
||||||
.map_err(|error| format!("建立远程端口转发失败: {error}"))?;
|
listen_remote_forward(&session, remote_port, &remote_bind_host).map_err(|error| {
|
||||||
|
map_remote_forward_error(error, remote_port, want_all_interfaces)
|
||||||
|
})?;
|
||||||
session.set_blocking(false);
|
session.set_blocking(false);
|
||||||
let descriptor = descriptor(
|
let descriptor = descriptor(
|
||||||
&forward_id,
|
&forward_id,
|
||||||
connection_id,
|
connection_id,
|
||||||
"remote",
|
"remote",
|
||||||
"remote",
|
if bind_host.is_empty() {
|
||||||
|
"localhost"
|
||||||
|
} else {
|
||||||
|
bind_host.as_str()
|
||||||
|
},
|
||||||
bound_port,
|
bound_port,
|
||||||
Some(local_host.clone()),
|
Some(local_host.clone()),
|
||||||
Some(local_port),
|
Some(local_port),
|
||||||
);
|
);
|
||||||
|
let fingerprint = connection_fingerprint(&config);
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let counters = Arc::new(TunnelCounters::default());
|
let counters = Arc::new(TunnelCounters::default());
|
||||||
let thread_cancel = cancel.clone();
|
let thread_cancel = cancel.clone();
|
||||||
let thread_running = running.clone();
|
let thread_running = running.clone();
|
||||||
let thread_counters = counters.clone();
|
let thread_counters = counters.clone();
|
||||||
|
let thread_config = config.clone();
|
||||||
|
let thread_bind = remote_bind_host.clone();
|
||||||
let listener_thread = thread::spawn(move || {
|
let listener_thread = thread::spawn(move || {
|
||||||
while !thread_cancel.load(Ordering::Acquire) {
|
run_remote_listener(
|
||||||
match listener.accept() {
|
session,
|
||||||
Ok(channel) => {
|
listener,
|
||||||
let Ok(socket) = TcpStream::connect((&*local_host, local_port)) else {
|
thread_config,
|
||||||
continue;
|
remote_port,
|
||||||
};
|
thread_bind,
|
||||||
let session = session.clone();
|
local_host,
|
||||||
let cancel = thread_cancel.clone();
|
local_port,
|
||||||
let counters = thread_counters.clone();
|
thread_cancel,
|
||||||
thread::spawn(move || {
|
thread_running,
|
||||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
thread_counters,
|
||||||
else {
|
);
|
||||||
return;
|
|
||||||
};
|
|
||||||
relay_channel(session, channel, socket, cancel, counters);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
|
||||||
thread::sleep(Duration::from_millis(25));
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(listener);
|
|
||||||
let _ = session.disconnect(None, "port forward stopped", None);
|
|
||||||
thread_running.store(false, Ordering::Release);
|
|
||||||
});
|
});
|
||||||
self.register(
|
self.register(
|
||||||
forward_id,
|
forward_id,
|
||||||
TunnelRuntime {
|
TunnelRuntime {
|
||||||
descriptor,
|
descriptor,
|
||||||
|
fingerprint,
|
||||||
cancel,
|
cancel,
|
||||||
running,
|
running,
|
||||||
counters,
|
counters,
|
||||||
@@ -327,18 +274,43 @@ impl TunnelManager {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(lock_error)?
|
.map_err(lock_error)?
|
||||||
.values()
|
.values()
|
||||||
.filter(|runtime| {
|
.filter(|runtime| connection_id.is_none_or(|id| runtime.descriptor.connection_id == id))
|
||||||
connection_id.map_or(true, |id| runtime.descriptor.connection_id == id)
|
|
||||||
})
|
|
||||||
.map(TunnelRuntime::snapshot)
|
.map(TunnelRuntime::snapshot)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
|
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
|
||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebind listeners left behind when the pooled SSH connection was replaced.
|
||||||
|
/// Only takes runtimes whose SSH identity matches and whose previous connection
|
||||||
|
/// is no longer live — a second session to the same host must not steal them.
|
||||||
|
pub fn adopt_orphans(
|
||||||
|
&self,
|
||||||
|
new_connection_id: &str,
|
||||||
|
fingerprint: &str,
|
||||||
|
live_connection_ids: &HashSet<String>,
|
||||||
|
) -> Result<Vec<ForwardStatus>, String> {
|
||||||
|
let mut adopted = Vec::new();
|
||||||
|
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
|
||||||
|
for runtime in runtimes.values_mut() {
|
||||||
|
if runtime.fingerprint != fingerprint {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if runtime.descriptor.connection_id == new_connection_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if live_connection_ids.contains(&runtime.descriptor.connection_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
runtime.descriptor.connection_id = new_connection_id.to_string();
|
||||||
|
adopted.push(runtime.snapshot());
|
||||||
|
}
|
||||||
|
Ok(adopted)
|
||||||
|
}
|
||||||
|
|
||||||
/// Claim a forward id for start.
|
/// Claim a forward id for start.
|
||||||
/// - Same connection + kind already running → return existing status (idempotent).
|
/// - Same connection + kind still running → return existing status (idempotent).
|
||||||
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
|
/// - Same id dead, owned by another connection, or a different kind → stop and reuse.
|
||||||
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
|
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
|
||||||
fn claim_id(
|
fn claim_id(
|
||||||
&self,
|
&self,
|
||||||
@@ -354,7 +326,8 @@ impl TunnelManager {
|
|||||||
match runtimes.get(&id) {
|
match runtimes.get(&id) {
|
||||||
Some(runtime)
|
Some(runtime)
|
||||||
if runtime.descriptor.connection_id == connection_id
|
if runtime.descriptor.connection_id == connection_id
|
||||||
&& runtime.descriptor.kind == kind =>
|
&& runtime.descriptor.kind == kind
|
||||||
|
&& runtime.running.load(Ordering::Acquire) =>
|
||||||
{
|
{
|
||||||
return Ok(IdClaim::Existing(runtime.snapshot()));
|
return Ok(IdClaim::Existing(runtime.snapshot()));
|
||||||
}
|
}
|
||||||
@@ -394,20 +367,322 @@ impl Drop for TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_target(host: &str, port: u16) -> Result<(), String> {
|
pub(crate) fn connection_fingerprint(config: &SshConnectRequest) -> String {
|
||||||
if host.trim().is_empty() || port == 0 {
|
format!(
|
||||||
|
"{}@{}:{}",
|
||||||
|
config.username.trim(),
|
||||||
|
config.host.trim().to_ascii_lowercase(),
|
||||||
|
config.port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum DirectTarget {
|
||||||
|
Fixed { host: String, port: u16 },
|
||||||
|
Dynamic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local / SOCKS listener loop. The TCP bind stays up until the user stops
|
||||||
|
/// the rule; SSH session flaps only rebuild the dedicated transport.
|
||||||
|
fn run_direct_listener(
|
||||||
|
listener: TcpListener,
|
||||||
|
config: SshConnectRequest,
|
||||||
|
target: DirectTarget,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
counters: Arc<TunnelCounters>,
|
||||||
|
) {
|
||||||
|
running.store(true, Ordering::Release);
|
||||||
|
let tunnel = std::sync::Arc::new(TunnelSession::new(config));
|
||||||
|
let mut last_keepalive = Instant::now();
|
||||||
|
while !cancel.load(Ordering::Acquire) {
|
||||||
|
if last_keepalive.elapsed() >= Duration::from_secs(15) {
|
||||||
|
tunnel.keepalive();
|
||||||
|
last_keepalive = Instant::now();
|
||||||
|
}
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((socket, _)) => {
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
let counters = counters.clone();
|
||||||
|
let tunnel = tunnel.clone();
|
||||||
|
let target = target.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle_direct_client(socket, target, tunnel, cancel, counters);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error)
|
||||||
|
if error.kind() == io::ErrorKind::WouldBlock
|
||||||
|
|| error.kind() == io::ErrorKind::Interrupted =>
|
||||||
|
{
|
||||||
|
thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("端口转发本地监听 accept 失败,继续等待: {error}");
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tunnel.close();
|
||||||
|
running.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_direct_client(
|
||||||
|
mut socket: TcpStream,
|
||||||
|
target: DirectTarget,
|
||||||
|
tunnel: std::sync::Arc<TunnelSession>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
counters: Arc<TunnelCounters>,
|
||||||
|
) {
|
||||||
|
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let is_dynamic = matches!(target, DirectTarget::Dynamic);
|
||||||
|
let (host, port) = match target {
|
||||||
|
DirectTarget::Fixed { host, port } => (host, port),
|
||||||
|
DirectTarget::Dynamic => match read_target(&mut socket) {
|
||||||
|
Ok(destination) => destination,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("SOCKS5 请求解析失败: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if cancel.load(Ordering::Acquire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match tunnel.open_direct(&host, port, &cancel) {
|
||||||
|
Ok((session, channel)) => {
|
||||||
|
if is_dynamic {
|
||||||
|
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
|
||||||
|
}
|
||||||
|
relay_channel(session, channel, socket, cancel, counters);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("端口转发目标 {host}:{port} 连接失败: {error}");
|
||||||
|
if is_dynamic {
|
||||||
|
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_remote_listener(
|
||||||
|
mut session: ssh2::Session,
|
||||||
|
mut listener: ssh2::Listener,
|
||||||
|
config: SshConnectRequest,
|
||||||
|
remote_port: u16,
|
||||||
|
remote_bind_host: String,
|
||||||
|
local_host: String,
|
||||||
|
local_port: u16,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
counters: Arc<TunnelCounters>,
|
||||||
|
) {
|
||||||
|
running.store(true, Ordering::Release);
|
||||||
|
while !cancel.load(Ordering::Acquire) {
|
||||||
|
let died = accept_remote_until_dead(
|
||||||
|
&session,
|
||||||
|
&mut listener,
|
||||||
|
&local_host,
|
||||||
|
local_port,
|
||||||
|
&cancel,
|
||||||
|
&counters,
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::Acquire) || !died {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tracing::warn!("远程端口转发会话断开,正在重连 {remote_bind_host}:{remote_port}");
|
||||||
|
drop(listener);
|
||||||
|
let _ = session.disconnect(None, "port forward reconnect", None);
|
||||||
|
let Some((new_session, new_listener)) =
|
||||||
|
reconnect_remote_listener(&config, remote_port, &remote_bind_host, &cancel)
|
||||||
|
else {
|
||||||
|
running.store(false, Ordering::Release);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
session = new_session;
|
||||||
|
listener = new_listener;
|
||||||
|
}
|
||||||
|
drop(listener);
|
||||||
|
let _ = session.disconnect(None, "port forward stopped", None);
|
||||||
|
running.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_remote_until_dead(
|
||||||
|
session: &ssh2::Session,
|
||||||
|
listener: &mut ssh2::Listener,
|
||||||
|
local_host: &str,
|
||||||
|
local_port: u16,
|
||||||
|
cancel: &Arc<AtomicBool>,
|
||||||
|
counters: &Arc<TunnelCounters>,
|
||||||
|
) -> bool {
|
||||||
|
let mut last_keepalive = Instant::now();
|
||||||
|
while !cancel.load(Ordering::Acquire) {
|
||||||
|
if last_keepalive.elapsed() >= Duration::from_secs(15) {
|
||||||
|
let _ = session.keepalive_send();
|
||||||
|
last_keepalive = Instant::now();
|
||||||
|
}
|
||||||
|
match listener.accept() {
|
||||||
|
Ok(channel) => {
|
||||||
|
let Ok(socket) = TcpStream::connect((local_host, local_port)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let session = session.clone();
|
||||||
|
let cancel = Arc::clone(cancel);
|
||||||
|
let counters = Arc::clone(counters);
|
||||||
|
thread::spawn(move || {
|
||||||
|
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
relay_channel(session, channel, socket, cancel, counters);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
||||||
|
thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
Err(_) => return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconnect_remote_listener(
|
||||||
|
config: &SshConnectRequest,
|
||||||
|
remote_port: u16,
|
||||||
|
remote_bind_host: &str,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Option<(ssh2::Session, ssh2::Listener)> {
|
||||||
|
let mut delay = Duration::from_millis(400);
|
||||||
|
while !cancel.load(Ordering::Acquire) {
|
||||||
|
match establish_session(config) {
|
||||||
|
Ok(session) => match listen_remote_forward(&session, remote_port, remote_bind_host) {
|
||||||
|
Ok((listener, _, _)) => {
|
||||||
|
session.set_blocking(false);
|
||||||
|
return Some((session, listener));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("远程转发重听失败: {error}");
|
||||||
|
let _ = session.disconnect(None, "remote re-listen failed", None);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("远程转发重连失败: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(delay);
|
||||||
|
delay = (delay.saturating_mul(2)).min(Duration::from_secs(15));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_target(host: &str, port: u16) -> Result<String, String> {
|
||||||
|
let host = host.trim();
|
||||||
|
if host.is_empty() || port == 0 {
|
||||||
return Err("转发目标主机和端口不能为空".to_string());
|
return Err("转发目标主机和端口不能为空".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
// 0.0.0.0 / :: are listen-only; treat as remote loopback (common user mix-up).
|
||||||
|
if host == "0.0.0.0" || host == "::" || host.eq_ignore_ascii_case("[::]") {
|
||||||
|
return Ok("127.0.0.1".to_string());
|
||||||
|
}
|
||||||
|
Ok(host.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_bind_host(host: &str) -> Result<String, String> {
|
||||||
|
let host = host.trim();
|
||||||
|
if host.is_empty() {
|
||||||
|
return Ok("127.0.0.1".to_string());
|
||||||
|
}
|
||||||
|
if host.chars().any(char::is_whitespace) {
|
||||||
|
return Err("本地绑定地址不能包含空格".to_string());
|
||||||
|
}
|
||||||
|
Ok(host.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_remote_bind_host(host: &str) -> Result<String, String> {
|
||||||
|
let host = host.trim();
|
||||||
|
if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
|
||||||
|
return Ok("127.0.0.1".to_string());
|
||||||
|
}
|
||||||
|
if host.chars().any(char::is_whitespace) {
|
||||||
|
return Err("远端监听地址不能包含空格".to_string());
|
||||||
|
}
|
||||||
|
Ok(host.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_all_interfaces_bind(host: &str) -> bool {
|
||||||
|
let host = host.trim();
|
||||||
|
host == "0.0.0.0" || host == "*" || host == "::" || host.eq_ignore_ascii_case("[::]")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
|
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
|
||||||
"端口转发状态锁异常".to_string()
|
"端口转发状态锁异常".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask sshd to listen for remote forwards.
|
||||||
|
/// Loopback: try localhost / 127.0.0.1 / empty (OpenSSH -R default).
|
||||||
|
/// All-interfaces (`0.0.0.0`): requires GatewayPorts yes/clientspecified.
|
||||||
|
fn listen_remote_forward(
|
||||||
|
session: &ssh2::Session,
|
||||||
|
remote_port: u16,
|
||||||
|
prefer_bind: &str,
|
||||||
|
) -> Result<(ssh2::Listener, u16, String), ssh2::Error> {
|
||||||
|
let prefer = prefer_bind.trim();
|
||||||
|
let candidates: Vec<&str> = if is_all_interfaces_bind(prefer) {
|
||||||
|
// OpenSSH clients send 0.0.0.0 (or *) when GatewayPorts allows it.
|
||||||
|
vec!["0.0.0.0"]
|
||||||
|
} else if prefer.is_empty() || prefer == "127.0.0.1" || prefer.eq_ignore_ascii_case("localhost")
|
||||||
|
{
|
||||||
|
// Order matters: PermitListen often lists "localhost:port"; empty string is
|
||||||
|
// what `ssh -R port:host:port` sends when no bind address is given.
|
||||||
|
vec!["localhost", "127.0.0.1", ""]
|
||||||
|
} else {
|
||||||
|
vec![prefer]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut denied: Option<ssh2::Error> = None;
|
||||||
|
for host in candidates {
|
||||||
|
match session.channel_forward_listen(remote_port, Some(host), Some(1)) {
|
||||||
|
Ok((listener, bound)) => {
|
||||||
|
let reported = if host.is_empty() {
|
||||||
|
"localhost".to_string()
|
||||||
|
} else {
|
||||||
|
host.to_string()
|
||||||
|
};
|
||||||
|
return Ok((listener, bound, reported));
|
||||||
|
}
|
||||||
|
Err(error) if error.code() == ErrorCode::Session(-32) => {
|
||||||
|
denied = Some(error);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(denied.unwrap_or_else(|| ssh2::Error::from_errno(ErrorCode::Session(-32))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// libssh2 Session(-32) == LIBSSH2_ERROR_REQUEST_DENIED: sshd refused tcpip-forward.
|
||||||
|
fn map_remote_forward_error(
|
||||||
|
error: ssh2::Error,
|
||||||
|
remote_port: u16,
|
||||||
|
want_all_interfaces: bool,
|
||||||
|
) -> String {
|
||||||
|
match error.code() {
|
||||||
|
ErrorCode::Session(-32) if want_all_interfaces => format!(
|
||||||
|
"建立远程端口转发失败: 远端拒绝 0.0.0.0:{remote_port}(通常需 GatewayPorts yes/clientspecified,且端口空闲)。仅本机访问可改用 127.0.0.1:{remote_port}。"
|
||||||
|
),
|
||||||
|
ErrorCode::Session(-32) if (1..1024).contains(&remote_port) => format!(
|
||||||
|
"建立远程端口转发失败: 远端拒绝特权端口 {remote_port}(需 root 或改用 ≥1024;检查 AllowTcpForwarding / PermitListen)。"
|
||||||
|
),
|
||||||
|
ErrorCode::Session(-32) => format!(
|
||||||
|
"建立远程端口转发失败: 远端拒绝端口 {remote_port}(AllowTcpForwarding / PermitListen / 端口占用)。可用 ssh -R {remote_port}:127.0.0.1:<本地端口> 核对。"
|
||||||
|
),
|
||||||
|
_ => format!("建立远程端口转发失败: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
fn unreachable_config() -> SshConnectRequest {
|
fn unreachable_config() -> SshConnectRequest {
|
||||||
SshConnectRequest {
|
SshConnectRequest {
|
||||||
@@ -496,4 +771,77 @@ mod tests {
|
|||||||
let _ = first;
|
let _ = first;
|
||||||
manager.stop_connection("connection-new").unwrap();
|
manager.stop_connection("connection-new").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopt_orphans_rebinds_to_new_connection() {
|
||||||
|
let manager = TunnelManager::default();
|
||||||
|
let config = unreachable_config();
|
||||||
|
manager
|
||||||
|
.start_dynamic(Some("rule-1".to_string()), "old-conn", config.clone(), 0)
|
||||||
|
.unwrap();
|
||||||
|
let fingerprint = connection_fingerprint(&config);
|
||||||
|
let live = HashSet::from(["new-conn".to_string()]);
|
||||||
|
let adopted = manager
|
||||||
|
.adopt_orphans("new-conn", &fingerprint, &live)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(adopted.len(), 1);
|
||||||
|
assert_eq!(adopted[0].connection_id, "new-conn");
|
||||||
|
assert!(manager.list(Some("old-conn")).unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
manager.list(Some("new-conn")).unwrap()[0].forward_id,
|
||||||
|
"rule-1"
|
||||||
|
);
|
||||||
|
manager.stop_connection("new-conn").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopt_skips_runtimes_still_owned_by_live_connection() {
|
||||||
|
let manager = TunnelManager::default();
|
||||||
|
let config = unreachable_config();
|
||||||
|
manager
|
||||||
|
.start_dynamic(Some("rule-live".to_string()), "old-conn", config.clone(), 0)
|
||||||
|
.unwrap();
|
||||||
|
let fingerprint = connection_fingerprint(&config);
|
||||||
|
let live = HashSet::from(["old-conn".to_string(), "new-conn".to_string()]);
|
||||||
|
let adopted = manager
|
||||||
|
.adopt_orphans("new-conn", &fingerprint, &live)
|
||||||
|
.unwrap();
|
||||||
|
assert!(adopted.is_empty());
|
||||||
|
assert_eq!(manager.list(Some("old-conn")).unwrap().len(), 1);
|
||||||
|
assert!(manager.list(Some("new-conn")).unwrap().is_empty());
|
||||||
|
manager.stop_connection("old-conn").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_runtime_is_reclaimed_on_start() {
|
||||||
|
let manager = TunnelManager::default();
|
||||||
|
manager
|
||||||
|
.start_dynamic(
|
||||||
|
Some("same-id".to_string()),
|
||||||
|
"connection-test",
|
||||||
|
unreachable_config(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let runtimes = manager.runtimes.lock().unwrap();
|
||||||
|
runtimes
|
||||||
|
.get("same-id")
|
||||||
|
.unwrap()
|
||||||
|
.running
|
||||||
|
.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
let restarted = manager
|
||||||
|
.start_dynamic(
|
||||||
|
Some("same-id".to_string()),
|
||||||
|
"connection-test",
|
||||||
|
unreachable_config(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restarted.forward_id, "same-id");
|
||||||
|
assert!(restarted.running);
|
||||||
|
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
|
||||||
|
manager.stop_connection("connection-test").unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,9 @@ pub(super) struct TunnelCounters {
|
|||||||
|
|
||||||
pub(super) struct TunnelRuntime {
|
pub(super) struct TunnelRuntime {
|
||||||
pub(super) descriptor: ForwardStatus,
|
pub(super) descriptor: ForwardStatus,
|
||||||
|
/// SSH identity this runtime was started from (`user@host:port`).
|
||||||
|
/// Used to rebind listeners onto a new connection id after reconnect.
|
||||||
|
pub(super) fingerprint: String,
|
||||||
pub(super) cancel: Arc<AtomicBool>,
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
pub(super) running: Arc<AtomicBool>,
|
pub(super) running: Arc<AtomicBool>,
|
||||||
pub(super) counters: Arc<TunnelCounters>,
|
pub(super) counters: Arc<TunnelCounters>,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Mutex,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ssh2::ErrorCode;
|
||||||
|
|
||||||
|
use crate::domain::ssh::SshConnectRequest;
|
||||||
|
|
||||||
|
use super::super::ssh::establish_session;
|
||||||
|
|
||||||
|
/// One long-lived SSH session per forwarding rule.
|
||||||
|
/// Local / dynamic listeners stay bound; this session is only the transport
|
||||||
|
/// used to open `direct-tcpip` channels. If it dies we reconnect it —
|
||||||
|
/// we do not tear down the local TCP listener.
|
||||||
|
pub(super) struct TunnelSession {
|
||||||
|
config: SshConnectRequest,
|
||||||
|
session: Mutex<Option<ssh2::Session>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TunnelSession {
|
||||||
|
pub(super) fn new(config: SshConnectRequest) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
session: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warm the session so the first client does not pay a full handshake.
|
||||||
|
/// Failure is non-fatal: the local listener stays up and we retry later.
|
||||||
|
pub(super) fn warm(&self) {
|
||||||
|
if let Err(error) = self.ensure_session() {
|
||||||
|
tracing::warn!("端口转发预热 SSH 失败: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_direct(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<(ssh2::Session, ssh2::Channel), String> {
|
||||||
|
const ATTEMPTS: u32 = 2;
|
||||||
|
let mut last_error = "端口转发 SSH 不可用".to_string();
|
||||||
|
for _ in 1..=ATTEMPTS {
|
||||||
|
if cancel.load(Ordering::Acquire) {
|
||||||
|
return Err("端口转发已停止".to_string());
|
||||||
|
}
|
||||||
|
let session = match self.ensure_session() {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(error) => {
|
||||||
|
last_error = error;
|
||||||
|
self.invalidate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match open_channel_nonblock(&session, host, port, cancel) {
|
||||||
|
Ok(channel) => return Ok((session, channel)),
|
||||||
|
Err(error) => {
|
||||||
|
last_error = error;
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a protocol keepalive. If the session is gone, try to bring it back
|
||||||
|
/// so an idle SOCKS / local forward does not wait until the next client.
|
||||||
|
pub(super) fn keepalive(&self) {
|
||||||
|
let current = self.session.lock().ok().and_then(|guard| guard.clone());
|
||||||
|
match current {
|
||||||
|
Some(session) => {
|
||||||
|
if session.keepalive_send().is_err() {
|
||||||
|
tracing::warn!("端口转发 SSH 保活失败,将重建会话");
|
||||||
|
self.invalidate();
|
||||||
|
self.warm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.warm(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn close(&self) {
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_session(&self) -> Result<ssh2::Session, String> {
|
||||||
|
let mut guard = self
|
||||||
|
.session
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "端口转发会话锁异常".to_string())?;
|
||||||
|
if let Some(session) = guard.as_ref() {
|
||||||
|
return Ok(session.clone());
|
||||||
|
}
|
||||||
|
let session = establish_session(&self.config)?;
|
||||||
|
*guard = Some(session.clone());
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&self) {
|
||||||
|
let Ok(mut guard) = self.session.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(session) = guard.take() {
|
||||||
|
let _ = session.disconnect(None, "tunnel session reset", None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_channel_nonblock(
|
||||||
|
session: &ssh2::Session,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<ssh2::Channel, String> {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||||
|
loop {
|
||||||
|
if cancel.load(Ordering::Acquire) {
|
||||||
|
return Err("端口转发已停止".to_string());
|
||||||
|
}
|
||||||
|
match session.channel_direct_tcpip(host, port, None) {
|
||||||
|
Ok(channel) => return Ok(channel),
|
||||||
|
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("打开转发通道超时 {host}:{port}"));
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(15));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(format!("打开转发通道失败 {host}:{port}: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"decorations": true,
|
"decorations": true,
|
||||||
"transparent": false
|
"transparent": false,
|
||||||
|
"dragDropEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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; 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 http://l123.91ai.io https://l123.91ai.io; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'",
|
||||||
"dangerousDisableAssetCspModification": [
|
"dangerousDisableAssetCspModification": [
|
||||||
"style-src"
|
"style-src"
|
||||||
]
|
]
|
||||||
@@ -44,8 +45,8 @@
|
|||||||
"signingIdentity": "-"
|
"signingIdentity": "-"
|
||||||
},
|
},
|
||||||
"category": "DeveloperTool",
|
"category": "DeveloperTool",
|
||||||
"shortDescription": "Professional SSH and SFTP workspace",
|
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
|
||||||
"longDescription": "A native, secure, cross-platform SSH, SFTP, terminal and port-forwarding workspace."
|
"longDescription": "OpsXShell is a lightweight native ops terminal: short paths for SSH, SFTP, port forwarding, RDP/VNC, and serial — practical and human-friendly, without bastion or audit bloat."
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
|
|||||||
+14
-14
@@ -16,31 +16,31 @@ export const AI_PROVIDER_FAMILY_META: Record<
|
|||||||
{ label: string; description: string }
|
{ label: string; description: string }
|
||||||
> = {
|
> = {
|
||||||
openai_compatible: {
|
openai_compatible: {
|
||||||
label: 'OpenAI兼容',
|
label: 'OpenAI compatible',
|
||||||
description: '按量',
|
description: 'payg',
|
||||||
},
|
},
|
||||||
standalone: {
|
standalone: {
|
||||||
label: '────────',
|
label: 'Native APIs',
|
||||||
description: '',
|
description: '',
|
||||||
},
|
},
|
||||||
plan: {
|
plan: {
|
||||||
label: 'Plan计划',
|
label: 'Subscription plans',
|
||||||
description: '套餐',
|
description: 'plan',
|
||||||
},
|
},
|
||||||
local: {
|
local: {
|
||||||
label: '私有化本地',
|
label: 'Self-hosted',
|
||||||
description: '本地',
|
description: 'local',
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
label: 'Custom自定义',
|
label: 'Custom',
|
||||||
description: '自定义',
|
description: 'custom',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
|
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
|
||||||
payg: '按量',
|
payg: 'payg',
|
||||||
plan: 'Plan',
|
plan: 'plan',
|
||||||
local: '本地',
|
local: 'local',
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = (
|
const model = (
|
||||||
@@ -292,8 +292,8 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
|||||||
// ——— Custom ———
|
// ——— Custom ———
|
||||||
{
|
{
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
name: 'Custom自定义',
|
name: 'Custom',
|
||||||
description: '自定义',
|
description: 'Custom endpoint',
|
||||||
family: 'custom',
|
family: 'custom',
|
||||||
presetId: 'custom',
|
presetId: 'custom',
|
||||||
protocol: 'openai-chat',
|
protocol: 'openai-chat',
|
||||||
|
|||||||
+53
-23
@@ -33,7 +33,7 @@ import {
|
|||||||
isEncryptedSessionBackup,
|
isEncryptedSessionBackup,
|
||||||
} from '../utils/sessionBackupCrypto'
|
} from '../utils/sessionBackupCrypto'
|
||||||
import { usePromptDialog } from '../components/common/PromptDialog'
|
import { usePromptDialog } from '../components/common/PromptDialog'
|
||||||
import { licenseServerBaseUrl } from '../stores/entitlementStore'
|
import { claimPurchase } from '../platform/licenseApi'
|
||||||
|
|
||||||
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
|
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
|
||||||
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
|
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
|
||||||
@@ -87,6 +87,9 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsLoaded) return
|
if (!settingsLoaded) return
|
||||||
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
||||||
|
void window.opsxApi?.applyNativeMenuLocale?.(settings.language).catch(() => {
|
||||||
|
// Native menu sync is best-effort (browser / tests have no menu bar).
|
||||||
|
})
|
||||||
}, [settingsLoaded, settings.language])
|
}, [settingsLoaded, settings.language])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,6 +100,7 @@ function App() {
|
|||||||
idleTimeoutMs: settings.idleTimeoutMs,
|
idleTimeoutMs: settings.idleTimeoutMs,
|
||||||
suspendTimeoutMs: settings.suspendTimeoutMs,
|
suspendTimeoutMs: settings.suspendTimeoutMs,
|
||||||
maxReconnectAttempts: settings.maxReconnectAttempts,
|
maxReconnectAttempts: settings.maxReconnectAttempts,
|
||||||
|
keepaliveIntervalMs: Math.max(5, settings.keepAliveIntervalSec || 15) * 1000,
|
||||||
})
|
})
|
||||||
}, [settings, settingsLoaded])
|
}, [settings, settingsLoaded])
|
||||||
|
|
||||||
@@ -470,35 +474,53 @@ function App() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
const tokenFromQuery = parsed.searchParams.get('token')
|
const q = parsed.searchParams
|
||||||
if (tokenFromQuery) {
|
const licenseKey =
|
||||||
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
|
q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
|
||||||
if (result.success) {
|
const orderNo = q.get('orderNo') || q.get('order_no') || ''
|
||||||
toast.success(t('common:pro.licenseActivated'))
|
const emailFromLink =
|
||||||
useEntitlementStore.getState().openUpgradeTab()
|
q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
|
||||||
return
|
const store = useEntitlementStore.getState()
|
||||||
}
|
|
||||||
}
|
|
||||||
const deviceId =
|
const deviceId =
|
||||||
useEntitlementStore.getState().deviceId ||
|
store.deviceId ||
|
||||||
|
q.get('deviceId') ||
|
||||||
|
q.get('device_id') ||
|
||||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||||
if (!deviceId) return
|
|
||||||
const res = await fetch(`${licenseServerBaseUrl()}/activate`, {
|
if (emailFromLink) {
|
||||||
method: 'POST',
|
await store.setEmail(emailFromLink)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ deviceId }),
|
|
||||||
})
|
const email = (emailFromLink || store.email).trim()
|
||||||
const data = (await res.json()) as { token?: string; error?: string }
|
|
||||||
if (data.token) {
|
const tryActivate = async (key: string) => {
|
||||||
const result = await useEntitlementStore.getState().activate(data.token)
|
const result = await store.activate(key, email || undefined)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(t('common:pro.licenseActivated'))
|
toast.success(t('common:pro.licenseActivated'))
|
||||||
useEntitlementStore.getState().openUpgradeTab()
|
store.openUpgradeTab()
|
||||||
return
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (licenseKey) {
|
||||||
|
if (await tryActivate(licenseKey)) return
|
||||||
|
} else if (orderNo || email) {
|
||||||
|
toast.info(t('common:pro.claimPending'))
|
||||||
|
try {
|
||||||
|
const claimed = await claimPurchase({
|
||||||
|
customerEmail: email || undefined,
|
||||||
|
orderNo: orderNo || undefined,
|
||||||
|
deviceId,
|
||||||
|
})
|
||||||
|
if (await tryActivate(claimed.licenseKey)) return
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('claim purchase pending', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.info(t('common:pro.paymentPending'))
|
toast.info(t('common:pro.paymentPending'))
|
||||||
useEntitlementStore.getState().openUpgradeTab()
|
store.openUpgradeTab()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('处理 Pro 深链接失败', error)
|
logger.warn('处理 Pro 深链接失败', error)
|
||||||
useEntitlementStore.getState().openUpgradeTab()
|
useEntitlementStore.getState().openUpgradeTab()
|
||||||
@@ -601,6 +623,14 @@ function App() {
|
|||||||
if (!requirePro('serial')) return
|
if (!requirePro('serial')) return
|
||||||
setIsSerialConnectOpen(true)
|
setIsSerialConnectOpen(true)
|
||||||
}}
|
}}
|
||||||
|
onRdpConnect={() => {
|
||||||
|
if (!requirePro('rdp')) return
|
||||||
|
setDesktopConnectProtocol('rdp')
|
||||||
|
}}
|
||||||
|
onVncConnect={() => {
|
||||||
|
if (!requirePro('vnc')) return
|
||||||
|
setDesktopConnectProtocol('vnc')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
+26
-5
@@ -5,6 +5,8 @@
|
|||||||
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
|
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
|
||||||
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
|
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
|
||||||
* - 标签栏空白区也由 Tauri 原生拖拽区处理
|
* - 标签栏空白区也由 Tauri 原生拖拽区处理
|
||||||
|
*
|
||||||
|
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -15,7 +17,9 @@ import TabBar from '../components/layout/TabBar'
|
|||||||
import TabContent from '../components/layout/TabContent'
|
import TabContent from '../components/layout/TabContent'
|
||||||
import StatusBar from '../components/layout/StatusBar'
|
import StatusBar from '../components/layout/StatusBar'
|
||||||
import type { Session } from '../stores'
|
import type { Session } from '../stores'
|
||||||
|
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
|
||||||
import { getDesktopPlatform } from '../platform/runtime'
|
import { getDesktopPlatform } from '../platform/runtime'
|
||||||
|
import { startWindowDragging } from '../platform/tauri'
|
||||||
|
|
||||||
export interface AppShellProps {
|
export interface AppShellProps {
|
||||||
activeTabId: string | null
|
activeTabId: string | null
|
||||||
@@ -42,7 +46,11 @@ function TitleBar() {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
void startWindowDragging()
|
||||||
|
}}
|
||||||
|
className="relative z-20 bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||||
>
|
>
|
||||||
<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" />
|
||||||
@@ -114,13 +122,18 @@ export default function AppShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const desktopSidebarHidden = !isMobile && sidebarCollapsed
|
const desktopSidebarHidden = !isMobile && sidebarCollapsed
|
||||||
|
const desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-dark-900 text-content-primary">
|
<div
|
||||||
<TitleBar />
|
className={`flex h-screen flex-col overflow-hidden text-content-primary ${
|
||||||
|
desktopFullscreen ? 'bg-black' : 'bg-dark-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!desktopFullscreen && <TitleBar />}
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
{isMobile && mobileSidebarOpen && (
|
{!desktopFullscreen && isMobile && mobileSidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="titlebar-no-drag fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
|
className="titlebar-no-drag fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
|
||||||
onClick={onCloseMobileSidebar}
|
onClick={onCloseMobileSidebar}
|
||||||
@@ -128,6 +141,7 @@ export default function AppShell({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!desktopFullscreen && (
|
||||||
<aside
|
<aside
|
||||||
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
||||||
isMobile
|
isMobile
|
||||||
@@ -145,8 +159,12 @@ export default function AppShell({
|
|||||||
onCollapse={isMobile ? undefined : onToggleSidebar}
|
onCollapse={isMobile ? undefined : onToggleSidebar}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col bg-dark-800">
|
<main
|
||||||
|
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
|
||||||
|
>
|
||||||
|
{!desktopFullscreen && (
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
||||||
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
|
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
|
||||||
@@ -154,6 +172,7 @@ export default function AppShell({
|
|||||||
<TabBar onOpenLauncher={onOpenLauncher} />
|
<TabBar onOpenLauncher={onOpenLauncher} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
|
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
|
||||||
<TabContent
|
<TabContent
|
||||||
@@ -167,9 +186,11 @@ export default function AppShell({
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!desktopFullscreen && (
|
||||||
<div className="titlebar-no-drag">
|
<div className="titlebar-no-drag">
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="titlebar-no-drag">{children}</div>
|
<div className="titlebar-no-drag">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface AgentMarkdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
|
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- shared helper colocated with markdown renderer
|
||||||
export function looksLikeMarkdown(text: string): boolean {
|
export function looksLikeMarkdown(text: string): boolean {
|
||||||
const t = text.trim()
|
const t = text.trim()
|
||||||
if (!t) return false
|
if (!t) return false
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function CommandPalette({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
|
}, [sessions, snippets, addTab, activeTabId, tabs, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
|
||||||
|
|
||||||
// ── 过滤结果 ──
|
// ── 过滤结果 ──
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Professional modal shell — shared by dialogs across OpsXShell
|
* Professional modal shell — shared by dialogs across OpsXShell
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ReactNode, useEffect, useId } from 'react'
|
import { type ReactNode, useEffect, useId, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@@ -62,21 +62,65 @@ export default function Modal({
|
|||||||
const resolvedConfirm = confirmText ?? t('action.confirm')
|
const resolvedConfirm = confirmText ?? t('action.confirm')
|
||||||
const resolvedCancel = cancelText ?? t('action.cancel')
|
const resolvedCancel = cancelText ?? t('action.cancel')
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
restoreFocusRef.current = document.activeElement instanceof HTMLElement
|
||||||
if (e.key === 'Escape') onClose()
|
? document.activeElement
|
||||||
|
: null
|
||||||
|
|
||||||
|
const focusables = () =>
|
||||||
|
Array.from(
|
||||||
|
panelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
) ?? []
|
||||||
|
).filter((el) => !el.hasAttribute('disabled') && el.tabIndex !== -1)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const root = panelRef.current
|
||||||
|
if (!root) return
|
||||||
|
if (root.contains(document.activeElement)) return
|
||||||
|
const target =
|
||||||
|
root.querySelector<HTMLElement>('[data-autofocus]') ??
|
||||||
|
root.querySelector<HTMLElement>('input:not([disabled]), textarea, select') ??
|
||||||
|
focusables()[0]
|
||||||
|
target?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
onCloseRef.current()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Lock body scroll while open
|
if (e.key !== 'Tab' || !panelRef.current) return
|
||||||
|
const items = focusables()
|
||||||
|
if (items.length === 0) return
|
||||||
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const prev = document.body.style.overflow
|
const prev = document.body.style.overflow
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
window.addEventListener('keydown', handleEsc)
|
window.addEventListener('keydown', handleKey, true)
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = prev
|
document.body.style.overflow = prev
|
||||||
window.removeEventListener('keydown', handleEsc)
|
window.removeEventListener('keydown', handleKey, true)
|
||||||
|
restoreFocusRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [isOpen, onClose])
|
}, [isOpen])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -93,6 +137,7 @@ export default function Modal({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
className={`
|
className={`
|
||||||
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
|
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
|
||||||
flex flex-col
|
flex flex-col
|
||||||
@@ -144,6 +189,7 @@ export default function Modal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
data-autofocus={isDanger ? true : undefined}
|
||||||
>
|
>
|
||||||
{resolvedConfirm}
|
{resolvedConfirm}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function usePromptDialog() {
|
|||||||
<p className="text-content-secondary mb-3">{state.message}</p>
|
<p className="text-content-secondary mb-3">{state.message}</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
data-autofocus
|
||||||
type={state.inputType ?? 'text'}
|
type={state.inputType ?? 'text'}
|
||||||
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
|
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
|
||||||
value={state.value}
|
value={state.value}
|
||||||
|
|||||||
@@ -18,31 +18,31 @@ const iconMap: Record<ToastType, typeof CheckCircle> = {
|
|||||||
info: Info,
|
info: Info,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 类型 → 颜色样式映射 */
|
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
|
||||||
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
||||||
success: {
|
success: {
|
||||||
bg: 'bg-green-900/40',
|
bg: 'bg-feedback-success-bg',
|
||||||
border: 'border-green-700/60',
|
border: 'border-feedback-success/35',
|
||||||
icon: 'text-green-400',
|
icon: 'text-feedback-success',
|
||||||
text: 'text-green-200',
|
text: 'text-content-primary',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
bg: 'bg-red-900/40',
|
bg: 'bg-feedback-error-bg',
|
||||||
border: 'border-red-700/60',
|
border: 'border-feedback-error/35',
|
||||||
icon: 'text-red-400',
|
icon: 'text-feedback-error',
|
||||||
text: 'text-red-200',
|
text: 'text-content-primary',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bg: 'bg-surface-basember-900/40',
|
bg: 'bg-feedback-warning-bg',
|
||||||
border: 'border-amber-700/60',
|
border: 'border-feedback-warning/35',
|
||||||
icon: 'text-amber-400',
|
icon: 'text-feedback-warning',
|
||||||
text: 'text-amber-200',
|
text: 'text-content-primary',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
bg: 'bg-blue-900/40',
|
bg: 'bg-feedback-info-bg',
|
||||||
border: 'border-blue-700/60',
|
border: 'border-feedback-info/35',
|
||||||
icon: 'text-blue-400',
|
icon: 'text-feedback-info',
|
||||||
text: 'text-blue-200',
|
text: 'text-content-primary',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ interface ToastContainerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconMap: Record<ToastType, React.ReactNode> = {
|
const iconMap: Record<ToastType, React.ReactNode> = {
|
||||||
info: <Info className="w-4 h-4 text-blue-400" />,
|
info: <Info className="w-4 h-4 text-feedback-info" />,
|
||||||
success: <CheckCircle className="w-4 h-4 text-green-400" />,
|
success: <CheckCircle className="w-4 h-4 text-feedback-success" />,
|
||||||
error: <XCircle className="w-4 h-4 text-red-400" />,
|
error: <XCircle className="w-4 h-4 text-feedback-error" />,
|
||||||
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
|
warning: <AlertTriangle className="w-4 h-4 text-feedback-warning" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgMap: Record<ToastType, string> = {
|
const bgMap: Record<ToastType, string> = {
|
||||||
info: 'bg-blue-900/80 border-blue-700',
|
info: 'bg-feedback-info-bg border-feedback-info/35',
|
||||||
success: 'bg-green-900/80 border-green-700',
|
success: 'bg-feedback-success-bg border-feedback-success/35',
|
||||||
error: 'bg-red-900/80 border-red-700',
|
error: 'bg-feedback-error-bg border-feedback-error/35',
|
||||||
warning: 'bg-yellow-900/80 border-yellow-700',
|
warning: 'bg-feedback-warning-bg border-feedback-warning/35',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||||
|
|||||||
@@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
|||||||
<span
|
<span
|
||||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
conn.state === 'connected'
|
conn.state === 'connected'
|
||||||
? 'bg-green-400/10 text-green-400'
|
? 'bg-feedback-success/10 text-feedback-success'
|
||||||
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
||||||
? 'bg-yellow-400/10 text-yellow-400'
|
? 'bg-feedback-warning/10 text-feedback-warning'
|
||||||
: conn.state === 'background'
|
: conn.state === 'background'
|
||||||
? 'bg-green-400/10 text-green-400 opacity-70'
|
? 'bg-feedback-success/10 text-feedback-success opacity-70'
|
||||||
: conn.state === 'suspended'
|
: conn.state === 'suspended'
|
||||||
? 'bg-surface-active/10 text-txt-muted'
|
? 'bg-surface-active/10 text-txt-muted'
|
||||||
: conn.state === 'error'
|
: conn.state === 'error'
|
||||||
? 'bg-red-400/10 text-red-400'
|
? 'bg-feedback-error/10 text-feedback-error'
|
||||||
: 'bg-surface-active/10 text-txt-muted'
|
: 'bg-surface-active/10 text-txt-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -200,9 +200,9 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
|
|||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
|
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
|
||||||
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-green-400' },
|
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-feedback-success' },
|
||||||
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-yellow-400' },
|
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-feedback-warning' },
|
||||||
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
|
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-feedback-error' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ import { createPortal } from 'react-dom'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
|
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
|
||||||
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
|
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
|
||||||
import type { ForwardRule, ForwardRuleType } from './forwardRules'
|
import {
|
||||||
|
DEFAULT_LOCAL_BIND_HOST,
|
||||||
|
DEFAULT_REMOTE_BIND_HOST,
|
||||||
|
formatHostPort,
|
||||||
|
isAllInterfacesBind,
|
||||||
|
parseHostPort,
|
||||||
|
resolveForwardTargetHost,
|
||||||
|
resolveLocalBindHost,
|
||||||
|
resolveRemoteBindHost,
|
||||||
|
type ForwardRule,
|
||||||
|
type ForwardRuleType,
|
||||||
|
} from './forwardRules'
|
||||||
|
|
||||||
export const DEFAULT_DYNAMIC_PORT = 1080
|
export const DEFAULT_DYNAMIC_PORT = 1080
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||||
export function resolveDynamicPort(port: unknown): number {
|
export function resolveDynamicPort(port: unknown): number {
|
||||||
const value = Number(port)
|
const value = Number(port)
|
||||||
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
|
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
|
||||||
@@ -25,6 +37,8 @@ export interface ForwardRuleDraft {
|
|||||||
type: ForwardRuleType
|
type: ForwardRuleType
|
||||||
name: string
|
name: string
|
||||||
localPort: number
|
localPort: number
|
||||||
|
localBindHost: string
|
||||||
|
remoteBindHost: string
|
||||||
remoteHost: string
|
remoteHost: string
|
||||||
remotePort: number
|
remotePort: number
|
||||||
}
|
}
|
||||||
@@ -43,6 +57,8 @@ interface ForwardRuleDrawerProps {
|
|||||||
rules?: ForwardRule[]
|
rules?: ForwardRule[]
|
||||||
runtimes?: Map<string, ForwardRuleRuntime>
|
runtimes?: Map<string, ForwardRuleRuntime>
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
/** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */
|
||||||
|
viaSession?: string
|
||||||
onChange: (next: Partial<ForwardRule>) => void
|
onChange: (next: Partial<ForwardRule>) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
@@ -55,36 +71,74 @@ const EMPTY_DRAFT: ForwardRuleDraft = {
|
|||||||
type: 'local',
|
type: 'local',
|
||||||
name: '',
|
name: '',
|
||||||
localPort: 0,
|
localPort: 0,
|
||||||
remoteHost: '',
|
localBindHost: DEFAULT_LOCAL_BIND_HOST,
|
||||||
|
remoteBindHost: DEFAULT_REMOTE_BIND_HOST,
|
||||||
|
remoteHost: '127.0.0.1',
|
||||||
remotePort: 0,
|
remotePort: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||||
export function emptyForwardDraft(): ForwardRuleDraft {
|
export function emptyForwardDraft(): ForwardRuleDraft {
|
||||||
return { ...EMPTY_DRAFT }
|
return { ...EMPTY_DRAFT }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||||
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
|
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
|
||||||
return {
|
return {
|
||||||
type: 'dynamic',
|
type: 'dynamic',
|
||||||
name,
|
name,
|
||||||
localPort: DEFAULT_DYNAMIC_PORT,
|
localPort: DEFAULT_DYNAMIC_PORT,
|
||||||
|
localBindHost: '',
|
||||||
|
remoteBindHost: '',
|
||||||
remoteHost: '',
|
remoteHost: '',
|
||||||
remotePort: 0,
|
remotePort: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleSummary(rule: ForwardRule, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
function ruleSummary(
|
||||||
|
rule: ForwardRule,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
boundPort?: number
|
||||||
|
): string {
|
||||||
if (rule.type === 'dynamic') {
|
if (rule.type === 'dynamic') {
|
||||||
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
|
return t('portForward.socksProxy', { port: resolveDynamicPort(boundPort ?? rule.localPort) })
|
||||||
}
|
}
|
||||||
if (rule.type === 'remote') {
|
if (rule.type === 'remote') {
|
||||||
return t('portForward.remoteToLocal', {
|
const bind = resolveRemoteBindHost(rule.remoteBindHost)
|
||||||
remote: rule.remotePort,
|
const remote = boundPort ?? rule.remotePort
|
||||||
|
const host = resolveForwardTargetHost(rule.remoteHost)
|
||||||
|
// Default remote bind is loopback — list as port only (matches ssh -R port:…).
|
||||||
|
if (bind === DEFAULT_REMOTE_BIND_HOST || bind === 'localhost') {
|
||||||
|
return t('portForward.remoteToLocalPort', {
|
||||||
|
remote,
|
||||||
|
host,
|
||||||
local: rule.localPort,
|
local: rule.localPort,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const host = rule.remoteHost || 'localhost'
|
return t('portForward.remoteToLocal', {
|
||||||
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
|
bind,
|
||||||
|
remote,
|
||||||
|
host,
|
||||||
|
local: rule.localPort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const bind = resolveLocalBindHost(rule.localBindHost)
|
||||||
|
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
|
||||||
|
const local = boundPort ?? rule.localPort
|
||||||
|
// Default bind is localhost — list as port only (matches ssh -L 8080:…).
|
||||||
|
if (bind === DEFAULT_LOCAL_BIND_HOST || bind === 'localhost') {
|
||||||
|
return t('portForward.localToRemotePort', {
|
||||||
|
local,
|
||||||
|
host,
|
||||||
|
remote: rule.remotePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return t('portForward.localToRemote', {
|
||||||
|
bind,
|
||||||
|
local,
|
||||||
|
host,
|
||||||
|
remote: rule.remotePort,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForwardRuleDrawer({
|
export default function ForwardRuleDrawer({
|
||||||
@@ -95,6 +149,7 @@ export default function ForwardRuleDrawer({
|
|||||||
rules,
|
rules,
|
||||||
runtimes,
|
runtimes,
|
||||||
busy = false,
|
busy = false,
|
||||||
|
viaSession,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -152,17 +207,33 @@ export default function ForwardRuleDrawer({
|
|||||||
type: 'dynamic',
|
type: 'dynamic',
|
||||||
name: nextName,
|
name: nextName,
|
||||||
localPort: DEFAULT_DYNAMIC_PORT,
|
localPort: DEFAULT_DYNAMIC_PORT,
|
||||||
|
localBindHost: '',
|
||||||
|
remoteBindHost: '',
|
||||||
remoteHost: '',
|
remoteHost: '',
|
||||||
remotePort: 0,
|
remotePort: 0,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
|
||||||
|
const nextRemotePort =
|
||||||
|
draft.type === 'dynamic'
|
||||||
|
? next === 'remote'
|
||||||
|
? 18080
|
||||||
|
: 80
|
||||||
|
: draft.remotePort || (next === 'remote' ? 18080 : 80)
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...draft,
|
...draft,
|
||||||
type: next,
|
type: next,
|
||||||
name: nextName,
|
name: nextName,
|
||||||
localPort: draft.type === 'dynamic' ? 0 : draft.localPort,
|
localPort: nextLocalPort,
|
||||||
|
localBindHost:
|
||||||
|
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
|
||||||
|
remoteBindHost:
|
||||||
|
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
|
||||||
|
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
|
||||||
|
remotePort: nextRemotePort,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +363,8 @@ export default function ForwardRuleDrawer({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{localRemoteRules.map((rule) => {
|
{localRemoteRules.map((rule) => {
|
||||||
const running = Boolean(runtimes?.get(rule.id)?.running)
|
const runtime = runtimes?.get(rule.id)
|
||||||
|
const running = Boolean(runtime?.running)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
@@ -339,7 +411,7 @@ export default function ForwardRuleDrawer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
|
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
|
||||||
{ruleSummary(rule, t)}
|
{ruleSummary(rule, t, runtime?.boundPort)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-0.5">
|
<div className="flex shrink-0 gap-0.5">
|
||||||
@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{type === 'local' ? (
|
||||||
|
<>
|
||||||
|
{viaSession ? (
|
||||||
|
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||||
|
{t('portForward.viaSession', { target: viaSession })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label">
|
<label className="ui-label">{t('portForward.localListenPort')}</label>
|
||||||
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
value={draft.localPort || ''}
|
value={draft.localPort || ''}
|
||||||
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
|
onChange={(e) =>
|
||||||
placeholder={t('portForward.portPlaceholder')}
|
onChange({
|
||||||
|
...draft,
|
||||||
|
localPort: Number(e.target.value),
|
||||||
|
localBindHost: resolveLocalBindHost(draft.localBindHost),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="8080"
|
||||||
className="ui-input font-mono"
|
className="ui-input font-mono"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{t('portForward.localListenPortHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label">
|
<label className="ui-label">{t('portForward.targetAddress')}</label>
|
||||||
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
|
<input
|
||||||
</label>
|
type="text"
|
||||||
|
value={formatHostPort(
|
||||||
|
resolveForwardTargetHost(draft.remoteHost),
|
||||||
|
draft.remotePort
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim()
|
||||||
|
const parsed = parseHostPort(raw)
|
||||||
|
if (parsed) {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(parsed.host),
|
||||||
|
remotePort: parsed.port,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const colon = raw.lastIndexOf(':')
|
||||||
|
if (colon > 0) {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
|
||||||
|
remotePort: Number(raw.slice(colon + 1)) || 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('portForward.targetAddressPlaceholder')}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{viaSession
|
||||||
|
? t('portForward.targetAddressHintVia', { target: viaSession })
|
||||||
|
: t('portForward.targetAddressHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
|
||||||
|
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||||
|
{t('portForward.localBindAdvanced')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="ui-label">{t('portForward.localBindHost')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resolveLocalBindHost(draft.localBindHost)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
localBindHost: resolveLocalBindHost(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t('portForward.localBindHostPlaceholder')}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{t('portForward.localBindHostHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{viaSession ? (
|
||||||
|
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||||
|
{t('portForward.viaSessionRemote', { target: viaSession })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<label className="ui-label">{t('portForward.remoteListenPort')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
value={draft.remotePort || ''}
|
value={draft.remotePort || ''}
|
||||||
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
|
onChange={(e) =>
|
||||||
placeholder={t('portForward.portPlaceholder')}
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remotePort: Number(e.target.value),
|
||||||
|
remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="18080"
|
||||||
className="ui-input font-mono"
|
className="ui-input font-mono"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{t('portForward.remoteListenPortHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{type === 'local' && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label">{t('portForward.remoteHost')}</label>
|
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draft.remoteHost || ''}
|
value={formatHostPort(
|
||||||
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
|
resolveForwardTargetHost(draft.remoteHost),
|
||||||
placeholder={t('portForward.remoteHostPlaceholder')}
|
draft.localPort
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim()
|
||||||
|
const parsed = parseHostPort(raw)
|
||||||
|
if (parsed) {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(parsed.host),
|
||||||
|
localPort: parsed.port,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const colon = raw.lastIndexOf(':')
|
||||||
|
if (colon > 0) {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
|
||||||
|
localPort: Number(raw.slice(colon + 1)) || 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('portForward.localTargetAddressPlaceholder')}
|
||||||
className="ui-input font-mono"
|
className="ui-input font-mono"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{t('portForward.localTargetAddressHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<details
|
||||||
|
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"
|
||||||
|
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||||
|
{t('portForward.remoteBindAdvanced')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="ui-label">{t('portForward.remoteBindHost')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resolveRemoteBindHost(draft.remoteBindHost)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
remoteBindHost: resolveRemoteBindHost(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t('portForward.remoteBindHostPlaceholder')}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{t('portForward.remoteBindHostHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useConnectionPool } from '../../stores/connection'
|
import { useConnectionPool } from '../../stores/connection'
|
||||||
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||||
|
import { translateErrorMessage } from '../../i18n/format'
|
||||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||||
import ForwardRuleDrawer, {
|
import ForwardRuleDrawer, {
|
||||||
DEFAULT_DYNAMIC_PORT,
|
DEFAULT_DYNAMIC_PORT,
|
||||||
@@ -21,9 +22,19 @@ import {
|
|||||||
duplicateForwardRule,
|
duplicateForwardRule,
|
||||||
parseForwardRules,
|
parseForwardRules,
|
||||||
portForwardStorageKey,
|
portForwardStorageKey,
|
||||||
|
resolveForwardTargetHost,
|
||||||
|
resolveLocalBindHost,
|
||||||
|
resolveRemoteBindHost,
|
||||||
runtimeMap,
|
runtimeMap,
|
||||||
type ForwardRule,
|
type ForwardRule,
|
||||||
} from './forwardRules'
|
} from './forwardRules'
|
||||||
|
import {
|
||||||
|
forgetForwardIntent,
|
||||||
|
forwardIntentKey,
|
||||||
|
rememberForwardIntent,
|
||||||
|
restoreForwardIntent,
|
||||||
|
tunnelCoversRule,
|
||||||
|
} from './forwardIntent'
|
||||||
|
|
||||||
interface PortForwardViewProps {
|
interface PortForwardViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
@@ -41,6 +52,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
const connectionId = currentTab?.connectionInstance?.connectionId
|
const connectionId = currentTab?.connectionInstance?.connectionId
|
||||||
const connection = connectionId ? pool.getConnection(connectionId) : null
|
const connection = connectionId ? pool.getConnection(connectionId) : null
|
||||||
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
|
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
|
||||||
|
const intentKey = forwardIntentKey(currentTab?.sessionId, tabId)
|
||||||
|
|
||||||
const [rules, setRules] = useState<ForwardRule[]>([])
|
const [rules, setRules] = useState<ForwardRule[]>([])
|
||||||
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
|
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
|
||||||
@@ -115,6 +127,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
...emptyForwardDraft(),
|
...emptyForwardDraft(),
|
||||||
type: 'local',
|
type: 'local',
|
||||||
name: t('common:portForward.defaultLocalName'),
|
name: t('common:portForward.defaultLocalName'),
|
||||||
|
localBindHost: '127.0.0.1',
|
||||||
|
remoteHost: '127.0.0.1',
|
||||||
})
|
})
|
||||||
setShowAddModal(true)
|
setShowAddModal(true)
|
||||||
}
|
}
|
||||||
@@ -127,11 +141,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
|
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
|
||||||
setDrawerError(t('common:portForward.errorTargetPort'))
|
setDrawerError(
|
||||||
|
newRule.type === 'local'
|
||||||
|
? t('common:portForward.errorTargetAddress')
|
||||||
|
: t('common:portForward.errorRemoteListenAddress')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
|
if (newRule.type !== 'dynamic' && !newRule.remoteHost?.trim()) {
|
||||||
setDrawerError(t('common:portForward.errorRemoteHost'))
|
setDrawerError(
|
||||||
|
newRule.type === 'local'
|
||||||
|
? t('common:portForward.errorTargetAddress')
|
||||||
|
: t('common:portForward.errorLocalTargetAddress')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -148,7 +170,12 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
||||||
name: newRule.name.trim(),
|
name: newRule.name.trim(),
|
||||||
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
|
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
|
||||||
remoteHost: newRule.remoteHost?.trim(),
|
localBindHost:
|
||||||
|
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
|
||||||
|
remoteBindHost:
|
||||||
|
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
|
||||||
|
remoteHost:
|
||||||
|
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
|
||||||
remotePort: newRule.remotePort,
|
remotePort: newRule.remotePort,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,17 +199,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
result = await window.opsxApi?.sshForwardLocal(
|
result = await window.opsxApi?.sshForwardLocal(
|
||||||
connectionId,
|
connectionId,
|
||||||
rule.localPort,
|
rule.localPort,
|
||||||
rule.remoteHost || '',
|
resolveForwardTargetHost(rule.remoteHost),
|
||||||
rule.remotePort || 0,
|
rule.remotePort || 0,
|
||||||
rule.id
|
rule.id,
|
||||||
|
resolveLocalBindHost(rule.localBindHost)
|
||||||
)
|
)
|
||||||
} else if (rule.type === 'remote') {
|
} else if (rule.type === 'remote') {
|
||||||
result = await window.opsxApi?.sshForwardRemote(
|
result = await window.opsxApi?.sshForwardRemote(
|
||||||
connectionId,
|
connectionId,
|
||||||
rule.remotePort || 0,
|
rule.remotePort || 0,
|
||||||
'127.0.0.1',
|
resolveForwardTargetHost(rule.remoteHost),
|
||||||
rule.localPort,
|
rule.localPort,
|
||||||
rule.id
|
rule.id,
|
||||||
|
resolveRemoteBindHost(rule.remoteBindHost)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
result = await window.opsxApi?.sshForwardDynamic(
|
result = await window.opsxApi?.sshForwardDynamic(
|
||||||
@@ -193,18 +222,43 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
|
rememberForwardIntent(intentKey, rule)
|
||||||
await refreshTunnels()
|
await refreshTunnels()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
setError(result?.error || t('common:portForward.errorStartFailed'))
|
setError(
|
||||||
|
result?.error
|
||||||
|
? translateErrorMessage(result.error)
|
||||||
|
: t('common:portForward.errorStartFailed')
|
||||||
|
)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStartFailed'))
|
const raw = err instanceof Error ? err.message : String(err)
|
||||||
|
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startForwardRef = useRef(startForward)
|
||||||
|
startForwardRef.current = startForward
|
||||||
|
const previousConnectionRef = useRef<string | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectionId) return
|
||||||
|
const previous = previousConnectionRef.current
|
||||||
|
previousConnectionRef.current = connectionId
|
||||||
|
if (!previous || previous === connectionId) return
|
||||||
|
void (async () => {
|
||||||
|
const listed = await window.opsxApi?.sshForwardList(connectionId)
|
||||||
|
const tunnels = listed?.success ? listed.tunnels || [] : []
|
||||||
|
await restoreForwardIntent(
|
||||||
|
intentKey,
|
||||||
|
(rule) => tunnelCoversRule(tunnels, rule),
|
||||||
|
(rule) => startForwardRef.current(rule)
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
}, [connectionId, intentKey])
|
||||||
|
|
||||||
const quickStartSocks = async () => {
|
const quickStartSocks = async () => {
|
||||||
const existing = rules.find((rule) => rule.type === 'dynamic')
|
const existing = rules.find((rule) => rule.type === 'dynamic')
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -230,11 +284,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await window.opsxApi?.sshForwardStop(rule.id)
|
let result = await window.opsxApi?.sshForwardStop(rule.id)
|
||||||
|
if (!result?.success && connectionId) {
|
||||||
|
const listed = await window.opsxApi?.sshForwardList(connectionId)
|
||||||
|
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||||
|
if (match) {
|
||||||
|
result = await window.opsxApi?.sshForwardStop(match.forwardId)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
setError(result?.error || t('common:portForward.errorStopFailed'))
|
setError(result?.error || t('common:portForward.errorStopFailed'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
forgetForwardIntent(intentKey, rule.id)
|
||||||
await refreshTunnels()
|
await refreshTunnels()
|
||||||
return true
|
return true
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -416,14 +478,44 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
|||||||
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
|
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
|
||||||
) : rule.type === 'local' ? (
|
) : rule.type === 'local' ? (
|
||||||
<span>
|
<span>
|
||||||
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
|
{(() => {
|
||||||
|
const bind = resolveLocalBindHost(rule.localBindHost)
|
||||||
|
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
|
||||||
|
if (bind === '127.0.0.1' || bind === 'localhost') {
|
||||||
|
return t('common:portForward.localToRemotePort', {
|
||||||
|
local: boundPort,
|
||||||
|
host,
|
||||||
|
remote: rule.remotePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return t('common:portForward.localToRemote', {
|
||||||
|
bind,
|
||||||
|
local: boundPort,
|
||||||
|
host,
|
||||||
|
remote: rule.remotePort,
|
||||||
|
})
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{t('common:portForward.remoteToLocal', {
|
{(() => {
|
||||||
remote: runtime?.boundPort || rule.remotePort,
|
const bind = resolveRemoteBindHost(rule.remoteBindHost)
|
||||||
|
const remote = runtime?.boundPort || rule.remotePort
|
||||||
|
const host = resolveForwardTargetHost(rule.remoteHost)
|
||||||
|
if (bind === '127.0.0.1' || bind === 'localhost') {
|
||||||
|
return t('common:portForward.remoteToLocalPort', {
|
||||||
|
remote,
|
||||||
|
host,
|
||||||
local: rule.localPort,
|
local: rule.localPort,
|
||||||
})}
|
})
|
||||||
|
}
|
||||||
|
return t('common:portForward.remoteToLocal', {
|
||||||
|
bind,
|
||||||
|
remote,
|
||||||
|
host,
|
||||||
|
local: rule.localPort,
|
||||||
|
})
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||||
|
import type { ForwardRule } from './forwardRules'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-local "this rule should stay up" intent.
|
||||||
|
* Never persisted — app restart must not resurrect listeners.
|
||||||
|
* Survives SSH reconnect so the same session can restore forwards.
|
||||||
|
*/
|
||||||
|
const remembered = new Map<string, Map<string, ForwardRule>>()
|
||||||
|
const resumeLocks = new Map<string, Promise<number>>()
|
||||||
|
|
||||||
|
export function forwardIntentKey(sessionId: string | undefined, tabId: string): string {
|
||||||
|
return sessionId ? `session:${sessionId}` : `tab:${tabId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberForwardIntent(key: string, rule: ForwardRule): void {
|
||||||
|
let rules = remembered.get(key)
|
||||||
|
if (!rules) {
|
||||||
|
rules = new Map()
|
||||||
|
remembered.set(key, rules)
|
||||||
|
}
|
||||||
|
rules.set(rule.id, { ...rule })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetForwardIntent(key: string, ruleId: string): void {
|
||||||
|
remembered.get(key)?.delete(ruleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listForwardIntent(key: string): ForwardRule[] {
|
||||||
|
return [...(remembered.get(key)?.values() ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tunnelCoversRule(tunnels: SSHForwardStatus[], rule: ForwardRule): boolean {
|
||||||
|
return tunnels.some((item) => {
|
||||||
|
if (!item.running || item.kind !== rule.type) return false
|
||||||
|
if (item.forwardId === rule.id) return true
|
||||||
|
// One dynamic SOCKS per SSH host.
|
||||||
|
if (rule.type === 'dynamic') return true
|
||||||
|
// boundPort is the listen port: local → localPort, remote → remotePort.
|
||||||
|
if (rule.type === 'remote') {
|
||||||
|
return item.boundPort === rule.remotePort
|
||||||
|
}
|
||||||
|
return item.boundPort === rule.localPort
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all remembered start intents for a tab/session (tab close). */
|
||||||
|
export function clearForwardIntent(key: string): void {
|
||||||
|
remembered.delete(key)
|
||||||
|
resumeLocks.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start remembered rules that are not running. Concurrent calls share one pass. */
|
||||||
|
export function restoreForwardIntent(
|
||||||
|
key: string,
|
||||||
|
isRunning: (rule: ForwardRule) => boolean,
|
||||||
|
start: (rule: ForwardRule) => Promise<boolean>
|
||||||
|
): Promise<number> {
|
||||||
|
const existing = resumeLocks.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const work = (async () => {
|
||||||
|
let restored = 0
|
||||||
|
for (const rule of listForwardIntent(key)) {
|
||||||
|
if (isRunning(rule)) continue
|
||||||
|
if (await start(rule)) restored += 1
|
||||||
|
}
|
||||||
|
return restored
|
||||||
|
})().finally(() => {
|
||||||
|
resumeLocks.delete(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
resumeLocks.set(key, work)
|
||||||
|
return work
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetForwardIntentForTests(): void {
|
||||||
|
remembered.clear()
|
||||||
|
resumeLocks.clear()
|
||||||
|
}
|
||||||
@@ -3,15 +3,96 @@ import i18n from '../../i18n'
|
|||||||
|
|
||||||
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
|
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
|
||||||
|
|
||||||
|
/** Default local listen bind for `-L` (this machine only). */
|
||||||
|
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
|
||||||
|
|
||||||
|
/** Default remote listen bind for `-R` (only the SSH server itself). */
|
||||||
|
export const DEFAULT_REMOTE_BIND_HOST = '127.0.0.1'
|
||||||
|
|
||||||
export interface ForwardRule {
|
export interface ForwardRule {
|
||||||
id: string
|
id: string
|
||||||
type: ForwardRuleType
|
type: ForwardRuleType
|
||||||
name: string
|
name: string
|
||||||
localPort: number
|
localPort: number
|
||||||
|
/** Local listen address for local forwards (127.0.0.1 / LAN IP / 0.0.0.0). */
|
||||||
|
localBindHost?: string
|
||||||
|
/** Remote listen address for remote forwards (127.0.0.1 or 0.0.0.0; needs GatewayPorts for all-interfaces). */
|
||||||
|
remoteBindHost?: string
|
||||||
|
/** Destination host in OpenSSH `-L` / `-R` sense (not a listen bind).
|
||||||
|
* - local: host as seen from the SSH server
|
||||||
|
* - remote: host as seen from this machine (service to expose; localhost or LAN IP)
|
||||||
|
*/
|
||||||
remoteHost?: string
|
remoteHost?: string
|
||||||
remotePort?: number
|
remotePort?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveLocalBindHost(host: unknown): string {
|
||||||
|
const value = typeof host === 'string' ? host.trim() : ''
|
||||||
|
return value || DEFAULT_LOCAL_BIND_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize remote `-R` listen bind. Default loopback; `0.0.0.0` opens all remote interfaces. */
|
||||||
|
export function resolveRemoteBindHost(host: unknown): string {
|
||||||
|
const value = typeof host === 'string' ? host.trim() : ''
|
||||||
|
if (!value || value.toLowerCase() === 'localhost') return DEFAULT_REMOTE_BIND_HOST
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllInterfacesBind(host: string): boolean {
|
||||||
|
const value = host.trim().toLowerCase()
|
||||||
|
return value === '0.0.0.0' || value === '*' || value === '::' || value === '[::]'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format `host:port` for listen/target address fields. */
|
||||||
|
export function formatHostPort(host: string, port: number | undefined): string {
|
||||||
|
const h = host.trim() || DEFAULT_LOCAL_BIND_HOST
|
||||||
|
if (!port || port < 1) return `${h}:`
|
||||||
|
return `${h}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `host:port` or `[ipv6]:port`. Returns null if invalid. */
|
||||||
|
export function parseHostPort(value: string): { host: string; port: number } | null {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
let host: string
|
||||||
|
let portText: string
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
const end = trimmed.indexOf(']')
|
||||||
|
if (end < 1 || trimmed[end + 1] !== ':') return null
|
||||||
|
host = trimmed.slice(1, end)
|
||||||
|
portText = trimmed.slice(end + 2)
|
||||||
|
} else {
|
||||||
|
const lastColon = trimmed.lastIndexOf(':')
|
||||||
|
if (lastColon <= 0) return null
|
||||||
|
host = trimmed.slice(0, lastColon)
|
||||||
|
portText = trimmed.slice(lastColon + 1)
|
||||||
|
}
|
||||||
|
const port = Number(portText)
|
||||||
|
if (!host.trim() || !Number.isInteger(port) || port < 1 || port > 65535) return null
|
||||||
|
return { host: host.trim(), port }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target host is where the SSH server connects (not a listen bind).
|
||||||
|
* `0.0.0.0` / `::` are not valid connect destinations — treat as remote loopback.
|
||||||
|
*/
|
||||||
|
export function resolveForwardTargetHost(host: unknown): string {
|
||||||
|
const value = typeof host === 'string' ? host.trim() : ''
|
||||||
|
if (!value || isListenOnlyAddress(value)) return '127.0.0.1'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for addresses that only make sense as listen binds, not TCP connect targets. */
|
||||||
|
export function isListenOnlyAddress(host: string): boolean {
|
||||||
|
const value = host.trim().toLowerCase()
|
||||||
|
return value === '0.0.0.0' || value === '::' || value === '[::]'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use resolveForwardTargetHost; kept for call-site clarity. */
|
||||||
|
export function isInvalidForwardTargetHost(host: string): boolean {
|
||||||
|
return isListenOnlyAddress(host)
|
||||||
|
}
|
||||||
|
|
||||||
export function portForwardStorageKey(
|
export function portForwardStorageKey(
|
||||||
sessionId: string | undefined,
|
sessionId: string | undefined,
|
||||||
tabId: string,
|
tabId: string,
|
||||||
@@ -54,8 +135,14 @@ export function parseForwardRules(value: unknown): ForwardRule[] {
|
|||||||
type: type as ForwardRuleType,
|
type: type as ForwardRuleType,
|
||||||
localPort,
|
localPort,
|
||||||
}
|
}
|
||||||
|
if (type === 'local') {
|
||||||
|
parsed.localBindHost = resolveLocalBindHost(rule.localBindHost)
|
||||||
|
}
|
||||||
|
if (type === 'remote') {
|
||||||
|
parsed.remoteBindHost = resolveRemoteBindHost(rule.remoteBindHost)
|
||||||
|
}
|
||||||
if (typeof rule.remoteHost === 'string' && rule.remoteHost.trim()) {
|
if (typeof rule.remoteHost === 'string' && rule.remoteHost.trim()) {
|
||||||
parsed.remoteHost = rule.remoteHost.trim()
|
parsed.remoteHost = resolveForwardTargetHost(rule.remoteHost)
|
||||||
}
|
}
|
||||||
const remotePort = Number(rule.remotePort)
|
const remotePort = Number(rule.remotePort)
|
||||||
if (Number.isInteger(remotePort) && remotePort >= 1 && remotePort <= 65535) {
|
if (Number.isInteger(remotePort) && remotePort >= 1 && remotePort <= 65535) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Monitor, Cast } from 'lucide-react'
|
import { Monitor, Cast } from 'lucide-react'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
|
import DesktopResolutionPicker from './DesktopResolutionPicker'
|
||||||
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
|
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
|
||||||
|
import { clampDesktopResolution, getDisplayResolution } from '../../utils/desktopResolution'
|
||||||
|
|
||||||
interface DesktopConnectDialogProps {
|
interface DesktopConnectDialogProps {
|
||||||
protocol: DesktopProtocol
|
protocol: DesktopProtocol
|
||||||
@@ -32,6 +34,9 @@ export default function DesktopConnectDialog({
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
setPort(isRdp ? 3389 : 5900)
|
setPort(isRdp ? 3389 : 5900)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
const display = getDisplayResolution()
|
||||||
|
setWidth(display.width)
|
||||||
|
setHeight(display.height)
|
||||||
}, [isOpen, isRdp])
|
}, [isOpen, isRdp])
|
||||||
|
|
||||||
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
|
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
|
||||||
@@ -46,6 +51,7 @@ export default function DesktopConnectDialog({
|
|||||||
setError(t('connect.errorUsernameRequired'))
|
setError(t('connect.errorUsernameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const resolution = clampDesktopResolution(width, height)
|
||||||
if (isRdp) {
|
if (isRdp) {
|
||||||
onConnect('rdp', {
|
onConnect('rdp', {
|
||||||
host: host.trim(),
|
host: host.trim(),
|
||||||
@@ -53,8 +59,8 @@ export default function DesktopConnectDialog({
|
|||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password,
|
password,
|
||||||
domain: domain.trim() || undefined,
|
domain: domain.trim() || undefined,
|
||||||
width,
|
width: resolution.width,
|
||||||
height,
|
height: resolution.height,
|
||||||
security: 'nla',
|
security: 'nla',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -62,8 +68,8 @@ export default function DesktopConnectDialog({
|
|||||||
host: host.trim(),
|
host: host.trim(),
|
||||||
port,
|
port,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
width,
|
width: resolution.width,
|
||||||
height,
|
height: resolution.height,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
@@ -145,26 +151,14 @@ export default function DesktopConnectDialog({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<DesktopResolutionPicker
|
||||||
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
|
width={width}
|
||||||
<input
|
height={height}
|
||||||
id="desktop-width"
|
onChange={(next) => {
|
||||||
type="number"
|
setWidth(next.width)
|
||||||
value={width}
|
setHeight(next.height)
|
||||||
onChange={(event) => setWidth(Number(event.target.value) || 1920)}
|
}}
|
||||||
className="ui-input font-mono"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="desktop-height" className="ui-label">{t('connect.height')}</label>
|
|
||||||
<input
|
|
||||||
id="desktop-height"
|
|
||||||
type="number"
|
|
||||||
value={height}
|
|
||||||
onChange={(event) => setHeight(Number(event.target.value) || 1080)}
|
|
||||||
className="ui-input font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<p className="text-[11px] text-txt-faint leading-relaxed">
|
<p className="text-[11px] text-txt-faint leading-relaxed">
|
||||||
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
|
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
DESKTOP_RESOLUTION_PRESETS,
|
||||||
|
clampDesktopResolution,
|
||||||
|
formatResolution,
|
||||||
|
matchPresetId,
|
||||||
|
type DesktopResolution,
|
||||||
|
} from '../../utils/desktopResolution'
|
||||||
|
|
||||||
|
interface DesktopResolutionPickerProps {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
onChange: (next: DesktopResolution) => void
|
||||||
|
ns?: 'desktop' | 'sessions'
|
||||||
|
resolutionLabelKey?: string
|
||||||
|
widthLabelKey?: string
|
||||||
|
heightLabelKey?: string
|
||||||
|
customKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DesktopResolutionPicker({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onChange,
|
||||||
|
ns = 'desktop',
|
||||||
|
resolutionLabelKey = ns === 'desktop' ? 'connect.resolution' : 'dialog.resolution',
|
||||||
|
widthLabelKey = ns === 'desktop' ? 'connect.width' : 'dialog.width',
|
||||||
|
heightLabelKey = ns === 'desktop' ? 'connect.height' : 'dialog.height',
|
||||||
|
customKey = ns === 'desktop' ? 'connect.resolutionCustom' : 'dialog.resolutionCustom',
|
||||||
|
}: DesktopResolutionPickerProps) {
|
||||||
|
const { t } = useTranslation(ns)
|
||||||
|
const current = clampDesktopResolution(width, height)
|
||||||
|
const matched = matchPresetId(current)
|
||||||
|
const [forceCustom, setForceCustom] = useState(false)
|
||||||
|
const selected = forceCustom || matched === 'custom' ? 'custom' : matched
|
||||||
|
const showCustom = selected === 'custom'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 sm:col-span-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${ns}-desktop-resolution`} className="ui-label">
|
||||||
|
{t(resolutionLabelKey)}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${ns}-desktop-resolution`}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
value={selected}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value
|
||||||
|
if (value === 'custom') {
|
||||||
|
setForceCustom(true)
|
||||||
|
onChange(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const preset = DESKTOP_RESOLUTION_PRESETS.find((item) => item.id === value)
|
||||||
|
if (!preset) return
|
||||||
|
setForceCustom(false)
|
||||||
|
onChange({ width: preset.width, height: preset.height })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DESKTOP_RESOLUTION_PRESETS.map((preset) => (
|
||||||
|
<option key={preset.id} value={preset.id}>
|
||||||
|
{formatResolution(preset)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">{t(customKey)}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{showCustom && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${ns}-desktop-width`} className="ui-label">
|
||||||
|
{t(widthLabelKey)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${ns}-desktop-width`}
|
||||||
|
type="number"
|
||||||
|
min={200}
|
||||||
|
max={8192}
|
||||||
|
step={2}
|
||||||
|
value={width}
|
||||||
|
onChange={(event) => {
|
||||||
|
setForceCustom(true)
|
||||||
|
onChange(clampDesktopResolution(Number(event.target.value) || current.width, height))
|
||||||
|
}}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${ns}-desktop-height`} className="ui-label">
|
||||||
|
{t(heightLabelKey)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${ns}-desktop-height`}
|
||||||
|
type="number"
|
||||||
|
min={200}
|
||||||
|
max={8192}
|
||||||
|
value={height}
|
||||||
|
onChange={(event) => {
|
||||||
|
setForceCustom(true)
|
||||||
|
onChange(clampDesktopResolution(width, Number(event.target.value) || current.height))
|
||||||
|
}}
|
||||||
|
className="ui-input font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { 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,
|
Cast,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
Monitor,
|
Monitor,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Unplug,
|
|
||||||
} from 'lucide-react'
|
} 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 type {
|
import type {
|
||||||
DesktopFrameEvent,
|
DesktopFrameEvent,
|
||||||
DesktopPointerButton,
|
DesktopPointerButton,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
rdpDisconnect,
|
rdpDisconnect,
|
||||||
rdpInputKey,
|
rdpInputKey,
|
||||||
rdpInputPointer,
|
rdpInputPointer,
|
||||||
|
rdpResize,
|
||||||
rdpUploadFile,
|
rdpUploadFile,
|
||||||
} from '../../platform/rdp'
|
} from '../../platform/rdp'
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
vncInputPointer,
|
vncInputPointer,
|
||||||
} from '../../platform/vnc'
|
} from '../../platform/vnc'
|
||||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||||
|
import { clampDesktopResolution } from '../../utils/desktopResolution'
|
||||||
|
|
||||||
interface DesktopSessionViewProps {
|
interface DesktopSessionViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
|
|||||||
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
||||||
const { t } = useTranslation('desktop')
|
const { t } = useTranslation('desktop')
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const paneRef = useRef<HTMLDivElement>(null)
|
||||||
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||||
const nativeSessionIdRef = useRef<string | null>(null)
|
const nativeSessionIdRef = useRef<string | null>(null)
|
||||||
const isActiveRef = useRef(isActive)
|
const isActiveRef = useRef(isActive)
|
||||||
@@ -77,7 +82,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
||||||
const pointerRafRef = useRef<number | null>(null)
|
const pointerRafRef = useRef<number | null>(null)
|
||||||
const paintRafRef = useRef<number | null>(null)
|
const paintRafRef = useRef<number | null>(null)
|
||||||
|
const hideBarTimerRef = useRef<number | null>(null)
|
||||||
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||||||
|
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
|
||||||
|
const remoteResizeTimerRef = useRef<number | null>(null)
|
||||||
|
const connectedRef = useRef(false)
|
||||||
const pendingPointerRef = useRef<{
|
const pendingPointerRef = useRef<{
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -87,10 +96,17 @@ 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(
|
||||||
|
(state) => state.active && state.tabId === tabId
|
||||||
|
)
|
||||||
|
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||||||
|
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||||||
|
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
||||||
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
|
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
||||||
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
||||||
|
const [chromeVisible, setChromeVisible] = useState(true)
|
||||||
|
|
||||||
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
||||||
isActiveRef.current = isActive
|
isActiveRef.current = isActive
|
||||||
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
updateTabStatus(tabId, 'connecting')
|
updateTabStatus(tabId, 'connecting')
|
||||||
setStatusMessage(t('status.connecting'))
|
setStatusMessage(t('status.connecting'))
|
||||||
setError(null)
|
setError(null)
|
||||||
|
connectedRef.current = false
|
||||||
|
lastRemoteResizeRef.current = null
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
unlistenStatus = await onRdpStatus((payload) => {
|
unlistenStatus = await onRdpStatus((payload) => {
|
||||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||||
|
if (payload.width && payload.height) {
|
||||||
|
setFbSize({ width: payload.width, height: payload.height })
|
||||||
|
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
|
||||||
|
}
|
||||||
if (payload.status === 'connected') {
|
if (payload.status === 'connected') {
|
||||||
|
connectedRef.current = true
|
||||||
updateTabStatus(tabId, 'connected')
|
updateTabStatus(tabId, 'connected')
|
||||||
setStatusMessage(payload.message || t('status.connected'))
|
setStatusMessage(payload.message || t('status.connected'))
|
||||||
} else if (payload.status === 'error') {
|
} else if (payload.status === 'error') {
|
||||||
|
connectedRef.current = false
|
||||||
updateTabStatus(tabId, 'error')
|
updateTabStatus(tabId, 'error')
|
||||||
setError(payload.message || t('status.connectFailed'))
|
setError(payload.message || t('status.connectFailed'))
|
||||||
} else if (payload.status === 'disconnected') {
|
} else if (payload.status === 'disconnected') {
|
||||||
|
connectedRef.current = false
|
||||||
updateTabStatus(tabId, 'disconnected')
|
updateTabStatus(tabId, 'disconnected')
|
||||||
setStatusMessage(t('status.disconnected'))
|
setStatusMessage(t('status.disconnected'))
|
||||||
} else if (payload.message) {
|
} else if (payload.message) {
|
||||||
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
|
connectedRef.current = false
|
||||||
|
if (remoteResizeTimerRef.current != null) {
|
||||||
|
window.clearTimeout(remoteResizeTimerRef.current)
|
||||||
|
remoteResizeTimerRef.current = null
|
||||||
|
}
|
||||||
if (paintRafRef.current != null) {
|
if (paintRafRef.current != null) {
|
||||||
window.cancelAnimationFrame(paintRafRef.current)
|
window.cancelAnimationFrame(paintRafRef.current)
|
||||||
paintRafRef.current = null
|
paintRafRef.current = null
|
||||||
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
canvasRef.current?.focus()
|
canvasRef.current?.focus()
|
||||||
}, [isActive])
|
}, [isActive])
|
||||||
|
|
||||||
|
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
|
||||||
|
useEffect(() => {
|
||||||
|
const pane = paneRef.current
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||||||
|
|
||||||
|
const updateFit = () => {
|
||||||
|
const availW = Math.max(1, pane.clientWidth)
|
||||||
|
const availH = Math.max(1, pane.clientHeight)
|
||||||
|
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
|
||||||
|
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
|
||||||
|
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
|
||||||
|
canvas.style.width = `${displayW}px`
|
||||||
|
canvas.style.height = `${displayH}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRemoteResize = () => {
|
||||||
|
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
|
||||||
|
const sessionId = nativeSessionIdRef.current
|
||||||
|
if (!sessionId) return
|
||||||
|
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
|
||||||
|
const last = lastRemoteResizeRef.current
|
||||||
|
if (last && last.width === next.width && last.height === next.height) return
|
||||||
|
// Skip tiny panes (drawer/animation) that would thrash the session.
|
||||||
|
if (next.width < 640 || next.height < 480) return
|
||||||
|
lastRemoteResizeRef.current = next
|
||||||
|
void rdpResize(sessionId, next.width, next.height).catch(() => {
|
||||||
|
// Allow retry on next size change if Display Control was not ready.
|
||||||
|
lastRemoteResizeRef.current = last
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPaneResize = () => {
|
||||||
|
updateFit()
|
||||||
|
if (remoteResizeTimerRef.current != null) {
|
||||||
|
window.clearTimeout(remoteResizeTimerRef.current)
|
||||||
|
}
|
||||||
|
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFit()
|
||||||
|
// After fullscreen / maximize layout settles, request matching remote size once.
|
||||||
|
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
|
||||||
|
const observer = new ResizeObserver(onPaneResize)
|
||||||
|
observer.observe(pane)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
if (remoteResizeTimerRef.current != null) {
|
||||||
|
window.clearTimeout(remoteResizeTimerRef.current)
|
||||||
|
remoteResizeTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
|
||||||
|
|
||||||
|
// Leave OS fullscreen when this tab is hidden or unmounted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive && fullscreenActive) {
|
||||||
|
void exitFullscreen()
|
||||||
|
}
|
||||||
|
}, [exitFullscreen, fullscreenActive, isActive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideBarTimerRef.current != null) {
|
||||||
|
window.clearTimeout(hideBarTimerRef.current)
|
||||||
|
}
|
||||||
|
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
|
||||||
|
const state = useDesktopFullscreenStore.getState()
|
||||||
|
if (state.active && state.tabId === tabId) {
|
||||||
|
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
|
||||||
|
if (!stillOpen) void state.exit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tabId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreenActive) {
|
||||||
|
setChromeVisible(true)
|
||||||
|
if (hideBarTimerRef.current != null) {
|
||||||
|
window.clearTimeout(hideBarTimerRef.current)
|
||||||
|
hideBarTimerRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const state = useDesktopFullscreenStore.getState()
|
||||||
|
if (!state.active || state.tabId !== tabId) return
|
||||||
|
if (Date.now() - state.enteredAt < 1600) return
|
||||||
|
const osFs = await window.opsxApi?.isFullscreen?.()
|
||||||
|
if (osFs === false) {
|
||||||
|
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [fullscreenActive, tabId])
|
||||||
|
|
||||||
|
const scheduleHideChrome = useCallback(() => {
|
||||||
|
if (!fullscreenActive) return
|
||||||
|
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
|
||||||
|
hideBarTimerRef.current = window.setTimeout(() => {
|
||||||
|
setChromeVisible(false)
|
||||||
|
hideBarTimerRef.current = null
|
||||||
|
}, 1800)
|
||||||
|
}, [fullscreenActive])
|
||||||
|
|
||||||
|
const revealChrome = useCallback(() => {
|
||||||
|
setChromeVisible(true)
|
||||||
|
scheduleHideChrome()
|
||||||
|
}, [scheduleHideChrome])
|
||||||
|
|
||||||
const flushPointer = () => {
|
const flushPointer = () => {
|
||||||
pointerRafRef.current = null
|
pointerRafRef.current = null
|
||||||
const sessionId = nativeSessionIdRef.current
|
const sessionId = nativeSessionIdRef.current
|
||||||
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||||||
const sessionId = nativeSessionIdRef.current
|
const sessionId = nativeSessionIdRef.current
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
|
// Local fullscreen controls — never forward to the remote desktop.
|
||||||
|
if (event.key === 'F11') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (pressed) void toggleFullscreen(tabId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fullscreenActive && event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (pressed) void exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const isPasteChord =
|
const isPasteChord =
|
||||||
@@ -434,20 +596,6 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnectSession = async () => {
|
|
||||||
const sessionId = nativeSessionIdRef.current
|
|
||||||
if (!sessionId) return
|
|
||||||
try {
|
|
||||||
if (protocol === 'rdp') await rdpDisconnect(sessionId)
|
|
||||||
else await vncDisconnect(sessionId)
|
|
||||||
nativeSessionIdRef.current = null
|
|
||||||
updateTabStatus(tabId, 'disconnected')
|
|
||||||
setStatusMessage(t('status.disconnected'))
|
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : String(reason))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = protocol === 'rdp' ? Monitor : Cast
|
const Icon = protocol === 'rdp' ? Monitor : Cast
|
||||||
const hostLabel =
|
const hostLabel =
|
||||||
resolvedConfig && 'host' in resolvedConfig
|
resolvedConfig && 'host' in resolvedConfig
|
||||||
@@ -458,9 +606,26 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
const toolBtn =
|
const toolBtn =
|
||||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
|
||||||
|
|
||||||
return (
|
const toolbar = (
|
||||||
<div className="h-full flex flex-col bg-dark-900">
|
<div
|
||||||
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||||
|
fullscreenActive
|
||||||
|
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
|
||||||
|
: 'border-b border-dark-700/50 bg-dark-800/80'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (fullscreenActive) {
|
||||||
|
setChromeVisible(true)
|
||||||
|
if (hideBarTimerRef.current != null) {
|
||||||
|
window.clearTimeout(hideBarTimerRef.current)
|
||||||
|
hideBarTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (fullscreenActive) scheduleHideChrome()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||||
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
||||||
{hostLabel}
|
{hostLabel}
|
||||||
@@ -479,6 +644,9 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
)}
|
)}
|
||||||
<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>}
|
||||||
|
{fullscreenActive && (
|
||||||
|
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
@@ -527,22 +695,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
|
className={toolBtn}
|
||||||
disabled={!connected}
|
onClick={(event) => {
|
||||||
onClick={() => void disconnectSession()}
|
event.preventDefault()
|
||||||
title={t('toolbar.disconnect')}
|
event.stopPropagation()
|
||||||
aria-label={t('toolbar.disconnectAria')}
|
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
|
||||||
|
}}
|
||||||
|
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
|
||||||
|
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
|
||||||
>
|
>
|
||||||
<Unplug className="h-3.5 w-3.5" />
|
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
if (!fullscreenActive) return
|
||||||
|
if (event.clientY <= 56) revealChrome()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fullscreenActive ? (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
|
||||||
|
chromeVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
toolbar
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={paneRef}
|
||||||
|
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
|
||||||
|
fullscreenActive ? 'p-0' : 'p-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
|
className="cursor-default outline-none shadow-lg shadow-black/40"
|
||||||
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)}
|
||||||
|
|||||||
@@ -75,16 +75,24 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
.finally(() => setSessionsLoading(false))
|
.finally(() => setSessionsLoading(false))
|
||||||
}, [setSessions])
|
}, [setSessions])
|
||||||
|
|
||||||
// Click outside to close context menu
|
// Click outside to close context menu (only while open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
if (!contextMenu) return
|
||||||
|
const handlePointerDown = (e: PointerEvent) => {
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
if (e.key === 'Escape') setContextMenu(null)
|
||||||
}, [])
|
}
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
}, [contextMenu])
|
||||||
|
|
||||||
// Resize handler
|
// Resize handler
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -423,7 +431,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
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="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 text-accent-green" />
|
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||||
{t('sidebar.newSessionShort')}
|
{t('sidebar.newSessionShort')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,6 +525,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
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-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{(contextMenu.kind === 'blank'
|
{(contextMenu.kind === 'blank'
|
||||||
? [
|
? [
|
||||||
@@ -536,7 +545,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: t('contextMenu.session.open'), action: 'connect' },
|
{ label: t('contextMenu.session.open'), action: 'connect' },
|
||||||
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
|
...(isSshSession(contextMenu.session)
|
||||||
|
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
|
||||||
|
: []),
|
||||||
{ label: t('contextMenu.session.edit'), action: 'edit' },
|
{ label: t('contextMenu.session.edit'), action: 'edit' },
|
||||||
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
|
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
|
||||||
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
|
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ConnectionState } from '../../stores/connection'
|
|||||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||||
import { useSessionStore, useEntitlementStore } from '../../stores'
|
import { useSessionStore, useEntitlementStore } from '../../stores'
|
||||||
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
|
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
|
||||||
import { formatLocaleTime } from '../../i18n/format'
|
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
|
||||||
import { useToast } from '../../hooks/useToast'
|
import { useToast } from '../../hooks/useToast'
|
||||||
import ForwardRuleDrawer, {
|
import ForwardRuleDrawer, {
|
||||||
DEFAULT_DYNAMIC_PORT,
|
DEFAULT_DYNAMIC_PORT,
|
||||||
@@ -17,8 +17,18 @@ import ForwardRuleDrawer, {
|
|||||||
import {
|
import {
|
||||||
parseForwardRules,
|
parseForwardRules,
|
||||||
portForwardStorageKey,
|
portForwardStorageKey,
|
||||||
|
resolveForwardTargetHost,
|
||||||
|
resolveLocalBindHost,
|
||||||
|
resolveRemoteBindHost,
|
||||||
type ForwardRule,
|
type ForwardRule,
|
||||||
} from '../connection/forwardRules'
|
} from '../connection/forwardRules'
|
||||||
|
import {
|
||||||
|
forgetForwardIntent,
|
||||||
|
forwardIntentKey,
|
||||||
|
rememberForwardIntent,
|
||||||
|
restoreForwardIntent,
|
||||||
|
tunnelCoversRule,
|
||||||
|
} from '../connection/forwardIntent'
|
||||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||||
|
|
||||||
// ============== Status Display Config ==============
|
// ============== Status Display Config ==============
|
||||||
@@ -34,37 +44,37 @@ const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey
|
|||||||
connected: {
|
connected: {
|
||||||
icon: <Wifi className="w-3 h-3" />,
|
icon: <Wifi className="w-3 h-3" />,
|
||||||
labelKey: 'status.connected',
|
labelKey: 'status.connected',
|
||||||
colorClass: 'text-green-400',
|
colorClass: 'text-feedback-success',
|
||||||
animateClass: '',
|
animateClass: '',
|
||||||
},
|
},
|
||||||
connecting: {
|
connecting: {
|
||||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||||
labelKey: 'status.connecting',
|
labelKey: 'status.connecting',
|
||||||
colorClass: 'text-yellow-400',
|
colorClass: 'text-feedback-warning',
|
||||||
animateClass: 'animate-pulse',
|
animateClass: 'animate-pulse',
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
icon: <Wifi className="w-3 h-3" />,
|
icon: <Wifi className="w-3 h-3" />,
|
||||||
labelKey: 'status.background',
|
labelKey: 'status.background',
|
||||||
colorClass: 'text-green-400',
|
colorClass: 'text-feedback-success',
|
||||||
animateClass: '',
|
animateClass: '',
|
||||||
},
|
},
|
||||||
suspended: {
|
suspended: {
|
||||||
icon: <WifiOff className="w-3 h-3" />,
|
icon: <WifiOff className="w-3 h-3" />,
|
||||||
labelKey: 'status.suspended',
|
labelKey: 'status.suspended',
|
||||||
colorClass: 'text-yellow-400',
|
colorClass: 'text-feedback-warning',
|
||||||
animateClass: '',
|
animateClass: '',
|
||||||
},
|
},
|
||||||
reconnecting: {
|
reconnecting: {
|
||||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||||
labelKey: 'status.reconnecting',
|
labelKey: 'status.reconnecting',
|
||||||
colorClass: 'text-yellow-400',
|
colorClass: 'text-feedback-warning',
|
||||||
animateClass: 'animate-pulse',
|
animateClass: 'animate-pulse',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
icon: <AlertCircle className="w-3 h-3" />,
|
icon: <AlertCircle className="w-3 h-3" />,
|
||||||
labelKey: 'status.error',
|
labelKey: 'status.error',
|
||||||
colorClass: 'text-red-400',
|
colorClass: 'text-feedback-error',
|
||||||
animateClass: '',
|
animateClass: '',
|
||||||
},
|
},
|
||||||
disconnected: {
|
disconnected: {
|
||||||
@@ -127,7 +137,7 @@ function formatCpuPercent(pct: number): string {
|
|||||||
|
|
||||||
function pctClass(pct?: number): string {
|
function pctClass(pct?: number): string {
|
||||||
if (pct == null) return 'text-txt-faint'
|
if (pct == null) return 'text-txt-faint'
|
||||||
if (pct >= WARN_PCT) return 'text-amber-400'
|
if (pct >= WARN_PCT) return 'text-feedback-warning'
|
||||||
return 'text-txt-muted'
|
return 'text-txt-muted'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +392,6 @@ export default function StatusBar() {
|
|||||||
connectionState: undefined,
|
connectionState: undefined,
|
||||||
hostLabel: undefined,
|
hostLabel: undefined,
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
tabKind: activeTab?.type as string | undefined,
|
|
||||||
connectionId: undefined,
|
connectionId: undefined,
|
||||||
isRemoteSsh: false,
|
isRemoteSsh: false,
|
||||||
sessionOsPretty: undefined as string | undefined,
|
sessionOsPretty: undefined as string | undefined,
|
||||||
@@ -399,7 +408,6 @@ export default function StatusBar() {
|
|||||||
: activeTab.title
|
: activeTab.title
|
||||||
: activeTab.title,
|
: activeTab.title,
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
tabKind: activeTab.type,
|
|
||||||
connectionId: undefined,
|
connectionId: undefined,
|
||||||
isRemoteSsh: false,
|
isRemoteSsh: false,
|
||||||
sessionOsPretty: undefined as string | undefined,
|
sessionOsPretty: undefined as string | undefined,
|
||||||
@@ -411,7 +419,6 @@ export default function StatusBar() {
|
|||||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||||
hostLabel: t('terminal:toolbar.labelLocal'),
|
hostLabel: t('terminal:toolbar.labelLocal'),
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
tabKind: activeTab.type,
|
|
||||||
connectionId: undefined,
|
connectionId: undefined,
|
||||||
isRemoteSsh: false,
|
isRemoteSsh: false,
|
||||||
sessionOsPretty: undefined as string | undefined,
|
sessionOsPretty: undefined as string | undefined,
|
||||||
@@ -431,7 +438,6 @@ export default function StatusBar() {
|
|||||||
connectionState: conn.state,
|
connectionState: conn.state,
|
||||||
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
|
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
|
||||||
stats: conn.stats,
|
stats: conn.stats,
|
||||||
tabKind: activeTab.type,
|
|
||||||
connectionId: instance.connectionId,
|
connectionId: instance.connectionId,
|
||||||
isRemoteSsh: true,
|
isRemoteSsh: true,
|
||||||
sessionOsPretty,
|
sessionOsPretty,
|
||||||
@@ -444,7 +450,6 @@ export default function StatusBar() {
|
|||||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||||
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
|
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
tabKind: activeTab.type,
|
|
||||||
connectionId: instance?.connectionId,
|
connectionId: instance?.connectionId,
|
||||||
isRemoteSsh: Boolean(instance?.connectionId),
|
isRemoteSsh: Boolean(instance?.connectionId),
|
||||||
sessionOsPretty,
|
sessionOsPretty,
|
||||||
@@ -461,7 +466,6 @@ export default function StatusBar() {
|
|||||||
connectionState: state,
|
connectionState: state,
|
||||||
hostLabel: label,
|
hostLabel: label,
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
tabKind: activeTab.type,
|
|
||||||
connectionId: instance?.connectionId,
|
connectionId: instance?.connectionId,
|
||||||
isRemoteSsh: Boolean(instance?.connectionId),
|
isRemoteSsh: Boolean(instance?.connectionId),
|
||||||
sessionOsPretty,
|
sessionOsPretty,
|
||||||
@@ -481,6 +485,11 @@ export default function StatusBar() {
|
|||||||
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
|
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
|
||||||
}, [activeTab?.sessionId, activeTabId, connectionId])
|
}, [activeTab?.sessionId, activeTabId, connectionId])
|
||||||
|
|
||||||
|
const intentKey = useMemo(
|
||||||
|
() => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null),
|
||||||
|
[activeTab?.sessionId, activeTabId]
|
||||||
|
)
|
||||||
|
|
||||||
const forwardRuntimes = useMemo(() => {
|
const forwardRuntimes = useMemo(() => {
|
||||||
const map = new Map<string, { running: boolean; boundPort?: number }>()
|
const map = new Map<string, { running: boolean; boundPort?: number }>()
|
||||||
for (const tunnel of forwardTunnels) {
|
for (const tunnel of forwardTunnels) {
|
||||||
@@ -489,10 +498,13 @@ export default function StatusBar() {
|
|||||||
boundPort: tunnel.boundPort,
|
boundPort: tunnel.boundPort,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const dynamic = forwardRules.find((rule) => rule.type === 'dynamic')
|
// Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port.
|
||||||
const dynamicTunnel = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
|
for (const rule of forwardRules) {
|
||||||
if (dynamic && dynamicTunnel && !map.get(dynamic.id)?.running) {
|
if (map.get(rule.id)?.running) continue
|
||||||
map.set(dynamic.id, { running: true, boundPort: dynamicTunnel.boundPort })
|
const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule))
|
||||||
|
if (tunnel) {
|
||||||
|
map.set(rule.id, { running: true, boundPort: tunnel.boundPort })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [forwardRules, forwardTunnels])
|
}, [forwardRules, forwardTunnels])
|
||||||
@@ -585,6 +597,11 @@ export default function StatusBar() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [socksAvailable, loadForwardRules, refreshForwardState])
|
}, [socksAvailable, loadForwardRules, refreshForwardState])
|
||||||
|
|
||||||
|
const previousForwardConnectionRef = useRef<string | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
previousForwardConnectionRef.current = undefined
|
||||||
|
}, [intentKey])
|
||||||
|
|
||||||
const openForwardDrawer = useCallback(() => {
|
const openForwardDrawer = useCallback(() => {
|
||||||
if (!connectionId || !activeTabId) return
|
if (!connectionId || !activeTabId) return
|
||||||
if (!requirePro('portForward')) return
|
if (!requirePro('portForward')) return
|
||||||
@@ -594,6 +611,10 @@ export default function StatusBar() {
|
|||||||
...emptyForwardDraft(),
|
...emptyForwardDraft(),
|
||||||
type: 'local',
|
type: 'local',
|
||||||
name: t('common:portForward.defaultLocalName'),
|
name: t('common:portForward.defaultLocalName'),
|
||||||
|
localBindHost: '127.0.0.1',
|
||||||
|
localPort: 8080,
|
||||||
|
remoteHost: '127.0.0.1',
|
||||||
|
remotePort: 80,
|
||||||
})
|
})
|
||||||
setForwardDrawerError(null)
|
setForwardDrawerError(null)
|
||||||
setForwardDrawerOpen(true)
|
setForwardDrawerOpen(true)
|
||||||
@@ -617,17 +638,19 @@ export default function StatusBar() {
|
|||||||
result = await window.opsxApi.sshForwardLocal(
|
result = await window.opsxApi.sshForwardLocal(
|
||||||
connectionId,
|
connectionId,
|
||||||
rule.localPort,
|
rule.localPort,
|
||||||
rule.remoteHost || '',
|
resolveForwardTargetHost(rule.remoteHost),
|
||||||
rule.remotePort || 0,
|
rule.remotePort || 0,
|
||||||
rule.id
|
rule.id,
|
||||||
|
resolveLocalBindHost(rule.localBindHost)
|
||||||
)
|
)
|
||||||
} else if (rule.type === 'remote') {
|
} else if (rule.type === 'remote') {
|
||||||
result = await window.opsxApi.sshForwardRemote(
|
result = await window.opsxApi.sshForwardRemote(
|
||||||
connectionId,
|
connectionId,
|
||||||
rule.remotePort || 0,
|
rule.remotePort || 0,
|
||||||
'127.0.0.1',
|
resolveForwardTargetHost(rule.remoteHost),
|
||||||
rule.localPort,
|
rule.localPort,
|
||||||
rule.id
|
rule.id,
|
||||||
|
resolveRemoteBindHost(rule.remoteBindHost)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
result = await window.opsxApi.sshForwardDynamic(
|
result = await window.opsxApi.sshForwardDynamic(
|
||||||
@@ -637,48 +660,73 @@ export default function StatusBar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
setForwardDrawerError(result?.error || t('common:portForward.errorStartFailed'))
|
setForwardDrawerError(
|
||||||
|
result?.error
|
||||||
|
? translateErrorMessage(result.error)
|
||||||
|
: t('common:portForward.errorStartFailed')
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (intentKey) rememberForwardIntent(intentKey, rule)
|
||||||
await refreshForwardState()
|
await refreshForwardState()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[connectionId, refreshForwardState, t]
|
[connectionId, intentKey, refreshForwardState, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socksAvailable || !connectionId || !intentKey) return
|
||||||
|
const previous = previousForwardConnectionRef.current
|
||||||
|
previousForwardConnectionRef.current = connectionId
|
||||||
|
if (!previous || previous === connectionId) return
|
||||||
|
void (async () => {
|
||||||
|
const listed = await window.opsxApi?.sshForwardList?.(connectionId)
|
||||||
|
const tunnels = listed?.success ? listed.tunnels || [] : []
|
||||||
|
const restored = await restoreForwardIntent(
|
||||||
|
intentKey,
|
||||||
|
(rule) => tunnelCoversRule(tunnels, rule),
|
||||||
|
(rule) => startForwardRule(rule)
|
||||||
|
)
|
||||||
|
if (restored > 0) {
|
||||||
|
toast.success(t('common:statusBar.forwardResumed', { count: restored }))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [socksAvailable, connectionId, intentKey, startForwardRule, t, toast])
|
||||||
|
|
||||||
const stopForwardRule = useCallback(
|
const stopForwardRule = useCallback(
|
||||||
async (rule: ForwardRule): Promise<boolean> => {
|
async (rule: ForwardRule): Promise<boolean> => {
|
||||||
if (!window.opsxApi?.sshForwardStop) return false
|
if (!window.opsxApi?.sshForwardStop) return false
|
||||||
const result = await window.opsxApi.sshForwardStop(rule.id)
|
let result = await window.opsxApi.sshForwardStop(rule.id)
|
||||||
if (!result?.success) {
|
if (!result?.success && connectionId) {
|
||||||
// Dynamic may have been started with a different id historically.
|
// Runtime id may differ from persisted rule id after reconnect.
|
||||||
if (rule.type === 'dynamic' && connectionId) {
|
|
||||||
const listed = await window.opsxApi.sshForwardList(connectionId)
|
const listed = await window.opsxApi.sshForwardList(connectionId)
|
||||||
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
|
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||||
if (match) {
|
if (match) {
|
||||||
const stopMatch = await window.opsxApi.sshForwardStop(match.forwardId)
|
result = await window.opsxApi.sshForwardStop(match.forwardId)
|
||||||
if (!stopMatch?.success) {
|
}
|
||||||
|
}
|
||||||
|
if (!result?.success) {
|
||||||
setForwardDrawerError(
|
setForwardDrawerError(
|
||||||
stopMatch?.error || result?.error || t('common:portForward.errorStopFailed')
|
result?.error
|
||||||
|
? translateErrorMessage(result.error)
|
||||||
|
: t('common:portForward.errorStopFailed')
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await refreshForwardState()
|
if (intentKey) forgetForwardIntent(intentKey, rule.id)
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
await refreshForwardState()
|
await refreshForwardState()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[connectionId, refreshForwardState, t]
|
[connectionId, intentKey, refreshForwardState, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStartRule = useCallback(
|
const handleStartRule = useCallback(
|
||||||
async (rule: ForwardRule) => {
|
async (rule: ForwardRule) => {
|
||||||
if (!connectionId || forwardSaving) return
|
if (!connectionId || forwardSaving) return
|
||||||
|
if (forwardRuntimes.get(rule.id)?.running) {
|
||||||
|
toast.success(t('common:statusBar.forwardStarted'))
|
||||||
|
return
|
||||||
|
}
|
||||||
setForwardSaving(true)
|
setForwardSaving(true)
|
||||||
setForwardDrawerError(null)
|
setForwardDrawerError(null)
|
||||||
try {
|
try {
|
||||||
@@ -701,16 +749,18 @@ export default function StatusBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ok = await startForwardRule(target)
|
const ok = await startForwardRule(target)
|
||||||
if (ok && target.type === 'dynamic') {
|
if (ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t('common:statusBar.forwardStartedDynamic', {
|
target.type === 'dynamic'
|
||||||
|
? t('common:statusBar.forwardStartedDynamic', {
|
||||||
port: resolveDynamicPort(target.localPort),
|
port: resolveDynamicPort(target.localPort),
|
||||||
})
|
})
|
||||||
|
: t('common:statusBar.forwardStarted')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setForwardDrawerError(
|
setForwardDrawerError(
|
||||||
(error instanceof Error ? error.message : String(error)) ||
|
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
|
||||||
t('common:portForward.errorStartFailed')
|
t('common:portForward.errorStartFailed')
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -720,6 +770,7 @@ export default function StatusBar() {
|
|||||||
[
|
[
|
||||||
connectionId,
|
connectionId,
|
||||||
forwardRules,
|
forwardRules,
|
||||||
|
forwardRuntimes,
|
||||||
forwardSaving,
|
forwardSaving,
|
||||||
persistForwardRules,
|
persistForwardRules,
|
||||||
startForwardRule,
|
startForwardRule,
|
||||||
@@ -800,11 +851,19 @@ export default function StatusBar() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!validPort(forwardDraft.remotePort)) {
|
if (!validPort(forwardDraft.remotePort)) {
|
||||||
setForwardDrawerError(t('common:portForward.errorTargetPort'))
|
setForwardDrawerError(
|
||||||
|
forwardDraft.type === 'local'
|
||||||
|
? t('common:portForward.errorTargetAddress')
|
||||||
|
: t('common:portForward.errorRemoteListenAddress')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (forwardDraft.type === 'local' && !forwardDraft.remoteHost?.trim()) {
|
if (!forwardDraft.remoteHost?.trim()) {
|
||||||
setForwardDrawerError(t('common:portForward.errorRemoteHost'))
|
setForwardDrawerError(
|
||||||
|
forwardDraft.type === 'local'
|
||||||
|
? t('common:portForward.errorTargetAddress')
|
||||||
|
: t('common:portForward.errorLocalTargetAddress')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,7 +872,15 @@ export default function StatusBar() {
|
|||||||
type: forwardDraft.type || 'local',
|
type: forwardDraft.type || 'local',
|
||||||
name: forwardDraft.name.trim(),
|
name: forwardDraft.name.trim(),
|
||||||
localPort: forwardDraft.localPort,
|
localPort: forwardDraft.localPort,
|
||||||
remoteHost: forwardDraft.remoteHost?.trim(),
|
localBindHost:
|
||||||
|
forwardDraft.type === 'local'
|
||||||
|
? resolveLocalBindHost(forwardDraft.localBindHost)
|
||||||
|
: undefined,
|
||||||
|
remoteBindHost:
|
||||||
|
forwardDraft.type === 'remote'
|
||||||
|
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
|
||||||
|
: undefined,
|
||||||
|
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
|
||||||
remotePort: forwardDraft.remotePort,
|
remotePort: forwardDraft.remotePort,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +903,7 @@ export default function StatusBar() {
|
|||||||
toast.success(t('common:statusBar.forwardStarted'))
|
toast.success(t('common:statusBar.forwardStarted'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setForwardDrawerError(
|
setForwardDrawerError(
|
||||||
(error instanceof Error ? error.message : String(error)) ||
|
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
|
||||||
t('common:portForward.errorStartFailed')
|
t('common:portForward.errorStartFailed')
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -955,7 +1022,9 @@ export default function StatusBar() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
|
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
|
||||||
<span className="tabular-nums">{t('common:statusBar.socksLabel')}</span>
|
<span className="tabular-nums">
|
||||||
|
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -972,6 +1041,7 @@ export default function StatusBar() {
|
|||||||
rules={forwardRules}
|
rules={forwardRules}
|
||||||
runtimes={forwardRuntimes}
|
runtimes={forwardRuntimes}
|
||||||
busy={forwardSaving}
|
busy={forwardSaving}
|
||||||
|
viaSession={hostLabel}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setForwardDraft({
|
setForwardDraft({
|
||||||
type: next.type || 'local',
|
type: next.type || 'local',
|
||||||
@@ -980,6 +1050,14 @@ export default function StatusBar() {
|
|||||||
next.type === 'dynamic'
|
next.type === 'dynamic'
|
||||||
? resolveDynamicPort(next.localPort)
|
? resolveDynamicPort(next.localPort)
|
||||||
: next.localPort || 0,
|
: next.localPort || 0,
|
||||||
|
localBindHost:
|
||||||
|
next.type === 'local'
|
||||||
|
? resolveLocalBindHost(next.localBindHost)
|
||||||
|
: '',
|
||||||
|
remoteBindHost:
|
||||||
|
next.type === 'remote'
|
||||||
|
? resolveRemoteBindHost(next.remoteBindHost)
|
||||||
|
: '',
|
||||||
remoteHost: next.remoteHost || '',
|
remoteHost: next.remoteHost || '',
|
||||||
remotePort: next.remotePort || 0,
|
remotePort: next.remotePort || 0,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
>
|
>
|
||||||
{/* 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 className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
|
<div
|
||||||
|
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('tab.listAria')}
|
||||||
|
>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -147,7 +151,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenLauncher}
|
onClick={onOpenLauncher}
|
||||||
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm 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-dark-600 text-white transition-colors hover:bg-accent-blue"
|
||||||
title={t('launcher.title')}
|
title={t('launcher.title')}
|
||||||
aria-label={t('launcher.title')}
|
aria-label={t('launcher.title')}
|
||||||
>
|
>
|
||||||
@@ -175,6 +179,8 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
sessionId: currentTab.sessionId,
|
sessionId: currentTab.sessionId,
|
||||||
connectionConfig: currentTab.connectionConfig,
|
connectionConfig: currentTab.connectionConfig,
|
||||||
serialConfig: currentTab.serialConfig,
|
serialConfig: currentTab.serialConfig,
|
||||||
|
rdpConfig: currentTab.rdpConfig,
|
||||||
|
vncConfig: currentTab.vncConfig,
|
||||||
isLocal: currentTab.isLocal,
|
isLocal: currentTab.isLocal,
|
||||||
localShellKind: currentTab.localShellKind,
|
localShellKind: currentTab.localShellKind,
|
||||||
localShellPath: currentTab.localShellPath,
|
localShellPath: currentTab.localShellPath,
|
||||||
@@ -182,6 +188,18 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onOpenSftp={
|
||||||
|
currentTab?.type === 'terminal' && currentTab.sessionId && !currentTab.isLocal
|
||||||
|
? () => {
|
||||||
|
const name = currentTab.title.replace(/^SFTP:\s*/i, '')
|
||||||
|
addTab({
|
||||||
|
title: `SFTP: ${name}`,
|
||||||
|
type: 'sftp',
|
||||||
|
sessionId: currentTab.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,8 +117,12 @@ function TabContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
{...(!isActive ? { inert: '' } : {})}
|
||||||
className={`absolute inset-0 transition-opacity duration-150 ${
|
className={`absolute inset-0 transition-opacity duration-150 ${
|
||||||
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
isActive
|
||||||
|
? 'visible opacity-100 z-10'
|
||||||
|
: 'invisible opacity-0 z-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
|
|||||||
)
|
)
|
||||||
|
|
||||||
const badgeClass = (kind: string) => {
|
const badgeClass = (kind: string) => {
|
||||||
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
|
if (kind === 'zsh') return 'bg-teal-700/90 text-teal-50'
|
||||||
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
|
if (kind === 'bash') return 'bg-accent-green/15 text-accent-green'
|
||||||
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
|
if (kind === 'fish') return 'bg-accent-purple/15 text-accent-purple'
|
||||||
return 'bg-dark-600 text-content-secondary'
|
return 'bg-dark-600 text-content-secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export function SessionTree({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-group-row
|
data-group-row
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={`session-group-${group}`}
|
||||||
onClick={() => onToggleGroup(group)}
|
onClick={() => onToggleGroup(group)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -126,10 +128,12 @@ export function SessionTree({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
id={`session-group-${group}`}
|
||||||
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||||
|
aria-hidden={!isExpanded}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden" {...(!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">{t('tree.emptyTitle')}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -142,7 +146,7 @@ export function SessionTree({
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
data-session-row
|
data-session-row
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (multiSelect) onToggleSelection?.(session)
|
if (multiSelect) onToggleSelection?.(session)
|
||||||
else onSelectSession(session)
|
else onSelectSession(session)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface TabContextMenuProps {
|
|||||||
onCloseLeft: () => void
|
onCloseLeft: () => void
|
||||||
onCloseAll: () => void
|
onCloseAll: () => void
|
||||||
onClone?: () => void
|
onClone?: () => void
|
||||||
|
onOpenSftp?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabContextMenu({
|
export function TabContextMenu({
|
||||||
@@ -34,19 +35,30 @@ export function TabContextMenu({
|
|||||||
onCloseLeft,
|
onCloseLeft,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
onClone,
|
onClone,
|
||||||
|
onOpenSftp,
|
||||||
}: TabContextMenuProps) {
|
}: TabContextMenuProps) {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
if (!isOpen) return
|
||||||
|
const handlePointerDown = (e: PointerEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
onClose()
|
onCloseRef.current()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
if (e.key === 'Escape') onCloseRef.current()
|
||||||
}, [onClose])
|
}
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -61,16 +73,21 @@ export function TabContextMenu({
|
|||||||
if (onClone) {
|
if (onClone) {
|
||||||
items.unshift({ label: t('tab.menu.clone'), action: onClone })
|
items.unshift({ label: t('tab.menu.clone'), action: onClone })
|
||||||
}
|
}
|
||||||
|
if (onOpenSftp) {
|
||||||
|
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
style={{ left: position.x, top: position.y }}
|
style={{ left: position.x, top: position.y }}
|
||||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
|
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.action()
|
item.action()
|
||||||
onClose()
|
onClose()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface TabItemProps {
|
|||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragOverIndex: number | null
|
dragOverIndex: number | null
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
onClose: (e: React.MouseEvent) => void
|
onClose: (e: { stopPropagation(): void }) => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onDragStart: (e: React.DragEvent, index: number) => void
|
onDragStart: (e: React.DragEvent, index: number) => void
|
||||||
onDragOver: (e: React.DragEvent, index: number) => void
|
onDragOver: (e: React.DragEvent, index: number) => void
|
||||||
@@ -38,7 +38,7 @@ export function TabItem({
|
|||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
}: TabItemProps) {
|
}: TabItemProps) {
|
||||||
const { t } = useTranslation('terminal')
|
const { t } = useTranslation(['terminal', 'common'])
|
||||||
const displayTitle = resolveTabTitle(tab)
|
const displayTitle = resolveTabTitle(tab)
|
||||||
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
|
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
|
||||||
const session = useSessionStore((state) =>
|
const session = useSessionStore((state) =>
|
||||||
@@ -49,7 +49,7 @@ export function TabItem({
|
|||||||
if (isLocalTerm) {
|
if (isLocalTerm) {
|
||||||
return (
|
return (
|
||||||
<Terminal
|
<Terminal
|
||||||
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
|
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-accent-green' : 'text-accent-green/70'}`}
|
||||||
aria-label={t('toolbar.labelLocal')}
|
aria-label={t('toolbar.labelLocal')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -99,8 +99,28 @@ export function TabItem({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tab-item
|
data-tab-item
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
tabIndex={0}
|
||||||
draggable
|
draggable
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
|
onAuxClick={(e) => {
|
||||||
|
if (e.button === 1) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
if (e.key === 'Delete') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onDragStart={(e) => onDragStart(e, index)}
|
onDragStart={(e) => onDragStart(e, index)}
|
||||||
onDragOver={(e) => onDragOver(e, index)}
|
onDragOver={(e) => onDragOver(e, index)}
|
||||||
@@ -132,9 +152,12 @@ export function TabItem({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
||||||
className={`
|
className={`
|
||||||
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover: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' : ''}
|
${isActive ? 'opacity-100' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
|
* UpgradeView — Free / Yearly / Perpetual plan picker + license activate
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
@@ -12,11 +12,11 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
licenseServerBaseUrl,
|
|
||||||
proFeatureLabel,
|
proFeatureLabel,
|
||||||
useEntitlementStore,
|
useEntitlementStore,
|
||||||
type ProFeature,
|
type ProFeature,
|
||||||
} from '../../stores/entitlementStore'
|
} from '../../stores/entitlementStore'
|
||||||
|
import { checkout as licenseCheckout } from '../../platform/licenseApi'
|
||||||
import { createLogger } from '../../utils/logger'
|
import { createLogger } from '../../utils/logger'
|
||||||
|
|
||||||
const logger = createLogger('UpgradeView')
|
const logger = createLogger('UpgradeView')
|
||||||
@@ -29,15 +29,49 @@ interface UpgradeViewProps {
|
|||||||
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
|
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
|
||||||
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
|
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
|
||||||
|
|
||||||
|
type SelectablePlan = 'free' | 'yearly' | 'perpetual'
|
||||||
|
|
||||||
|
function looksLikeValidLicenseKey(value: string): boolean {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
|
||||||
|
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
function activatedLabel(
|
||||||
|
t: (key: string) => string,
|
||||||
|
planType: string | null
|
||||||
|
): string {
|
||||||
|
switch (planType) {
|
||||||
|
case 'yearly':
|
||||||
|
return t('pro.activatedYearly')
|
||||||
|
case 'trial':
|
||||||
|
return t('pro.activatedTrial')
|
||||||
|
case 'perpetual':
|
||||||
|
case 'lifetime':
|
||||||
|
return t('pro.activatedPerpetual')
|
||||||
|
default:
|
||||||
|
return t('pro.activated')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const plan = useEntitlementStore((s) => s.plan)
|
const plan = useEntitlementStore((s) => s.plan)
|
||||||
const deviceId = useEntitlementStore((s) => s.deviceId)
|
const deviceId = useEntitlementStore((s) => s.deviceId)
|
||||||
|
const email = useEntitlementStore((s) => s.email)
|
||||||
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
||||||
|
const maxDevices = useEntitlementStore((s) => s.maxDevices)
|
||||||
|
const planType = useEntitlementStore((s) => s.planType)
|
||||||
const activate = useEntitlementStore((s) => s.activate)
|
const activate = useEntitlementStore((s) => s.activate)
|
||||||
|
const setEmail = useEntitlementStore((s) => s.setEmail)
|
||||||
const clear = useEntitlementStore((s) => s.clear)
|
const clear = useEntitlementStore((s) => s.clear)
|
||||||
const hydrate = useEntitlementStore((s) => s.hydrate)
|
const hydrate = useEntitlementStore((s) => s.hydrate)
|
||||||
|
|
||||||
|
const [emailInput, setEmailInput] = useState(email)
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<SelectablePlan>(
|
||||||
|
plan === 'pro' ? (planType === 'yearly' ? 'yearly' : 'perpetual') : 'free'
|
||||||
|
)
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
||||||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||||||
const [licenseInput, setLicenseInput] = useState('')
|
const [licenseInput, setLicenseInput] = useState('')
|
||||||
@@ -49,25 +83,44 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
|||||||
void hydrate()
|
void hydrate()
|
||||||
}, [hydrate])
|
}, [hydrate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEmailInput(email)
|
||||||
|
}, [email])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (plan === 'pro') {
|
||||||
|
setSelectedPlan(planType === 'yearly' ? 'yearly' : 'perpetual')
|
||||||
|
} else {
|
||||||
|
setSelectedPlan('free')
|
||||||
|
}
|
||||||
|
}, [plan, planType])
|
||||||
|
|
||||||
const highlight = feature ? proFeatureLabel(feature) : null
|
const highlight = feature ? proFeatureLabel(feature) : null
|
||||||
|
|
||||||
const startCheckout = useCallback(async () => {
|
const startCheckout = useCallback(async (planChoice: 'yearly' | 'perpetual') => {
|
||||||
|
setSelectedPlan(planChoice)
|
||||||
setCheckoutLoading(true)
|
setCheckoutLoading(true)
|
||||||
setCheckoutError(null)
|
setCheckoutError(null)
|
||||||
|
const trimmedEmail = emailInput.trim()
|
||||||
|
if (!trimmedEmail || !trimmedEmail.includes('@')) {
|
||||||
|
setCheckoutError(t('pro.emailRequired'))
|
||||||
|
setCheckoutLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
await setEmail(trimmedEmail)
|
||||||
const id =
|
const id =
|
||||||
deviceId ||
|
deviceId ||
|
||||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||||
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
|
const result = await licenseCheckout({
|
||||||
method: 'POST',
|
customerEmail: trimmedEmail,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
deviceId: id,
|
||||||
body: JSON.stringify({ deviceId: id }),
|
planType: planChoice,
|
||||||
})
|
})
|
||||||
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
|
if (!result.checkoutUrl) {
|
||||||
if (!res.ok || !data.checkoutUrl) {
|
throw new Error(t('pro.checkoutUnavailable', { status: 'no-url' }))
|
||||||
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
|
|
||||||
}
|
}
|
||||||
await window.opsxApi.openExternal(data.checkoutUrl)
|
await window.opsxApi.openExternal(result.checkoutUrl)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
logger.warn('checkout failed', message)
|
logger.warn('checkout failed', message)
|
||||||
@@ -75,19 +128,36 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoading(false)
|
setCheckoutLoading(false)
|
||||||
}
|
}
|
||||||
}, [deviceId, t])
|
}, [deviceId, emailInput, setEmail, t])
|
||||||
|
|
||||||
const handleActivate = useCallback(async () => {
|
const handleActivate = useCallback(async () => {
|
||||||
setActivateError(null)
|
setActivateError(null)
|
||||||
setActivateOk(false)
|
setActivateOk(false)
|
||||||
const result = await activate(licenseInput)
|
if (!looksLikeValidLicenseKey(licenseInput)) {
|
||||||
|
setActivateError(t('pro.invalidKeyFormat'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trimmedEmail = emailInput.trim()
|
||||||
|
if (trimmedEmail) await setEmail(trimmedEmail)
|
||||||
|
const result = await activate(licenseInput, trimmedEmail || undefined)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setActivateOk(true)
|
setActivateOk(true)
|
||||||
setLicenseInput('')
|
setLicenseInput('')
|
||||||
} else {
|
} else {
|
||||||
setActivateError(result.error || t('pro.activateFailed'))
|
setActivateError(result.error || t('pro.activateFailed'))
|
||||||
}
|
}
|
||||||
}, [activate, licenseInput, t])
|
}, [activate, emailInput, licenseInput, setEmail, t])
|
||||||
|
|
||||||
|
const cardClass = (id: SelectablePlan, accent = false) => {
|
||||||
|
const selected = selectedPlan === id
|
||||||
|
return `flex flex-col rounded-xl border px-4 py-4 text-left transition-colors ${
|
||||||
|
selected
|
||||||
|
? accent
|
||||||
|
? 'border-accent-blue bg-accent-blue/10'
|
||||||
|
: 'border-dark-500 bg-dark-800/60'
|
||||||
|
: 'border-dark-700/60 bg-dark-800/40 hover:border-dark-500'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
||||||
@@ -104,72 +174,173 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
|||||||
{comingSoon && highlight
|
{comingSoon && highlight
|
||||||
? t('pro.subtitleComingSoon', { feature: highlight })
|
? t('pro.subtitleComingSoon', { feature: highlight })
|
||||||
: highlight
|
: highlight
|
||||||
? t('pro.subtitleUnlock', { feature: highlight })
|
? t('pro.subtitleUnlock')
|
||||||
: t('pro.subtitleDefault')}
|
: t('pro.subtitleDefault')}
|
||||||
</p>
|
</p>
|
||||||
{plan === 'pro' && (
|
{highlight && !comingSoon ? (
|
||||||
|
<p className="mt-1 text-xs text-txt-faint">{t('pro.subtitleUnlockDetail', { feature: highlight })}</p>
|
||||||
|
) : null}
|
||||||
|
{(plan === 'pro' || email) && (
|
||||||
<p className="mt-2 text-xs text-accent-green">
|
<p className="mt-2 text-xs text-accent-green">
|
||||||
{t('pro.activated')}
|
{plan === 'pro' ? activatedLabel(t, planType) : t('pro.accountEmail', { email })}
|
||||||
{expiresAt
|
{plan === 'pro' && email ? ` · ${email}` : null}
|
||||||
|
{plan === 'pro'
|
||||||
|
? expiresAt
|
||||||
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
||||||
: t('pro.lifetime')}
|
: t('pro.lifetime')
|
||||||
|
: null}
|
||||||
|
{plan === 'pro' && maxDevices
|
||||||
|
? ` · ${t('pro.devices', { count: maxDevices })}`
|
||||||
|
: null}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mb-8 grid gap-4 sm:grid-cols-2">
|
<p className="mb-3 text-center text-sm text-txt-muted">{t('pro.choosePlan')}</p>
|
||||||
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
|
|
||||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">{t('pro.freeTier')}</div>
|
{plan !== 'pro' && (
|
||||||
<ul className="space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
|
<label className="ui-label" htmlFor="license-email">
|
||||||
|
{t('pro.emailLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="license-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => setEmailInput(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-content-primary outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
{checkoutError && (
|
||||||
|
<p className="text-xs text-red-400">{checkoutError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
{/* Free */}
|
||||||
|
<div className={cardClass('free')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left"
|
||||||
|
onClick={() => setSelectedPlan('free')}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-txt-faint">
|
||||||
|
{t('pro.planFree')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-content-primary">{t('pro.planFreePrice')}</div>
|
||||||
|
<div className="mt-1 text-xs text-txt-muted">{t('pro.planFreeDesc')}</div>
|
||||||
|
</button>
|
||||||
|
<ul className="mt-3 flex-1 space-y-1.5">
|
||||||
{FREE_ITEM_KEYS.map((key) => (
|
{FREE_ITEM_KEYS.map((key) => (
|
||||||
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
|
<li key={key} className="flex items-start gap-1.5 text-xs text-content-secondary">
|
||||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
|
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-txt-muted" />
|
||||||
{t(`pro.freeItems.${key}`)}
|
{t(`pro.freeItems.${key}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-4 rounded-lg border border-dark-600 px-3 py-2 text-center text-xs text-txt-muted">
|
||||||
|
{plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
|
</div>
|
||||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
|
|
||||||
<ul className="space-y-2">
|
{/* Yearly */}
|
||||||
|
<div className={cardClass('yearly', true)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left"
|
||||||
|
onClick={() => setSelectedPlan('yearly')}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
|
||||||
|
{t('pro.planYearly')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-content-primary">¥99</div>
|
||||||
|
<div className="mt-1 text-xs text-txt-muted">{t('pro.planYearlyDesc')}</div>
|
||||||
|
</button>
|
||||||
|
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
|
||||||
|
<ul className="mt-2 flex-1 space-y-1.5">
|
||||||
{PRO_ITEM_KEYS.map((key) => (
|
{PRO_ITEM_KEYS.map((key) => (
|
||||||
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
|
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
|
||||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
|
||||||
{t(`pro.proItems.${key}`)}
|
{t(`pro.proItems.${key}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{plan !== 'pro' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={checkoutLoading}
|
||||||
|
onClick={() => void startCheckout('yearly')}
|
||||||
|
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{checkoutLoading && selectedPlan === 'yearly' ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t('pro.goUpgradeYearly')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plan === 'pro' && planType === 'yearly' && (
|
||||||
|
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
|
||||||
|
{t('pro.activatedYearly')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Perpetual */}
|
||||||
|
<div className={cardClass('perpetual', true)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left"
|
||||||
|
onClick={() => setSelectedPlan('perpetual')}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
|
||||||
|
{t('pro.planPerpetual')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-content-primary">¥199</div>
|
||||||
|
<div className="mt-1 text-xs text-txt-muted">{t('pro.planPerpetualDesc')}</div>
|
||||||
|
</button>
|
||||||
|
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
|
||||||
|
<ul className="mt-2 flex-1 space-y-1.5">
|
||||||
|
{PRO_ITEM_KEYS.map((key) => (
|
||||||
|
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
|
||||||
|
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
|
||||||
|
{t(`pro.proItems.${key}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{plan !== 'pro' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={checkoutLoading}
|
||||||
|
onClick={() => void startCheckout('perpetual')}
|
||||||
|
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{checkoutLoading && selectedPlan === 'perpetual' ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t('pro.goUpgradePerpetual')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plan === 'pro' && (planType === 'perpetual' || planType === 'lifetime' || (!planType && !expiresAt)) && (
|
||||||
|
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
|
||||||
|
{t('pro.activatedPerpetual')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plan !== 'pro' && (
|
{plan !== 'pro' && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 text-center text-sm text-txt-muted">
|
|
||||||
{t('pro.oneTimeFee')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={checkoutLoading}
|
|
||||||
onClick={() => void startCheckout()}
|
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{checkoutLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t('pro.goUpgrade')}
|
|
||||||
</button>
|
|
||||||
{checkoutError && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowActivate((v) => !v)}
|
onClick={() => setShowActivate((v) => !v)}
|
||||||
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
|
className="mt-1 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
|
||||||
>
|
>
|
||||||
{t('pro.haveLicense')}
|
{t('pro.haveLicense')}
|
||||||
</button>
|
</button>
|
||||||
@@ -185,7 +356,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
|||||||
onChange={(e) => setLicenseInput(e.target.value)}
|
onChange={(e) => setLicenseInput(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder="opsx1...."
|
placeholder="LIC-… / LICOFF-…"
|
||||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function UpgradeWall() {
|
|||||||
if (!wall.open) return null
|
if (!wall.open) return null
|
||||||
|
|
||||||
const feature = wall.feature as ProFeature | undefined
|
const feature = wall.feature as ProFeature | undefined
|
||||||
const label = feature ? proFeatureLabel(feature) : t('pro.feature')
|
const label = feature ? proFeatureLabel(feature) : null
|
||||||
const comingSoon = wall.comingSoon
|
const comingSoon = wall.comingSoon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,11 +48,17 @@ export default function UpgradeWall() {
|
|||||||
<Crown className="h-5 w-5" />
|
<Crown className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
|
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
|
||||||
{comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })}
|
{comingSoon ? t('pro.wallUnlockComingSoon') : t('pro.wallUnlock')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
|
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
|
||||||
{wall.message ||
|
{wall.message ||
|
||||||
(comingSoon ? t('pro.wallMessageComingSoon') : t('pro.wallMessageDefault'))}
|
(comingSoon
|
||||||
|
? label
|
||||||
|
? t('pro.wallMessageComingSoonFeature', { feature: label })
|
||||||
|
: t('pro.wallMessageComingSoon')
|
||||||
|
: label
|
||||||
|
? t('pro.wallMessageFeature', { feature: label })
|
||||||
|
: t('pro.wallMessageDefault'))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Cable, RefreshCw } from 'lucide-react'
|
|||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
|
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
|
||||||
|
import { safeTerminalDispose, safeTerminalFit } from '../../utils/safeTerminalFit'
|
||||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||||
@@ -72,7 +73,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
|||||||
terminal.open(container)
|
terminal.open(container)
|
||||||
terminalRef.current = terminal
|
terminalRef.current = terminal
|
||||||
fitRef.current = fit
|
fitRef.current = fit
|
||||||
fit.fit()
|
safeTerminalFit(fit, terminal)
|
||||||
|
|
||||||
const pipeline = new TerminalPipeline({
|
const pipeline = new TerminalPipeline({
|
||||||
input: async (data) => {
|
input: async (data) => {
|
||||||
@@ -109,7 +110,9 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
|||||||
}
|
}
|
||||||
container.addEventListener('keydown', handleKeyDown)
|
container.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
const handleResize = () => fit.fit()
|
const handleResize = () => {
|
||||||
|
safeTerminalFit(fit, terminal)
|
||||||
|
}
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
updateTabStatus(tabId, 'connecting')
|
updateTabStatus(tabId, 'connecting')
|
||||||
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
|
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
|
||||||
@@ -167,15 +170,15 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
|||||||
const sessionId = sessionIdRef.current
|
const sessionId = sessionIdRef.current
|
||||||
sessionIdRef.current = null
|
sessionIdRef.current = null
|
||||||
if (sessionId) void closeSerial(sessionId)
|
if (sessionId) void closeSerial(sessionId)
|
||||||
terminal.dispose()
|
|
||||||
terminalRef.current = null
|
|
||||||
fitRef.current = null
|
fitRef.current = null
|
||||||
|
terminalRef.current = null
|
||||||
|
safeTerminalDispose(terminal)
|
||||||
}
|
}
|
||||||
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
|
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
fitRef.current?.fit()
|
safeTerminalFit(fitRef.current, terminalRef.current)
|
||||||
terminalRef.current?.focus()
|
terminalRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [isActive])
|
}, [isActive])
|
||||||
@@ -189,7 +192,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
|||||||
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
|
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
|
||||||
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
|
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
|
<div ref={containerRef} className="terminal-surface min-h-0 flex-1 p-1" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}, [timeout, updateResult])
|
}, [timeout, updateResult, t])
|
||||||
|
|
||||||
const execute = useCallback(async () => {
|
const execute = useCallback(async () => {
|
||||||
const commandText = command.trim()
|
const commandText = command.trim()
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[tabId, timeout, updateResult]
|
[tabId, timeout, updateResult, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const execute = useCallback(async () => {
|
const execute = useCallback(async () => {
|
||||||
|
|||||||
@@ -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 { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
import { Cable, Cast, Monitor, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
type AvailableLocalShell,
|
type AvailableLocalShell,
|
||||||
} from '../../platform/localTerminal'
|
} from '../../platform/localTerminal'
|
||||||
import { fallbackLocalShells } from '../../utils/platformShells'
|
import { fallbackLocalShells } from '../../utils/platformShells'
|
||||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
import {
|
||||||
|
buildOpenTabPayload,
|
||||||
|
defaultPortForProtocol,
|
||||||
|
sessionProtocol,
|
||||||
|
} from '../../utils/sessionProtocol'
|
||||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||||
|
|
||||||
interface QuickLauncherProps {
|
interface QuickLauncherProps {
|
||||||
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
|
|||||||
onNewSession: () => void
|
onNewSession: () => void
|
||||||
onQuickConnect: () => void
|
onQuickConnect: () => void
|
||||||
onSerialConnect: () => void
|
onSerialConnect: () => void
|
||||||
|
onRdpConnect?: () => void
|
||||||
|
onVncConnect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuickLauncher({
|
export default function QuickLauncher({
|
||||||
@@ -26,6 +32,8 @@ export default function QuickLauncher({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onQuickConnect,
|
onQuickConnect,
|
||||||
onSerialConnect,
|
onSerialConnect,
|
||||||
|
onRdpConnect,
|
||||||
|
onVncConnect,
|
||||||
}: QuickLauncherProps) {
|
}: QuickLauncherProps) {
|
||||||
const { t } = useTranslation(['common', 'sessions'])
|
const { t } = useTranslation(['common', 'sessions'])
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -86,7 +94,7 @@ export default function QuickLauncher({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||||
<LogIn className="w-4 h-4 text-accent-blue" />
|
<LogIn className="w-4 h-4 text-accent-blue" />
|
||||||
<span>
|
<span>
|
||||||
@@ -95,7 +103,7 @@ export default function QuickLauncher({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||||
<Plus className="w-4 h-4 text-accent-green" />
|
<Plus className="w-4 h-4 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||||
<span>
|
<span>
|
||||||
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
|
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
|
||||||
<small>{t('common:launcher.cards.saveSession.desc')}</small>
|
<small>{t('common:launcher.cards.saveSession.desc')}</small>
|
||||||
@@ -108,6 +116,42 @@ export default function QuickLauncher({
|
|||||||
<small>{t('common:launcher.cards.serial.desc')}</small>
|
<small>{t('common:launcher.cards.serial.desc')}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{onRdpConnect && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
run(() => {
|
||||||
|
if (!requirePro('rdp')) return
|
||||||
|
onRdpConnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="launcher-card"
|
||||||
|
>
|
||||||
|
<Monitor className="w-4 h-4 text-accent-blue" />
|
||||||
|
<span>
|
||||||
|
<strong>{t('common:launcher.cards.rdp.title')}</strong>
|
||||||
|
<small>{t('common:launcher.cards.rdp.desc')}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onVncConnect && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
run(() => {
|
||||||
|
if (!requirePro('vnc')) return
|
||||||
|
onVncConnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="launcher-card"
|
||||||
|
>
|
||||||
|
<Cast className="w-4 h-4 text-accent-blue" />
|
||||||
|
<span>
|
||||||
|
<strong>{t('common:launcher.cards.vnc.title')}</strong>
|
||||||
|
<small>{t('common:launcher.cards.vnc.desc')}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -131,7 +175,7 @@ export default function QuickLauncher({
|
|||||||
}))}
|
}))}
|
||||||
className="launcher-row"
|
className="launcher-row"
|
||||||
>
|
>
|
||||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
<Terminal className="w-4 h-4 text-accent-green" />
|
||||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||||
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
|
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
|
||||||
</button>
|
</button>
|
||||||
@@ -161,7 +205,10 @@ export default function QuickLauncher({
|
|||||||
<Server className="w-4 h-4 text-accent-blue" />
|
<Server className="w-4 h-4 text-accent-blue" />
|
||||||
<span className="flex-1 min-w-0 text-left">
|
<span className="flex-1 min-w-0 text-left">
|
||||||
<strong className="truncate">{session.name}</strong>
|
<strong className="truncate">{session.name}</strong>
|
||||||
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
|
<small className="truncate">
|
||||||
|
{session.username}@{session.host}:
|
||||||
|
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="launcher-badge">{session.group || 'default'}</span>
|
<span className="launcher-badge">{session.group || 'default'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
} from '../../utils/sessionGroups'
|
} from '../../utils/sessionGroups'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { usePromptDialog } from '../common/PromptDialog'
|
import { usePromptDialog } from '../common/PromptDialog'
|
||||||
|
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
||||||
|
import { getDisplayResolution } from '../../utils/desktopResolution'
|
||||||
|
|
||||||
const logger = createLogger('SessionDialog')
|
const logger = createLogger('SessionDialog')
|
||||||
|
|
||||||
@@ -152,14 +154,17 @@ export default function SessionDialog({
|
|||||||
: [],
|
: [],
|
||||||
domain: editSession.domain || '',
|
domain: editSession.domain || '',
|
||||||
security: editSession.security || 'nla',
|
security: editSession.security || 'nla',
|
||||||
width: editSession.width || 1920,
|
width: editSession.width || getDisplayResolution().width,
|
||||||
height: editSession.height || 1080,
|
height: editSession.height || getDisplayResolution().height,
|
||||||
})
|
})
|
||||||
setShowAdvanced(hasAdvanced)
|
setShowAdvanced(hasAdvanced)
|
||||||
} else {
|
} else {
|
||||||
|
const display = getDisplayResolution()
|
||||||
setFormData({
|
setFormData({
|
||||||
...emptyForm,
|
...emptyForm,
|
||||||
group: normalizeGroupId(defaultGroup),
|
group: normalizeGroupId(defaultGroup),
|
||||||
|
width: display.width,
|
||||||
|
height: display.height,
|
||||||
})
|
})
|
||||||
setTarget('')
|
setTarget('')
|
||||||
setShowAdvanced(false)
|
setShowAdvanced(false)
|
||||||
@@ -185,12 +190,15 @@ export default function SessionDialog({
|
|||||||
prev.port === defaultPortForProtocol(prev.protocol)
|
prev.port === defaultPortForProtocol(prev.protocol)
|
||||||
? defaultPortForProtocol(protocol)
|
? defaultPortForProtocol(protocol)
|
||||||
: prev.port
|
: prev.port
|
||||||
|
const display = getDisplayResolution()
|
||||||
|
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
protocol,
|
protocol,
|
||||||
username: nextUser,
|
username: nextUser,
|
||||||
port: nextPort,
|
port: nextPort,
|
||||||
authType: 'password',
|
authType: 'password',
|
||||||
|
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Keep host-only target when switching protocol.
|
// Keep host-only target when switching protocol.
|
||||||
@@ -394,6 +402,50 @@ export default function SessionDialog({
|
|||||||
{ value: 'key', label: t('dialog.authKey'), icon: Key },
|
{ value: 'key', label: t('dialog.authKey'), icon: Key },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const groupField = (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="session-group" className="ui-label">
|
||||||
|
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||||
|
{t('dialog.group')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="session-group"
|
||||||
|
value={normalizeGroupId(formData.group)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
if (value !== '__new__') {
|
||||||
|
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
const raw = await prompt({
|
||||||
|
title: t('groups.new.title'),
|
||||||
|
message: t('groups.new.message'),
|
||||||
|
placeholder: t('groups.new.placeholder'),
|
||||||
|
confirmText: t('groups.new.confirm'),
|
||||||
|
})
|
||||||
|
if (raw == null) return
|
||||||
|
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
className="ui-input"
|
||||||
|
>
|
||||||
|
{knownGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{groupDisplayName(group)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||||
|
<option value={normalizeGroupId(formData.group)}>
|
||||||
|
{groupDisplayName(formData.group)}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
<option value="__new__">{t('groups.newOption')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[11px] text-txt-faint">{t('groups.hint')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||||
@@ -549,73 +601,30 @@ export default function SessionDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formData.protocol !== 'ssh' && (
|
{formData.protocol === 'rdp' && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
|
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
||||||
<input
|
<input
|
||||||
id="session-width"
|
id="session-password"
|
||||||
type="number"
|
type="password"
|
||||||
value={formData.width}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
className="ui-input font-mono"
|
placeholder="••••••••"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
|
|
||||||
<input
|
|
||||||
id="session-height"
|
|
||||||
type="number"
|
|
||||||
value={formData.height}
|
|
||||||
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
|
|
||||||
className="ui-input font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="session-group" className="ui-label">
|
|
||||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
|
||||||
{t('dialog.group')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="session-group"
|
|
||||||
value={normalizeGroupId(formData.group)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
if (value !== '__new__') {
|
|
||||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void (async () => {
|
|
||||||
const raw = await prompt({
|
|
||||||
title: t('groups.new.title'),
|
|
||||||
message: t('groups.new.message'),
|
|
||||||
placeholder: t('groups.new.placeholder'),
|
|
||||||
confirmText: t('groups.new.confirm'),
|
|
||||||
})
|
|
||||||
if (raw == null) return
|
|
||||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
|
||||||
})()
|
|
||||||
}}
|
|
||||||
className="ui-input"
|
className="ui-input"
|
||||||
>
|
autoComplete="current-password"
|
||||||
{knownGroups.map((group) => (
|
/>
|
||||||
<option key={group} value={group}>
|
|
||||||
{groupDisplayName(group)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
|
||||||
<option value={normalizeGroupId(formData.group)}>
|
|
||||||
{groupDisplayName(formData.group)}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
<option value="__new__">{t('groups.newOption')}</option>
|
|
||||||
</select>
|
|
||||||
<p className="mt-1 text-[11px] text-txt-faint">
|
|
||||||
{t('groups.hint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{formData.protocol !== 'ssh' && (
|
||||||
|
<DesktopResolutionPicker
|
||||||
|
ns="sessions"
|
||||||
|
width={formData.width}
|
||||||
|
height={formData.height}
|
||||||
|
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
|
||||||
|
{formData.protocol === 'rdp' && groupField}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 凭据 */}
|
{/* 凭据 */}
|
||||||
@@ -647,7 +656,7 @@ export default function SessionDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(formData.authType === 'password' || !isSsh) && (
|
{(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
|
||||||
@@ -774,6 +783,7 @@ export default function SessionDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(isSsh || formData.protocol === 'vnc') && groupField}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 高级 */}
|
{/* 高级 */}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ 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 { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
import {
|
||||||
|
buildOpenTabPayload,
|
||||||
|
defaultPortForProtocol,
|
||||||
|
sessionProtocol,
|
||||||
|
} from '../../utils/sessionProtocol'
|
||||||
|
|
||||||
interface WelcomeViewProps {
|
interface WelcomeViewProps {
|
||||||
onNewSession?: () => void
|
onNewSession?: () => void
|
||||||
@@ -273,7 +277,8 @@ export default function WelcomeView({
|
|||||||
<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>
|
||||||
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
||||||
{session.username}@{session.host}:{session.port || 22}
|
{session.username}@{session.host}:
|
||||||
|
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{session.group ? (
|
{session.group ? (
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* AccountSettings — license email, plan status, activate / devices / clear
|
||||||
|
* (standalone settings nav tab, not under General)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||||
|
import {
|
||||||
|
deactivateLicenseDevice,
|
||||||
|
listLicenseDevices,
|
||||||
|
type LicenseDevice,
|
||||||
|
} from '../../platform/licenseApi'
|
||||||
|
|
||||||
|
function looksLikeValidLicenseKey(value: string): boolean {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
|
||||||
|
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
function planLabel(
|
||||||
|
t: (key: string) => string,
|
||||||
|
plan: 'free' | 'pro',
|
||||||
|
planType: string | null
|
||||||
|
): string {
|
||||||
|
if (plan !== 'pro') return t('account.planFree')
|
||||||
|
switch (planType) {
|
||||||
|
case 'yearly':
|
||||||
|
return t('account.planYearly')
|
||||||
|
case 'trial':
|
||||||
|
return t('account.planTrial')
|
||||||
|
case 'perpetual':
|
||||||
|
case 'lifetime':
|
||||||
|
return t('account.planPerpetual')
|
||||||
|
default:
|
||||||
|
return t('account.planPerpetual')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountSettings() {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const { t: tc } = useTranslation('common')
|
||||||
|
const plan = useEntitlementStore((s) => s.plan)
|
||||||
|
const email = useEntitlementStore((s) => s.email)
|
||||||
|
const deviceId = useEntitlementStore((s) => s.deviceId)
|
||||||
|
const licenseKey = useEntitlementStore((s) => s.licenseKey)
|
||||||
|
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
||||||
|
const maxDevices = useEntitlementStore((s) => s.maxDevices)
|
||||||
|
const planType = useEntitlementStore((s) => s.planType)
|
||||||
|
const hydrate = useEntitlementStore((s) => s.hydrate)
|
||||||
|
const setEmail = useEntitlementStore((s) => s.setEmail)
|
||||||
|
const activate = useEntitlementStore((s) => s.activate)
|
||||||
|
const clear = useEntitlementStore((s) => s.clear)
|
||||||
|
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||||
|
|
||||||
|
const [emailInput, setEmailInput] = useState(email)
|
||||||
|
const [licenseInput, setLicenseInput] = useState('')
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [devices, setDevices] = useState<LicenseDevice[]>([])
|
||||||
|
const [deviceUsed, setDeviceUsed] = useState(0)
|
||||||
|
const [deviceMax, setDeviceMax] = useState(0)
|
||||||
|
const [devicesLoading, setDevicesLoading] = useState(false)
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrate()
|
||||||
|
}, [hydrate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEmailInput(email)
|
||||||
|
}, [email])
|
||||||
|
|
||||||
|
const refreshDevices = useCallback(async () => {
|
||||||
|
if (plan !== 'pro' || !licenseKey) {
|
||||||
|
setDevices([])
|
||||||
|
setDeviceUsed(0)
|
||||||
|
setDeviceMax(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDevicesLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await listLicenseDevices(licenseKey)
|
||||||
|
setDevices(
|
||||||
|
result.devices.map((d) => ({
|
||||||
|
...d,
|
||||||
|
isCurrent: Boolean(deviceId && d.deviceId === deviceId),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setDeviceUsed(result.used)
|
||||||
|
setDeviceMax(result.maxDevices || maxDevices || 0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setDevicesLoading(false)
|
||||||
|
}
|
||||||
|
}, [deviceId, licenseKey, maxDevices, plan])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshDevices()
|
||||||
|
}, [refreshDevices])
|
||||||
|
|
||||||
|
const saveEmail = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await setEmail(emailInput.trim())
|
||||||
|
setMessage(t('account.saved'))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [emailInput, setEmail, t])
|
||||||
|
|
||||||
|
const handleActivate = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
if (!looksLikeValidLicenseKey(licenseInput)) {
|
||||||
|
setError(tc('pro.invalidKeyFormat'))
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (emailInput.trim()) await setEmail(emailInput.trim())
|
||||||
|
const result = await activate(licenseInput, emailInput.trim() || undefined)
|
||||||
|
if (result.success) {
|
||||||
|
setMessage(t('account.activateOk'))
|
||||||
|
setLicenseInput('')
|
||||||
|
await refreshDevices()
|
||||||
|
} else {
|
||||||
|
setError(result.error || tc('pro.activateFailed'))
|
||||||
|
}
|
||||||
|
setBusy(false)
|
||||||
|
}, [activate, emailInput, licenseInput, refreshDevices, setEmail, t, tc])
|
||||||
|
|
||||||
|
const handleDeactivate = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
await clear()
|
||||||
|
setDevices([])
|
||||||
|
setMessage(t('account.saved'))
|
||||||
|
setBusy(false)
|
||||||
|
}, [clear, t])
|
||||||
|
|
||||||
|
const handleRemoveDevice = useCallback(
|
||||||
|
async (target: LicenseDevice) => {
|
||||||
|
if (!licenseKey || target.isCurrent) return
|
||||||
|
setRemovingId(target.deviceId)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await deactivateLicenseDevice(licenseKey, target.deviceId)
|
||||||
|
setMessage(t('account.deviceRemoved'))
|
||||||
|
await refreshDevices()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[licenseKey, refreshDevices, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const atSeatLimit = deviceMax > 0 && deviceUsed >= deviceMax
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.title')}</h3>
|
||||||
|
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||||
|
<Row
|
||||||
|
label={t('account.plan')}
|
||||||
|
description={
|
||||||
|
plan === 'pro'
|
||||||
|
? expiresAt
|
||||||
|
? t('account.expires', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
||||||
|
: t('account.lifetime')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-content-primary">
|
||||||
|
{planLabel(t, plan, planType)}
|
||||||
|
{maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row label={t('account.email')} description={t('account.emailDesc')}>
|
||||||
|
<div className="flex min-w-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => setEmailInput(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-56 rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-content-primary outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void saveEmail()}
|
||||||
|
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-content-primary hover:bg-dark-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('account.saveEmail')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row label={t('account.deviceId')}>
|
||||||
|
<code className="max-w-[14rem] truncate text-xs text-txt-muted" title={deviceId || '—'}>
|
||||||
|
{deviceId || '—'}
|
||||||
|
</code>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.activate')}</h3>
|
||||||
|
<div className="border-dark-700/45 bg-dark-800/50 space-y-3 overflow-hidden rounded-2xl border p-4">
|
||||||
|
<textarea
|
||||||
|
value={licenseInput}
|
||||||
|
onChange={(e) => setLicenseInput(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={t('account.pasteLicense')}
|
||||||
|
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !licenseInput.trim()}
|
||||||
|
onClick={() => void handleActivate()}
|
||||||
|
className="rounded-lg bg-accent-blue px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('account.activate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => openUpgradeTab()}
|
||||||
|
className="rounded-lg border border-dark-600 px-3 py-1.5 text-xs text-content-secondary hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
{t('account.upgrade')}
|
||||||
|
</button>
|
||||||
|
{plan === 'pro' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleDeactivate()}
|
||||||
|
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
{t('account.deactivate')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{message && <p className="text-xs text-accent-green">{message}</p>}
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan === 'pro' && licenseKey ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">
|
||||||
|
{t('account.deviceList')}
|
||||||
|
{deviceMax > 0 ? ` · ${deviceUsed}/${deviceMax}` : ''}
|
||||||
|
</h3>
|
||||||
|
<div className="border-dark-700/45 bg-dark-800/50 overflow-hidden rounded-2xl border">
|
||||||
|
{atSeatLimit ? (
|
||||||
|
<p className="border-b border-dark-700/35 px-4 py-2.5 text-xs text-feedback-warning">
|
||||||
|
{t('account.seatFull')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{devicesLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-6 text-xs text-txt-muted">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t('account.loadingDevices')}
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-xs text-txt-muted">{t('account.noDevices')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-dark-700/35 divide-y">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.deviceId || String(d.id)}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-content-primary">
|
||||||
|
{d.deviceName || 'OpsXShell'}
|
||||||
|
{d.isCurrent ? (
|
||||||
|
<span className="ml-2 text-xs text-accent-blue">
|
||||||
|
{t('account.thisDevice')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<code className="mt-0.5 block truncate text-[11px] text-txt-muted">
|
||||||
|
{d.deviceId}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{!d.isCurrent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={removingId === d.deviceId}
|
||||||
|
onClick={() => void handleRemoveDevice(d)}
|
||||||
|
className="rounded-lg border border-red-500/35 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{removingId === d.deviceId ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t('account.removeDevice')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
|
||||||
|
<div className="min-w-[11rem] flex-1 basis-[14rem]">
|
||||||
|
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||||
|
{description ? <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -202,7 +202,7 @@ export default function AiProviderSettings() {
|
|||||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||||
{presetsByFamily(familyId).map((preset) => (
|
{presetsByFamily(familyId).map((preset) => (
|
||||||
<option key={preset.id} value={preset.id}>
|
<option key={preset.id} value={preset.id}>
|
||||||
{preset.name}
|
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
|
|||||||
import type { AppLanguage } from '../../i18n/detectLocale'
|
import type { AppLanguage } from '../../i18n/detectLocale'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
Monitor,
|
||||||
Palette,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -18,13 +17,16 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
UserRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AiProviderSettings from './AiProviderSettings'
|
import AiProviderSettings from './AiProviderSettings'
|
||||||
|
import AccountSettings from './AccountSettings'
|
||||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||||
|
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
|
||||||
|
|
||||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||||
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||||
|
|
||||||
interface TabMeta {
|
interface TabMeta {
|
||||||
id: SettingsTab
|
id: SettingsTab
|
||||||
@@ -41,11 +43,12 @@ interface RuntimeInfo {
|
|||||||
sshBackend: string
|
sshBackend: string
|
||||||
dataPath: string
|
dataPath: string
|
||||||
credentialPath: string
|
credentialPath: string
|
||||||
|
logPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_LIST: TabMeta[] = [
|
const TAB_LIST: TabMeta[] = [
|
||||||
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
|
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
|
||||||
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
|
{ id: 'account', labelKey: 'nav.account', descriptionKey: 'nav.accountDesc', icon: UserRound },
|
||||||
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
|
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
|
||||||
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
|
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
|
||||||
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
|
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
|
||||||
@@ -148,25 +151,13 @@ export default function SettingsView() {
|
|||||||
// 当前 tab 元信息
|
// 当前 tab 元信息
|
||||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||||
|
|
||||||
// ── 搜索过滤 ──────────────────────────────────────────────
|
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
|
||||||
const filtered = useMemo((): SettingsTab[] | null => {
|
const filtered = useMemo((): SettingsTab[] | null => {
|
||||||
if (!searchQuery.trim()) return null
|
return filterSettingsTabsByQuery(
|
||||||
const q = searchQuery.toLowerCase()
|
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
|
||||||
const keywords: Record<SettingsTab, string[]> = {
|
searchQuery
|
||||||
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
|
)
|
||||||
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
|
}, [searchQuery])
|
||||||
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
|
|
||||||
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
|
|
||||||
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
|
|
||||||
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
|
|
||||||
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
|
|
||||||
about: [t('nav.about'), 'version', 'about', 'license'],
|
|
||||||
}
|
|
||||||
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
|
|
||||||
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
|
|
||||||
.map(([id]) => id)
|
|
||||||
return matchIds.length > 0 ? matchIds : []
|
|
||||||
}, [searchQuery, t])
|
|
||||||
|
|
||||||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,6 +173,7 @@ export default function SettingsView() {
|
|||||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||||
`Data: ${runtimeInfo.dataPath}`,
|
`Data: ${runtimeInfo.dataPath}`,
|
||||||
|
`Log: ${runtimeInfo.logPath}`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: `OpsXShell v${appVersion}`
|
: `OpsXShell v${appVersion}`
|
||||||
|
|
||||||
@@ -197,10 +189,39 @@ export default function SettingsView() {
|
|||||||
// ── 渲染内容区 ────────────────────────────────────────────
|
// ── 渲染内容区 ────────────────────────────────────────────
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
// ──────── 常规 ─────────
|
// ──────── 通用(主题 / 语言 / 工作区)─────────
|
||||||
case 'general':
|
case 'general':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SettingGroup title={t('appearance.themeGroup')}>
|
||||||
|
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
|
||||||
|
<select
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
||||||
|
className="ui-select-pill"
|
||||||
|
>
|
||||||
|
<option value="dark">{t('appearance.dark')}</option>
|
||||||
|
<option value="light">{t('appearance.light')}</option>
|
||||||
|
<option value="system">{t('appearance.system')}</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label={t('language.label')} description={t('language.description')}>
|
||||||
|
<select
|
||||||
|
value={settings.language}
|
||||||
|
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
|
||||||
|
className="ui-select-pill"
|
||||||
|
>
|
||||||
|
<option value="zh-CN">{t('language.zhCN')}</option>
|
||||||
|
<option value="zh-TW">{t('language.zhTW')}</option>
|
||||||
|
<option value="en">{t('language.en')}</option>
|
||||||
|
<option value="ja">{t('language.ja')}</option>
|
||||||
|
<option value="ko">{t('language.ko')}</option>
|
||||||
|
<option value="vi">{t('language.vi')}</option>
|
||||||
|
<option value="ru">{t('language.ru')}</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
<SettingGroup title={t('general.layout')}>
|
<SettingGroup title={t('general.layout')}>
|
||||||
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
|
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -238,40 +259,8 @@ export default function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ──────── 外观 ─────────
|
case 'account':
|
||||||
case 'appearance':
|
return <AccountSettings />
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<SettingGroup title={t('appearance.themeGroup')}>
|
|
||||||
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
|
|
||||||
<select
|
|
||||||
value={settings.theme}
|
|
||||||
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
|
||||||
className="ui-select-pill"
|
|
||||||
>
|
|
||||||
<option value="dark">{t('appearance.dark')}</option>
|
|
||||||
<option value="light">{t('appearance.light')}</option>
|
|
||||||
<option value="system">{t('appearance.system')}</option>
|
|
||||||
</select>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label={t('language.label')} description={t('language.description')}>
|
|
||||||
<select
|
|
||||||
value={settings.language}
|
|
||||||
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
|
|
||||||
className="ui-select-pill"
|
|
||||||
>
|
|
||||||
<option value="zh-CN">{t('language.zhCN')}</option>
|
|
||||||
<option value="zh-TW">{t('language.zhTW')}</option>
|
|
||||||
<option value="en">{t('language.en')}</option>
|
|
||||||
<option value="ja">{t('language.ja')}</option>
|
|
||||||
<option value="ko">{t('language.ko')}</option>
|
|
||||||
<option value="vi">{t('language.vi')}</option>
|
|
||||||
<option value="ru">{t('language.ru')}</option>
|
|
||||||
</select>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingGroup>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ──────── 终端 ─────────
|
// ──────── 终端 ─────────
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -520,6 +509,16 @@ export default function SettingsView() {
|
|||||||
checked={settings.keepAlive}
|
checked={settings.keepAlive}
|
||||||
onChange={(v) => setSetting('keepAlive', v)}
|
onChange={(v) => setSetting('keepAlive', v)}
|
||||||
/>
|
/>
|
||||||
|
<SettingRow label={t('ssh.keepAliveInterval')} description={t('ssh.keepAliveIntervalDesc')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={120}
|
||||||
|
value={settings.keepAliveIntervalSec}
|
||||||
|
onChange={(e) => setSetting('keepAliveIntervalSec', parseInt(e.target.value) || 15)}
|
||||||
|
className="ui-input-sm w-20 text-center"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label={t('ssh.autoReconnect')}
|
label={t('ssh.autoReconnect')}
|
||||||
description={t('ssh.autoReconnectDesc')}
|
description={t('ssh.autoReconnectDesc')}
|
||||||
@@ -572,10 +571,10 @@ export default function SettingsView() {
|
|||||||
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
|
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={5}
|
||||||
max={120}
|
max={240}
|
||||||
value={Math.round(settings.idleTimeoutMs / 60000)}
|
value={Math.round(settings.idleTimeoutMs / 60000)}
|
||||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
|
||||||
className="ui-input-sm w-20 text-center"
|
className="ui-input-sm w-20 text-center"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -662,6 +661,12 @@ export default function SettingsView() {
|
|||||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
|
||||||
|
<code className="block break-all text-content-secondary">
|
||||||
|
{runtimeInfo?.logPath ?? t('common:status.reading')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingCard>
|
</SettingCard>
|
||||||
@@ -711,9 +716,9 @@ export default function SettingsView() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
data-testid={`settings-tab-${tab.id}`}
|
data-testid={`settings-tab-${tab.id}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
|
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
|
||||||
>
|
>
|
||||||
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
|
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
|
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
|
||||||
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
|
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
|
|||||||
onClick={onHome}
|
onClick={onHome}
|
||||||
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
|
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
|
||||||
title={t('nav.home')}
|
title={t('nav.home')}
|
||||||
|
aria-label={t('nav.home')}
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4 text-txt-muted" />
|
<Home className="h-4 w-4 text-txt-muted" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNavigate(root || '/')}
|
onClick={() => onNavigate(root || '/')}
|
||||||
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-dark-200"
|
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-secondary"
|
||||||
|
title={rootLabel}
|
||||||
>
|
>
|
||||||
{rootLabel}
|
{rootLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ interface ConnectionPanelProps {
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
isConnecting: boolean
|
isConnecting: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** Last remote directory to resume after reconnect (shown as hint). */
|
||||||
|
resumePath?: string | null
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
showSelector: boolean
|
showSelector: boolean
|
||||||
onConnect: (sessionId: string) => void
|
onConnect: (sessionId: string) => void
|
||||||
|
/** Prefer same-session reconnect when the tab already knows sessionId. */
|
||||||
|
onReconnect?: () => void
|
||||||
onShowSelector: () => void
|
onShowSelector: () => void
|
||||||
onCloseSelector: () => void
|
onCloseSelector: () => void
|
||||||
}
|
}
|
||||||
@@ -24,13 +28,16 @@ export function ConnectionPanel({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
error,
|
error,
|
||||||
|
resumePath,
|
||||||
sessions,
|
sessions,
|
||||||
showSelector,
|
showSelector,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onReconnect,
|
||||||
onShowSelector,
|
onShowSelector,
|
||||||
onCloseSelector,
|
onCloseSelector,
|
||||||
}: ConnectionPanelProps) {
|
}: ConnectionPanelProps) {
|
||||||
const { t } = useTranslation('sftp')
|
const { t } = useTranslation('sftp')
|
||||||
|
const canResumePath = Boolean(resumePath)
|
||||||
|
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return null
|
return null
|
||||||
@@ -40,22 +47,36 @@ export function ConnectionPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
|
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
|
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
|
||||||
<span className="text-sm text-accent-blue">{t('connection.connecting')}</span>
|
<span className="text-sm text-accent-blue">
|
||||||
|
{canResumePath
|
||||||
|
? t('connection.reconnectingResume', { path: resumePath })
|
||||||
|
: t('connection.connecting')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-red-500/20 bg-red-500/10 px-4 py-2">
|
<div className="flex items-center justify-between gap-3 border-b border-red-500/20 bg-red-500/10 px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm text-red-400">{error}</span>
|
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-red-400 truncate">{error}</span>
|
||||||
|
</div>
|
||||||
|
{canResumePath && (
|
||||||
|
<span className="truncate pl-6 text-[11px] text-red-300/80" title={resumePath ?? undefined}>
|
||||||
|
{t('connection.resumeHint', { path: resumePath })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onShowSelector}
|
onClick={() => {
|
||||||
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
|
if (onReconnect) onReconnect()
|
||||||
|
else onShowSelector()
|
||||||
|
}}
|
||||||
|
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{t('connection.reconnect')}
|
{t('connection.reconnect')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Monitor, ChevronRight, X } from 'lucide-react'
|
import { Monitor, ChevronRight, X } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Session } from '../../stores/connection'
|
import type { Session } from '../../stores/connection'
|
||||||
|
import { isSshSession } from '../../utils/sessionProtocol'
|
||||||
|
|
||||||
interface ConnectionSelectorProps {
|
interface ConnectionSelectorProps {
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
@@ -14,6 +15,7 @@ interface ConnectionSelectorProps {
|
|||||||
|
|
||||||
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
|
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
|
||||||
const { t } = useTranslation('sftp')
|
const { t } = useTranslation('sftp')
|
||||||
|
const sshSessions = sessions.filter((session) => isSshSession(session))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
|
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
|
||||||
@@ -25,7 +27,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 max-h-80 overflow-y-auto">
|
<div className="p-4 max-h-80 overflow-y-auto">
|
||||||
{sessions.length === 0 ? (
|
{sshSessions.length === 0 ? (
|
||||||
<div className="text-center py-8 text-txt-muted">
|
<div className="text-center py-8 text-txt-muted">
|
||||||
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p>{t('connection.noSessions')}</p>
|
<p>{t('connection.noSessions')}</p>
|
||||||
@@ -33,7 +35,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sessions.map((session) => (
|
{sshSessions.map((session) => (
|
||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => onSelect(session.id)}
|
onClick={() => onSelect(session.id)}
|
||||||
@@ -41,12 +43,12 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
|||||||
>
|
>
|
||||||
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
|
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-slate-200 font-medium truncate group-hover:text-content-primary">{session.name}</div>
|
<div className="text-content-primary font-medium truncate group-hover:text-content-primary">{session.name}</div>
|
||||||
<div className="text-sm text-txt-muted truncate">
|
<div className="text-sm text-txt-muted truncate">
|
||||||
{session.username}@{session.host}:{session.port}
|
{session.username}@{session.host}:{session.port}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-5 h-5 text-slate-600 group-hover:text-txt-muted transition-colors" />
|
<ChevronRight className="w-5 h-5 text-txt-faint group-hover:text-txt-muted transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* ContextMenu - 右键菜单组件
|
* ContextMenu - 右键菜单组件
|
||||||
|
*
|
||||||
|
* Rendered via portal to document.body so parent overflow/transform cannot clip it.
|
||||||
|
* Position is fitted to the viewport (flips up/left when near edges).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
import { CONTEXT_MENU_VIEWPORT_MARGIN, fitContextMenuToViewport } from './contextMenuPosition'
|
||||||
|
|
||||||
|
interface MenuPosition {
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
@@ -21,9 +32,40 @@ interface ContextMenuProps {
|
|||||||
|
|
||||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [position, setPosition] = useState<MenuPosition>({ left: x, top: y })
|
||||||
|
const [positioned, setPositioned] = useState(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const fitToViewport = () => {
|
||||||
|
const menu = menuRef.current
|
||||||
|
if (!menu) return
|
||||||
|
// Prefer scrollHeight/Width so we measure full content even if maxHeight
|
||||||
|
// temporarily constrains the box during the first paint.
|
||||||
|
const width = Math.max(menu.offsetWidth, menu.scrollWidth)
|
||||||
|
const height = Math.max(menu.offsetHeight, menu.scrollHeight)
|
||||||
|
setPosition(fitContextMenuToViewport(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
window.innerWidth,
|
||||||
|
window.innerHeight,
|
||||||
|
))
|
||||||
|
setPositioned(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fitToViewport()
|
||||||
|
// Second pass after fonts/layout settle (still before paint when possible).
|
||||||
|
const raf = window.requestAnimationFrame(fitToViewport)
|
||||||
|
window.addEventListener('resize', fitToViewport)
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('resize', fitToViewport)
|
||||||
|
}
|
||||||
|
}, [items, x, y])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handlePointerDown = (e: PointerEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -31,19 +73,26 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClick)
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClick)
|
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
}
|
}
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
|
className="fixed z-[200] min-w-[180px] overflow-y-auto rounded-lg border border-dark-700 bg-dark-700 py-1 shadow-xl"
|
||||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
style={{
|
||||||
|
left: position.left,
|
||||||
|
top: position.top,
|
||||||
|
maxWidth: `calc(100vw - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
|
||||||
|
maxHeight: `calc(100vh - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
|
||||||
|
visibility: positioned ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
@@ -51,6 +100,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
<div className="my-1 border-t border-dark-600/30" />
|
<div className="my-1 border-t border-dark-600/30" />
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.action()
|
item.action()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -64,6 +114,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* FileListPanel - 文件列表面板
|
* FileListPanel - 文件列表面板
|
||||||
*
|
*
|
||||||
* 本地面板或远程面板的文件列表组件
|
* 本地面板或远程面板的文件列表组件。
|
||||||
* 远程面板支持拖拽上传
|
* 栏间传输:自定义指针拖动(不依赖 WKWebView HTML5 drop)。
|
||||||
* 支持当前文件夹内文件名搜索
|
* 系统文件拖入远程:仍走 HTML5 drop。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
|
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, Download, FolderOpen,
|
||||||
Search, X, Eye, EyeOff,
|
Search, X, Eye, EyeOff, RefreshCw, FolderPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Breadcrumb } from './Breadcrumb'
|
import { Breadcrumb } from './Breadcrumb'
|
||||||
import { SortHeader } from './SortHeader'
|
import { SortHeader } from './SortHeader'
|
||||||
import { FileRow } from './FileRow'
|
import { FileRow } from './FileRow'
|
||||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
|
||||||
import { FileListSkeleton } from '../common/Skeleton'
|
import { FileListSkeleton } from '../common/Skeleton'
|
||||||
import EmptyState from '../common/EmptyState'
|
import EmptyState from '../common/EmptyState'
|
||||||
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
|
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
|
||||||
|
import { parentPath } from '../../utils/localPath'
|
||||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||||
import {
|
import {
|
||||||
hasSftpDragEntry,
|
clearSftpDragEntry,
|
||||||
readSftpDragEntry,
|
dndLog,
|
||||||
writeSftpDragEntry,
|
shortPath,
|
||||||
type SftpDragEntry,
|
type SftpDragEntry,
|
||||||
} from './dragDrop'
|
} from './dragDrop'
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ interface FileListPanelProps {
|
|||||||
title: string
|
title: string
|
||||||
isLocal: boolean
|
isLocal: boolean
|
||||||
path: string
|
path: string
|
||||||
|
/** 主目录(Home 按钮目标);远程应传会话起始家目录,不是 / */
|
||||||
|
homePath?: string
|
||||||
files: FileItem[]
|
files: FileItem[]
|
||||||
selected: Set<string>
|
selected: Set<string>
|
||||||
history: string[]
|
history: string[]
|
||||||
@@ -45,20 +47,74 @@ interface FileListPanelProps {
|
|||||||
onOpen: (file: FileItem) => void
|
onOpen: (file: FileItem) => void
|
||||||
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
|
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
|
||||||
onSort: (field: SortField) => void
|
onSort: (field: SortField) => void
|
||||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
|
||||||
onCloseContextMenu: () => void
|
|
||||||
/** 操作系统文件拖入远程面板。 */
|
/** 操作系统文件拖入远程面板。 */
|
||||||
onDropFiles?: (files: FileList, targetPath: string) => void
|
onDropFiles?: (files: FileList, targetPath: string) => void
|
||||||
/** OpsXShell 本地/远程栏之间的内部拖拽。 */
|
/** 本地/远程栏之间的内部指针拖放(支持多选批量)。 */
|
||||||
onDropEntry?: (entry: SftpDragEntry, targetPath: string) => void
|
onDropEntry?: (entries: SftpDragEntry[], targetPath: string) => void
|
||||||
/** 是否正在加载 */
|
/** 系统文件 drop 接受了但读不到路径时回调(用于 toast)。 */
|
||||||
|
onDropFailed?: () => void
|
||||||
|
onRefresh?: () => void
|
||||||
|
onNewFolder?: () => void
|
||||||
|
onFocusPane?: () => void
|
||||||
|
/** 是否正在加载(空列表时显示骨架) */
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
/** 刷新中:图标旋转 + 状态栏提示,不拆掉现有列表 */
|
||||||
|
refreshing?: boolean
|
||||||
|
listError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileListPanel({
|
function isAtFilesystemRoot(path: string, isLocal: boolean): boolean {
|
||||||
|
const parent = parentPath(path, isLocal)
|
||||||
|
return parent === path || path === '/' || /^[a-zA-Z]:\\?$/.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const POINTER_DRAG_EVENT = 'opsxshell:sftp-pointer-drag'
|
||||||
|
const POINTER_DRAG_THRESHOLD = 6
|
||||||
|
|
||||||
|
interface PointerDragDetail {
|
||||||
|
source: SftpDragEntry['source'] | null
|
||||||
|
target: SftpDragEntry['source'] | null
|
||||||
|
targetPath: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointerDragState {
|
||||||
|
pointerId: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
entries: SftpDragEntry[]
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOsFiles(dataTransfer: DataTransfer): boolean {
|
||||||
|
return Array.from(dataTransfer.types || []).includes('Files')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerDropTarget(clientX: number, clientY: number, source: SftpDragEntry['source']) {
|
||||||
|
const hit = document.elementFromPoint?.(clientX, clientY)
|
||||||
|
const pane = hit?.closest<HTMLElement>('[data-sftp-pane]')
|
||||||
|
if (!pane || pane.dataset.sftpPane === source) return null
|
||||||
|
|
||||||
|
const directory = hit?.closest<HTMLElement>('[data-sftp-directory-path]')
|
||||||
|
const targetPath = directory && pane.contains(directory)
|
||||||
|
? directory.dataset.sftpDirectoryPath
|
||||||
|
: pane.dataset.sftpPath
|
||||||
|
if (!targetPath) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
pane: pane.dataset.sftpPane as SftpDragEntry['source'],
|
||||||
|
path: targetPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function announcePointerDrag(detail: PointerDragDetail) {
|
||||||
|
window.dispatchEvent(new CustomEvent<PointerDragDetail>(POINTER_DRAG_EVENT, { detail }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileListPanel = memo(function FileListPanel({
|
||||||
title,
|
title,
|
||||||
isLocal,
|
isLocal,
|
||||||
path,
|
path,
|
||||||
|
homePath,
|
||||||
files,
|
files,
|
||||||
selected,
|
selected,
|
||||||
history,
|
history,
|
||||||
@@ -73,22 +129,26 @@ export function FileListPanel({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onSort,
|
onSort,
|
||||||
contextMenu,
|
|
||||||
onCloseContextMenu,
|
|
||||||
onDropFiles,
|
onDropFiles,
|
||||||
onDropEntry,
|
onDropEntry,
|
||||||
|
onDropFailed,
|
||||||
|
onRefresh,
|
||||||
|
onNewFolder,
|
||||||
|
onFocusPane,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
refreshing = false,
|
||||||
|
listError = null,
|
||||||
}: FileListPanelProps) {
|
}: FileListPanelProps) {
|
||||||
const { t } = useTranslation('sftp')
|
const { t } = useTranslation('sftp')
|
||||||
const canGoBack = historyIndex > 0
|
const canGoBack = historyIndex > 0
|
||||||
const canGoForward = historyIndex < history.length - 1
|
const canGoForward = historyIndex < history.length - 1
|
||||||
|
const canGoUp = !isAtFilesystemRoot(path, isLocal)
|
||||||
|
const resolvedHome = homePath || (isLocal ? history[0] || path : '/')
|
||||||
|
|
||||||
// Current-folder filename filter + hidden files toggle
|
|
||||||
const [nameFilter, setNameFilter] = useState('')
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
const [showHidden, setShowHidden] = useState(false)
|
const [showHidden, setShowHidden] = useState(false)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Clear name filter when directory changes (keep showHidden preference)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNameFilter('')
|
setNameFilter('')
|
||||||
}, [path])
|
}, [path])
|
||||||
@@ -109,26 +169,61 @@ export function FileListPanel({
|
|||||||
)
|
)
|
||||||
const isFiltering = nameFilter.trim().length > 0
|
const isFiltering = nameFilter.trim().length > 0
|
||||||
|
|
||||||
// Drag state for remote panel upload
|
const parentEntry = useMemo((): FileItem | null => {
|
||||||
|
if (!canGoUp || isFiltering) return null
|
||||||
|
return {
|
||||||
|
name: '..',
|
||||||
|
path: parentPath(path, isLocal),
|
||||||
|
type: 'directory',
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
permissions: 'drwxr-xr-x',
|
||||||
|
}
|
||||||
|
}, [canGoUp, isFiltering, isLocal, path])
|
||||||
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
|
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
|
||||||
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
|
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
|
||||||
const dragCounterRef = useRef(0)
|
/** Nested dragenter/leave — OS file HTML5 drop only. */
|
||||||
|
const dragDepthRef = useRef(0)
|
||||||
|
const pointerDragRef = useRef<PointerDragState | null>(null)
|
||||||
|
const suppressNextClickRef = useRef(false)
|
||||||
|
const [pointerPreview, setPointerPreview] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
entries: SftpDragEntry[]
|
||||||
|
canDrop: boolean
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const acceptsDrag = useCallback((dataTransfer: DataTransfer) => {
|
const acceptsOsFileDrag = useCallback((dataTransfer: DataTransfer) => {
|
||||||
const internal = readSftpDragEntry(dataTransfer)
|
if (isLocal || !onDropFiles) return false
|
||||||
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
|
return hasOsFiles(dataTransfer)
|
||||||
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
|
}, [isLocal, onDropFiles])
|
||||||
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
|
|
||||||
}, [isLocal, onDropEntry, onDropFiles])
|
|
||||||
|
|
||||||
const resetDragState = useCallback(() => {
|
const resetOsDragState = useCallback(() => {
|
||||||
dragCounterRef.current = 0
|
dragDepthRef.current = 0
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
setDropTargetPath(null)
|
setDropTargetPath(null)
|
||||||
setDragSource(null)
|
setDragSource(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Cross-pane highlight for pointer-based internal dragging.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDrag = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<PointerDragDetail>).detail
|
||||||
|
const pane = isLocal ? 'local' : 'remote'
|
||||||
|
const active = detail.target === pane
|
||||||
|
setIsDragOver(active)
|
||||||
|
setDragSource(active ? detail.source : null)
|
||||||
|
setDropTargetPath(active ? detail.targetPath : null)
|
||||||
|
}
|
||||||
|
window.addEventListener(POINTER_DRAG_EVENT, onPointerDrag)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(POINTER_DRAG_EVENT, onPointerDrag)
|
||||||
|
document.body.classList.remove('sftp-pointer-dragging')
|
||||||
|
}
|
||||||
|
}, [isLocal])
|
||||||
|
|
||||||
const clearFilter = useCallback(() => {
|
const clearFilter = useCallback(() => {
|
||||||
setNameFilter('')
|
setNameFilter('')
|
||||||
searchInputRef.current?.focus()
|
searchInputRef.current?.focus()
|
||||||
@@ -146,7 +241,6 @@ export function FileListPanel({
|
|||||||
}
|
}
|
||||||
}, [nameFilter])
|
}, [nameFilter])
|
||||||
|
|
||||||
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
|
|
||||||
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -156,81 +250,213 @@ export function FileListPanel({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const consumeOsDrop = useCallback((dataTransfer: DataTransfer, targetPath: string) => {
|
||||||
|
resetOsDragState()
|
||||||
|
const droppedFiles = dataTransfer.files
|
||||||
|
if (!isLocal && droppedFiles && droppedFiles.length > 0) {
|
||||||
|
dndLog('drop-os-files', {
|
||||||
|
pane: 'remote',
|
||||||
|
target: shortPath(targetPath),
|
||||||
|
count: droppedFiles.length,
|
||||||
|
})
|
||||||
|
onDropFiles?.(droppedFiles, targetPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dndLog('drop-os-failed', {
|
||||||
|
pane: isLocal ? 'local' : 'remote',
|
||||||
|
target: shortPath(targetPath),
|
||||||
|
types: Array.from(dataTransfer.types || []),
|
||||||
|
})
|
||||||
|
onDropFailed?.()
|
||||||
|
}, [isLocal, onDropFiles, onDropFailed, resetOsDragState])
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
if (!acceptsDrag(e.dataTransfer)) return
|
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
|
dragDepthRef.current += 1
|
||||||
dragCounterRef.current++
|
setDragSource(null)
|
||||||
if (dragCounterRef.current === 1) {
|
|
||||||
setIsDragOver(true)
|
setIsDragOver(true)
|
||||||
|
if (dragDepthRef.current === 1) {
|
||||||
|
dndLog('os-dragenter', { pane: 'remote', accept: true })
|
||||||
}
|
}
|
||||||
}, [acceptsDrag])
|
}, [acceptsOsFileDrag])
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
if (!acceptsDrag(e.dataTransfer)) return
|
if (!acceptsOsFileDrag(e.dataTransfer) && dragDepthRef.current === 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
dragCounterRef.current--
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||||
if (dragCounterRef.current === 0) {
|
if (dragDepthRef.current === 0) {
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
|
setDropTargetPath(null)
|
||||||
}
|
}
|
||||||
}, [acceptsDrag])
|
}, [acceptsOsFileDrag])
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (!acceptsDrag(e.dataTransfer)) return
|
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
}, [acceptsDrag])
|
if (!isDragOver) setIsDragOver(true)
|
||||||
|
}, [acceptsOsFileDrag, isDragOver])
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
if (!acceptsDrag(e.dataTransfer)) return
|
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const entry = readSftpDragEntry(e.dataTransfer)
|
consumeOsDrop(e.dataTransfer, path)
|
||||||
resetDragState()
|
}, [acceptsOsFileDrag, consumeOsDrop, path])
|
||||||
|
|
||||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
const dropOsOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
|
||||||
onDropEntry?.(entry, path)
|
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
if (droppedFiles && droppedFiles.length > 0) {
|
|
||||||
onDropFiles?.(droppedFiles, path)
|
|
||||||
}
|
|
||||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, path, resetDragState])
|
|
||||||
|
|
||||||
const dropOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
|
|
||||||
if (!acceptsDrag(event.dataTransfer)) return
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const entry = readSftpDragEntry(event.dataTransfer)
|
dndLog('drop-os-on-dir', { dir: shortPath(directory.path) })
|
||||||
resetDragState()
|
consumeOsDrop(event.dataTransfer, directory.path)
|
||||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
}, [acceptsOsFileDrag, consumeOsDrop])
|
||||||
onDropEntry?.(entry, directory.path)
|
|
||||||
return
|
const dragEntriesFor = useCallback((file: FileItem): SftpDragEntry[] => {
|
||||||
|
const source: SftpDragEntry['source'] = isLocal ? 'local' : 'remote'
|
||||||
|
const selectedNames = selected.has(file.name) && selected.size > 1
|
||||||
|
? selected
|
||||||
|
: new Set([file.name])
|
||||||
|
const entries = files
|
||||||
|
.filter((item) => selectedNames.has(item.name))
|
||||||
|
.map((item) => ({
|
||||||
|
source,
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
type: item.type,
|
||||||
|
size: item.size,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return entries.length > 0 ? entries : [{
|
||||||
|
source,
|
||||||
|
name: file.name,
|
||||||
|
path: file.path,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
}]
|
||||||
|
}, [files, isLocal, selected])
|
||||||
|
|
||||||
|
const resetPointerDrag = useCallback(() => {
|
||||||
|
pointerDragRef.current = null
|
||||||
|
setPointerPreview(null)
|
||||||
|
announcePointerDrag({ source: null, target: null, targetPath: null })
|
||||||
|
clearSftpDragEntry()
|
||||||
|
document.body.classList.remove('sftp-pointer-dragging')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPointerDrag = useCallback((file: FileItem, event: React.PointerEvent) => {
|
||||||
|
if (event.button !== 0 || !onDropEntry) return
|
||||||
|
// Stop the browser from starting a text selection before the drag threshold.
|
||||||
|
event.preventDefault()
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
const entries = dragEntriesFor(file)
|
||||||
|
pointerDragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
entries,
|
||||||
|
active: false,
|
||||||
}
|
}
|
||||||
if (!isLocal && event.dataTransfer.files?.length) {
|
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||||
onDropFiles?.(event.dataTransfer.files, directory.path)
|
}, [dragEntriesFor, onDropEntry])
|
||||||
|
|
||||||
|
const movePointerDrag = useCallback((event: React.PointerEvent) => {
|
||||||
|
const state = pointerDragRef.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
|
||||||
|
suppressNextClickRef.current = true
|
||||||
|
document.body.classList.add('sftp-pointer-dragging')
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
dndLog('pointer-drag-start', {
|
||||||
|
source: state.entries[0]?.source,
|
||||||
|
count: state.entries.length,
|
||||||
|
names: state.entries.slice(0, 8).map((entry) => entry.name),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
|
|
||||||
|
event.preventDefault()
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
const source = state.entries[0]?.source
|
||||||
|
if (!source) return
|
||||||
|
const target = pointerDropTarget(event.clientX, event.clientY, source)
|
||||||
|
setPointerPreview({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
entries: state.entries,
|
||||||
|
canDrop: Boolean(target),
|
||||||
|
})
|
||||||
|
announcePointerDrag({
|
||||||
|
source,
|
||||||
|
target: target?.pane || null,
|
||||||
|
targetPath: target?.path || null,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finishPointerDrag = useCallback((event: React.PointerEvent) => {
|
||||||
|
const state = pointerDragRef.current
|
||||||
|
if (!state || state.pointerId !== event.pointerId) return
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||||
|
|
||||||
|
if (state.active) {
|
||||||
|
event.preventDefault()
|
||||||
|
const source = state.entries[0]?.source
|
||||||
|
const target = source ? pointerDropTarget(event.clientX, event.clientY, source) : null
|
||||||
|
if (target) {
|
||||||
|
dndLog('pointer-drop', {
|
||||||
|
source,
|
||||||
|
target: target.pane,
|
||||||
|
targetPath: shortPath(target.path),
|
||||||
|
count: state.entries.length,
|
||||||
|
})
|
||||||
|
onDropEntry?.(state.entries, target.path)
|
||||||
|
} else {
|
||||||
|
dndLog('pointer-drop-cancelled', { source })
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressNextClickRef.current = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
resetPointerDrag()
|
||||||
|
}, [onDropEntry, resetPointerDrag])
|
||||||
|
|
||||||
|
const cancelPointerDrag = useCallback((event: React.PointerEvent) => {
|
||||||
|
const state = pointerDragRef.current
|
||||||
|
if (!state || state.pointerId !== event.pointerId) return
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||||
|
suppressNextClickRef.current = false
|
||||||
|
resetPointerDrag()
|
||||||
|
}, [resetPointerDrag])
|
||||||
|
|
||||||
const statusText = (() => {
|
const statusText = (() => {
|
||||||
|
if (refreshing) return t('status.refreshing')
|
||||||
if (loading) return t('status.loading')
|
if (loading) return t('status.loading')
|
||||||
|
// listError is shown in the banner above; keep the footer for selection/count.
|
||||||
if (selected.size > 0) return t('status.selected', { count: selected.size })
|
if (selected.size > 0) return t('status.selected', { count: selected.size })
|
||||||
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
|
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
|
||||||
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
|
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
|
||||||
return t('status.total', { count: visibleFiles.length })
|
return t('status.total', { count: visibleFiles.length })
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const PreviewIcon = pointerPreview?.entries[0]?.source === 'remote' ? Download : Upload
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label={t('panel.ariaLabel', { title })}
|
aria-label={t('panel.ariaLabel', { title })}
|
||||||
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
data-sftp-pane={isLocal ? 'local' : 'remote'}
|
||||||
|
data-sftp-path={path}
|
||||||
|
className={`sftp-file-panel flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
||||||
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
|
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
|
||||||
}`}
|
}`}
|
||||||
|
onMouseDown={() => onFocusPane?.()}
|
||||||
|
onFocusCapture={() => onFocusPane?.()}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -238,13 +464,36 @@ export function FileListPanel({
|
|||||||
onKeyDown={handlePanelKeyDown}
|
onKeyDown={handlePanelKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
|
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
|
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
|
||||||
<span className="text-sm font-medium truncate">{title}</span>
|
<span className="text-sm font-medium truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{onRefresh && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={refreshing || loading}
|
||||||
|
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
title={t('toolbar.refresh')}
|
||||||
|
aria-label={t('toolbar.refreshAria')}
|
||||||
|
aria-busy={refreshing || loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${(refreshing || loading) ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNewFolder && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNewFolder}
|
||||||
|
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary"
|
||||||
|
title={t('toolbar.newFolder')}
|
||||||
|
aria-label={t('toolbar.newFolderAria')}
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowHidden((v) => !v)}
|
onClick={() => setShowHidden((v) => !v)}
|
||||||
@@ -277,22 +526,22 @@ export function FileListPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateUp}
|
onClick={onNavigateUp}
|
||||||
className="p-1.5 hover:bg-dark-600 rounded"
|
disabled={!canGoUp}
|
||||||
title={t('nav.up')}
|
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||||
|
title={canGoUp ? t('nav.up') : t('nav.upAtRoot')}
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumb + search */}
|
|
||||||
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
|
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
path={path}
|
path={path}
|
||||||
isLocal={isLocal}
|
isLocal={isLocal}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
|
onHome={() => onNavigate(resolvedHome)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
|
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
|
||||||
@@ -321,27 +570,40 @@ export function FileListPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Headers — 与 FileRow 共用 px-4 对齐 */}
|
{listError && (
|
||||||
|
<div className="px-3 py-1.5 border-b border-red-500/20 bg-red-500/10 text-xs text-red-400 flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{listError}</span>
|
||||||
|
{canGoUp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNavigateUp}
|
||||||
|
className="flex-shrink-0 rounded bg-dark-700 px-2 py-0.5 text-content-secondary hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
{t('empty.goUp')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
|
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
|
||||||
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File List */}
|
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<FileListSkeleton rows={6} />
|
<FileListSkeleton rows={6} />
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 && !parentEntry ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
title={t('empty.dirTitle')}
|
title={t('empty.dirTitle')}
|
||||||
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
|
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
|
||||||
action={
|
action={
|
||||||
path !== '/'
|
canGoUp
|
||||||
? { label: t('empty.goUp'), onClick: onNavigateUp }
|
? { label: t('empty.goUp'), onClick: onNavigateUp }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : visibleFiles.length === 0 ? (
|
) : visibleFiles.length === 0 && !parentEntry ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={EyeOff}
|
icon={EyeOff}
|
||||||
title={t('empty.hiddenOnlyTitle')}
|
title={t('empty.hiddenOnlyTitle')}
|
||||||
@@ -351,7 +613,7 @@ export function FileListPanel({
|
|||||||
onClick: () => setShowHidden(true),
|
onClick: () => setShowHidden(true),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : filteredFiles.length === 0 ? (
|
) : filteredFiles.length === 0 && !parentEntry ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Search}
|
icon={Search}
|
||||||
title={t('empty.noMatchTitle')}
|
title={t('empty.noMatchTitle')}
|
||||||
@@ -359,54 +621,65 @@ export function FileListPanel({
|
|||||||
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
|
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredFiles.map((file) => (
|
<>
|
||||||
|
{parentEntry && (
|
||||||
|
<FileRow
|
||||||
|
key="__parent__"
|
||||||
|
file={parentEntry}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onOpen={() => onNavigate(parentEntry.path)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filteredFiles.map((file) => (
|
||||||
<FileRow
|
<FileRow
|
||||||
key={file.name}
|
key={file.name}
|
||||||
file={file}
|
file={file}
|
||||||
isSelected={selected.has(file.name)}
|
isSelected={selected.has(file.name)}
|
||||||
onSelect={() => onSelect(file.name)}
|
onSelect={() => {
|
||||||
|
if (suppressNextClickRef.current) return
|
||||||
|
onSelect(file.name)
|
||||||
|
}}
|
||||||
onOpen={() => onOpen(file)}
|
onOpen={() => onOpen(file)}
|
||||||
onContextMenu={(e) => onContextMenu(e, file)}
|
onContextMenu={(e) => onContextMenu(e, file)}
|
||||||
onDragStart={(event) => {
|
onPointerDown={(event) => startPointerDrag(file, event)}
|
||||||
writeSftpDragEntry(event.dataTransfer, {
|
onPointerMove={movePointerDrag}
|
||||||
source: isLocal ? 'local' : 'remote',
|
onPointerUp={finishPointerDrag}
|
||||||
name: file.name,
|
onPointerCancel={cancelPointerDrag}
|
||||||
path: file.path,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onDragEnd={resetDragState}
|
|
||||||
onDragEnter={file.type === 'directory' ? (event) => {
|
onDragEnter={file.type === 'directory' ? (event) => {
|
||||||
if (!acceptsDrag(event.dataTransfer)) return
|
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
setDragSource(null)
|
||||||
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
|
|
||||||
setIsDragOver(true)
|
setIsDragOver(true)
|
||||||
setDropTargetPath(file.path)
|
setDropTargetPath(file.path)
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDragLeave={file.type === 'directory' ? (event) => {
|
onDragLeave={file.type === 'directory' ? (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
|
||||||
setDropTargetPath((current) => current === file.path ? null : current)
|
setDropTargetPath((current) => current === file.path ? null : current)
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDragOver={file.type === 'directory' ? (event) => {
|
onDragOver={file.type === 'directory' ? (event) => {
|
||||||
if (!acceptsDrag(event.dataTransfer)) return
|
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
|
||||||
event.dataTransfer.dropEffect = 'copy'
|
event.dataTransfer.dropEffect = 'copy'
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
|
onDrop={file.type === 'directory' ? (event) => dropOsOnDirectory(event, file) : undefined}
|
||||||
isDropTarget={dropTargetPath === file.path}
|
isDropTarget={dropTargetPath === file.path}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
|
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
||||||
<div className="flex flex-col items-center gap-2 text-accent-blue">
|
<div className="flex flex-col items-center gap-2 text-accent-blue">
|
||||||
|
{isLocal || dragSource === 'remote' ? (
|
||||||
|
<Download className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
<Upload className="w-10 h-10" />
|
<Upload className="w-10 h-10" />
|
||||||
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isLocal || dragSource === 'remote'
|
{isLocal || dragSource === 'remote'
|
||||||
? t('transfer.dropDownload', { path: dropTargetPath || path })
|
? t('transfer.dropDownload', { path: dropTargetPath || path })
|
||||||
@@ -415,20 +688,31 @@ export function FileListPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pointerPreview && (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none fixed z-[100] flex max-w-64 items-center gap-2 rounded-md border px-3 py-2 text-xs shadow-xl ${
|
||||||
|
pointerPreview.canDrop
|
||||||
|
? 'border-accent-blue bg-dark-700 text-content-primary'
|
||||||
|
: 'border-dark-600 bg-dark-800 text-txt-muted'
|
||||||
|
}`}
|
||||||
|
style={{ left: pointerPreview.x + 14, top: pointerPreview.y + 14 }}
|
||||||
|
>
|
||||||
|
<PreviewIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{pointerPreview.entries[0]?.name}</span>
|
||||||
|
{pointerPreview.entries.length > 1 && (
|
||||||
|
<span className="flex-shrink-0">+{pointerPreview.entries.length - 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
|
||||||
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
|
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
|
||||||
<span>{statusText}</span>
|
<span className="truncate">{statusText}</span>
|
||||||
{isFiltering && (
|
{isFiltering && (
|
||||||
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
|
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
|
||||||
{contextMenu && (
|
|
||||||
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
@@ -13,6 +13,10 @@ interface FileRowProps {
|
|||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
|
onPointerDown?: (e: React.PointerEvent) => void
|
||||||
|
onPointerMove?: (e: React.PointerEvent) => void
|
||||||
|
onPointerUp?: (e: React.PointerEvent) => void
|
||||||
|
onPointerCancel?: (e: React.PointerEvent) => void
|
||||||
onDragStart?: (e: React.DragEvent) => void
|
onDragStart?: (e: React.DragEvent) => void
|
||||||
onDragEnd?: (e: React.DragEvent) => void
|
onDragEnd?: (e: React.DragEvent) => void
|
||||||
onDragEnter?: (e: React.DragEvent) => void
|
onDragEnter?: (e: React.DragEvent) => void
|
||||||
@@ -28,6 +32,10 @@ export function FileRow({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onOpen,
|
onOpen,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerCancel,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onDragEnter,
|
onDragEnter,
|
||||||
@@ -42,22 +50,29 @@ export function FileRow({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
|
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
draggable={!!onDragStart}
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerCancel}
|
||||||
|
data-sftp-entry-path={file.path}
|
||||||
|
data-sftp-directory-path={file.type === 'directory' ? file.path : undefined}
|
||||||
|
draggable={false}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
|
className={`h-10 flex select-none items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
|
||||||
isDropTarget
|
isDropTarget
|
||||||
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
|
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
|
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
|
||||||
: 'hover:bg-dark-700/40'
|
: 'hover:bg-dark-700/40'
|
||||||
}`}
|
}`}
|
||||||
|
style={{ WebkitUserSelect: 'none', userSelect: 'none', touchAction: 'none' }}
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
<div className="sftp-col-name flex-1 flex items-center gap-2 min-w-0">
|
||||||
{file.type === 'directory' ? (
|
{file.type === 'directory' ? (
|
||||||
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
|
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
|
||||||
) : file.type === 'symlink' ? (
|
) : file.type === 'symlink' ? (
|
||||||
@@ -66,13 +81,15 @@ export function FileRow({
|
|||||||
<File className="w-4 h-4 text-txt-muted flex-shrink-0" />
|
<File className="w-4 h-4 text-txt-muted flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-content-primary truncate group-hover:text-content-primary">{file.name}</span>
|
<span className="text-sm text-content-primary truncate group-hover:text-content-primary">{file.name}</span>
|
||||||
{file.type === 'symlink' && <span className="text-xs text-yellow-500">→</span>}
|
{file.type === 'symlink' && <span className="text-xs text-yellow-500 flex-shrink-0">→</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32 text-right text-xs text-txt-muted">{formatDate(file.modified)}</div>
|
<div className="sftp-col-modified w-28 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
|
||||||
<div className="w-24 text-right text-xs text-txt-muted">
|
{formatDate(file.modified)}
|
||||||
|
</div>
|
||||||
|
<div className="sftp-col-size w-16 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
|
||||||
{file.type === 'directory' ? '--' : formatSize(file.size)}
|
{file.type === 'directory' ? '--' : formatSize(file.size)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 text-right text-xs text-txt-muted font-mono">
|
<div className="sftp-col-type w-16 flex-shrink-0 text-right text-xs text-txt-muted font-mono truncate">
|
||||||
{file.type === 'directory'
|
{file.type === 'directory'
|
||||||
? t('fileType.folder')
|
? t('fileType.folder')
|
||||||
: file.type === 'symlink'
|
: file.type === 'symlink'
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Remote text file editor (SFTP) with Monaco
|
* Remote text file editor (SFTP) with Monaco.
|
||||||
|
*
|
||||||
|
* Renders as an in-pane overlay (not viewport-fixed) so the app title bar /
|
||||||
|
* tab bar stay free for window dragging.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
|
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { X, Save, FileCode } from 'lucide-react'
|
import { X, Save, FileCode, RotateCcw } from 'lucide-react'
|
||||||
|
import type { editor } from 'monaco-editor'
|
||||||
import { guessLanguageFromPath } from '../../utils/fileLanguage'
|
import { guessLanguageFromPath } from '../../utils/fileLanguage'
|
||||||
|
import { translateErrorMessage } from '../../i18n/format'
|
||||||
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
|
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
|
||||||
|
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||||
|
import { useTheme } from '../../hooks/useTheme'
|
||||||
|
|
||||||
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
|
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
|
||||||
|
|
||||||
|
/** Above this, turn off expensive Monaco features (minimap, folding, etc.). */
|
||||||
|
const LIGHT_EDITOR_CHARS = 80_000
|
||||||
|
|
||||||
export interface RemoteTextEditorProps {
|
export interface RemoteTextEditorProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
sftpId: string
|
sftpId: string
|
||||||
@@ -28,6 +38,8 @@ export default function RemoteTextEditor({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: RemoteTextEditorProps) {
|
}: RemoteTextEditorProps) {
|
||||||
const { t } = useTranslation(['sftp', 'common'])
|
const { t } = useTranslation(['sftp', 'common'])
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
const { effective } = useTheme()
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [original, setOriginal] = useState('')
|
const [original, setOriginal] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -36,6 +48,36 @@ export default function RemoteTextEditor({
|
|||||||
|
|
||||||
const dirty = content !== original
|
const dirty = content !== original
|
||||||
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
|
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
|
||||||
|
const lightMode = content.length > LIGHT_EDITOR_CHARS
|
||||||
|
|
||||||
|
const editorOptions = useMemo((): editor.IStandaloneEditorConstructionOptions => {
|
||||||
|
return {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
||||||
|
minimap: { enabled: !lightMode && content.length > 20_000, scale: 0.75 },
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
tabSize: 2,
|
||||||
|
renderWhitespace: lightMode ? 'none' : 'selection',
|
||||||
|
smoothScrolling: !lightMode,
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
folding: !lightMode,
|
||||||
|
links: !lightMode,
|
||||||
|
matchBrackets: lightMode ? 'never' : 'always',
|
||||||
|
occurrencesHighlight: lightMode ? 'off' : 'singleFile',
|
||||||
|
renderLineHighlight: lightMode ? 'none' : 'line',
|
||||||
|
quickSuggestions: !lightMode,
|
||||||
|
suggestOnTriggerCharacters: !lightMode,
|
||||||
|
parameterHints: { enabled: !lightMode },
|
||||||
|
hover: { enabled: !lightMode },
|
||||||
|
codeLens: false,
|
||||||
|
lightbulb: {
|
||||||
|
enabled: (lightMode ? 'off' : 'onCode') as editor.ShowLightbulbIconMode,
|
||||||
|
},
|
||||||
|
unicodeHighlight: { ambiguousCharacters: false },
|
||||||
|
}
|
||||||
|
}, [content.length, lightMode])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!isOpen || !sftpId || !remotePath) return
|
if (!isOpen || !sftpId || !remotePath) return
|
||||||
@@ -49,33 +91,17 @@ export default function RemoteTextEditor({
|
|||||||
setContent(result.content)
|
setContent(result.content)
|
||||||
setOriginal(result.content)
|
setOriginal(result.content)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [isOpen, sftpId, remotePath])
|
}, [isOpen, sftpId, remotePath, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
// Cmd/Ctrl+S
|
const handleSave = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (dirty && !saving && !loading) {
|
|
||||||
void handleSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isOpen, dirty, saving, loading, content])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -90,93 +116,169 @@ export default function RemoteTextEditor({
|
|||||||
setOriginal(content)
|
setOriginal(content)
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}, [content, onSaved, remotePath, sftpId, t])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = useCallback(async () => {
|
||||||
if (dirty && !confirm(t('editor.unsavedClose'))) return
|
if (dirty) {
|
||||||
onClose()
|
const ok = await confirm({
|
||||||
|
title: t('editor.unsavedTitle'),
|
||||||
|
message: t('editor.unsavedClose'),
|
||||||
|
danger: true,
|
||||||
|
confirmText: t('editor.discardAndClose'),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
}
|
}
|
||||||
|
onClose()
|
||||||
|
}, [dirty, confirm, onClose, t])
|
||||||
|
|
||||||
|
const handleReload = useCallback(async () => {
|
||||||
|
if (dirty) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t('editor.reloadTitle'),
|
||||||
|
message: t('editor.unsavedReload'),
|
||||||
|
danger: true,
|
||||||
|
confirmText: t('editor.discardAndReload'),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [dirty, confirm, load, t])
|
||||||
|
|
||||||
|
// Cmd/Ctrl+S save · Escape close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (dirty && !saving && !loading) void handleSave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [isOpen, dirty, saving, loading, handleSave, handleClose])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
<div
|
||||||
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-dark-600 bg-dark-800 shadow-2xl">
|
className="absolute inset-0 z-30 flex flex-col bg-dark-900"
|
||||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
|
role="dialog"
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
aria-modal="true"
|
||||||
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
|
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">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
|
||||||
|
<FileCode className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium text-content-primary">
|
<div className="flex items-center gap-2 truncate text-sm font-medium text-content-primary">
|
||||||
{fileName}
|
<span className="truncate">{fileName}</span>
|
||||||
{dirty && <span className="ml-1 text-yellow-400">•</span>}
|
{dirty && (
|
||||||
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
|
<span className="flex-shrink-0 rounded bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-normal text-yellow-400">
|
||||||
|
{t('editor.dirtyBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-[10px] font-normal uppercase tracking-wide text-txt-faint">
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
|
<div className="truncate font-mono text-[11px] text-txt-faint" title={remotePath}>
|
||||||
|
{remotePath}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleReload()}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-40"
|
||||||
|
title={t('editor.reload')}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{t('editor.reload')}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!dirty || saving || loading}
|
disabled={!dirty || saving || loading}
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 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
|
||||||
|
? '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" />
|
||||||
{saving ? t('editor.saving') : t('editor.save')}
|
{saving ? t('editor.saving') : t('editor.save')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleClose()}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||||
|
title={t('common:action.close')}
|
||||||
|
aria-label={t('common:action.close')}
|
||||||
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
|
<div className="flex-shrink-0 border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
|
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||||
|
{t('common:status.loading')}
|
||||||
|
</div>
|
||||||
|
) : error && !content ? (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
|
||||||
|
{t('editor.loadBlocked')}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
|
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||||
|
{t('editor.loadingEditor')}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MonacoEditor
|
<MonacoEditor
|
||||||
height="100%"
|
height="100%"
|
||||||
language={language}
|
language={language}
|
||||||
theme="vs-dark"
|
theme={effective === 'light' ? 'vs' : 'vs-dark'}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(v) => setContent(v ?? '')}
|
onChange={(v) => setContent(v ?? '')}
|
||||||
options={{
|
options={editorOptions}
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
|
||||||
minimap: { enabled: true, scale: 0.75 },
|
|
||||||
wordWrap: 'on',
|
|
||||||
automaticLayout: true,
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
tabSize: 2,
|
|
||||||
renderWhitespace: 'selection',
|
|
||||||
smoothScrolling: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
|
<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">
|
||||||
<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() })}
|
||||||
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
|
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
{ConfirmDialogComponent}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,30 +4,39 @@
|
|||||||
* 使用子组件拆分后的简化版本
|
* 使用子组件拆分后的简化版本
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
FolderOpen, Upload, Download, Trash2,
|
FolderOpen, Upload, Download, Trash2,
|
||||||
Edit3, Copy, FileText, ShieldCheck,
|
Edit3, FileText, ShieldCheck,
|
||||||
Unplug, FileCode,
|
Unplug, FileCode,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSFTP } from '../../hooks'
|
import { useSFTP } from '../../hooks'
|
||||||
import { useToast } from '../../hooks/useToast'
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
import { getConnectionStore } from '../../stores/connection'
|
||||||
import { FileListPanel } from './FileListPanel'
|
import { FileListPanel } from './FileListPanel'
|
||||||
import { TransferPanel } from './TransferPanel'
|
import { TransferPanel } from './TransferPanel'
|
||||||
import { Toolbar } from './Toolbar'
|
|
||||||
import { ConnectionPanel } from './ConnectionPanel'
|
import { ConnectionPanel } from './ConnectionPanel'
|
||||||
|
import { TransferRail } from './TransferRail'
|
||||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||||
import { ToastContainer } from '../common/ToastContainer'
|
|
||||||
import EmptyState from '../common/EmptyState'
|
import EmptyState from '../common/EmptyState'
|
||||||
import { sortFiles } from './utils'
|
import { sortFiles } from './utils'
|
||||||
import { usePromptDialog } from '../common/PromptDialog'
|
import { usePromptDialog } from '../common/PromptDialog'
|
||||||
|
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||||
import FilePropertiesDialog from './FilePropertiesDialog'
|
import FilePropertiesDialog from './FilePropertiesDialog'
|
||||||
import ChmodDialog from './ChmodDialog'
|
import ChmodDialog from './ChmodDialog'
|
||||||
import RemoteTextEditor from './RemoteTextEditor'
|
import RemoteTextEditor from './RemoteTextEditor'
|
||||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||||
import { parentPath } from '../../utils/localPath'
|
import { parentPath, joinLocalPath } from '../../utils/localPath'
|
||||||
import type { SftpDragEntry } from './dragDrop'
|
import type { SftpDragEntry } from './dragDrop'
|
||||||
|
import { dndLog, shortPath } from './dragDrop'
|
||||||
|
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
|
||||||
|
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
|
||||||
|
import {
|
||||||
|
conflictNamesFromListing,
|
||||||
|
formatOverwriteMessage,
|
||||||
|
transferDestinationPath,
|
||||||
|
} from './transferConflicts'
|
||||||
|
|
||||||
interface SFTPViewProps {
|
interface SFTPViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
@@ -35,8 +44,8 @@ interface SFTPViewProps {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
|
export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPViewProps) {
|
||||||
const { t } = useTranslation('sftp')
|
const { t } = useTranslation(['sftp', 'errors'])
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
localPath,
|
localPath,
|
||||||
@@ -59,23 +68,32 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
navigateRemoteBack,
|
navigateRemoteBack,
|
||||||
navigateRemoteForward,
|
navigateRemoteForward,
|
||||||
loadRemoteDirectory,
|
loadRemoteDirectory,
|
||||||
|
remoteHomePath,
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
connect,
|
connect,
|
||||||
transferQueue,
|
transferQueue,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
downloadFiles,
|
downloadFiles,
|
||||||
|
cancelTransfer,
|
||||||
removeTransfer,
|
removeTransfer,
|
||||||
|
retryTransfer,
|
||||||
deleteRemote,
|
deleteRemote,
|
||||||
renameRemote,
|
renameRemote,
|
||||||
|
mkdirRemote,
|
||||||
getRemoteStat,
|
getRemoteStat,
|
||||||
chmodRemote,
|
chmodRemote,
|
||||||
error,
|
error,
|
||||||
|
clearError,
|
||||||
sftpId,
|
sftpId,
|
||||||
|
connectionId,
|
||||||
|
localLoading,
|
||||||
|
remoteLoading,
|
||||||
} = useSFTP({ tabId })
|
} = useSFTP({ tabId })
|
||||||
|
|
||||||
const { toasts, toast, removeToast } = useToast()
|
const { toast } = useToast()
|
||||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [sortField, setSortField] = useState<SortField>('name')
|
const [sortField, setSortField] = useState<SortField>('name')
|
||||||
@@ -83,6 +101,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
|
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
|
||||||
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||||
|
const hadConnectedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected) hadConnectedRef.current = true
|
||||||
|
}, [isConnected])
|
||||||
|
|
||||||
// Properties dialog state
|
// Properties dialog state
|
||||||
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
|
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
|
||||||
@@ -93,6 +115,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
|
|
||||||
// Remote text editor
|
// Remote text editor
|
||||||
const [editFile, setEditFile] = useState<FileItem | null>(null)
|
const [editFile, setEditFile] = useState<FileItem | null>(null)
|
||||||
|
/** 工具栏「新建文件夹」作用于当前焦点面板 */
|
||||||
|
const [, setActivePane] = useState<'local' | 'remote'>('remote')
|
||||||
|
/** 仅远程列表错误展示在远程面板(连接错误仍走 ConnectionPanel) */
|
||||||
|
const remoteListError = isConnected ? error : null
|
||||||
|
const lastListErrorToast = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Surface list failures as a short toast (status strip alone is easy to miss).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!remoteListError) {
|
||||||
|
lastListErrorToast.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lastListErrorToast.current === remoteListError) return
|
||||||
|
lastListErrorToast.current = remoteListError
|
||||||
|
toast.warning(remoteListError, 4000)
|
||||||
|
}, [remoteListError, toast])
|
||||||
|
|
||||||
// Auto-connect when sessionId is provided and not connected
|
// Auto-connect when sessionId is provided and not connected
|
||||||
const autoConnectAttempted = useRef(false)
|
const autoConnectAttempted = useRef(false)
|
||||||
@@ -109,6 +147,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
|
// Keep pool idle-suspend from arming while this SFTP tab is in front.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !connectionId || !isConnected) return
|
||||||
|
getConnectionStore().setActiveTab(connectionId, tabId)
|
||||||
|
}, [isActive, connectionId, isConnected, tabId])
|
||||||
|
|
||||||
// Sort handler
|
// Sort handler
|
||||||
const handleSort = useCallback((field: SortField) => {
|
const handleSort = useCallback((field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -155,14 +199,107 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
}, [t, toast])
|
}, [t, toast])
|
||||||
|
|
||||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
|
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
|
||||||
const result = await uploadFiles(sources, targetPath)
|
const dest = targetPath ?? remotePath
|
||||||
|
let pending = sources
|
||||||
|
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
|
||||||
|
let conflicts =
|
||||||
|
dest === remotePath
|
||||||
|
? conflictNamesFromListing(names, remoteFiles)
|
||||||
|
: []
|
||||||
|
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
|
||||||
|
const hits: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const remoteTarget = transferDestinationPath('upload', dest, name)
|
||||||
|
try {
|
||||||
|
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
|
||||||
|
if (result?.success && result.stats) hits.push(name)
|
||||||
|
} catch {
|
||||||
|
// treat as missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conflicts = hits
|
||||||
|
}
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
const overwrite = await confirm({
|
||||||
|
title: t('dialog.overwriteTitle'),
|
||||||
|
message: formatOverwriteMessage(conflicts, 'upload', t),
|
||||||
|
confirmText: t('dialog.overwriteConfirm'),
|
||||||
|
cancelText: t('dialog.overwriteSkip'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!overwrite) {
|
||||||
|
const skip = new Set(conflicts)
|
||||||
|
pending = sources.filter((source) => {
|
||||||
|
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
|
||||||
|
return !skip.has(name)
|
||||||
|
})
|
||||||
|
if (pending.length === 0) {
|
||||||
|
toast.info(t('toast.allSkippedExisting'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsTransferPanelOpen(true)
|
||||||
|
const result = await uploadFiles(pending, dest)
|
||||||
reportTransfer('upload', result)
|
reportTransfer('upload', result)
|
||||||
}, [reportTransfer, uploadFiles])
|
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
|
||||||
|
|
||||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||||
const result = await downloadFiles(files, targetPath)
|
const dest = targetPath ?? localPath
|
||||||
|
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
|
||||||
|
let pending = selected
|
||||||
|
const names = selected.map((file) => file.name)
|
||||||
|
let conflicts =
|
||||||
|
dest === localPath ? conflictNamesFromListing(names, localFiles) : []
|
||||||
|
if (dest !== localPath && window.opsxApi?.localGetStats) {
|
||||||
|
const hits: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const localTarget = transferDestinationPath('download', dest, name)
|
||||||
|
try {
|
||||||
|
const result = (await window.opsxApi.localGetStats(localTarget)) as {
|
||||||
|
success?: boolean
|
||||||
|
stats?: unknown
|
||||||
|
}
|
||||||
|
if (result?.success && result.stats) hits.push(name)
|
||||||
|
} catch {
|
||||||
|
// treat as missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conflicts = hits
|
||||||
|
}
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
const overwrite = await confirm({
|
||||||
|
title: t('dialog.overwriteTitle'),
|
||||||
|
message: formatOverwriteMessage(conflicts, 'download', t),
|
||||||
|
confirmText: t('dialog.overwriteConfirm'),
|
||||||
|
cancelText: t('dialog.overwriteSkip'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!overwrite) {
|
||||||
|
const skip = new Set(conflicts)
|
||||||
|
pending = selected.filter((file) => !skip.has(file.name))
|
||||||
|
if (pending.length === 0) {
|
||||||
|
toast.info(t('toast.allSkippedExisting'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsTransferPanelOpen(true)
|
||||||
|
const result = await downloadFiles(pending, dest)
|
||||||
reportTransfer('download', result)
|
reportTransfer('download', result)
|
||||||
}, [downloadFiles, reportTransfer])
|
}, [
|
||||||
|
confirm,
|
||||||
|
downloadFiles,
|
||||||
|
localFiles,
|
||||||
|
localPath,
|
||||||
|
remoteFiles,
|
||||||
|
remoteSelected,
|
||||||
|
reportTransfer,
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
])
|
||||||
|
|
||||||
// ===== Context menu handlers =====
|
// ===== Context menu handlers =====
|
||||||
|
|
||||||
@@ -184,21 +321,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
}
|
}
|
||||||
}, [prompt, renameRemote, t, toast])
|
}, [prompt, renameRemote, t, toast])
|
||||||
|
|
||||||
// Copy path handler
|
|
||||||
const handleCopyPath = useCallback(async (filePath: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(filePath)
|
|
||||||
toast.success(t('toast.pathCopied'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('toast.pathCopyFailed'))
|
|
||||||
}
|
|
||||||
}, [t, toast])
|
|
||||||
|
|
||||||
// Properties handler
|
// Properties handler
|
||||||
const handleShowProperties = useCallback((file: FileItem) => {
|
const handleShowProperties = useCallback((file: FileItem) => {
|
||||||
setPropertiesFile(file)
|
setPropertiesFile(file)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteRemote = useCallback(async (files: FileItem[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
const hasDir = files.some((file) => file.type === 'directory')
|
||||||
|
const names = files.slice(0, 3).map((file) => file.name).join(', ')
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t('dialog.deleteTitle'),
|
||||||
|
message:
|
||||||
|
t('dialog.deleteMessage', { count: files.length, names }) +
|
||||||
|
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
|
||||||
|
danger: true,
|
||||||
|
confirmText: t('contextMenu.delete'),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
const success = await deleteRemote(files)
|
||||||
|
if (!success) toast.error(t('toast.deleteFailed'))
|
||||||
|
}, [confirm, deleteRemote, t, toast])
|
||||||
|
|
||||||
// Permissions (chmod) handler — fetch stat first, then show dialog
|
// Permissions (chmod) handler — fetch stat first, then show dialog
|
||||||
const handleChmod = useCallback(async (file: FileItem) => {
|
const handleChmod = useCallback(async (file: FileItem) => {
|
||||||
const stats = await getRemoteStat(file.path)
|
const stats = await getRemoteStat(file.path)
|
||||||
@@ -217,7 +361,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
toast.error(t('toast.chmodFailed'))
|
toast.error(t('toast.chmodFailed'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, [chmodRemote, toast])
|
}, [chmodRemote, toast, t])
|
||||||
|
|
||||||
// Local context menu
|
// Local context menu
|
||||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||||
@@ -235,15 +379,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||||
} as MenuItem]
|
} as MenuItem]
|
||||||
: []),
|
: []),
|
||||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
|
|
||||||
{
|
{
|
||||||
icon: <FileText className="w-4 h-4" />,
|
icon: <FileText className="w-4 h-4" />,
|
||||||
label: t('contextMenu.properties'),
|
label: t('contextMenu.properties'),
|
||||||
|
separator: true,
|
||||||
action: () => handleShowProperties(file),
|
action: () => handleShowProperties(file),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||||
}, [navigateLocal, handleCopyPath, handleShowProperties, performUpload])
|
}, [navigateLocal, handleShowProperties, performUpload, t])
|
||||||
|
|
||||||
// Remote context menu
|
// Remote context menu
|
||||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||||
@@ -259,17 +403,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
label: t('contextMenu.download'),
|
label: t('contextMenu.download'),
|
||||||
action: () => void performDownload([file]),
|
action: () => void performDownload([file]),
|
||||||
},
|
},
|
||||||
...(file.type === 'file'
|
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: <FileCode className="w-4 h-4" />,
|
icon: <FileCode className="w-4 h-4" />,
|
||||||
label: t('contextMenu.edit'),
|
label: t('contextMenu.edit'),
|
||||||
action: () => setEditFile(file),
|
action: () => {
|
||||||
|
if (!isWithinRemoteTextEditLimit(file.size)) {
|
||||||
|
toast.warning(t('errors:sftp.editTooLarge'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditFile(file)
|
||||||
|
},
|
||||||
} as MenuItem,
|
} as MenuItem,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
|
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
|
||||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
|
|
||||||
{
|
{
|
||||||
icon: <FileText className="w-4 h-4" />,
|
icon: <FileText className="w-4 h-4" />,
|
||||||
label: t('contextMenu.properties'),
|
label: t('contextMenu.properties'),
|
||||||
@@ -280,10 +429,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
label: t('contextMenu.permissions'),
|
label: t('contextMenu.permissions'),
|
||||||
action: () => handleChmod(file),
|
action: () => handleChmod(file),
|
||||||
},
|
},
|
||||||
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([file]) },
|
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) },
|
||||||
]
|
]
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||||
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
|
||||||
|
|
||||||
// Navigate up
|
// Navigate up
|
||||||
const navigateLocalUp = useCallback(() => {
|
const navigateLocalUp = useCallback(() => {
|
||||||
@@ -294,6 +443,48 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
navigateRemote(parentPath(remotePath, false))
|
navigateRemote(parentPath(remotePath, false))
|
||||||
}, [remotePath, navigateRemote])
|
}, [remotePath, navigateRemote])
|
||||||
|
|
||||||
|
const sortedLocalFiles = useMemo(
|
||||||
|
() => sortFiles(localFiles, sortField, sortOrder),
|
||||||
|
[localFiles, sortField, sortOrder]
|
||||||
|
)
|
||||||
|
const sortedRemoteFiles = useMemo(
|
||||||
|
() => sortFiles(remoteFiles, sortField, sortOrder),
|
||||||
|
[remoteFiles, sortField, sortOrder]
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshLocal = useCallback(() => {
|
||||||
|
void loadLocalDirectory(localPath)
|
||||||
|
}, [loadLocalDirectory, localPath])
|
||||||
|
|
||||||
|
const refreshRemote = useCallback(() => {
|
||||||
|
if (!isConnected) return
|
||||||
|
clearError()
|
||||||
|
void loadRemoteDirectory(remotePath)
|
||||||
|
}, [clearError, isConnected, loadRemoteDirectory, remotePath])
|
||||||
|
|
||||||
|
const focusLocalPane = useCallback(() => setActivePane('local'), [])
|
||||||
|
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
|
||||||
|
|
||||||
|
const navigateRemoteClear = useCallback((p: string) => {
|
||||||
|
clearError()
|
||||||
|
navigateRemote(p)
|
||||||
|
}, [clearError, navigateRemote])
|
||||||
|
|
||||||
|
const navigateRemoteBackClear = useCallback(() => {
|
||||||
|
clearError()
|
||||||
|
navigateRemoteBack()
|
||||||
|
}, [clearError, navigateRemoteBack])
|
||||||
|
|
||||||
|
const navigateRemoteForwardClear = useCallback(() => {
|
||||||
|
clearError()
|
||||||
|
navigateRemoteForward()
|
||||||
|
}, [clearError, navigateRemoteForward])
|
||||||
|
|
||||||
|
const navigateRemoteUpClear = useCallback(() => {
|
||||||
|
clearError()
|
||||||
|
navigateRemoteUp()
|
||||||
|
}, [clearError, navigateRemoteUp])
|
||||||
|
|
||||||
// Handle file open
|
// Handle file open
|
||||||
const handleLocalOpen = useCallback((file: FileItem) => {
|
const handleLocalOpen = useCallback((file: FileItem) => {
|
||||||
if (file.type === 'directory') {
|
if (file.type === 'directory') {
|
||||||
@@ -333,6 +524,14 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
}
|
}
|
||||||
}, [localFiles, localPath, localSelected, performUpload, t])
|
}, [localFiles, localPath, localSelected, performUpload, t])
|
||||||
|
|
||||||
|
const railUpload = useCallback(() => {
|
||||||
|
void handleUpload()
|
||||||
|
}, [handleUpload])
|
||||||
|
|
||||||
|
const railDownload = useCallback(() => {
|
||||||
|
void performDownload()
|
||||||
|
}, [performDownload])
|
||||||
|
|
||||||
// Handle drag-and-drop upload on remote panel
|
// Handle drag-and-drop upload on remote panel
|
||||||
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
||||||
if (!isConnected) return
|
if (!isConnected) return
|
||||||
@@ -340,6 +539,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
const path = (file as File & { path?: string }).path
|
const path = (file as File & { path?: string }).path
|
||||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||||
})
|
})
|
||||||
|
dndLog('dispatch-os-upload', {
|
||||||
|
target: shortPath(targetPath),
|
||||||
|
fileCount: files.length,
|
||||||
|
withPath: sources.length,
|
||||||
|
})
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
toast.warning(t('toast.noDragPath'))
|
toast.warning(t('toast.noDragPath'))
|
||||||
return
|
return
|
||||||
@@ -347,83 +551,128 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
void performUpload(sources, targetPath)
|
void performUpload(sources, targetPath)
|
||||||
}, [isConnected, performUpload, toast, t])
|
}, [isConnected, performUpload, toast, t])
|
||||||
|
|
||||||
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
|
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
|
||||||
if (!isConnected) return
|
if (!isConnected || entries.length === 0) return
|
||||||
if (entry.source === 'local') {
|
const localEntries = entries.filter((entry) => entry.source === 'local')
|
||||||
|
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
|
||||||
|
dndLog('dispatch', {
|
||||||
|
target: shortPath(targetPath),
|
||||||
|
upload: localEntries.length,
|
||||||
|
download: remoteEntries.length,
|
||||||
|
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
|
||||||
|
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
|
||||||
|
via: 'pointer',
|
||||||
|
})
|
||||||
|
if (localEntries.length > 0) {
|
||||||
void performUpload(
|
void performUpload(
|
||||||
[{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }],
|
localEntries.map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
name: entry.name,
|
||||||
|
size: entry.size,
|
||||||
|
isDirectory: entry.type === 'directory',
|
||||||
|
})),
|
||||||
targetPath
|
targetPath
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
void performDownload([{
|
if (remoteEntries.length > 0) {
|
||||||
|
void performDownload(
|
||||||
|
remoteEntries.map((entry) => ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
size: entry.size,
|
size: entry.size,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
permissions: '',
|
permissions: '',
|
||||||
}], targetPath)
|
})),
|
||||||
|
targetPath
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [isConnected, performDownload, performUpload])
|
}, [isConnected, performDownload, performUpload])
|
||||||
|
|
||||||
// Handle new folder
|
const handleDropFailed = useCallback(() => {
|
||||||
const handleNewFolder = useCallback(async () => {
|
dndLog('toast-drop-failed')
|
||||||
|
toast.warning(t('toast.dropFailed'))
|
||||||
|
}, [t, toast])
|
||||||
|
|
||||||
|
const handleNewLocalFolder = useCallback(async () => {
|
||||||
const name = await prompt({
|
const name = await prompt({
|
||||||
title: t('dialog.newFolderTitle'),
|
title: t('dialog.newFolderTitle'),
|
||||||
message: t('dialog.newFolderMessage'),
|
message: t('dialog.newFolderMessage'),
|
||||||
placeholder: t('dialog.newFolderPlaceholder'),
|
placeholder: t('dialog.newFolderPlaceholder'),
|
||||||
})
|
})
|
||||||
if (name && window.opsxApi?.localMkdir) {
|
if (!name || !window.opsxApi?.localMkdir) return
|
||||||
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
|
const fullPath = joinLocalPath(localPath, name.trim())
|
||||||
loadLocalDirectory(localPath)
|
try {
|
||||||
})
|
await window.opsxApi.localMkdir(fullPath)
|
||||||
|
await loadLocalDirectory(localPath)
|
||||||
|
toast.success(t('toast.folderCreated', { name: name.trim() }))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('errors.mkdirFailed'))
|
||||||
}
|
}
|
||||||
}, [localPath, loadLocalDirectory, prompt, t])
|
}, [localPath, loadLocalDirectory, prompt, t, toast])
|
||||||
|
|
||||||
|
const handleNewRemoteFolder = useCallback(async () => {
|
||||||
|
const name = await prompt({
|
||||||
|
title: t('dialog.newFolderTitle'),
|
||||||
|
message: t('dialog.newFolderMessage'),
|
||||||
|
placeholder: t('dialog.newFolderPlaceholder'),
|
||||||
|
})
|
||||||
|
if (!name) return
|
||||||
|
const ok = await mkdirRemote(name.trim(), remotePath)
|
||||||
|
if (ok) {
|
||||||
|
toast.success(t('toast.folderCreated', { name: name.trim() }))
|
||||||
|
} else {
|
||||||
|
toast.error(t('errors.mkdirFailed'))
|
||||||
|
}
|
||||||
|
}, [mkdirRemote, prompt, remotePath, t, toast])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-900">
|
<div className="relative flex h-full flex-col bg-dark-900">
|
||||||
{/* Connection Status */}
|
{/* Connection Status */}
|
||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isConnecting={isConnecting}
|
isConnecting={isConnecting}
|
||||||
error={error}
|
error={error}
|
||||||
|
resumePath={!isConnected && hadConnectedRef.current ? remotePath : null}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
showSelector={showConnectionSelector}
|
showSelector={showConnectionSelector}
|
||||||
onConnect={(sessionId) => sessionId && connect(sessionId)}
|
onConnect={(id) => id && connect(id)}
|
||||||
|
onReconnect={() => {
|
||||||
|
clearError()
|
||||||
|
autoConnectAttempted.current = false
|
||||||
|
if (sessionId) {
|
||||||
|
autoConnectAttempted.current = true
|
||||||
|
void connect(sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowConnectionSelector(true)
|
||||||
|
}}
|
||||||
onShowSelector={() => setShowConnectionSelector(true)}
|
onShowSelector={() => setShowConnectionSelector(true)}
|
||||||
onCloseSelector={() => setShowConnectionSelector(false)}
|
onCloseSelector={() => setShowConnectionSelector(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<Toolbar
|
|
||||||
isConnected={isConnected}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
|
|
||||||
onDownload={() => void performDownload()}
|
|
||||||
onRefresh={() => {
|
|
||||||
loadLocalDirectory(localPath)
|
|
||||||
if (isConnected) loadRemoteDirectory(remotePath)
|
|
||||||
}}
|
|
||||||
onNewFolder={handleNewFolder}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* File Panels */}
|
{/* File Panels */}
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Unplug}
|
icon={Unplug}
|
||||||
title={t('panel.notConnectedTitle')}
|
title={t('panel.notConnectedTitle')}
|
||||||
description={t('panel.notConnectedDesc')}
|
description={
|
||||||
|
hadConnectedRef.current && remotePath
|
||||||
|
? t('panel.reconnectResumeDesc', { path: remotePath })
|
||||||
|
: t('panel.notConnectedDesc')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
<div className="flex-1 flex gap-2 p-4 min-h-0">
|
||||||
{/* Local Panel */}
|
{/* Local Panel */}
|
||||||
<FileListPanel
|
<FileListPanel
|
||||||
title={t('panel.local')}
|
title={t('panel.local')}
|
||||||
isLocal={true}
|
isLocal={true}
|
||||||
path={localPath}
|
path={localPath}
|
||||||
files={sortFiles(localFiles, sortField, sortOrder)}
|
homePath={localHistory[0] || localPath}
|
||||||
|
files={sortedLocalFiles}
|
||||||
selected={localSelected}
|
selected={localSelected}
|
||||||
history={localHistory}
|
history={localHistory}
|
||||||
historyIndex={localHistoryIndex}
|
historyIndex={localHistoryIndex}
|
||||||
@@ -437,9 +686,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
onOpen={handleLocalOpen}
|
onOpen={handleLocalOpen}
|
||||||
onContextMenu={showLocalContextMenu}
|
onContextMenu={showLocalContextMenu}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
contextMenu={contextMenu}
|
|
||||||
onCloseContextMenu={() => setContextMenu(null)}
|
|
||||||
onDropEntry={handleDropEntry}
|
onDropEntry={handleDropEntry}
|
||||||
|
onDropFailed={handleDropFailed}
|
||||||
|
onRefresh={refreshLocal}
|
||||||
|
onNewFolder={handleNewLocalFolder}
|
||||||
|
onFocusPane={focusLocalPane}
|
||||||
|
refreshing={localLoading}
|
||||||
|
loading={localLoading && localFiles.length === 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransferRail
|
||||||
|
canUpload={isConnected}
|
||||||
|
canDownload={isConnected && remoteSelected.size > 0}
|
||||||
|
onUpload={railUpload}
|
||||||
|
onDownload={railDownload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Remote Panel */}
|
{/* Remote Panel */}
|
||||||
@@ -447,25 +707,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
title={t('panel.remote')}
|
title={t('panel.remote')}
|
||||||
isLocal={false}
|
isLocal={false}
|
||||||
path={remotePath}
|
path={remotePath}
|
||||||
files={sortFiles(remoteFiles, sortField, sortOrder)}
|
homePath={remoteHomePath}
|
||||||
|
files={sortedRemoteFiles}
|
||||||
selected={remoteSelected}
|
selected={remoteSelected}
|
||||||
history={remoteHistory}
|
history={remoteHistory}
|
||||||
historyIndex={remoteHistoryIndex}
|
historyIndex={remoteHistoryIndex}
|
||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onNavigate={navigateRemote}
|
onNavigate={navigateRemoteClear}
|
||||||
onNavigateBack={navigateRemoteBack}
|
onNavigateBack={navigateRemoteBackClear}
|
||||||
onNavigateForward={navigateRemoteForward}
|
onNavigateForward={navigateRemoteForwardClear}
|
||||||
onNavigateUp={navigateRemoteUp}
|
onNavigateUp={navigateRemoteUpClear}
|
||||||
onSelect={toggleRemoteSelect}
|
onSelect={toggleRemoteSelect}
|
||||||
onOpen={handleRemoteOpen}
|
onOpen={handleRemoteOpen}
|
||||||
onContextMenu={showRemoteContextMenu}
|
onContextMenu={showRemoteContextMenu}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
contextMenu={contextMenu}
|
|
||||||
onCloseContextMenu={() => setContextMenu(null)}
|
|
||||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||||
onDropEntry={handleDropEntry}
|
onDropEntry={handleDropEntry}
|
||||||
loading={isConnecting}
|
onDropFailed={handleDropFailed}
|
||||||
|
onRefresh={isConnected ? refreshRemote : undefined}
|
||||||
|
onNewFolder={handleNewRemoteFolder}
|
||||||
|
onFocusPane={focusRemotePane}
|
||||||
|
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
|
||||||
|
refreshing={remoteLoading}
|
||||||
|
listError={remoteListError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -475,7 +740,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
tasks={transferQueue}
|
tasks={transferQueue}
|
||||||
isOpen={isTransferPanelOpen}
|
isOpen={isTransferPanelOpen}
|
||||||
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
||||||
|
onCancel={cancelTransfer}
|
||||||
onRemove={removeTransfer}
|
onRemove={removeTransfer}
|
||||||
|
onRetry={retryTransfer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Global Context Menu */}
|
{/* Global Context Menu */}
|
||||||
@@ -488,6 +755,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{PromptDialogComponent}
|
{PromptDialogComponent}
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
|
||||||
{/* File Properties Dialog */}
|
{/* File Properties Dialog */}
|
||||||
{propertiesFile && (
|
{propertiesFile && (
|
||||||
@@ -527,9 +795,6 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
|||||||
onSaved={() => toast.success(t('editor.saved'))}
|
onSaved={() => toast.success(t('editor.saved'))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -13,10 +13,10 @@ interface SortHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIELDS: { key: SortField; className: string }[] = [
|
const FIELDS: { key: SortField; className: string }[] = [
|
||||||
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
|
{ key: 'name', className: 'sftp-col-name flex-1 min-w-0 justify-start' },
|
||||||
{ key: 'modified', className: 'w-32 justify-end' },
|
{ key: 'modified', className: 'sftp-col-modified w-28 flex-shrink-0 justify-end' },
|
||||||
{ key: 'size', className: 'w-24 justify-end' },
|
{ key: 'size', className: 'sftp-col-size w-16 flex-shrink-0 justify-end' },
|
||||||
{ key: 'type', className: 'w-24 justify-end' },
|
{ key: 'type', className: 'sftp-col-type w-16 flex-shrink-0 justify-end' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||||
|
|||||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user