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
*.sqlite3
# ZCode workspace
# Local agent / IDE workspace
.zcode/
.claude/
+27 -6
View File
@@ -5,7 +5,7 @@ OpsXShell 是基于 Tauri 2、Rust、React 和 TypeScript 的轻量运维终端
## 架构
- `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/sftp.ts`:固定 `localPath`/`remotePath` 方向,区分远程文本读写与文件传输。
- `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/`:认证、known_hosts、代理/跳板、路径和 SFTP,禁止重新堆回 `ssh.rs`。
- `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 契约。
- `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。
- 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`。
- 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
- RDP / VNC 仅在 Tauri 原生应用中可用;会话运行态不落盘,断开即清理。批量命令排除桌面会话。
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
@@ -40,10 +53,18 @@ React 不直接访问 SSH、文件系统或加密实现。新增原生能力时
## 命令
```bash
npm run dev
./run.sh # 开发启动(scripts/run.sh)
npm run test:patrol
npm run acceptance:quick
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) 为准。
核心产品范围: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
轻量原生的跨平台运维终端。产品目标是让常用远程操作更直接,不做审计平台,也不依赖插件才能完成基础工作。
简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。
## 已支持
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面中英双语(zh-CN / en)
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC)
- SFTP:双栏文件管理、断点续传、完整后原子替换、重命名、权限、远程文本编辑
- SFTP:双栏、中间传输箭头、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限,超限/二进制友好提示)
- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
@@ -19,9 +19,9 @@
## 产品边界
OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内。
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
当前仍需补齐的专业能力:
仍在打磨、不影响「先用起来」的方向:
- Agent Forwarding、OpenSSH 证书与 FIDO2
- 断网重连、休眠恢复和大量标签切换的持续压测
@@ -42,7 +42,7 @@ OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终
| 前端 | React 18 + TypeScript 5 + Vite 5 |
| 状态管理 | Zustand 4.4+ |
| 终端 | XTerm.js 5.5+ |
| 国际化 | i18next(zh-CN / en) |
| 国际化 | i18next(zh-CN / zh-TW / en / ja / ko / vi / ru) |
| 测试 | Vitest + Rust test |
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
@@ -61,32 +61,27 @@ npm run dev
```bash
npm run acceptance:quick
npm run acceptance:full
./build.sh v2.1.1 mac-arm64
build.bat v2.1.1 win-x64
./release.py v2.1.1 --dry-run
./scripts/build.sh v2.1.1 mac-arm64
scripts\build.bat v2.1.1 win-x64
./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
src/ React 界面与状态管理
src/i18n/ 界面文案(zh-CN / en)
src/platform/ Tauri 命令和事件适配层
src/platform/sftp.ts 本地/远程路径强类型传输边界
src/platform/rdp.ts RDP 会话适配
src/platform/vnc.ts VNC 会话适配
src/utils/terminalPipeline.ts 终端输入、输出和背压管线
shared/types/ 前后端共享 TypeScript 契约
src-tauri/src/commands/ 精简的 Tauri 命令边界
src-tauri/src/domain/ 原生领域模型
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、桌面、AI、存储服务
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/ 构建和验收脚本
src/ React 界面与状态管理
src/i18n/ 界面文案(多语言)
src/platform/ Tauri 命令和事件适配层
shared/types/ 前后端共享 TypeScript 契约
src-tauri/ Rust / Tauri 原生后端
test/ 前端单元、组件与巡检测试
test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
scripts/ 启动、验收、构建与发布脚本
docs/ 使用 / 架构 / 验收 / 发布文档
docs/assets/ 品牌与设计资源
license-server/ 授权与结账服务
```
## 快捷键
@@ -105,9 +100,11 @@ scripts/ 构建和验收脚本
- [使用指南](docs/user-guide.md)
- [架构说明](docs/architecture.md)
- [SSH Agent CLI 设计](docs/ai-agent.md)
- [产品体验与基础能力评估](docs/ux-foundation-audit.md)
- [测试与验收](docs/testing-acceptance.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 助手和数据安全 |
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
| [产品体验全面评估](./ux-foundation-audit.md) | 全产品评分、大众操作习惯、P0/P1/P2 与统一回归清单 |
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
| [发布指南](./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
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 适配层
shared/types/ 渲染层 TypeScript 契约
src-tauri/src/commands/ 参数边界和统一响应
@@ -65,7 +65,7 @@ services/
| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` |
| 主机信任 | OpenSSH `~/.ssh/known_hosts` |
| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 |
| 界面语言 | i18next;zh-CN / en,首启按系统语言检测 |
| 界面语言 | i18next;zh-CN / zh-TW / en / ja / ko / vi / ru,首启按系统语言检测 |
## SSH Agent CLI 边界
@@ -106,9 +106,11 @@ XTerm 当前焦点分屏
- `ForwardRule` 由 React 管理并按会话 / 标签 / 连接维度写入设置,只保存类型、端口和目标。
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
- 每个运行实例有唯一 `forwardId` 和取消标志。停止、删除正在运行的规则或断开 SSH 连接时,Rust 必须关闭真实 Listener。
- 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。
- 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。
- 规则支持编辑和复制。复制只产生新的规则 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`,不对局域网暴露。客户端仅支持 SOCKS5 CONNECT 和无认证方式。流量经当前 SSH 服务器出口 IP 访问外网。
File renamed without changes.
+8 -8
View File
@@ -4,19 +4,19 @@
```bash
# 推荐:当前平台完整门禁、打包、验证和 SHA-256
./build.sh
./scripts/build.sh
# macOS Apple Silicon 指定版本
./build.sh v2.1.1 mac-arm64
./scripts/build.sh v2.1.1 mac-arm64
# 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
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 target add x86_64-pc-windows-msvc
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 可能提示未知发布者。
@@ -52,10 +52,10 @@ build.bat v2.1.1 win-x64
```bash
# 只检查附件,不访问 Gitee API
./release.py v2.1.1 --dry-run
./scripts/release.py v2.1.1 --dry-run
# 创建或更新 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`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
+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 和断点复制
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 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 环境不得显示为可连接;桌面会话不参与批量命令
- SFTP 本地/远程路径参数方向、原生路径上传、选中文件下载、双栏双向拖拽、目录落点、远程文本读写和错误反馈
- 终端分屏、剪贴板、快捷键、分片 UTF-8 解码、高输出背压和异步输入错误
+16 -9
View File
@@ -1,5 +1,7 @@
# 使用指南
OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默认收起。不做堡垒审计。
## 快速连接
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
@@ -35,10 +37,12 @@ socks5://127.0.0.1:1080
- 动态转发每台 SSH 主机仅一条,端口固定 `1080`;在抽屉中直接启停。
- 状态栏「转发」右键可复制 `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,不是只改界面图标。
- 远程转发(`-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 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
## 远程桌面(RDP / VNC)
@@ -67,15 +71,18 @@ socks5://127.0.0.1:1080
## SFTP
SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时文件,中断后再次传输同一路径会从已有字节继续。传输完成并通过大小校验后才替换正式文件,因此失败传输不会伪装成完整文件。
SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下载先写入 `.opsx-part` 临时文件,中断后再次传输同一路径会从已有字节继续。传输完成并通过大小校验后才替换正式文件,因此失败传输不会伪装成完整文件。
- 上传:在左侧本地栏选中文件后点击“上传”;未选择文件时点击“上传”会打开系统原生文件选择器。
- 下载:在右侧远程栏选中文件后点击“下载”。下载按钮在未选择远程文件时保持禁用。
- 文件右键菜单也提供对应的“上传”或“下载”操作。
- 可直接把左侧本地文件拖到右侧远程栏上传,也可把右侧远程文件拖到左侧本地栏下载;拖到目录行时以该目录为目标,并显示落点高亮。
- 上传:在左侧本地栏选中文件后点中间「上传」箭头;未选择文件时会打开系统原生文件选择器。
- 下载:在右侧远程栏选中文件后点中间「下载」箭头;未选择远程文件时下载保持禁用。
- 文件右键菜单也提供对应的「上传」或「下载」操作。
- 可把左侧本地文件拖到右侧远程栏上传,也可把右侧远程文件拖到左侧本地栏下载;拖到目录行时以该目录为目标,并显示落点高亮。系统未提供拖拽路径时,请改用中间箭头或文件选择器。
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
- 远程文本编辑的“打开/保存”直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。
- 传输失败会在传输队列和界面通知中显示 Rust/SFTP 返回的具体原因。
- 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。
- 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。
- 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
轻量 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
```
桌面端默认请求 `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
```
将返回的 `token` 粘贴到 App「已有许可证?激活」。
将返回的 `token` 粘贴到 App「已有许可证?激活」(仅旧 HMAC 路径;新客户端需 `LIC-` 密钥)。
## 安全
- `@waffo/pancake-ts` 仅在本服务使用
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
- `OPSX_LICENSE_HMAC` 与旧桌面端校验密钥一致(生产请轮换)
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "opsxshell-gui",
"version": "2.1.1",
"description": "轻量原生的跨平台运维终端 · SSH / SFTP / 转发 / RDP·VNC · Tauri 2 + Rust",
"description": "简单好用的原生运维终端 · SSH / SFTP / 转发 / RDP·VNC · 短路径、不堆堡垒",
"author": "OpsXShell Team",
"license": "MIT",
"scripts": {
@@ -21,6 +21,7 @@
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"test": "vitest run",
"test:watch": "vitest",
"test:patrol": "vitest run test/patrol",
"test:coverage": "vitest run --coverage",
"test:release": "node --test scripts/*.test.mjs",
"acceptance": "npm run acceptance:quick",
@@ -35,7 +36,9 @@
"release:preflight:win": "node scripts/release-preflight.mjs windows",
"release:verify:mac": "node scripts/verify-release.mjs macos",
"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": {
"@monaco-editor/react": "^4.7.0",
+2 -43
View File
@@ -1,43 +1,2 @@
#!/bin/bash
set -e
# 颜色定义
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
#!/usr/bin/env bash
exec "$(cd "$(dirname "$0")" && pwd)/scripts/run.sh" "$@"
+1 -1
View File
@@ -17,7 +17,7 @@ const manifest = 'src-tauri/Cargo.toml'
const quickSteps = [
['Renderer type contract', npm, ['run', 'typecheck:ui']],
['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 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)), '..')
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)
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.match(help.stdout, /mac-arm64/)
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', () => {
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, /acceptance:full/)
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 Run this script from Windows x64 with the MSVC Rust toolchain installed.
cd /d "%~dp0"
cd /d "%~dp0.."
set "VERSION_ARG="
set "CHECK_MODE=full"
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!"=="2.1." goto set_version_arg
call :fail "未知参数:!ARG!(使用 build.bat --help 查看用法)"
call :fail "未知参数:!ARG!(使用 scripts\build.bat --help 查看用法)"
exit /b 1
:set_version_arg
@@ -181,7 +181,7 @@ exit /b 0
echo OpsXShell Windows 原生安装包构建脚本
echo.
echo 用法:
echo build.bat [v2.1.x] [win-x64] [选项]
echo scripts\build.bat [v2.1.x] [win-x64] [选项]
echo.
echo 选项:
echo --quick 构建前只运行快速门禁
@@ -189,9 +189,9 @@ echo --skip-checks 跳过门禁,仅用于本地重复打包
echo -h, --help 显示帮助
echo.
echo 示例:
echo build.bat
echo build.bat v2.1.1
echo build.bat v2.1.2 win-x64 --quick
echo scripts\build.bat
echo scripts\build.bat v2.1.1
echo scripts\build.bat v2.1.2 win-x64 --quick
echo.
echo 环境要求:
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
+6 -6
View File
@@ -6,7 +6,7 @@
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
VERSION_ARG=""
@@ -18,7 +18,7 @@ usage() {
OpsXShell 原生安装包构建脚本
用法:
./build.sh [v2.1.x] [目标] [选项]
./scripts/build.sh [v2.1.x] [目标] [选项]
目标:
auto 根据当前系统自动选择(默认)
@@ -32,9 +32,9 @@ OpsXShell 原生安装包构建脚本
-h, --help 显示帮助
示例:
./build.sh
./build.sh v2.1.0 mac-arm64
./build.sh v2.1.1 mac-arm64 --quick
./scripts/build.sh
./scripts/build.sh v2.1.0 mac-arm64
./scripts/build.sh v2.1.1 mac-arm64 --quick
说明:
- 显式传入新版本时,会同步 package.json、package-lock.json、
@@ -73,7 +73,7 @@ while [ "$#" -gt 0 ]; do
VERSION_ARG="${1#v}"
;;
*)
fail "未知参数:$1(使用 ./build.sh --help 查看用法)"
fail "未知参数:$1(使用 ./scripts/build.sh --help 查看用法)"
;;
esac
shift
+1 -1
View File
@@ -242,7 +242,7 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="发布 OpsXShell 原生安装包到 Gitee")
parser.add_argument("version", help="版本号,例如 v2.1.1")
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="只检查并列出待上传附件")
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 {
maxConnections: number // 最大连接数 (默认 10)
maxBackgroundConnections: number // 最大后台连接数 (默认 5)
idleTimeoutMs: number // 空闲超时 (默认 5 分钟)
suspendTimeoutMs: number // 挂起超时 (默认 30 分钟)
keepaliveIntervalMs: number // 保活间隔 (默认 60 秒)
idleTimeoutMs: number // 空闲超时 (默认 30 分钟)
suspendTimeoutMs: number // 挂起超时 (默认 60 分钟)
keepaliveIntervalMs: number // 保活间隔 (默认 15 秒)
maxReconnectAttempts: number // 最大重连次数 (默认 3)
}
@@ -66,6 +66,8 @@ export interface PooledConnection {
// 重连
reconnectAttempts: 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'
fileName: string
filePath: string
/** Destination directory (remote for upload, local for download). */
destinationDir?: string
status: 'pending' | 'transferring' | 'completed' | 'failed' | 'canceled'
progress: number
error?: string
+84 -14
View File
@@ -1158,6 +1158,22 @@ dependencies = [
"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]]
name = "curve25519-dalek"
version = "5.0.0-rc.1"
@@ -1168,7 +1184,7 @@ dependencies = [
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
@@ -1506,18 +1522,42 @@ dependencies = [
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"signature 3.0.0",
"spki 0.8.0",
"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]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
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]]
@@ -1526,8 +1566,8 @@ version = "3.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
dependencies = [
"curve25519-dalek",
"ed25519",
"curve25519-dalek 5.0.0-rc.1",
"ed25519 3.0.0",
"rand_core 0.10.1",
"sha2 0.11.0",
"subtle",
@@ -1549,7 +1589,7 @@ dependencies = [
"hkdf",
"hybrid-array",
"pem-rfc7468",
"pkcs8",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sec1",
"subtle",
@@ -1681,6 +1721,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
@@ -2673,6 +2719,8 @@ dependencies = [
"ironrdp-cliprdr",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-graphics",
"ironrdp-input",
"ironrdp-pdu",
@@ -3757,10 +3805,13 @@ dependencies = [
"arboard",
"base64 0.22.1",
"dirs",
"ed25519-dalek 2.2.0",
"hex",
"hmac 0.12.1",
"image",
"ironrdp",
"ironrdp-blocking",
"ironrdp-bulk",
"open",
"portable-pty",
"rand 0.8.7",
@@ -3836,7 +3887,7 @@ checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f"
dependencies = [
"ecdsa",
"elliptic-curve",
"fiat-crypto",
"fiat-crypto 0.3.0",
"primefield",
"primeorder",
"sha2 0.11.0",
@@ -4002,11 +4053,11 @@ dependencies = [
"crypto-bigint",
"crypto-common 0.2.2",
"ctr 0.10.1",
"curve25519-dalek",
"curve25519-dalek 5.0.0-rc.1",
"des",
"digest 0.11.3",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-rc.1",
"hex",
"hmac 0.13.0",
"http",
@@ -4149,6 +4200,16 @@ dependencies = [
"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]]
name = "pkcs8"
version = "0.11.0"
@@ -4795,9 +4856,9 @@ dependencies = [
"crypto-primes",
"digest 0.11.3",
"pkcs1 0.8.0-rc.4",
"pkcs8",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"signature",
"signature 3.0.0",
"spki 0.8.0",
"zeroize",
]
@@ -5421,6 +5482,15 @@ dependencies = [
"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]]
name = "signature"
version = "3.0.0"
@@ -5575,8 +5645,8 @@ dependencies = [
"crypto-bigint",
"crypto-mac",
"cryptoki",
"curve25519-dalek",
"ed25519-dalek",
"curve25519-dalek 5.0.0-rc.1",
"ed25519-dalek 3.0.0-rc.1",
"futures",
"getrandom 0.3.4",
"hmac 0.13.0",
@@ -7735,7 +7805,7 @@ version = "3.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e"
dependencies = [
"curve25519-dalek",
"curve25519-dalek 5.0.0-rc.1",
"rand_core 0.10.1",
"zeroize",
]
+5 -2
View File
@@ -17,13 +17,15 @@ tauri-build = { version = "2.4", features = [] }
anyhow = "1.0"
aes-gcm = "0.10"
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"] }
dirs = "6.0"
open = "5.3"
rfd = "0.15"
rand = "0.8"
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"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -42,8 +44,9 @@ uuid = { version = "1.11", features = ["v4"] }
arboard = { version = "3.6.1", default-features = false }
url = "2.5"
# 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-bulk = "0.1"
sspi = { version = "0.21", features = ["network_client"] }
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "aws-lc-rs"] }
tokio-rustls = "0.26"
+3 -1
View File
@@ -9,6 +9,8 @@
"permissions": [
"core: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)
}
#[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]
pub fn rdp_disconnect(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
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
.lines()
.filter(|line| {
line.split_whitespace().next().map_or(true, |hosts| {
line.split_whitespace().next().is_none_or(|hosts| {
!hosts
.split(',')
.any(|candidate| candidate == label || (port == 22 && candidate == host))
+22 -3
View File
@@ -1,6 +1,6 @@
use tauri::AppHandle;
use crate::services::license::{self, LicenseStatus};
use crate::services::license::{self, CheckoutResult, LicenseStatus};
#[tauri::command]
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
@@ -8,8 +8,12 @@ pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
}
#[tauri::command]
pub fn license_activate(app: AppHandle, token: String) -> Result<LicenseStatus, String> {
license::activate(&app, &token)
pub fn license_activate(
app: AppHandle,
token: String,
email: Option<String>,
) -> Result<LicenseStatus, String> {
license::activate(&app, &token, email.as_deref())
}
#[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> {
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,
state: State<'_, AppState>,
connection_id: String,
retain_forwards: Option<bool>,
) -> OperationResponse {
operation(state.ssh.disconnect(&app, &connection_id))
operation(
state
.ssh
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
)
}
#[tauri::command]
@@ -154,11 +159,34 @@ pub fn ssh_close_shell(state: State<'_, AppState>, shell_id: String) -> Operatio
}
#[tauri::command]
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
match state.ssh.create_sftp(&connection_id) {
Ok(id) => IdResponse::sftp(id),
Err(error) => IdResponse::error(error),
}
pub async fn sftp_connect(
state: State<'_, AppState>,
connection_id: String,
) -> 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)]
@@ -171,23 +199,32 @@ pub struct SftpListResponse {
}
#[tauri::command]
pub fn sftp_list_directory(
pub async fn sftp_list_directory(
state: State<'_, AppState>,
sftp_id: String,
path: String,
) -> SftpListResponse {
match state.ssh.sftp_list(&sftp_id, &path) {
Ok(files) => SftpListResponse {
success: true,
files: Some(files),
error: None,
) -> Result<SftpListResponse, String> {
let service = state.ssh.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || service.sftp_list(&sftp_id, &path)).await
{
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]
@@ -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]
pub async fn sftp_download_dir(
app: AppHandle,
@@ -275,28 +320,60 @@ pub async fn sftp_download_dir(
}
#[tauri::command]
pub fn sftp_rename(
pub async fn sftp_rename(
state: State<'_, AppState>,
sftp_id: String,
old_path: String,
new_path: String,
) -> OperationResponse {
operation(state.ssh.sftp_rename(&sftp_id, &old_path, &new_path))
) -> Result<OperationResponse, String> {
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]
pub fn sftp_delete(
pub async fn sftp_delete(
state: State<'_, AppState>,
sftp_id: String,
path: String,
is_directory: bool,
) -> OperationResponse {
operation(state.ssh.sftp_delete(&sftp_id, &path, is_directory))
) -> Result<OperationResponse, String> {
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]
pub fn sftp_mkdir(state: State<'_, AppState>, sftp_id: String, path: String) -> OperationResponse {
operation(state.ssh.sftp_mkdir(&sftp_id, &path))
pub async fn sftp_mkdir(
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)]
@@ -322,40 +399,63 @@ pub struct SftpStat {
}
#[tauri::command]
pub fn sftp_stat(state: State<'_, AppState>, sftp_id: String, path: String) -> SftpStatResponse {
match state.ssh.sftp_stat(&sftp_id, &path) {
Ok(stat) => {
let mode = stat.perm.unwrap_or(0);
SftpStatResponse {
success: true,
stats: Some(SftpStat {
size: stat.size.unwrap_or(0),
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,
pub async fn sftp_stat(
state: State<'_, AppState>,
sftp_id: String,
path: String,
) -> Result<SftpStatResponse, String> {
let service = state.ssh.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || service.sftp_stat(&sftp_id, &path)).await
{
Ok(Ok(stat)) => {
let mode = stat.perm.unwrap_or(0);
SftpStatResponse {
success: true,
stats: Some(SftpStat {
size: stat.size.unwrap_or(0),
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,
}
}
}
Err(error) => SftpStatResponse {
success: false,
stats: None,
error: Some(error),
Ok(Err(error)) => SftpStatResponse {
success: false,
stats: None,
error: Some(error),
},
Err(error) => SftpStatResponse {
success: false,
stats: None,
error: Some(error.to_string()),
},
},
}
)
}
#[tauri::command]
pub fn sftp_chmod(
pub async fn sftp_chmod(
state: State<'_, AppState>,
sftp_id: String,
path: String,
mode: u32,
) -> OperationResponse {
operation(state.ssh.sftp_chmod(&sftp_id, &path, mode))
) -> Result<OperationResponse, String> {
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)]
@@ -372,37 +472,59 @@ pub struct SftpReadTextResponse {
}
#[tauri::command]
pub fn sftp_read_text(
pub async fn sftp_read_text(
state: State<'_, AppState>,
sftp_id: String,
path: String,
) -> SftpReadTextResponse {
match state.ssh.sftp_read_text(&sftp_id, &path) {
Ok((content, size)) => SftpReadTextResponse {
success: true,
content: Some(content),
size: Some(size),
encoding: Some("utf-8"),
error: None,
) -> Result<SftpReadTextResponse, String> {
let service = state.ssh.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || service.sftp_read_text(&sftp_id, &path))
.await
{
Ok(Ok((content, size))) => SftpReadTextResponse {
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]
pub fn sftp_write_text(
pub async fn sftp_write_text(
state: State<'_, AppState>,
sftp_id: String,
path: String,
content: String,
) -> OperationResponse {
operation(state.ssh.sftp_write_text(&sftp_id, &path, &content))
) -> Result<OperationResponse, String> {
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]
@@ -410,6 +532,7 @@ pub fn ssh_forward_local(
state: State<'_, AppState>,
connection_id: String,
forward_id: Option<String>,
local_bind_host: Option<String>,
local_port: u16,
remote_host: String,
remote_port: u16,
@@ -417,6 +540,7 @@ pub fn ssh_forward_local(
forward(state.ssh.forward_local(
&connection_id,
forward_id,
local_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
local_port,
remote_host,
remote_port,
@@ -428,6 +552,7 @@ pub fn ssh_forward_remote(
state: State<'_, AppState>,
connection_id: String,
forward_id: Option<String>,
remote_bind_host: Option<String>,
remote_port: u16,
local_host: String,
local_port: u16,
@@ -435,6 +560,7 @@ pub fn ssh_forward_remote(
forward(state.ssh.forward_remote(
&connection_id,
forward_id,
remote_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
remote_port,
local_host,
local_port,
+21
View File
@@ -11,6 +11,7 @@ pub struct AppRuntimeInfo {
ssh_backend: String,
data_path: String,
credential_path: String,
log_path: String,
}
#[tauri::command]
@@ -46,6 +47,9 @@ pub fn app_runtime_info(app: AppHandle) -> Result<AppRuntimeInfo, String> {
ssh_backend: "libssh2 / ssh2 0.9".to_string(),
data_path: data_path.display().to_string(),
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]
pub fn clipboard_read_text() -> Result<String, 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)
}
}
#[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.
//! 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 commands;
pub mod domain;
mod infrastructure;
mod native_menu;
mod services;
use application::AppState;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
Emitter, LogicalSize, Manager, Size,
};
use tauri::{Emitter, LogicalSize, Manager, Size};
use tauri_plugin_deep_link::DeepLinkExt;
/// 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().
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_target(false)
.init();
infrastructure::file_log::init();
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
@@ -71,9 +67,12 @@ pub fn run() {
commands::system::app_version,
commands::system::app_runtime_info,
commands::system::toggle_maximize,
commands::system::set_fullscreen,
commands::system::is_fullscreen,
commands::system::clipboard_read_text,
commands::system::clipboard_write_text,
commands::system::toggle_devtools,
commands::system::apply_native_menu_locale,
commands::deep_link::take_pending_ssh_links,
commands::local_terminal::local_list_shells,
commands::local_terminal::local_create_shell,
@@ -89,6 +88,7 @@ pub fn run() {
commands::desktop::rdp_input_key,
commands::desktop::rdp_clipboard_set,
commands::desktop::rdp_upload_file,
commands::desktop::rdp_resize,
commands::desktop::rdp_disconnect,
commands::desktop::vnc_connect,
commands::desktop::vnc_input_pointer,
@@ -107,11 +107,13 @@ pub fn run() {
commands::ssh::ssh_shell_resize,
commands::ssh::ssh_close_shell,
commands::ssh::sftp_connect,
commands::ssh::sftp_disconnect,
commands::ssh::sftp_list_directory,
commands::ssh::sftp_upload,
commands::ssh::sftp_upload_dir,
commands::ssh::sftp_download,
commands::ssh::sftp_download_dir,
commands::ssh::sftp_cancel_transfer,
commands::ssh::sftp_rename,
commands::ssh::sftp_delete,
commands::ssh::sftp_mkdir,
@@ -152,11 +154,18 @@ pub fn run() {
commands::license::license_activate,
commands::license::license_clear,
commands::license::license_device_id,
commands::license::license_set_email,
commands::license::license_checkout,
]);
let builder = builder
.setup(|app| {
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") {
let min = LogicalSize::new(MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_MIN_HEIGHT);
if let Err(error) = window.set_min_size(Some(Size::Logical(min))) {
@@ -194,81 +203,8 @@ pub fn run() {
Ok(())
})
.menu(|app| {
#[cfg(target_os = "macos")]
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
.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()
let locale = native_menu::resolve_startup_locale(app);
native_menu::build_menu(app, &locale)
})
.on_menu_event(|app, event| match event.id().as_ref() {
"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() {
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));
}
Ok(())
+24
View File
@@ -33,6 +33,8 @@ pub enum DesktopInput {
ClipboardSet { text: String },
/// Place a file into the host Downloads folder for RDP RDPDR.
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).
@@ -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(
&mut self,
app: &AppHandle,
@@ -86,6 +93,16 @@ impl FrameEmitter {
let Some((left, top, right, bottom)) = self.pending.take() else {
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 height = bottom.saturating_sub(top).saturating_add(1);
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 pending_move: Option<DesktopInput> = None;
let mut pending_resize: Option<DesktopInput> = None;
for event in queued.drain(..) {
match &event {
DesktopInput::Pointer { moving: true, .. } => {
pending_move = Some(event);
}
DesktopInput::Resize { .. } => {
pending_resize = Some(event);
}
_ => {
if let Some(mv) = pending_move.take() {
out.push(mv);
@@ -222,6 +243,9 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
if let Some(mv) = pending_move {
out.push(mv);
}
if let Some(resize) = pending_resize {
out.push(resize);
}
out
}
+7
View File
@@ -100,6 +100,13 @@ impl RdpService {
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> {
if let Some(session) = self
.sessions
+426 -38
View File
@@ -13,8 +13,14 @@ use std::{
use anyhow::Context as _;
use ironrdp::cliprdr::backend::ClipboardMessage;
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::displaycontrol::client::DisplayControlClient;
use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
use ironrdp::dvc::DrdynvcClient;
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
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::rdpdr::Rdpdr;
use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
use ironrdp::session::fast_path::ProcessorBuilder;
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{ActiveStage, ActiveStageBuilder, ActiveStageOutput};
use ironrdp_bulk::BulkCompressor;
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
use tauri::AppHandle;
use tokio_rustls::rustls;
@@ -174,8 +182,15 @@ fn run_session_inner(
return Ok(());
}
let fb_width = connection_result.desktop_size.width;
let fb_height = connection_result.desktop_size.height;
let mut fb_width = connection_result.desktop_size.width;
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(
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
@@ -185,13 +200,13 @@ fn run_session_inner(
let mut active_stage = ActiveStageBuilder {
static_channels: connection_result.static_channels,
user_channel_id: connection_result.user_channel_id,
io_channel_id: connection_result.io_channel_id,
user_channel_id,
io_channel_id,
message_channel_id: connection_result.message_channel_id,
share_id: connection_result.share_id,
compression_type: connection_result.compression_type,
enable_server_pointer: connection_result.enable_server_pointer,
pointer_software_rendering: connection_result.pointer_software_rendering,
share_id,
compression_type,
enable_server_pointer,
pointer_software_rendering,
}
.build();
@@ -219,6 +234,7 @@ fn run_session_inner(
// Avoid shipping a full black framebuffer over IPC; wait for real GraphicsUpdate.
let mut frame_emitter = FrameEmitter::new("rdp:frame", session_id);
let mut pending_resize: Option<(u16, u16)> = None;
loop {
if stop.load(Ordering::Relaxed) {
@@ -236,6 +252,23 @@ fn run_session_inner(
&clip_outbound,
&want_paste_inject,
&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)?;
if pending_paste_inject.swap(false, Ordering::Relaxed) {
@@ -246,6 +279,15 @@ fn run_session_inner(
&mut image,
&mut framed,
&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)?;
@@ -264,9 +306,31 @@ fn run_session_inner(
Err(error) => return Err(anyhow::Error::new(error).context("读取 RDP 帧失败")),
};
let outputs = active_stage
.process(&mut image, action, &payload)
.map_err(|e| anyhow::anyhow!("处理 RDP 帧失败: {e}"))?;
let outputs = match active_stage.process(&mut image, action, &payload) {
Ok(outputs) => outputs,
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).
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
@@ -278,6 +342,15 @@ fn run_session_inner(
&mut image,
&mut framed,
&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,
session_id,
&mut framed,
&image,
fb_width,
fb_height,
&mut active_stage,
&mut image,
&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,
&mut frame_emitter,
)? {
@@ -368,6 +449,16 @@ fn drain_and_send_input(
clip_outbound: &Mutex<Vec<ClipboardMessage>>,
want_paste_inject: &AtomicBool,
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<()> {
let queued = {
let mut guard = input
@@ -379,8 +470,6 @@ fn drain_and_send_input(
return Ok(());
}
let fb_width = image.width();
let fb_height = image.height();
let mut events = Vec::new();
for event in queued {
match event {
@@ -403,8 +492,8 @@ fn drain_and_send_input(
session_id,
"connected",
Some(error),
Some(fb_width),
Some(fb_height),
Some(*fb_width),
Some(*fb_height),
);
} else {
emit_status(
@@ -413,13 +502,24 @@ fn drain_and_send_input(
session_id,
"connected",
Some("已放入共享盘「下载」,远端此电脑可打开".into()),
Some(fb_width),
Some(fb_height),
Some(*fb_width),
Some(*fb_height),
);
}
}
}
}
DesktopInput::Resize { width, height } => {
queue_or_send_resize(
active_stage,
framed,
pending_resize,
*fb_width,
*fb_height,
width,
height,
)?;
}
other => {
if let Some(fast) = map_desktop_input(other) {
events.push(fast);
@@ -438,9 +538,17 @@ fn drain_and_send_input(
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,
outputs,
frame_emitter,
)?;
@@ -454,6 +562,15 @@ fn inject_remote_paste(
image: &mut DecodedImage,
framed: &mut UpgradedFramed,
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<()> {
// Left Ctrl (0x1D) + V (0x2F) — Windows paste after CLIPRDR FormatList accepted.
let events = [
@@ -462,8 +579,6 @@ fn inject_remote_paste(
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x2F),
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x1D),
];
let fb_width = image.width();
let fb_height = image.height();
let outputs = active_stage
.process_fastpath_input(image, &events)
.map_err(|e| anyhow::anyhow!("注入粘贴失败: {e}"))?;
@@ -471,9 +586,17 @@ fn inject_remote_paste(
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,
outputs,
frame_emitter,
)?;
@@ -535,7 +658,9 @@ fn map_desktop_input(event: DesktopInput) -> Option<FastPathInputEvent> {
}
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,
session_id: &str,
framed: &mut UpgradedFramed,
image: &DecodedImage,
fb_width: u16,
fb_height: u16,
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>,
outputs: Vec<ActiveStageOutput>,
frame_emitter: &mut FrameEmitter,
) -> anyhow::Result<bool> {
let mut terminate = false;
let mut need_reactivate = false;
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => {
@@ -649,15 +783,239 @@ fn apply_outputs(
terminate = true;
}
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)
}
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(
app: &AppHandle,
frame_emitter: &mut FrameEmitter,
@@ -678,7 +1036,7 @@ fn flush_pending_frames(
right: left.saturating_add(width.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_height,
@@ -686,19 +1044,36 @@ fn flush_pending_frames(
);
}
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Vec<u8> {
let w = usize::from(rect.width());
let h = usize::from(rect.height());
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Option<Vec<u8>> {
let fb_w = usize::from(image.width());
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 stride = image.stride();
let data = image.data();
let mut out = Vec::with_capacity(w * h * bpp);
for row in 0..h {
let y = usize::from(rect.top) + row;
let start = y * stride + usize::from(rect.left) * bpp;
out.extend_from_slice(&data[start..start + w * bpp]);
let y = top + row;
let start = y * stride + left * 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(
@@ -748,7 +1123,11 @@ fn build_config(
request_data: None,
autologon: 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.
pointer_software_rendering: false,
multitransport_flags: None,
@@ -811,8 +1190,17 @@ fn connect(
)
.with_drives(Some(vec![(1, super::rdpdr_fs::DRIVE_NAME.into())]));
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)
.with_static_channel(drdynvc)
.with_static_channel(rdpsnd)
.with_static_channel(rdpdr)
.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.
inject_remote_paste(vnc).await?;
}
DesktopInput::UploadFile { .. } => {}
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
}
}
Ok(())
+608 -112
View File
@@ -1,35 +1,54 @@
//! Device-bound Pro license helpers (HMAC signed tokens).
//! Signing secret stays on license-server; desktop embeds the matching
//! verify key for offline activate (rotate for production).
//! Pro license persistence + thin-client activate against opsx-licence.
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
//! offline `LICOFF-` serials are Ed25519 self-contained.
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use serde_json::Value;
use tauri::{AppHandle, Manager};
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`.
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LicensePayload {
pub plan: String,
pub device_id: String,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
struct StoredLicense {
#[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)]
@@ -41,12 +60,43 @@ pub struct LicenseStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
pub features: Vec<String>,
/// License key string (compat field name for existing frontend).
#[serde(skip_serializing_if = "Option::is_none")]
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 {
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
/// 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> {
@@ -70,6 +120,13 @@ fn ensure_parent(path: &Path) -> Result<(), String> {
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> {
let path = device_id_path(app)?;
if path.exists() {
@@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
Ok(id)
}
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|error| format!("HMAC 初始化失败: {error}"))?;
mac.update(payload_b64.as_bytes());
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
LicenseStatus {
plan: "free".to_string(),
device_id,
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.
#[allow(dead_code)]
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
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());
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
if !stored.activated || stored.license_key.trim().is_empty() {
return free_status(device_id, stored.email.clone());
}
let expected = sign_payload(parts[1], secret)?;
if expected != parts[2] {
return Err("许可证签名无效".to_string());
}
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());
if let Some(exp) = stored.expires_at {
if exp > 0 && exp < now_secs() {
return free_status(device_id, stored.email.clone());
}
}
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> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
if !path.exists() {
return Ok(LicenseStatus {
plan: "free".to_string(),
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,
}),
match read_stored(&path)? {
None => Ok(free_status(device_id, None)),
Some(stored) => Ok(status_from_stored(device_id, &stored)),
}
}
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 payload = verify_token(token, &hmac_secret())?;
if payload.device_id != "*" && payload.device_id != device_id {
return Err("许可证与当前设备不匹配".to_string());
let key = license_key.trim();
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)?;
ensure_parent(&path)?;
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
write_stored(&path, &stored)?;
read_status(app)
}
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
license_key: String::new(),
email: None,
plan: "free".to_string(),
activated: false,
expires_at: None,
features: vec![],
max_devices: None,
plan_type: None,
last_verified_at: 0,
});
let trimmed = email.trim();
stored.email = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
write_stored(&path, &stored)?;
read_status(app)
}
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(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() {
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
}
@@ -202,16 +646,68 @@ mod tests {
use super::*;
#[test]
fn roundtrip_token() {
let payload = LicensePayload {
fn normalize_api_base_accepts_host_and_strips_v1() {
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(),
device_id: "dev-1".to_string(),
iat: 1,
exp: None,
activated: true,
expires_at: None,
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 parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
assert_eq!(parsed.device_id, "dev-1");
let json = serde_json::to_string(&stored).unwrap();
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
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}"))?;
let mut command = CommandBuilder::new(&shell);
command.cwd(cwd);
apply_login_shell_args(&mut command, &shell);
apply_terminal_env(&mut command);
let child = pair
.slave
@@ -216,8 +217,27 @@ fn local_home_dir() -> Option<PathBuf> {
.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`)
/// 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) {
#[cfg(windows)]
{
@@ -237,9 +257,100 @@ fn apply_terminal_env(command: &mut CommandBuilder) {
if should_set_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 {
match env::var("LANG") {
Ok(lang) => {
@@ -449,4 +560,40 @@ mod tests {
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::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, Read, Write},
net::{Shutdown, TcpStream},
sync::{
@@ -19,7 +19,7 @@ use crate::domain::ssh::{
ShellDataEvent, SshConnectRequest,
};
use super::tunnel::TunnelManager;
use super::tunnel::{connection_fingerprint, TunnelManager};
mod auth;
mod host_metrics;
@@ -50,13 +50,27 @@ struct Shell {
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)]
pub struct SshService {
connections: Mutex<HashMap<String, Connection>>,
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 停止).
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,
}
@@ -65,15 +79,30 @@ impl SshService {
validate_config(&config)?;
let session = establish_session(&config)?;
let id = Uuid::new_v4().to_string();
self.connections
.lock()
.map_err(lock_error)?
.insert(id.clone(), Connection { session, config });
let fingerprint = connection_fingerprint(&config);
let live = {
let mut connections = self.connections.lock().map_err(lock_error)?;
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)
}
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
self.tunnels.stop_connection(connection_id)?;
pub fn disconnect(
&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
.active_execs
.lock()
@@ -97,10 +126,22 @@ impl SshService {
let _ = shell.session.disconnect(None, "closed by user", None);
}
}
self.sftps
.lock()
.map_err(lock_error)?
.retain(|_, id| id != connection_id);
let sftp_ids = {
let sftps = self.sftps.lock().map_err(lock_error)?;
sftps
.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
.connections
.lock()
@@ -256,8 +297,8 @@ impl SshService {
.map(|_| ())
.map_err(|error| format!("SSH 保活失败: {error}"))?;
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
// channel cannot stall terminal IO. Keep those transports alive too.
// Interactive panes / SFTP use dedicated SSH sessions so a busy
// SFTP/exec channel cannot stall terminal IO. Keep those alive too.
let shell_sessions = self
.shells
.lock()
@@ -271,6 +312,21 @@ impl SshService {
.keepalive_send()
.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(())
}
@@ -393,6 +449,7 @@ impl SshService {
&self,
connection_id: &str,
forward_id: Option<String>,
local_bind_host: String,
local_port: u16,
remote_host: String,
remote_port: u16,
@@ -402,6 +459,7 @@ impl SshService {
forward_id,
connection_id,
config,
local_bind_host,
local_port,
remote_host,
remote_port,
@@ -423,6 +481,7 @@ impl SshService {
&self,
connection_id: &str,
forward_id: Option<String>,
remote_bind_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
@@ -432,6 +491,7 @@ impl SshService {
forward_id,
connection_id,
config,
remote_bind_host,
remote_port,
local_host,
local_port,
@@ -481,7 +541,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
session
.handshake()
.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)?;
authenticate(&session, config)?;
Ok(session)
@@ -782,7 +842,10 @@ mod tests {
target.seek(SeekFrom::Start(3)).unwrap();
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!(progress.first(), Some(&3));
@@ -835,23 +898,23 @@ mod tests {
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
fs::write(&local_source, &payload).expect("write local SFTP fixture");
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
let transfer_result = (|| -> Result<(), String> {
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
let transfer_result = service.with_sftp(&sftp_id, |sftp| {
upload_file(sftp, &local_source, &remote_path, None, |_, _| {})?;
download_file(sftp, &remote_path, &local_download, None, |_, _| {})?;
let downloaded = fs::read(&local_download)
.map_err(|error| format!("read downloaded probe: {error}"))?;
if downloaded != payload {
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(())
})();
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_download);
let _ = fs::remove_file(local_partial_path(&local_download));
transfer_result.expect("SFTP upload/download round trip");
service.close_sftp(&sftp_id).expect("close SFTP");
let connection = service.connection(&connection_id).unwrap();
let shell_session = establish_session(&connection.config).expect("shell SSH session");
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
.lines()
.filter(|line| {
line.split_whitespace().next().map_or(true, |hosts| {
line.split_whitespace().next().is_none_or(|hosts| {
!hosts
.split(',')
.any(|candidate| candidate == label || (port == 22 && candidate == host))
+486 -115
View File
@@ -2,6 +2,11 @@ use std::{
fs::{self, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
@@ -10,39 +15,103 @@ use uuid::Uuid;
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 {
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
let connection = self.connection(connection_id)?;
connection
.session
// Keep browsing off the pooled connection. Upload/download lazily create
// their own transport so a long copy cannot block directory operations.
let session = establish_session(&connection.config)?;
let sftp = session
.sftp()
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
let id = format!("sftp-{}", Uuid::new_v4());
self.sftps
.lock()
.map_err(lock_error)?
.insert(id.clone(), connection_id.to_string());
self.sftps.lock().map_err(lock_error)?.insert(
id.clone(),
Arc::new(Mutex::new(ManagedSftp {
connection_id: connection_id.to_string(),
session,
sftp,
})),
);
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> {
let sftp = self.sftp(sftp_id)?;
let mut entries = sftp
.readdir(Path::new(remote_path))
.map_err(|error| format!("读取远程目录失败: {error}"))?
.into_iter()
.filter_map(|(path, stat)| {
let filename = path.file_name()?.to_string_lossy().to_string();
if filename == "." || filename == ".." {
return None;
}
Some(dir_entry(filename, stat))
})
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.filename.to_lowercase());
Ok(entries)
self.with_browse_sftp(sftp_id, |sftp| {
let mut entries = sftp
.readdir(Path::new(remote_path))
.map_err(|error| format!("读取远程目录失败: {error}"))?
.into_iter()
.filter_map(|(path, stat)| {
let filename = path.file_name()?.to_string_lossy().to_string();
if filename == "." || filename == ".." {
return None;
}
Some(dir_entry(filename, stat))
})
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.filename.to_lowercase());
Ok(entries)
})
}
pub fn sftp_upload(
@@ -52,13 +121,21 @@ impl SshService {
local_path: &str,
remote_path: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
upload_file(
&sftp,
&expand_home(local_path),
remote_path,
|transferred, total| emit_progress(app, sftp_id, transferred, total),
)
tracing::info!(sftp_id = %sftp_id, operation = "upload", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
upload_file(
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(
@@ -68,11 +145,22 @@ impl SshService {
remote_path: &str,
local_path: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let local_path = expand_home(local_path);
download_file(&sftp, remote_path, &local_path, |transferred, total| {
emit_progress(app, sftp_id, transferred, total)
})
tracing::info!(sftp_id = %sftp_id, operation = "download", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
let local_path = expand_home(local_path);
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(
@@ -82,13 +170,21 @@ impl SshService {
local_dir: &str,
remote_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
upload_dir(
&sftp,
&expand_home(local_dir),
remote_dir,
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
)
tracing::info!(sftp_id = %sftp_id, operation = "upload_dir", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
upload_dir(
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(
@@ -98,11 +194,22 @@ impl SshService {
remote_dir: &str,
local_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let local_dir = expand_home(local_dir);
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
emit_progress(app, sftp_id, transferred, total)
})
tracing::info!(sftp_id = %sftp_id, operation = "download_dir", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
let local_dir = expand_home(local_dir);
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(
@@ -111,9 +218,10 @@ impl SshService {
old_remote_path: &str,
new_remote_path: &str,
) -> Result<(), String> {
self.sftp(sftp_id)?
.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
.map_err(|error| format!("远程重命名失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
.map_err(|error| format!("远程重命名失败: {error}"))
})
}
pub fn sftp_delete(
@@ -122,31 +230,34 @@ impl SshService {
remote_path: &str,
directory: bool,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
if directory {
sftp.rmdir(Path::new(remote_path))
.map_err(|error| format!("删除远程目录失败: {error}"))
} else {
sftp.unlink(Path::new(remote_path))
.map_err(|error| format!("删除远程文件失败: {error}"))
}
self.with_browse_sftp(sftp_id, |sftp| {
if directory {
sftp.rmdir(Path::new(remote_path))
.map_err(|error| format!("删除远程目录失败: {error}"))
} else {
sftp.unlink(Path::new(remote_path))
.map_err(|error| format!("删除远程文件失败: {error}"))
}
})
}
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
self.sftp(sftp_id)?
.mkdir(Path::new(remote_path), 0o755)
.map_err(|error| format!("创建远程目录失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.mkdir(Path::new(remote_path), 0o755)
.map_err(|error| format!("创建远程目录失败: {error}"))
})
}
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
self.sftp(sftp_id)?
.lstat(Path::new(remote_path))
.map_err(|error| format!("读取远程属性失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.lstat(Path::new(remote_path))
.map_err(|error| format!("读取远程属性失败: {error}"))
})
}
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
self.sftp(sftp_id)?
.setstat(
self.with_browse_sftp(sftp_id, |sftp| {
sftp.setstat(
Path::new(remote_path),
FileStat {
size: None,
@@ -158,6 +269,7 @@ impl SshService {
},
)
.map_err(|error| format!("修改远程权限失败: {error}"))
})
}
pub fn sftp_read_text(
@@ -165,15 +277,9 @@ impl SshService {
sftp_id: &str,
remote_path: &str,
) -> Result<(String, u64), String> {
let sftp = self.sftp(sftp_id)?;
let mut file = sftp
.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))
self.with_browse_sftp(sftp_id, |sftp| {
read_remote_text_capped(sftp, Path::new(remote_path), MAX_REMOTE_TEXT_BYTES)
})
}
pub fn sftp_write_text(
@@ -182,26 +288,199 @@ impl SshService {
remote_path: &str,
content: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
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}"))
if content.len() as u64 > MAX_REMOTE_TEXT_BYTES {
return Err(format!(
"文件过大({}),编辑上限约 2MB",
format_byte_size(content.len() as u64)
));
}
self.with_browse_sftp(sftp_id, |sftp| {
let mut file = sftp
.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> {
let connection_id = self
/// Blocking access to the browsing channel for native integration tests.
#[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
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned()
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
self.connection(&connection_id)?
.session
.sftp()
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
let guard = handle.lock().map_err(lock_error)?;
f(&guard.sftp)
}
/// Transfer-only channel. It is opened lazily and then reused by the serial
/// transfer pump, so large copies never hold the browsing channel lock.
fn with_transfer_sftp<R>(
&self,
sftp_id: &str,
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
) -> Result<R, String> {
let existing = self
.sftp_transfers
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned();
let handle = match existing {
Some(handle) => handle,
None => {
let browse = self
.sftps
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned()
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
let connection_id = browse.lock().map_err(lock_error)?.connection_id.clone();
let config = self.connection(&connection_id)?.config;
let session = establish_session(&config)?;
let sftp = session
.sftp()
.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
}
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(
sftp: &ssh2::Sftp,
local_path: &Path,
remote_path: &str,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let mut source = fs::File::open(local_path)
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
let total = source
@@ -271,9 +566,14 @@ pub(super) fn upload_file(
target
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("定位远程断点失败: {error}"))?;
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
progress(transferred, total)
})?;
copy_with_progress(
&mut source,
&mut target,
offset,
total,
cancel,
|transferred| progress(transferred, total),
)?;
drop(target);
let uploaded = sftp
.stat(Path::new(&partial_path))
@@ -292,8 +592,10 @@ pub(super) fn download_file(
sftp: &ssh2::Sftp,
remote_path: &str,
local_path: &Path,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let mut source = sftp
.open(Path::new(remote_path))
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
@@ -319,9 +621,14 @@ pub(super) fn download_file(
target
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("定位本地断点失败: {error}"))?;
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
progress(transferred, total)
})?;
copy_with_progress(
&mut source,
&mut target,
offset,
total,
cancel,
|transferred| progress(transferred, total),
)?;
target
.sync_all()
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
@@ -397,8 +704,10 @@ fn upload_dir(
sftp: &ssh2::Sftp,
local_dir: &Path,
remote_dir: &str,
cancel: Option<&AtomicBool>,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let meta = fs::metadata(local_dir)
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
if !meta.is_dir() {
@@ -413,6 +722,7 @@ fn upload_dir(
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
ensure_not_cancelled(cancel)?;
let file_type = entry
.file_type()
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
@@ -423,11 +733,9 @@ fn upload_dir(
let local_child = entry.path();
let remote_child = join_remote(remote_dir, &name);
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() {
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
progress(transferred, total)
})?;
upload_file(sftp, &local_child, &remote_child, cancel, &mut *progress)?;
}
// skip symlinks / special files for safety
}
@@ -438,8 +746,10 @@ fn download_dir(
sftp: &ssh2::Sftp,
remote_dir: &str,
local_dir: &Path,
cancel: Option<&AtomicBool>,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let stat = sftp
.lstat(Path::new(remote_dir))
.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()));
for (path, entry_stat) in entries {
ensure_not_cancelled(cancel)?;
let name = match path.file_name() {
Some(value) => value.to_string_lossy().to_string(),
None => continue,
@@ -465,11 +776,9 @@ fn download_dir(
let remote_child = join_remote(remote_dir, &name);
let local_child = local_dir.join(&name);
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 {
download_file(sftp, &remote_child, &local_child, |transferred, total| {
progress(transferred, total)
})?;
download_file(sftp, &remote_child, &local_child, cancel, &mut *progress)?;
}
}
Ok(())
@@ -480,12 +789,15 @@ pub(super) fn copy_with_progress(
writer: &mut impl Write,
initial: u64,
total: u64,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64),
) -> 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;
progress(transferred);
loop {
ensure_not_cancelled(cancel)?;
let length = reader
.read(&mut buffer)
.map_err(|error| format!("读取传输数据失败: {error}"))?;
@@ -535,19 +847,78 @@ pub(super) fn local_partial_path(destination: &Path) -> PathBuf {
PathBuf::from(value)
}
fn emit_progress(app: &AppHandle, sftp_id: &str, transferred: u64, total: u64) {
let percentage = transferred
.saturating_mul(100)
.checked_div(total)
.unwrap_or(100)
.min(100) as u8;
let _ = app.emit(
"sftp:progress",
SftpProgressEvent {
/// Throttle IPC progress so a large file does not flood the WebView/React thread.
struct ProgressEmitter {
app: AppHandle,
sftp_id: String,
last_emit: Instant,
last_percent: u8,
}
impl ProgressEmitter {
fn new(app: AppHandle, sftp_id: &str) -> Self {
Self {
app,
sftp_id: sftp_id.to_string(),
transferred,
total,
percentage,
},
);
// Force first emit after start.
last_emit: Instant::now()
.checked_sub(Duration::from_secs(1))
.unwrap_or_else(Instant::now),
last_percent: 255,
}
}
fn emit(&mut self, transferred: u64, total: u64) {
let percentage = if total == 0 {
100
} else {
transferred
.saturating_mul(100)
.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::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
@@ -7,7 +7,7 @@ use std::{
Arc, Mutex,
},
thread,
time::Duration,
time::{Duration, Instant},
};
use ssh2::ErrorCode;
@@ -19,10 +19,12 @@ use super::ssh::establish_session;
mod model;
mod relay;
mod session;
mod socks5;
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
use relay::relay_channel;
use session::TunnelSession;
use socks5::read_target;
enum IdClaim {
@@ -43,16 +45,18 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
local_bind_host: String,
local_port: u16,
remote_host: String,
remote_port: u16,
) -> 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")? {
IdClaim::Existing(status) => return Ok(status),
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}"))?;
listener
.set_nonblocking(true)
@@ -65,7 +69,7 @@ impl TunnelManager {
&forward_id,
connection_id,
"local",
"127.0.0.1",
&bind_host,
bound_port,
Some(remote_host.clone()),
Some(remote_port),
@@ -76,42 +80,25 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((socket, _)) => {
let config = config.clone();
let host = remote_host.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
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);
run_direct_listener(
listener,
config,
DirectTarget::Fixed {
host: remote_host,
port: remote_port,
},
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -155,60 +142,22 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((mut socket, _)) => {
let config = config.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
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);
run_direct_listener(
listener,
config,
DirectTarget::Dynamic,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -222,67 +171,65 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
remote_bind_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
) -> 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")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let session = establish_session(&config)?;
let (mut listener, bound_port) = session
.channel_forward_listen(remote_port, None, None)
.map_err(|error| format!("建立远程端口转发失败: {error}"))?;
let want_all_interfaces = is_all_interfaces_bind(&remote_bind_host);
let (listener, bound_port, bind_host) =
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);
let descriptor = descriptor(
&forward_id,
connection_id,
"remote",
"remote",
if bind_host.is_empty() {
"localhost"
} else {
bind_host.as_str()
},
bound_port,
Some(local_host.clone()),
Some(local_port),
);
let fingerprint = connection_fingerprint(&config);
let cancel = Arc::new(AtomicBool::new(false));
let running = Arc::new(AtomicBool::new(true));
let counters = Arc::new(TunnelCounters::default());
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let thread_config = config.clone();
let thread_bind = remote_bind_host.clone();
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok(channel) => {
let Ok(socket) = TcpStream::connect((&*local_host, local_port)) else {
continue;
};
let session = session.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
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(_) => break,
}
}
drop(listener);
let _ = session.disconnect(None, "port forward stopped", None);
thread_running.store(false, Ordering::Release);
run_remote_listener(
session,
listener,
thread_config,
remote_port,
thread_bind,
local_host,
local_port,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -327,18 +274,43 @@ impl TunnelManager {
.lock()
.map_err(lock_error)?
.values()
.filter(|runtime| {
connection_id.map_or(true, |id| runtime.descriptor.connection_id == id)
})
.filter(|runtime| connection_id.is_none_or(|id| runtime.descriptor.connection_id == id))
.map(TunnelRuntime::snapshot)
.collect::<Vec<_>>();
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
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.
/// - Same connection + kind already running → return existing status (idempotent).
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
/// - Same connection + kind still running → return existing status (idempotent).
/// - 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.
fn claim_id(
&self,
@@ -354,7 +326,8 @@ impl TunnelManager {
match runtimes.get(&id) {
Some(runtime)
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()));
}
@@ -394,20 +367,322 @@ impl Drop for TunnelManager {
}
}
fn validate_target(host: &str, port: u16) -> Result<(), String> {
if host.trim().is_empty() || port == 0 {
pub(crate) fn connection_fingerprint(config: &SshConnectRequest) -> String {
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());
}
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 {
"端口转发状态锁异常".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)]
mod tests {
use super::*;
use std::collections::HashSet;
fn unreachable_config() -> SshConnectRequest {
SshConnectRequest {
@@ -496,4 +771,77 @@ mod tests {
let _ = first;
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) 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) running: Arc<AtomicBool>,
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,
"resizable": true,
"decorations": true,
"transparent": false
"transparent": false,
"dragDropEnabled": false
}
],
"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": [
"style-src"
]
@@ -44,8 +45,8 @@
"signingIdentity": "-"
},
"category": "DeveloperTool",
"shortDescription": "Professional SSH and SFTP workspace",
"longDescription": "A native, secure, cross-platform SSH, SFTP, terminal and port-forwarding workspace."
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
"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": {
"deep-link": {
+14 -14
View File
@@ -16,31 +16,31 @@ export const AI_PROVIDER_FAMILY_META: Record<
{ label: string; description: string }
> = {
openai_compatible: {
label: 'OpenAI兼容',
description: '按量',
label: 'OpenAI compatible',
description: 'payg',
},
standalone: {
label: '────────',
label: 'Native APIs',
description: '',
},
plan: {
label: 'Plan计划',
description: '套餐',
label: 'Subscription plans',
description: 'plan',
},
local: {
label: '私有化本地',
description: '本地',
label: 'Self-hosted',
description: 'local',
},
custom: {
label: 'Custom自定义',
description: '自定义',
label: 'Custom',
description: 'custom',
},
}
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
payg: '按量',
plan: 'Plan',
local: '本地',
payg: 'payg',
plan: 'plan',
local: 'local',
}
const model = (
@@ -292,8 +292,8 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
// ——— Custom ———
{
id: 'custom',
name: 'Custom自定义',
description: '自定义',
name: 'Custom',
description: 'Custom endpoint',
family: 'custom',
presetId: 'custom',
protocol: 'openai-chat',
+53 -23
View File
@@ -33,7 +33,7 @@ import {
isEncryptedSessionBackup,
} from '../utils/sessionBackupCrypto'
import { usePromptDialog } from '../components/common/PromptDialog'
import { licenseServerBaseUrl } from '../stores/entitlementStore'
import { claimPurchase } from '../platform/licenseApi'
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
@@ -87,6 +87,9 @@ function App() {
useEffect(() => {
if (!settingsLoaded) return
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])
useEffect(() => {
@@ -97,6 +100,7 @@ function App() {
idleTimeoutMs: settings.idleTimeoutMs,
suspendTimeoutMs: settings.suspendTimeoutMs,
maxReconnectAttempts: settings.maxReconnectAttempts,
keepaliveIntervalMs: Math.max(5, settings.keepAliveIntervalSec || 15) * 1000,
})
}, [settings, settingsLoaded])
@@ -470,35 +474,53 @@ function App() {
void (async () => {
try {
const parsed = new URL(url)
const tokenFromQuery = parsed.searchParams.get('token')
if (tokenFromQuery) {
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
if (result.success) {
toast.success(t('common:pro.licenseActivated'))
useEntitlementStore.getState().openUpgradeTab()
return
}
}
const q = parsed.searchParams
const licenseKey =
q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
const orderNo = q.get('orderNo') || q.get('order_no') || ''
const emailFromLink =
q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
const store = useEntitlementStore.getState()
const deviceId =
useEntitlementStore.getState().deviceId ||
store.deviceId ||
q.get('deviceId') ||
q.get('device_id') ||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
if (!deviceId) return
const res = await fetch(`${licenseServerBaseUrl()}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
const data = (await res.json()) as { token?: string; error?: string }
if (data.token) {
const result = await useEntitlementStore.getState().activate(data.token)
if (emailFromLink) {
await store.setEmail(emailFromLink)
}
const email = (emailFromLink || store.email).trim()
const tryActivate = async (key: string) => {
const result = await store.activate(key, email || undefined)
if (result.success) {
toast.success(t('common:pro.licenseActivated'))
useEntitlementStore.getState().openUpgradeTab()
return
store.openUpgradeTab()
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'))
useEntitlementStore.getState().openUpgradeTab()
store.openUpgradeTab()
} catch (error) {
logger.warn('处理 Pro 深链接失败', error)
useEntitlementStore.getState().openUpgradeTab()
@@ -601,6 +623,14 @@ function App() {
if (!requirePro('serial')) return
setIsSerialConnectOpen(true)
}}
onRdpConnect={() => {
if (!requirePro('rdp')) return
setDesktopConnectProtocol('rdp')
}}
onVncConnect={() => {
if (!requirePro('vnc')) return
setDesktopConnectProtocol('vnc')
}}
/>
</Suspense>
+52 -31
View File
@@ -5,6 +5,8 @@
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
* - 标签栏空白区也由 Tauri 原生拖拽区处理
*
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
*/
import type { ReactNode } from 'react'
@@ -15,7 +17,9 @@ import TabBar from '../components/layout/TabBar'
import TabContent from '../components/layout/TabContent'
import StatusBar from '../components/layout/StatusBar'
import type { Session } from '../stores'
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
import { getDesktopPlatform } from '../platform/runtime'
import { startWindowDragging } from '../platform/tauri'
export interface AppShellProps {
activeTabId: string | null
@@ -42,7 +46,11 @@ function TitleBar() {
return (
<header
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="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 desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
return (
<div className="flex h-screen flex-col overflow-hidden bg-dark-900 text-content-primary">
<TitleBar />
<div
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">
{isMobile && mobileSidebarOpen && (
{!desktopFullscreen && isMobile && mobileSidebarOpen && (
<div
className="titlebar-no-drag fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
onClick={onCloseMobileSidebar}
@@ -128,32 +141,38 @@ export default function AppShell({
/>
)}
<aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: desktopSidebarHidden
? 'w-0 overflow-hidden border-0'
: ''
} `}
aria-hidden={desktopSidebarHidden}
>
<Sidebar
onNewSession={handleSidebarNewSession}
onQuickConnect={handleSidebarQuickConnect}
onEditSession={handleSidebarEditSession}
onCollapse={isMobile ? undefined : onToggleSidebar}
/>
</aside>
{!desktopFullscreen && (
<aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: desktopSidebarHidden
? 'w-0 overflow-hidden border-0'
: ''
} `}
aria-hidden={desktopSidebarHidden}
>
<Sidebar
onNewSession={handleSidebarNewSession}
onQuickConnect={handleSidebarQuickConnect}
onEditSession={handleSidebarEditSession}
onCollapse={isMobile ? undefined : onToggleSidebar}
/>
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col bg-dark-800">
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<TabBar onOpenLauncher={onOpenLauncher} />
<main
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
>
{!desktopFullscreen && (
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<TabBar onOpenLauncher={onOpenLauncher} />
</div>
</div>
</div>
)}
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
<TabContent
@@ -167,9 +186,11 @@ export default function AppShell({
</main>
</div>
<div className="titlebar-no-drag">
<StatusBar />
</div>
{!desktopFullscreen && (
<div className="titlebar-no-drag">
<StatusBar />
</div>
)}
<div className="titlebar-no-drag">{children}</div>
</div>
+1
View File
@@ -7,6 +7,7 @@ interface AgentMarkdownProps {
}
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
// eslint-disable-next-line react-refresh/only-export-components -- shared helper colocated with markdown renderer
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim()
if (!t) return false
+1 -1
View File
@@ -222,7 +222,7 @@ export function CommandPalette({
}
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(() => {
+53 -7
View File
@@ -2,7 +2,7 @@
* 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 { X } from 'lucide-react'
import Button from './Button'
@@ -62,21 +62,65 @@ export default function Modal({
const resolvedConfirm = confirmText ?? t('action.confirm')
const resolvedCancel = cancelText ?? t('action.cancel')
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
if (!isOpen) return
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
restoreFocusRef.current = document.activeElement instanceof HTMLElement
? 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
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleEsc)
window.addEventListener('keydown', handleKey, true)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', handleEsc)
window.removeEventListener('keydown', handleKey, true)
restoreFocusRef.current?.focus()
}
}, [isOpen, onClose])
}, [isOpen])
if (!isOpen) return null
@@ -93,6 +137,7 @@ export default function Modal({
/>
<div
ref={panelRef}
className={`
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
flex flex-col
@@ -144,6 +189,7 @@ export default function Modal({
size="sm"
onClick={onConfirm}
loading={loading}
data-autofocus={isDanger ? true : undefined}
>
{resolvedConfirm}
</Button>
+1
View File
@@ -119,6 +119,7 @@ export function usePromptDialog() {
<p className="text-content-secondary mb-3">{state.message}</p>
<input
ref={inputRef}
data-autofocus
type={state.inputType ?? 'text'}
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
value={state.value}
+17 -17
View File
@@ -18,31 +18,31 @@ const iconMap: Record<ToastType, typeof CheckCircle> = {
info: Info,
}
/** 类型 → 颜色样式映射 */
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
success: {
bg: 'bg-green-900/40',
border: 'border-green-700/60',
icon: 'text-green-400',
text: 'text-green-200',
bg: 'bg-feedback-success-bg',
border: 'border-feedback-success/35',
icon: 'text-feedback-success',
text: 'text-content-primary',
},
error: {
bg: 'bg-red-900/40',
border: 'border-red-700/60',
icon: 'text-red-400',
text: 'text-red-200',
bg: 'bg-feedback-error-bg',
border: 'border-feedback-error/35',
icon: 'text-feedback-error',
text: 'text-content-primary',
},
warning: {
bg: 'bg-surface-basember-900/40',
border: 'border-amber-700/60',
icon: 'text-amber-400',
text: 'text-amber-200',
bg: 'bg-feedback-warning-bg',
border: 'border-feedback-warning/35',
icon: 'text-feedback-warning',
text: 'text-content-primary',
},
info: {
bg: 'bg-blue-900/40',
border: 'border-blue-700/60',
icon: 'text-blue-400',
text: 'text-blue-200',
bg: 'bg-feedback-info-bg',
border: 'border-feedback-info/35',
icon: 'text-feedback-info',
text: 'text-content-primary',
},
}
+8 -8
View File
@@ -11,17 +11,17 @@ interface ToastContainerProps {
}
const iconMap: Record<ToastType, React.ReactNode> = {
info: <Info className="w-4 h-4 text-blue-400" />,
success: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
info: <Info className="w-4 h-4 text-feedback-info" />,
success: <CheckCircle className="w-4 h-4 text-feedback-success" />,
error: <XCircle className="w-4 h-4 text-feedback-error" />,
warning: <AlertTriangle className="w-4 h-4 text-feedback-warning" />,
}
const bgMap: Record<ToastType, string> = {
info: 'bg-blue-900/80 border-blue-700',
success: 'bg-green-900/80 border-green-700',
error: 'bg-red-900/80 border-red-700',
warning: 'bg-yellow-900/80 border-yellow-700',
info: 'bg-feedback-info-bg border-feedback-info/35',
success: 'bg-feedback-success-bg border-feedback-success/35',
error: 'bg-feedback-error-bg border-feedback-error/35',
warning: 'bg-feedback-warning-bg border-feedback-warning/35',
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
@@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<span
className={`text-xs px-1.5 py-0.5 rounded ${
conn.state === 'connected'
? 'bg-green-400/10 text-green-400'
? 'bg-feedback-success/10 text-feedback-success'
: conn.state === 'connecting' || conn.state === 'reconnecting'
? 'bg-yellow-400/10 text-yellow-400'
? 'bg-feedback-warning/10 text-feedback-warning'
: conn.state === 'background'
? 'bg-green-400/10 text-green-400 opacity-70'
? 'bg-feedback-success/10 text-feedback-success opacity-70'
: conn.state === 'suspended'
? 'bg-surface-active/10 text-txt-muted'
: conn.state === 'error'
? 'bg-red-400/10 text-red-400'
? 'bg-feedback-error/10 text-feedback-error'
: 'bg-surface-active/10 text-txt-muted'
}`}
>
@@ -200,9 +200,9 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const { t } = useTranslation('common')
const items = [
{ 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.background'), value: background, color: 'text-yellow-400' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-feedback-success' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-feedback-warning' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-feedback-error' },
]
return (
+277 -55
View File
@@ -11,10 +11,22 @@ import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
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
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
@@ -25,6 +37,8 @@ export interface ForwardRuleDraft {
type: ForwardRuleType
name: string
localPort: number
localBindHost: string
remoteBindHost: string
remoteHost: string
remotePort: number
}
@@ -43,6 +57,8 @@ interface ForwardRuleDrawerProps {
rules?: ForwardRule[]
runtimes?: Map<string, ForwardRuleRuntime>
busy?: boolean
/** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */
viaSession?: string
onChange: (next: Partial<ForwardRule>) => void
onClose: () => void
onSave: () => void
@@ -55,36 +71,74 @@ const EMPTY_DRAFT: ForwardRuleDraft = {
type: 'local',
name: '',
localPort: 0,
remoteHost: '',
localBindHost: DEFAULT_LOCAL_BIND_HOST,
remoteBindHost: DEFAULT_REMOTE_BIND_HOST,
remoteHost: '127.0.0.1',
remotePort: 0,
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyForwardDraft(): ForwardRuleDraft {
return { ...EMPTY_DRAFT }
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
return {
type: 'dynamic',
name,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
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') {
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
return t('portForward.socksProxy', { port: resolveDynamicPort(boundPort ?? rule.localPort) })
}
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', {
remote: rule.remotePort,
bind,
remote,
host,
local: rule.localPort,
})
}
const host = rule.remoteHost || 'localhost'
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
const bind = resolveLocalBindHost(rule.localBindHost)
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
const local = boundPort ?? rule.localPort
// Default bind is localhost — list as port only (matches ssh -L 8080:…).
if (bind === DEFAULT_LOCAL_BIND_HOST || bind === 'localhost') {
return t('portForward.localToRemotePort', {
local,
host,
remote: rule.remotePort,
})
}
return t('portForward.localToRemote', {
bind,
local,
host,
remote: rule.remotePort,
})
}
export default function ForwardRuleDrawer({
@@ -95,6 +149,7 @@ export default function ForwardRuleDrawer({
rules,
runtimes,
busy = false,
viaSession,
onChange,
onClose,
onSave,
@@ -152,17 +207,33 @@ export default function ForwardRuleDrawer({
type: 'dynamic',
name: nextName,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
remotePort: 0,
})
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({
...draft,
type: next,
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">
{localRemoteRules.map((rule) => {
const running = Boolean(runtimes?.get(rule.id)?.running)
const runtime = runtimes?.get(rule.id)
const running = Boolean(runtime?.running)
return (
<div
key={rule.id}
@@ -339,7 +411,7 @@ export default function ForwardRuleDrawer({
)}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
{ruleSummary(rule, t)}
{ruleSummary(rule, t, runtime?.boundPort)}
</p>
</div>
<div className="flex shrink-0 gap-0.5">
@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
</div>
{type === 'local' && (
<div>
<label className="ui-label">{t('portForward.remoteHost')}</label>
<input
type="text"
value={draft.remoteHost || ''}
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
placeholder={t('portForward.remoteHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
</div>
{type === 'local' ? (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSession', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.localListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) =>
onChange({
...draft,
localPort: Number(e.target.value),
localBindHost: resolveLocalBindHost(draft.localBindHost),
})
}
placeholder="8080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.targetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.remotePort
)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
if (parsed) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(parsed.host),
remotePort: parsed.port,
})
return
}
const colon = raw.lastIndexOf(':')
if (colon > 0) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
remotePort: Number(raw.slice(colon + 1)) || 0,
})
} else {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
})
}
}}
placeholder={t('portForward.targetAddressPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{viaSession
? t('portForward.targetAddressHintVia', { target: viaSession })
: t('portForward.targetAddressHint')}
</p>
</div>
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.localBindAdvanced')}
</summary>
<div className="mt-2">
<label className="ui-label">{t('portForward.localBindHost')}</label>
<input
type="text"
value={resolveLocalBindHost(draft.localBindHost)}
onChange={(e) =>
onChange({
...draft,
localBindHost: resolveLocalBindHost(e.target.value),
})
}
placeholder={t('portForward.localBindHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localBindHostHint')}
</p>
</div>
</details>
</>
) : (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSessionRemote', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.remoteListenPort')}</label>
<input
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>
)}
+109 -17
View File
@@ -4,12 +4,13 @@
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection'
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { translateErrorMessage } from '../../i18n/format'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -21,9 +22,19 @@ import {
duplicateForwardRule,
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
runtimeMap,
type ForwardRule,
} from './forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from './forwardIntent'
interface PortForwardViewProps {
tabId: string
@@ -41,6 +52,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const connectionId = currentTab?.connectionInstance?.connectionId
const connection = connectionId ? pool.getConnection(connectionId) : null
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
const intentKey = forwardIntentKey(currentTab?.sessionId, tabId)
const [rules, setRules] = useState<ForwardRule[]>([])
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
@@ -115,6 +127,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
remoteHost: '127.0.0.1',
})
setShowAddModal(true)
}
@@ -127,11 +141,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
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
}
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
setDrawerError(t('common:portForward.errorRemoteHost'))
if (newRule.type !== 'dynamic' && !newRule.remoteHost?.trim()) {
setDrawerError(
newRule.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
if (
@@ -148,7 +170,12 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
type: newRule.type as 'local' | 'remote' | 'dynamic',
name: newRule.name.trim(),
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,
}
@@ -172,17 +199,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
result = await window.opsxApi?.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi?.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi?.sshForwardDynamic(
@@ -193,18 +222,43 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
if (result?.success) {
rememberForwardIntent(intentKey, rule)
await refreshTunnels()
return true
}
setError(result?.error || t('common:portForward.errorStartFailed'))
setError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
} 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 {
setIsLoading(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 existing = rules.find((rule) => rule.type === 'dynamic')
if (existing) {
@@ -230,11 +284,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setIsLoading(true)
setError(null)
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) {
setError(result?.error || t('common:portForward.errorStopFailed'))
return false
}
forgetForwardIntent(intentKey, rule.id)
await refreshTunnels()
return true
} catch (err: unknown) {
@@ -416,14 +478,44 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
) : rule.type === 'local' ? (
<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>
{t('common:portForward.remoteToLocal', {
remote: runtime?.boundPort || rule.remotePort,
local: rule.localPort,
})}
{(() => {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
const remote = runtime?.boundPort || rule.remotePort
const host = resolveForwardTargetHost(rule.remoteHost)
if (bind === '127.0.0.1' || bind === 'localhost') {
return t('common:portForward.remoteToLocalPort', {
remote,
host,
local: rule.localPort,
})
}
return t('common:portForward.remoteToLocal', {
bind,
remote,
host,
local: rule.localPort,
})
})()}
</span>
)}
</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'
/** 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 {
id: string
type: ForwardRuleType
name: string
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
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(
sessionId: string | undefined,
tabId: string,
@@ -54,8 +135,14 @@ export function parseForwardRules(value: unknown): ForwardRule[] {
type: type as ForwardRuleType,
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()) {
parsed.remoteHost = rule.remoteHost.trim()
parsed.remoteHost = resolveForwardTargetHost(rule.remoteHost)
}
const remotePort = Number(rule.remotePort)
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 { Monitor, Cast } from 'lucide-react'
import Modal from '../common/Modal'
import DesktopResolutionPicker from './DesktopResolutionPicker'
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
import { clampDesktopResolution, getDisplayResolution } from '../../utils/desktopResolution'
interface DesktopConnectDialogProps {
protocol: DesktopProtocol
@@ -32,6 +34,9 @@ export default function DesktopConnectDialog({
if (!isOpen) return
setPort(isRdp ? 3389 : 5900)
setError(null)
const display = getDisplayResolution()
setWidth(display.width)
setHeight(display.height)
}, [isOpen, isRdp])
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
@@ -46,6 +51,7 @@ export default function DesktopConnectDialog({
setError(t('connect.errorUsernameRequired'))
return
}
const resolution = clampDesktopResolution(width, height)
if (isRdp) {
onConnect('rdp', {
host: host.trim(),
@@ -53,8 +59,8 @@ export default function DesktopConnectDialog({
username: username.trim(),
password,
domain: domain.trim() || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
security: 'nla',
})
} else {
@@ -62,8 +68,8 @@ export default function DesktopConnectDialog({
host: host.trim(),
port,
password: password || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
})
}
onClose()
@@ -145,26 +151,14 @@ export default function DesktopConnectDialog({
autoComplete="off"
/>
</div>
<div>
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
<input
id="desktop-width"
type="number"
value={width}
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>
<DesktopResolutionPicker
width={width}
height={height}
onChange={(next) => {
setWidth(next.width)
setHeight(next.height)
}}
/>
</section>
<p className="text-[11px] text-txt-faint leading-relaxed">
{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 {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Maximize2,
Minimize2,
Monitor,
RefreshCw,
Unplug,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import type {
DesktopFrameEvent,
DesktopPointerButton,
@@ -28,6 +30,7 @@ import {
rdpDisconnect,
rdpInputKey,
rdpInputPointer,
rdpResize,
rdpUploadFile,
} from '../../platform/rdp'
import {
@@ -41,6 +44,7 @@ import {
vncInputPointer,
} from '../../platform/vnc'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
import { clampDesktopResolution } from '../../utils/desktopResolution'
interface DesktopSessionViewProps {
tabId: string
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
const { t } = useTranslation('desktop')
const canvasRef = useRef<HTMLCanvasElement>(null)
const paneRef = useRef<HTMLDivElement>(null)
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
const nativeSessionIdRef = useRef<string | null>(null)
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 pointerRafRef = useRef<number | null>(null)
const paintRafRef = useRef<number | null>(null)
const hideBarTimerRef = useRef<number | null>(null)
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<{
x: 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 sessions = useSessionStore((state) => state.sessions)
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 [error, setError] = useState<string | null>(null)
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const [chromeVisible, setChromeVisible] = useState(true)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.connecting'))
setError(null)
connectedRef.current = false
lastRemoteResizeRef.current = null
void (async () => {
try {
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
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') {
connectedRef.current = true
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
connectedRef.current = false
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
} else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} else if (payload.message) {
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
return () => {
disposed = true
connectedRef.current = false
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
remoteResizeTimerRef.current = null
}
if (paintRafRef.current != null) {
window.cancelAnimationFrame(paintRafRef.current)
paintRafRef.current = null
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
canvasRef.current?.focus()
}, [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 = () => {
pointerRafRef.current = null
const sessionId = nativeSessionIdRef.current
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
const sessionId = nativeSessionIdRef.current
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()
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 hostLabel =
resolvedConfig && 'host' in resolvedConfig
@@ -458,91 +606,140 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
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'
return (
<div className="h-full flex flex-col bg-dark-900">
<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">
<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'
}`}
/>
const toolbar = (
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
fullscreenActive
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
: 'border-b border-dark-700/50 bg-dark-800/80'
}`}
onMouseEnter={() => {
if (fullscreenActive) {
setChromeVisible(true)
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = null
}
}
}}
onMouseLeave={() => {
if (fullscreenActive) scheduleHideChrome()
}}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<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
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>
</>
)}
<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>
<button
type="button"
className={toolBtn}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
}}
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
>
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
</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
ref={canvasRef}
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()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
+18 -7
View File
@@ -75,16 +75,24 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
.finally(() => setSessionsLoading(false))
}, [setSessions])
// Click outside to close context menu
// Click outside to close context menu (only while open)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!contextMenu) return
const handlePointerDown = (e: PointerEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleKeydown = (e: KeyboardEvent) => {
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
const handleResizeStart = useCallback((e: React.MouseEvent) => {
@@ -423,7 +431,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 text-accent-green" />
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
@@ -517,6 +525,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
onPointerDown={(e) => e.stopPropagation()}
>
{(contextMenu.kind === 'blank'
? [
@@ -536,7 +545,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
]
: [
{ 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.duplicate'), action: 'duplicate' },
{ 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 { useSessionStore, useEntitlementStore } from '../../stores'
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 ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -17,8 +17,18 @@ import ForwardRuleDrawer, {
import {
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from '../connection/forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from '../connection/forwardIntent'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
// ============== Status Display Config ==============
@@ -34,37 +44,37 @@ const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey
connected: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.connected',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.connecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
background: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.background',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
suspended: {
icon: <WifiOff className="w-3 h-3" />,
labelKey: 'status.suspended',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: '',
},
reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.reconnecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
error: {
icon: <AlertCircle className="w-3 h-3" />,
labelKey: 'status.error',
colorClass: 'text-red-400',
colorClass: 'text-feedback-error',
animateClass: '',
},
disconnected: {
@@ -127,7 +137,7 @@ function formatCpuPercent(pct: number): string {
function pctClass(pct?: number): string {
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'
}
@@ -382,7 +392,6 @@ export default function StatusBar() {
connectionState: undefined,
hostLabel: undefined,
stats: undefined,
tabKind: activeTab?.type as string | undefined,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -399,7 +408,6 @@ export default function StatusBar() {
: activeTab.title
: activeTab.title,
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -411,7 +419,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: t('terminal:toolbar.labelLocal'),
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -431,7 +438,6 @@ export default function StatusBar() {
connectionState: conn.state,
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
stats: conn.stats,
tabKind: activeTab.type,
connectionId: instance.connectionId,
isRemoteSsh: true,
sessionOsPretty,
@@ -444,7 +450,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -461,7 +466,6 @@ export default function StatusBar() {
connectionState: state,
hostLabel: label,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -481,6 +485,11 @@ export default function StatusBar() {
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
}, [activeTab?.sessionId, activeTabId, connectionId])
const intentKey = useMemo(
() => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null),
[activeTab?.sessionId, activeTabId]
)
const forwardRuntimes = useMemo(() => {
const map = new Map<string, { running: boolean; boundPort?: number }>()
for (const tunnel of forwardTunnels) {
@@ -489,10 +498,13 @@ export default function StatusBar() {
boundPort: tunnel.boundPort,
})
}
const dynamic = forwardRules.find((rule) => rule.type === 'dynamic')
const dynamicTunnel = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (dynamic && dynamicTunnel && !map.get(dynamic.id)?.running) {
map.set(dynamic.id, { running: true, boundPort: dynamicTunnel.boundPort })
// Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port.
for (const rule of forwardRules) {
if (map.get(rule.id)?.running) continue
const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule))
if (tunnel) {
map.set(rule.id, { running: true, boundPort: tunnel.boundPort })
}
}
return map
}, [forwardRules, forwardTunnels])
@@ -585,6 +597,11 @@ export default function StatusBar() {
return () => window.clearInterval(timer)
}, [socksAvailable, loadForwardRules, refreshForwardState])
const previousForwardConnectionRef = useRef<string | undefined>(undefined)
useEffect(() => {
previousForwardConnectionRef.current = undefined
}, [intentKey])
const openForwardDrawer = useCallback(() => {
if (!connectionId || !activeTabId) return
if (!requirePro('portForward')) return
@@ -594,6 +611,10 @@ export default function StatusBar() {
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
localPort: 8080,
remoteHost: '127.0.0.1',
remotePort: 80,
})
setForwardDrawerError(null)
setForwardDrawerOpen(true)
@@ -617,17 +638,19 @@ export default function StatusBar() {
result = await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi.sshForwardDynamic(
@@ -637,48 +660,73 @@ export default function StatusBar() {
)
}
if (!result?.success) {
setForwardDrawerError(result?.error || t('common:portForward.errorStartFailed'))
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
return false
}
if (intentKey) rememberForwardIntent(intentKey, rule)
await refreshForwardState()
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(
async (rule: ForwardRule): Promise<boolean> => {
if (!window.opsxApi?.sshForwardStop) return false
const result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success) {
// Dynamic may have been started with a different id historically.
if (rule.type === 'dynamic' && connectionId) {
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (match) {
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
}
let result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
// Runtime id may differ from persisted rule id after reconnect.
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)
}
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed'))
}
if (!result?.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
)
return false
}
if (intentKey) forgetForwardIntent(intentKey, rule.id)
await refreshForwardState()
return true
},
[connectionId, refreshForwardState, t]
[connectionId, intentKey, refreshForwardState, t]
)
const handleStartRule = useCallback(
async (rule: ForwardRule) => {
if (!connectionId || forwardSaving) return
if (forwardRuntimes.get(rule.id)?.running) {
toast.success(t('common:statusBar.forwardStarted'))
return
}
setForwardSaving(true)
setForwardDrawerError(null)
try {
@@ -701,16 +749,18 @@ export default function StatusBar() {
}
}
const ok = await startForwardRule(target)
if (ok && target.type === 'dynamic') {
if (ok) {
toast.success(
t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
target.type === 'dynamic'
? t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
: t('common:statusBar.forwardStarted')
)
}
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} finally {
@@ -720,6 +770,7 @@ export default function StatusBar() {
[
connectionId,
forwardRules,
forwardRuntimes,
forwardSaving,
persistForwardRules,
startForwardRule,
@@ -800,11 +851,19 @@ export default function StatusBar() {
return
}
if (!validPort(forwardDraft.remotePort)) {
setForwardDrawerError(t('common:portForward.errorTargetPort'))
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorRemoteListenAddress')
)
return
}
if (forwardDraft.type === 'local' && !forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(t('common:portForward.errorRemoteHost'))
if (!forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
@@ -813,7 +872,15 @@ export default function StatusBar() {
type: forwardDraft.type || 'local',
name: forwardDraft.name.trim(),
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,
}
@@ -836,7 +903,7 @@ export default function StatusBar() {
toast.success(t('common:statusBar.forwardStarted'))
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} 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" />}
<span className="tabular-nums">{t('common:statusBar.socksLabel')}</span>
<span className="tabular-nums">
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
</span>
</button>
)}
@@ -972,6 +1041,7 @@ export default function StatusBar() {
rules={forwardRules}
runtimes={forwardRuntimes}
busy={forwardSaving}
viaSession={hostLabel}
onChange={(next) => {
setForwardDraft({
type: next.type || 'local',
@@ -980,6 +1050,14 @@ export default function StatusBar() {
next.type === 'dynamic'
? resolveDynamicPort(next.localPort)
: next.localPort || 0,
localBindHost:
next.type === 'local'
? resolveLocalBindHost(next.localBindHost)
: '',
remoteBindHost:
next.type === 'remote'
? resolveRemoteBindHost(next.remoteBindHost)
: '',
remoteHost: next.remoteHost || '',
remotePort: next.remotePort || 0,
})
+20 -2
View File
@@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
>
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
<div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist"
aria-label={t('tab.listAria')}
>
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
@@ -147,7 +151,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
<button
type="button"
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')}
aria-label={t('launcher.title')}
>
@@ -175,6 +179,8 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
sessionId: currentTab.sessionId,
connectionConfig: currentTab.connectionConfig,
serialConfig: currentTab.serialConfig,
rdpConfig: currentTab.rdpConfig,
vncConfig: currentTab.vncConfig,
isLocal: currentTab.isLocal,
localShellKind: currentTab.localShellKind,
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>
)
+5 -1
View File
@@ -117,8 +117,12 @@ function TabContent({
return (
<div
key={tab.id}
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
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}
@@ -77,9 +77,9 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
)
const badgeClass = (kind: string) => {
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
if (kind === 'zsh') return 'bg-teal-700/90 text-teal-50'
if (kind === 'bash') return 'bg-accent-green/15 text-accent-green'
if (kind === 'fish') return 'bg-accent-purple/15 text-accent-purple'
return 'bg-dark-600 text-content-secondary'
}
@@ -108,6 +108,8 @@ export function SessionTree({
<button
type="button"
data-group-row
aria-expanded={isExpanded}
aria-controls={`session-group-${group}`}
onClick={() => onToggleGroup(group)}
onContextMenu={(e) => {
e.preventDefault()
@@ -126,10 +128,12 @@ export function SessionTree({
</button>
<div
id={`session-group-${group}`}
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
aria-hidden={!isExpanded}
>
<div className="overflow-hidden">
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
{groupSessions.length === 0 ? (
<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}
data-session-row
role="button"
tabIndex={0}
tabIndex={isExpanded ? 0 : -1}
onClick={() => {
if (multiSelect) onToggleSelection?.(session)
else onSelectSession(session)
@@ -22,6 +22,7 @@ interface TabContextMenuProps {
onCloseLeft: () => void
onCloseAll: () => void
onClone?: () => void
onOpenSftp?: () => void
}
export function TabContextMenu({
@@ -34,19 +35,30 @@ export function TabContextMenu({
onCloseLeft,
onCloseAll,
onClone,
onOpenSftp,
}: TabContextMenuProps) {
const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!isOpen) return
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
onCloseRef.current()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [isOpen])
if (!isOpen) return null
@@ -61,16 +73,21 @@ export function TabContextMenu({
if (onClone) {
items.unshift({ label: t('tab.menu.clone'), action: onClone })
}
if (onOpenSftp) {
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
}
return (
<div
ref={menuRef}
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]"
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={() => {
item.action()
onClose()
+27 -4
View File
@@ -16,7 +16,7 @@ interface TabItemProps {
isDragging: boolean
dragOverIndex: number | null
onActivate: () => void
onClose: (e: React.MouseEvent) => void
onClose: (e: { stopPropagation(): void }) => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
@@ -38,7 +38,7 @@ export function TabItem({
onDrop,
onDragEnd,
}: TabItemProps) {
const { t } = useTranslation('terminal')
const { t } = useTranslation(['terminal', 'common'])
const displayTitle = resolveTabTitle(tab)
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
const session = useSessionStore((state) =>
@@ -49,7 +49,7 @@ export function TabItem({
if (isLocalTerm) {
return (
<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')}
/>
)
@@ -99,8 +99,28 @@ export function TabItem({
return (
<div
data-tab-item
role="tab"
aria-selected={isActive}
tabIndex={0}
draggable
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}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
@@ -132,9 +152,12 @@ export function TabItem({
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common:tab.closeAria', { title: displayTitle })}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
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' : ''}
`}
>
+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'
@@ -12,11 +12,11 @@ import {
Shield,
} from 'lucide-react'
import {
licenseServerBaseUrl,
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
import { checkout as licenseCheckout } from '../../platform/licenseApi'
import { createLogger } from '../../utils/logger'
const logger = createLogger('UpgradeView')
@@ -29,15 +29,49 @@ interface UpgradeViewProps {
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] 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) {
const { t } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const deviceId = useEntitlementStore((s) => s.deviceId)
const email = useEntitlementStore((s) => s.email)
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 setEmail = useEntitlementStore((s) => s.setEmail)
const clear = useEntitlementStore((s) => s.clear)
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 [checkoutError, setCheckoutError] = useState<string | null>(null)
const [licenseInput, setLicenseInput] = useState('')
@@ -49,25 +83,44 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
void 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 startCheckout = useCallback(async () => {
const startCheckout = useCallback(async (planChoice: 'yearly' | 'perpetual') => {
setSelectedPlan(planChoice)
setCheckoutLoading(true)
setCheckoutError(null)
const trimmedEmail = emailInput.trim()
if (!trimmedEmail || !trimmedEmail.includes('@')) {
setCheckoutError(t('pro.emailRequired'))
setCheckoutLoading(false)
return
}
try {
await setEmail(trimmedEmail)
const id =
deviceId ||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: id }),
const result = await licenseCheckout({
customerEmail: trimmedEmail,
deviceId: id,
planType: planChoice,
})
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
if (!result.checkoutUrl) {
throw new Error(t('pro.checkoutUnavailable', { status: 'no-url' }))
}
await window.opsxApi.openExternal(data.checkoutUrl)
await window.opsxApi.openExternal(result.checkoutUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('checkout failed', message)
@@ -75,19 +128,36 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
} finally {
setCheckoutLoading(false)
}
}, [deviceId, t])
}, [deviceId, emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setActivateError(null)
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) {
setActivateOk(true)
setLicenseInput('')
} else {
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 (
<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
? t('pro.subtitleComingSoon', { feature: highlight })
: highlight
? t('pro.subtitleUnlock', { feature: highlight })
? t('pro.subtitleUnlock')
: t('pro.subtitleDefault')}
</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">
{t('pro.activated')}
{expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')}
{plan === 'pro' ? activatedLabel(t, planType) : t('pro.accountEmail', { email })}
{plan === 'pro' && email ? ` · ${email}` : null}
{plan === 'pro'
? expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')
: null}
{plan === 'pro' && maxDevices
? ` · ${t('pro.devices', { count: maxDevices })}`
: null}
</p>
)}
</div>
</header>
<div className="mb-8 grid gap-4 sm:grid-cols-2">
<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>
<ul className="space-y-2">
<p className="mb-3 text-center text-sm text-txt-muted">{t('pro.choosePlan')}</p>
{plan !== 'pro' && (
<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) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
<li key={key} className="flex items-start gap-1.5 text-xs text-content-secondary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-txt-muted" />
{t(`pro.freeItems.${key}`)}
</li>
))}
</ul>
<div className="mt-4 rounded-lg border border-dark-600 px-3 py-2 text-center text-xs text-txt-muted">
{plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')}
</div>
</div>
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
<ul className="space-y-2">
{/* Yearly */}
<div className={cardClass('yearly', true)}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('yearly')}
>
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
{t('pro.planYearly')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">¥99</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planYearlyDesc')}</div>
</button>
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
<ul className="mt-2 flex-1 space-y-1.5">
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<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('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>
{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
type="button"
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')}
</button>
@@ -185,7 +356,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
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"
/>
<button
+9 -3
View File
@@ -19,7 +19,7 @@ export default function UpgradeWall() {
if (!wall.open) return null
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
return (
@@ -48,11 +48,17 @@ export default function UpgradeWall() {
<Crown className="h-5 w-5" />
</div>
<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>
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
{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>
</div>
+9 -6
View File
@@ -7,6 +7,7 @@ import { Cable, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalDispose, safeTerminalFit } from '../../utils/safeTerminalFit'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -72,7 +73,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
terminal.open(container)
terminalRef.current = terminal
fitRef.current = fit
fit.fit()
safeTerminalFit(fit, terminal)
const pipeline = new TerminalPipeline({
input: async (data) => {
@@ -109,7 +110,9 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
}
container.addEventListener('keydown', handleKeyDown)
const handleResize = () => fit.fit()
const handleResize = () => {
safeTerminalFit(fit, terminal)
}
window.addEventListener('resize', handleResize)
updateTabStatus(tabId, 'connecting')
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
sessionIdRef.current = null
if (sessionId) void closeSerial(sessionId)
terminal.dispose()
terminalRef.current = null
fitRef.current = null
terminalRef.current = null
safeTerminalDispose(terminal)
}
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
useEffect(() => {
if (isActive) {
fitRef.current?.fit()
safeTerminalFit(fitRef.current, terminalRef.current)
terminalRef.current?.focus()
}
}, [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" />}
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
</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>
)
}
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
} finally {
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
}
}, [timeout, updateResult])
}, [timeout, updateResult, t])
const execute = useCallback(async () => {
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 () => {
+53 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
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 { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
@@ -9,7 +9,11 @@ import {
type AvailableLocalShell,
} from '../../platform/localTerminal'
import { fallbackLocalShells } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
import { useEntitlementStore } from '../../stores/entitlementStore'
interface QuickLauncherProps {
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
onNewSession: () => void
onQuickConnect: () => void
onSerialConnect: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
}
export default function QuickLauncher({
@@ -26,6 +32,8 @@ export default function QuickLauncher({
onNewSession,
onQuickConnect,
onSerialConnect,
onRdpConnect,
onVncConnect,
}: QuickLauncherProps) {
const { t } = useTranslation(['common', 'sessions'])
const [query, setQuery] = useState('')
@@ -86,7 +94,7 @@ export default function QuickLauncher({
/>
</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">
<LogIn className="w-4 h-4 text-accent-blue" />
<span>
@@ -95,7 +103,7 @@ export default function QuickLauncher({
</span>
</button>
<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>
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
<small>{t('common:launcher.cards.saveSession.desc')}</small>
@@ -108,6 +116,42 @@ export default function QuickLauncher({
<small>{t('common:launcher.cards.serial.desc')}</small>
</span>
</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>
<section>
@@ -131,7 +175,7 @@ export default function QuickLauncher({
}))}
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>
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
</button>
@@ -161,7 +205,10 @@ export default function QuickLauncher({
<Server className="w-4 h-4 text-accent-blue" />
<span className="flex-1 min-w-0 text-left">
<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 className="launcher-badge">{session.group || 'default'}</span>
</button>
+78 -68
View File
@@ -20,6 +20,8 @@ import {
} from '../../utils/sessionGroups'
import { useSettingsStore } from '../../stores/settingsStore'
import { usePromptDialog } from '../common/PromptDialog'
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution'
const logger = createLogger('SessionDialog')
@@ -152,14 +154,17 @@ export default function SessionDialog({
: [],
domain: editSession.domain || '',
security: editSession.security || 'nla',
width: editSession.width || 1920,
height: editSession.height || 1080,
width: editSession.width || getDisplayResolution().width,
height: editSession.height || getDisplayResolution().height,
})
setShowAdvanced(hasAdvanced)
} else {
const display = getDisplayResolution()
setFormData({
...emptyForm,
group: normalizeGroupId(defaultGroup),
width: display.width,
height: display.height,
})
setTarget('')
setShowAdvanced(false)
@@ -185,12 +190,15 @@ export default function SessionDialog({
prev.port === defaultPortForProtocol(prev.protocol)
? defaultPortForProtocol(protocol)
: prev.port
const display = getDisplayResolution()
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
return {
...prev,
protocol,
username: nextUser,
port: nextPort,
authType: 'password',
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
}
})
// Keep host-only target when switching protocol.
@@ -394,6 +402,50 @@ export default function SessionDialog({
{ 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 (
<>
<div className="fixed inset-0 z-[100] flex justify-end">
@@ -549,73 +601,30 @@ export default function SessionDialog({
/>
</div>
)}
{formData.protocol !== 'ssh' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
<input
id="session-width"
type="number"
value={formData.width}
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
className="ui-input font-mono"
/>
</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>
{formData.protocol === 'rdp' && (
<div>
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
)}
<div>
<label htmlFor="session-group" className="ui-label">
<Folder className="inline w-3 h-3 mr-0.5" />
{t('dialog.group')}
</label>
<select
id="session-group"
value={normalizeGroupId(formData.group)}
onChange={(e) => {
const value = e.target.value
if (value !== '__new__') {
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
return
}
void (async () => {
const raw = await prompt({
title: t('groups.new.title'),
message: t('groups.new.message'),
placeholder: t('groups.new.placeholder'),
confirmText: t('groups.new.confirm'),
})
if (raw == null) return
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
})()
}}
className="ui-input"
>
{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>
{formData.protocol !== 'ssh' && (
<DesktopResolutionPicker
ns="sessions"
width={formData.width}
height={formData.height}
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
/>
)}
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
{formData.protocol === 'rdp' && groupField}
</section>
{/* 凭据 */}
@@ -647,7 +656,7 @@ export default function SessionDialog({
</div>
)}
{(formData.authType === 'password' || !isSsh) && (
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<input
@@ -774,6 +783,7 @@ export default function SessionDialog({
</div>
)}
{(isSsh || formData.protocol === 'vnc') && groupField}
</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 { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
@@ -273,7 +277,8 @@ export default function WelcomeView({
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
<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>
{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}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
</option>
))}
</optgroup>
+66 -61
View File
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
import type { AppLanguage } from '../../i18n/detectLocale'
import {
Monitor,
Palette,
Terminal,
Keyboard,
Shield,
@@ -18,13 +17,16 @@ import {
Copy,
Check,
Sparkles,
UserRound,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import AccountSettings from './AccountSettings'
import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
interface TabMeta {
id: SettingsTab
@@ -41,11 +43,12 @@ interface RuntimeInfo {
sshBackend: string
dataPath: string
credentialPath: string
logPath: string
}
const TAB_LIST: TabMeta[] = [
{ 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: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
@@ -148,25 +151,13 @@ export default function SettingsView() {
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
// ── 搜索过滤 ──────────────────────────────────────────────
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
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])
return filterSettingsTabsByQuery(
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
searchQuery
)
}, [searchQuery])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
@@ -182,6 +173,7 @@ export default function SettingsView() {
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
`Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`,
].join('\n')
: `OpsXShell v${appVersion}`
@@ -197,10 +189,39 @@ export default function SettingsView() {
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
// ──────── 通用(主题 / 语言 / 工作区)─────────
case 'general':
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>
<SettingGroup title={t('general.layout')}>
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
<div className="flex items-center gap-2">
@@ -238,40 +259,8 @@ export default function SettingsView() {
</div>
)
// ──────── 外观 ─────────
case 'appearance':
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 'account':
return <AccountSettings />
// ──────── 终端 ─────────
case 'terminal':
@@ -520,6 +509,16 @@ export default function SettingsView() {
checked={settings.keepAlive}
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
label={t('ssh.autoReconnect')}
description={t('ssh.autoReconnectDesc')}
@@ -572,10 +571,10 @@ export default function SettingsView() {
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
<input
type="number"
min={1}
max={120}
min={5}
max={240}
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"
/>
</SettingRow>
@@ -662,6 +661,12 @@ export default function SettingsView() {
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</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>
</div>
</SettingCard>
@@ -711,9 +716,9 @@ export default function SettingsView() {
key={tab.id}
data-testid={`settings-tab-${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="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'}`}>
+3 -1
View File
@@ -25,13 +25,15 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
onClick={onHome}
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
title={t('nav.home')}
aria-label={t('nav.home')}
>
<Home className="h-4 w-4 text-txt-muted" />
</button>
<button
type="button"
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}
</button>
+28 -7
View File
@@ -13,9 +13,13 @@ interface ConnectionPanelProps {
isConnected: boolean
isConnecting: boolean
error: string | null
/** Last remote directory to resume after reconnect (shown as hint). */
resumePath?: string | null
sessions: Session[]
showSelector: boolean
onConnect: (sessionId: string) => void
/** Prefer same-session reconnect when the tab already knows sessionId. */
onReconnect?: () => void
onShowSelector: () => void
onCloseSelector: () => void
}
@@ -24,13 +28,16 @@ export function ConnectionPanel({
isConnected,
isConnecting,
error,
resumePath,
sessions,
showSelector,
onConnect,
onReconnect,
onShowSelector,
onCloseSelector,
}: ConnectionPanelProps) {
const { t } = useTranslation('sftp')
const canResumePath = Boolean(resumePath)
if (isConnected) {
return null
@@ -40,22 +47,36 @@ export function ConnectionPanel({
return (
<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" />
<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>
)
}
if (error) {
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 gap-2">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
<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 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<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>
<button
type="button"
onClick={onShowSelector}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
onClick={() => {
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')}
</button>
+6 -4
View File
@@ -5,6 +5,7 @@
import { Monitor, ChevronRight, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Session } from '../../stores/connection'
import { isSshSession } from '../../utils/sessionProtocol'
interface ConnectionSelectorProps {
sessions: Session[]
@@ -14,6 +15,7 @@ interface ConnectionSelectorProps {
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
const { t } = useTranslation('sftp')
const sshSessions = sessions.filter((session) => isSshSession(session))
return (
<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>
</div>
<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">
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{t('connection.noSessions')}</p>
@@ -33,7 +35,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
</div>
) : (
<div className="space-y-2">
{sessions.map((session) => (
{sshSessions.map((session) => (
<button
key={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" />
<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">
{session.username}@{session.host}:{session.port}
</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>
))}
</div>
+59 -8
View File
@@ -1,8 +1,19 @@
/**
* 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 {
icon: React.ReactNode
@@ -21,9 +32,40 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
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(() => {
const handleClick = (e: MouseEvent) => {
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
@@ -31,19 +73,26 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [onClose])
return (
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
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: 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) => (
<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" />
)}
<button
type="button"
onClick={() => {
item.action()
onClose()
@@ -64,6 +114,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
</button>
</div>
))}
</div>
</div>,
document.body
)
}
+415 -131
View File
@@ -1,29 +1,29 @@
/**
* 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 {
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
Search, X, Eye, EyeOff,
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, Download, FolderOpen,
Search, X, Eye, EyeOff, RefreshCw, FolderPlus,
} from 'lucide-react'
import { Breadcrumb } from './Breadcrumb'
import { SortHeader } from './SortHeader'
import { FileRow } from './FileRow'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { FileListSkeleton } from '../common/Skeleton'
import EmptyState from '../common/EmptyState'
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
import { parentPath } from '../../utils/localPath'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
import {
hasSftpDragEntry,
readSftpDragEntry,
writeSftpDragEntry,
clearSftpDragEntry,
dndLog,
shortPath,
type SftpDragEntry,
} from './dragDrop'
@@ -31,6 +31,8 @@ interface FileListPanelProps {
title: string
isLocal: boolean
path: string
/** 主目录(Home 按钮目标);远程应传会话起始家目录,不是 / */
homePath?: string
files: FileItem[]
selected: Set<string>
history: string[]
@@ -45,20 +47,74 @@ interface FileListPanelProps {
onOpen: (file: FileItem) => void
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
onSort: (field: SortField) => void
contextMenu: { x: number; y: number; items: MenuItem[] } | null
onCloseContextMenu: () => 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
/** 刷新中:图标旋转 + 状态栏提示,不拆掉现有列表 */
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,
isLocal,
path,
homePath,
files,
selected,
history,
@@ -73,22 +129,26 @@ export function FileListPanel({
onOpen,
onContextMenu,
onSort,
contextMenu,
onCloseContextMenu,
onDropFiles,
onDropEntry,
onDropFailed,
onRefresh,
onNewFolder,
onFocusPane,
loading = false,
refreshing = false,
listError = null,
}: FileListPanelProps) {
const { t } = useTranslation('sftp')
const canGoBack = historyIndex > 0
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 [showHidden, setShowHidden] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
// Clear name filter when directory changes (keep showHidden preference)
useEffect(() => {
setNameFilter('')
}, [path])
@@ -109,26 +169,61 @@ export function FileListPanel({
)
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 [dropTargetPath, setDropTargetPath] = useState<string | 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 internal = readSftpDragEntry(dataTransfer)
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
}, [isLocal, onDropEntry, onDropFiles])
const acceptsOsFileDrag = useCallback((dataTransfer: DataTransfer) => {
if (isLocal || !onDropFiles) return false
return hasOsFiles(dataTransfer)
}, [isLocal, onDropFiles])
const resetDragState = useCallback(() => {
dragCounterRef.current = 0
const resetOsDragState = useCallback(() => {
dragDepthRef.current = 0
setIsDragOver(false)
setDropTargetPath(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(() => {
setNameFilter('')
searchInputRef.current?.focus()
@@ -146,7 +241,6 @@ export function FileListPanel({
}
}, [nameFilter])
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
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) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
dragDepthRef.current += 1
setDragSource(null)
setIsDragOver(true)
if (dragDepthRef.current === 1) {
dndLog('os-dragenter', { pane: 'remote', accept: true })
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer) && dragDepthRef.current === 0) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragOver(false)
setDropTargetPath(null)
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [acceptsDrag])
if (!isDragOver) setIsDragOver(true)
}, [acceptsOsFileDrag, isDragOver])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const entry = readSftpDragEntry(e.dataTransfer)
resetDragState()
consumeOsDrop(e.dataTransfer, path)
}, [acceptsOsFileDrag, consumeOsDrop, path])
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, path)
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
const dropOsOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
const entry = readSftpDragEntry(event.dataTransfer)
resetDragState()
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, directory.path)
return
dndLog('drop-os-on-dir', { dir: shortPath(directory.path) })
consumeOsDrop(event.dataTransfer, directory.path)
}, [acceptsOsFileDrag, consumeOsDrop])
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) {
onDropFiles?.(event.dataTransfer.files, directory.path)
event.currentTarget.setPointerCapture?.(event.pointerId)
}, [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 = (() => {
if (refreshing) return t('status.refreshing')
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 (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
return t('status.total', { count: visibleFiles.length })
})()
const PreviewIcon = pointerPreview?.entries[0]?.source === 'remote' ? Download : Upload
return (
<div
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'
}`}
onMouseDown={() => onFocusPane?.()}
onFocusCapture={() => onFocusPane?.()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@@ -238,13 +464,36 @@ export function FileListPanel({
onKeyDown={handlePanelKeyDown}
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 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" />}
<span className="text-sm font-medium truncate">{title}</span>
</div>
<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
type="button"
onClick={() => setShowHidden((v) => !v)}
@@ -277,22 +526,22 @@ export function FileListPanel({
</button>
<button
onClick={onNavigateUp}
className="p-1.5 hover:bg-dark-600 rounded"
title={t('nav.up')}
disabled={!canGoUp}
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" />
</button>
</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="flex-1 min-w-0">
<Breadcrumb
path={path}
isLocal={isLocal}
onNavigate={onNavigate}
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
onHome={() => onNavigate(resolvedHome)}
/>
</div>
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
@@ -321,27 +570,40 @@ export function FileListPanel({
</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">
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto relative">
{loading ? (
<FileListSkeleton rows={6} />
) : files.length === 0 ? (
) : files.length === 0 && !parentEntry ? (
<EmptyState
icon={FolderOpen}
title={t('empty.dirTitle')}
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
action={
path !== '/'
canGoUp
? { label: t('empty.goUp'), onClick: onNavigateUp }
: undefined
}
/>
) : visibleFiles.length === 0 ? (
) : visibleFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={EyeOff}
title={t('empty.hiddenOnlyTitle')}
@@ -351,7 +613,7 @@ export function FileListPanel({
onClick: () => setShowHidden(true),
}}
/>
) : filteredFiles.length === 0 ? (
) : filteredFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={Search}
title={t('empty.noMatchTitle')}
@@ -359,54 +621,65 @@ export function FileListPanel({
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
/>
) : (
filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => onSelect(file.name)}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onDragStart={(event) => {
writeSftpDragEntry(event.dataTransfer, {
source: isLocal ? 'local' : 'remote',
name: file.name,
path: file.path,
type: file.type,
size: file.size,
})
}}
onDragEnd={resetDragState}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
event.stopPropagation()
setDropTargetPath((current) => current === file.path ? null : current)
} : undefined}
onDragOver={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
isDropTarget={dropTargetPath === file.path}
/>
))
<>
{parentEntry && (
<FileRow
key="__parent__"
file={parentEntry}
isSelected={false}
onSelect={() => {}}
onOpen={() => onNavigate(parentEntry.path)}
onContextMenu={(e) => {
e.preventDefault()
}}
/>
)}
{filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => {
if (suppressNextClickRef.current) return
onSelect(file.name)
}}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onPointerDown={(event) => startPointerDrag(file, event)}
onPointerMove={movePointerDrag}
onPointerUp={finishPointerDrag}
onPointerCancel={cancelPointerDrag}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
setDragSource(null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
setDropTargetPath((current) => current === file.path ? null : current)
} : 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 && (
<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">
<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">
{isLocal || dragSource === 'remote'
? t('transfer.dropDownload', { path: dropTargetPath || path })
@@ -415,20 +688,31 @@ export function FileListPanel({
</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>
{/* 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">
<span>{statusText}</span>
<span className="truncate">{statusText}</span>
{isFiltering && (
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
)}
</div>
{/* Context Menu */}
{contextMenu && (
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
)}
</div>
)
}
})
+24 -7
View File
@@ -13,6 +13,10 @@ interface FileRowProps {
onSelect: () => void
onOpen: () => 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
onDragEnd?: (e: React.DragEvent) => void
onDragEnter?: (e: React.DragEvent) => void
@@ -28,6 +32,10 @@ export function FileRow({
onSelect,
onOpen,
onContextMenu,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onDragStart,
onDragEnd,
onDragEnter,
@@ -42,22 +50,29 @@ export function FileRow({
onClick={onSelect}
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
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}
onDragEnd={onDragEnd}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
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
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
: isSelected
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
: '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' ? (
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
) : file.type === 'symlink' ? (
@@ -66,13 +81,15 @@ export function FileRow({
<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>
{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 className="w-32 text-right text-xs text-txt-muted">{formatDate(file.modified)}</div>
<div className="w-24 text-right text-xs text-txt-muted">
<div className="sftp-col-modified w-28 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
{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)}
</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'
? t('fileType.folder')
: 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 { 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 { translateErrorMessage } from '../../i18n/format'
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'))
/** Above this, turn off expensive Monaco features (minimap, folding, etc.). */
const LIGHT_EDITOR_CHARS = 80_000
export interface RemoteTextEditorProps {
isOpen: boolean
sftpId: string
@@ -28,6 +38,8 @@ export default function RemoteTextEditor({
onSaved,
}: RemoteTextEditorProps) {
const { t } = useTranslation(['sftp', 'common'])
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { effective } = useTheme()
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(false)
@@ -36,6 +48,36 @@ export default function RemoteTextEditor({
const dirty = content !== original
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 () => {
if (!isOpen || !sftpId || !remotePath) return
@@ -49,33 +91,17 @@ export default function RemoteTextEditor({
setContent(result.content)
setOriginal(result.content)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setLoading(false)
}
}, [isOpen, sftpId, remotePath])
}, [isOpen, sftpId, remotePath, t])
useEffect(() => {
void load()
}, [load])
// Cmd/Ctrl+S
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 () => {
const handleSave = useCallback(async () => {
setSaving(true)
setError(null)
try {
@@ -90,93 +116,169 @@ export default function RemoteTextEditor({
setOriginal(content)
onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
}, [content, onSaved, remotePath, sftpId, t])
const handleClose = () => {
if (dirty && !confirm(t('editor.unsavedClose'))) return
const handleClose = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.unsavedTitle'),
message: t('editor.unsavedClose'),
danger: true,
confirmText: t('editor.discardAndClose'),
})
if (!ok) return
}
onClose()
}
}, [dirty, confirm, onClose, t])
const handleReload = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.reloadTitle'),
message: t('editor.unsavedReload'),
danger: true,
confirmText: t('editor.discardAndReload'),
})
if (!ok) return
}
void load()
}, [dirty, confirm, load, t])
// Cmd/Ctrl+S save · Escape close
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
e.stopPropagation()
if (dirty && !saving && !loading) void handleSave()
return
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
void handleClose()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [isOpen, dirty, saving, loading, handleSave, handleClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<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">
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-content-primary">
{fileName}
{dirty && <span className="ml-1 text-yellow-400">•</span>}
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
</div>
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
<div
className="absolute inset-0 z-30 flex flex-col bg-dark-900"
role="dialog"
aria-modal="true"
aria-label={t('editor.title', { name: fileName })}
>
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<FileCode className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="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 className="flex items-center gap-2">
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void handleReload()}
disabled={loading || saving}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-40"
title={t('editor.reload')}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('editor.reload')}</span>
</button>
<button
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>
{error && (
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
) : error && !content ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
{t('editor.loadBlocked')}
</div>
) : (
<Suspense
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>
<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>
)
}
+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 {
FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck,
Edit3, FileText, ShieldCheck,
Unplug, FileCode,
} from 'lucide-react'
import { useSFTP } from '../../hooks'
import { useToast } from '../../hooks/useToast'
import { getConnectionStore } from '../../stores/connection'
import { FileListPanel } from './FileListPanel'
import { TransferPanel } from './TransferPanel'
import { Toolbar } from './Toolbar'
import { ConnectionPanel } from './ConnectionPanel'
import { TransferRail } from './TransferRail'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { ToastContainer } from '../common/ToastContainer'
import EmptyState from '../common/EmptyState'
import { sortFiles } from './utils'
import { usePromptDialog } from '../common/PromptDialog'
import { useConfirmDialog } from '../common/ConfirmDialog'
import FilePropertiesDialog from './FilePropertiesDialog'
import ChmodDialog from './ChmodDialog'
import RemoteTextEditor from './RemoteTextEditor'
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 { dndLog, shortPath } from './dragDrop'
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
import {
conflictNamesFromListing,
formatOverwriteMessage,
transferDestinationPath,
} from './transferConflicts'
interface SFTPViewProps {
tabId: string
@@ -35,8 +44,8 @@ interface SFTPViewProps {
sessionId?: string
}
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation('sftp')
export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation(['sftp', 'errors'])
const {
sessions,
localPath,
@@ -59,23 +68,32 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemoteBack,
navigateRemoteForward,
loadRemoteDirectory,
remoteHomePath,
isConnected,
isConnecting,
connect,
transferQueue,
uploadFiles,
downloadFiles,
cancelTransfer,
removeTransfer,
retryTransfer,
deleteRemote,
renameRemote,
mkdirRemote,
getRemoteStat,
chmodRemote,
error,
clearError,
sftpId,
connectionId,
localLoading,
remoteLoading,
} = useSFTP({ tabId })
const { toasts, toast, removeToast } = useToast()
const { toast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
// UI State
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 [showConnectionSelector, setShowConnectionSelector] = useState(false)
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
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
@@ -93,6 +115,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
// Remote text editor
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
const autoConnectAttempted = useRef(false)
@@ -109,6 +147,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true, 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
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
@@ -155,14 +199,107 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}, [t, toast])
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, uploadFiles])
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
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)
}, [downloadFiles, reportTransfer])
}, [
confirm,
downloadFiles,
localFiles,
localPath,
remoteFiles,
remoteSelected,
reportTransfer,
t,
toast,
])
// ===== Context menu handlers =====
@@ -184,21 +321,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [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
const handleShowProperties = useCallback((file: FileItem) => {
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
const handleChmod = useCallback(async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
@@ -217,7 +361,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
toast.error(t('toast.chmodFailed'))
return false
}
}, [chmodRemote, toast])
}, [chmodRemote, toast, t])
// Local context menu
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 }]),
} 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" />,
label: t('contextMenu.properties'),
separator: true,
action: () => handleShowProperties(file),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateLocal, handleCopyPath, handleShowProperties, performUpload])
}, [navigateLocal, handleShowProperties, performUpload, t])
// Remote context menu
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'),
action: () => void performDownload([file]),
},
...(file.type === 'file'
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
? [
{
icon: <FileCode className="w-4 h-4" />,
label: t('contextMenu.edit'),
action: () => setEditFile(file),
action: () => {
if (!isWithinRemoteTextEditLimit(file.size)) {
toast.warning(t('errors:sftp.editTooLarge'))
return
}
setEditFile(file)
},
} as MenuItem,
]
: []),
{ 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" />,
label: t('contextMenu.properties'),
@@ -280,10 +429,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
label: t('contextMenu.permissions'),
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 })
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -294,6 +443,48 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemote(parentPath(remotePath, false))
}, [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
const handleLocalOpen = useCallback((file: FileItem) => {
if (file.type === 'directory') {
@@ -333,6 +524,14 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [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
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
if (!isConnected) return
@@ -340,6 +539,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
const path = (file as File & { path?: string }).path
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) {
toast.warning(t('toast.noDragPath'))
return
@@ -347,83 +551,128 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
void performUpload(sources, targetPath)
}, [isConnected, performUpload, toast, t])
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
if (!isConnected) return
if (entry.source === 'local') {
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
if (!isConnected || entries.length === 0) return
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(
[{ 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
)
} else {
void performDownload([{
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
}], targetPath)
}
}, [isConnected, performDownload, performUpload])
// Handle new folder
const handleNewFolder = useCallback(async () => {
const handleDropFailed = useCallback(() => {
dndLog('toast-drop-failed')
toast.warning(t('toast.dropFailed'))
}, [t, toast])
const handleNewLocalFolder = useCallback(async () => {
const name = await prompt({
title: t('dialog.newFolderTitle'),
message: t('dialog.newFolderMessage'),
placeholder: t('dialog.newFolderPlaceholder'),
})
if (name && window.opsxApi?.localMkdir) {
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
loadLocalDirectory(localPath)
})
if (!name || !window.opsxApi?.localMkdir) return
const fullPath = joinLocalPath(localPath, name.trim())
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 (
<div className="h-full flex flex-col bg-dark-900">
<div className="relative flex h-full flex-col bg-dark-900">
{/* Connection Status */}
<ConnectionPanel
isConnected={isConnected}
isConnecting={isConnecting}
error={error}
resumePath={!isConnected && hadConnectedRef.current ? remotePath : null}
sessions={sessions}
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)}
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 */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Unplug}
title={t('panel.notConnectedTitle')}
description={t('panel.notConnectedDesc')}
description={
hadConnectedRef.current && remotePath
? t('panel.reconnectResumeDesc', { path: remotePath })
: t('panel.notConnectedDesc')
}
/>
</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 */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
files={sortFiles(localFiles, sortField, sortOrder)}
homePath={localHistory[0] || localPath}
files={sortedLocalFiles}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
@@ -437,9 +686,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
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 */}
@@ -447,25 +707,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
title={t('panel.remote')}
isLocal={false}
path={remotePath}
files={sortFiles(remoteFiles, sortField, sortOrder)}
homePath={remoteHomePath}
files={sortedRemoteFiles}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemote}
onNavigateBack={navigateRemoteBack}
onNavigateForward={navigateRemoteForward}
onNavigateUp={navigateRemoteUp}
onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropFiles={isConnected ? handleDropFiles : undefined}
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>
)}
@@ -475,7 +740,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
tasks={transferQueue}
isOpen={isTransferPanelOpen}
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
onCancel={cancelTransfer}
onRemove={removeTransfer}
onRetry={retryTransfer}
/>
{/* Global Context Menu */}
@@ -488,6 +755,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
/>
)}
{PromptDialogComponent}
{ConfirmDialogComponent}
{/* File Properties Dialog */}
{propertiesFile && (
@@ -527,9 +795,6 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onSaved={() => toast.success(t('editor.saved'))}
/>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
}
+4 -4
View File
@@ -13,10 +13,10 @@ interface SortHeaderProps {
}
const FIELDS: { key: SortField; className: string }[] = [
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'w-32 justify-end' },
{ key: 'size', className: 'w-24 justify-end' },
{ key: 'type', className: 'w-24 justify-end' },
{ key: 'name', className: 'sftp-col-name flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'sftp-col-modified w-28 flex-shrink-0 justify-end' },
{ key: 'size', className: 'sftp-col-size w-16 flex-shrink-0 justify-end' },
{ key: 'type', className: 'sftp-col-type w-16 flex-shrink-0 justify-end' },
]
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