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:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

-42
View File
@@ -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
View File
@@ -52,5 +52,6 @@ src-tauri/target/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# ZCode workspace # Local agent / IDE workspace
.zcode/ .zcode/
.claude/
+27 -6
View File
@@ -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 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
+19 -2
View File
@@ -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。
+24 -27
View File
@@ -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 本地/远程路径强类型传输边界 shared/types/ 前后端共享 TypeScript 契约
src/platform/rdp.ts RDP 会话适配 src-tauri/ Rust / Tauri 原生后端
src/platform/vnc.ts VNC 会话适配 test/ 前端单元、组件与巡检测试
src/utils/terminalPipeline.ts 终端输入、输出和背压管线 test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
shared/types/ 前后端共享 TypeScript 契约 scripts/ 启动、验收、构建与发布脚本
src-tauri/src/commands/ 精简的 Tauri 命令边界 docs/ 使用 / 架构 / 验收 / 发布文档
src-tauri/src/domain/ 原生领域模型 docs/assets/ 品牌与设计资源
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、桌面、AI、存储服务 license-server/ 授权与结账服务
src-tauri/src/services/ssh/ 认证、known_hosts、代理/跳板和 SFTP 子模块
src-tauri/src/services/tunnel/ 转发运行模型、SOCKS5 协议和双向 Relay
src-tauri/src/services/desktop/ RDP(IronRDP)与 VNC(vnc-rs)
test/ 前端测试与基础验收
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
View File
@@ -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` 堆回桌面进程。
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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) 维护。
+7 -5
View File
@@ -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
View File
@@ -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`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
+9
View File
@@ -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) 为准。不要在本文件再维护一份进度,避免漂移。
+1 -1
View File
@@ -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
View File
@@ -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 对齐。验收与单元测试默认中文文案。
## 本地数据 ## 本地数据
-175
View File
@@ -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 或隧道问题都按未通过处理。
+6 -4
View File
@@ -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
View File
@@ -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",
+2 -43
View File
@@ -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
+1 -1
View File
@@ -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]],
] ]
+3 -3
View File
@@ -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/)
+6 -6
View File
@@ -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 -6
View File
@@ -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
+1 -1
View File
@@ -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()
+31
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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
} }
// 连接结果 // 连接结果
+2
View File
@@ -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
+84 -14
View File
@@ -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",
] ]
+5 -2
View File
@@ -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"
+3 -1
View File
@@ -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
View File
@@ -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"]}}
+10
View File
@@ -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)
+1 -1
View File
@@ -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))
+22 -3
View File
@@ -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())
}
+198 -72
View File
@@ -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(
success: true, match tauri::async_runtime::spawn_blocking(move || service.sftp_list(&sftp_id, &path)).await
files: Some(files), {
error: None, Ok(Ok(files)) => SftpListResponse {
success: true,
files: Some(files),
error: None,
},
Ok(Err(error)) => SftpListResponse {
success: false,
files: None,
error: Some(error),
},
Err(error) => SftpListResponse {
success: false,
files: None,
error: Some(error.to_string()),
},
}, },
Err(error) => SftpListResponse { )
success: false,
files: None,
error: Some(error),
},
}
} }
#[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,40 +399,63 @@ 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,
let mode = stat.perm.unwrap_or(0); path: String,
SftpStatResponse { ) -> Result<SftpStatResponse, String> {
success: true, let service = state.ssh.clone();
stats: Some(SftpStat { Ok(
size: stat.size.unwrap_or(0), match tauri::async_runtime::spawn_blocking(move || service.sftp_stat(&sftp_id, &path)).await
mode, {
mtime: stat.mtime.unwrap_or(0), Ok(Ok(stat)) => {
atime: stat.atime.unwrap_or(0), let mode = stat.perm.unwrap_or(0);
is_directory: mode & 0o170000 == 0o040000, SftpStatResponse {
is_file: mode & 0o170000 == 0o100000, success: true,
is_symbolic_link: mode & 0o170000 == 0o120000, stats: Some(SftpStat {
}), size: stat.size.unwrap_or(0),
error: None, mode,
mtime: stat.mtime.unwrap_or(0),
atime: stat.atime.unwrap_or(0),
is_directory: mode & 0o170000 == 0o040000,
is_file: mode & 0o170000 == 0o100000,
is_symbolic_link: mode & 0o170000 == 0o120000,
}),
error: None,
}
} }
} Ok(Err(error)) => SftpStatResponse {
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(
success: true, match tauri::async_runtime::spawn_blocking(move || service.sftp_read_text(&sftp_id, &path))
content: Some(content), .await
size: Some(size), {
encoding: Some("utf-8"), Ok(Ok((content, size))) => SftpReadTextResponse {
error: None, success: true,
content: Some(content),
size: Some(size),
encoding: Some("utf-8"),
error: None,
},
Ok(Err(error)) => SftpReadTextResponse {
success: false,
content: None,
size: None,
encoding: None,
error: Some(error),
},
Err(error) => SftpReadTextResponse {
success: false,
content: None,
size: None,
encoding: None,
error: Some(error.to_string()),
},
}, },
Err(error) => SftpReadTextResponse { )
success: false,
content: None,
size: None,
encoding: None,
error: Some(error),
},
}
} }
#[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,
+21
View File
@@ -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)
}
+245
View File
@@ -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);
}
}
+2
View File
@@ -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
View File
@@ -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" => {
+404
View File
@@ -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");
}
}
+1 -1
View File
@@ -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(())
+24
View File
@@ -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
} }
+7
View File
@@ -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
+426 -38
View File
@@ -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.extend_from_slice(&data[start..end]);
} }
out 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);
+1 -1
View File
@@ -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(())
+608 -112
View File
@@ -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);
let sig = sign_payload(&payload_b64, secret)?;
Ok(format!("opsx1.{payload_b64}.{sig}"))
}
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
let trimmed = token.trim();
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() != 3 || parts[0] != "opsx1" {
return Err("许可证格式无效".to_string());
} }
let expected = sign_payload(parts[1], secret)?; if let Some(exp) = stored.expires_at {
if expected != parts[2] { if exp > 0 && exp < now_secs() {
return Err("许可证签名无效".to_string()); return free_status(device_id, stored.email.clone());
}
let bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| "许可证载荷损坏".to_string())?;
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 now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now > exp {
return Err("许可证已过期".to_string());
} }
} }
Ok(payload) 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(),
}
}
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
return Ok(None);
}
let stored: StoredLicense =
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
Ok(Some(stored))
}
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), 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,
}
}
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)?;
write_stored(&path, &stored)?;
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)?; let path = license_path(app)?;
ensure_parent(&path)?; write_stored(&path, &stored)?;
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?; 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());
} }
} }
+147
View File
@@ -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,9 +257,100 @@ 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(&current);
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") {
Ok(lang) => { Ok(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}"
);
}
} }
+87 -24
View File
@@ -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 = {
.lock() let sftps = self.sftps.lock().map_err(lock_error)?;
.map_err(lock_error)? sftps
.retain(|_, id| id != connection_id); .iter()
.filter_map(|(id, handle)| {
handle
.lock()
.ok()
.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());
} }
let _ = sftp.unlink(Path::new(&remote_path));
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
Ok(()) Ok(())
})(); });
let _ = sftp.unlink(Path::new(&remote_path));
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
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");
+1 -1
View File
@@ -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))
+486 -115
View File
@@ -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,39 +15,103 @@ 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}"))?
.into_iter() .into_iter()
.filter_map(|(path, stat)| { .filter_map(|(path, stat)| {
let filename = path.file_name()?.to_string_lossy().to_string(); let filename = path.file_name()?.to_string_lossy().to_string();
if filename == "." || filename == ".." { if filename == "." || filename == ".." {
return None; return None;
} }
Some(dir_entry(filename, stat)) Some(dir_entry(filename, stat))
}) })
.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");
upload_file( let cancel = self.begin_sftp_transfer(sftp_id)?;
&sftp, let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
&expand_home(local_path), let result = self.with_transfer_sftp(sftp_id, |sftp| {
remote_path, upload_file(
|transferred, total| emit_progress(app, sftp_id, transferred, total), sftp,
) &expand_home(local_path),
remote_path,
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 local_path = expand_home(local_path); let cancel = self.begin_sftp_transfer(sftp_id)?;
download_file(&sftp, remote_path, &local_path, |transferred, total| { let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
emit_progress(app, sftp_id, transferred, total) let result = self.with_transfer_sftp(sftp_id, |sftp| {
}) let local_path = expand_home(local_path);
download_file(
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");
upload_dir( let cancel = self.begin_sftp_transfer(sftp_id)?;
&sftp, let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
&expand_home(local_dir), let result = self.with_transfer_sftp(sftp_id, |sftp| {
remote_dir, upload_dir(
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total), sftp,
) &expand_home(local_dir),
remote_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, "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 local_dir = expand_home(local_dir); let cancel = self.begin_sftp_transfer(sftp_id)?;
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| { let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
emit_progress(app, sftp_id, transferred, total) let result = self.with_transfer_sftp(sftp_id, |sftp| {
}) let local_dir = expand_home(local_dir);
download_dir(
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,31 +230,34 @@ 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}"))
} else { } else {
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 {
let mut file = sftp return Err(format!(
.create(Path::new(remote_path)) "文件过大({}),编辑上限约 2MB",
.map_err(|error| format!("创建远程文件失败: {error}"))?; format_byte_size(content.len() as u64)
file.write_all(content.as_bytes()) ));
.map_err(|error| format!("写入远程文件失败: {error}")) }
self.with_browse_sftp(sftp_id, |sftp| {
let mut file = sftp
.create(Path::new(remote_path))
.map_err(|error| format!("创建远程文件失败: {error}"))?;
file.write_all(content.as_bytes())
.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)
.sftp() }
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
/// 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()
.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 {
.saturating_mul(100) app: AppHandle,
.checked_div(total) sftp_id: String,
.unwrap_or(100) last_emit: Instant,
.min(100) as u8; last_percent: u8,
let _ = app.emit( }
"sftp:progress",
SftpProgressEvent { impl ProgressEmitter {
fn new(app: AppHandle, sftp_id: &str) -> Self {
Self {
app,
sftp_id: sftp_id.to_string(), sftp_id: sftp_id.to_string(),
transferred, // Force first emit after start.
total, last_emit: Instant::now()
percentage, .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)
.checked_div(total)
.unwrap_or(100)
.min(100) as u8
};
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",
SftpProgressEvent {
sftp_id: self.sftp_id.clone(),
transferred,
total,
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
View File
@@ -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();
}
} }
+3
View File
@@ -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>,
+137
View File
@@ -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}")),
}
}
}
+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+52 -31
View File
@@ -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,32 +141,38 @@ export default function AppShell({
/> />
)} )}
<aside {!desktopFullscreen && (
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${ <aside
isMobile className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}` isMobile
: desktopSidebarHidden ? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
? 'w-0 overflow-hidden border-0' : desktopSidebarHidden
: '' ? 'w-0 overflow-hidden border-0'
} `} : ''
aria-hidden={desktopSidebarHidden} } `}
> aria-hidden={desktopSidebarHidden}
<Sidebar >
onNewSession={handleSidebarNewSession} <Sidebar
onQuickConnect={handleSidebarQuickConnect} onNewSession={handleSidebarNewSession}
onEditSession={handleSidebarEditSession} onQuickConnect={handleSidebarQuickConnect}
onCollapse={isMobile ? undefined : onToggleSidebar} onEditSession={handleSidebarEditSession}
/> onCollapse={isMobile ? undefined : onToggleSidebar}
</aside> />
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col bg-dark-800"> <main
<div className="flex items-stretch"> className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />} >
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />} {!desktopFullscreen && (
<div className="min-w-0 flex-1"> <div className="flex items-stretch">
<TabBar onOpenLauncher={onOpenLauncher} /> {isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<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>
<div className="titlebar-no-drag"> {!desktopFullscreen && (
<StatusBar /> <div className="titlebar-no-drag">
</div> <StatusBar />
</div>
)}
<div className="titlebar-no-drag">{children}</div> <div className="titlebar-no-drag">{children}</div>
</div> </div>
+1
View File
@@ -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
+1 -1
View File
@@ -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(() => {
+53 -7
View File
@@ -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
}
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()
}
} }
// Lock body scroll while open
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>
+1
View File
@@ -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}
+17 -17
View File
@@ -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',
}, },
} }
+8 -8
View File
@@ -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 (
+277 -55
View File
@@ -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') {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
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,
})
}
return t('portForward.remoteToLocal', { return t('portForward.remoteToLocal', {
remote: rule.remotePort, bind,
remote,
host,
local: rule.localPort, local: rule.localPort,
}) })
} }
const host = rule.remoteHost || 'localhost' const bind = resolveLocalBindHost(rule.localBindHost)
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}` 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' ? (
<div> <>
<label className="ui-label"> {viaSession ? (
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')} <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">
</label> {t('portForward.viaSession', { target: viaSession })}
<input </p>
type="number" ) : null}
min={1} <div>
max={65535} <label className="ui-label">{t('portForward.localListenPort')}</label>
value={draft.localPort || ''} <input
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })} type="number"
placeholder={t('portForward.portPlaceholder')} min={1}
className="ui-input font-mono" max={65535}
inputMode="numeric" value={draft.localPort || ''}
/> onChange={(e) =>
</div> onChange({
<div> ...draft,
<label className="ui-label"> localPort: Number(e.target.value),
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')} localBindHost: resolveLocalBindHost(draft.localBindHost),
</label> })
<input }
type="number" placeholder="8080"
min={1} className="ui-input font-mono"
max={65535} inputMode="numeric"
value={draft.remotePort || ''} />
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })} <p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
placeholder={t('portForward.portPlaceholder')} {t('portForward.localListenPortHint')}
className="ui-input font-mono" </p>
inputMode="numeric" </div>
/> <div>
</div> <label className="ui-label">{t('portForward.targetAddress')}</label>
</div> <input
type="text"
{type === 'local' && ( value={formatHostPort(
<div> resolveForwardTargetHost(draft.remoteHost),
<label className="ui-label">{t('portForward.remoteHost')}</label> draft.remotePort
<input )}
type="text" onChange={(e) => {
value={draft.remoteHost || ''} const raw = e.target.value.trim()
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })} const parsed = parseHostPort(raw)
placeholder={t('portForward.remoteHostPlaceholder')} if (parsed) {
className="ui-input font-mono" onChange({
spellCheck={false} ...draft,
/> remoteHost: resolveForwardTargetHost(parsed.host),
</div> 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
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) =>
onChange({
...draft,
remotePort: Number(e.target.value),
remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost),
})
}
placeholder="18080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.remoteListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
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"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localTargetAddressHint')}
</p>
</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>
)} )}
+109 -17
View File
@@ -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)
local: rule.localPort, 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,
})
}
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()
}
+88 -1
View File
@@ -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) {
+18 -24
View File
@@ -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>
)
}
+291 -94
View File
@@ -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,91 +606,140 @@ 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 ${
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" /> fullscreenActive
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}> ? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
{hostLabel} : 'border-b border-dark-700/50 bg-dark-800/80'
</span> }`}
<span className="tabular-nums text-txt-faint"> onMouseEnter={() => {
{fbSize.width}×{fbSize.height} if (fullscreenActive) {
</span> setChromeVisible(true)
{tab?.connectionStatus === 'connecting' ? ( if (hideBarTimerRef.current != null) {
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" /> window.clearTimeout(hideBarTimerRef.current)
) : ( hideBarTimerRef.current = null
<span }
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${ }
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500' }}
}`} onMouseLeave={() => {
/> if (fullscreenActive) scheduleHideChrome()
}}
>
<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}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
)}
<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>}
{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">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)} )}
<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>}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5"> <button
<button type="button"
type="button" className={toolBtn}
className={toolBtn} onClick={(event) => {
disabled={!connected} event.preventDefault()
onClick={() => void pasteToRemote()} event.stopPropagation()
title={t('toolbar.pasteClipboard')} void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
aria-label={t('toolbar.pasteAria')} }}
> title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
<ClipboardPaste className="h-3.5 w-3.5" /> aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
</button> >
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
{protocol === 'rdp' && ( </button>
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<button
type="button"
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
disabled={!connected}
onClick={() => void disconnectSession()}
title={t('toolbar.disconnect')}
aria-label={t('toolbar.disconnectAria')}
>
<Unplug className="h-3.5 w-3.5" />
</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)}
+18 -7
View File
@@ -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 },
+132 -54
View File
@@ -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) => tunnelCoversRule([tunnel], rule))
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running) if (match) {
if (match) { result = await window.opsxApi.sshForwardStop(match.forwardId)
const stopMatch = await window.opsxApi.sshForwardStop(match.forwardId)
if (!stopMatch?.success) {
setForwardDrawerError(
stopMatch?.error || result?.error || t('common:portForward.errorStopFailed')
)
return false
}
await refreshForwardState()
return true
}
} }
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed')) }
if (!result?.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
)
return false return false
} }
if (intentKey) forgetForwardIntent(intentKey, rule.id)
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'
port: resolveDynamicPort(target.localPort), ? t('common:statusBar.forwardStartedDynamic', {
}) 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,
}) })
+20 -2
View File
@@ -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>
) )
+5 -1
View File
@@ -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()
+27 -4
View File
@@ -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' : ''}
`} `}
> >
+225 -54
View File
@@ -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}
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() }) {plan === 'pro'
: t('pro.lifetime')} ? expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: 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>
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div> {/* Yearly */}
<ul className="space-y-2"> <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
+9 -3
View File
@@ -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>
+9 -6
View File
@@ -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()
+1 -1
View File
@@ -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 () => {
+53 -6
View File
@@ -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>
+78 -68
View File
@@ -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 htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label> <input
<input id="session-password"
id="session-width" type="password"
type="number" value={formData.password}
value={formData.width} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })} placeholder="••••••••"
className="ui-input font-mono" className="ui-input"
/> autoComplete="current-password"
</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>
)} )}
<div> {formData.protocol !== 'ssh' && (
<label htmlFor="session-group" className="ui-label"> <DesktopResolutionPicker
<Folder className="inline w-3 h-3 mr-0.5" /> ns="sessions"
{t('dialog.group')} width={formData.width}
</label> height={formData.height}
<select onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
id="session-group" />
value={normalizeGroupId(formData.group)} )}
onChange={(e) => { {/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
const value = e.target.value {formData.protocol === 'rdp' && groupField}
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>
</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>
{/* 高级 */} {/* 高级 */}
+7 -2
View File
@@ -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 ? (
+348
View File
@@ -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>
+66 -61
View File
@@ -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'}`}>
+3 -1
View File
@@ -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>
+28 -7
View File
@@ -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>
+6 -4
View File
@@ -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>
+59 -8
View File
@@ -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
) )
} }
+415 -131
View File
@@ -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) => ( <>
<FileRow {parentEntry && (
key={file.name} <FileRow
file={file} key="__parent__"
isSelected={selected.has(file.name)} file={parentEntry}
onSelect={() => onSelect(file.name)} isSelected={false}
onOpen={() => onOpen(file)} onSelect={() => {}}
onContextMenu={(e) => onContextMenu(e, file)} onOpen={() => onNavigate(parentEntry.path)}
onDragStart={(event) => { onContextMenu={(e) => {
writeSftpDragEntry(event.dataTransfer, { e.preventDefault()
source: isLocal ? 'local' : 'remote', }}
name: file.name, />
path: file.path, )}
type: file.type, {filteredFiles.map((file) => (
size: file.size, <FileRow
}) key={file.name}
}} file={file}
onDragEnd={resetDragState} isSelected={selected.has(file.name)}
onDragEnter={file.type === 'directory' ? (event) => { onSelect={() => {
if (!acceptsDrag(event.dataTransfer)) return if (suppressNextClickRef.current) return
event.preventDefault() onSelect(file.name)
event.stopPropagation() }}
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null) onOpen={() => onOpen(file)}
setIsDragOver(true) onContextMenu={(e) => onContextMenu(e, file)}
setDropTargetPath(file.path) onPointerDown={(event) => startPointerDrag(file, event)}
} : undefined} onPointerMove={movePointerDrag}
onDragLeave={file.type === 'directory' ? (event) => { onPointerUp={finishPointerDrag}
event.preventDefault() onPointerCancel={cancelPointerDrag}
event.stopPropagation() onDragEnter={file.type === 'directory' ? (event) => {
setDropTargetPath((current) => current === file.path ? null : current) if (!acceptsOsFileDrag(event.dataTransfer)) return
} : undefined} event.preventDefault()
onDragOver={file.type === 'directory' ? (event) => { setDragSource(null)
if (!acceptsDrag(event.dataTransfer)) return setIsDragOver(true)
event.preventDefault() setDropTargetPath(file.path)
event.stopPropagation() } : undefined}
event.dataTransfer.dropEffect = 'copy' onDragLeave={file.type === 'directory' ? (event) => {
} : undefined} event.preventDefault()
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined} setDropTargetPath((current) => current === file.path ? null : current)
isDropTarget={dropTargetPath === file.path} } : undefined}
/> onDragOver={file.type === 'directory' ? (event) => {
)) if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOsOnDirectory(event, file) : undefined}
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">
<Upload className="w-10 h-10" /> {isLocal || dragSource === 'remote' ? (
<Download 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>
) )
} })
+24 -7
View File
@@ -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'
+196 -94
View File
@@ -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) {
const ok = await confirm({
title: t('editor.unsavedTitle'),
message: t('editor.unsavedClose'),
danger: true,
confirmText: t('editor.discardAndClose'),
})
if (!ok) return
}
onClose() 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 })}
<div className="min-w-0"> >
<div className="truncate text-sm font-medium text-content-primary"> <header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
{fileName} <div className="flex min-w-0 flex-1 items-center gap-2.5">
{dirty && <span className="ml-1 text-yellow-400">•</span>} <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span> <FileCode className="h-4 w-4" />
</div> </div>
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div> <div className="min-w-0">
<div className="flex items-center gap-2 truncate text-sm font-medium text-content-primary">
<span className="truncate">{fileName}</span>
{dirty && (
<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 className="truncate font-mono text-[11px] text-txt-faint" title={remotePath}>
{remotePath}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> </div>
<button
type="button" <div className="flex flex-shrink-0 items-center gap-1.5">
disabled={!dirty || saving || loading} <button
onClick={() => void handleSave()} type="button"
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" onClick={() => void handleReload()}
> disabled={loading || saving}
<Save className="h-3.5 w-3.5" /> 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"
{saving ? t('editor.saving') : t('editor.save')} title={t('editor.reload')}
</button> >
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700"> <RotateCcw className="h-3.5 w-3.5" />
<X className="h-4 w-4" /> <span className="hidden sm:inline">{t('editor.reload')}</span>
</button> </button>
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
dirty
? 'bg-accent-blue text-white hover:bg-blue-500'
: 'bg-dark-700 text-txt-muted'
}`}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<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" />
</button>
</div>
</header>
{error && (
<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 bg-[#1e1e1e]">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('common:status.loading')}
</div> </div>
</div> ) : error && !content ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
{error && ( {t('editor.loadBlocked')}
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div> </div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('editor.loadingEditor')}
</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme={effective === 'light' ? 'vs' : 'vs-dark'}
value={content}
onChange={(v) => setContent(v ?? '')}
options={editorOptions}
/>
</Suspense>
)} )}
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme="vs-dark"
value={content}
onChange={(v) => setContent(v ?? '')}
options={{
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>
)}
</div>
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</div>
</div> </div>
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</footer>
{ConfirmDialogComponent}
</div> </div>
) )
} }
+346 -81
View File
@@ -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
)
}
if (remoteEntries.length > 0) {
void performDownload(
remoteEntries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
})),
targetPath targetPath
) )
} else {
void performDownload([{
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
}], 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>
) )
} }
+4 -4
View File
@@ -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