diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8de77cc..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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 *)" - ] - } -} diff --git a/.gitignore b/.gitignore index 414c8c6..4c57e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,6 @@ src-tauri/target/ *.sqlite *.sqlite3 -# ZCode workspace +# Local agent / IDE workspace .zcode/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 6615589..87e1ac8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。 diff --git a/CLAUDE.md b/CLAUDE.md index 623b2a1..7c85435 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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。 diff --git a/README.md b/README.md index 00aa498..5f6ea5c 100644 --- a/README.md +++ b/README.md @@ -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) ## 许可证 diff --git a/ROADMAP.md b/ROADMAP.md index d85d5df..5bfef45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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` 堆回桌面进程。 diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..61b6710 --- /dev/null +++ b/TASKS.md @@ -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。 diff --git a/TDD_PLAN.md b/TDD_PLAN.md new file mode 100644 index 0000000..1bb8659 --- /dev/null +++ b/TDD_PLAN.md @@ -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 / 未签名。 diff --git a/docs/README.md b/docs/README.md index 6853067..54eb3ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) 维护。 diff --git a/docs/architecture.md b/docs/architecture.md index 2bf6782..f77a401 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 访问外网。 diff --git a/design/opsxshell-app-icon.svg b/docs/assets/opsxshell-app-icon.svg similarity index 100% rename from design/opsxshell-app-icon.svg rename to docs/assets/opsxshell-app-icon.svg diff --git a/docs/release.md b/docs/release.md index b82de3a..9b51992 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..13fffb8 --- /dev/null +++ b/docs/roadmap.md @@ -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) 为准。不要在本文件再维护一份进度,避免漂移。 diff --git a/docs/testing-acceptance.md b/docs/testing-acceptance.md index 19946ee..891846c 100644 --- a/docs/testing-acceptance.md +++ b/docs/testing-acceptance.md @@ -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 解码、高输出背压和异步输入错误 diff --git a/docs/user-guide.md b/docs/user-guide.md index c842206..d811abb 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 对齐。验收与单元测试默认中文文案。 ## 本地数据 diff --git a/docs/ux-foundation-audit.md b/docs/ux-foundation-audit.md deleted file mode 100644 index 63d2ec8..0000000 --- a/docs/ux-foundation-audit.md +++ /dev/null @@ -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 或隧道问题都按未通过处理。 diff --git a/license-server/README.md b/license-server/README.md index a1f4801..e23404b 100644 --- a/license-server/README.md +++ b/license-server/README.md @@ -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 **轮换** diff --git a/package.json b/package.json index 8400615..152b819 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/run.sh b/run.sh index 429b9b4..2c3def2 100755 --- a/run.sh +++ b/run.sh @@ -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" "$@" diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs index 9340f33..69ff480 100644 --- a/scripts/acceptance.mjs +++ b/scripts/acceptance.mjs @@ -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]], ] diff --git a/scripts/build-script.test.mjs b/scripts/build-script.test.mjs index 1badbb3..9f9eba9 100644 --- a/scripts/build-script.test.mjs +++ b/scripts/build-script.test.mjs @@ -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/) diff --git a/build.bat b/scripts/build.bat similarity index 95% rename from build.bat rename to scripts/build.bat index 7f65bfb..128976b 100644 --- a/build.bat +++ b/scripts/build.bat @@ -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、 diff --git a/build.sh b/scripts/build.sh similarity index 95% rename from build.sh rename to scripts/build.sh index 66d66f0..b83a214 100755 --- a/build.sh +++ b/scripts/build.sh @@ -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 diff --git a/release.py b/scripts/release.py similarity index 99% rename from release.py rename to scripts/release.py index 16454cb..a6bb929 100755 --- a/release.py +++ b/scripts/release.py @@ -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() diff --git a/scripts/run.bat b/scripts/run.bat new file mode 100644 index 0000000..63f59b3 --- /dev/null +++ b/scripts/run.bat @@ -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 diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..3a44b1c --- /dev/null +++ b/scripts/run.sh @@ -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 diff --git a/shared/types/connection.types.ts b/shared/types/connection.types.ts index 7a95c8f..22d4889 100644 --- a/shared/types/connection.types.ts +++ b/shared/types/connection.types.ts @@ -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 } // 连接结果 diff --git a/shared/types/sftp.types.ts b/shared/types/sftp.types.ts index 8baa86e..e63cc3d 100644 --- a/shared/types/sftp.types.ts +++ b/shared/types/sftp.types.ts @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b30c4ed..217e7cd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f21ebfd..eb8663a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 77c750e..5cd427e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 319decc..182656e 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/desktop.rs b/src-tauri/src/commands/desktop.rs index 0ac592e..fdf580a 100644 --- a/src-tauri/src/commands/desktop.rs +++ b/src-tauri/src/commands/desktop.rs @@ -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) diff --git a/src-tauri/src/commands/known_hosts.rs b/src-tauri/src/commands/known_hosts.rs index eb18cc4..1d2e9bf 100644 --- a/src-tauri/src/commands/known_hosts.rs +++ b/src-tauri/src/commands/known_hosts.rs @@ -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)) diff --git a/src-tauri/src/commands/license.rs b/src-tauri/src/commands/license.rs index 35d3f18..482c1ed 100644 --- a/src-tauri/src/commands/license.rs +++ b/src-tauri/src/commands/license.rs @@ -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 { @@ -8,8 +8,12 @@ pub fn license_get(app: AppHandle) -> Result { } #[tauri::command] -pub fn license_activate(app: AppHandle, token: String) -> Result { - license::activate(&app, &token) +pub fn license_activate( + app: AppHandle, + token: String, + email: Option, +) -> Result { + license::activate(&app, &token, email.as_deref()) } #[tauri::command] @@ -21,3 +25,18 @@ pub fn license_clear(app: AppHandle) -> Result { pub fn license_device_id(app: AppHandle) -> Result { license::get_or_create_device_id(&app) } + +#[tauri::command] +pub fn license_set_email(app: AppHandle, email: String) -> Result { + license::set_email(&app, &email) +} + +#[tauri::command] +pub fn license_checkout( + app: AppHandle, + customer_email: String, + plan_type: String, + return_url: Option, +) -> Result { + license::checkout(&app, &customer_email, &plan_type, return_url.as_deref()) +} diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index cc0d607..11a6c2e 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -29,8 +29,13 @@ pub fn ssh_disconnect( app: AppHandle, state: State<'_, AppState>, connection_id: String, + retain_forwards: Option, ) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + local_bind_host: Option, 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, + remote_bind_host: Option, 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, diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 2418478..d78477d 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -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 { 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 { + window.is_fullscreen().map_err(|error| error.to_string()) +} + #[tauri::command] pub fn clipboard_read_text() -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?; @@ -84,3 +100,8 @@ pub fn toggle_devtools(app: AppHandle) -> Result { Ok(true) } } + +#[tauri::command] +pub fn apply_native_menu_locale(app: AppHandle, locale: String) -> Result { + crate::native_menu::apply_locale(&app, &locale) +} diff --git a/src-tauri/src/infrastructure/file_log.rs b/src-tauri/src/infrastructure/file_log.rs new file mode 100644 index 0000000..cc8120b --- /dev/null +++ b/src-tauri/src/infrastructure/file_log.rs @@ -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 = 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>, +} + +struct RollingState { + path: PathBuf, + file: Option, + len: u64, + max_bytes: u64, + backups: usize, +} + +impl SizeRollingWriter { + fn open(path: &Path, max_bytes: u64, backups: usize) -> io::Result { + 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 { + 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 { + 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); + } +} diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 5cfeb4e..16c50f6 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1eb86cc..ef4dddf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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" => { diff --git a/src-tauri/src/native_menu.rs b/src-tauri/src/native_menu.rs new file mode 100644 index 0000000..2da5cec --- /dev/null +++ b/src-tauri/src/native_menu.rs @@ -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::() { + 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(app: &AppHandle, locale: &str) -> tauri::Result> { + 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 { + 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"); + } +} diff --git a/src-tauri/src/services/ai/mod.rs b/src-tauri/src/services/ai/mod.rs index 339e4cb..6dfeadc 100644 --- a/src-tauri/src/services/ai/mod.rs +++ b/src-tauri/src/services/ai/mod.rs @@ -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(()) diff --git a/src-tauri/src/services/desktop/common.rs b/src-tauri/src/services/desktop/common.rs index a18496c..85db712 100644 --- a/src-tauri/src/services/desktop/common.rs +++ b/src-tauri/src/services/desktop/common.rs @@ -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 }, + /// 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) -> Vec = None; + let mut pending_resize: Option = 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) -> Vec 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 diff --git a/src-tauri/src/services/desktop/rdp_engine.rs b/src-tauri/src/services/desktop/rdp_engine.rs index 40a5419..04a3f40 100644 --- a/src-tauri/src/services/desktop/rdp_engine.rs +++ b/src-tauri/src/services/desktop/rdp_engine.rs @@ -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>, 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, + 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, ) -> 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 { } 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, outputs: Vec, frame_emitter: &mut FrameEmitter, ) -> anyhow::Result { 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) -> Option { + 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, +) -> 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 { - let w = usize::from(rect.width()); - let h = usize::from(rect.height()); +fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Option> { + 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); diff --git a/src-tauri/src/services/desktop/vnc_engine.rs b/src-tauri/src/services/desktop/vnc_engine.rs index cff1640..65c1952 100644 --- a/src-tauri/src/services/desktop/vnc_engine.rs +++ b/src-tauri/src/services/desktop/vnc_engine.rs @@ -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(()) diff --git a/src-tauri/src/services/license.rs b/src-tauri/src/services/license.rs index 0095cee..7aa9dc6 100644 --- a/src-tauri/src/services/license.rs +++ b/src-tauri/src/services/license.rs @@ -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; +/// 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, +struct StoredLicense { #[serde(default)] - pub features: Vec, + license_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(default = "default_plan")] + plan: String, + #[serde(default)] + activated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + expires_at: Option, + #[serde(default)] + features: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_devices: Option, + /// yearly | perpetual | trial | … + #[serde(default, skip_serializing_if = "Option::is_none")] + plan_type: Option, + #[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, pub features: Vec, + /// License key string (compat field name for existing frontend). #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_devices: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_type: Option, } -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 { @@ -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 { let path = device_id_path(app)?; if path.exists() { @@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result { Ok(id) } -fn sign_payload(payload_b64: &str, secret: &str) -> Result { - 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) -> 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 { - 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 { - 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, 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, + #[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 { + 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::builder() + .timeout(HTTP_TIMEOUT) + .user_agent("OpsXShell/2") + .build() + .map_err(|error| format!("创建 HTTP 客户端失败: {error}")) +} + +fn envelope_message(value: &Value) -> Option { + 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 { + 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 { + 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 { + 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 { + 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) -> Option { + 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 { + 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, + features: Vec, + max_devices: Option, + plan_type: Option, +} + +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 { + 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 { + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +/// 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 { + 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 { 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 { +pub fn activate( + app: &AppHandle, + license_key: &str, + email: Option<&str>, +) -> Result { + 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 { + 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 { + 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()); } } diff --git a/src-tauri/src/services/local_terminal.rs b/src-tauri/src/services/local_terminal.rs index 669baee..d90f0ea 100644 --- a/src-tauri/src/services/local_terminal.rs +++ b/src-tauri/src/services/local_terminal.rs @@ -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 { .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(¤t); + if enriched != current { + command.env("PATH", enriched); + } } } +fn path_separator() -> char { + if cfg!(windows) { + ';' + } else { + ':' + } +} + +/// Prepend missing user/system bin dirs onto a GUI-minimal PATH. +/// Existing entries keep their order; duplicates are not added. +fn merge_path(current: &str, extras: &[String]) -> String { + let sep = path_separator(); + let existing: Vec<&str> = current.split(sep).filter(|part| !part.is_empty()).collect(); + let mut seen: HashSet<&str> = existing.iter().copied().collect(); + let mut prefix = Vec::new(); + for dir in extras { + if dir.is_empty() || !seen.insert(dir.as_str()) { + continue; + } + prefix.push(dir.as_str()); + } + prefix + .into_iter() + .chain(existing) + .collect::>() + .join(&sep.to_string()) +} + +#[cfg(not(windows))] +fn extra_path_dirs() -> Vec { + 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 { + 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 { + 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 = 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}" + ); + } } diff --git a/src-tauri/src/services/ssh.rs b/src-tauri/src/services/ssh.rs index e8b2d34..0d85030 100644 --- a/src-tauri/src/services/ssh.rs +++ b/src-tauri/src/services/ssh.rs @@ -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>>, } +/// 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>, shells: Mutex>, - sftps: Mutex>, + /// Interactive browsing channel for each SFTP tab. + sftps: Mutex>>>, + /// Lazily-created transfer channel, isolated from browsing/file operations. + sftp_transfers: Mutex>>>, /// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止). active_execs: Mutex>>, + /// Per-SFTP-session cancel for the active upload/download copy loop. + sftp_transfer_cancels: Mutex>>, 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::>() + }; + // 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::>() + }; + 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::>() + }; + 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, + 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, + 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 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"); diff --git a/src-tauri/src/services/ssh/known_hosts.rs b/src-tauri/src/services/ssh/known_hosts.rs index 738b2bb..2e74cf4 100644 --- a/src-tauri/src/services/ssh/known_hosts.rs +++ b/src-tauri/src/services/ssh/known_hosts.rs @@ -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)) diff --git a/src-tauri/src/services/ssh/sftp.rs b/src-tauri/src/services/ssh/sftp.rs index 2ad8811..11da5ce 100644 --- a/src-tauri/src/services/ssh/sftp.rs +++ b/src-tauri/src/services/ssh/sftp.rs @@ -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 { 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, 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, + ) -> 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, 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::>(); - 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::>(); + 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 { - 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 { - let connection_id = self + /// Blocking access to the browsing channel for native integration tests. + #[cfg(test)] + pub(super) fn with_sftp( + &self, + sftp_id: &str, + f: impl FnOnce(&ssh2::Sftp) -> Result, + ) -> Result { + 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( + &self, + sftp_id: &str, + f: impl FnOnce(&ssh2::Sftp) -> Result, + ) -> Result { + 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( + &self, + sftp_id: &str, + f: impl FnOnce(&ssh2::Sftp) -> Result, + ) -> Result { + 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>>, 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)); + } } diff --git a/src-tauri/src/services/tunnel.rs b/src-tauri/src/services/tunnel.rs index 9484721..845c78a 100644 --- a/src-tauri/src/services/tunnel.rs +++ b/src-tauri/src/services/tunnel.rs @@ -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, connection_id: &str, config: SshConnectRequest, + local_bind_host: String, local_port: u16, remote_host: String, remote_port: u16, ) -> Result { - 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, connection_id: &str, config: SshConnectRequest, + remote_bind_host: String, remote_port: u16, local_host: String, local_port: u16, ) -> Result { - 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::>(); 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, + ) -> Result, 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, + running: Arc, + counters: Arc, +) { + 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, + cancel: Arc, + counters: Arc, +) { + 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, + running: Arc, + counters: Arc, +) { + 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, + counters: &Arc, +) -> 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 { + 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 { + 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 { + 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(_: std::sync::PoisonError) -> 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 = 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(); + } } diff --git a/src-tauri/src/services/tunnel/model.rs b/src-tauri/src/services/tunnel/model.rs index fc01e4f..cbe1ba4 100644 --- a/src-tauri/src/services/tunnel/model.rs +++ b/src-tauri/src/services/tunnel/model.rs @@ -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, pub(super) running: Arc, pub(super) counters: Arc, diff --git a/src-tauri/src/services/tunnel/session.rs b/src-tauri/src/services/tunnel/session.rs new file mode 100644 index 0000000..3f669fa --- /dev/null +++ b/src-tauri/src/services/tunnel/session.rs @@ -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>, +} + +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 { + 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 { + 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}")), + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2fc7628..6b1a2bd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/ai/providerPresets.ts b/src/ai/providerPresets.ts index a8a63cd..3912185 100644 --- a/src/ai/providerPresets.ts +++ b/src/ai/providerPresets.ts @@ -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 = { - 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', diff --git a/src/app/App.tsx b/src/app/App.tsx index 8010420..9288906 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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') + }} /> diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 1de3593..6610bf8 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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 (
{ + 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" >
@@ -114,13 +122,18 @@ export default function AppShell({ } const desktopSidebarHidden = !isMobile && sidebarCollapsed + const desktopFullscreen = useDesktopFullscreenStore((state) => state.active) return ( -
- +
+ {!desktopFullscreen && }
- {isMobile && mobileSidebarOpen && ( + {!desktopFullscreen && isMobile && mobileSidebarOpen && (
)} - + {!desktopFullscreen && ( + + )} -
-
- {isMobile && } - {desktopSidebarHidden && } -
- +
+ {!desktopFullscreen && ( +
+ {isMobile && } + {desktopSidebarHidden && } +
+ +
-
+ )}
-
- -
+ {!desktopFullscreen && ( +
+ +
+ )}
{children}
diff --git a/src/components/ai/AgentMarkdown.tsx b/src/components/ai/AgentMarkdown.tsx index 9babf8b..435429c 100644 --- a/src/components/ai/AgentMarkdown.tsx +++ b/src/components/ai/AgentMarkdown.tsx @@ -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 diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index 2ebbe4f..ba35ab8 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -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(() => { diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx index 05ff7e4..46450b7 100644 --- a/src/components/common/Modal.tsx +++ b/src/components/common/Modal.tsx @@ -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(null) + const restoreFocusRef = useRef(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( + '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('[data-autofocus]') ?? + root.querySelector('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({ />
{resolvedConfirm} diff --git a/src/components/common/PromptDialog.tsx b/src/components/common/PromptDialog.tsx index 655a44a..eb53a8f 100644 --- a/src/components/common/PromptDialog.tsx +++ b/src/components/common/PromptDialog.tsx @@ -119,6 +119,7 @@ export function usePromptDialog() {

{state.message}

= { info: Info, } -/** 类型 → 颜色样式映射 */ +/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */ const colorMap: Record = { 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', }, } diff --git a/src/components/common/ToastContainer.tsx b/src/components/common/ToastContainer.tsx index 0a69f4a..1bb4e45 100644 --- a/src/components/common/ToastContainer.tsx +++ b/src/components/common/ToastContainer.tsx @@ -11,17 +11,17 @@ interface ToastContainerProps { } const iconMap: Record = { - info: , - success: , - error: , - warning: , + info: , + success: , + error: , + warning: , } const bgMap: Record = { - 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) { diff --git a/src/components/connection/ConnectionPanel.tsx b/src/components/connection/ConnectionPanel.tsx index 2c0f8e2..d64c122 100644 --- a/src/components/connection/ConnectionPanel.tsx +++ b/src/components/connection/ConnectionPanel.tsx @@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con @@ -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 ( diff --git a/src/components/connection/ForwardRuleDrawer.tsx b/src/components/connection/ForwardRuleDrawer.tsx index 31a54f1..1330daf 100644 --- a/src/components/connection/ForwardRuleDrawer.tsx +++ b/src/components/connection/ForwardRuleDrawer.tsx @@ -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 busy?: boolean + /** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */ + viaSession?: string onChange: (next: Partial) => 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): string { +function ruleSummary( + rule: ForwardRule, + t: (key: string, opts?: Record) => 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({ ) : (
{localRemoteRules.map((rule) => { - const running = Boolean(runtimes?.get(rule.id)?.running) + const runtime = runtimes?.get(rule.id) + const running = Boolean(runtime?.running) return (

- {ruleSummary(rule, t)} + {ruleSummary(rule, t, runtime?.boundPort)}

@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({ />
-
-
- - onChange({ ...draft, localPort: Number(e.target.value) })} - placeholder={t('portForward.portPlaceholder')} - className="ui-input font-mono" - inputMode="numeric" - /> -
-
- - onChange({ ...draft, remotePort: Number(e.target.value) })} - placeholder={t('portForward.portPlaceholder')} - className="ui-input font-mono" - inputMode="numeric" - /> -
-
- - {type === 'local' && ( -
- - onChange({ ...draft, remoteHost: e.target.value })} - placeholder={t('portForward.remoteHostPlaceholder')} - className="ui-input font-mono" - spellCheck={false} - /> -
+ {type === 'local' ? ( + <> + {viaSession ? ( +

+ {t('portForward.viaSession', { target: viaSession })} +

+ ) : null} +
+ + + onChange({ + ...draft, + localPort: Number(e.target.value), + localBindHost: resolveLocalBindHost(draft.localBindHost), + }) + } + placeholder="8080" + className="ui-input font-mono" + inputMode="numeric" + /> +

+ {t('portForward.localListenPortHint')} +

+
+
+ + { + 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} + /> +

+ {viaSession + ? t('portForward.targetAddressHintVia', { target: viaSession }) + : t('portForward.targetAddressHint')} +

+
+
+ + {t('portForward.localBindAdvanced')} + +
+ + + onChange({ + ...draft, + localBindHost: resolveLocalBindHost(e.target.value), + }) + } + placeholder={t('portForward.localBindHostPlaceholder')} + className="ui-input font-mono" + spellCheck={false} + /> +

+ {t('portForward.localBindHostHint')} +

+
+
+ + ) : ( + <> + {viaSession ? ( +

+ {t('portForward.viaSessionRemote', { target: viaSession })} +

+ ) : null} +
+ + + onChange({ + ...draft, + remotePort: Number(e.target.value), + remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost), + }) + } + placeholder="18080" + className="ui-input font-mono" + inputMode="numeric" + /> +

+ {t('portForward.remoteListenPortHint')} +

+
+
+ + { + 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} + /> +

+ {t('portForward.localTargetAddressHint')} +

+
+
+ + {t('portForward.remoteBindAdvanced')} + +
+ + + onChange({ + ...draft, + remoteBindHost: resolveRemoteBindHost(e.target.value), + }) + } + placeholder={t('portForward.remoteBindHostPlaceholder')} + className="ui-input font-mono" + spellCheck={false} + /> +

+ {t('portForward.remoteBindHostHint')} +

+
+
+ )} )} diff --git a/src/components/connection/PortForwardView.tsx b/src/components/connection/PortForwardView.tsx index eff76c3..1515fed 100644 --- a/src/components/connection/PortForwardView.tsx +++ b/src/components/connection/PortForwardView.tsx @@ -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([]) const [tunnels, setTunnels] = useState([]) @@ -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(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 {t('common:portForward.socksProxy', { port: boundPort })} ) : rule.type === 'local' ? ( - 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, + }) + })()} ) : ( - {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, + }) + })()} )}
diff --git a/src/components/connection/forwardIntent.ts b/src/components/connection/forwardIntent.ts new file mode 100644 index 0000000..9d0c4bc --- /dev/null +++ b/src/components/connection/forwardIntent.ts @@ -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>() +const resumeLocks = new Map>() + +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 +): Promise { + 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() +} diff --git a/src/components/connection/forwardRules.ts b/src/components/connection/forwardRules.ts index 47d4ad6..27fc4e2 100644 --- a/src/components/connection/forwardRules.ts +++ b/src/components/connection/forwardRules.ts @@ -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) { diff --git a/src/components/desktop/DesktopConnectDialog.tsx b/src/components/desktop/DesktopConnectDialog.tsx index 77ad760..9af1626 100644 --- a/src/components/desktop/DesktopConnectDialog.tsx +++ b/src/components/desktop/DesktopConnectDialog.tsx @@ -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" />
-
- - setWidth(Number(event.target.value) || 1920)} - className="ui-input font-mono" - /> -
-
- - setHeight(Number(event.target.value) || 1080)} - className="ui-input font-mono" - /> -
+ { + setWidth(next.width) + setHeight(next.height) + }} + />

{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')} diff --git a/src/components/desktop/DesktopResolutionPicker.tsx b/src/components/desktop/DesktopResolutionPicker.tsx new file mode 100644 index 0000000..0e54723 --- /dev/null +++ b/src/components/desktop/DesktopResolutionPicker.tsx @@ -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 ( +

+
+ + +
+ {showCustom && ( +
+
+ + { + setForceCustom(true) + onChange(clampDesktopResolution(Number(event.target.value) || current.width, height)) + }} + className="ui-input font-mono" + /> +
+
+ + { + setForceCustom(true) + onChange(clampDesktopResolution(width, Number(event.target.value) || current.height)) + }} + className="ui-input font-mono" + /> +
+
+ )} +
+ ) +} diff --git a/src/components/desktop/DesktopSessionView.tsx b/src/components/desktop/DesktopSessionView.tsx index 0c70068..f589040 100644 --- a/src/components/desktop/DesktopSessionView.tsx +++ b/src/components/desktop/DesktopSessionView.tsx @@ -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 { export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) { const { t } = useTranslation('desktop') const canvasRef = useRef(null) + const paneRef = useRef(null) const canvasCtxRef = useRef(null) const nativeSessionIdRef = useRef(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(null) const paintRafRef = useRef(null) + const hideBarTimerRef = useRef(null) const pendingFramesRef = useRef([]) + const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null) + const remoteResizeTimerRef = useRef(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(() => t('status.connecting')) const [error, setError] = useState(null) const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 }) const [resolvedConfig, setResolvedConfig] = useState(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, 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 ( -
-
- - - {hostLabel} - - - {fbSize.width}×{fbSize.height} - - {tab?.connectionStatus === 'connecting' ? ( - - ) : ( - + const toolbar = ( +
{ + if (fullscreenActive) { + setChromeVisible(true) + if (hideBarTimerRef.current != null) { + window.clearTimeout(hideBarTimerRef.current) + hideBarTimerRef.current = null + } + } + }} + onMouseLeave={() => { + if (fullscreenActive) scheduleHideChrome() + }} + > + + + {hostLabel} + + + {fbSize.width}×{fbSize.height} + + {tab?.connectionStatus === 'connecting' ? ( + + ) : ( + + )} + {statusMessage} + {error && {error}} + {fullscreenActive && ( + {t('toolbar.exitFullscreenHint')} + )} + +
+ + + {protocol === 'rdp' && ( + <> + + { + void onShareFileSelected(event.target.files) + event.target.value = '' + }} + /> + + )} - {statusMessage} - {error && {error}} -
- - - {protocol === 'rdp' && ( - <> - - { - void onShareFileSelected(event.target.files) - event.target.value = '' - }} - /> - - - )} - - -
+
+
+ ) -
+ return ( +
{ + if (!fullscreenActive) return + if (event.clientY <= 56) revealChrome() + }} + > + {fullscreenActive ? ( +
+
{toolbar}
+
+ ) : ( + toolbar + )} + +
event.preventDefault()} onMouseMove={(event) => mapPointer(event)} onMouseDown={(event) => mapPointer(event, true)} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index eb8c8ce..e431fc1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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" > - + {t('sidebar.newSessionShort')}
@@ -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 }, diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 98330d9..02a80d0 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -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 & { labelKey connected: { icon: , labelKey: 'status.connected', - colorClass: 'text-green-400', + colorClass: 'text-feedback-success', animateClass: '', }, connecting: { icon: , labelKey: 'status.connecting', - colorClass: 'text-yellow-400', + colorClass: 'text-feedback-warning', animateClass: 'animate-pulse', }, background: { icon: , labelKey: 'status.background', - colorClass: 'text-green-400', + colorClass: 'text-feedback-success', animateClass: '', }, suspended: { icon: , labelKey: 'status.suspended', - colorClass: 'text-yellow-400', + colorClass: 'text-feedback-warning', animateClass: '', }, reconnecting: { icon: , labelKey: 'status.reconnecting', - colorClass: 'text-yellow-400', + colorClass: 'text-feedback-warning', animateClass: 'animate-pulse', }, error: { icon: , 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() 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(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 => { 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 ? : } - {t('common:statusBar.socksLabel')} + + {t('common:statusBar.socksLabel')} :{dynamicListenPort} + )} @@ -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, }) diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index 64f1b53..feb9e0e 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) { > {/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
-
+
{tabs.map((tab, index) => ( @@ -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 + } />
) diff --git a/src/components/layout/TabContent.tsx b/src/components/layout/TabContent.tsx index f336cb7..2c9cfdc 100644 --- a/src/components/layout/TabContent.tsx +++ b/src/components/layout/TabContent.tsx @@ -117,8 +117,12 @@ function TabContent({ return (
{content} diff --git a/src/components/layout/sidebar/LocalShellMenu.tsx b/src/components/layout/sidebar/LocalShellMenu.tsx index bf070b9..800d997 100644 --- a/src/components/layout/sidebar/LocalShellMenu.tsx +++ b/src/components/layout/sidebar/LocalShellMenu.tsx @@ -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' } diff --git a/src/components/layout/sidebar/SessionTree.tsx b/src/components/layout/sidebar/SessionTree.tsx index e407f1f..6247b26 100644 --- a/src/components/layout/sidebar/SessionTree.tsx +++ b/src/components/layout/sidebar/SessionTree.tsx @@ -108,6 +108,8 @@ export function SessionTree({
-
+
{groupSessions.length === 0 ? (
{t('tree.emptyTitle')}
) : ( @@ -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) diff --git a/src/components/layout/tabbar/TabContextMenu.tsx b/src/components/layout/tabbar/TabContextMenu.tsx index 9bcdd1f..0f17ac3 100644 --- a/src/components/layout/tabbar/TabContextMenu.tsx +++ b/src/components/layout/tabbar/TabContextMenu.tsx @@ -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(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 (
e.stopPropagation()} > {items.map((item, index) => (
-
-
-
{t('pro.freeTier')}
-
    +

    {t('pro.choosePlan')}

    + + {plan !== 'pro' && ( +
    + + 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 && ( +

    {checkoutError}

    + )} +
    + )} + +
    + {/* Free */} +
    + +
      {FREE_ITEM_KEYS.map((key) => ( -
    • - +
    • + {t(`pro.freeItems.${key}`)}
    • ))}
    +
    + {plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')} +
    -
    -
    {t('pro.proTier')}
    -
      + + {/* Yearly */} +
      + +

      {t('pro.planIncludesPro')}

      +
        {PRO_ITEM_KEYS.map((key) => ( -
      • - +
      • + {t(`pro.proItems.${key}`)}
      • ))}
      + {plan !== 'pro' && ( + + )} + {plan === 'pro' && planType === 'yearly' && ( +
      + {t('pro.activatedYearly')} +
      + )} +
      + + {/* Perpetual */} +
      + +

      {t('pro.planIncludesPro')}

      +
        + {PRO_ITEM_KEYS.map((key) => ( +
      • + + {t(`pro.proItems.${key}`)} +
      • + ))} +
      + {plan !== 'pro' && ( + + )} + {plan === 'pro' && (planType === 'perpetual' || planType === 'lifetime' || (!planType && !expiresAt)) && ( +
      + {t('pro.activatedPerpetual')} +
      + )}
    {plan !== 'pro' && ( <> -

    - {t('pro.oneTimeFee')} -

    - - - {checkoutError && ( -

    {checkoutError}

    - )} - @@ -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" />

    - {comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })} + {comingSoon ? t('pro.wallUnlockComingSoon') : t('pro.wallUnlock')}

    {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'))}

diff --git a/src/components/serial/SerialTerminalView.tsx b/src/components/serial/SerialTerminalView.tsx index f18de97..ae7ceac 100644 --- a/src/components/serial/SerialTerminalView.tsx +++ b/src/components/serial/SerialTerminalView.tsx @@ -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' && } {error && {error}}
-
+
) } diff --git a/src/components/sessions/BatchCommandDialog.tsx b/src/components/sessions/BatchCommandDialog.tsx index edfd60d..45c6b08 100644 --- a/src/components/sessions/BatchCommandDialog.tsx +++ b/src/components/sessions/BatchCommandDialog.tsx @@ -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() diff --git a/src/components/sessions/BatchExecView.tsx b/src/components/sessions/BatchExecView.tsx index bf044a0..cc1ad16 100644 --- a/src/components/sessions/BatchExecView.tsx +++ b/src/components/sessions/BatchExecView.tsx @@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx } } }, - [tabId, timeout, updateResult] + [tabId, timeout, updateResult, t] ) const execute = useCallback(async () => { diff --git a/src/components/sessions/QuickLauncher.tsx b/src/components/sessions/QuickLauncher.tsx index 24dffb4..ab9bc0f 100644 --- a/src/components/sessions/QuickLauncher.tsx +++ b/src/components/sessions/QuickLauncher.tsx @@ -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({ />
-
+
+ {onRdpConnect && ( + + )} + {onVncConnect && ( + + )}
@@ -131,7 +175,7 @@ export default function QuickLauncher({ }))} className="launcher-row" > - + {shell.name}{shell.path} {shell.isDefault && {t('common:launcher.systemDefault')}} @@ -161,7 +205,10 @@ export default function QuickLauncher({ {session.name} - {session.username}@{session.host}:{session.port || 22} + + {session.username}@{session.host}: + {session.port || defaultPortForProtocol(sessionProtocol(session))} + {session.group || 'default'} diff --git a/src/components/sessions/SessionDialog.tsx b/src/components/sessions/SessionDialog.tsx index e1ccb86..5292672 100644 --- a/src/components/sessions/SessionDialog.tsx +++ b/src/components/sessions/SessionDialog.tsx @@ -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 = ( +
+ + +

{t('groups.hint')}

+
+ ) + return ( <>
@@ -549,73 +601,30 @@ export default function SessionDialog({ />
)} - {formData.protocol !== 'ssh' && ( -
-
- - setFormData({ ...formData, width: Number(e.target.value) || 1920 })} - className="ui-input font-mono" - /> -
-
- - setFormData({ ...formData, height: Number(e.target.value) || 1080 })} - className="ui-input font-mono" - /> -
+ {formData.protocol === 'rdp' && ( +
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="••••••••" + className="ui-input" + autoComplete="current-password" + />
)} -
- - -

- {t('groups.hint')} -

-
+ {formData.protocol !== 'ssh' && ( + setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))} + /> + )} + {/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */} + {formData.protocol === 'rdp' && groupField}
{/* 凭据 */} @@ -647,7 +656,7 @@ export default function SessionDialog({
)} - {(formData.authType === 'password' || !isSsh) && ( + {(formData.authType === 'password' || formData.protocol === 'vnc') && (
)} + {(isSsh || formData.protocol === 'vnc') && groupField} {/* 高级 */} diff --git a/src/components/sessions/WelcomeView.tsx b/src/components/sessions/WelcomeView.tsx index d256bcb..d7e9d74 100644 --- a/src/components/sessions/WelcomeView.tsx +++ b/src/components/sessions/WelcomeView.tsx @@ -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({ {session.name} - {session.username}@{session.host}:{session.port || 22} + {session.username}@{session.host}: + {session.port || defaultPortForProtocol(sessionProtocol(session))} {session.group ? ( diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx new file mode 100644 index 0000000..310fe33 --- /dev/null +++ b/src/components/settings/AccountSettings.tsx @@ -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(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [devices, setDevices] = useState([]) + const [deviceUsed, setDeviceUsed] = useState(0) + const [deviceMax, setDeviceMax] = useState(0) + const [devicesLoading, setDevicesLoading] = useState(false) + const [removingId, setRemovingId] = useState(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 ( +
+
+

{t('account.title')}

+
+ + + {planLabel(t, plan, planType)} + {maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''} + + + + +
+ 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" + /> + +
+
+ + + + {deviceId || '—'} + + +
+
+ +
+

{t('account.activate')}

+
+