feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm config:*)",
|
||||
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install electron --save-dev)",
|
||||
"Bash(pkill -f \"electron\")",
|
||||
"Bash(pkill -f \"vite\")",
|
||||
"Bash(chmod +x:*)",
|
||||
"Bash(./start.sh:*)",
|
||||
"Bash(pkill -f \"electron.*opsxshell\")",
|
||||
"Bash(pkill -f \"vite.*5173\")",
|
||||
"Bash(npx @electron/rebuild -f -w better-sqlite3)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(lsof -i:5173)",
|
||||
"Bash(pkill -f \"esbuild\")",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(npm view:*)",
|
||||
"WebFetch(domain:127.0.0.1)",
|
||||
"Bash(npm rebuild:*)",
|
||||
"Bash(ls -la /Users/loganli/devops/desktop-ui/opsxshell/*.md)",
|
||||
"Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(pkill -f \"Electron\")",
|
||||
"Bash(./run.sh)",
|
||||
"Bash(pkill -f \"node.*5173\")",
|
||||
"Bash(curl -s http://localhost:5173)",
|
||||
"Bash(pkill -f electron)",
|
||||
"Bash(pkill -f vite)",
|
||||
"Bash(curl -s http://localhost:9223/json)",
|
||||
"Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(f'{i[\\\\\"title\\\\\"]}: {i[\\\\\"url\\\\\"]}'\\) for i in data]\")",
|
||||
"Bash(./run.sh restart:*)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx /Users/loganli/devops/desktop-ui/opsxshell/src/stores/*.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/main.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/services/*.ts)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -52,5 +52,6 @@ src-tauri/target/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# ZCode workspace
|
||||
# Local agent / IDE workspace
|
||||
.zcode/
|
||||
.claude/
|
||||
@@ -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 的运行态只在原生侧内存;评审时以「停止是否关掉真实资源」为准。
|
||||
@@ -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。
|
||||
@@ -1,13 +1,13 @@
|
||||
# OpsXShell
|
||||
|
||||
轻量原生的跨平台运维终端。产品目标是让常用远程操作更直接,不做审计平台,也不依赖插件才能完成基础工作。
|
||||
简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。
|
||||
|
||||
## 已支持
|
||||
|
||||
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
|
||||
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面中英双语(zh-CN / en)
|
||||
- 会话管理:分组、标签、快速连接、多标签、终端分屏;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
|
||||
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口(不含 RDP / VNC)
|
||||
- SFTP:双栏文件管理、断点续传、完整后原子替换、重命名、权限、远程文本编辑
|
||||
- SFTP:双栏、中间传输箭头、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限,超限/二进制友好提示)
|
||||
- 端口转发:本地、远程、动态 SOCKS5;状态栏「转发」打开抽屉启停;规则可编辑和复制,与运行实例分离;动态默认 `127.0.0.1:1080`,经当前 SSH 服务器出口 IP 访问外网
|
||||
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
|
||||
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
## 产品边界
|
||||
|
||||
OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内。
|
||||
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
|
||||
|
||||
当前仍需补齐的专业能力:
|
||||
仍在打磨、不影响「先用起来」的方向:
|
||||
|
||||
- Agent Forwarding、OpenSSH 证书与 FIDO2
|
||||
- 断网重连、休眠恢复和大量标签切换的持续压测
|
||||
@@ -42,7 +42,7 @@ OpsXShell 聚焦 SSH、SFTP、代理、转发、远程桌面、串口和多终
|
||||
| 前端 | React 18 + TypeScript 5 + Vite 5 |
|
||||
| 状态管理 | Zustand 4.4+ |
|
||||
| 终端 | XTerm.js 5.5+ |
|
||||
| 国际化 | i18next(zh-CN / en) |
|
||||
| 国际化 | i18next(zh-CN / zh-TW / en / ja / ko / vi / ru) |
|
||||
| 测试 | Vitest + Rust test |
|
||||
|
||||
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
|
||||
@@ -61,32 +61,27 @@ npm run dev
|
||||
```bash
|
||||
npm run acceptance:quick
|
||||
npm run acceptance:full
|
||||
./build.sh v2.1.1 mac-arm64
|
||||
build.bat v2.1.1 win-x64
|
||||
./release.py v2.1.1 --dry-run
|
||||
./scripts/build.sh v2.1.1 mac-arm64
|
||||
scripts\build.bat v2.1.1 win-x64
|
||||
./scripts/release.py v2.1.1 --dry-run
|
||||
```
|
||||
|
||||
`build.sh`(macOS/Git Bash)和 `build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
||||
`scripts/build.sh`(macOS/Git Bash)和 `scripts/build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`scripts/release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
src/ React 界面与状态管理
|
||||
src/i18n/ 界面文案(zh-CN / en)
|
||||
src/platform/ Tauri 命令和事件适配层
|
||||
src/platform/sftp.ts 本地/远程路径强类型传输边界
|
||||
src/platform/rdp.ts RDP 会话适配
|
||||
src/platform/vnc.ts VNC 会话适配
|
||||
src/utils/terminalPipeline.ts 终端输入、输出和背压管线
|
||||
shared/types/ 前后端共享 TypeScript 契约
|
||||
src-tauri/src/commands/ 精简的 Tauri 命令边界
|
||||
src-tauri/src/domain/ 原生领域模型
|
||||
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、桌面、AI、存储服务
|
||||
src-tauri/src/services/ssh/ 认证、known_hosts、代理/跳板和 SFTP 子模块
|
||||
src-tauri/src/services/tunnel/ 转发运行模型、SOCKS5 协议和双向 Relay
|
||||
src-tauri/src/services/desktop/ RDP(IronRDP)与 VNC(vnc-rs)
|
||||
test/ 前端测试与基础验收
|
||||
scripts/ 构建和验收脚本
|
||||
src/ React 界面与状态管理
|
||||
src/i18n/ 界面文案(多语言)
|
||||
src/platform/ Tauri 命令和事件适配层
|
||||
shared/types/ 前后端共享 TypeScript 契约
|
||||
src-tauri/ Rust / Tauri 原生后端
|
||||
test/ 前端单元、组件与巡检测试
|
||||
test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
|
||||
scripts/ 启动、验收、构建与发布脚本
|
||||
docs/ 使用 / 架构 / 验收 / 发布文档
|
||||
docs/assets/ 品牌与设计资源
|
||||
license-server/ 授权与结账服务
|
||||
```
|
||||
|
||||
## 快捷键
|
||||
@@ -105,9 +100,11 @@ scripts/ 构建和验收脚本
|
||||
- [使用指南](docs/user-guide.md)
|
||||
- [架构说明](docs/architecture.md)
|
||||
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
||||
- [产品体验与基础能力评估](docs/ux-foundation-audit.md)
|
||||
- [测试与验收](docs/testing-acceptance.md)
|
||||
- [发布指南](docs/release.md)
|
||||
- [路线图](ROADMAP.md)
|
||||
- [当前任务](TASKS.md)
|
||||
- [测试计划](TDD_PLAN.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+27
-31
@@ -1,38 +1,34 @@
|
||||
# OpsXShell Roadmap
|
||||
# OpsXShell 路线图
|
||||
|
||||
## 已完成基线
|
||||
> 最后更新:2026-08-16
|
||||
> 版本基线:v2.1.1(Tauri 2 原生)
|
||||
> 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。
|
||||
|
||||
- Rust `TunnelManager`:本地、远程和动态 SOCKS5 转发拥有独立 ID、真实启停、连接清理和运行状态
|
||||
- 转发规则按保存会话持久化,不持久化运行中状态
|
||||
- SOCKS5 一键启动、代理地址复制、活动连接数和上下行字节统计
|
||||
- 转发规则的创建、编辑、复制、删除与运行实例完全分离
|
||||
- `TerminalPipeline` 统一输入错误、UTF-8 分片解码、输出背压和流量统计
|
||||
- SFTP 本地/远程路径使用不同类型;read/write 与 upload/download 使用不同接口
|
||||
- `ssh.rs` 已拆出认证、known_hosts、代理/ProxyJump、路径和 SFTP 模块
|
||||
- `tunnel.rs` 已拆出运行模型、SOCKS5 协议与 Relay 模块
|
||||
- 注册 `ssh://` 系统链接,支持冷启动队列和运行中单实例唤起
|
||||
- SSH Agent CLI 单任务入口、8 步执行观察循环、三级安全模式、五级风险、critical 硬拒绝、输出分析和重复命令拦截
|
||||
- 全终端广播、批量主机选择与执行进度、终端标准右键菜单和远端单 Shell 关闭
|
||||
- 远程 Shell EOF/TMOUT 与 SSH 连接生命周期分离,支持单分屏一键重开
|
||||
## 产品定位
|
||||
|
||||
## 当前重点
|
||||
轻量运维终端:连上就能用 SSH、SFTP、转发、RDP/VNC、串口和本地终端。短路径、好用实用。不做审计、录像、审批或堡垒机。
|
||||
|
||||
- SSH/SFTP/PTY/串口稳定性和断网恢复
|
||||
- 隧道专用 SSH 连接复用、保活、断线重建和可选自动启动
|
||||
- 继续抽离 SSH connection/shell 生命周期,并为隧道增加专用连接 Multiplexer
|
||||
- 完成 Windows 安装包的 `ssh://` 注册与运行中单实例实机验收
|
||||
- 从 URL、IP 和远程路径识别中抽取安全点击策略
|
||||
- Agent Forwarding、OpenSSH 证书和 FIDO2
|
||||
- SFTP 内容校验和、显式冲突选择及任务恢复
|
||||
- Windows x86_64 实机安装与功能验收
|
||||
- Agent Runtime 从 React 面板抽离,补齐取消、单步超时、Rust 硬红线和原生 tool calling
|
||||
- 建立主机排障 Agent eval 集,覆盖非零退出、连续失败、循环、Prompt Injection 和敏感输出
|
||||
## 当前基线(已落地,不要重做)
|
||||
|
||||
## 发布闭环
|
||||
- SSH:密码 / 私钥 / Agent、known_hosts、HTTP·SOCKS 代理、最多 8 跳 ProxyJump、`ssh://` 有界确认队列
|
||||
- 工作区:多标签、分屏、广播、批量命令(排除桌面会话)、七语种 i18n
|
||||
- SFTP:双栏、拖拽、断点、原子替换、远程文本编辑(约 2MB)
|
||||
- 转发:本地 / 远程 / 动态 SOCKS5;规则可持久化,运行态不落盘;`TunnelManager` 持有 Listener
|
||||
- 转发恢复:未点停止则 retain;`user@host:port` orphan-adopt;同窗 `forwardIntent`;关标签不自动再启;空闲挂起先断原生并 `retainForwards`
|
||||
- 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存
|
||||
- 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards
|
||||
- AI Agent:当前 SSH connection、strict/relaxed、critical 硬拒绝、不落命令历史
|
||||
- 打包:macOS arm64 `.dmg` 可由 `./scripts/build.sh` 产出(无签名凭据时仅为 ad-hoc,未公证)
|
||||
|
||||
- macOS Developer ID 签名与公证
|
||||
- Windows Authenticode 签名
|
||||
- 带签名清单和回滚策略的自动更新
|
||||
- 可由用户主动导出的脱敏崩溃诊断包
|
||||
## 下一阶段
|
||||
|
||||
审计、录像、审批和命令历史不在产品范围内。
|
||||
1. **可靠性**:断网 / 休眠 / 大量标签切换的持续压测;keepalive 失败原因不要盖成「用户关闭」;SFTP 与终端共享连接时的有限自动重连
|
||||
2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复
|
||||
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
|
||||
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
|
||||
5. **发布闭环**:Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新;用户可导出的脱敏崩溃包
|
||||
6. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出)
|
||||
|
||||
## 明确不做
|
||||
|
||||
审计、录像、审批、命令历史、插件宿主、对外 MCP/CLI 公共 API、把 `license-server` 堆回桌面进程。
|
||||
@@ -0,0 +1,44 @@
|
||||
# OpsXShell 当前任务
|
||||
|
||||
> 最后更新:2026-08-16
|
||||
> 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。
|
||||
|
||||
## 本周期已完成(勿回退)
|
||||
|
||||
- [x] 本地/远程转发:端口优先 + 高级绑定;远程默认 `127.0.0.1`;`0.0.0.0` 提示 GatewayPorts
|
||||
- [x] 转发 retain / orphan-adopt / `forwardIntent`;空闲挂起断开原生并 retain Listener
|
||||
- [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab
|
||||
- [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出
|
||||
- [x] `AGENTS.md` / `CLAUDE.md` 对齐当前架构与命令
|
||||
- [x] macOS arm64 v2.1.1 安装包(ad-hoc,未公证)
|
||||
- [x] 删除过时 `docs/ux-foundation-audit.md`
|
||||
|
||||
## 现在可做
|
||||
|
||||
按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。
|
||||
|
||||
- [ ] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」
|
||||
- [ ] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录)
|
||||
- [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖
|
||||
- [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」
|
||||
- [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开
|
||||
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
|
||||
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新
|
||||
|
||||
## 稍后
|
||||
|
||||
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
|
||||
- [ ] SFTP 校验和与失败任务恢复
|
||||
- [ ] RDP/VNC 生产级证书校验
|
||||
- [ ] macOS Developer ID + 公证;Windows Authenticode
|
||||
- [ ] 自动更新(签名清单 + 回滚)
|
||||
- [ ] 用户可导出的脱敏崩溃诊断包
|
||||
- [ ] AI 执行运行时从 React 面板抽离(取消、单步超时、原生 tool calling)
|
||||
- [ ] 主机排障 Agent eval 集
|
||||
|
||||
## 约束
|
||||
|
||||
- 不引入审计/审批/堡垒机。
|
||||
- 未由 Rust 实现的能力不能在界面显示为可用。
|
||||
- 可见字符串同步七语种;`test/i18n/localeKeys.test.ts` 必须过。
|
||||
- 转发运行态不落盘;停止必须关掉真实 Listener。
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# OpsXShell 测试计划
|
||||
|
||||
> 最后更新:2026-08-16
|
||||
> 持续开发默认测试先行:先锁定失败用例或巡检,再改实现。门禁细节见 [docs/testing-acceptance.md](./docs/testing-acceptance.md)。
|
||||
|
||||
## 原则
|
||||
|
||||
- 改 SSH / SFTP / 转发 / RDP / VNC 后,先跑 `npm run test:patrol`。
|
||||
- 渲染层只测 `src/platform` 契约与 UI 状态;真实 TCP/SSH 不进默认 Vitest。
|
||||
- 原生新 command 必须同步 `src/desktop.d.ts`、`src/platform/*.ts`、`test/setup.ts`,否则 `typecheck:test` 应失败。
|
||||
- 七语种 key 对齐是硬门槛:`test/i18n/localeKeys.test.ts`。
|
||||
- 没有签名凭据的构建测试不得断言「已公证」。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
npm run test:patrol # 基础巡检(优先)
|
||||
npm run acceptance:quick # typecheck + 全量 Vitest + Rust fmt/check
|
||||
npm run acceptance:full # 发包:再加 lint、clippy -D warnings、Rust test、生产构建
|
||||
./scripts/build.sh # 当前平台安装包;内部会跑完整门禁
|
||||
```
|
||||
|
||||
真实探针(需显式主机,默认 ignored):
|
||||
|
||||
```bash
|
||||
OPSX_TEST_SSH_HOST=… OPSX_TEST_SSH_PORT=… OPSX_TEST_SSH_USER=… \
|
||||
cargo test --manifest-path src-tauri/Cargo.toml real_ssh_exec_and_sftp_probe -- --ignored --nocapture
|
||||
```
|
||||
|
||||
## 自动化分层
|
||||
|
||||
| 层 | 路径 | 覆盖 |
|
||||
|----|------|------|
|
||||
| 巡检 | `test/patrol/` | SSH 开终端、SFTP 双栏/拖拽/冲突、RDP·VNC 对话框、平台契约 |
|
||||
| 单元 | `test/`、`src-tauri` tests | 转发规则、forwardIntent、resume 回写 tab、错误文案映射、隧道 orphan |
|
||||
| 门禁 | `scripts/acceptance.mjs` | ESLint、tsc、Vitest、rustfmt、clippy、cargo test |
|
||||
| 发布 | `scripts/build.sh`、`scripts/release.py --dry-run` | 产物、SHA-256;不自动宣称签名 |
|
||||
|
||||
## 下一任务对应的测试(先写再改)
|
||||
|
||||
1. **断开原因**
|
||||
keepalive 失败 → 事件/文案不是「用户关闭」。覆盖 `sshDisconnect` reason 与终端/SFTP 回收。
|
||||
|
||||
2. **SFTP 自动重连**
|
||||
mock `onConnectionClosed` 后活跃 SFTP tab 有限次重连,并 `loadRemoteDirectory(lastBrowsePath)`。
|
||||
|
||||
3. **远程文本冲突**
|
||||
open 记 mtime;save 前 stat 变化 → 确认对话框;取消则不调用 write。
|
||||
|
||||
4. **目录冲突文案**
|
||||
同名目录覆盖 vs 跳过;断言文案含「合并/内部同名」,且七语种 key 存在。
|
||||
|
||||
5. **远程 bind 展示**
|
||||
请求 `0.0.0.0` 但运行态 bind 为 `127.0.0.1` 时,列表不得显示为全接口已开。
|
||||
|
||||
6. **RDP 缩放**
|
||||
回归:K64 协商;DeactivateAll 后 pending 脏区被清空;解压失败不直接 Terminate。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
1. 本项对应测试或巡检通过。
|
||||
2. `npm run acceptance:full` 通过后再打安装包。
|
||||
3. 平台包必须在目标 OS 实机点开验证;macOS 不能替代 Windows。
|
||||
4. 无 `APPLE_*` / Authenticode 时,文档只写 ad-hoc / 未签名。
|
||||
+4
-2
@@ -7,8 +7,10 @@
|
||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||
| [架构说明](./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) 维护。
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## 产品边界
|
||||
|
||||
OpsXShell 是轻量原生的运维终端,核心范围是连接、终端、文件传输、端口转发、代理、RDP / VNC 远程桌面、串口和多会话操作。审计、录像、审批和堡垒机能力不在客户端范围内。
|
||||
OpsXShell 是简单好用的原生运维终端:短路径完成连接、终端、传文件、转发和远程桌面。审计、录像、审批和堡垒机能力不在客户端范围内。
|
||||
|
||||
## 目录与职责
|
||||
|
||||
```text
|
||||
src/ React 界面、工作区和 Zustand 状态
|
||||
src/i18n/ 界面文案(zh-CN / en)与 locale 检测
|
||||
src/i18n/ 界面文案(zh-CN / zh-TW / en / ja / ko / vi / ru)与 locale 检测
|
||||
src/platform/ Tauri command/event 适配层
|
||||
shared/types/ 渲染层 TypeScript 契约
|
||||
src-tauri/src/commands/ 参数边界和统一响应
|
||||
@@ -65,7 +65,7 @@ services/
|
||||
| 凭据 | AES-256-GCM + `~/.opsxshell/master.key` |
|
||||
| 主机信任 | OpenSSH `~/.ssh/known_hosts` |
|
||||
| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 |
|
||||
| 界面语言 | i18next;zh-CN / en,首启按系统语言检测 |
|
||||
| 界面语言 | i18next;zh-CN / zh-TW / en / ja / ko / vi / ru,首启按系统语言检测 |
|
||||
|
||||
## SSH Agent CLI 边界
|
||||
|
||||
@@ -106,9 +106,11 @@ XTerm 当前焦点分屏
|
||||
|
||||
- `ForwardRule` 由 React 管理并按会话 / 标签 / 连接维度写入设置,只保存类型、端口和目标。
|
||||
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
|
||||
- 每个运行实例有唯一 `forwardId` 和取消标志。停止、删除正在运行的规则或断开 SSH 连接时,Rust 必须关闭真实 Listener。
|
||||
- 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。
|
||||
- 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。
|
||||
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。
|
||||
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
|
||||
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。
|
||||
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。
|
||||
- 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。
|
||||
|
||||
当前动态 SOCKS5 默认仅绑定 `127.0.0.1`,不对局域网暴露。客户端仅支持 SOCKS5 CONNECT 和无认证方式。流量经当前 SSH 服务器出口 IP 访问外网。
|
||||
|
||||
File renamed without changes.
+8
-8
@@ -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`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 路线图
|
||||
|
||||
权威副本在仓库根目录:
|
||||
|
||||
- [ROADMAP.md](../ROADMAP.md) — 产品方向与下一阶段
|
||||
- [TASKS.md](../TASKS.md) — 当前可勾选任务(Hermes / OpenClaw / Cursor)
|
||||
- [TDD_PLAN.md](../TDD_PLAN.md) — 测试先行与门禁
|
||||
|
||||
开发规则以 [AGENTS.md](../AGENTS.md) 为准。不要在本文件再维护一份进度,避免漂移。
|
||||
@@ -29,7 +29,7 @@ npm run acceptance:full
|
||||
- HTTP CONNECT、SOCKS5 和断点复制
|
||||
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 SSH 断开时清理
|
||||
- 状态栏「转发」抽屉启停动态 `127.0.0.1:1080`,右键复制 SOCKS 地址;每台 SSH 主机仅一条动态规则
|
||||
- i18n:zh-CN / en 的 namespace key 对齐(`test/i18n/localeKeys.test.ts`)
|
||||
- i18n:全部 APP_LANGUAGES(zh-CN / zh-TW / en / ja / ko / vi / ru)的 namespace key 对齐(`test/i18n/localeKeys.test.ts`)
|
||||
- RDP / VNC 仅原生可用;非 Tauri 环境不得显示为可连接;桌面会话不参与批量命令
|
||||
- SFTP 本地/远程路径参数方向、原生路径上传、选中文件下载、双栏双向拖拽、目录落点、远程文本读写和错误反馈
|
||||
- 终端分屏、剪贴板、快捷键、分片 UTF-8 解码、高输出背压和异步输入错误
|
||||
|
||||
+16
-9
@@ -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 对齐。验收与单元测试默认中文文案。
|
||||
|
||||
## 本地数据
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
# 产品体验与基础能力全面评估
|
||||
|
||||
> 更新:2026-07-31
|
||||
> 目标:OpsXShell 做成“麻雀虽小、五脏俱全”的轻量 SSH/SFTP 工具。常用操作直接、结果明确、失败可恢复,不用花哨概念增加学习成本。
|
||||
|
||||
## 评估结论
|
||||
|
||||
当前开发分支已具备 SSH、SFTP、串口、分屏、批量命令、SOCKS5、跳板和 AI Agent 的完整产品骨架,已经不是功能演示,但距离可长期替代 Tabby、Netcatty、Xshell 或 WindTerm 仍有一段可靠性和跨平台发布差距。
|
||||
|
||||
综合评分为 **7.1/10**。评分针对当前代码和自动化基线,不等于正式安装包质量;Windows 实机、macOS 安装版、断网/休眠和真实大文件传输未统一验收前,不能按成熟商业终端评价。
|
||||
|
||||
| 维度 | 分数 | 判断 |
|
||||
| ------------------ | ---: | ---------------------------------------------------------------------------------- |
|
||||
| 产品聚焦 | 8.5 | SSH/SFTP/代理/串口边界清楚,没有堡垒机、审计和 CMDB 噪音 |
|
||||
| 首次连接与会话管理 | 7.8 | 快速连接和保存会话路径短,支持密码、私钥、代理和跳板 |
|
||||
| 终端基础体验 | 7.2 | 复制粘贴、右键、分屏、广播和 Shell 生命周期已补齐;搜索和高压稳定性仍不足 |
|
||||
| SFTP | 6.8 | 双栏、双向拖拽、上传下载、权限和远程编辑已具备;冲突、重试和任务恢复不足 |
|
||||
| 隧道与 SOCKS5 | 7.5 | 规则与运行实例分离、真实启停和状态统计方向正确;重连和复用还需压测 |
|
||||
| 批量运维 | 7.0 | 多主机选择、并发、逐机结果清楚;取消和独立超时未完成 |
|
||||
| AI Agent | 6.8 | 单入口、当前 SSH 执行、风险模式和 ReAct 基线可用;执行内核仍在 React,真正取消不足 |
|
||||
| 性能与可靠性 | 6.2 | Tauri/Rust 基础占优,但断网、休眠、大输出和频繁切换仍缺持续压测 |
|
||||
| 跨平台发布 | 5.8 | macOS/Windows 构建脚本已具备;Windows 实机、签名、公证、更新和崩溃诊断未闭环 |
|
||||
| 文档与自动化 | 7.8 | 有契约、组件、Rust 和真实探针分层;仍需把实机矩阵纳入每次候选发布 |
|
||||
|
||||
## 产品主旨
|
||||
|
||||
OpsXShell 的默认界面只围绕七件事:连接主机、管理会话、操作终端、传输文件、开隧道、批量执行、必要时让 AI 在当前主机协助处理。
|
||||
|
||||
必须坚持:
|
||||
|
||||
1. 高频动作一步可达,高级选项默认收起。
|
||||
2. 当前操作目标始终可见,尤其是广播、批量命令、SFTP 和 AI 执行。
|
||||
3. 成功、进行中、失败、断开是四种不同状态,不能只靠颜色或开发日志表达。
|
||||
4. 空闲操作不滥用确认框;删除、覆盖、关闭运行任务和高风险 AI 命令才确认。
|
||||
5. 不保存终端命令、广播输入、批量命令、AI 对话或工具输出。
|
||||
6. 不加入审计、录像、审批、CMDB、独立 podexec 或 Kubernetes 凭据。
|
||||
|
||||
## 大众操作习惯基线
|
||||
|
||||
### 鼠标与窗口
|
||||
|
||||
- 单击负责选择和聚焦,双击负责打开会话/目录或切换窗口最大化,不能让一次双击触发两次相反操作。
|
||||
- 右键必须显示与当前位置相关的菜单;普通区域不得出现 WebView 的 Reload/Inspect Element。
|
||||
- macOS 使用原生红黄绿窗口控制,Windows 使用系统最小化/最大化/关闭;标题栏空白区可拖动。
|
||||
- 拖拽必须显示源、目标和允许/禁止状态;文件落到目录行时传入该目录,落到空白区时传入当前目录。
|
||||
|
||||
### 键盘
|
||||
|
||||
| 动作 | macOS | Windows/Linux |
|
||||
| -------------------- | -------------- | ------------------------------------------------- |
|
||||
| 复制/粘贴终端 | `Cmd+C/V` | `Ctrl+Shift+C/V` |
|
||||
| 普通输入框复制/粘贴 | `Cmd+C/V` | `Ctrl+C/V` |
|
||||
| 新建会话 | `Cmd+N` | `Ctrl+N` |
|
||||
| 快速连接 | `Cmd+K` | `Ctrl+K` |
|
||||
| 折叠/展开侧栏 | `Cmd+B` | `Ctrl+B` |
|
||||
| 关闭标签 | `Cmd+W` | `Ctrl+W` |
|
||||
| 分屏 | `Cmd+D` | `Ctrl+D`,但必须避免与远端 EOF 语义冲突并允许改键 |
|
||||
| 终端搜索 | `Cmd+F` | `Ctrl+F` |
|
||||
| 开发工具(开发构建) | `Cmd+Option+I` | `F12` |
|
||||
|
||||
- 侧栏折叠采用**完全隐藏**(专心终端):折叠后不保留图标窄轨。会话树是主内容而非 IDE 工具切换条;折叠后靠 `Cmd/Ctrl+B`、标签栏左侧展开按钮恢复。快速连接继续走 `Cmd/Ctrl+K`,不依赖侧栏可见。
|
||||
- 快捷键不能抢占输入框的复制、粘贴、全选、撤销和输入法组合状态。
|
||||
- 菜单和提示必须显示当前平台实际快捷键,不能同时展示两套组合键让用户猜。
|
||||
- `Escape` 关闭当前最上层弹框/菜单;`Enter` 触发主动作,文本域需要明确换行组合键。
|
||||
|
||||
### 表单
|
||||
|
||||
- SSH 目标允许直接粘贴 `user@host:port`,端口默认 22。
|
||||
- 保存会话只展示“密码/密钥”两个常用认证入口;代理、跳板、标签和启动命令放在高级区。
|
||||
- 端口、路径和 Endpoint 在失焦或提交时给出就地错误,不能无提示恢复默认值。
|
||||
- 取消或重新打开快速连接、批量命令、AI 面板时,敏感输入必须清空。
|
||||
- 按钮文案说明结果,如“连接”“创建并保存”“执行到 3 台”,避免只显示含义不明的图标。
|
||||
|
||||
### 状态与错误
|
||||
|
||||
- 错误必须包含“什么失败、作用于谁、如何恢复”,例如“Host A 命令超时,可重试”,而不是 `unknown`。
|
||||
- 剪贴板失败、广播目标变为 0、Shell 已退出、隧道端口占用和 SFTP 冲突必须在界面提示。
|
||||
- 重试不能重复创建连接、Shell、SFTP 通道或 Listener;状态必须以 Rust 真实资源为准。
|
||||
- 加载超过 300ms 显示进行中,超过设置超时显示具体阶段;不能无限转圈。
|
||||
|
||||
## 核心用户旅程
|
||||
|
||||
| 旅程 | 目标操作长度 | 当前状态 | 验收标准 |
|
||||
| ----------- | ----------------------------: | -------------- | ------------------------------------------------------- |
|
||||
| 临时连接 | 快捷键 → 粘贴 → 回车 | 已实现 | `user@host:port` 可粘贴;取消重开后密码为空 |
|
||||
| 保存会话 | 新建 → 目标/凭据 → 保存 | 已实现 | 默认 22;常用项不超过一屏;错误就地显示 |
|
||||
| 重复连接 | 双击侧栏会话 | 已实现 | 单击只选择,双击只打开一个终端标签 |
|
||||
| 终端操作 | 右键或快捷键 | 已实现 | 复制、粘贴、全选、清屏、分屏、最大化、关闭均明确 |
|
||||
| 多终端广播 | 开广播 → 输入 | 已实现,待增强 | 覆盖所有打开 SSH Shell,显示目标数,本地 Shell 永不参与 |
|
||||
| 批量命令 | 选主机 → 命令 → 执行 | 已实现,待增强 | 最多并发 5,逐机结果和耗时;关闭后命令销毁 |
|
||||
| SFTP | 会话右键 → SFTP 文件 | 已实现,待实机 | 本地↔远程拖拽、按钮和右键都走明确 upload/download 方向 |
|
||||
| SOCKS5 | 会话 → 转发 → 启动 | 已实现,待压测 | 默认 `127.0.0.1:1080`;停止后真实端口不可连接 |
|
||||
| 串口 | 快速启动 → 串口 → 参数 → 连接 | 已实现,待实机 | 可枚举设备,默认常用参数,断开和重连状态明确 |
|
||||
| AI 处理任务 | 当前 SSH → AI → 描述 → 执行 | 实验可用 | 目标固定、安全模式明确、critical 硬拒、关闭后上下文销毁 |
|
||||
|
||||
## 当前已完成的基础修正
|
||||
|
||||
- 全局广播覆盖所有打开且已连接的 SSH 标签与分屏,排除本地 Shell;目标不足两个时自动关闭。
|
||||
- 终端右键菜单包含复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭。
|
||||
- 关闭分屏会调用 Rust `ssh_close_shell`,远端关闭失败会记录明确错误,不只移除前端格子。
|
||||
- 远端 Shell 的 EOF、`TMOUT` 和 `Ctrl+D` 不再等同整个 SSH 连接断开,可只重开当前 Shell。
|
||||
- 批量命令支持搜索、全选当前结果、调整目标、最大并发 5、逐机状态/输出/耗时和总进度。
|
||||
- 快速连接和批量命令重新打开会清空敏感输入,结果不落盘。
|
||||
- Agent 支持严格、均衡、宽松三级安全模式;默认均衡只确认 high,critical 永久硬拒绝。
|
||||
- 普通 WebView 区域禁止 Reload/Inspect Element,文本输入保留系统编辑菜单。
|
||||
- 应用版本由 `package.json` 构建时注入,不再在状态栏硬编码旧版本。
|
||||
|
||||
## P0:下一候选版本必须完成
|
||||
|
||||
| ID | 优化项 | 当前缺口 | 完成标准 |
|
||||
| ----- | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| P0-01 | 连接生命周期压测 | 断网、睡眠唤醒、快速切标签和连接复用证据不足 | 自动覆盖连接/重连/挂起/恢复;100 次切换无重复连接和监听器泄漏 |
|
||||
| P0-02 | 终端搜索接通 | `SearchAddon` 已加载,但分屏工作区没有可用搜索 UI | `Cmd/Ctrl+F` 搜索当前焦点分屏,支持上一项/下一项/关闭 |
|
||||
| P0-03 | 终端高输出稳定性 | 仍出现过 CPU 高占用和操作卡顿 | 持续输出 10 分钟 UI 可操作;后台标签限流;CPU/内存有基准阈值 |
|
||||
| P0-04 | 用户可见 I/O 错误 | 部分剪贴板、关闭 Shell 和事件错误只写日志 | toast/覆盖层说明失败对象和恢复动作;不得产生未处理 Promise |
|
||||
| P0-05 | SFTP 基础传输实机门禁 | 自动测试有方向契约和探针,但真实安装版仍需重复验证 | 上传、下载、覆盖、目录落点、拖拽、取消后临时文件处理全部通过 |
|
||||
| P0-06 | SFTP 冲突规则 | 同名文件处理不够显式 | 每次任务可选择覆盖/跳过/重命名;目录递归不静默覆盖 |
|
||||
| P0-07 | 批量任务超时与取消 | 当前只能等待 worker 完成 | 区分连接/命令超时;停止后不再启动新主机,当前命令标为等待完成 |
|
||||
| P0-08 | 隧道重连与清理 | Listener 真实启停已做,网络恢复策略不足 | 主连接断开立即清理;重连后按用户选择恢复;端口冲突显示占用地址 |
|
||||
| P0-09 | 最小窗口适配 | 960×720 基础可用,AI 打开后终端区域偏窄 | 960×640 无遮挡;打开 AI 时可自动收起侧栏或提供一键扩展 |
|
||||
| P0-10 | 双平台安装验收 | macOS 结果不能代表 Windows | macOS arm64 与 Windows x86_64 分别完成安装、升级、卸载和核心冒烟 |
|
||||
|
||||
## P1:提升日常效率
|
||||
|
||||
| ID | 优化项 | 建议 |
|
||||
| ----- | --------------- | ----------------------------------------------------------------------------- |
|
||||
| P1-01 | 广播目标预览 | 点击目标数查看主机/分屏,允许本次临时排除;默认仍是全部打开 SSH |
|
||||
| P1-02 | 批量失败重试 | 一键仅重试失败主机,可从分组/标签建立目标集合 |
|
||||
| P1-03 | 终端标题与路径 | 安全解析 OSC 标题/当前目录,允许锁定会话名,防止远端伪装安全提示 |
|
||||
| P1-04 | 关闭保护 | 仅当前台有运行任务时提示关闭会结束远端 Shell;空闲时一步关闭 |
|
||||
| P1-05 | SFTP 任务中心 | 统一暂停/继续/取消/重试,显示速度、剩余时间和失败原因;重启后可恢复未完成任务 |
|
||||
| P1-06 | SFTP 易用性 | 多选拖拽、复制路径、在终端打开目录、远程编辑冲突提示、校验和验证 |
|
||||
| P1-07 | SSH Config 导入 | 展示将导入字段、冲突处理和不可支持项;支持 ProxyJump 链预览 |
|
||||
| P1-08 | 隧道便捷入口 | 状态栏一键查看/复制 SOCKS5 地址;复制规则永不复制运行态 |
|
||||
| P1-09 | Agent Runtime | 从 React 抽离状态机,增加单步超时、真正取消、结构化 tool calling 和 eval |
|
||||
| P1-10 | 无障碍 | 所有图标按钮有名称;焦点圈清楚;弹框焦点锁定/返回;状态不只靠颜色 |
|
||||
|
||||
## P2:专业兼容与发布闭环
|
||||
|
||||
- Agent Forwarding、OpenSSH 证书、FIDO2 和更完整的算法策略。
|
||||
- 保存常用分屏布局,但不保存 Shell ID、命令或终端输出。
|
||||
- 隧道使用明确的共享连接/Multiplexer 策略,避免每个请求重新握手。
|
||||
- macOS Developer ID 签名与公证、Windows Authenticode、自动更新清单和回滚。
|
||||
- 用户主动导出的脱敏诊断包:版本、平台、错误码、资源统计,不包含命令、密码和终端输出。
|
||||
- 可选主题和键位导入;不为了生态而引入重量级插件框架。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不恢复命令历史,不持久化广播、批量命令或 Agent 输入输出。
|
||||
- 不增加堡垒机审计、审批流、录像、CMDB、告警大盘或独立 podexec。
|
||||
- 不使用“有选区右键复制、无选区右键直接粘贴”这类隐藏行为。
|
||||
- 不让本地 Shell 加入远程广播,不允许 `ssh://` 携带密码或静默连接。
|
||||
- 不为“专业感”堆无用途状态卡、动画、统计图和多级配置。
|
||||
|
||||
## 统一回归场景
|
||||
|
||||
1. 快速连接粘贴 `root@opsbase.cn:55555`,取消后重开,目标和密码为空;通过 `ssh://` 唤起时只预填安全目标。
|
||||
2. 新建会话默认端口 22,密码/密钥切换不会把另一种凭据落盘;编辑高级配置不影响基础字段。
|
||||
3. 打开两个 SSH 标签,其中一个分两屏;广播输入 `date`,三个远端各收到一次,本地 Shell 不收到。
|
||||
4. 关闭一个分屏,Rust Shell 释放,广播目标立即减少,兄弟分屏、SFTP 和隧道保持可用。
|
||||
5. 终端右键复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭均可用,无 Reload/Inspect Element。
|
||||
6. `Cmd/Ctrl+F` 只搜索当前焦点分屏;切换分屏后结果随焦点切换。
|
||||
7. 批量选择两台执行 `date`,逐机显示耗时和结果;停止后不启动剩余主机;关闭重开命令为空。
|
||||
8. SFTP 按钮上传、右键上传、本地拖到远程、远程拖到本地结果一致;文件逐字节校验成功。
|
||||
9. 同名文件明确选择覆盖/跳过/重命名;失败不留下被误认为完整文件的目标。
|
||||
10. 启动 `127.0.0.1:1080` SOCKS5,代理请求成功且统计变化;停止后端口不可连接,重启应用显示未运行。
|
||||
11. 远端 `TMOUT` 只关闭当前 Shell,一键重开可用,SSH connection 和其他资源不被误断。
|
||||
12. Agent 均衡模式自动执行 `df -h`,`systemctl restart nginx` 先确认;严格模式修改文件先确认;宽松仍拒绝 `rm -rf /`。
|
||||
13. 断网后状态由“已连接”进入“重连中/失败”,恢复后不重复创建标签、连接、Shell 或 Listener。
|
||||
14. macOS 窗口可拖动,双击标题栏只切换一次最大化;Windows 系统按钮、快捷键和安装卸载符合平台习惯。
|
||||
15. 960×640、125%/150% Windows 缩放以及长主机名、中文分组下,无按钮遮挡、水平溢出或不可达操作。
|
||||
|
||||
## 发版判定
|
||||
|
||||
自动化全绿只代表代码门禁通过。候选发布还必须分别在 macOS arm64 和 Windows x86_64 完成上述核心旅程,并记录版本、安装包哈希、测试平台、失败项和回归结果。任何“只能偶现”“重试一般能好”的连接、终端输入、SFTP 或隧道问题都按未通过处理。
|
||||
@@ -1,6 +1,8 @@
|
||||
# OpsXShell License Server
|
||||
|
||||
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发。
|
||||
> **DEPRECATED** — 桌面端已切换到 **opsx-licence**(默认 `http://127.0.0.1:9001/api`)。本目录保留供对照与临时本地调试,**请勿用于新功能开发**。新结账 / 领取 / 激活请走 opsx-licence 的 `/api/v1/*` 契约。
|
||||
|
||||
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发(旧 HMAC `opsx1` 方案)。
|
||||
|
||||
**私钥只放本机环境变量,不要写入仓库或桌面端。**
|
||||
|
||||
@@ -15,7 +17,7 @@ npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env
|
||||
npm run start
|
||||
```
|
||||
|
||||
桌面端默认请求 `http://127.0.0.1:8787`。可用 `VITE_LICENSE_SERVER_URL` 覆盖。
|
||||
旧桌面端曾默认请求 `http://127.0.0.1:8787`。当前桌面默认已改为 opsx-licence:`http://127.0.0.1:9001/api`(`VITE_LICENSE_SERVER_URL` / `OPSX_LICENSE_API_BASE`)。
|
||||
|
||||
## 接口
|
||||
|
||||
@@ -34,10 +36,10 @@ npm run issue-dev-license -- --deviceId '*'
|
||||
# 临时授权可加 --days 30
|
||||
```
|
||||
|
||||
将返回的 `token` 粘贴到 App「已有许可证?激活」。
|
||||
将返回的 `token` 粘贴到 App「已有许可证?激活」(仅旧 HMAC 路径;新客户端需 `LIC-` 密钥)。
|
||||
|
||||
## 安全
|
||||
|
||||
- `@waffo/pancake-ts` 仅在本服务使用
|
||||
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
|
||||
- `OPSX_LICENSE_HMAC` 与旧桌面端校验密钥一致(生产请轮换)
|
||||
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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]],
|
||||
]
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -5,7 +5,7 @@ chcp 65001 >nul
|
||||
rem OpsXShell v2.1.x native Windows release builder.
|
||||
rem Run this script from Windows x64 with the MSVC Rust toolchain installed.
|
||||
|
||||
cd /d "%~dp0"
|
||||
cd /d "%~dp0.."
|
||||
set "VERSION_ARG="
|
||||
set "CHECK_MODE=full"
|
||||
set "RUST_TARGET=x86_64-pc-windows-msvc"
|
||||
@@ -33,7 +33,7 @@ if /I "!ARG!"=="win-x64" (
|
||||
)
|
||||
if /I "!ARG:~0,4!"=="v2.1" goto set_version_arg
|
||||
if /I "!ARG:~0,4!"=="2.1." goto set_version_arg
|
||||
call :fail "未知参数:!ARG!(使用 build.bat --help 查看用法)"
|
||||
call :fail "未知参数:!ARG!(使用 scripts\build.bat --help 查看用法)"
|
||||
exit /b 1
|
||||
|
||||
:set_version_arg
|
||||
@@ -181,7 +181,7 @@ exit /b 0
|
||||
echo OpsXShell Windows 原生安装包构建脚本
|
||||
echo.
|
||||
echo 用法:
|
||||
echo build.bat [v2.1.x] [win-x64] [选项]
|
||||
echo scripts\build.bat [v2.1.x] [win-x64] [选项]
|
||||
echo.
|
||||
echo 选项:
|
||||
echo --quick 构建前只运行快速门禁
|
||||
@@ -189,9 +189,9 @@ echo --skip-checks 跳过门禁,仅用于本地重复打包
|
||||
echo -h, --help 显示帮助
|
||||
echo.
|
||||
echo 示例:
|
||||
echo build.bat
|
||||
echo build.bat v2.1.1
|
||||
echo build.bat v2.1.2 win-x64 --quick
|
||||
echo scripts\build.bat
|
||||
echo scripts\build.bat v2.1.1
|
||||
echo scripts\build.bat v2.1.2 win-x64 --quick
|
||||
echo.
|
||||
echo 环境要求:
|
||||
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
|
||||
@@ -6,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
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
chcp 65001 >nul
|
||||
|
||||
rem OpsXShell local GUI launcher (Windows).
|
||||
rem App log path matches src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set "PID_FILE=%TEMP%\opsxshell.pid"
|
||||
set "APP_LOG=%LOCALAPPDATA%\opsxshell\logs\opsxshell.log"
|
||||
|
||||
echo.
|
||||
echo [opsxshell] starting...
|
||||
|
||||
if not exist "node_modules\" (
|
||||
echo [opsxshell] npm install...
|
||||
call npm install
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
echo [opsxshell] npm run dev
|
||||
echo [opsxshell] app log: %APP_LOG% ^(max 10MB x 3 files^)
|
||||
start /b "" cmd /c "npm run dev"
|
||||
|
||||
echo %DATE% %TIME% > "%PID_FILE%"
|
||||
|
||||
echo [opsxshell] started. Tail app log with:
|
||||
echo powershell -Command "Get-Content -Path '%APP_LOG%' -Wait -Tail 50"
|
||||
echo.
|
||||
exit /b 0
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PID_FILE="/tmp/opsxshell.pid"
|
||||
|
||||
# Same path as src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
APP_LOG="$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||
else
|
||||
APP_LOG="${XDG_DATA_HOME:-$HOME/.local/share}/opsxshell/logs/opsxshell.log"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}🦐 启动 OpsXShell...${NC}"
|
||||
|
||||
# 1. 清理旧进程
|
||||
echo -e "${YELLOW}🧹 清理旧进程...${NC}"
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
pkill -f "opsxshell" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# 2. 检查依赖
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${YELLOW}📦 安装依赖...${NC}"
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 3. 启动(原生 tracing 写入应用日志目录;Vite 输出留在本终端)
|
||||
echo -e "${GREEN}⚡ 启动 OpsXShell...${NC}"
|
||||
npm run dev &
|
||||
VITE_PID=$!
|
||||
echo $VITE_PID >"$PID_FILE"
|
||||
|
||||
# 4. 等待启动
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
||||
echo -e "${CYAN}📄 应用日志: tail -f \"${APP_LOG}\"${NC}"
|
||||
echo -e "${CYAN}📦 轮转: 单文件 ≤10MB,最多 opsxshell.log + .1 + .2(正式版同路径)${NC}"
|
||||
echo -e "${CYAN}🔍 DevTools: 菜单「视图 → 开发者工具」,或 Cmd+Option+I / F12(前端 console)${NC}"
|
||||
|
||||
# 等待子进程
|
||||
wait $VITE_PID 2>/dev/null || true
|
||||
@@ -31,9 +31,9 @@ export interface ConnectionStats {
|
||||
export interface PoolConfig {
|
||||
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
|
||||
}
|
||||
|
||||
// 连接结果
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+84
-14
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-internal-toggle-devtools"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","remote":{"urls":["http://127.0.0.1:1420","http://127.0.0.1:1420/*","http://localhost:1420","http://localhost:1420/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:allow-start-dragging"]}}
|
||||
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","remote":{"urls":["http://127.0.0.1:1420","http://127.0.0.1:1420/*","http://localhost:1420","http://localhost:1420/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:webview:allow-internal-toggle-devtools"]}}
|
||||
@@ -60,6 +60,16 @@ pub fn rdp_upload_file(
|
||||
state.rdp.upload_file(&session_id, name, data)
|
||||
}
|
||||
|
||||
#[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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::services::license::{self, LicenseStatus};
|
||||
use crate::services::license::{self, CheckoutResult, LicenseStatus};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
@@ -8,8 +8,12 @@ pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_activate(app: AppHandle, token: String) -> Result<LicenseStatus, String> {
|
||||
license::activate(&app, &token)
|
||||
pub fn license_activate(
|
||||
app: AppHandle,
|
||||
token: String,
|
||||
email: Option<String>,
|
||||
) -> Result<LicenseStatus, String> {
|
||||
license::activate(&app, &token, email.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -21,3 +25,18 @@ pub fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||
license::get_or_create_device_id(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_set_email(app: AppHandle, email: String) -> Result<LicenseStatus, String> {
|
||||
license::set_email(&app, &email)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_checkout(
|
||||
app: AppHandle,
|
||||
customer_email: String,
|
||||
plan_type: String,
|
||||
return_url: Option<String>,
|
||||
) -> Result<CheckoutResult, String> {
|
||||
license::checkout(&app, &customer_email, &plan_type, return_url.as_deref())
|
||||
}
|
||||
+198
-72
@@ -29,8 +29,13 @@ pub fn ssh_disconnect(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
retain_forwards: Option<bool>,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.disconnect(&app, &connection_id))
|
||||
operation(
|
||||
state
|
||||
.ssh
|
||||
.disconnect(&app, &connection_id, retain_forwards.unwrap_or(false)),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -154,11 +159,34 @@ pub fn ssh_close_shell(state: State<'_, AppState>, shell_id: String) -> Operatio
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
|
||||
match state.ssh.create_sftp(&connection_id) {
|
||||
Ok(id) => IdResponse::sftp(id),
|
||||
Err(error) => IdResponse::error(error),
|
||||
}
|
||||
pub async fn sftp_connect(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<IdResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.create_sftp(&connection_id))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::sftp(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 SFTP 任务失败: {error}")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sftp_disconnect(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.close_sftp(&sftp_id)).await {
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -171,23 +199,32 @@ pub struct SftpListResponse {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_list_directory(
|
||||
pub async fn sftp_list_directory(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> SftpListResponse {
|
||||
match state.ssh.sftp_list(&sftp_id, &path) {
|
||||
Ok(files) => SftpListResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
) -> Result<SftpListResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.sftp_list(&sftp_id, &path)).await
|
||||
{
|
||||
Ok(Ok(files)) => SftpListResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Ok(Err(error)) => SftpListResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
Err(error) => SftpListResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
Err(error) => SftpListResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -253,6 +290,14 @@ pub async fn sftp_download(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sftp_cancel_transfer(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
Ok(operation(state.ssh.cancel_sftp_transfer(&sftp_id)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sftp_download_dir(
|
||||
app: AppHandle,
|
||||
@@ -275,28 +320,60 @@ pub async fn sftp_download_dir(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_rename(
|
||||
pub async fn sftp_rename(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_rename(&sftp_id, &old_path, &new_path))
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_rename(&sftp_id, &old_path, &new_path)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_delete(
|
||||
pub async fn sftp_delete(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
is_directory: bool,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_delete(&sftp_id, &path, is_directory))
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_delete(&sftp_id, &path, is_directory)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_mkdir(state: State<'_, AppState>, sftp_id: String, path: String) -> OperationResponse {
|
||||
operation(state.ssh.sftp_mkdir(&sftp_id, &path))
|
||||
pub async fn sftp_mkdir(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.sftp_mkdir(&sftp_id, &path))
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -322,40 +399,63 @@ pub struct SftpStat {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_stat(state: State<'_, AppState>, sftp_id: String, path: String) -> SftpStatResponse {
|
||||
match state.ssh.sftp_stat(&sftp_id, &path) {
|
||||
Ok(stat) => {
|
||||
let mode = stat.perm.unwrap_or(0);
|
||||
SftpStatResponse {
|
||||
success: true,
|
||||
stats: Some(SftpStat {
|
||||
size: stat.size.unwrap_or(0),
|
||||
mode,
|
||||
mtime: stat.mtime.unwrap_or(0),
|
||||
atime: stat.atime.unwrap_or(0),
|
||||
is_directory: mode & 0o170000 == 0o040000,
|
||||
is_file: mode & 0o170000 == 0o100000,
|
||||
is_symbolic_link: mode & 0o170000 == 0o120000,
|
||||
}),
|
||||
error: None,
|
||||
pub async fn sftp_stat(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> Result<SftpStatResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.sftp_stat(&sftp_id, &path)).await
|
||||
{
|
||||
Ok(Ok(stat)) => {
|
||||
let mode = stat.perm.unwrap_or(0);
|
||||
SftpStatResponse {
|
||||
success: true,
|
||||
stats: Some(SftpStat {
|
||||
size: stat.size.unwrap_or(0),
|
||||
mode,
|
||||
mtime: stat.mtime.unwrap_or(0),
|
||||
atime: stat.atime.unwrap_or(0),
|
||||
is_directory: mode & 0o170000 == 0o040000,
|
||||
is_file: mode & 0o170000 == 0o100000,
|
||||
is_symbolic_link: mode & 0o170000 == 0o120000,
|
||||
}),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => SftpStatResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
Ok(Err(error)) => SftpStatResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
Err(error) => SftpStatResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_chmod(
|
||||
pub async fn sftp_chmod(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
mode: u32,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_chmod(&sftp_id, &path, mode))
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_chmod(&sftp_id, &path, mode)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -372,37 +472,59 @@ pub struct SftpReadTextResponse {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_read_text(
|
||||
pub async fn sftp_read_text(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> SftpReadTextResponse {
|
||||
match state.ssh.sftp_read_text(&sftp_id, &path) {
|
||||
Ok((content, size)) => SftpReadTextResponse {
|
||||
success: true,
|
||||
content: Some(content),
|
||||
size: Some(size),
|
||||
encoding: Some("utf-8"),
|
||||
error: None,
|
||||
) -> Result<SftpReadTextResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.sftp_read_text(&sftp_id, &path))
|
||||
.await
|
||||
{
|
||||
Ok(Ok((content, size))) => SftpReadTextResponse {
|
||||
success: true,
|
||||
content: Some(content),
|
||||
size: Some(size),
|
||||
encoding: Some("utf-8"),
|
||||
error: None,
|
||||
},
|
||||
Ok(Err(error)) => SftpReadTextResponse {
|
||||
success: false,
|
||||
content: None,
|
||||
size: None,
|
||||
encoding: None,
|
||||
error: Some(error),
|
||||
},
|
||||
Err(error) => SftpReadTextResponse {
|
||||
success: false,
|
||||
content: None,
|
||||
size: None,
|
||||
encoding: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
Err(error) => SftpReadTextResponse {
|
||||
success: false,
|
||||
content: None,
|
||||
size: None,
|
||||
encoding: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_write_text(
|
||||
pub async fn sftp_write_text(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
content: String,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_write_text(&sftp_id, &path, &content))
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_write_text(&sftp_id, &path, &content)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -410,6 +532,7 @@ pub fn ssh_forward_local(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
forward_id: Option<String>,
|
||||
local_bind_host: Option<String>,
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
@@ -417,6 +540,7 @@ pub fn ssh_forward_local(
|
||||
forward(state.ssh.forward_local(
|
||||
&connection_id,
|
||||
forward_id,
|
||||
local_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port,
|
||||
@@ -428,6 +552,7 @@ pub fn ssh_forward_remote(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
forward_id: Option<String>,
|
||||
remote_bind_host: Option<String>,
|
||||
remote_port: u16,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
@@ -435,6 +560,7 @@ pub fn ssh_forward_remote(
|
||||
forward(state.ssh.forward_remote(
|
||||
&connection_id,
|
||||
forward_id,
|
||||
remote_bind_host.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
remote_port,
|
||||
local_host,
|
||||
local_port,
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct AppRuntimeInfo {
|
||||
ssh_backend: String,
|
||||
data_path: String,
|
||||
credential_path: String,
|
||||
log_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -46,6 +47,9 @@ pub fn app_runtime_info(app: AppHandle) -> Result<AppRuntimeInfo, String> {
|
||||
ssh_backend: "libssh2 / ssh2 0.9".to_string(),
|
||||
data_path: data_path.display().to_string(),
|
||||
credential_path,
|
||||
log_path: crate::infrastructure::file_log::log_file_path()
|
||||
.display()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,6 +63,18 @@ pub async fn toggle_maximize(window: WebviewWindow) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_fullscreen(window: WebviewWindow, fullscreen: bool) -> Result<(), String> {
|
||||
window
|
||||
.set_fullscreen(fullscreen)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_fullscreen(window: WebviewWindow) -> Result<bool, String> {
|
||||
window.is_fullscreen().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clipboard_read_text() -> Result<String, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
||||
@@ -84,3 +100,8 @@ pub fn toggle_devtools(app: AppHandle) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_native_menu_locale(app: AppHandle, locale: String) -> Result<String, String> {
|
||||
crate::native_menu::apply_locale(&app, &locale)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Size-capped application log file for release (and optional dev dual-write).
|
||||
//!
|
||||
//! Path: `{data_local}/opsxshell/logs/opsxshell.log`
|
||||
//! Rotate at 10 MiB; keep `.1` and `.2` (3 files total).
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
const MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const BACKUPS: usize = 2;
|
||||
|
||||
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Directory + active log file path used by this process.
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
LOG_PATH
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(default_log_file_path)
|
||||
}
|
||||
|
||||
pub fn default_log_dir() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("opsxshell")
|
||||
.join("logs")
|
||||
}
|
||||
|
||||
fn default_log_file_path() -> PathBuf {
|
||||
default_log_dir().join("opsxshell.log")
|
||||
}
|
||||
|
||||
/// Install tracing to a rolling file (and stderr in debug builds).
|
||||
/// Safe to call once at process start; subsequent calls are no-ops if already set.
|
||||
pub fn init() {
|
||||
if tracing::dispatcher::has_been_set() {
|
||||
return;
|
||||
}
|
||||
|
||||
let path = default_log_file_path();
|
||||
let _ = LOG_PATH.set(path.clone());
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
if cfg!(debug_assertions) {
|
||||
tracing_subscriber::EnvFilter::new("info")
|
||||
} else {
|
||||
tracing_subscriber::EnvFilter::new("warn")
|
||||
}
|
||||
});
|
||||
|
||||
let file = match SizeRollingWriter::open(&path, MAX_BYTES, BACKUPS) {
|
||||
Ok(w) => w,
|
||||
Err(error) => {
|
||||
// Last resort: stderr only so the app still starts.
|
||||
eprintln!(
|
||||
"opsxshell: failed to open log file {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.with_ansi(cfg!(debug_assertions))
|
||||
.init();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.with_ansi(false);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
use tracing_subscriber::fmt::writer::MakeWriterExt;
|
||||
subscriber.with_writer(std::io::stderr.and(file)).init();
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
subscriber.with_writer(file).init();
|
||||
}
|
||||
|
||||
tracing::info!(path = %path.display(), "application log ready");
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SizeRollingWriter {
|
||||
state: Arc<Mutex<RollingState>>,
|
||||
}
|
||||
|
||||
struct RollingState {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
len: u64,
|
||||
max_bytes: u64,
|
||||
backups: usize,
|
||||
}
|
||||
|
||||
impl SizeRollingWriter {
|
||||
fn open(path: &Path, max_bytes: u64, backups: usize) -> io::Result<Self> {
|
||||
let file = open_append(path)?;
|
||||
let len = file.metadata()?.len();
|
||||
Ok(Self {
|
||||
state: Arc::new(Mutex::new(RollingState {
|
||||
path: path.to_path_buf(),
|
||||
file: Some(file),
|
||||
len,
|
||||
max_bytes,
|
||||
backups,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn open_append(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
impl RollingState {
|
||||
fn rotate(&mut self) -> io::Result<()> {
|
||||
if let Some(mut file) = self.file.take() {
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
}
|
||||
|
||||
// Shift: .(n-1) -> .n … then current -> .1
|
||||
for i in (1..=self.backups).rev() {
|
||||
let from = if i == 1 {
|
||||
self.path.clone()
|
||||
} else {
|
||||
backup_path(&self.path, i - 1)
|
||||
};
|
||||
let to = backup_path(&self.path, i);
|
||||
if from.exists() {
|
||||
let _ = fs::remove_file(&to);
|
||||
let _ = fs::rename(&from, &to);
|
||||
}
|
||||
}
|
||||
|
||||
self.file = Some(open_append(&self.path)?);
|
||||
self.len = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> io::Result<&mut File> {
|
||||
if self.file.is_none() {
|
||||
self.file = Some(open_append(&self.path)?);
|
||||
self.len = self
|
||||
.file
|
||||
.as_ref()
|
||||
.and_then(|f| f.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
self.file
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("log file unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_path(path: &Path, index: usize) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_os_string();
|
||||
s.push(format!(".{index}"));
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
impl Write for SizeRollingWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("log lock poisoned"))?;
|
||||
if state.len > 0 && state.len.saturating_add(buf.len() as u64) > state.max_bytes {
|
||||
state.rotate()?;
|
||||
}
|
||||
let n = state.writer()?.write(buf)?;
|
||||
state.len = state.len.saturating_add(n as u64);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("log lock poisoned"))?;
|
||||
if let Some(file) = state.file.as_mut() {
|
||||
file.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SizeRollingWriter {
|
||||
type Writer = SizeRollingWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn rotates_when_over_max_bytes() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"opsxshell-log-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("opsxshell.log");
|
||||
|
||||
let mut writer = SizeRollingWriter::open(&path, 64, 2).unwrap();
|
||||
writer
|
||||
.write_all(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
.unwrap();
|
||||
writer.write_all(b"bbbb").unwrap();
|
||||
writer.flush().unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert!(backup_path(&path, 1).exists());
|
||||
let current = fs::read_to_string(&path).unwrap();
|
||||
assert!(current.contains("bbbb"));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
//! Adapters for filesystem, SQLite, keychain, SSH transport, SFTP and PTY.
|
||||
//! They must not be called directly by Tauri commands.
|
||||
|
||||
pub mod file_log;
|
||||
+19
-83
@@ -1,14 +1,13 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
mod application;
|
||||
mod 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" => {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Native application menu labels (File / Edit / View / …).
|
||||
//!
|
||||
//! React i18n does not reach the Tauri menu bar, so strings live here and are
|
||||
//! rebuilt when the UI language changes or when the process starts.
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
||||
AppHandle, Manager, Runtime,
|
||||
};
|
||||
|
||||
use crate::application::AppState;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MenuCopy {
|
||||
file: &'static str,
|
||||
edit: &'static str,
|
||||
view: &'static str,
|
||||
window: &'static str,
|
||||
new_session: &'static str,
|
||||
quick_connect: &'static str,
|
||||
local_terminal: &'static str,
|
||||
import_sessions: &'static str,
|
||||
export_sessions: &'static str,
|
||||
settings: &'static str,
|
||||
devtools: &'static str,
|
||||
undo: &'static str,
|
||||
redo: &'static str,
|
||||
cut: &'static str,
|
||||
copy: &'static str,
|
||||
paste: &'static str,
|
||||
select_all: &'static str,
|
||||
minimize: &'static str,
|
||||
maximize: &'static str,
|
||||
close_window: &'static str,
|
||||
services: &'static str,
|
||||
hide: &'static str,
|
||||
hide_others: &'static str,
|
||||
show_all: &'static str,
|
||||
}
|
||||
|
||||
const EN: MenuCopy = MenuCopy {
|
||||
file: "File",
|
||||
edit: "Edit",
|
||||
view: "View",
|
||||
window: "Window",
|
||||
new_session: "New Session",
|
||||
quick_connect: "Quick Connect",
|
||||
local_terminal: "Local Terminal",
|
||||
import_sessions: "Import Sessions",
|
||||
export_sessions: "Export Sessions",
|
||||
settings: "Settings",
|
||||
devtools: "Developer Tools",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
cut: "Cut",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
select_all: "Select All",
|
||||
minimize: "Minimize",
|
||||
maximize: "Zoom",
|
||||
close_window: "Close Window",
|
||||
services: "Services",
|
||||
hide: "Hide OpsXShell",
|
||||
hide_others: "Hide Others",
|
||||
show_all: "Show All",
|
||||
};
|
||||
|
||||
const ZH_CN: MenuCopy = MenuCopy {
|
||||
file: "文件",
|
||||
edit: "编辑",
|
||||
view: "视图",
|
||||
window: "窗口",
|
||||
new_session: "新建会话",
|
||||
quick_connect: "快速连接",
|
||||
local_terminal: "本地终端",
|
||||
import_sessions: "导入会话",
|
||||
export_sessions: "导出会话",
|
||||
settings: "设置",
|
||||
devtools: "开发者工具",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
cut: "剪切",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
select_all: "全选",
|
||||
minimize: "最小化",
|
||||
maximize: "缩放",
|
||||
close_window: "关闭窗口",
|
||||
services: "服务",
|
||||
hide: "隐藏 OpsXShell",
|
||||
hide_others: "隐藏其他",
|
||||
show_all: "全部显示",
|
||||
};
|
||||
|
||||
const ZH_TW: MenuCopy = MenuCopy {
|
||||
file: "檔案",
|
||||
edit: "編輯",
|
||||
view: "檢視",
|
||||
window: "視窗",
|
||||
new_session: "新建工作階段",
|
||||
quick_connect: "快速連線",
|
||||
local_terminal: "本機終端機",
|
||||
import_sessions: "匯入工作階段",
|
||||
export_sessions: "匯出工作階段",
|
||||
settings: "設定",
|
||||
devtools: "開發者工具",
|
||||
undo: "復原",
|
||||
redo: "重做",
|
||||
cut: "剪下",
|
||||
copy: "複製",
|
||||
paste: "貼上",
|
||||
select_all: "全選",
|
||||
minimize: "最小化",
|
||||
maximize: "縮放",
|
||||
close_window: "關閉視窗",
|
||||
services: "服務",
|
||||
hide: "隱藏 OpsXShell",
|
||||
hide_others: "隱藏其他",
|
||||
show_all: "全部顯示",
|
||||
};
|
||||
|
||||
const JA: MenuCopy = MenuCopy {
|
||||
file: "ファイル",
|
||||
edit: "編集",
|
||||
view: "表示",
|
||||
window: "ウィンドウ",
|
||||
new_session: "新しいセッション",
|
||||
quick_connect: "クイック接続",
|
||||
local_terminal: "ローカルターミナル",
|
||||
import_sessions: "セッションをインポート",
|
||||
export_sessions: "セッションをエクスポート",
|
||||
settings: "設定",
|
||||
devtools: "開発者ツール",
|
||||
undo: "元に戻す",
|
||||
redo: "やり直す",
|
||||
cut: "切り取り",
|
||||
copy: "コピー",
|
||||
paste: "貼り付け",
|
||||
select_all: "すべて選択",
|
||||
minimize: "最小化",
|
||||
maximize: "ズーム",
|
||||
close_window: "ウィンドウを閉じる",
|
||||
services: "サービス",
|
||||
hide: "OpsXShell を隠す",
|
||||
hide_others: "ほかを隠す",
|
||||
show_all: "すべてを表示",
|
||||
};
|
||||
|
||||
const KO: MenuCopy = MenuCopy {
|
||||
file: "파일",
|
||||
edit: "편집",
|
||||
view: "보기",
|
||||
window: "창",
|
||||
new_session: "새 세션",
|
||||
quick_connect: "빠른 연결",
|
||||
local_terminal: "로컬 터미널",
|
||||
import_sessions: "세션 가져오기",
|
||||
export_sessions: "세션 내보내기",
|
||||
settings: "설정",
|
||||
devtools: "개발자 도구",
|
||||
undo: "실행 취소",
|
||||
redo: "다시 실행",
|
||||
cut: "잘라내기",
|
||||
copy: "복사",
|
||||
paste: "붙여넣기",
|
||||
select_all: "모두 선택",
|
||||
minimize: "최소화",
|
||||
maximize: "확대/축소",
|
||||
close_window: "창 닫기",
|
||||
services: "서비스",
|
||||
hide: "OpsXShell 가리기",
|
||||
hide_others: "기타 가리기",
|
||||
show_all: "모두 보기",
|
||||
};
|
||||
|
||||
const VI: MenuCopy = MenuCopy {
|
||||
file: "Tệp",
|
||||
edit: "Chỉnh sửa",
|
||||
view: "Xem",
|
||||
window: "Cửa sổ",
|
||||
new_session: "Phiên mới",
|
||||
quick_connect: "Kết nối nhanh",
|
||||
local_terminal: "Terminal cục bộ",
|
||||
import_sessions: "Nhập phiên",
|
||||
export_sessions: "Xuất phiên",
|
||||
settings: "Cài đặt",
|
||||
devtools: "Công cụ nhà phát triển",
|
||||
undo: "Hoàn tác",
|
||||
redo: "Làm lại",
|
||||
cut: "Cắt",
|
||||
copy: "Sao chép",
|
||||
paste: "Dán",
|
||||
select_all: "Chọn tất cả",
|
||||
minimize: "Thu nhỏ",
|
||||
maximize: "Phóng to",
|
||||
close_window: "Đóng cửa sổ",
|
||||
services: "Dịch vụ",
|
||||
hide: "Ẩn OpsXShell",
|
||||
hide_others: "Ẩn ứng dụng khác",
|
||||
show_all: "Hiện tất cả",
|
||||
};
|
||||
|
||||
const RU: MenuCopy = MenuCopy {
|
||||
file: "Файл",
|
||||
edit: "Правка",
|
||||
view: "Вид",
|
||||
window: "Окно",
|
||||
new_session: "Новый сеанс",
|
||||
quick_connect: "Быстрое подключение",
|
||||
local_terminal: "Локальный терминал",
|
||||
import_sessions: "Импорт сеансов",
|
||||
export_sessions: "Экспорт сеансов",
|
||||
settings: "Настройки",
|
||||
devtools: "Инструменты разработчика",
|
||||
undo: "Отменить",
|
||||
redo: "Повторить",
|
||||
cut: "Вырезать",
|
||||
copy: "Копировать",
|
||||
paste: "Вставить",
|
||||
select_all: "Выделить всё",
|
||||
minimize: "Свернуть",
|
||||
maximize: "Масштаб",
|
||||
close_window: "Закрыть окно",
|
||||
services: "Службы",
|
||||
hide: "Скрыть OpsXShell",
|
||||
hide_others: "Скрыть остальные",
|
||||
show_all: "Показать все",
|
||||
};
|
||||
|
||||
pub fn normalize_locale(raw: &str) -> &'static str {
|
||||
let lower = raw.trim().to_ascii_lowercase().replace('_', "-");
|
||||
if lower.starts_with("zh-tw")
|
||||
|| lower.starts_with("zh-hk")
|
||||
|| lower.starts_with("zh-mo")
|
||||
|| lower.contains("hant")
|
||||
{
|
||||
return "zh-TW";
|
||||
}
|
||||
if lower.starts_with("zh") {
|
||||
return "zh-CN";
|
||||
}
|
||||
if lower.starts_with("ja") {
|
||||
return "ja";
|
||||
}
|
||||
if lower.starts_with("ko") {
|
||||
return "ko";
|
||||
}
|
||||
if lower.starts_with("vi") {
|
||||
return "vi";
|
||||
}
|
||||
if lower.starts_with("ru") {
|
||||
return "ru";
|
||||
}
|
||||
if lower.starts_with("en") {
|
||||
return "en";
|
||||
}
|
||||
"en"
|
||||
}
|
||||
|
||||
fn copy_for(locale: &str) -> MenuCopy {
|
||||
match normalize_locale(locale) {
|
||||
"zh-CN" => ZH_CN,
|
||||
"zh-TW" => ZH_TW,
|
||||
"ja" => JA,
|
||||
"ko" => KO,
|
||||
"vi" => VI,
|
||||
"ru" => RU,
|
||||
_ => EN,
|
||||
}
|
||||
}
|
||||
|
||||
/// OS locale only — settings storage is unavailable while `.menu()` runs
|
||||
/// (PathResolver is not managed yet).
|
||||
pub fn resolve_startup_locale(_app: &AppHandle) -> String {
|
||||
detect_os_locale()
|
||||
}
|
||||
|
||||
/// After setup: prefer saved UI language, else keep OS detection.
|
||||
pub fn resolve_saved_or_os_locale(app: &AppHandle) -> String {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(Some(value)) = state.storage.get_setting(app, "language") {
|
||||
if let Some(raw) = value.as_str() {
|
||||
return normalize_locale(raw).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
detect_os_locale()
|
||||
}
|
||||
|
||||
fn detect_os_locale() -> String {
|
||||
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() && trimmed != "C" && trimmed != "POSIX" {
|
||||
return normalize_locale(trimmed).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
pub fn build_menu<R: Runtime>(app: &AppHandle<R>, locale: &str) -> tauri::Result<Menu<R>> {
|
||||
let t = copy_for(locale);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
||||
.about(None)
|
||||
.separator()
|
||||
.services_with_text(t.services)
|
||||
.separator()
|
||||
.hide_with_text(t.hide)
|
||||
.hide_others_with_text(t.hide_others)
|
||||
.show_all_with_text(t.show_all)
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let app_menu = SubmenuBuilder::new(app, "OpsXShell")
|
||||
.about(None)
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?;
|
||||
|
||||
let new_session = MenuItemBuilder::with_id("new-session", t.new_session)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(app)?;
|
||||
let quick_connect = MenuItemBuilder::with_id("quick-connect", t.quick_connect)
|
||||
.accelerator("CmdOrCtrl+K")
|
||||
.build(app)?;
|
||||
let local_terminal = MenuItemBuilder::with_id("local-terminal", t.local_terminal)
|
||||
.accelerator("CmdOrCtrl+Shift+T")
|
||||
.build(app)?;
|
||||
let import_sessions =
|
||||
MenuItemBuilder::with_id("import-sessions", t.import_sessions).build(app)?;
|
||||
let export_sessions =
|
||||
MenuItemBuilder::with_id("export-sessions", t.export_sessions).build(app)?;
|
||||
let file_menu = SubmenuBuilder::new(app, t.file)
|
||||
.items(&[&new_session, &quick_connect, &local_terminal])
|
||||
.separator()
|
||||
.items(&[&import_sessions, &export_sessions])
|
||||
.build()?;
|
||||
|
||||
// Keep native edit actions. On macOS WKWebView resolves Cmd+C/X/V/A through
|
||||
// these selectors; omitting the menu makes ordinary React inputs unable to paste.
|
||||
let edit_menu = SubmenuBuilder::new(app, t.edit)
|
||||
.undo_with_text(t.undo)
|
||||
.redo_with_text(t.redo)
|
||||
.separator()
|
||||
.cut_with_text(t.cut)
|
||||
.copy_with_text(t.copy)
|
||||
.paste_with_text(t.paste)
|
||||
.select_all_with_text(t.select_all)
|
||||
.build()?;
|
||||
|
||||
let settings = MenuItemBuilder::with_id("open-settings", t.settings)
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app)?;
|
||||
let devtools = MenuItemBuilder::with_id("toggle-devtools", t.devtools)
|
||||
.accelerator(if cfg!(target_os = "macos") {
|
||||
"CmdOrCtrl+Alt+I"
|
||||
} else {
|
||||
"F12"
|
||||
})
|
||||
.build(app)?;
|
||||
let view_menu = SubmenuBuilder::new(app, t.view)
|
||||
.item(&settings)
|
||||
.separator()
|
||||
.item(&devtools)
|
||||
.build()?;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
let window_menu = SubmenuBuilder::new(app, t.window)
|
||||
.minimize_with_text(t.minimize)
|
||||
.maximize_with_text(t.maximize)
|
||||
.separator()
|
||||
.close_window_with_text(t.close_window)
|
||||
.build()?;
|
||||
|
||||
let menu = MenuBuilder::new(app).items(&[&app_menu, &file_menu, &edit_menu, &view_menu]);
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
let menu = menu.item(&window_menu);
|
||||
menu.build()
|
||||
}
|
||||
|
||||
pub fn apply_locale(app: &AppHandle, locale: &str) -> Result<String, String> {
|
||||
let normalized = normalize_locale(locale).to_string();
|
||||
let menu = build_menu(app, &normalized).map_err(|error| error.to_string())?;
|
||||
app.set_menu(menu).map_err(|error| error.to_string())?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_os_and_ui_tags() {
|
||||
assert_eq!(normalize_locale("en_US.UTF-8"), "en");
|
||||
assert_eq!(normalize_locale("zh_CN.UTF-8"), "zh-CN");
|
||||
assert_eq!(normalize_locale("zh-TW"), "zh-TW");
|
||||
assert_eq!(normalize_locale("ja_JP"), "ja");
|
||||
assert_eq!(normalize_locale("pt-BR"), "en");
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ fn validate_provider(provider: &AiProviderConfig, api_key: Option<&str>) -> Resu
|
||||
if provider.id.trim().is_empty() || provider.name.trim().is_empty() {
|
||||
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(())
|
||||
|
||||
@@ -33,6 +33,8 @@ pub enum DesktopInput {
|
||||
ClipboardSet { text: String },
|
||||
/// Place a file into the host Downloads folder for RDP RDPDR.
|
||||
UploadFile { name: String, data: Vec<u8> },
|
||||
/// Request remote desktop resize via RDP Display Control (ignored by VNC).
|
||||
Resize { width: u16, height: u16 },
|
||||
}
|
||||
|
||||
/// Coalesce dirty rects and rate-limit IPC emits (~30 FPS).
|
||||
@@ -69,6 +71,11 @@ impl FrameEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/// Drop coalesced dirty rects (e.g. after DisplayControl resize / new framebuffer).
|
||||
pub fn clear_pending(&mut self) {
|
||||
self.pending = None;
|
||||
}
|
||||
|
||||
pub fn flush_if_due(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
@@ -86,6 +93,16 @@ impl FrameEmitter {
|
||||
let Some((left, top, right, bottom)) = self.pending.take() else {
|
||||
return;
|
||||
};
|
||||
// Clamp to current framebuffer — stale rects after resize must not be extracted.
|
||||
if fb_width == 0 || fb_height == 0 {
|
||||
return;
|
||||
}
|
||||
let max_x = fb_width.saturating_sub(1);
|
||||
let max_y = fb_height.saturating_sub(1);
|
||||
let left = left.min(max_x);
|
||||
let top = top.min(max_y);
|
||||
let right = right.min(max_x).max(left);
|
||||
let bottom = bottom.min(max_y).max(top);
|
||||
let width = right.saturating_sub(left).saturating_add(1);
|
||||
let height = bottom.saturating_sub(top).saturating_add(1);
|
||||
let Some(rgba) = rgba_extractor(left, top, width, height) else {
|
||||
@@ -206,11 +223,15 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
|
||||
}
|
||||
let mut out = Vec::with_capacity(queued.len());
|
||||
let mut pending_move: Option<DesktopInput> = None;
|
||||
let mut pending_resize: Option<DesktopInput> = None;
|
||||
for event in queued.drain(..) {
|
||||
match &event {
|
||||
DesktopInput::Pointer { moving: true, .. } => {
|
||||
pending_move = Some(event);
|
||||
}
|
||||
DesktopInput::Resize { .. } => {
|
||||
pending_resize = Some(event);
|
||||
}
|
||||
_ => {
|
||||
if let Some(mv) = pending_move.take() {
|
||||
out.push(mv);
|
||||
@@ -222,6 +243,9 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
|
||||
if let Some(mv) = pending_move {
|
||||
out.push(mv);
|
||||
}
|
||||
if let Some(resize) = pending_resize {
|
||||
out.push(resize);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,13 @@ impl RdpService {
|
||||
self.push_input(session_id, DesktopInput::UploadFile { name, data })
|
||||
}
|
||||
|
||||
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
|
||||
if !(200..=8192).contains(&width) || !(200..=8192).contains(&height) {
|
||||
return Err("分辨率超出允许范围 (200–8192)".into());
|
||||
}
|
||||
self.push_input(session_id, DesktopInput::Resize { width, height })
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||
if let Some(session) = self
|
||||
.sessions
|
||||
|
||||
@@ -13,8 +13,14 @@ use std::{
|
||||
use anyhow::Context as _;
|
||||
use ironrdp::cliprdr::backend::ClipboardMessage;
|
||||
use ironrdp::cliprdr::{Cliprdr, CliprdrClient};
|
||||
use ironrdp::connector::{self, ConnectionResult, Credentials};
|
||||
use ironrdp::connector::connection_activation::{
|
||||
ConnectionActivationFactory, ConnectionActivationState,
|
||||
};
|
||||
use ironrdp::connector::{self, ConnectionResult, Credentials, Sequence as _};
|
||||
use ironrdp::core::WriteBuf;
|
||||
use ironrdp::displaycontrol::client::DisplayControlClient;
|
||||
use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
|
||||
use ironrdp::dvc::DrdynvcClient;
|
||||
use ironrdp::pdu::gcc::KeyboardType;
|
||||
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
|
||||
use ironrdp::pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags};
|
||||
@@ -25,8 +31,10 @@ use ironrdp::pdu::rdp::headers::ShareDataPdu;
|
||||
use ironrdp::pdu::rdp::suppress_output::SuppressOutputPdu;
|
||||
use ironrdp::rdpdr::Rdpdr;
|
||||
use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
|
||||
use ironrdp::session::fast_path::ProcessorBuilder;
|
||||
use ironrdp::session::image::DecodedImage;
|
||||
use ironrdp::session::{ActiveStage, ActiveStageBuilder, ActiveStageOutput};
|
||||
use ironrdp_bulk::BulkCompressor;
|
||||
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
|
||||
use tauri::AppHandle;
|
||||
use tokio_rustls::rustls;
|
||||
@@ -174,8 +182,15 @@ fn run_session_inner(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let fb_width = connection_result.desktop_size.width;
|
||||
let fb_height = connection_result.desktop_size.height;
|
||||
let mut fb_width = connection_result.desktop_size.width;
|
||||
let mut fb_height = connection_result.desktop_size.height;
|
||||
let io_channel_id = connection_result.io_channel_id;
|
||||
let user_channel_id = connection_result.user_channel_id;
|
||||
let compression_type = connection_result.compression_type;
|
||||
let mut share_id = connection_result.share_id;
|
||||
let activation_factory = connection_result.activation_factory;
|
||||
let mut enable_server_pointer = connection_result.enable_server_pointer;
|
||||
let mut pointer_software_rendering = connection_result.pointer_software_rendering;
|
||||
|
||||
let mut image = DecodedImage::new(
|
||||
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
|
||||
@@ -185,13 +200,13 @@ fn run_session_inner(
|
||||
|
||||
let mut active_stage = ActiveStageBuilder {
|
||||
static_channels: connection_result.static_channels,
|
||||
user_channel_id: connection_result.user_channel_id,
|
||||
io_channel_id: connection_result.io_channel_id,
|
||||
user_channel_id,
|
||||
io_channel_id,
|
||||
message_channel_id: connection_result.message_channel_id,
|
||||
share_id: connection_result.share_id,
|
||||
compression_type: connection_result.compression_type,
|
||||
enable_server_pointer: connection_result.enable_server_pointer,
|
||||
pointer_software_rendering: connection_result.pointer_software_rendering,
|
||||
share_id,
|
||||
compression_type,
|
||||
enable_server_pointer,
|
||||
pointer_software_rendering,
|
||||
}
|
||||
.build();
|
||||
|
||||
@@ -219,6 +234,7 @@ fn run_session_inner(
|
||||
|
||||
// Avoid shipping a full black framebuffer over IPC; wait for real GraphicsUpdate.
|
||||
let mut frame_emitter = FrameEmitter::new("rdp:frame", session_id);
|
||||
let mut pending_resize: Option<(u16, u16)> = None;
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
@@ -236,6 +252,23 @@ fn run_session_inner(
|
||||
&clip_outbound,
|
||||
&want_paste_inject,
|
||||
&mut frame_emitter,
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut share_id,
|
||||
&mut enable_server_pointer,
|
||||
&mut pointer_software_rendering,
|
||||
&activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
&mut pending_resize,
|
||||
)?;
|
||||
try_pending_resize(
|
||||
&mut active_stage,
|
||||
&mut framed,
|
||||
&mut pending_resize,
|
||||
fb_width,
|
||||
fb_height,
|
||||
)?;
|
||||
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
||||
if pending_paste_inject.swap(false, Ordering::Relaxed) {
|
||||
@@ -246,6 +279,15 @@ fn run_session_inner(
|
||||
&mut image,
|
||||
&mut framed,
|
||||
&mut frame_emitter,
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut share_id,
|
||||
&mut enable_server_pointer,
|
||||
&mut pointer_software_rendering,
|
||||
&activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
)?;
|
||||
}
|
||||
drive_cliprdr_timeouts(&mut active_stage, &mut framed)?;
|
||||
@@ -264,9 +306,31 @@ fn run_session_inner(
|
||||
Err(error) => return Err(anyhow::Error::new(error).context("读取 RDP 帧失败")),
|
||||
};
|
||||
|
||||
let outputs = active_stage
|
||||
.process(&mut image, action, &payload)
|
||||
.map_err(|e| anyhow::anyhow!("处理 RDP 帧失败: {e}"))?;
|
||||
let outputs = match active_stage.process(&mut image, action, &payload) {
|
||||
Ok(outputs) => outputs,
|
||||
Err(error) => {
|
||||
let text = error.to_string();
|
||||
if text.contains("bulk decompression failed") {
|
||||
// Prefer surviving a single bad compressed PDU over killing the session
|
||||
// (history desync after resize is the usual trigger).
|
||||
warn!(%text, "RDP bulk decompression failed — resetting FastPath decompressor");
|
||||
frame_emitter.clear_pending();
|
||||
active_stage.set_fastpath_processor(
|
||||
ProcessorBuilder {
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
share_id,
|
||||
enable_server_pointer,
|
||||
pointer_software_rendering,
|
||||
bulk_decompressor: build_bulk_decompressor(compression_type),
|
||||
}
|
||||
.build(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(map_fast_path_error(error));
|
||||
}
|
||||
};
|
||||
|
||||
// Backend callbacks during `process` may queue CLIPRDR replies (paste / format data).
|
||||
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
|
||||
@@ -278,6 +342,15 @@ fn run_session_inner(
|
||||
&mut image,
|
||||
&mut framed,
|
||||
&mut frame_emitter,
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut share_id,
|
||||
&mut enable_server_pointer,
|
||||
&mut pointer_software_rendering,
|
||||
&activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -285,9 +358,17 @@ fn run_session_inner(
|
||||
app,
|
||||
session_id,
|
||||
&mut framed,
|
||||
&image,
|
||||
fb_width,
|
||||
fb_height,
|
||||
&mut active_stage,
|
||||
&mut image,
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut share_id,
|
||||
&mut enable_server_pointer,
|
||||
&mut pointer_software_rendering,
|
||||
&activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
outputs,
|
||||
&mut frame_emitter,
|
||||
)? {
|
||||
@@ -368,6 +449,16 @@ fn drain_and_send_input(
|
||||
clip_outbound: &Mutex<Vec<ClipboardMessage>>,
|
||||
want_paste_inject: &AtomicBool,
|
||||
frame_emitter: &mut FrameEmitter,
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
share_id: &mut u32,
|
||||
enable_server_pointer: &mut bool,
|
||||
pointer_software_rendering: &mut bool,
|
||||
activation_factory: &ConnectionActivationFactory,
|
||||
io_channel_id: u16,
|
||||
user_channel_id: u16,
|
||||
compression_type: Option<CompressionType>,
|
||||
pending_resize: &mut Option<(u16, u16)>,
|
||||
) -> anyhow::Result<()> {
|
||||
let queued = {
|
||||
let mut guard = input
|
||||
@@ -379,8 +470,6 @@ fn drain_and_send_input(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let fb_width = image.width();
|
||||
let fb_height = image.height();
|
||||
let mut events = Vec::new();
|
||||
for event in queued {
|
||||
match event {
|
||||
@@ -403,8 +492,8 @@ fn drain_and_send_input(
|
||||
session_id,
|
||||
"connected",
|
||||
Some(error),
|
||||
Some(fb_width),
|
||||
Some(fb_height),
|
||||
Some(*fb_width),
|
||||
Some(*fb_height),
|
||||
);
|
||||
} else {
|
||||
emit_status(
|
||||
@@ -413,13 +502,24 @@ fn drain_and_send_input(
|
||||
session_id,
|
||||
"connected",
|
||||
Some("已放入共享盘「下载」,远端此电脑可打开".into()),
|
||||
Some(fb_width),
|
||||
Some(fb_height),
|
||||
Some(*fb_width),
|
||||
Some(*fb_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopInput::Resize { width, height } => {
|
||||
queue_or_send_resize(
|
||||
active_stage,
|
||||
framed,
|
||||
pending_resize,
|
||||
*fb_width,
|
||||
*fb_height,
|
||||
width,
|
||||
height,
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
if let Some(fast) = map_desktop_input(other) {
|
||||
events.push(fast);
|
||||
@@ -438,9 +538,17 @@ fn drain_and_send_input(
|
||||
app,
|
||||
session_id,
|
||||
framed,
|
||||
active_stage,
|
||||
image,
|
||||
fb_width,
|
||||
fb_height,
|
||||
share_id,
|
||||
enable_server_pointer,
|
||||
pointer_software_rendering,
|
||||
activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
outputs,
|
||||
frame_emitter,
|
||||
)?;
|
||||
@@ -454,6 +562,15 @@ fn inject_remote_paste(
|
||||
image: &mut DecodedImage,
|
||||
framed: &mut UpgradedFramed,
|
||||
frame_emitter: &mut FrameEmitter,
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
share_id: &mut u32,
|
||||
enable_server_pointer: &mut bool,
|
||||
pointer_software_rendering: &mut bool,
|
||||
activation_factory: &ConnectionActivationFactory,
|
||||
io_channel_id: u16,
|
||||
user_channel_id: u16,
|
||||
compression_type: Option<CompressionType>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Left Ctrl (0x1D) + V (0x2F) — Windows paste after CLIPRDR FormatList accepted.
|
||||
let events = [
|
||||
@@ -462,8 +579,6 @@ fn inject_remote_paste(
|
||||
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x2F),
|
||||
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x1D),
|
||||
];
|
||||
let fb_width = image.width();
|
||||
let fb_height = image.height();
|
||||
let outputs = active_stage
|
||||
.process_fastpath_input(image, &events)
|
||||
.map_err(|e| anyhow::anyhow!("注入粘贴失败: {e}"))?;
|
||||
@@ -471,9 +586,17 @@ fn inject_remote_paste(
|
||||
app,
|
||||
session_id,
|
||||
framed,
|
||||
active_stage,
|
||||
image,
|
||||
fb_width,
|
||||
fb_height,
|
||||
share_id,
|
||||
enable_server_pointer,
|
||||
pointer_software_rendering,
|
||||
activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
outputs,
|
||||
frame_emitter,
|
||||
)?;
|
||||
@@ -535,7 +658,9 @@ fn map_desktop_input(event: DesktopInput) -> Option<FastPathInputEvent> {
|
||||
}
|
||||
Some(FastPathInputEvent::KeyboardEvent(flags, scan))
|
||||
}
|
||||
DesktopInput::ClipboardSet { .. } | DesktopInput::UploadFile { .. } => None,
|
||||
DesktopInput::ClipboardSet { .. }
|
||||
| DesktopInput::UploadFile { .. }
|
||||
| DesktopInput::Resize { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,13 +753,22 @@ fn apply_outputs(
|
||||
app: &AppHandle,
|
||||
session_id: &str,
|
||||
framed: &mut UpgradedFramed,
|
||||
image: &DecodedImage,
|
||||
fb_width: u16,
|
||||
fb_height: u16,
|
||||
active_stage: &mut ActiveStage,
|
||||
image: &mut DecodedImage,
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
share_id: &mut u32,
|
||||
enable_server_pointer: &mut bool,
|
||||
pointer_software_rendering: &mut bool,
|
||||
activation_factory: &ConnectionActivationFactory,
|
||||
io_channel_id: u16,
|
||||
user_channel_id: u16,
|
||||
compression_type: Option<CompressionType>,
|
||||
outputs: Vec<ActiveStageOutput>,
|
||||
frame_emitter: &mut FrameEmitter,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut terminate = false;
|
||||
let mut need_reactivate = false;
|
||||
for out in outputs {
|
||||
match out {
|
||||
ActiveStageOutput::ResponseFrame(frame) => {
|
||||
@@ -649,15 +783,239 @@ fn apply_outputs(
|
||||
terminate = true;
|
||||
}
|
||||
ActiveStageOutput::DeactivateAll => {
|
||||
warn!(session_id, "RDP DeactivateAll received");
|
||||
need_reactivate = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = (app, image, fb_width, fb_height);
|
||||
if need_reactivate {
|
||||
frame_emitter.clear_pending();
|
||||
reactivate_after_deactivate(
|
||||
app,
|
||||
session_id,
|
||||
framed,
|
||||
active_stage,
|
||||
image,
|
||||
fb_width,
|
||||
fb_height,
|
||||
share_id,
|
||||
enable_server_pointer,
|
||||
pointer_software_rendering,
|
||||
activation_factory,
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
compression_type,
|
||||
)?;
|
||||
}
|
||||
Ok(terminate)
|
||||
}
|
||||
|
||||
fn queue_or_send_resize(
|
||||
active_stage: &mut ActiveStage,
|
||||
framed: &mut UpgradedFramed,
|
||||
pending_resize: &mut Option<(u16, u16)>,
|
||||
fb_width: u16,
|
||||
fb_height: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
let (adj_w, adj_h) =
|
||||
MonitorLayoutEntry::adjust_display_size(u32::from(width), u32::from(height));
|
||||
let width = u16::try_from(adj_w).unwrap_or(fb_width);
|
||||
let height = u16::try_from(adj_h).unwrap_or(fb_height);
|
||||
if width == fb_width && height == fb_height {
|
||||
*pending_resize = None;
|
||||
return Ok(());
|
||||
}
|
||||
match active_stage.encode_resize(u32::from(width), u32::from(height), Some(100), None) {
|
||||
Some(Ok(frame)) => {
|
||||
*pending_resize = None;
|
||||
if !frame.is_empty() {
|
||||
framed
|
||||
.write_all(&frame)
|
||||
.context("写入 DisplayControl 分辨率请求失败")?;
|
||||
}
|
||||
info!(width, height, "RDP DisplayControl resize requested");
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
return Err(anyhow::anyhow!("编码 DisplayControl 分辨率失败: {error}"));
|
||||
}
|
||||
None => {
|
||||
debug!(width, height, "DisplayControl not ready — defer resize");
|
||||
*pending_resize = Some((width, height));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_pending_resize(
|
||||
active_stage: &mut ActiveStage,
|
||||
framed: &mut UpgradedFramed,
|
||||
pending_resize: &mut Option<(u16, u16)>,
|
||||
fb_width: u16,
|
||||
fb_height: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some((width, height)) = *pending_resize else {
|
||||
return Ok(());
|
||||
};
|
||||
queue_or_send_resize(
|
||||
active_stage,
|
||||
framed,
|
||||
pending_resize,
|
||||
fb_width,
|
||||
fb_height,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_fast_path_error(error: impl std::fmt::Display) -> anyhow::Error {
|
||||
let text = error.to_string();
|
||||
if text.contains("bulk decompression failed") {
|
||||
anyhow::anyhow!(
|
||||
"处理 RDP 帧失败: 远端图像压缩解压异常(常见于分辨率切换后)。请断开后重连。"
|
||||
)
|
||||
} else {
|
||||
// Drop crate source paths like `[FastPath @ /Users/.../fast_path.rs:110]`.
|
||||
let short = text
|
||||
.split(" @ ")
|
||||
.next()
|
||||
.unwrap_or(text.as_str())
|
||||
.trim_end_matches([']', ' ']);
|
||||
anyhow::anyhow!("处理 RDP 帧失败: {short}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bulk_decompressor(compression_type: Option<CompressionType>) -> Option<BulkCompressor> {
|
||||
compression_type.and_then(|ct| {
|
||||
let bulk_ct = match ct {
|
||||
CompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4,
|
||||
CompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5,
|
||||
CompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6,
|
||||
CompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61,
|
||||
};
|
||||
match BulkCompressor::new(bulk_ct) {
|
||||
Ok(compressor) => Some(compressor),
|
||||
Err(error) => {
|
||||
warn!(%error, "Failed to recreate bulk decompressor after resize");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn reactivate_after_deactivate(
|
||||
app: &AppHandle,
|
||||
session_id: &str,
|
||||
framed: &mut UpgradedFramed,
|
||||
active_stage: &mut ActiveStage,
|
||||
image: &mut DecodedImage,
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
share_id: &mut u32,
|
||||
enable_server_pointer: &mut bool,
|
||||
pointer_software_rendering: &mut bool,
|
||||
activation_factory: &ConnectionActivationFactory,
|
||||
io_channel_id: u16,
|
||||
user_channel_id: u16,
|
||||
compression_type: Option<CompressionType>,
|
||||
) -> anyhow::Result<()> {
|
||||
info!(session_id, "RDP DeactivateAll — running reactivation");
|
||||
set_stream_timeouts(
|
||||
framed.get_inner_mut().0,
|
||||
HANDSHAKE_TIMEOUT,
|
||||
HANDSHAKE_TIMEOUT,
|
||||
)?;
|
||||
|
||||
let mut sequence = activation_factory.create();
|
||||
let mut buf = WriteBuf::new();
|
||||
loop {
|
||||
if matches!(
|
||||
sequence.connection_activation_state(),
|
||||
ConnectionActivationState::Finalized { .. }
|
||||
) {
|
||||
break;
|
||||
}
|
||||
buf.clear();
|
||||
let written = if let Some(hint) = sequence.next_pdu_hint() {
|
||||
let pdu = framed
|
||||
.read_by_hint(hint)
|
||||
.context("读取 Deactivation-Reactivation PDU 失败")?;
|
||||
sequence
|
||||
.step(&pdu, &mut buf)
|
||||
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
|
||||
} else {
|
||||
sequence
|
||||
.step_no_input(&mut buf)
|
||||
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
|
||||
};
|
||||
if let Some(response_len) = written.size() {
|
||||
framed
|
||||
.write_all(&buf[..response_len])
|
||||
.context("写入 Deactivation-Reactivation 响应失败")?;
|
||||
}
|
||||
}
|
||||
|
||||
let ConnectionActivationState::Finalized {
|
||||
desktop_size,
|
||||
share_id: new_share_id,
|
||||
enable_server_pointer: new_enable_server_pointer,
|
||||
pointer_software_rendering: new_pointer_software_rendering,
|
||||
} = sequence.connection_activation_state()
|
||||
else {
|
||||
return Err(anyhow::anyhow!("Deactivation-Reactivation 未完成"));
|
||||
};
|
||||
|
||||
*fb_width = desktop_size.width;
|
||||
*fb_height = desktop_size.height;
|
||||
*share_id = new_share_id;
|
||||
*enable_server_pointer = new_enable_server_pointer;
|
||||
*pointer_software_rendering = new_pointer_software_rendering;
|
||||
*image = DecodedImage::new(
|
||||
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
|
||||
*fb_width,
|
||||
*fb_height,
|
||||
);
|
||||
active_stage.set_share_id(*share_id);
|
||||
active_stage.set_enable_server_pointer(*enable_server_pointer);
|
||||
active_stage.set_fastpath_processor(
|
||||
ProcessorBuilder {
|
||||
io_channel_id,
|
||||
user_channel_id,
|
||||
share_id: *share_id,
|
||||
enable_server_pointer: *enable_server_pointer,
|
||||
pointer_software_rendering: *pointer_software_rendering,
|
||||
bulk_decompressor: build_bulk_decompressor(compression_type),
|
||||
}
|
||||
.build(),
|
||||
);
|
||||
|
||||
send_allow_display_updates(active_stage, framed, *fb_width, *fb_height)
|
||||
.context("重激活后发送 SuppressOutput 失败")?;
|
||||
set_stream_timeouts(
|
||||
framed.get_inner_mut().0,
|
||||
ACTIVE_READ_TIMEOUT,
|
||||
ACTIVE_WRITE_TIMEOUT,
|
||||
)?;
|
||||
|
||||
emit_status(
|
||||
app,
|
||||
"rdp:status",
|
||||
session_id,
|
||||
"connected",
|
||||
Some(format!("分辨率已更新 {}×{}", fb_width, fb_height)),
|
||||
Some(*fb_width),
|
||||
Some(*fb_height),
|
||||
);
|
||||
info!(
|
||||
session_id,
|
||||
width = *fb_width,
|
||||
height = *fb_height,
|
||||
"RDP reactivation complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_pending_frames(
|
||||
app: &AppHandle,
|
||||
frame_emitter: &mut FrameEmitter,
|
||||
@@ -678,7 +1036,7 @@ fn flush_pending_frames(
|
||||
right: left.saturating_add(width.saturating_sub(1)),
|
||||
bottom: top.saturating_add(height.saturating_sub(1)),
|
||||
};
|
||||
Some(extract_rgba_rect(image, &rect))
|
||||
extract_rgba_rect(image, &rect)
|
||||
},
|
||||
fb_width,
|
||||
fb_height,
|
||||
@@ -686,19 +1044,36 @@ fn flush_pending_frames(
|
||||
);
|
||||
}
|
||||
|
||||
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Vec<u8> {
|
||||
let w = usize::from(rect.width());
|
||||
let h = usize::from(rect.height());
|
||||
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Option<Vec<u8>> {
|
||||
let fb_w = usize::from(image.width());
|
||||
let fb_h = usize::from(image.height());
|
||||
if fb_w == 0 || fb_h == 0 {
|
||||
return None;
|
||||
}
|
||||
let left = usize::from(rect.left).min(fb_w.saturating_sub(1));
|
||||
let top = usize::from(rect.top).min(fb_h.saturating_sub(1));
|
||||
let right = usize::from(rect.right)
|
||||
.min(fb_w.saturating_sub(1))
|
||||
.max(left);
|
||||
let bottom = usize::from(rect.bottom)
|
||||
.min(fb_h.saturating_sub(1))
|
||||
.max(top);
|
||||
let w = right - left + 1;
|
||||
let h = bottom - top + 1;
|
||||
let bpp = image.bytes_per_pixel();
|
||||
let stride = image.stride();
|
||||
let data = image.data();
|
||||
let mut out = Vec::with_capacity(w * h * bpp);
|
||||
for row in 0..h {
|
||||
let y = usize::from(rect.top) + row;
|
||||
let start = y * stride + usize::from(rect.left) * bpp;
|
||||
out.extend_from_slice(&data[start..start + w * bpp]);
|
||||
let y = top + row;
|
||||
let start = y * stride + left * bpp;
|
||||
let end = start + w * bpp;
|
||||
if end > data.len() {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&data[start..end]);
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn build_config(
|
||||
@@ -748,7 +1123,11 @@ fn build_config(
|
||||
request_data: None,
|
||||
autologon: false,
|
||||
enable_audio_playback: false,
|
||||
compression_type: Some(CompressionType::Rdp61),
|
||||
// Prefer RDP5 MPPC (64 KB) over RDP6.1 XCRUSH. IronRDP's XCRUSH path is stateful and
|
||||
// commonly fails after DisplayControl resize / DeactivateAll ("match output offset out
|
||||
// of order"), dropping the whole session. MPPC is what IronRDP itself recommends until
|
||||
// slow-path bulk decompression is fully wired.
|
||||
compression_type: Some(CompressionType::K64),
|
||||
// Hardware/server pointer + CSS cursor on the canvas reduces framebuffer churn.
|
||||
pointer_software_rendering: false,
|
||||
multitransport_flags: None,
|
||||
@@ -811,8 +1190,17 @@ fn connect(
|
||||
)
|
||||
.with_drives(Some(vec![(1, super::rdpdr_fs::DRIVE_NAME.into())]));
|
||||
let rdpsnd = Rdpsnd::new(Box::new(NoopRdpsndBackend));
|
||||
let display_control = DisplayControlClient::new(|caps| {
|
||||
debug!(
|
||||
max_area = caps.max_monitor_area(),
|
||||
"RDP DisplayControl capabilities"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
});
|
||||
let drdynvc = DrdynvcClient::new().with_dynamic_channel(display_control);
|
||||
|
||||
let mut connector = connector::ClientConnector::new(config, client_addr)
|
||||
.with_static_channel(drdynvc)
|
||||
.with_static_channel(rdpsnd)
|
||||
.with_static_channel(rdpdr)
|
||||
.with_static_channel(cliprdr);
|
||||
|
||||
@@ -243,7 +243,7 @@ async fn drain_input(
|
||||
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
|
||||
inject_remote_paste(vnc).await?;
|
||||
}
|
||||
DesktopInput::UploadFile { .. } => {}
|
||||
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+608
-112
@@ -1,35 +1,54 @@
|
||||
//! Device-bound Pro license helpers (HMAC signed tokens).
|
||||
//! Signing secret stays on license-server; desktop embeds the matching
|
||||
//! verify key for offline activate (rotate for production).
|
||||
//! Pro license persistence + thin-client activate against opsx-licence.
|
||||
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
|
||||
//! offline `LICOFF-` serials are Ed25519 self-contained.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
|
||||
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
|
||||
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
|
||||
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
|
||||
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicensePayload {
|
||||
pub plan: String,
|
||||
pub device_id: String,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exp: Option<u64>,
|
||||
struct StoredLicense {
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
license_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
#[serde(default = "default_plan")]
|
||||
plan: String,
|
||||
#[serde(default)]
|
||||
activated: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expires_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
features: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
max_devices: Option<u32>,
|
||||
/// yearly | perpetual | trial | …
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
plan_type: Option<String>,
|
||||
#[serde(default)]
|
||||
last_verified_at: u64,
|
||||
}
|
||||
|
||||
fn default_plan() -> String {
|
||||
"pro".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -41,12 +60,43 @@ pub struct LicenseStatus {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
pub features: Vec<String>,
|
||||
/// License key string (compat field name for existing frontend).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_devices: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
fn hmac_secret() -> String {
|
||||
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
|
||||
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
|
||||
/// Accepts `https://host`, `https://host/api`, or mistaken `…/api/v1`. // Logan.Li
|
||||
fn normalize_api_base(raw: &str) -> String {
|
||||
let mut base = raw.trim().trim_end_matches('/').to_string();
|
||||
if base.is_empty() {
|
||||
return DEFAULT_API_BASE.to_string();
|
||||
}
|
||||
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
|
||||
if base.starts_with("http://l123.91ai.io") {
|
||||
base = format!("https{}", &base["http".len()..]);
|
||||
}
|
||||
if let Some(stripped) = base.strip_suffix("/api/v1") {
|
||||
base = format!("{stripped}/api");
|
||||
} else if base.ends_with("/v1") && !base.ends_with("/api/v1") {
|
||||
// e.g. https://l123.91ai.io/v1 → https://l123.91ai.io/api
|
||||
base = format!("{}/api", base.trim_end_matches("/v1").trim_end_matches('/'));
|
||||
} else if !base.ends_with("/api") {
|
||||
base = format!("{base}/api");
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn api_base() -> String {
|
||||
let raw =
|
||||
std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
|
||||
normalize_api_base(&raw)
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -70,6 +120,13 @@ fn ensure_parent(path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
let path = device_id_path(app)?;
|
||||
if path.exists() {
|
||||
@@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|error| format!("HMAC 初始化失败: {error}"))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
|
||||
fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
|
||||
LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
email,
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow helpers used by unit tests / future issue endpoints.
|
||||
#[allow(dead_code)]
|
||||
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
|
||||
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
|
||||
let sig = sign_payload(&payload_b64, secret)?;
|
||||
Ok(format!("opsx1.{payload_b64}.{sig}"))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
|
||||
let trimmed = token.trim();
|
||||
let parts: Vec<&str> = trimmed.split('.').collect();
|
||||
if parts.len() != 3 || parts[0] != "opsx1" {
|
||||
return Err("许可证格式无效".to_string());
|
||||
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
|
||||
if !stored.activated || stored.license_key.trim().is_empty() {
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
let expected = sign_payload(parts[1], secret)?;
|
||||
if expected != parts[2] {
|
||||
return Err("许可证签名无效".to_string());
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|_| "许可证载荷损坏".to_string())?;
|
||||
let payload: LicensePayload =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
|
||||
if payload.plan != "pro" {
|
||||
return Err("许可证计划无效".to_string());
|
||||
}
|
||||
if let Some(exp) = payload.exp {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if now > exp {
|
||||
return Err("许可证已过期".to_string());
|
||||
if let Some(exp) = stored.expires_at {
|
||||
if exp > 0 && exp < now_secs() {
|
||||
return free_status(device_id, stored.email.clone());
|
||||
}
|
||||
}
|
||||
Ok(payload)
|
||||
LicenseStatus {
|
||||
plan: if stored.plan.is_empty() {
|
||||
"pro".to_string()
|
||||
} else {
|
||||
stored.plan.clone()
|
||||
},
|
||||
device_id,
|
||||
activated: true,
|
||||
expires_at: stored.expires_at,
|
||||
features: if stored.features.is_empty() {
|
||||
vec!["*".to_string()]
|
||||
} else {
|
||||
stored.features.clone()
|
||||
},
|
||||
token: Some(stored.license_key.clone()),
|
||||
email: stored.email.clone(),
|
||||
max_devices: stored.max_devices,
|
||||
plan_type: stored.plan_type.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
|
||||
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
|
||||
return Ok(None);
|
||||
}
|
||||
let stored: StoredLicense =
|
||||
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
|
||||
Ok(Some(stored))
|
||||
}
|
||||
|
||||
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), String> {
|
||||
ensure_parent(path)?;
|
||||
let json = serde_json::to_string_pretty(stored)
|
||||
.map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||
fs::write(path, json).map_err(|error| format!("保存许可证失败: {error}"))
|
||||
}
|
||||
|
||||
fn reject_legacy_key(license_key: &str) -> Result<(), String> {
|
||||
let trimmed = license_key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入许可证密钥".to_string());
|
||||
}
|
||||
if trimmed.starts_with("opsx1.") || trimmed.contains(".opsx1.") {
|
||||
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".to_string());
|
||||
}
|
||||
if trimmed.starts_with("MII") {
|
||||
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_offline_license_key(license_key: &str) -> bool {
|
||||
license_key.trim().starts_with("LICOFF-")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OfflineClaims {
|
||||
#[serde(default)]
|
||||
v: i32,
|
||||
#[serde(rename = "pid")]
|
||||
product_code: String,
|
||||
#[serde(rename = "did")]
|
||||
device_id: String,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(rename = "pt")]
|
||||
plan_type: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
#[serde(rename = "md", default)]
|
||||
max_devices: i32,
|
||||
}
|
||||
|
||||
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
|
||||
let trimmed = serial.trim();
|
||||
let body = trimmed
|
||||
.strip_prefix("LICOFF-1.")
|
||||
.ok_or_else(|| "不是合法的离线序列号(需 LICOFF-1. 前缀)".to_string())?;
|
||||
let (payload_b64, sig_b64) = body
|
||||
.rsplit_once('.')
|
||||
.ok_or_else(|| "离线序列号格式错误".to_string())?;
|
||||
|
||||
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let sig_bytes = engine
|
||||
.decode(sig_b64)
|
||||
.map_err(|_| "离线序列号签名无法解码".to_string())?;
|
||||
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "离线序列号签名无效".to_string())?;
|
||||
|
||||
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
|
||||
.map_err(|_| "离线验签公钥配置错误".to_string())?;
|
||||
let verifying_key = VerifyingKey::from_bytes(
|
||||
pub_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "离线验签公钥长度错误".to_string())?,
|
||||
)
|
||||
.map_err(|_| "离线验签公钥无效".to_string())?;
|
||||
verifying_key
|
||||
.verify(payload_b64.as_bytes(), &sig)
|
||||
.map_err(|_| "离线序列号签名校验失败".to_string())?;
|
||||
|
||||
let payload = engine
|
||||
.decode(payload_b64)
|
||||
.map_err(|_| "离线序列号载荷无法解码".to_string())?;
|
||||
let claims: OfflineClaims =
|
||||
serde_json::from_slice(&payload).map_err(|_| "离线序列号载荷无法解析".to_string())?;
|
||||
|
||||
if claims.v != 0 && claims.v != 1 {
|
||||
return Err("不支持的离线证版本".to_string());
|
||||
}
|
||||
if claims.product_code != "opsxshell" {
|
||||
return Err("离线证产品不匹配".to_string());
|
||||
}
|
||||
if claims.device_id != device_id {
|
||||
return Err("离线证与当前设备不匹配".to_string());
|
||||
}
|
||||
let plan = claims.plan_type.as_str();
|
||||
if plan != "trial" && plan != "yearly" {
|
||||
return Err("离线证计划无效(仅体验/包年)".to_string());
|
||||
}
|
||||
let now = now_secs() as i64;
|
||||
if claims.exp <= now {
|
||||
return Err("离线许可证已过期".to_string());
|
||||
}
|
||||
if claims.exp - claims.iat > 365 * 86400 + 3600 {
|
||||
return Err("离线许可证有效期非法".to_string());
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::blocking::Client, String> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.user_agent("OpsXShell/2")
|
||||
.build()
|
||||
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))
|
||||
}
|
||||
|
||||
fn envelope_message(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn envelope_code(value: &Value) -> Option<i64> {
|
||||
value.get("code").and_then(|v| v.as_i64())
|
||||
}
|
||||
|
||||
fn envelope_data(value: &Value) -> &Value {
|
||||
value.get("data").unwrap_or(value)
|
||||
}
|
||||
|
||||
fn json_string_field(value: &Value, snake: &str, camel: &str) -> Option<String> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn json_i64_field(value: &Value, snake: &str, camel: &str) -> Option<i64> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
|
||||
}
|
||||
|
||||
fn json_string_array(value: &Value, snake: &str, camel: &str) -> Vec<String> {
|
||||
value
|
||||
.get(snake)
|
||||
.or_else(|| value.get(camel))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
|
||||
let days = days_left?;
|
||||
if days < 0 {
|
||||
return Some(0);
|
||||
}
|
||||
// Treat very long remaining windows as perpetual.
|
||||
if days > 36_500 {
|
||||
return None;
|
||||
}
|
||||
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
|
||||
}
|
||||
|
||||
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||
let url = format!("{}{path}", api_base());
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.map_err(|error| format!("读取许可证服务响应失败: {error}"))?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(format!("许可证服务返回空响应 ({status})"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|error| {
|
||||
format!(
|
||||
"许可证服务响应无法解析 ({status}): {error}; body={}",
|
||||
&text[..text.len().min(200)]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct RemoteLicenseInfo {
|
||||
expires_at: Option<u64>,
|
||||
features: Vec<String>,
|
||||
max_devices: Option<u32>,
|
||||
plan_type: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
|
||||
let days_left = json_i64_field(data, "days_left", "daysLeft");
|
||||
let expires_at = expires_from_days_left(days_left);
|
||||
let features = json_string_array(data, "features", "features");
|
||||
let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32);
|
||||
let plan_type = json_string_field(data, "plan_type", "planType");
|
||||
RemoteLicenseInfo {
|
||||
expires_at,
|
||||
features,
|
||||
max_devices,
|
||||
plan_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
"device_name": "OpsXShell",
|
||||
});
|
||||
let value = post_json("/v1/license/activate", body)?;
|
||||
if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(parse_remote_info(envelope_data(&value)))
|
||||
}
|
||||
|
||||
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
let value = post_json("/v1/license/verify", body)?;
|
||||
// Bare object or envelope.
|
||||
let payload = if value.get("valid").is_some() {
|
||||
&value
|
||||
} else if envelope_code(&value) == Some(0) {
|
||||
envelope_data(&value)
|
||||
} else if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
|
||||
}
|
||||
envelope_data(&value)
|
||||
} else {
|
||||
&value
|
||||
};
|
||||
|
||||
let valid = payload
|
||||
.get("valid")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
if !valid {
|
||||
return Err(envelope_message(payload)
|
||||
.or_else(|| json_string_field(payload, "message", "message"))
|
||||
.unwrap_or_else(|| "许可证无效".to_string()));
|
||||
}
|
||||
|
||||
Ok(parse_remote_info(payload))
|
||||
}
|
||||
|
||||
fn remote_deactivate(license_key: &str, device_id: &str) {
|
||||
let body = serde_json::json!({
|
||||
"license_key": license_key,
|
||||
"device_id": device_id,
|
||||
});
|
||||
let _ = post_json("/v1/license/deactivate", body);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckoutResult {
|
||||
pub order_no: String,
|
||||
pub checkout_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub amount: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub currency: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a pending order via opsx-licence and return the Waffo hosted checkout URL. // Logan.Li
|
||||
pub fn checkout(
|
||||
app: &AppHandle,
|
||||
customer_email: &str,
|
||||
plan_type: &str,
|
||||
return_url: Option<&str>,
|
||||
) -> Result<CheckoutResult, String> {
|
||||
let email = customer_email.trim();
|
||||
if email.is_empty() || !email.contains('@') {
|
||||
return Err("请先填写有效邮箱".to_string());
|
||||
}
|
||||
let plan = match plan_type.trim() {
|
||||
"yearly" | "perpetual" => plan_type.trim(),
|
||||
other if !other.is_empty() => other,
|
||||
_ => "perpetual",
|
||||
};
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let body = serde_json::json!({
|
||||
"customer_email": email,
|
||||
"product_code": "opsxshell",
|
||||
"plan_type": plan,
|
||||
"device_id": device_id,
|
||||
"return_url": return_url.unwrap_or("https://l123.91ai.io/api/v1/purchase/success"),
|
||||
});
|
||||
let value = post_json("/v1/checkout", body)?;
|
||||
if let Some(code) = envelope_code(&value) {
|
||||
if code != 0 {
|
||||
return Err(envelope_message(&value).unwrap_or_else(|| "创建结账订单失败".to_string()));
|
||||
}
|
||||
}
|
||||
let data = envelope_data(&value);
|
||||
let checkout_url = json_string_field(data, "checkout_url", "checkoutUrl")
|
||||
.ok_or_else(|| "结账链接缺失".to_string())?;
|
||||
let order_no = json_string_field(data, "order_no", "orderNo").unwrap_or_default();
|
||||
Ok(CheckoutResult {
|
||||
order_no,
|
||||
checkout_url,
|
||||
amount: data
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
|
||||
currency: json_string_field(data, "currency", "currency"),
|
||||
status: json_string_field(data, "status", "status"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let path = license_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||
match verify_token(&token, &hmac_secret()) {
|
||||
Ok(payload) => {
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
Ok(LicenseStatus {
|
||||
plan: payload.plan,
|
||||
device_id,
|
||||
activated: true,
|
||||
expires_at: payload.exp,
|
||||
features: payload.features,
|
||||
token: Some(token.trim().to_string()),
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
}),
|
||||
match read_stored(&path)? {
|
||||
None => Ok(free_status(device_id, None)),
|
||||
Some(stored) => Ok(status_from_stored(device_id, &stored)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
|
||||
pub fn activate(
|
||||
app: &AppHandle,
|
||||
license_key: &str,
|
||||
email: Option<&str>,
|
||||
) -> Result<LicenseStatus, String> {
|
||||
reject_legacy_key(license_key)?;
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let payload = verify_token(token, &hmac_secret())?;
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Err("许可证与当前设备不匹配".to_string());
|
||||
let key = license_key.trim();
|
||||
|
||||
if is_offline_license_key(key) {
|
||||
let claims = verify_offline_serial(key, &device_id)?;
|
||||
let email_store = email
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(claims.email.clone());
|
||||
let stored = StoredLicense {
|
||||
license_key: key.to_string(),
|
||||
email: email_store,
|
||||
plan: "pro".to_string(),
|
||||
activated: true,
|
||||
expires_at: Some(claims.exp as u64),
|
||||
features: vec!["*".to_string()],
|
||||
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||
plan_type: Some(claims.plan_type),
|
||||
last_verified_at: now_secs(),
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
write_stored(&path, &stored)?;
|
||||
return read_status(app);
|
||||
}
|
||||
|
||||
let mut info = remote_activate(key, &device_id)?;
|
||||
if let Ok(verify) = remote_verify(key, &device_id) {
|
||||
if verify.expires_at.is_some() {
|
||||
info.expires_at = verify.expires_at;
|
||||
}
|
||||
if !verify.features.is_empty() {
|
||||
info.features = verify.features;
|
||||
}
|
||||
if verify.max_devices.is_some() {
|
||||
info.max_devices = verify.max_devices;
|
||||
}
|
||||
if verify.plan_type.is_some() {
|
||||
info.plan_type = verify.plan_type;
|
||||
}
|
||||
}
|
||||
if info.features.is_empty() {
|
||||
info.features = vec!["*".to_string()];
|
||||
}
|
||||
|
||||
let stored = StoredLicense {
|
||||
license_key: key.to_string(),
|
||||
email: email
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
plan: "pro".to_string(),
|
||||
activated: true,
|
||||
expires_at: info.expires_at,
|
||||
features: info.features,
|
||||
max_devices: info.max_devices,
|
||||
plan_type: info.plan_type,
|
||||
last_verified_at: now_secs(),
|
||||
};
|
||||
let path = license_path(app)?;
|
||||
ensure_parent(&path)?;
|
||||
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
|
||||
license_key: String::new(),
|
||||
email: None,
|
||||
plan: "free".to_string(),
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
max_devices: None,
|
||||
plan_type: None,
|
||||
last_verified_at: 0,
|
||||
});
|
||||
let trimmed = email.trim();
|
||||
stored.email = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let path = license_path(app)?;
|
||||
if let Ok(Some(stored)) = read_stored(&path) {
|
||||
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
|
||||
remote_deactivate(&stored.license_key, &device_id);
|
||||
}
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
||||
}
|
||||
@@ -202,16 +646,68 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_token() {
|
||||
let payload = LicensePayload {
|
||||
fn normalize_api_base_accepts_host_and_strips_v1() {
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io/api/"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("https://l123.91ai.io/api/v1"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("http://l123.91ai.io/api"),
|
||||
"https://l123.91ai.io/api"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_base("http://127.0.0.1:9001/api"),
|
||||
"http://127.0.0.1:9001/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_opsx1_and_private_key() {
|
||||
assert!(reject_legacy_key("opsx1.abc.def").is_err());
|
||||
assert!(reject_legacy_key("MIIEowIBAAK...").is_err());
|
||||
assert!(reject_legacy_key("LIC-TEST-KEY").is_ok());
|
||||
assert!(reject_legacy_key("LICOFF-1.abc.def").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_offline_prefix() {
|
||||
assert!(is_offline_license_key("LICOFF-1.x.y"));
|
||||
assert!(!is_offline_license_key("LIC-2026-AAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_license_roundtrip_json() {
|
||||
let stored = StoredLicense {
|
||||
license_key: "LIC-ABC".to_string(),
|
||||
email: Some("a@b.com".to_string()),
|
||||
plan: "pro".to_string(),
|
||||
device_id: "dev-1".to_string(),
|
||||
iat: 1,
|
||||
exp: None,
|
||||
activated: true,
|
||||
expires_at: None,
|
||||
features: vec!["*".to_string()],
|
||||
max_devices: Some(3),
|
||||
plan_type: Some("perpetual".to_string()),
|
||||
last_verified_at: 1,
|
||||
};
|
||||
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
assert_eq!(parsed.device_id, "dev-1");
|
||||
let json = serde_json::to_string(&stored).unwrap();
|
||||
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.license_key, "LIC-ABC");
|
||||
assert_eq!(parsed.email.as_deref(), Some("a@b.com"));
|
||||
assert!(parsed.activated);
|
||||
assert_eq!(parsed.max_devices, Some(3));
|
||||
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_from_days_left_perpetual() {
|
||||
assert_eq!(expires_from_days_left(Some(40_000)), None);
|
||||
assert!(expires_from_days_left(Some(30)).is_some());
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ impl LocalTerminalService {
|
||||
.map_err(|error| format!("failed to open local PTY: {error}"))?;
|
||||
let mut command = CommandBuilder::new(&shell);
|
||||
command.cwd(cwd);
|
||||
apply_login_shell_args(&mut command, &shell);
|
||||
apply_terminal_env(&mut command);
|
||||
let child = pair
|
||||
.slave
|
||||
@@ -216,8 +217,27 @@ fn local_home_dir() -> Option<PathBuf> {
|
||||
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
|
||||
}
|
||||
|
||||
/// Match Terminal.app / VS Code so a Finder/Dock-launched GUI process still
|
||||
/// sees Homebrew, `/usr/local/bin` (Ollama), and other login-profile tools.
|
||||
fn apply_login_shell_args(command: &mut CommandBuilder, shell: &str) {
|
||||
for arg in login_shell_args(&shell_kind(shell)) {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
|
||||
fn login_shell_args(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"zsh" | "bash" | "fish" | "sh" => &["-l"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Match VS Code integrated terminal env so local shells (and nested `ssh`)
|
||||
/// get a color-capable, UTF-8 locale. See vscode `addTerminalEnvironmentKeys`.
|
||||
///
|
||||
/// macOS GUI apps inherit a stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||
/// VS Code repairs this by resolving a login-shell env; we do the same by
|
||||
/// starting a login shell and prepending the dirs `path_helper` would add.
|
||||
fn apply_terminal_env(command: &mut CommandBuilder) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -237,9 +257,100 @@ fn apply_terminal_env(command: &mut CommandBuilder) {
|
||||
if should_set_utf8_lang() {
|
||||
command.env("LANG", utf8_lang());
|
||||
}
|
||||
let current = env::var("PATH").unwrap_or_default();
|
||||
let enriched = enrich_inherited_path(¤t);
|
||||
if enriched != current {
|
||||
command.env("PATH", enriched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_separator() -> char {
|
||||
if cfg!(windows) {
|
||||
';'
|
||||
} else {
|
||||
':'
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepend missing user/system bin dirs onto a GUI-minimal PATH.
|
||||
/// Existing entries keep their order; duplicates are not added.
|
||||
fn merge_path(current: &str, extras: &[String]) -> String {
|
||||
let sep = path_separator();
|
||||
let existing: Vec<&str> = current.split(sep).filter(|part| !part.is_empty()).collect();
|
||||
let mut seen: HashSet<&str> = existing.iter().copied().collect();
|
||||
let mut prefix = Vec::new();
|
||||
for dir in extras {
|
||||
if dir.is_empty() || !seen.insert(dir.as_str()) {
|
||||
continue;
|
||||
}
|
||||
prefix.push(dir.as_str());
|
||||
}
|
||||
prefix
|
||||
.into_iter()
|
||||
.chain(existing)
|
||||
.collect::<Vec<_>>()
|
||||
.join(&sep.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn extra_path_dirs() -> Vec<String> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = local_home_dir() {
|
||||
for rel in [".local/bin", ".cargo/bin", "bin"] {
|
||||
dirs.push(home.join(rel).to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs.extend([
|
||||
"/opt/homebrew/bin".into(),
|
||||
"/opt/homebrew/sbin".into(),
|
||||
"/usr/local/bin".into(),
|
||||
"/usr/local/sbin".into(),
|
||||
]);
|
||||
dirs.extend(macos_path_helper_dirs());
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
dirs.extend(["/usr/local/bin".into(), "/usr/local/sbin".into()]);
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_path_helper_dirs() -> Vec<String> {
|
||||
let mut dirs = read_path_file(Path::new("/etc/paths"));
|
||||
if let Ok(entries) = std::fs::read_dir("/etc/paths.d") {
|
||||
let mut files: Vec<_> = entries.filter_map(|entry| entry.ok()).collect();
|
||||
files.sort_by_key(|entry| entry.file_name());
|
||||
for file in files {
|
||||
dirs.extend(read_path_file(&file.path()));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_path_file(path: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn enrich_inherited_path(current: &str) -> String {
|
||||
let extras: Vec<String> = extra_path_dirs()
|
||||
.into_iter()
|
||||
.filter(|dir| Path::new(dir).is_dir())
|
||||
.collect();
|
||||
merge_path(current, &extras)
|
||||
}
|
||||
|
||||
fn should_set_utf8_lang() -> bool {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
io::{self, Read, Write},
|
||||
net::{Shutdown, TcpStream},
|
||||
sync::{
|
||||
@@ -19,7 +19,7 @@ use crate::domain::ssh::{
|
||||
ShellDataEvent, SshConnectRequest,
|
||||
};
|
||||
|
||||
use super::tunnel::TunnelManager;
|
||||
use super::tunnel::{connection_fingerprint, TunnelManager};
|
||||
|
||||
mod auth;
|
||||
mod host_metrics;
|
||||
@@ -50,13 +50,27 @@ struct Shell {
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Dedicated SSH transport + persistent SFTP channel for one workload.
|
||||
/// Each UI SFTP tab owns a browsing channel and may lazily open a separate
|
||||
/// transfer channel; neither shares the pooled session used by exec/keepalive.
|
||||
struct ManagedSftp {
|
||||
connection_id: String,
|
||||
session: Session,
|
||||
sftp: ssh2::Sftp,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SshService {
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
shells: Mutex<HashMap<String, Shell>>,
|
||||
sftps: Mutex<HashMap<String, String>>,
|
||||
/// Interactive browsing channel for each SFTP tab.
|
||||
sftps: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||
/// Lazily-created transfer channel, isolated from browsing/file operations.
|
||||
sftp_transfers: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
|
||||
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
/// Per-SFTP-session cancel for the active upload/download copy loop.
|
||||
sftp_transfer_cancels: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
tunnels: TunnelManager,
|
||||
}
|
||||
|
||||
@@ -65,15 +79,30 @@ impl SshService {
|
||||
validate_config(&config)?;
|
||||
let session = establish_session(&config)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.connections
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.insert(id.clone(), Connection { session, config });
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let live = {
|
||||
let mut connections = self.connections.lock().map_err(lock_error)?;
|
||||
connections.insert(id.clone(), Connection { session, config });
|
||||
connections.keys().cloned().collect::<HashSet<_>>()
|
||||
};
|
||||
// Reattach listeners that survived a reconnect (same SSH identity,
|
||||
// previous connection id is gone). Runtime is never persisted.
|
||||
self.tunnels.adopt_orphans(&id, &fingerprint, &live)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
|
||||
self.tunnels.stop_connection(connection_id)?;
|
||||
pub fn disconnect(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
connection_id: &str,
|
||||
retain_forwards: bool,
|
||||
) -> Result<(), String> {
|
||||
// Dedicated tunnel sessions keep listening across terminal flaps when
|
||||
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
|
||||
// must still close real Listeners.
|
||||
if !retain_forwards {
|
||||
let _ = self.tunnels.stop_connection(connection_id);
|
||||
}
|
||||
if let Some(flag) = self
|
||||
.active_execs
|
||||
.lock()
|
||||
@@ -97,10 +126,22 @@ impl SshService {
|
||||
let _ = shell.session.disconnect(None, "closed by user", None);
|
||||
}
|
||||
}
|
||||
self.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.retain(|_, id| id != connection_id);
|
||||
let sftp_ids = {
|
||||
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||
sftps
|
||||
.iter()
|
||||
.filter_map(|(id, handle)| {
|
||||
handle
|
||||
.lock()
|
||||
.ok()
|
||||
.filter(|sftp| sftp.connection_id == connection_id)
|
||||
.map(|_| id.clone())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for sftp_id in sftp_ids {
|
||||
let _ = self.close_sftp(&sftp_id);
|
||||
}
|
||||
if let Some(connection) = self
|
||||
.connections
|
||||
.lock()
|
||||
@@ -256,8 +297,8 @@ impl SshService {
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("SSH 保活失败: {error}"))?;
|
||||
|
||||
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
|
||||
// channel cannot stall terminal IO. Keep those transports alive too.
|
||||
// Interactive panes / SFTP use dedicated SSH sessions so a busy
|
||||
// SFTP/exec channel cannot stall terminal IO. Keep those alive too.
|
||||
let shell_sessions = self
|
||||
.shells
|
||||
.lock()
|
||||
@@ -271,6 +312,21 @@ impl SshService {
|
||||
.keepalive_send()
|
||||
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
|
||||
}
|
||||
let sftp_sessions = {
|
||||
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||
sftps
|
||||
.values()
|
||||
.filter_map(|handle| {
|
||||
let guard = handle.lock().ok()?;
|
||||
(guard.connection_id == connection_id).then(|| guard.session.clone())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for session in sftp_sessions {
|
||||
session
|
||||
.keepalive_send()
|
||||
.map_err(|error| format!("SFTP SSH 保活失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -393,6 +449,7 @@ impl SshService {
|
||||
&self,
|
||||
connection_id: &str,
|
||||
forward_id: Option<String>,
|
||||
local_bind_host: String,
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
@@ -402,6 +459,7 @@ impl SshService {
|
||||
forward_id,
|
||||
connection_id,
|
||||
config,
|
||||
local_bind_host,
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port,
|
||||
@@ -423,6 +481,7 @@ impl SshService {
|
||||
&self,
|
||||
connection_id: &str,
|
||||
forward_id: Option<String>,
|
||||
remote_bind_host: String,
|
||||
remote_port: u16,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
@@ -432,6 +491,7 @@ impl SshService {
|
||||
forward_id,
|
||||
connection_id,
|
||||
config,
|
||||
remote_bind_host,
|
||||
remote_port,
|
||||
local_host,
|
||||
local_port,
|
||||
@@ -481,7 +541,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
|
||||
session
|
||||
.handshake()
|
||||
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
|
||||
session.set_keepalive(config.keep_alive.unwrap_or(true), 30);
|
||||
session.set_keepalive(config.keep_alive.unwrap_or(true), 15);
|
||||
verify_host_key(&session, &config.host, config.port)?;
|
||||
authenticate(&session, config)?;
|
||||
Ok(session)
|
||||
@@ -782,7 +842,10 @@ mod tests {
|
||||
target.seek(SeekFrom::Start(3)).unwrap();
|
||||
let mut progress = Vec::new();
|
||||
|
||||
copy_with_progress(&mut source, &mut target, 3, 6, |value| progress.push(value)).unwrap();
|
||||
copy_with_progress(&mut source, &mut target, 3, 6, None, |value| {
|
||||
progress.push(value)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(target.into_inner(), b"abcdef");
|
||||
assert_eq!(progress.first(), Some(&3));
|
||||
@@ -835,23 +898,23 @@ mod tests {
|
||||
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
|
||||
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
|
||||
fs::write(&local_source, &payload).expect("write local SFTP fixture");
|
||||
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
|
||||
let transfer_result = (|| -> Result<(), String> {
|
||||
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
|
||||
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
|
||||
let transfer_result = service.with_sftp(&sftp_id, |sftp| {
|
||||
upload_file(sftp, &local_source, &remote_path, None, |_, _| {})?;
|
||||
download_file(sftp, &remote_path, &local_download, None, |_, _| {})?;
|
||||
let downloaded = fs::read(&local_download)
|
||||
.map_err(|error| format!("read downloaded probe: {error}"))?;
|
||||
if downloaded != payload {
|
||||
return Err("SFTP round-trip content mismatch".to_string());
|
||||
}
|
||||
let _ = sftp.unlink(Path::new(&remote_path));
|
||||
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
||||
Ok(())
|
||||
})();
|
||||
let _ = sftp.unlink(Path::new(&remote_path));
|
||||
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
||||
});
|
||||
let _ = fs::remove_file(&local_source);
|
||||
let _ = fs::remove_file(&local_download);
|
||||
let _ = fs::remove_file(local_partial_path(&local_download));
|
||||
transfer_result.expect("SFTP upload/download round trip");
|
||||
service.close_sftp(&sftp_id).expect("close SFTP");
|
||||
let connection = service.connection(&connection_id).unwrap();
|
||||
let shell_session = establish_session(&connection.config).expect("shell SSH session");
|
||||
let mut shell = shell_session.channel_session().expect("shell channel");
|
||||
|
||||
@@ -264,7 +264,7 @@ fn remove_known_host_entries(host: &str, port: u16) -> Result<(), String> {
|
||||
let retained = input
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_whitespace().next().map_or(true, |hosts| {
|
||||
line.split_whitespace().next().is_none_or(|hosts| {
|
||||
!hosts
|
||||
.split(',')
|
||||
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
||||
|
||||
+486
-115
@@ -2,6 +2,11 @@ use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
|
||||
@@ -10,39 +15,103 @@ use uuid::Uuid;
|
||||
|
||||
use crate::domain::ssh::{SftpDirEntry, SftpFileAttrs, SftpProgressEvent};
|
||||
|
||||
use super::{lock_error, path::expand_home, SshService};
|
||||
use super::{establish_session, lock_error, path::expand_home, ManagedSftp, SshService};
|
||||
|
||||
/// Soft cap for in-memory remote text editing (matches UI copy).
|
||||
const MAX_REMOTE_TEXT_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const TEXT_READ_CHUNK: usize = 64 * 1024;
|
||||
|
||||
impl SshService {
|
||||
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
|
||||
let connection = self.connection(connection_id)?;
|
||||
connection
|
||||
.session
|
||||
// Keep browsing off the pooled connection. Upload/download lazily create
|
||||
// their own transport so a long copy cannot block directory operations.
|
||||
let session = establish_session(&connection.config)?;
|
||||
let sftp = session
|
||||
.sftp()
|
||||
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
|
||||
let id = format!("sftp-{}", Uuid::new_v4());
|
||||
self.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.insert(id.clone(), connection_id.to_string());
|
||||
self.sftps.lock().map_err(lock_error)?.insert(
|
||||
id.clone(),
|
||||
Arc::new(Mutex::new(ManagedSftp {
|
||||
connection_id: connection_id.to_string(),
|
||||
session,
|
||||
sftp,
|
||||
})),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn close_sftp(&self, sftp_id: &str) -> Result<(), String> {
|
||||
let _ = self.cancel_sftp_transfer(sftp_id);
|
||||
let _ = self
|
||||
.sftp_transfer_cancels
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.remove(sftp_id);
|
||||
let browse = self.sftps.lock().map_err(lock_error)?.remove(sftp_id);
|
||||
let transfer = self
|
||||
.sftp_transfers
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.remove(sftp_id);
|
||||
disconnect_managed_sftp(browse, "sftp browsing closed");
|
||||
disconnect_managed_sftp(transfer, "sftp transfer closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Signal the in-flight copy loop for this SFTP session to stop at the next chunk.
|
||||
pub fn cancel_sftp_transfer(&self, sftp_id: &str) -> Result<(), String> {
|
||||
tracing::info!(sftp_id = %sftp_id, "SFTP transfer cancellation requested");
|
||||
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||
let flag = cancels
|
||||
.entry(sftp_id.to_string())
|
||||
.or_insert_with(|| Arc::new(AtomicBool::new(true)));
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn begin_sftp_transfer(&self, sftp_id: &str) -> Result<Arc<AtomicBool>, String> {
|
||||
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||
Ok(Arc::clone(
|
||||
cancels
|
||||
.entry(sftp_id.to_string())
|
||||
.or_insert_with(|| Arc::new(AtomicBool::new(false))),
|
||||
))
|
||||
}
|
||||
|
||||
fn finish_sftp_transfer(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
completed_flag: &Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
|
||||
if cancels
|
||||
.get(sftp_id)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, completed_flag))
|
||||
{
|
||||
cancels.remove(sftp_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sftp_list(&self, sftp_id: &str, remote_path: &str) -> Result<Vec<SftpDirEntry>, String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut entries = sftp
|
||||
.readdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程目录失败: {error}"))?
|
||||
.into_iter()
|
||||
.filter_map(|(path, stat)| {
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
if filename == "." || filename == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(dir_entry(filename, stat))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.filename.to_lowercase());
|
||||
Ok(entries)
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
let mut entries = sftp
|
||||
.readdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程目录失败: {error}"))?
|
||||
.into_iter()
|
||||
.filter_map(|(path, stat)| {
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
if filename == "." || filename == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(dir_entry(filename, stat))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.filename.to_lowercase());
|
||||
Ok(entries)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_upload(
|
||||
@@ -52,13 +121,21 @@ impl SshService {
|
||||
local_path: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
upload_file(
|
||||
&sftp,
|
||||
&expand_home(local_path),
|
||||
remote_path,
|
||||
|transferred, total| emit_progress(app, sftp_id, transferred, total),
|
||||
)
|
||||
tracing::info!(sftp_id = %sftp_id, operation = "upload", "SFTP transfer started");
|
||||
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||
upload_file(
|
||||
sftp,
|
||||
&expand_home(local_path),
|
||||
remote_path,
|
||||
Some(cancel.as_ref()),
|
||||
|transferred, total| progress.emit(transferred, total),
|
||||
)
|
||||
});
|
||||
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||
trace_transfer_result(sftp_id, "upload", &result);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sftp_download(
|
||||
@@ -68,11 +145,22 @@ impl SshService {
|
||||
remote_path: &str,
|
||||
local_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let local_path = expand_home(local_path);
|
||||
download_file(&sftp, remote_path, &local_path, |transferred, total| {
|
||||
emit_progress(app, sftp_id, transferred, total)
|
||||
})
|
||||
tracing::info!(sftp_id = %sftp_id, operation = "download", "SFTP transfer started");
|
||||
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||
let local_path = expand_home(local_path);
|
||||
download_file(
|
||||
sftp,
|
||||
remote_path,
|
||||
&local_path,
|
||||
Some(cancel.as_ref()),
|
||||
|transferred, total| progress.emit(transferred, total),
|
||||
)
|
||||
});
|
||||
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||
trace_transfer_result(sftp_id, "download", &result);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sftp_upload_dir(
|
||||
@@ -82,13 +170,21 @@ impl SshService {
|
||||
local_dir: &str,
|
||||
remote_dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
upload_dir(
|
||||
&sftp,
|
||||
&expand_home(local_dir),
|
||||
remote_dir,
|
||||
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
|
||||
)
|
||||
tracing::info!(sftp_id = %sftp_id, operation = "upload_dir", "SFTP transfer started");
|
||||
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||
upload_dir(
|
||||
sftp,
|
||||
&expand_home(local_dir),
|
||||
remote_dir,
|
||||
Some(cancel.as_ref()),
|
||||
&mut |transferred, total| progress.emit(transferred, total),
|
||||
)
|
||||
});
|
||||
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||
trace_transfer_result(sftp_id, "upload_dir", &result);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sftp_download_dir(
|
||||
@@ -98,11 +194,22 @@ impl SshService {
|
||||
remote_dir: &str,
|
||||
local_dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let local_dir = expand_home(local_dir);
|
||||
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
|
||||
emit_progress(app, sftp_id, transferred, total)
|
||||
})
|
||||
tracing::info!(sftp_id = %sftp_id, operation = "download_dir", "SFTP transfer started");
|
||||
let cancel = self.begin_sftp_transfer(sftp_id)?;
|
||||
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
|
||||
let result = self.with_transfer_sftp(sftp_id, |sftp| {
|
||||
let local_dir = expand_home(local_dir);
|
||||
download_dir(
|
||||
sftp,
|
||||
remote_dir,
|
||||
&local_dir,
|
||||
Some(cancel.as_ref()),
|
||||
&mut |transferred, total| progress.emit(transferred, total),
|
||||
)
|
||||
});
|
||||
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
|
||||
trace_transfer_result(sftp_id, "download_dir", &result);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sftp_rename(
|
||||
@@ -111,9 +218,10 @@ impl SshService {
|
||||
old_remote_path: &str,
|
||||
new_remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
|
||||
.map_err(|error| format!("远程重命名失败: {error}"))
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
sftp.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
|
||||
.map_err(|error| format!("远程重命名失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_delete(
|
||||
@@ -122,31 +230,34 @@ impl SshService {
|
||||
remote_path: &str,
|
||||
directory: bool,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
if directory {
|
||||
sftp.rmdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程目录失败: {error}"))
|
||||
} else {
|
||||
sftp.unlink(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程文件失败: {error}"))
|
||||
}
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
if directory {
|
||||
sftp.rmdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程目录失败: {error}"))
|
||||
} else {
|
||||
sftp.unlink(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程文件失败: {error}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.mkdir(Path::new(remote_path), 0o755)
|
||||
.map_err(|error| format!("创建远程目录失败: {error}"))
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
sftp.mkdir(Path::new(remote_path), 0o755)
|
||||
.map_err(|error| format!("创建远程目录失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
|
||||
self.sftp(sftp_id)?
|
||||
.lstat(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程属性失败: {error}"))
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
sftp.lstat(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程属性失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.setstat(
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
sftp.setstat(
|
||||
Path::new(remote_path),
|
||||
FileStat {
|
||||
size: None,
|
||||
@@ -158,6 +269,7 @@ impl SshService {
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("修改远程权限失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_read_text(
|
||||
@@ -165,15 +277,9 @@ impl SshService {
|
||||
sftp_id: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<(String, u64), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut file = sftp
|
||||
.open(Path::new(remote_path))
|
||||
.map_err(|error| format!("打开远程文件失败: {error}"))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|error| format!("远程文件不是有效 UTF-8 文本: {error}"))?;
|
||||
let size = content.len() as u64;
|
||||
Ok((content, size))
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
read_remote_text_capped(sftp, Path::new(remote_path), MAX_REMOTE_TEXT_BYTES)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_write_text(
|
||||
@@ -182,26 +288,199 @@ impl SshService {
|
||||
remote_path: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut file = sftp
|
||||
.create(Path::new(remote_path))
|
||||
.map_err(|error| format!("创建远程文件失败: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("写入远程文件失败: {error}"))
|
||||
if content.len() as u64 > MAX_REMOTE_TEXT_BYTES {
|
||||
return Err(format!(
|
||||
"文件过大({}),编辑上限约 2MB",
|
||||
format_byte_size(content.len() as u64)
|
||||
));
|
||||
}
|
||||
self.with_browse_sftp(sftp_id, |sftp| {
|
||||
let mut file = sftp
|
||||
.create(Path::new(remote_path))
|
||||
.map_err(|error| format!("创建远程文件失败: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("写入远程文件失败: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn sftp(&self, sftp_id: &str) -> Result<ssh2::Sftp, String> {
|
||||
let connection_id = self
|
||||
/// Blocking access to the browsing channel for native integration tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn with_sftp<R>(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||
) -> Result<R, String> {
|
||||
let handle = self
|
||||
.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(sftp_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||
self.connection(&connection_id)?
|
||||
.session
|
||||
.sftp()
|
||||
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
|
||||
let guard = handle.lock().map_err(lock_error)?;
|
||||
f(&guard.sftp)
|
||||
}
|
||||
|
||||
/// Transfer-only channel. It is opened lazily and then reused by the serial
|
||||
/// transfer pump, so large copies never hold the browsing channel lock.
|
||||
fn with_transfer_sftp<R>(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||
) -> Result<R, String> {
|
||||
let existing = self
|
||||
.sftp_transfers
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(sftp_id)
|
||||
.cloned();
|
||||
|
||||
let handle = match existing {
|
||||
Some(handle) => handle,
|
||||
None => {
|
||||
let browse = self
|
||||
.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(sftp_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||
let connection_id = browse.lock().map_err(lock_error)?.connection_id.clone();
|
||||
let config = self.connection(&connection_id)?.config;
|
||||
let session = establish_session(&config)?;
|
||||
let sftp = session
|
||||
.sftp()
|
||||
.map_err(|error| format!("初始化 SFTP 传输通道失败: {error}"))?;
|
||||
let created = Arc::new(Mutex::new(ManagedSftp {
|
||||
connection_id,
|
||||
session,
|
||||
sftp,
|
||||
}));
|
||||
tracing::info!(sftp_id = %sftp_id, "SFTP transfer channel created");
|
||||
self.sftp_transfers
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.entry(sftp_id.to_string())
|
||||
.or_insert_with(|| Arc::clone(&created))
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let guard = handle.lock().map_err(lock_error)?;
|
||||
f(&guard.sftp)
|
||||
}
|
||||
|
||||
/// Serialize short browsing/file operations on their dedicated background channel.
|
||||
/// Tauri commands call this from `spawn_blocking`, so waiting here never blocks the UI.
|
||||
pub(super) fn with_browse_sftp<R>(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
|
||||
) -> Result<R, String> {
|
||||
let handle = self
|
||||
.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(sftp_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||
let guard = handle.lock().map_err(lock_error)?;
|
||||
f(&guard.sftp)
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_managed_sftp(handle: Option<Arc<Mutex<ManagedSftp>>>, reason: &str) {
|
||||
if let Some(handle) = handle {
|
||||
if let Ok(guard) = handle.lock() {
|
||||
let _ = guard.session.disconnect(None, reason, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_byte_size(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
let value = bytes as f64;
|
||||
if value >= MB {
|
||||
format!("{:.1} MB", value / MB)
|
||||
} else if value >= KB {
|
||||
format!("{:.1} KB", value / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_binary(sample: &[u8]) -> bool {
|
||||
if sample.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// NUL in the first chunk is a strong binary signal for text editors.
|
||||
sample.contains(&0)
|
||||
}
|
||||
|
||||
fn read_remote_text_capped(
|
||||
sftp: &ssh2::Sftp,
|
||||
remote_path: &Path,
|
||||
max_bytes: u64,
|
||||
) -> Result<(String, u64), String> {
|
||||
if let Ok(stat) = sftp.stat(remote_path) {
|
||||
if let Some(size) = stat.size {
|
||||
if size > max_bytes {
|
||||
return Err(format!(
|
||||
"文件过大({}),编辑上限约 2MB",
|
||||
format_byte_size(size)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = sftp
|
||||
.open(remote_path)
|
||||
.map_err(|error| format!("打开远程文件失败: {error}"))?;
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = vec![0u8; TEXT_READ_CHUNK];
|
||||
let mut sniffed = false;
|
||||
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut chunk)
|
||||
.map_err(|error| format!("读取远程文件失败: {error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if !sniffed {
|
||||
sniffed = true;
|
||||
if looks_like_binary(&chunk[..read]) {
|
||||
return Err("该文件像是二进制内容,无法用文本编辑器打开".to_string());
|
||||
}
|
||||
}
|
||||
if (buffer.len() as u64).saturating_add(read as u64) > max_bytes {
|
||||
return Err(format!(
|
||||
"文件过大(超过 {}),编辑上限约 2MB",
|
||||
format_byte_size(max_bytes)
|
||||
));
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
let size = buffer.len() as u64;
|
||||
let content =
|
||||
String::from_utf8(buffer).map_err(|_| "远程文件不是有效 UTF-8 文本".to_string())?;
|
||||
Ok((content, size))
|
||||
}
|
||||
|
||||
fn trace_transfer_result(sftp_id: &str, operation: &str, result: &Result<(), String>) {
|
||||
match result {
|
||||
Ok(()) => tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer completed"),
|
||||
Err(error) if error.contains("传输已取消") => {
|
||||
tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer canceled")
|
||||
}
|
||||
Err(error) => tracing::warn!(
|
||||
sftp_id = %sftp_id,
|
||||
operation,
|
||||
error = %error,
|
||||
"SFTP transfer failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +514,28 @@ fn permission_string(mode: u32) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
fn transfer_cancelled(cancel: Option<&AtomicBool>) -> bool {
|
||||
cancel
|
||||
.map(|flag| flag.load(Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ensure_not_cancelled(cancel: Option<&AtomicBool>) -> Result<(), String> {
|
||||
if transfer_cancelled(cancel) {
|
||||
Err("传输已取消".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn upload_file(
|
||||
sftp: &ssh2::Sftp,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
cancel: Option<&AtomicBool>,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let mut source = fs::File::open(local_path)
|
||||
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
|
||||
let total = source
|
||||
@@ -271,9 +566,14 @@ pub(super) fn upload_file(
|
||||
target
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位远程断点失败: {error}"))?;
|
||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
copy_with_progress(
|
||||
&mut source,
|
||||
&mut target,
|
||||
offset,
|
||||
total,
|
||||
cancel,
|
||||
|transferred| progress(transferred, total),
|
||||
)?;
|
||||
drop(target);
|
||||
let uploaded = sftp
|
||||
.stat(Path::new(&partial_path))
|
||||
@@ -292,8 +592,10 @@ pub(super) fn download_file(
|
||||
sftp: &ssh2::Sftp,
|
||||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
cancel: Option<&AtomicBool>,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let mut source = sftp
|
||||
.open(Path::new(remote_path))
|
||||
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
|
||||
@@ -319,9 +621,14 @@ pub(super) fn download_file(
|
||||
target
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位本地断点失败: {error}"))?;
|
||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
copy_with_progress(
|
||||
&mut source,
|
||||
&mut target,
|
||||
offset,
|
||||
total,
|
||||
cancel,
|
||||
|transferred| progress(transferred, total),
|
||||
)?;
|
||||
target
|
||||
.sync_all()
|
||||
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
|
||||
@@ -397,8 +704,10 @@ fn upload_dir(
|
||||
sftp: &ssh2::Sftp,
|
||||
local_dir: &Path,
|
||||
remote_dir: &str,
|
||||
cancel: Option<&AtomicBool>,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let meta = fs::metadata(local_dir)
|
||||
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
|
||||
if !meta.is_dir() {
|
||||
@@ -413,6 +722,7 @@ fn upload_dir(
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
|
||||
@@ -423,11 +733,9 @@ fn upload_dir(
|
||||
let local_child = entry.path();
|
||||
let remote_child = join_remote(remote_dir, &name);
|
||||
if file_type.is_dir() {
|
||||
upload_dir(sftp, &local_child, &remote_child, progress)?;
|
||||
upload_dir(sftp, &local_child, &remote_child, cancel, progress)?;
|
||||
} else if file_type.is_file() {
|
||||
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
upload_file(sftp, &local_child, &remote_child, cancel, &mut *progress)?;
|
||||
}
|
||||
// skip symlinks / special files for safety
|
||||
}
|
||||
@@ -438,8 +746,10 @@ fn download_dir(
|
||||
sftp: &ssh2::Sftp,
|
||||
remote_dir: &str,
|
||||
local_dir: &Path,
|
||||
cancel: Option<&AtomicBool>,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let stat = sftp
|
||||
.lstat(Path::new(remote_dir))
|
||||
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
|
||||
@@ -455,6 +765,7 @@ fn download_dir(
|
||||
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
|
||||
|
||||
for (path, entry_stat) in entries {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let name = match path.file_name() {
|
||||
Some(value) => value.to_string_lossy().to_string(),
|
||||
None => continue,
|
||||
@@ -465,11 +776,9 @@ fn download_dir(
|
||||
let remote_child = join_remote(remote_dir, &name);
|
||||
let local_child = local_dir.join(&name);
|
||||
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
|
||||
download_dir(sftp, &remote_child, &local_child, progress)?;
|
||||
download_dir(sftp, &remote_child, &local_child, cancel, progress)?;
|
||||
} else {
|
||||
download_file(sftp, &remote_child, &local_child, |transferred, total| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
download_file(sftp, &remote_child, &local_child, cancel, &mut *progress)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -480,12 +789,15 @@ pub(super) fn copy_with_progress(
|
||||
writer: &mut impl Write,
|
||||
initial: u64,
|
||||
total: u64,
|
||||
cancel: Option<&AtomicBool>,
|
||||
mut progress: impl FnMut(u64),
|
||||
) -> Result<(), String> {
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
// 256 KiB chunks: fewer syscalls / progress callbacks on large files.
|
||||
let mut buffer = [0_u8; 256 * 1024];
|
||||
let mut transferred = initial;
|
||||
progress(transferred);
|
||||
loop {
|
||||
ensure_not_cancelled(cancel)?;
|
||||
let length = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("读取传输数据失败: {error}"))?;
|
||||
@@ -535,19 +847,78 @@ pub(super) fn local_partial_path(destination: &Path) -> PathBuf {
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, sftp_id: &str, transferred: u64, total: u64) {
|
||||
let percentage = transferred
|
||||
.saturating_mul(100)
|
||||
.checked_div(total)
|
||||
.unwrap_or(100)
|
||||
.min(100) as u8;
|
||||
let _ = app.emit(
|
||||
"sftp:progress",
|
||||
SftpProgressEvent {
|
||||
/// Throttle IPC progress so a large file does not flood the WebView/React thread.
|
||||
struct ProgressEmitter {
|
||||
app: AppHandle,
|
||||
sftp_id: String,
|
||||
last_emit: Instant,
|
||||
last_percent: u8,
|
||||
}
|
||||
|
||||
impl ProgressEmitter {
|
||||
fn new(app: AppHandle, sftp_id: &str) -> Self {
|
||||
Self {
|
||||
app,
|
||||
sftp_id: sftp_id.to_string(),
|
||||
transferred,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
);
|
||||
// Force first emit after start.
|
||||
last_emit: Instant::now()
|
||||
.checked_sub(Duration::from_secs(1))
|
||||
.unwrap_or_else(Instant::now),
|
||||
last_percent: 255,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, transferred: u64, total: u64) {
|
||||
let percentage = if total == 0 {
|
||||
100
|
||||
} else {
|
||||
transferred
|
||||
.saturating_mul(100)
|
||||
.checked_div(total)
|
||||
.unwrap_or(100)
|
||||
.min(100) as u8
|
||||
};
|
||||
let done = total > 0 && transferred >= total;
|
||||
// Cap IPC to ~1 Hz (plus start/done). Faster % ticks were freezing the WebView.
|
||||
if !done && self.last_percent != 255 && self.last_emit.elapsed() < Duration::from_secs(1) {
|
||||
return;
|
||||
}
|
||||
self.last_emit = Instant::now();
|
||||
self.last_percent = percentage;
|
||||
if cfg!(debug_assertions) {
|
||||
let sid = if self.sftp_id.len() > 12 {
|
||||
&self.sftp_id[self.sftp_id.len() - 12..]
|
||||
} else {
|
||||
self.sftp_id.as_str()
|
||||
};
|
||||
eprintln!("[sftp-xfer] emit sid=…{sid} {percentage}% {transferred}/{total} bytes");
|
||||
}
|
||||
let _ = self.app.emit(
|
||||
"sftp:progress",
|
||||
SftpProgressEvent {
|
||||
sftp_id: self.sftp_id.clone(),
|
||||
transferred,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cancellation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cancellation_requested_before_worker_start_is_not_lost() {
|
||||
let service = SshService::default();
|
||||
service.cancel_sftp_transfer("sftp-test").unwrap();
|
||||
|
||||
let flag = service.begin_sftp_transfer("sftp-test").unwrap();
|
||||
assert!(flag.load(Ordering::Acquire));
|
||||
|
||||
service.finish_sftp_transfer("sftp-test", &flag).unwrap();
|
||||
let next = service.begin_sftp_transfer("sftp-test").unwrap();
|
||||
assert!(!next.load(Ordering::Acquire));
|
||||
}
|
||||
}
|
||||
+471
-123
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
io::{self, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::{
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ssh2::ErrorCode;
|
||||
@@ -19,10 +19,12 @@ use super::ssh::establish_session;
|
||||
|
||||
mod model;
|
||||
mod relay;
|
||||
mod session;
|
||||
mod socks5;
|
||||
|
||||
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
|
||||
use relay::relay_channel;
|
||||
use session::TunnelSession;
|
||||
use socks5::read_target;
|
||||
|
||||
enum IdClaim {
|
||||
@@ -43,16 +45,18 @@ impl TunnelManager {
|
||||
forward_id: Option<String>,
|
||||
connection_id: &str,
|
||||
config: SshConnectRequest,
|
||||
local_bind_host: String,
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
) -> Result<ForwardStatus, String> {
|
||||
validate_target(&remote_host, remote_port)?;
|
||||
let remote_host = validate_target(&remote_host, remote_port)?;
|
||||
let bind_host = validate_bind_host(&local_bind_host)?;
|
||||
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
|
||||
IdClaim::Existing(status) => return Ok(status),
|
||||
IdClaim::Fresh(id) => id,
|
||||
};
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
||||
let listener = TcpListener::bind((bind_host.as_str(), local_port))
|
||||
.map_err(|error| format!("监听本地端口失败: {error}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
@@ -65,7 +69,7 @@ impl TunnelManager {
|
||||
&forward_id,
|
||||
connection_id,
|
||||
"local",
|
||||
"127.0.0.1",
|
||||
&bind_host,
|
||||
bound_port,
|
||||
Some(remote_host.clone()),
|
||||
Some(remote_port),
|
||||
@@ -76,42 +80,25 @@ impl TunnelManager {
|
||||
let thread_cancel = cancel.clone();
|
||||
let thread_running = running.clone();
|
||||
let thread_counters = counters.clone();
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let listener_thread = thread::spawn(move || {
|
||||
while !thread_cancel.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((socket, _)) => {
|
||||
let config = config.clone();
|
||||
let host = remote_host.clone();
|
||||
let cancel = thread_cancel.clone();
|
||||
let counters = thread_counters.clone();
|
||||
thread::spawn(move || {
|
||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(session) = establish_session(&config) else {
|
||||
return;
|
||||
};
|
||||
let Ok(channel) =
|
||||
session.channel_direct_tcpip(&host, remote_port, None)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
relay_channel(session, channel, socket, cancel, counters);
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
thread_running.store(false, Ordering::Release);
|
||||
run_direct_listener(
|
||||
listener,
|
||||
config,
|
||||
DirectTarget::Fixed {
|
||||
host: remote_host,
|
||||
port: remote_port,
|
||||
},
|
||||
thread_cancel,
|
||||
thread_running,
|
||||
thread_counters,
|
||||
);
|
||||
});
|
||||
self.register(
|
||||
forward_id,
|
||||
TunnelRuntime {
|
||||
descriptor,
|
||||
fingerprint,
|
||||
cancel,
|
||||
running,
|
||||
counters,
|
||||
@@ -155,60 +142,22 @@ impl TunnelManager {
|
||||
let thread_cancel = cancel.clone();
|
||||
let thread_running = running.clone();
|
||||
let thread_counters = counters.clone();
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let listener_thread = thread::spawn(move || {
|
||||
while !thread_cancel.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((mut socket, _)) => {
|
||||
let config = config.clone();
|
||||
let cancel = thread_cancel.clone();
|
||||
let counters = thread_counters.clone();
|
||||
thread::spawn(move || {
|
||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (host, port) = match read_target(&mut socket) {
|
||||
Ok(target) => target,
|
||||
Err(error) => {
|
||||
tracing::warn!("SOCKS5 请求解析失败: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let session = match establish_session(&config) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
tracing::warn!("SOCKS5 专用 SSH 连接失败: {error}");
|
||||
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let channel = match session.channel_direct_tcpip(&host, port, None) {
|
||||
Ok(channel) => channel,
|
||||
Err(error) => {
|
||||
tracing::warn!("SOCKS5 目标 {host}:{port} 连接失败: {error}");
|
||||
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
|
||||
relay_channel(session, channel, socket, cancel, counters);
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
thread_running.store(false, Ordering::Release);
|
||||
run_direct_listener(
|
||||
listener,
|
||||
config,
|
||||
DirectTarget::Dynamic,
|
||||
thread_cancel,
|
||||
thread_running,
|
||||
thread_counters,
|
||||
);
|
||||
});
|
||||
self.register(
|
||||
forward_id,
|
||||
TunnelRuntime {
|
||||
descriptor,
|
||||
fingerprint,
|
||||
cancel,
|
||||
running,
|
||||
counters,
|
||||
@@ -222,67 +171,65 @@ impl TunnelManager {
|
||||
forward_id: Option<String>,
|
||||
connection_id: &str,
|
||||
config: SshConnectRequest,
|
||||
remote_bind_host: String,
|
||||
remote_port: u16,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
) -> Result<ForwardStatus, String> {
|
||||
validate_target(&local_host, local_port)?;
|
||||
let local_host = validate_target(&local_host, local_port)?;
|
||||
let remote_bind_host = validate_remote_bind_host(&remote_bind_host)?;
|
||||
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
|
||||
IdClaim::Existing(status) => return Ok(status),
|
||||
IdClaim::Fresh(id) => id,
|
||||
};
|
||||
let session = establish_session(&config)?;
|
||||
let (mut listener, bound_port) = session
|
||||
.channel_forward_listen(remote_port, None, None)
|
||||
.map_err(|error| format!("建立远程端口转发失败: {error}"))?;
|
||||
let want_all_interfaces = is_all_interfaces_bind(&remote_bind_host);
|
||||
let (listener, bound_port, bind_host) =
|
||||
listen_remote_forward(&session, remote_port, &remote_bind_host).map_err(|error| {
|
||||
map_remote_forward_error(error, remote_port, want_all_interfaces)
|
||||
})?;
|
||||
session.set_blocking(false);
|
||||
let descriptor = descriptor(
|
||||
&forward_id,
|
||||
connection_id,
|
||||
"remote",
|
||||
"remote",
|
||||
if bind_host.is_empty() {
|
||||
"localhost"
|
||||
} else {
|
||||
bind_host.as_str()
|
||||
},
|
||||
bound_port,
|
||||
Some(local_host.clone()),
|
||||
Some(local_port),
|
||||
);
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let counters = Arc::new(TunnelCounters::default());
|
||||
let thread_cancel = cancel.clone();
|
||||
let thread_running = running.clone();
|
||||
let thread_counters = counters.clone();
|
||||
let thread_config = config.clone();
|
||||
let thread_bind = remote_bind_host.clone();
|
||||
let listener_thread = thread::spawn(move || {
|
||||
while !thread_cancel.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok(channel) => {
|
||||
let Ok(socket) = TcpStream::connect((&*local_host, local_port)) else {
|
||||
continue;
|
||||
};
|
||||
let session = session.clone();
|
||||
let cancel = thread_cancel.clone();
|
||||
let counters = thread_counters.clone();
|
||||
thread::spawn(move || {
|
||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
relay_channel(session, channel, socket, cancel, counters);
|
||||
});
|
||||
}
|
||||
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
drop(listener);
|
||||
let _ = session.disconnect(None, "port forward stopped", None);
|
||||
thread_running.store(false, Ordering::Release);
|
||||
run_remote_listener(
|
||||
session,
|
||||
listener,
|
||||
thread_config,
|
||||
remote_port,
|
||||
thread_bind,
|
||||
local_host,
|
||||
local_port,
|
||||
thread_cancel,
|
||||
thread_running,
|
||||
thread_counters,
|
||||
);
|
||||
});
|
||||
self.register(
|
||||
forward_id,
|
||||
TunnelRuntime {
|
||||
descriptor,
|
||||
fingerprint,
|
||||
cancel,
|
||||
running,
|
||||
counters,
|
||||
@@ -327,18 +274,43 @@ impl TunnelManager {
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.values()
|
||||
.filter(|runtime| {
|
||||
connection_id.map_or(true, |id| runtime.descriptor.connection_id == id)
|
||||
})
|
||||
.filter(|runtime| connection_id.is_none_or(|id| runtime.descriptor.connection_id == id))
|
||||
.map(TunnelRuntime::snapshot)
|
||||
.collect::<Vec<_>>();
|
||||
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
/// Rebind listeners left behind when the pooled SSH connection was replaced.
|
||||
/// Only takes runtimes whose SSH identity matches and whose previous connection
|
||||
/// is no longer live — a second session to the same host must not steal them.
|
||||
pub fn adopt_orphans(
|
||||
&self,
|
||||
new_connection_id: &str,
|
||||
fingerprint: &str,
|
||||
live_connection_ids: &HashSet<String>,
|
||||
) -> Result<Vec<ForwardStatus>, String> {
|
||||
let mut adopted = Vec::new();
|
||||
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
|
||||
for runtime in runtimes.values_mut() {
|
||||
if runtime.fingerprint != fingerprint {
|
||||
continue;
|
||||
}
|
||||
if runtime.descriptor.connection_id == new_connection_id {
|
||||
continue;
|
||||
}
|
||||
if live_connection_ids.contains(&runtime.descriptor.connection_id) {
|
||||
continue;
|
||||
}
|
||||
runtime.descriptor.connection_id = new_connection_id.to_string();
|
||||
adopted.push(runtime.snapshot());
|
||||
}
|
||||
Ok(adopted)
|
||||
}
|
||||
|
||||
/// Claim a forward id for start.
|
||||
/// - Same connection + kind already running → return existing status (idempotent).
|
||||
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
|
||||
/// - Same connection + kind still running → return existing status (idempotent).
|
||||
/// - Same id dead, owned by another connection, or a different kind → stop and reuse.
|
||||
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
|
||||
fn claim_id(
|
||||
&self,
|
||||
@@ -354,7 +326,8 @@ impl TunnelManager {
|
||||
match runtimes.get(&id) {
|
||||
Some(runtime)
|
||||
if runtime.descriptor.connection_id == connection_id
|
||||
&& runtime.descriptor.kind == kind =>
|
||||
&& runtime.descriptor.kind == kind
|
||||
&& runtime.running.load(Ordering::Acquire) =>
|
||||
{
|
||||
return Ok(IdClaim::Existing(runtime.snapshot()));
|
||||
}
|
||||
@@ -394,20 +367,322 @@ impl Drop for TunnelManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_target(host: &str, port: u16) -> Result<(), String> {
|
||||
if host.trim().is_empty() || port == 0 {
|
||||
pub(crate) fn connection_fingerprint(config: &SshConnectRequest) -> String {
|
||||
format!(
|
||||
"{}@{}:{}",
|
||||
config.username.trim(),
|
||||
config.host.trim().to_ascii_lowercase(),
|
||||
config.port
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DirectTarget {
|
||||
Fixed { host: String, port: u16 },
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// Local / SOCKS listener loop. The TCP bind stays up until the user stops
|
||||
/// the rule; SSH session flaps only rebuild the dedicated transport.
|
||||
fn run_direct_listener(
|
||||
listener: TcpListener,
|
||||
config: SshConnectRequest,
|
||||
target: DirectTarget,
|
||||
cancel: Arc<AtomicBool>,
|
||||
running: Arc<AtomicBool>,
|
||||
counters: Arc<TunnelCounters>,
|
||||
) {
|
||||
running.store(true, Ordering::Release);
|
||||
let tunnel = std::sync::Arc::new(TunnelSession::new(config));
|
||||
let mut last_keepalive = Instant::now();
|
||||
while !cancel.load(Ordering::Acquire) {
|
||||
if last_keepalive.elapsed() >= Duration::from_secs(15) {
|
||||
tunnel.keepalive();
|
||||
last_keepalive = Instant::now();
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((socket, _)) => {
|
||||
let cancel = cancel.clone();
|
||||
let counters = counters.clone();
|
||||
let tunnel = tunnel.clone();
|
||||
let target = target.clone();
|
||||
thread::spawn(move || {
|
||||
handle_direct_client(socket, target, tunnel, cancel, counters);
|
||||
});
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == io::ErrorKind::WouldBlock
|
||||
|| error.kind() == io::ErrorKind::Interrupted =>
|
||||
{
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("端口转发本地监听 accept 失败,继续等待: {error}");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
tunnel.close();
|
||||
running.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn handle_direct_client(
|
||||
mut socket: TcpStream,
|
||||
target: DirectTarget,
|
||||
tunnel: std::sync::Arc<TunnelSession>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
counters: Arc<TunnelCounters>,
|
||||
) {
|
||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
|
||||
return;
|
||||
};
|
||||
let is_dynamic = matches!(target, DirectTarget::Dynamic);
|
||||
let (host, port) = match target {
|
||||
DirectTarget::Fixed { host, port } => (host, port),
|
||||
DirectTarget::Dynamic => match read_target(&mut socket) {
|
||||
Ok(destination) => destination,
|
||||
Err(error) => {
|
||||
tracing::warn!("SOCKS5 请求解析失败: {error}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
match tunnel.open_direct(&host, port, &cancel) {
|
||||
Ok((session, channel)) => {
|
||||
if is_dynamic {
|
||||
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
|
||||
}
|
||||
relay_channel(session, channel, socket, cancel, counters);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("端口转发目标 {host}:{port} 连接失败: {error}");
|
||||
if is_dynamic {
|
||||
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_remote_listener(
|
||||
mut session: ssh2::Session,
|
||||
mut listener: ssh2::Listener,
|
||||
config: SshConnectRequest,
|
||||
remote_port: u16,
|
||||
remote_bind_host: String,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
cancel: Arc<AtomicBool>,
|
||||
running: Arc<AtomicBool>,
|
||||
counters: Arc<TunnelCounters>,
|
||||
) {
|
||||
running.store(true, Ordering::Release);
|
||||
while !cancel.load(Ordering::Acquire) {
|
||||
let died = accept_remote_until_dead(
|
||||
&session,
|
||||
&mut listener,
|
||||
&local_host,
|
||||
local_port,
|
||||
&cancel,
|
||||
&counters,
|
||||
);
|
||||
if cancel.load(Ordering::Acquire) || !died {
|
||||
break;
|
||||
}
|
||||
tracing::warn!("远程端口转发会话断开,正在重连 {remote_bind_host}:{remote_port}");
|
||||
drop(listener);
|
||||
let _ = session.disconnect(None, "port forward reconnect", None);
|
||||
let Some((new_session, new_listener)) =
|
||||
reconnect_remote_listener(&config, remote_port, &remote_bind_host, &cancel)
|
||||
else {
|
||||
running.store(false, Ordering::Release);
|
||||
return;
|
||||
};
|
||||
session = new_session;
|
||||
listener = new_listener;
|
||||
}
|
||||
drop(listener);
|
||||
let _ = session.disconnect(None, "port forward stopped", None);
|
||||
running.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn accept_remote_until_dead(
|
||||
session: &ssh2::Session,
|
||||
listener: &mut ssh2::Listener,
|
||||
local_host: &str,
|
||||
local_port: u16,
|
||||
cancel: &Arc<AtomicBool>,
|
||||
counters: &Arc<TunnelCounters>,
|
||||
) -> bool {
|
||||
let mut last_keepalive = Instant::now();
|
||||
while !cancel.load(Ordering::Acquire) {
|
||||
if last_keepalive.elapsed() >= Duration::from_secs(15) {
|
||||
let _ = session.keepalive_send();
|
||||
last_keepalive = Instant::now();
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok(channel) => {
|
||||
let Ok(socket) = TcpStream::connect((local_host, local_port)) else {
|
||||
continue;
|
||||
};
|
||||
let session = session.clone();
|
||||
let cancel = Arc::clone(cancel);
|
||||
let counters = Arc::clone(counters);
|
||||
thread::spawn(move || {
|
||||
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
|
||||
return;
|
||||
};
|
||||
relay_channel(session, channel, socket, cancel, counters);
|
||||
});
|
||||
}
|
||||
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn reconnect_remote_listener(
|
||||
config: &SshConnectRequest,
|
||||
remote_port: u16,
|
||||
remote_bind_host: &str,
|
||||
cancel: &AtomicBool,
|
||||
) -> Option<(ssh2::Session, ssh2::Listener)> {
|
||||
let mut delay = Duration::from_millis(400);
|
||||
while !cancel.load(Ordering::Acquire) {
|
||||
match establish_session(config) {
|
||||
Ok(session) => match listen_remote_forward(&session, remote_port, remote_bind_host) {
|
||||
Ok((listener, _, _)) => {
|
||||
session.set_blocking(false);
|
||||
return Some((session, listener));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("远程转发重听失败: {error}");
|
||||
let _ = session.disconnect(None, "remote re-listen failed", None);
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!("远程转发重连失败: {error}");
|
||||
}
|
||||
}
|
||||
thread::sleep(delay);
|
||||
delay = (delay.saturating_mul(2)).min(Duration::from_secs(15));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn validate_target(host: &str, port: u16) -> Result<String, String> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() || port == 0 {
|
||||
return Err("转发目标主机和端口不能为空".to_string());
|
||||
}
|
||||
Ok(())
|
||||
// 0.0.0.0 / :: are listen-only; treat as remote loopback (common user mix-up).
|
||||
if host == "0.0.0.0" || host == "::" || host.eq_ignore_ascii_case("[::]") {
|
||||
return Ok("127.0.0.1".to_string());
|
||||
}
|
||||
Ok(host.to_string())
|
||||
}
|
||||
|
||||
fn validate_bind_host(host: &str) -> Result<String, String> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Ok("127.0.0.1".to_string());
|
||||
}
|
||||
if host.chars().any(char::is_whitespace) {
|
||||
return Err("本地绑定地址不能包含空格".to_string());
|
||||
}
|
||||
Ok(host.to_string())
|
||||
}
|
||||
|
||||
fn validate_remote_bind_host(host: &str) -> Result<String, String> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
|
||||
return Ok("127.0.0.1".to_string());
|
||||
}
|
||||
if host.chars().any(char::is_whitespace) {
|
||||
return Err("远端监听地址不能包含空格".to_string());
|
||||
}
|
||||
Ok(host.to_string())
|
||||
}
|
||||
|
||||
fn is_all_interfaces_bind(host: &str) -> bool {
|
||||
let host = host.trim();
|
||||
host == "0.0.0.0" || host == "*" || host == "::" || host.eq_ignore_ascii_case("[::]")
|
||||
}
|
||||
|
||||
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
|
||||
"端口转发状态锁异常".to_string()
|
||||
}
|
||||
|
||||
/// Ask sshd to listen for remote forwards.
|
||||
/// Loopback: try localhost / 127.0.0.1 / empty (OpenSSH -R default).
|
||||
/// All-interfaces (`0.0.0.0`): requires GatewayPorts yes/clientspecified.
|
||||
fn listen_remote_forward(
|
||||
session: &ssh2::Session,
|
||||
remote_port: u16,
|
||||
prefer_bind: &str,
|
||||
) -> Result<(ssh2::Listener, u16, String), ssh2::Error> {
|
||||
let prefer = prefer_bind.trim();
|
||||
let candidates: Vec<&str> = if is_all_interfaces_bind(prefer) {
|
||||
// OpenSSH clients send 0.0.0.0 (or *) when GatewayPorts allows it.
|
||||
vec!["0.0.0.0"]
|
||||
} else if prefer.is_empty() || prefer == "127.0.0.1" || prefer.eq_ignore_ascii_case("localhost")
|
||||
{
|
||||
// Order matters: PermitListen often lists "localhost:port"; empty string is
|
||||
// what `ssh -R port:host:port` sends when no bind address is given.
|
||||
vec!["localhost", "127.0.0.1", ""]
|
||||
} else {
|
||||
vec![prefer]
|
||||
};
|
||||
|
||||
let mut denied: Option<ssh2::Error> = None;
|
||||
for host in candidates {
|
||||
match session.channel_forward_listen(remote_port, Some(host), Some(1)) {
|
||||
Ok((listener, bound)) => {
|
||||
let reported = if host.is_empty() {
|
||||
"localhost".to_string()
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
return Ok((listener, bound, reported));
|
||||
}
|
||||
Err(error) if error.code() == ErrorCode::Session(-32) => {
|
||||
denied = Some(error);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(denied.unwrap_or_else(|| ssh2::Error::from_errno(ErrorCode::Session(-32))))
|
||||
}
|
||||
|
||||
/// libssh2 Session(-32) == LIBSSH2_ERROR_REQUEST_DENIED: sshd refused tcpip-forward.
|
||||
fn map_remote_forward_error(
|
||||
error: ssh2::Error,
|
||||
remote_port: u16,
|
||||
want_all_interfaces: bool,
|
||||
) -> String {
|
||||
match error.code() {
|
||||
ErrorCode::Session(-32) if want_all_interfaces => format!(
|
||||
"建立远程端口转发失败: 远端拒绝 0.0.0.0:{remote_port}(通常需 GatewayPorts yes/clientspecified,且端口空闲)。仅本机访问可改用 127.0.0.1:{remote_port}。"
|
||||
),
|
||||
ErrorCode::Session(-32) if (1..1024).contains(&remote_port) => format!(
|
||||
"建立远程端口转发失败: 远端拒绝特权端口 {remote_port}(需 root 或改用 ≥1024;检查 AllowTcpForwarding / PermitListen)。"
|
||||
),
|
||||
ErrorCode::Session(-32) => format!(
|
||||
"建立远程端口转发失败: 远端拒绝端口 {remote_port}(AllowTcpForwarding / PermitListen / 端口占用)。可用 ssh -R {remote_port}:127.0.0.1:<本地端口> 核对。"
|
||||
),
|
||||
_ => format!("建立远程端口转发失败: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn unreachable_config() -> SshConnectRequest {
|
||||
SshConnectRequest {
|
||||
@@ -496,4 +771,77 @@ mod tests {
|
||||
let _ = first;
|
||||
manager.stop_connection("connection-new").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopt_orphans_rebinds_to_new_connection() {
|
||||
let manager = TunnelManager::default();
|
||||
let config = unreachable_config();
|
||||
manager
|
||||
.start_dynamic(Some("rule-1".to_string()), "old-conn", config.clone(), 0)
|
||||
.unwrap();
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let live = HashSet::from(["new-conn".to_string()]);
|
||||
let adopted = manager
|
||||
.adopt_orphans("new-conn", &fingerprint, &live)
|
||||
.unwrap();
|
||||
assert_eq!(adopted.len(), 1);
|
||||
assert_eq!(adopted[0].connection_id, "new-conn");
|
||||
assert!(manager.list(Some("old-conn")).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
manager.list(Some("new-conn")).unwrap()[0].forward_id,
|
||||
"rule-1"
|
||||
);
|
||||
manager.stop_connection("new-conn").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopt_skips_runtimes_still_owned_by_live_connection() {
|
||||
let manager = TunnelManager::default();
|
||||
let config = unreachable_config();
|
||||
manager
|
||||
.start_dynamic(Some("rule-live".to_string()), "old-conn", config.clone(), 0)
|
||||
.unwrap();
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let live = HashSet::from(["old-conn".to_string(), "new-conn".to_string()]);
|
||||
let adopted = manager
|
||||
.adopt_orphans("new-conn", &fingerprint, &live)
|
||||
.unwrap();
|
||||
assert!(adopted.is_empty());
|
||||
assert_eq!(manager.list(Some("old-conn")).unwrap().len(), 1);
|
||||
assert!(manager.list(Some("new-conn")).unwrap().is_empty());
|
||||
manager.stop_connection("old-conn").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_runtime_is_reclaimed_on_start() {
|
||||
let manager = TunnelManager::default();
|
||||
manager
|
||||
.start_dynamic(
|
||||
Some("same-id".to_string()),
|
||||
"connection-test",
|
||||
unreachable_config(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
{
|
||||
let runtimes = manager.runtimes.lock().unwrap();
|
||||
runtimes
|
||||
.get("same-id")
|
||||
.unwrap()
|
||||
.running
|
||||
.store(false, Ordering::Release);
|
||||
}
|
||||
let restarted = manager
|
||||
.start_dynamic(
|
||||
Some("same-id".to_string()),
|
||||
"connection-test",
|
||||
unreachable_config(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restarted.forward_id, "same-id");
|
||||
assert!(restarted.running);
|
||||
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
|
||||
manager.stop_connection("connection-test").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ pub(super) struct TunnelCounters {
|
||||
|
||||
pub(super) struct TunnelRuntime {
|
||||
pub(super) descriptor: ForwardStatus,
|
||||
/// SSH identity this runtime was started from (`user@host:port`).
|
||||
/// Used to rebind listeners onto a new connection id after reconnect.
|
||||
pub(super) fingerprint: String,
|
||||
pub(super) cancel: Arc<AtomicBool>,
|
||||
pub(super) running: Arc<AtomicBool>,
|
||||
pub(super) counters: Arc<TunnelCounters>,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ssh2::ErrorCode;
|
||||
|
||||
use crate::domain::ssh::SshConnectRequest;
|
||||
|
||||
use super::super::ssh::establish_session;
|
||||
|
||||
/// One long-lived SSH session per forwarding rule.
|
||||
/// Local / dynamic listeners stay bound; this session is only the transport
|
||||
/// used to open `direct-tcpip` channels. If it dies we reconnect it —
|
||||
/// we do not tear down the local TCP listener.
|
||||
pub(super) struct TunnelSession {
|
||||
config: SshConnectRequest,
|
||||
session: Mutex<Option<ssh2::Session>>,
|
||||
}
|
||||
|
||||
impl TunnelSession {
|
||||
pub(super) fn new(config: SshConnectRequest) -> Self {
|
||||
Self {
|
||||
config,
|
||||
session: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Warm the session so the first client does not pay a full handshake.
|
||||
/// Failure is non-fatal: the local listener stays up and we retry later.
|
||||
pub(super) fn warm(&self) {
|
||||
if let Err(error) = self.ensure_session() {
|
||||
tracing::warn!("端口转发预热 SSH 失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_direct(
|
||||
&self,
|
||||
host: &str,
|
||||
port: u16,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(ssh2::Session, ssh2::Channel), String> {
|
||||
const ATTEMPTS: u32 = 2;
|
||||
let mut last_error = "端口转发 SSH 不可用".to_string();
|
||||
for _ in 1..=ATTEMPTS {
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
return Err("端口转发已停止".to_string());
|
||||
}
|
||||
let session = match self.ensure_session() {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
last_error = error;
|
||||
self.invalidate();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match open_channel_nonblock(&session, host, port, cancel) {
|
||||
Ok(channel) => return Ok((session, channel)),
|
||||
Err(error) => {
|
||||
last_error = error;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Send a protocol keepalive. If the session is gone, try to bring it back
|
||||
/// so an idle SOCKS / local forward does not wait until the next client.
|
||||
pub(super) fn keepalive(&self) {
|
||||
let current = self.session.lock().ok().and_then(|guard| guard.clone());
|
||||
match current {
|
||||
Some(session) => {
|
||||
if session.keepalive_send().is_err() {
|
||||
tracing::warn!("端口转发 SSH 保活失败,将重建会话");
|
||||
self.invalidate();
|
||||
self.warm();
|
||||
}
|
||||
}
|
||||
None => self.warm(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close(&self) {
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
fn ensure_session(&self) -> Result<ssh2::Session, String> {
|
||||
let mut guard = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| "端口转发会话锁异常".to_string())?;
|
||||
if let Some(session) = guard.as_ref() {
|
||||
return Ok(session.clone());
|
||||
}
|
||||
let session = establish_session(&self.config)?;
|
||||
*guard = Some(session.clone());
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn invalidate(&self) {
|
||||
let Ok(mut guard) = self.session.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(session) = guard.take() {
|
||||
let _ = session.disconnect(None, "tunnel session reset", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_channel_nonblock(
|
||||
session: &ssh2::Session,
|
||||
host: &str,
|
||||
port: u16,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<ssh2::Channel, String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if cancel.load(Ordering::Acquire) {
|
||||
return Err("端口转发已停止".to_string());
|
||||
}
|
||||
match session.channel_direct_tcpip(host, port, None) {
|
||||
Ok(channel) => return Ok(channel),
|
||||
Err(error) if error.code() == ErrorCode::Session(-37) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(format!("打开转发通道超时 {host}:{port}"));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
Err(error) => return Err(format!("打开转发通道失败 {host}:{port}: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
"transparent": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'",
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420 http://127.0.0.1:9001 http://localhost:9001 http://l123.91ai.io https://l123.91ai.io; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
@@ -44,8 +45,8 @@
|
||||
"signingIdentity": "-"
|
||||
},
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Professional SSH and SFTP workspace",
|
||||
"longDescription": "A native, secure, cross-platform SSH, SFTP, terminal and port-forwarding workspace."
|
||||
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
|
||||
"longDescription": "OpsXShell is a lightweight native ops terminal: short paths for SSH, SFTP, port forwarding, RDP/VNC, and serial — practical and human-friendly, without bastion or audit bloat."
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
|
||||
+14
-14
@@ -16,31 +16,31 @@ export const AI_PROVIDER_FAMILY_META: Record<
|
||||
{ label: string; description: string }
|
||||
> = {
|
||||
openai_compatible: {
|
||||
label: 'OpenAI兼容',
|
||||
description: '按量',
|
||||
label: 'OpenAI compatible',
|
||||
description: 'payg',
|
||||
},
|
||||
standalone: {
|
||||
label: '────────',
|
||||
label: 'Native APIs',
|
||||
description: '',
|
||||
},
|
||||
plan: {
|
||||
label: 'Plan计划',
|
||||
description: '套餐',
|
||||
label: 'Subscription plans',
|
||||
description: 'plan',
|
||||
},
|
||||
local: {
|
||||
label: '私有化本地',
|
||||
description: '本地',
|
||||
label: 'Self-hosted',
|
||||
description: 'local',
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom自定义',
|
||||
description: '自定义',
|
||||
label: 'Custom',
|
||||
description: 'custom',
|
||||
},
|
||||
}
|
||||
|
||||
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
|
||||
payg: '按量',
|
||||
plan: 'Plan',
|
||||
local: '本地',
|
||||
payg: 'payg',
|
||||
plan: 'plan',
|
||||
local: 'local',
|
||||
}
|
||||
|
||||
const model = (
|
||||
@@ -292,8 +292,8 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
// ——— Custom ———
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom自定义',
|
||||
description: '自定义',
|
||||
name: 'Custom',
|
||||
description: 'Custom endpoint',
|
||||
family: 'custom',
|
||||
presetId: 'custom',
|
||||
protocol: 'openai-chat',
|
||||
|
||||
+53
-23
@@ -33,7 +33,7 @@ import {
|
||||
isEncryptedSessionBackup,
|
||||
} from '../utils/sessionBackupCrypto'
|
||||
import { usePromptDialog } from '../components/common/PromptDialog'
|
||||
import { licenseServerBaseUrl } from '../stores/entitlementStore'
|
||||
import { claimPurchase } from '../platform/licenseApi'
|
||||
|
||||
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
|
||||
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
|
||||
@@ -87,6 +87,9 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
||||
void window.opsxApi?.applyNativeMenuLocale?.(settings.language).catch(() => {
|
||||
// Native menu sync is best-effort (browser / tests have no menu bar).
|
||||
})
|
||||
}, [settingsLoaded, settings.language])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,6 +100,7 @@ function App() {
|
||||
idleTimeoutMs: settings.idleTimeoutMs,
|
||||
suspendTimeoutMs: settings.suspendTimeoutMs,
|
||||
maxReconnectAttempts: settings.maxReconnectAttempts,
|
||||
keepaliveIntervalMs: Math.max(5, settings.keepAliveIntervalSec || 15) * 1000,
|
||||
})
|
||||
}, [settings, settingsLoaded])
|
||||
|
||||
@@ -470,35 +474,53 @@ function App() {
|
||||
void (async () => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const tokenFromQuery = parsed.searchParams.get('token')
|
||||
if (tokenFromQuery) {
|
||||
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
|
||||
if (result.success) {
|
||||
toast.success(t('common:pro.licenseActivated'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
}
|
||||
}
|
||||
const q = parsed.searchParams
|
||||
const licenseKey =
|
||||
q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
|
||||
const orderNo = q.get('orderNo') || q.get('order_no') || ''
|
||||
const emailFromLink =
|
||||
q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
|
||||
const store = useEntitlementStore.getState()
|
||||
const deviceId =
|
||||
useEntitlementStore.getState().deviceId ||
|
||||
store.deviceId ||
|
||||
q.get('deviceId') ||
|
||||
q.get('device_id') ||
|
||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||
if (!deviceId) return
|
||||
const res = await fetch(`${licenseServerBaseUrl()}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId }),
|
||||
})
|
||||
const data = (await res.json()) as { token?: string; error?: string }
|
||||
if (data.token) {
|
||||
const result = await useEntitlementStore.getState().activate(data.token)
|
||||
|
||||
if (emailFromLink) {
|
||||
await store.setEmail(emailFromLink)
|
||||
}
|
||||
|
||||
const email = (emailFromLink || store.email).trim()
|
||||
|
||||
const tryActivate = async (key: string) => {
|
||||
const result = await store.activate(key, email || undefined)
|
||||
if (result.success) {
|
||||
toast.success(t('common:pro.licenseActivated'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
store.openUpgradeTab()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (licenseKey) {
|
||||
if (await tryActivate(licenseKey)) return
|
||||
} else if (orderNo || email) {
|
||||
toast.info(t('common:pro.claimPending'))
|
||||
try {
|
||||
const claimed = await claimPurchase({
|
||||
customerEmail: email || undefined,
|
||||
orderNo: orderNo || undefined,
|
||||
deviceId,
|
||||
})
|
||||
if (await tryActivate(claimed.licenseKey)) return
|
||||
} catch (error) {
|
||||
logger.warn('claim purchase pending', error)
|
||||
}
|
||||
}
|
||||
|
||||
toast.info(t('common:pro.paymentPending'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
store.openUpgradeTab()
|
||||
} catch (error) {
|
||||
logger.warn('处理 Pro 深链接失败', error)
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
@@ -601,6 +623,14 @@ function App() {
|
||||
if (!requirePro('serial')) return
|
||||
setIsSerialConnectOpen(true)
|
||||
}}
|
||||
onRdpConnect={() => {
|
||||
if (!requirePro('rdp')) return
|
||||
setDesktopConnectProtocol('rdp')
|
||||
}}
|
||||
onVncConnect={() => {
|
||||
if (!requirePro('vnc')) return
|
||||
setDesktopConnectProtocol('vnc')
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
|
||||
+52
-31
@@ -5,6 +5,8 @@
|
||||
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
|
||||
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
|
||||
* - 标签栏空白区也由 Tauri 原生拖拽区处理
|
||||
*
|
||||
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -15,7 +17,9 @@ import TabBar from '../components/layout/TabBar'
|
||||
import TabContent from '../components/layout/TabContent'
|
||||
import StatusBar from '../components/layout/StatusBar'
|
||||
import type { Session } from '../stores'
|
||||
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
|
||||
import { getDesktopPlatform } from '../platform/runtime'
|
||||
import { startWindowDragging } from '../platform/tauri'
|
||||
|
||||
export interface AppShellProps {
|
||||
activeTabId: string | null
|
||||
@@ -42,7 +46,11 @@ function TitleBar() {
|
||||
return (
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
className="bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
void startWindowDragging()
|
||||
}}
|
||||
className="relative z-20 bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||
>
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2" />
|
||||
@@ -114,13 +122,18 @@ export default function AppShell({
|
||||
}
|
||||
|
||||
const desktopSidebarHidden = !isMobile && sidebarCollapsed
|
||||
const desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-dark-900 text-content-primary">
|
||||
<TitleBar />
|
||||
<div
|
||||
className={`flex h-screen flex-col overflow-hidden text-content-primary ${
|
||||
desktopFullscreen ? 'bg-black' : 'bg-dark-900'
|
||||
}`}
|
||||
>
|
||||
{!desktopFullscreen && <TitleBar />}
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{isMobile && mobileSidebarOpen && (
|
||||
{!desktopFullscreen && isMobile && mobileSidebarOpen && (
|
||||
<div
|
||||
className="titlebar-no-drag fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
|
||||
onClick={onCloseMobileSidebar}
|
||||
@@ -128,32 +141,38 @@ export default function AppShell({
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
||||
isMobile
|
||||
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
|
||||
: desktopSidebarHidden
|
||||
? 'w-0 overflow-hidden border-0'
|
||||
: ''
|
||||
} `}
|
||||
aria-hidden={desktopSidebarHidden}
|
||||
>
|
||||
<Sidebar
|
||||
onNewSession={handleSidebarNewSession}
|
||||
onQuickConnect={handleSidebarQuickConnect}
|
||||
onEditSession={handleSidebarEditSession}
|
||||
onCollapse={isMobile ? undefined : onToggleSidebar}
|
||||
/>
|
||||
</aside>
|
||||
{!desktopFullscreen && (
|
||||
<aside
|
||||
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
||||
isMobile
|
||||
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
|
||||
: desktopSidebarHidden
|
||||
? 'w-0 overflow-hidden border-0'
|
||||
: ''
|
||||
} `}
|
||||
aria-hidden={desktopSidebarHidden}
|
||||
>
|
||||
<Sidebar
|
||||
onNewSession={handleSidebarNewSession}
|
||||
onQuickConnect={handleSidebarQuickConnect}
|
||||
onEditSession={handleSidebarEditSession}
|
||||
onCollapse={isMobile ? undefined : onToggleSidebar}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-dark-800">
|
||||
<div className="flex items-stretch">
|
||||
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
||||
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabBar onOpenLauncher={onOpenLauncher} />
|
||||
<main
|
||||
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
|
||||
>
|
||||
{!desktopFullscreen && (
|
||||
<div className="flex items-stretch">
|
||||
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
||||
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabBar onOpenLauncher={onOpenLauncher} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
|
||||
<TabContent
|
||||
@@ -167,9 +186,11 @@ export default function AppShell({
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className="titlebar-no-drag">
|
||||
<StatusBar />
|
||||
</div>
|
||||
{!desktopFullscreen && (
|
||||
<div className="titlebar-no-drag">
|
||||
<StatusBar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="titlebar-no-drag">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Professional modal shell — shared by dialogs across OpsXShell
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect, useId } from 'react'
|
||||
import { type ReactNode, useEffect, useId, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X } from 'lucide-react'
|
||||
import Button from './Button'
|
||||
@@ -62,21 +62,65 @@ export default function Modal({
|
||||
const resolvedConfirm = confirmText ?? t('action.confirm')
|
||||
const resolvedCancel = cancelText ?? t('action.cancel')
|
||||
const titleId = useId()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
) ?? []
|
||||
).filter((el) => !el.hasAttribute('disabled') && el.tabIndex !== -1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const root = panelRef.current
|
||||
if (!root) return
|
||||
if (root.contains(document.activeElement)) return
|
||||
const target =
|
||||
root.querySelector<HTMLElement>('[data-autofocus]') ??
|
||||
root.querySelector<HTMLElement>('input:not([disabled]), textarea, select') ??
|
||||
focusables()[0]
|
||||
target?.focus()
|
||||
})
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !panelRef.current) return
|
||||
const items = focusables()
|
||||
if (items.length === 0) return
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
// Lock body scroll while open
|
||||
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
window.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('keydown', handleKey, true)
|
||||
restoreFocusRef.current?.focus()
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -93,6 +137,7 @@ export default function Modal({
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`
|
||||
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
|
||||
flex flex-col
|
||||
@@ -144,6 +189,7 @@ export default function Modal({
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
data-autofocus={isDanger ? true : undefined}
|
||||
>
|
||||
{resolvedConfirm}
|
||||
</Button>
|
||||
|
||||
@@ -119,6 +119,7 @@ export function usePromptDialog() {
|
||||
<p className="text-content-secondary mb-3">{state.message}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-autofocus
|
||||
type={state.inputType ?? 'text'}
|
||||
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
|
||||
value={state.value}
|
||||
|
||||
@@ -18,31 +18,31 @@ const iconMap: Record<ToastType, typeof CheckCircle> = {
|
||||
info: Info,
|
||||
}
|
||||
|
||||
/** 类型 → 颜色样式映射 */
|
||||
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
|
||||
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
||||
success: {
|
||||
bg: 'bg-green-900/40',
|
||||
border: 'border-green-700/60',
|
||||
icon: 'text-green-400',
|
||||
text: 'text-green-200',
|
||||
bg: 'bg-feedback-success-bg',
|
||||
border: 'border-feedback-success/35',
|
||||
icon: 'text-feedback-success',
|
||||
text: 'text-content-primary',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-900/40',
|
||||
border: 'border-red-700/60',
|
||||
icon: 'text-red-400',
|
||||
text: 'text-red-200',
|
||||
bg: 'bg-feedback-error-bg',
|
||||
border: 'border-feedback-error/35',
|
||||
icon: 'text-feedback-error',
|
||||
text: 'text-content-primary',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-surface-basember-900/40',
|
||||
border: 'border-amber-700/60',
|
||||
icon: 'text-amber-400',
|
||||
text: 'text-amber-200',
|
||||
bg: 'bg-feedback-warning-bg',
|
||||
border: 'border-feedback-warning/35',
|
||||
icon: 'text-feedback-warning',
|
||||
text: 'text-content-primary',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-900/40',
|
||||
border: 'border-blue-700/60',
|
||||
icon: 'text-blue-400',
|
||||
text: 'text-blue-200',
|
||||
bg: 'bg-feedback-info-bg',
|
||||
border: 'border-feedback-info/35',
|
||||
icon: 'text-feedback-info',
|
||||
text: 'text-content-primary',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -11,17 +11,17 @@ interface ToastContainerProps {
|
||||
}
|
||||
|
||||
const iconMap: Record<ToastType, React.ReactNode> = {
|
||||
info: <Info className="w-4 h-4 text-blue-400" />,
|
||||
success: <CheckCircle className="w-4 h-4 text-green-400" />,
|
||||
error: <XCircle className="w-4 h-4 text-red-400" />,
|
||||
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
|
||||
info: <Info className="w-4 h-4 text-feedback-info" />,
|
||||
success: <CheckCircle className="w-4 h-4 text-feedback-success" />,
|
||||
error: <XCircle className="w-4 h-4 text-feedback-error" />,
|
||||
warning: <AlertTriangle className="w-4 h-4 text-feedback-warning" />,
|
||||
}
|
||||
|
||||
const bgMap: Record<ToastType, string> = {
|
||||
info: 'bg-blue-900/80 border-blue-700',
|
||||
success: 'bg-green-900/80 border-green-700',
|
||||
error: 'bg-red-900/80 border-red-700',
|
||||
warning: 'bg-yellow-900/80 border-yellow-700',
|
||||
info: 'bg-feedback-info-bg border-feedback-info/35',
|
||||
success: 'bg-feedback-success-bg border-feedback-success/35',
|
||||
error: 'bg-feedback-error-bg border-feedback-error/35',
|
||||
warning: 'bg-feedback-warning-bg border-feedback-warning/35',
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||
|
||||
@@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
conn.state === 'connected'
|
||||
? 'bg-green-400/10 text-green-400'
|
||||
? 'bg-feedback-success/10 text-feedback-success'
|
||||
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
||||
? 'bg-yellow-400/10 text-yellow-400'
|
||||
? 'bg-feedback-warning/10 text-feedback-warning'
|
||||
: conn.state === 'background'
|
||||
? 'bg-green-400/10 text-green-400 opacity-70'
|
||||
? 'bg-feedback-success/10 text-feedback-success opacity-70'
|
||||
: conn.state === 'suspended'
|
||||
? 'bg-surface-active/10 text-txt-muted'
|
||||
: conn.state === 'error'
|
||||
? 'bg-red-400/10 text-red-400'
|
||||
? 'bg-feedback-error/10 text-feedback-error'
|
||||
: 'bg-surface-active/10 text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
@@ -200,9 +200,9 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const items = [
|
||||
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
|
||||
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-green-400' },
|
||||
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-yellow-400' },
|
||||
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
|
||||
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-feedback-success' },
|
||||
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-feedback-warning' },
|
||||
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-feedback-error' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,10 +11,22 @@ import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
|
||||
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
|
||||
import type { ForwardRule, ForwardRuleType } from './forwardRules'
|
||||
import {
|
||||
DEFAULT_LOCAL_BIND_HOST,
|
||||
DEFAULT_REMOTE_BIND_HOST,
|
||||
formatHostPort,
|
||||
isAllInterfacesBind,
|
||||
parseHostPort,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
type ForwardRule,
|
||||
type ForwardRuleType,
|
||||
} from './forwardRules'
|
||||
|
||||
export const DEFAULT_DYNAMIC_PORT = 1080
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||
export function resolveDynamicPort(port: unknown): number {
|
||||
const value = Number(port)
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
|
||||
@@ -25,6 +37,8 @@ export interface ForwardRuleDraft {
|
||||
type: ForwardRuleType
|
||||
name: string
|
||||
localPort: number
|
||||
localBindHost: string
|
||||
remoteBindHost: string
|
||||
remoteHost: string
|
||||
remotePort: number
|
||||
}
|
||||
@@ -43,6 +57,8 @@ interface ForwardRuleDrawerProps {
|
||||
rules?: ForwardRule[]
|
||||
runtimes?: Map<string, ForwardRuleRuntime>
|
||||
busy?: boolean
|
||||
/** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */
|
||||
viaSession?: string
|
||||
onChange: (next: Partial<ForwardRule>) => void
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
@@ -55,36 +71,74 @@ const EMPTY_DRAFT: ForwardRuleDraft = {
|
||||
type: 'local',
|
||||
name: '',
|
||||
localPort: 0,
|
||||
remoteHost: '',
|
||||
localBindHost: DEFAULT_LOCAL_BIND_HOST,
|
||||
remoteBindHost: DEFAULT_REMOTE_BIND_HOST,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 0,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||
export function emptyForwardDraft(): ForwardRuleDraft {
|
||||
return { ...EMPTY_DRAFT }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
|
||||
return {
|
||||
type: 'dynamic',
|
||||
name,
|
||||
localPort: DEFAULT_DYNAMIC_PORT,
|
||||
localBindHost: '',
|
||||
remoteBindHost: '',
|
||||
remoteHost: '',
|
||||
remotePort: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function ruleSummary(rule: ForwardRule, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
function ruleSummary(
|
||||
rule: ForwardRule,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
boundPort?: number
|
||||
): string {
|
||||
if (rule.type === 'dynamic') {
|
||||
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
|
||||
return t('portForward.socksProxy', { port: resolveDynamicPort(boundPort ?? rule.localPort) })
|
||||
}
|
||||
if (rule.type === 'remote') {
|
||||
const bind = resolveRemoteBindHost(rule.remoteBindHost)
|
||||
const remote = boundPort ?? rule.remotePort
|
||||
const host = resolveForwardTargetHost(rule.remoteHost)
|
||||
// Default remote bind is loopback — list as port only (matches ssh -R port:…).
|
||||
if (bind === DEFAULT_REMOTE_BIND_HOST || bind === 'localhost') {
|
||||
return t('portForward.remoteToLocalPort', {
|
||||
remote,
|
||||
host,
|
||||
local: rule.localPort,
|
||||
})
|
||||
}
|
||||
return t('portForward.remoteToLocal', {
|
||||
remote: rule.remotePort,
|
||||
bind,
|
||||
remote,
|
||||
host,
|
||||
local: rule.localPort,
|
||||
})
|
||||
}
|
||||
const host = rule.remoteHost || 'localhost'
|
||||
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
|
||||
const bind = resolveLocalBindHost(rule.localBindHost)
|
||||
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
|
||||
const local = boundPort ?? rule.localPort
|
||||
// Default bind is localhost — list as port only (matches ssh -L 8080:…).
|
||||
if (bind === DEFAULT_LOCAL_BIND_HOST || bind === 'localhost') {
|
||||
return t('portForward.localToRemotePort', {
|
||||
local,
|
||||
host,
|
||||
remote: rule.remotePort,
|
||||
})
|
||||
}
|
||||
return t('portForward.localToRemote', {
|
||||
bind,
|
||||
local,
|
||||
host,
|
||||
remote: rule.remotePort,
|
||||
})
|
||||
}
|
||||
|
||||
export default function ForwardRuleDrawer({
|
||||
@@ -95,6 +149,7 @@ export default function ForwardRuleDrawer({
|
||||
rules,
|
||||
runtimes,
|
||||
busy = false,
|
||||
viaSession,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -152,17 +207,33 @@ export default function ForwardRuleDrawer({
|
||||
type: 'dynamic',
|
||||
name: nextName,
|
||||
localPort: DEFAULT_DYNAMIC_PORT,
|
||||
localBindHost: '',
|
||||
remoteBindHost: '',
|
||||
remoteHost: '',
|
||||
remotePort: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
|
||||
const nextRemotePort =
|
||||
draft.type === 'dynamic'
|
||||
? next === 'remote'
|
||||
? 18080
|
||||
: 80
|
||||
: draft.remotePort || (next === 'remote' ? 18080 : 80)
|
||||
|
||||
onChange({
|
||||
...draft,
|
||||
type: next,
|
||||
name: nextName,
|
||||
localPort: draft.type === 'dynamic' ? 0 : draft.localPort,
|
||||
localPort: nextLocalPort,
|
||||
localBindHost:
|
||||
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
|
||||
remoteBindHost:
|
||||
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
|
||||
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
|
||||
remotePort: nextRemotePort,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,7 +363,8 @@ export default function ForwardRuleDrawer({
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{localRemoteRules.map((rule) => {
|
||||
const running = Boolean(runtimes?.get(rule.id)?.running)
|
||||
const runtime = runtimes?.get(rule.id)
|
||||
const running = Boolean(runtime?.running)
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
@@ -339,7 +411,7 @@ export default function ForwardRuleDrawer({
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
|
||||
{ruleSummary(rule, t)}
|
||||
{ruleSummary(rule, t, runtime?.boundPort)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={draft.localPort || ''}
|
||||
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
|
||||
placeholder={t('portForward.portPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={draft.remotePort || ''}
|
||||
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
|
||||
placeholder={t('portForward.portPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'local' && (
|
||||
<div>
|
||||
<label className="ui-label">{t('portForward.remoteHost')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.remoteHost || ''}
|
||||
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
|
||||
placeholder={t('portForward.remoteHostPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{type === 'local' ? (
|
||||
<>
|
||||
{viaSession ? (
|
||||
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
{t('portForward.viaSession', { target: viaSession })}
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
<label className="ui-label">{t('portForward.localListenPort')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={draft.localPort || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...draft,
|
||||
localPort: Number(e.target.value),
|
||||
localBindHost: resolveLocalBindHost(draft.localBindHost),
|
||||
})
|
||||
}
|
||||
placeholder="8080"
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{t('portForward.localListenPortHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">{t('portForward.targetAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatHostPort(
|
||||
resolveForwardTargetHost(draft.remoteHost),
|
||||
draft.remotePort
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim()
|
||||
const parsed = parseHostPort(raw)
|
||||
if (parsed) {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(parsed.host),
|
||||
remotePort: parsed.port,
|
||||
})
|
||||
return
|
||||
}
|
||||
const colon = raw.lastIndexOf(':')
|
||||
if (colon > 0) {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
|
||||
remotePort: Number(raw.slice(colon + 1)) || 0,
|
||||
})
|
||||
} else {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
|
||||
})
|
||||
}
|
||||
}}
|
||||
placeholder={t('portForward.targetAddressPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{viaSession
|
||||
? t('portForward.targetAddressHintVia', { target: viaSession })
|
||||
: t('portForward.targetAddressHint')}
|
||||
</p>
|
||||
</div>
|
||||
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
|
||||
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||
{t('portForward.localBindAdvanced')}
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<label className="ui-label">{t('portForward.localBindHost')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={resolveLocalBindHost(draft.localBindHost)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...draft,
|
||||
localBindHost: resolveLocalBindHost(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder={t('portForward.localBindHostPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{t('portForward.localBindHostHint')}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{viaSession ? (
|
||||
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
{t('portForward.viaSessionRemote', { target: viaSession })}
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
<label className="ui-label">{t('portForward.remoteListenPort')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={draft.remotePort || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...draft,
|
||||
remotePort: Number(e.target.value),
|
||||
remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost),
|
||||
})
|
||||
}
|
||||
placeholder="18080"
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{t('portForward.remoteListenPortHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatHostPort(
|
||||
resolveForwardTargetHost(draft.remoteHost),
|
||||
draft.localPort
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim()
|
||||
const parsed = parseHostPort(raw)
|
||||
if (parsed) {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(parsed.host),
|
||||
localPort: parsed.port,
|
||||
})
|
||||
return
|
||||
}
|
||||
const colon = raw.lastIndexOf(':')
|
||||
if (colon > 0) {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
|
||||
localPort: Number(raw.slice(colon + 1)) || 0,
|
||||
})
|
||||
} else {
|
||||
onChange({
|
||||
...draft,
|
||||
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
|
||||
})
|
||||
}
|
||||
}}
|
||||
placeholder={t('portForward.localTargetAddressPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{t('portForward.localTargetAddressHint')}
|
||||
</p>
|
||||
</div>
|
||||
<details
|
||||
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"
|
||||
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
|
||||
>
|
||||
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||
{t('portForward.remoteBindAdvanced')}
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<label className="ui-label">{t('portForward.remoteBindHost')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={resolveRemoteBindHost(draft.remoteBindHost)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...draft,
|
||||
remoteBindHost: resolveRemoteBindHost(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder={t('portForward.remoteBindHostPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
|
||||
{t('portForward.remoteBindHostHint')}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConnectionPool } from '../../stores/connection'
|
||||
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
import ForwardRuleDrawer, {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
@@ -21,9 +22,19 @@ import {
|
||||
duplicateForwardRule,
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
runtimeMap,
|
||||
type ForwardRule,
|
||||
} from './forwardRules'
|
||||
import {
|
||||
forgetForwardIntent,
|
||||
forwardIntentKey,
|
||||
rememberForwardIntent,
|
||||
restoreForwardIntent,
|
||||
tunnelCoversRule,
|
||||
} from './forwardIntent'
|
||||
|
||||
interface PortForwardViewProps {
|
||||
tabId: string
|
||||
@@ -41,6 +52,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
const connectionId = currentTab?.connectionInstance?.connectionId
|
||||
const connection = connectionId ? pool.getConnection(connectionId) : null
|
||||
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
|
||||
const intentKey = forwardIntentKey(currentTab?.sessionId, tabId)
|
||||
|
||||
const [rules, setRules] = useState<ForwardRule[]>([])
|
||||
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
|
||||
@@ -115,6 +127,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
...emptyForwardDraft(),
|
||||
type: 'local',
|
||||
name: t('common:portForward.defaultLocalName'),
|
||||
localBindHost: '127.0.0.1',
|
||||
remoteHost: '127.0.0.1',
|
||||
})
|
||||
setShowAddModal(true)
|
||||
}
|
||||
@@ -127,11 +141,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
}
|
||||
|
||||
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
|
||||
setDrawerError(t('common:portForward.errorTargetPort'))
|
||||
setDrawerError(
|
||||
newRule.type === 'local'
|
||||
? t('common:portForward.errorTargetAddress')
|
||||
: t('common:portForward.errorRemoteListenAddress')
|
||||
)
|
||||
return
|
||||
}
|
||||
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
|
||||
setDrawerError(t('common:portForward.errorRemoteHost'))
|
||||
if (newRule.type !== 'dynamic' && !newRule.remoteHost?.trim()) {
|
||||
setDrawerError(
|
||||
newRule.type === 'local'
|
||||
? t('common:portForward.errorTargetAddress')
|
||||
: t('common:portForward.errorLocalTargetAddress')
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -148,7 +170,12 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
||||
name: newRule.name.trim(),
|
||||
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
|
||||
remoteHost: newRule.remoteHost?.trim(),
|
||||
localBindHost:
|
||||
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
|
||||
remoteBindHost:
|
||||
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
|
||||
remoteHost:
|
||||
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
|
||||
remotePort: newRule.remotePort,
|
||||
}
|
||||
|
||||
@@ -172,17 +199,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
result = await window.opsxApi?.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
rule.remoteHost || '',
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.remotePort || 0,
|
||||
rule.id
|
||||
rule.id,
|
||||
resolveLocalBindHost(rule.localBindHost)
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.opsxApi?.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
'127.0.0.1',
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.localPort,
|
||||
rule.id
|
||||
rule.id,
|
||||
resolveRemoteBindHost(rule.remoteBindHost)
|
||||
)
|
||||
} else {
|
||||
result = await window.opsxApi?.sshForwardDynamic(
|
||||
@@ -193,18 +222,43 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
rememberForwardIntent(intentKey, rule)
|
||||
await refreshTunnels()
|
||||
return true
|
||||
}
|
||||
setError(result?.error || t('common:portForward.errorStartFailed'))
|
||||
setError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStartFailed')
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStartFailed'))
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const startForwardRef = useRef(startForward)
|
||||
startForwardRef.current = startForward
|
||||
const previousConnectionRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!connectionId) return
|
||||
const previous = previousConnectionRef.current
|
||||
previousConnectionRef.current = connectionId
|
||||
if (!previous || previous === connectionId) return
|
||||
void (async () => {
|
||||
const listed = await window.opsxApi?.sshForwardList(connectionId)
|
||||
const tunnels = listed?.success ? listed.tunnels || [] : []
|
||||
await restoreForwardIntent(
|
||||
intentKey,
|
||||
(rule) => tunnelCoversRule(tunnels, rule),
|
||||
(rule) => startForwardRef.current(rule)
|
||||
)
|
||||
})()
|
||||
}, [connectionId, intentKey])
|
||||
|
||||
const quickStartSocks = async () => {
|
||||
const existing = rules.find((rule) => rule.type === 'dynamic')
|
||||
if (existing) {
|
||||
@@ -230,11 +284,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.opsxApi?.sshForwardStop(rule.id)
|
||||
let result = await window.opsxApi?.sshForwardStop(rule.id)
|
||||
if (!result?.success && connectionId) {
|
||||
const listed = await window.opsxApi?.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||
if (match) {
|
||||
result = await window.opsxApi?.sshForwardStop(match.forwardId)
|
||||
}
|
||||
}
|
||||
if (!result?.success) {
|
||||
setError(result?.error || t('common:portForward.errorStopFailed'))
|
||||
return false
|
||||
}
|
||||
forgetForwardIntent(intentKey, rule.id)
|
||||
await refreshTunnels()
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
@@ -416,14 +478,44 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
|
||||
) : rule.type === 'local' ? (
|
||||
<span>
|
||||
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
|
||||
{(() => {
|
||||
const bind = resolveLocalBindHost(rule.localBindHost)
|
||||
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
|
||||
if (bind === '127.0.0.1' || bind === 'localhost') {
|
||||
return t('common:portForward.localToRemotePort', {
|
||||
local: boundPort,
|
||||
host,
|
||||
remote: rule.remotePort,
|
||||
})
|
||||
}
|
||||
return t('common:portForward.localToRemote', {
|
||||
bind,
|
||||
local: boundPort,
|
||||
host,
|
||||
remote: rule.remotePort,
|
||||
})
|
||||
})()}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('common:portForward.remoteToLocal', {
|
||||
remote: runtime?.boundPort || rule.remotePort,
|
||||
local: rule.localPort,
|
||||
})}
|
||||
{(() => {
|
||||
const bind = resolveRemoteBindHost(rule.remoteBindHost)
|
||||
const remote = runtime?.boundPort || rule.remotePort
|
||||
const host = resolveForwardTargetHost(rule.remoteHost)
|
||||
if (bind === '127.0.0.1' || bind === 'localhost') {
|
||||
return t('common:portForward.remoteToLocalPort', {
|
||||
remote,
|
||||
host,
|
||||
local: rule.localPort,
|
||||
})
|
||||
}
|
||||
return t('common:portForward.remoteToLocal', {
|
||||
bind,
|
||||
remote,
|
||||
host,
|
||||
local: rule.localPort,
|
||||
})
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
import type { ForwardRule } from './forwardRules'
|
||||
|
||||
/**
|
||||
* Process-local "this rule should stay up" intent.
|
||||
* Never persisted — app restart must not resurrect listeners.
|
||||
* Survives SSH reconnect so the same session can restore forwards.
|
||||
*/
|
||||
const remembered = new Map<string, Map<string, ForwardRule>>()
|
||||
const resumeLocks = new Map<string, Promise<number>>()
|
||||
|
||||
export function forwardIntentKey(sessionId: string | undefined, tabId: string): string {
|
||||
return sessionId ? `session:${sessionId}` : `tab:${tabId}`
|
||||
}
|
||||
|
||||
export function rememberForwardIntent(key: string, rule: ForwardRule): void {
|
||||
let rules = remembered.get(key)
|
||||
if (!rules) {
|
||||
rules = new Map()
|
||||
remembered.set(key, rules)
|
||||
}
|
||||
rules.set(rule.id, { ...rule })
|
||||
}
|
||||
|
||||
export function forgetForwardIntent(key: string, ruleId: string): void {
|
||||
remembered.get(key)?.delete(ruleId)
|
||||
}
|
||||
|
||||
export function listForwardIntent(key: string): ForwardRule[] {
|
||||
return [...(remembered.get(key)?.values() ?? [])]
|
||||
}
|
||||
|
||||
export function tunnelCoversRule(tunnels: SSHForwardStatus[], rule: ForwardRule): boolean {
|
||||
return tunnels.some((item) => {
|
||||
if (!item.running || item.kind !== rule.type) return false
|
||||
if (item.forwardId === rule.id) return true
|
||||
// One dynamic SOCKS per SSH host.
|
||||
if (rule.type === 'dynamic') return true
|
||||
// boundPort is the listen port: local → localPort, remote → remotePort.
|
||||
if (rule.type === 'remote') {
|
||||
return item.boundPort === rule.remotePort
|
||||
}
|
||||
return item.boundPort === rule.localPort
|
||||
})
|
||||
}
|
||||
|
||||
/** Drop all remembered start intents for a tab/session (tab close). */
|
||||
export function clearForwardIntent(key: string): void {
|
||||
remembered.delete(key)
|
||||
resumeLocks.delete(key)
|
||||
}
|
||||
|
||||
/** Start remembered rules that are not running. Concurrent calls share one pass. */
|
||||
export function restoreForwardIntent(
|
||||
key: string,
|
||||
isRunning: (rule: ForwardRule) => boolean,
|
||||
start: (rule: ForwardRule) => Promise<boolean>
|
||||
): Promise<number> {
|
||||
const existing = resumeLocks.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const work = (async () => {
|
||||
let restored = 0
|
||||
for (const rule of listForwardIntent(key)) {
|
||||
if (isRunning(rule)) continue
|
||||
if (await start(rule)) restored += 1
|
||||
}
|
||||
return restored
|
||||
})().finally(() => {
|
||||
resumeLocks.delete(key)
|
||||
})
|
||||
|
||||
resumeLocks.set(key, work)
|
||||
return work
|
||||
}
|
||||
|
||||
export function resetForwardIntentForTests(): void {
|
||||
remembered.clear()
|
||||
resumeLocks.clear()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Monitor, Cast } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import DesktopResolutionPicker from './DesktopResolutionPicker'
|
||||
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
|
||||
import { clampDesktopResolution, getDisplayResolution } from '../../utils/desktopResolution'
|
||||
|
||||
interface DesktopConnectDialogProps {
|
||||
protocol: DesktopProtocol
|
||||
@@ -32,6 +34,9 @@ export default function DesktopConnectDialog({
|
||||
if (!isOpen) return
|
||||
setPort(isRdp ? 3389 : 5900)
|
||||
setError(null)
|
||||
const display = getDisplayResolution()
|
||||
setWidth(display.width)
|
||||
setHeight(display.height)
|
||||
}, [isOpen, isRdp])
|
||||
|
||||
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
|
||||
@@ -46,6 +51,7 @@ export default function DesktopConnectDialog({
|
||||
setError(t('connect.errorUsernameRequired'))
|
||||
return
|
||||
}
|
||||
const resolution = clampDesktopResolution(width, height)
|
||||
if (isRdp) {
|
||||
onConnect('rdp', {
|
||||
host: host.trim(),
|
||||
@@ -53,8 +59,8 @@ export default function DesktopConnectDialog({
|
||||
username: username.trim(),
|
||||
password,
|
||||
domain: domain.trim() || undefined,
|
||||
width,
|
||||
height,
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
security: 'nla',
|
||||
})
|
||||
} else {
|
||||
@@ -62,8 +68,8 @@ export default function DesktopConnectDialog({
|
||||
host: host.trim(),
|
||||
port,
|
||||
password: password || undefined,
|
||||
width,
|
||||
height,
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
})
|
||||
}
|
||||
onClose()
|
||||
@@ -145,26 +151,14 @@ export default function DesktopConnectDialog({
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
|
||||
<input
|
||||
id="desktop-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(event) => setWidth(Number(event.target.value) || 1920)}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="desktop-height" className="ui-label">{t('connect.height')}</label>
|
||||
<input
|
||||
id="desktop-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(event) => setHeight(Number(event.target.value) || 1080)}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<DesktopResolutionPicker
|
||||
width={width}
|
||||
height={height}
|
||||
onChange={(next) => {
|
||||
setWidth(next.width)
|
||||
setHeight(next.height)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<p className="text-[11px] text-txt-faint leading-relaxed">
|
||||
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DESKTOP_RESOLUTION_PRESETS,
|
||||
clampDesktopResolution,
|
||||
formatResolution,
|
||||
matchPresetId,
|
||||
type DesktopResolution,
|
||||
} from '../../utils/desktopResolution'
|
||||
|
||||
interface DesktopResolutionPickerProps {
|
||||
width: number
|
||||
height: number
|
||||
onChange: (next: DesktopResolution) => void
|
||||
ns?: 'desktop' | 'sessions'
|
||||
resolutionLabelKey?: string
|
||||
widthLabelKey?: string
|
||||
heightLabelKey?: string
|
||||
customKey?: string
|
||||
}
|
||||
|
||||
export default function DesktopResolutionPicker({
|
||||
width,
|
||||
height,
|
||||
onChange,
|
||||
ns = 'desktop',
|
||||
resolutionLabelKey = ns === 'desktop' ? 'connect.resolution' : 'dialog.resolution',
|
||||
widthLabelKey = ns === 'desktop' ? 'connect.width' : 'dialog.width',
|
||||
heightLabelKey = ns === 'desktop' ? 'connect.height' : 'dialog.height',
|
||||
customKey = ns === 'desktop' ? 'connect.resolutionCustom' : 'dialog.resolutionCustom',
|
||||
}: DesktopResolutionPickerProps) {
|
||||
const { t } = useTranslation(ns)
|
||||
const current = clampDesktopResolution(width, height)
|
||||
const matched = matchPresetId(current)
|
||||
const [forceCustom, setForceCustom] = useState(false)
|
||||
const selected = forceCustom || matched === 'custom' ? 'custom' : matched
|
||||
const showCustom = selected === 'custom'
|
||||
|
||||
return (
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<div>
|
||||
<label htmlFor={`${ns}-desktop-resolution`} className="ui-label">
|
||||
{t(resolutionLabelKey)}
|
||||
</label>
|
||||
<select
|
||||
id={`${ns}-desktop-resolution`}
|
||||
className="ui-input font-mono"
|
||||
value={selected}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (value === 'custom') {
|
||||
setForceCustom(true)
|
||||
onChange(current)
|
||||
return
|
||||
}
|
||||
const preset = DESKTOP_RESOLUTION_PRESETS.find((item) => item.id === value)
|
||||
if (!preset) return
|
||||
setForceCustom(false)
|
||||
onChange({ width: preset.width, height: preset.height })
|
||||
}}
|
||||
>
|
||||
{DESKTOP_RESOLUTION_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{formatResolution(preset)}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">{t(customKey)}</option>
|
||||
</select>
|
||||
</div>
|
||||
{showCustom && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={`${ns}-desktop-width`} className="ui-label">
|
||||
{t(widthLabelKey)}
|
||||
</label>
|
||||
<input
|
||||
id={`${ns}-desktop-width`}
|
||||
type="number"
|
||||
min={200}
|
||||
max={8192}
|
||||
step={2}
|
||||
value={width}
|
||||
onChange={(event) => {
|
||||
setForceCustom(true)
|
||||
onChange(clampDesktopResolution(Number(event.target.value) || current.width, height))
|
||||
}}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${ns}-desktop-height`} className="ui-label">
|
||||
{t(heightLabelKey)}
|
||||
</label>
|
||||
<input
|
||||
id={`${ns}-desktop-height`}
|
||||
type="number"
|
||||
min={200}
|
||||
max={8192}
|
||||
value={height}
|
||||
onChange={(event) => {
|
||||
setForceCustom(true)
|
||||
onChange(clampDesktopResolution(width, Number(event.target.value) || current.height))
|
||||
}}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Cast,
|
||||
ClipboardPaste,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Unplug,
|
||||
} from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||||
import type {
|
||||
DesktopFrameEvent,
|
||||
DesktopPointerButton,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
rdpDisconnect,
|
||||
rdpInputKey,
|
||||
rdpInputPointer,
|
||||
rdpResize,
|
||||
rdpUploadFile,
|
||||
} from '../../platform/rdp'
|
||||
import {
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
vncInputPointer,
|
||||
} from '../../platform/vnc'
|
||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||
import { clampDesktopResolution } from '../../utils/desktopResolution'
|
||||
|
||||
interface DesktopSessionViewProps {
|
||||
tabId: string
|
||||
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
|
||||
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
||||
const { t } = useTranslation('desktop')
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const paneRef = useRef<HTMLDivElement>(null)
|
||||
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const nativeSessionIdRef = useRef<string | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
@@ -77,7 +82,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
||||
const pointerRafRef = useRef<number | null>(null)
|
||||
const paintRafRef = useRef<number | null>(null)
|
||||
const hideBarTimerRef = useRef<number | null>(null)
|
||||
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||||
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
|
||||
const remoteResizeTimerRef = useRef<number | null>(null)
|
||||
const connectedRef = useRef(false)
|
||||
const pendingPointerRef = useRef<{
|
||||
x: number
|
||||
y: number
|
||||
@@ -87,10 +96,17 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||
const fullscreenActive = useDesktopFullscreenStore(
|
||||
(state) => state.active && state.tabId === tabId
|
||||
)
|
||||
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||||
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||||
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
||||
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
||||
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
||||
const [chromeVisible, setChromeVisible] = useState(true)
|
||||
|
||||
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
||||
isActiveRef.current = isActive
|
||||
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
setStatusMessage(t('status.connecting'))
|
||||
setError(null)
|
||||
connectedRef.current = false
|
||||
lastRemoteResizeRef.current = null
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenStatus = await onRdpStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (payload.width && payload.height) {
|
||||
setFbSize({ width: payload.width, height: payload.height })
|
||||
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
|
||||
}
|
||||
if (payload.status === 'connected') {
|
||||
connectedRef.current = true
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
} else if (payload.status === 'disconnected') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} else if (payload.message) {
|
||||
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
connectedRef.current = false
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
remoteResizeTimerRef.current = null
|
||||
}
|
||||
if (paintRafRef.current != null) {
|
||||
window.cancelAnimationFrame(paintRafRef.current)
|
||||
paintRafRef.current = null
|
||||
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
canvasRef.current?.focus()
|
||||
}, [isActive])
|
||||
|
||||
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
|
||||
useEffect(() => {
|
||||
const pane = paneRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||||
|
||||
const updateFit = () => {
|
||||
const availW = Math.max(1, pane.clientWidth)
|
||||
const availH = Math.max(1, pane.clientHeight)
|
||||
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
|
||||
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
|
||||
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
|
||||
canvas.style.width = `${displayW}px`
|
||||
canvas.style.height = `${displayH}px`
|
||||
}
|
||||
|
||||
const requestRemoteResize = () => {
|
||||
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
|
||||
const last = lastRemoteResizeRef.current
|
||||
if (last && last.width === next.width && last.height === next.height) return
|
||||
// Skip tiny panes (drawer/animation) that would thrash the session.
|
||||
if (next.width < 640 || next.height < 480) return
|
||||
lastRemoteResizeRef.current = next
|
||||
void rdpResize(sessionId, next.width, next.height).catch(() => {
|
||||
// Allow retry on next size change if Display Control was not ready.
|
||||
lastRemoteResizeRef.current = last
|
||||
})
|
||||
}
|
||||
|
||||
const onPaneResize = () => {
|
||||
updateFit()
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
}
|
||||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
|
||||
}
|
||||
|
||||
updateFit()
|
||||
// After fullscreen / maximize layout settles, request matching remote size once.
|
||||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
|
||||
const observer = new ResizeObserver(onPaneResize)
|
||||
observer.observe(pane)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
remoteResizeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
|
||||
|
||||
// Leave OS fullscreen when this tab is hidden or unmounted.
|
||||
useEffect(() => {
|
||||
if (!isActive && fullscreenActive) {
|
||||
void exitFullscreen()
|
||||
}
|
||||
}, [exitFullscreen, fullscreenActive, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
}
|
||||
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
|
||||
const state = useDesktopFullscreenStore.getState()
|
||||
if (state.active && state.tabId === tabId) {
|
||||
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
|
||||
if (!stillOpen) void state.exit()
|
||||
}
|
||||
}
|
||||
}, [tabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenActive) {
|
||||
setChromeVisible(true)
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
|
||||
const timer = window.setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const state = useDesktopFullscreenStore.getState()
|
||||
if (!state.active || state.tabId !== tabId) return
|
||||
if (Date.now() - state.enteredAt < 1600) return
|
||||
const osFs = await window.opsxApi?.isFullscreen?.()
|
||||
if (osFs === false) {
|
||||
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
}, 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fullscreenActive, tabId])
|
||||
|
||||
const scheduleHideChrome = useCallback(() => {
|
||||
if (!fullscreenActive) return
|
||||
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = window.setTimeout(() => {
|
||||
setChromeVisible(false)
|
||||
hideBarTimerRef.current = null
|
||||
}, 1800)
|
||||
}, [fullscreenActive])
|
||||
|
||||
const revealChrome = useCallback(() => {
|
||||
setChromeVisible(true)
|
||||
scheduleHideChrome()
|
||||
}, [scheduleHideChrome])
|
||||
|
||||
const flushPointer = () => {
|
||||
pointerRafRef.current = null
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
|
||||
// Local fullscreen controls — never forward to the remote desktop.
|
||||
if (event.key === 'F11') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (pressed) void toggleFullscreen(tabId)
|
||||
return
|
||||
}
|
||||
if (fullscreenActive && event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (pressed) void exitFullscreen()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
@@ -434,20 +596,6 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectSession = async () => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
try {
|
||||
if (protocol === 'rdp') await rdpDisconnect(sessionId)
|
||||
else await vncDisconnect(sessionId)
|
||||
nativeSessionIdRef.current = null
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = protocol === 'rdp' ? Monitor : Cast
|
||||
const hostLabel =
|
||||
resolvedConfig && 'host' in resolvedConfig
|
||||
@@ -458,91 +606,140 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const toolBtn =
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="tabular-nums text-txt-faint">
|
||||
{fbSize.width}×{fbSize.height}
|
||||
</span>
|
||||
{tab?.connectionStatus === 'connecting' ? (
|
||||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
|
||||
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
|
||||
}`}
|
||||
/>
|
||||
const toolbar = (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||
fullscreenActive
|
||||
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
|
||||
: 'border-b border-dark-700/50 bg-dark-800/80'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (fullscreenActive) {
|
||||
setChromeVisible(true)
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (fullscreenActive) scheduleHideChrome()
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="tabular-nums text-txt-faint">
|
||||
{fbSize.width}×{fbSize.height}
|
||||
</span>
|
||||
{tab?.connectionStatus === 'connecting' ? (
|
||||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
|
||||
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
||||
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||||
{fullscreenActive && (
|
||||
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</span>
|
||||
)}
|
||||
|
||||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => void pasteToRemote()}
|
||||
title={t('toolbar.pasteClipboard')}
|
||||
aria-label={t('toolbar.pasteAria')}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{protocol === 'rdp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t('toolbar.shareDrive')}
|
||||
aria-label={t('toolbar.shareDriveAria')}
|
||||
>
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onShareFileSelected(event.target.files)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
onClick={() => void openSharedFolder()}
|
||||
title={t('toolbar.openShareDir')}
|
||||
aria-label={t('toolbar.openShareDirAria')}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
||||
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||||
|
||||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => void pasteToRemote()}
|
||||
title={t('toolbar.pasteClipboard')}
|
||||
aria-label={t('toolbar.pasteAria')}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{protocol === 'rdp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t('toolbar.shareDrive')}
|
||||
aria-label={t('toolbar.shareDriveAria')}
|
||||
>
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onShareFileSelected(event.target.files)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
onClick={() => void openSharedFolder()}
|
||||
title={t('toolbar.openShareDir')}
|
||||
aria-label={t('toolbar.openShareDirAria')}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
|
||||
disabled={!connected}
|
||||
onClick={() => void disconnectSession()}
|
||||
title={t('toolbar.disconnect')}
|
||||
aria-label={t('toolbar.disconnectAria')}
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
|
||||
}}
|
||||
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
|
||||
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
|
||||
>
|
||||
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
|
||||
onMouseMove={(event) => {
|
||||
if (!fullscreenActive) return
|
||||
if (event.clientY <= 56) revealChrome()
|
||||
}}
|
||||
>
|
||||
{fullscreenActive ? (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
|
||||
chromeVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
|
||||
</div>
|
||||
) : (
|
||||
toolbar
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={paneRef}
|
||||
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
|
||||
fullscreenActive ? 'p-0' : 'p-2'
|
||||
}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
|
||||
className="cursor-default outline-none shadow-lg shadow-black/40"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => mapPointer(event)}
|
||||
onMouseDown={(event) => mapPointer(event, true)}
|
||||
|
||||
@@ -75,16 +75,24 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
.finally(() => setSessionsLoading(false))
|
||||
}, [setSessions])
|
||||
|
||||
// Click outside to close context menu
|
||||
// Click outside to close context menu (only while open)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!contextMenu) return
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setContextMenu(null)
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
@@ -423,7 +431,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
title={t('sidebar.newSession')}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-accent-green" />
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||
{t('sidebar.newSessionShort')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -517,6 +525,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(contextMenu.kind === 'blank'
|
||||
? [
|
||||
@@ -536,7 +545,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
]
|
||||
: [
|
||||
{ label: t('contextMenu.session.open'), action: 'connect' },
|
||||
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
|
||||
...(isSshSession(contextMenu.session)
|
||||
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
|
||||
: []),
|
||||
{ label: t('contextMenu.session.edit'), action: 'edit' },
|
||||
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
|
||||
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ConnectionState } from '../../stores/connection'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import { useSessionStore, useEntitlementStore } from '../../stores'
|
||||
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
|
||||
import { formatLocaleTime } from '../../i18n/format'
|
||||
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import ForwardRuleDrawer, {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
@@ -17,8 +17,18 @@ import ForwardRuleDrawer, {
|
||||
import {
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
type ForwardRule,
|
||||
} from '../connection/forwardRules'
|
||||
import {
|
||||
forgetForwardIntent,
|
||||
forwardIntentKey,
|
||||
rememberForwardIntent,
|
||||
restoreForwardIntent,
|
||||
tunnelCoversRule,
|
||||
} from '../connection/forwardIntent'
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
|
||||
// ============== Status Display Config ==============
|
||||
@@ -34,37 +44,37 @@ const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey
|
||||
connected: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
labelKey: 'status.connected',
|
||||
colorClass: 'text-green-400',
|
||||
colorClass: 'text-feedback-success',
|
||||
animateClass: '',
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
labelKey: 'status.connecting',
|
||||
colorClass: 'text-yellow-400',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
background: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
labelKey: 'status.background',
|
||||
colorClass: 'text-green-400',
|
||||
colorClass: 'text-feedback-success',
|
||||
animateClass: '',
|
||||
},
|
||||
suspended: {
|
||||
icon: <WifiOff className="w-3 h-3" />,
|
||||
labelKey: 'status.suspended',
|
||||
colorClass: 'text-yellow-400',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: '',
|
||||
},
|
||||
reconnecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
labelKey: 'status.reconnecting',
|
||||
colorClass: 'text-yellow-400',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
error: {
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
labelKey: 'status.error',
|
||||
colorClass: 'text-red-400',
|
||||
colorClass: 'text-feedback-error',
|
||||
animateClass: '',
|
||||
},
|
||||
disconnected: {
|
||||
@@ -127,7 +137,7 @@ function formatCpuPercent(pct: number): string {
|
||||
|
||||
function pctClass(pct?: number): string {
|
||||
if (pct == null) return 'text-txt-faint'
|
||||
if (pct >= WARN_PCT) return 'text-amber-400'
|
||||
if (pct >= WARN_PCT) return 'text-feedback-warning'
|
||||
return 'text-txt-muted'
|
||||
}
|
||||
|
||||
@@ -382,7 +392,6 @@ export default function StatusBar() {
|
||||
connectionState: undefined,
|
||||
hostLabel: undefined,
|
||||
stats: undefined,
|
||||
tabKind: activeTab?.type as string | undefined,
|
||||
connectionId: undefined,
|
||||
isRemoteSsh: false,
|
||||
sessionOsPretty: undefined as string | undefined,
|
||||
@@ -399,7 +408,6 @@ export default function StatusBar() {
|
||||
: activeTab.title
|
||||
: activeTab.title,
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
connectionId: undefined,
|
||||
isRemoteSsh: false,
|
||||
sessionOsPretty: undefined as string | undefined,
|
||||
@@ -411,7 +419,6 @@ export default function StatusBar() {
|
||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||
hostLabel: t('terminal:toolbar.labelLocal'),
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
connectionId: undefined,
|
||||
isRemoteSsh: false,
|
||||
sessionOsPretty: undefined as string | undefined,
|
||||
@@ -431,7 +438,6 @@ export default function StatusBar() {
|
||||
connectionState: conn.state,
|
||||
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
|
||||
stats: conn.stats,
|
||||
tabKind: activeTab.type,
|
||||
connectionId: instance.connectionId,
|
||||
isRemoteSsh: true,
|
||||
sessionOsPretty,
|
||||
@@ -444,7 +450,6 @@ export default function StatusBar() {
|
||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
connectionId: instance?.connectionId,
|
||||
isRemoteSsh: Boolean(instance?.connectionId),
|
||||
sessionOsPretty,
|
||||
@@ -461,7 +466,6 @@ export default function StatusBar() {
|
||||
connectionState: state,
|
||||
hostLabel: label,
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
connectionId: instance?.connectionId,
|
||||
isRemoteSsh: Boolean(instance?.connectionId),
|
||||
sessionOsPretty,
|
||||
@@ -481,6 +485,11 @@ export default function StatusBar() {
|
||||
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
|
||||
}, [activeTab?.sessionId, activeTabId, connectionId])
|
||||
|
||||
const intentKey = useMemo(
|
||||
() => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null),
|
||||
[activeTab?.sessionId, activeTabId]
|
||||
)
|
||||
|
||||
const forwardRuntimes = useMemo(() => {
|
||||
const map = new Map<string, { running: boolean; boundPort?: number }>()
|
||||
for (const tunnel of forwardTunnels) {
|
||||
@@ -489,10 +498,13 @@ export default function StatusBar() {
|
||||
boundPort: tunnel.boundPort,
|
||||
})
|
||||
}
|
||||
const dynamic = forwardRules.find((rule) => rule.type === 'dynamic')
|
||||
const dynamicTunnel = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
|
||||
if (dynamic && dynamicTunnel && !map.get(dynamic.id)?.running) {
|
||||
map.set(dynamic.id, { running: true, boundPort: dynamicTunnel.boundPort })
|
||||
// Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port.
|
||||
for (const rule of forwardRules) {
|
||||
if (map.get(rule.id)?.running) continue
|
||||
const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule))
|
||||
if (tunnel) {
|
||||
map.set(rule.id, { running: true, boundPort: tunnel.boundPort })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [forwardRules, forwardTunnels])
|
||||
@@ -585,6 +597,11 @@ export default function StatusBar() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [socksAvailable, loadForwardRules, refreshForwardState])
|
||||
|
||||
const previousForwardConnectionRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
previousForwardConnectionRef.current = undefined
|
||||
}, [intentKey])
|
||||
|
||||
const openForwardDrawer = useCallback(() => {
|
||||
if (!connectionId || !activeTabId) return
|
||||
if (!requirePro('portForward')) return
|
||||
@@ -594,6 +611,10 @@ export default function StatusBar() {
|
||||
...emptyForwardDraft(),
|
||||
type: 'local',
|
||||
name: t('common:portForward.defaultLocalName'),
|
||||
localBindHost: '127.0.0.1',
|
||||
localPort: 8080,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 80,
|
||||
})
|
||||
setForwardDrawerError(null)
|
||||
setForwardDrawerOpen(true)
|
||||
@@ -617,17 +638,19 @@ export default function StatusBar() {
|
||||
result = await window.opsxApi.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
rule.remoteHost || '',
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.remotePort || 0,
|
||||
rule.id
|
||||
rule.id,
|
||||
resolveLocalBindHost(rule.localBindHost)
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.opsxApi.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
'127.0.0.1',
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.localPort,
|
||||
rule.id
|
||||
rule.id,
|
||||
resolveRemoteBindHost(rule.remoteBindHost)
|
||||
)
|
||||
} else {
|
||||
result = await window.opsxApi.sshForwardDynamic(
|
||||
@@ -637,48 +660,73 @@ export default function StatusBar() {
|
||||
)
|
||||
}
|
||||
if (!result?.success) {
|
||||
setForwardDrawerError(result?.error || t('common:portForward.errorStartFailed'))
|
||||
setForwardDrawerError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStartFailed')
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (intentKey) rememberForwardIntent(intentKey, rule)
|
||||
await refreshForwardState()
|
||||
return true
|
||||
},
|
||||
[connectionId, refreshForwardState, t]
|
||||
[connectionId, intentKey, refreshForwardState, t]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!socksAvailable || !connectionId || !intentKey) return
|
||||
const previous = previousForwardConnectionRef.current
|
||||
previousForwardConnectionRef.current = connectionId
|
||||
if (!previous || previous === connectionId) return
|
||||
void (async () => {
|
||||
const listed = await window.opsxApi?.sshForwardList?.(connectionId)
|
||||
const tunnels = listed?.success ? listed.tunnels || [] : []
|
||||
const restored = await restoreForwardIntent(
|
||||
intentKey,
|
||||
(rule) => tunnelCoversRule(tunnels, rule),
|
||||
(rule) => startForwardRule(rule)
|
||||
)
|
||||
if (restored > 0) {
|
||||
toast.success(t('common:statusBar.forwardResumed', { count: restored }))
|
||||
}
|
||||
})()
|
||||
}, [socksAvailable, connectionId, intentKey, startForwardRule, t, toast])
|
||||
|
||||
const stopForwardRule = useCallback(
|
||||
async (rule: ForwardRule): Promise<boolean> => {
|
||||
if (!window.opsxApi?.sshForwardStop) return false
|
||||
const result = await window.opsxApi.sshForwardStop(rule.id)
|
||||
if (!result?.success) {
|
||||
// Dynamic may have been started with a different id historically.
|
||||
if (rule.type === 'dynamic' && connectionId) {
|
||||
const listed = await window.opsxApi.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
|
||||
if (match) {
|
||||
const stopMatch = await window.opsxApi.sshForwardStop(match.forwardId)
|
||||
if (!stopMatch?.success) {
|
||||
setForwardDrawerError(
|
||||
stopMatch?.error || result?.error || t('common:portForward.errorStopFailed')
|
||||
)
|
||||
return false
|
||||
}
|
||||
await refreshForwardState()
|
||||
return true
|
||||
}
|
||||
let result = await window.opsxApi.sshForwardStop(rule.id)
|
||||
if (!result?.success && connectionId) {
|
||||
// Runtime id may differ from persisted rule id after reconnect.
|
||||
const listed = await window.opsxApi.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||
if (match) {
|
||||
result = await window.opsxApi.sshForwardStop(match.forwardId)
|
||||
}
|
||||
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed'))
|
||||
}
|
||||
if (!result?.success) {
|
||||
setForwardDrawerError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStopFailed')
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (intentKey) forgetForwardIntent(intentKey, rule.id)
|
||||
await refreshForwardState()
|
||||
return true
|
||||
},
|
||||
[connectionId, refreshForwardState, t]
|
||||
[connectionId, intentKey, refreshForwardState, t]
|
||||
)
|
||||
|
||||
const handleStartRule = useCallback(
|
||||
async (rule: ForwardRule) => {
|
||||
if (!connectionId || forwardSaving) return
|
||||
if (forwardRuntimes.get(rule.id)?.running) {
|
||||
toast.success(t('common:statusBar.forwardStarted'))
|
||||
return
|
||||
}
|
||||
setForwardSaving(true)
|
||||
setForwardDrawerError(null)
|
||||
try {
|
||||
@@ -701,16 +749,18 @@ export default function StatusBar() {
|
||||
}
|
||||
}
|
||||
const ok = await startForwardRule(target)
|
||||
if (ok && target.type === 'dynamic') {
|
||||
if (ok) {
|
||||
toast.success(
|
||||
t('common:statusBar.forwardStartedDynamic', {
|
||||
port: resolveDynamicPort(target.localPort),
|
||||
})
|
||||
target.type === 'dynamic'
|
||||
? t('common:statusBar.forwardStartedDynamic', {
|
||||
port: resolveDynamicPort(target.localPort),
|
||||
})
|
||||
: t('common:statusBar.forwardStarted')
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
setForwardDrawerError(
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
|
||||
t('common:portForward.errorStartFailed')
|
||||
)
|
||||
} finally {
|
||||
@@ -720,6 +770,7 @@ export default function StatusBar() {
|
||||
[
|
||||
connectionId,
|
||||
forwardRules,
|
||||
forwardRuntimes,
|
||||
forwardSaving,
|
||||
persistForwardRules,
|
||||
startForwardRule,
|
||||
@@ -800,11 +851,19 @@ export default function StatusBar() {
|
||||
return
|
||||
}
|
||||
if (!validPort(forwardDraft.remotePort)) {
|
||||
setForwardDrawerError(t('common:portForward.errorTargetPort'))
|
||||
setForwardDrawerError(
|
||||
forwardDraft.type === 'local'
|
||||
? t('common:portForward.errorTargetAddress')
|
||||
: t('common:portForward.errorRemoteListenAddress')
|
||||
)
|
||||
return
|
||||
}
|
||||
if (forwardDraft.type === 'local' && !forwardDraft.remoteHost?.trim()) {
|
||||
setForwardDrawerError(t('common:portForward.errorRemoteHost'))
|
||||
if (!forwardDraft.remoteHost?.trim()) {
|
||||
setForwardDrawerError(
|
||||
forwardDraft.type === 'local'
|
||||
? t('common:portForward.errorTargetAddress')
|
||||
: t('common:portForward.errorLocalTargetAddress')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -813,7 +872,15 @@ export default function StatusBar() {
|
||||
type: forwardDraft.type || 'local',
|
||||
name: forwardDraft.name.trim(),
|
||||
localPort: forwardDraft.localPort,
|
||||
remoteHost: forwardDraft.remoteHost?.trim(),
|
||||
localBindHost:
|
||||
forwardDraft.type === 'local'
|
||||
? resolveLocalBindHost(forwardDraft.localBindHost)
|
||||
: undefined,
|
||||
remoteBindHost:
|
||||
forwardDraft.type === 'remote'
|
||||
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
|
||||
: undefined,
|
||||
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
|
||||
remotePort: forwardDraft.remotePort,
|
||||
}
|
||||
|
||||
@@ -836,7 +903,7 @@ export default function StatusBar() {
|
||||
toast.success(t('common:statusBar.forwardStarted'))
|
||||
} catch (error) {
|
||||
setForwardDrawerError(
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
|
||||
t('common:portForward.errorStartFailed')
|
||||
)
|
||||
} finally {
|
||||
@@ -955,7 +1022,9 @@ export default function StatusBar() {
|
||||
}`}
|
||||
>
|
||||
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
|
||||
<span className="tabular-nums">{t('common:statusBar.socksLabel')}</span>
|
||||
<span className="tabular-nums">
|
||||
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -972,6 +1041,7 @@ export default function StatusBar() {
|
||||
rules={forwardRules}
|
||||
runtimes={forwardRuntimes}
|
||||
busy={forwardSaving}
|
||||
viaSession={hostLabel}
|
||||
onChange={(next) => {
|
||||
setForwardDraft({
|
||||
type: next.type || 'local',
|
||||
@@ -980,6 +1050,14 @@ export default function StatusBar() {
|
||||
next.type === 'dynamic'
|
||||
? resolveDynamicPort(next.localPort)
|
||||
: next.localPort || 0,
|
||||
localBindHost:
|
||||
next.type === 'local'
|
||||
? resolveLocalBindHost(next.localBindHost)
|
||||
: '',
|
||||
remoteBindHost:
|
||||
next.type === 'remote'
|
||||
? resolveRemoteBindHost(next.remoteBindHost)
|
||||
: '',
|
||||
remoteHost: next.remoteHost || '',
|
||||
remotePort: next.remotePort || 0,
|
||||
})
|
||||
|
||||
@@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
>
|
||||
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
|
||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
|
||||
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
aria-label={t('tab.listAria')}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
@@ -147,7 +151,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLauncher}
|
||||
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm transition-colors hover:bg-accent-blue"
|
||||
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
|
||||
title={t('launcher.title')}
|
||||
aria-label={t('launcher.title')}
|
||||
>
|
||||
@@ -175,6 +179,8 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
sessionId: currentTab.sessionId,
|
||||
connectionConfig: currentTab.connectionConfig,
|
||||
serialConfig: currentTab.serialConfig,
|
||||
rdpConfig: currentTab.rdpConfig,
|
||||
vncConfig: currentTab.vncConfig,
|
||||
isLocal: currentTab.isLocal,
|
||||
localShellKind: currentTab.localShellKind,
|
||||
localShellPath: currentTab.localShellPath,
|
||||
@@ -182,6 +188,18 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
})
|
||||
}
|
||||
}}
|
||||
onOpenSftp={
|
||||
currentTab?.type === 'terminal' && currentTab.sessionId && !currentTab.isLocal
|
||||
? () => {
|
||||
const name = currentTab.title.replace(/^SFTP:\s*/i, '')
|
||||
addTab({
|
||||
title: `SFTP: ${name}`,
|
||||
type: 'sftp',
|
||||
sessionId: currentTab.sessionId,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -117,8 +117,12 @@ function TabContent({
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
{...(!isActive ? { inert: '' } : {})}
|
||||
className={`absolute inset-0 transition-opacity duration-150 ${
|
||||
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
isActive
|
||||
? 'visible opacity-100 z-10'
|
||||
: 'invisible opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -77,9 +77,9 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
|
||||
)
|
||||
|
||||
const badgeClass = (kind: string) => {
|
||||
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
|
||||
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
|
||||
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
|
||||
if (kind === 'zsh') return 'bg-teal-700/90 text-teal-50'
|
||||
if (kind === 'bash') return 'bg-accent-green/15 text-accent-green'
|
||||
if (kind === 'fish') return 'bg-accent-purple/15 text-accent-purple'
|
||||
return 'bg-dark-600 text-content-secondary'
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ export function SessionTree({
|
||||
<button
|
||||
type="button"
|
||||
data-group-row
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`session-group-${group}`}
|
||||
onClick={() => onToggleGroup(group)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -126,10 +128,12 @@ export function SessionTree({
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`session-group-${group}`}
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
aria-hidden={!isExpanded}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
|
||||
{groupSessions.length === 0 ? (
|
||||
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
|
||||
) : (
|
||||
@@ -142,7 +146,7 @@ export function SessionTree({
|
||||
key={session.id}
|
||||
data-session-row
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={isExpanded ? 0 : -1}
|
||||
onClick={() => {
|
||||
if (multiSelect) onToggleSelection?.(session)
|
||||
else onSelectSession(session)
|
||||
|
||||
@@ -22,6 +22,7 @@ interface TabContextMenuProps {
|
||||
onCloseLeft: () => void
|
||||
onCloseAll: () => void
|
||||
onClone?: () => void
|
||||
onOpenSftp?: () => void
|
||||
}
|
||||
|
||||
export function TabContextMenu({
|
||||
@@ -34,19 +35,30 @@ export function TabContextMenu({
|
||||
onCloseLeft,
|
||||
onCloseAll,
|
||||
onClone,
|
||||
onOpenSftp,
|
||||
}: TabContextMenuProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!isOpen) return
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
onCloseRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [onClose])
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCloseRef.current()
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -61,16 +73,21 @@ export function TabContextMenu({
|
||||
if (onClone) {
|
||||
items.unshift({ label: t('tab.menu.clone'), action: onClone })
|
||||
}
|
||||
if (onOpenSftp) {
|
||||
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
|
||||
@@ -16,7 +16,7 @@ interface TabItemProps {
|
||||
isDragging: boolean
|
||||
dragOverIndex: number | null
|
||||
onActivate: () => void
|
||||
onClose: (e: React.MouseEvent) => void
|
||||
onClose: (e: { stopPropagation(): void }) => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent, index: number) => void
|
||||
onDragOver: (e: React.DragEvent, index: number) => void
|
||||
@@ -38,7 +38,7 @@ export function TabItem({
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: TabItemProps) {
|
||||
const { t } = useTranslation('terminal')
|
||||
const { t } = useTranslation(['terminal', 'common'])
|
||||
const displayTitle = resolveTabTitle(tab)
|
||||
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
|
||||
const session = useSessionStore((state) =>
|
||||
@@ -49,7 +49,7 @@ export function TabItem({
|
||||
if (isLocalTerm) {
|
||||
return (
|
||||
<Terminal
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-accent-green' : 'text-accent-green/70'}`}
|
||||
aria-label={t('toolbar.labelLocal')}
|
||||
/>
|
||||
)
|
||||
@@ -99,8 +99,28 @@ export function TabItem({
|
||||
return (
|
||||
<div
|
||||
data-tab-item
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onClick={onActivate}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose(e)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
if (e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
onClose(e)
|
||||
}
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
@@ -132,9 +152,12 @@ export function TabItem({
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
||||
className={`
|
||||
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
|
||||
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
|
||||
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
|
||||
${isActive ? 'opacity-100' : ''}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
|
||||
* UpgradeView — Free / Yearly / Perpetual plan picker + license activate
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
licenseServerBaseUrl,
|
||||
proFeatureLabel,
|
||||
useEntitlementStore,
|
||||
type ProFeature,
|
||||
} from '../../stores/entitlementStore'
|
||||
import { checkout as licenseCheckout } from '../../platform/licenseApi'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
|
||||
const logger = createLogger('UpgradeView')
|
||||
@@ -29,15 +29,49 @@ interface UpgradeViewProps {
|
||||
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
|
||||
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
|
||||
|
||||
type SelectablePlan = 'free' | 'yearly' | 'perpetual'
|
||||
|
||||
function looksLikeValidLicenseKey(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
|
||||
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
|
||||
}
|
||||
|
||||
function activatedLabel(
|
||||
t: (key: string) => string,
|
||||
planType: string | null
|
||||
): string {
|
||||
switch (planType) {
|
||||
case 'yearly':
|
||||
return t('pro.activatedYearly')
|
||||
case 'trial':
|
||||
return t('pro.activatedTrial')
|
||||
case 'perpetual':
|
||||
case 'lifetime':
|
||||
return t('pro.activatedPerpetual')
|
||||
default:
|
||||
return t('pro.activated')
|
||||
}
|
||||
}
|
||||
|
||||
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const plan = useEntitlementStore((s) => s.plan)
|
||||
const deviceId = useEntitlementStore((s) => s.deviceId)
|
||||
const email = useEntitlementStore((s) => s.email)
|
||||
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
||||
const maxDevices = useEntitlementStore((s) => s.maxDevices)
|
||||
const planType = useEntitlementStore((s) => s.planType)
|
||||
const activate = useEntitlementStore((s) => s.activate)
|
||||
const setEmail = useEntitlementStore((s) => s.setEmail)
|
||||
const clear = useEntitlementStore((s) => s.clear)
|
||||
const hydrate = useEntitlementStore((s) => s.hydrate)
|
||||
|
||||
const [emailInput, setEmailInput] = useState(email)
|
||||
const [selectedPlan, setSelectedPlan] = useState<SelectablePlan>(
|
||||
plan === 'pro' ? (planType === 'yearly' ? 'yearly' : 'perpetual') : 'free'
|
||||
)
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||||
const [licenseInput, setLicenseInput] = useState('')
|
||||
@@ -49,25 +83,44 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
useEffect(() => {
|
||||
setEmailInput(email)
|
||||
}, [email])
|
||||
|
||||
useEffect(() => {
|
||||
if (plan === 'pro') {
|
||||
setSelectedPlan(planType === 'yearly' ? 'yearly' : 'perpetual')
|
||||
} else {
|
||||
setSelectedPlan('free')
|
||||
}
|
||||
}, [plan, planType])
|
||||
|
||||
const highlight = feature ? proFeatureLabel(feature) : null
|
||||
|
||||
const startCheckout = useCallback(async () => {
|
||||
const startCheckout = useCallback(async (planChoice: 'yearly' | 'perpetual') => {
|
||||
setSelectedPlan(planChoice)
|
||||
setCheckoutLoading(true)
|
||||
setCheckoutError(null)
|
||||
const trimmedEmail = emailInput.trim()
|
||||
if (!trimmedEmail || !trimmedEmail.includes('@')) {
|
||||
setCheckoutError(t('pro.emailRequired'))
|
||||
setCheckoutLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await setEmail(trimmedEmail)
|
||||
const id =
|
||||
deviceId ||
|
||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId: id }),
|
||||
const result = await licenseCheckout({
|
||||
customerEmail: trimmedEmail,
|
||||
deviceId: id,
|
||||
planType: planChoice,
|
||||
})
|
||||
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
|
||||
if (!res.ok || !data.checkoutUrl) {
|
||||
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
|
||||
if (!result.checkoutUrl) {
|
||||
throw new Error(t('pro.checkoutUnavailable', { status: 'no-url' }))
|
||||
}
|
||||
await window.opsxApi.openExternal(data.checkoutUrl)
|
||||
await window.opsxApi.openExternal(result.checkoutUrl)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.warn('checkout failed', message)
|
||||
@@ -75,19 +128,36 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
} finally {
|
||||
setCheckoutLoading(false)
|
||||
}
|
||||
}, [deviceId, t])
|
||||
}, [deviceId, emailInput, setEmail, t])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
setActivateError(null)
|
||||
setActivateOk(false)
|
||||
const result = await activate(licenseInput)
|
||||
if (!looksLikeValidLicenseKey(licenseInput)) {
|
||||
setActivateError(t('pro.invalidKeyFormat'))
|
||||
return
|
||||
}
|
||||
const trimmedEmail = emailInput.trim()
|
||||
if (trimmedEmail) await setEmail(trimmedEmail)
|
||||
const result = await activate(licenseInput, trimmedEmail || undefined)
|
||||
if (result.success) {
|
||||
setActivateOk(true)
|
||||
setLicenseInput('')
|
||||
} else {
|
||||
setActivateError(result.error || t('pro.activateFailed'))
|
||||
}
|
||||
}, [activate, licenseInput, t])
|
||||
}, [activate, emailInput, licenseInput, setEmail, t])
|
||||
|
||||
const cardClass = (id: SelectablePlan, accent = false) => {
|
||||
const selected = selectedPlan === id
|
||||
return `flex flex-col rounded-xl border px-4 py-4 text-left transition-colors ${
|
||||
selected
|
||||
? accent
|
||||
? 'border-accent-blue bg-accent-blue/10'
|
||||
: 'border-dark-500 bg-dark-800/60'
|
||||
: 'border-dark-700/60 bg-dark-800/40 hover:border-dark-500'
|
||||
}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
||||
@@ -104,72 +174,173 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
{comingSoon && highlight
|
||||
? t('pro.subtitleComingSoon', { feature: highlight })
|
||||
: highlight
|
||||
? t('pro.subtitleUnlock', { feature: highlight })
|
||||
? t('pro.subtitleUnlock')
|
||||
: t('pro.subtitleDefault')}
|
||||
</p>
|
||||
{plan === 'pro' && (
|
||||
{highlight && !comingSoon ? (
|
||||
<p className="mt-1 text-xs text-txt-faint">{t('pro.subtitleUnlockDetail', { feature: highlight })}</p>
|
||||
) : null}
|
||||
{(plan === 'pro' || email) && (
|
||||
<p className="mt-2 text-xs text-accent-green">
|
||||
{t('pro.activated')}
|
||||
{expiresAt
|
||||
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
||||
: t('pro.lifetime')}
|
||||
{plan === 'pro' ? activatedLabel(t, planType) : t('pro.accountEmail', { email })}
|
||||
{plan === 'pro' && email ? ` · ${email}` : null}
|
||||
{plan === 'pro'
|
||||
? expiresAt
|
||||
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
||||
: t('pro.lifetime')
|
||||
: null}
|
||||
{plan === 'pro' && maxDevices
|
||||
? ` · ${t('pro.devices', { count: maxDevices })}`
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
|
||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">{t('pro.freeTier')}</div>
|
||||
<ul className="space-y-2">
|
||||
<p className="mb-3 text-center text-sm text-txt-muted">{t('pro.choosePlan')}</p>
|
||||
|
||||
{plan !== 'pro' && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<label className="ui-label" htmlFor="license-email">
|
||||
{t('pro.emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="license-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-content-primary outline-none focus:border-accent-blue"
|
||||
/>
|
||||
{checkoutError && (
|
||||
<p className="text-xs text-red-400">{checkoutError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
{/* Free */}
|
||||
<div className={cardClass('free')}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setSelectedPlan('free')}
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-txt-faint">
|
||||
{t('pro.planFree')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-content-primary">{t('pro.planFreePrice')}</div>
|
||||
<div className="mt-1 text-xs text-txt-muted">{t('pro.planFreeDesc')}</div>
|
||||
</button>
|
||||
<ul className="mt-3 flex-1 space-y-1.5">
|
||||
{FREE_ITEM_KEYS.map((key) => (
|
||||
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
|
||||
<li key={key} className="flex items-start gap-1.5 text-xs text-content-secondary">
|
||||
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-txt-muted" />
|
||||
{t(`pro.freeItems.${key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 rounded-lg border border-dark-600 px-3 py-2 text-center text-xs text-txt-muted">
|
||||
{plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
|
||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
|
||||
<ul className="space-y-2">
|
||||
|
||||
{/* Yearly */}
|
||||
<div className={cardClass('yearly', true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setSelectedPlan('yearly')}
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
|
||||
{t('pro.planYearly')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-content-primary">¥99</div>
|
||||
<div className="mt-1 text-xs text-txt-muted">{t('pro.planYearlyDesc')}</div>
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
|
||||
<ul className="mt-2 flex-1 space-y-1.5">
|
||||
{PRO_ITEM_KEYS.map((key) => (
|
||||
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
|
||||
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
|
||||
{t(`pro.proItems.${key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan !== 'pro' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => void startCheckout('yearly')}
|
||||
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{checkoutLoading && selectedPlan === 'yearly' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('pro.goUpgradeYearly')}
|
||||
</button>
|
||||
)}
|
||||
{plan === 'pro' && planType === 'yearly' && (
|
||||
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
|
||||
{t('pro.activatedYearly')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Perpetual */}
|
||||
<div className={cardClass('perpetual', true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setSelectedPlan('perpetual')}
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
|
||||
{t('pro.planPerpetual')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-content-primary">¥199</div>
|
||||
<div className="mt-1 text-xs text-txt-muted">{t('pro.planPerpetualDesc')}</div>
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
|
||||
<ul className="mt-2 flex-1 space-y-1.5">
|
||||
{PRO_ITEM_KEYS.map((key) => (
|
||||
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
|
||||
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
|
||||
{t(`pro.proItems.${key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan !== 'pro' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => void startCheckout('perpetual')}
|
||||
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{checkoutLoading && selectedPlan === 'perpetual' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('pro.goUpgradePerpetual')}
|
||||
</button>
|
||||
)}
|
||||
{plan === 'pro' && (planType === 'perpetual' || planType === 'lifetime' || (!planType && !expiresAt)) && (
|
||||
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
|
||||
{t('pro.activatedPerpetual')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan !== 'pro' && (
|
||||
<>
|
||||
<p className="mb-3 text-center text-sm text-txt-muted">
|
||||
{t('pro.oneTimeFee')}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => void startCheckout()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{checkoutLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
)}
|
||||
{t('pro.goUpgrade')}
|
||||
</button>
|
||||
{checkoutError && (
|
||||
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActivate((v) => !v)}
|
||||
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
|
||||
className="mt-1 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
|
||||
>
|
||||
{t('pro.haveLicense')}
|
||||
</button>
|
||||
@@ -185,7 +356,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
onChange={(e) => setLicenseInput(e.target.value)}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="opsx1...."
|
||||
placeholder="LIC-… / LICOFF-…"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function UpgradeWall() {
|
||||
if (!wall.open) return null
|
||||
|
||||
const feature = wall.feature as ProFeature | undefined
|
||||
const label = feature ? proFeatureLabel(feature) : t('pro.feature')
|
||||
const label = feature ? proFeatureLabel(feature) : null
|
||||
const comingSoon = wall.comingSoon
|
||||
|
||||
return (
|
||||
@@ -48,11 +48,17 @@ export default function UpgradeWall() {
|
||||
<Crown className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
|
||||
{comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })}
|
||||
{comingSoon ? t('pro.wallUnlockComingSoon') : t('pro.wallUnlock')}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
|
||||
{wall.message ||
|
||||
(comingSoon ? t('pro.wallMessageComingSoon') : t('pro.wallMessageDefault'))}
|
||||
(comingSoon
|
||||
? label
|
||||
? t('pro.wallMessageComingSoonFeature', { feature: label })
|
||||
: t('pro.wallMessageComingSoon')
|
||||
: label
|
||||
? t('pro.wallMessageFeature', { feature: label })
|
||||
: t('pro.wallMessageDefault'))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Cable, RefreshCw } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
|
||||
import { safeTerminalDispose, safeTerminalFit } from '../../utils/safeTerminalFit'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
@@ -72,7 +73,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
||||
terminal.open(container)
|
||||
terminalRef.current = terminal
|
||||
fitRef.current = fit
|
||||
fit.fit()
|
||||
safeTerminalFit(fit, terminal)
|
||||
|
||||
const pipeline = new TerminalPipeline({
|
||||
input: async (data) => {
|
||||
@@ -109,7 +110,9 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
||||
}
|
||||
container.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
const handleResize = () => fit.fit()
|
||||
const handleResize = () => {
|
||||
safeTerminalFit(fit, terminal)
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
|
||||
@@ -167,15 +170,15 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
||||
const sessionId = sessionIdRef.current
|
||||
sessionIdRef.current = null
|
||||
if (sessionId) void closeSerial(sessionId)
|
||||
terminal.dispose()
|
||||
terminalRef.current = null
|
||||
fitRef.current = null
|
||||
terminalRef.current = null
|
||||
safeTerminalDispose(terminal)
|
||||
}
|
||||
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
fitRef.current?.fit()
|
||||
safeTerminalFit(fitRef.current, terminalRef.current)
|
||||
terminalRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
@@ -189,7 +192,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
|
||||
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
|
||||
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
|
||||
</div>
|
||||
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
|
||||
<div ref={containerRef} className="terminal-surface min-h-0 flex-1 p-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
|
||||
} finally {
|
||||
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
}, [timeout, updateResult, t])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabId, timeout, updateResult]
|
||||
[tabId, timeout, updateResult, t]
|
||||
)
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||
import { Cable, Cast, Monitor, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
import { fallbackLocalShells } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
sessionProtocol,
|
||||
} from '../../utils/sessionProtocol'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onSerialConnect: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
}
|
||||
|
||||
export default function QuickLauncher({
|
||||
@@ -26,6 +32,8 @@ export default function QuickLauncher({
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onSerialConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
}: QuickLauncherProps) {
|
||||
const { t } = useTranslation(['common', 'sessions'])
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -86,7 +94,7 @@ export default function QuickLauncher({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||
<LogIn className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
@@ -95,7 +103,7 @@ export default function QuickLauncher({
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||
<Plus className="w-4 h-4 text-accent-green" />
|
||||
<Plus className="w-4 h-4 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
|
||||
<small>{t('common:launcher.cards.saveSession.desc')}</small>
|
||||
@@ -108,6 +116,42 @@ export default function QuickLauncher({
|
||||
<small>{t('common:launcher.cards.serial.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
{onRdpConnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run(() => {
|
||||
if (!requirePro('rdp')) return
|
||||
onRdpConnect()
|
||||
})
|
||||
}
|
||||
className="launcher-card"
|
||||
>
|
||||
<Monitor className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.rdp.title')}</strong>
|
||||
<small>{t('common:launcher.cards.rdp.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onVncConnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run(() => {
|
||||
if (!requirePro('vnc')) return
|
||||
onVncConnect()
|
||||
})
|
||||
}
|
||||
className="launcher-card"
|
||||
>
|
||||
<Cast className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.vnc.title')}</strong>
|
||||
<small>{t('common:launcher.cards.vnc.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
@@ -131,7 +175,7 @@ export default function QuickLauncher({
|
||||
}))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
||||
<Terminal className="w-4 h-4 text-accent-green" />
|
||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
|
||||
</button>
|
||||
@@ -161,7 +205,10 @@ export default function QuickLauncher({
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<strong className="truncate">{session.name}</strong>
|
||||
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
|
||||
<small className="truncate">
|
||||
{session.username}@{session.host}:
|
||||
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||
</small>
|
||||
</span>
|
||||
<span className="launcher-badge">{session.group || 'default'}</span>
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../../utils/sessionGroups'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { usePromptDialog } from '../common/PromptDialog'
|
||||
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
||||
import { getDisplayResolution } from '../../utils/desktopResolution'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
@@ -152,14 +154,17 @@ export default function SessionDialog({
|
||||
: [],
|
||||
domain: editSession.domain || '',
|
||||
security: editSession.security || 'nla',
|
||||
width: editSession.width || 1920,
|
||||
height: editSession.height || 1080,
|
||||
width: editSession.width || getDisplayResolution().width,
|
||||
height: editSession.height || getDisplayResolution().height,
|
||||
})
|
||||
setShowAdvanced(hasAdvanced)
|
||||
} else {
|
||||
const display = getDisplayResolution()
|
||||
setFormData({
|
||||
...emptyForm,
|
||||
group: normalizeGroupId(defaultGroup),
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
})
|
||||
setTarget('')
|
||||
setShowAdvanced(false)
|
||||
@@ -185,12 +190,15 @@ export default function SessionDialog({
|
||||
prev.port === defaultPortForProtocol(prev.protocol)
|
||||
? defaultPortForProtocol(protocol)
|
||||
: prev.port
|
||||
const display = getDisplayResolution()
|
||||
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
|
||||
return {
|
||||
...prev,
|
||||
protocol,
|
||||
username: nextUser,
|
||||
port: nextPort,
|
||||
authType: 'password',
|
||||
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
|
||||
}
|
||||
})
|
||||
// Keep host-only target when switching protocol.
|
||||
@@ -394,6 +402,50 @@ export default function SessionDialog({
|
||||
{ value: 'key', label: t('dialog.authKey'), icon: Key },
|
||||
]
|
||||
|
||||
const groupField = (
|
||||
<div>
|
||||
<label htmlFor="session-group" className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
{t('dialog.group')}
|
||||
</label>
|
||||
<select
|
||||
id="session-group"
|
||||
value={normalizeGroupId(formData.group)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value !== '__new__') {
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const raw = await prompt({
|
||||
title: t('groups.new.title'),
|
||||
message: t('groups.new.message'),
|
||||
placeholder: t('groups.new.placeholder'),
|
||||
confirmText: t('groups.new.confirm'),
|
||||
})
|
||||
if (raw == null) return
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||
})()
|
||||
}}
|
||||
className="ui-input"
|
||||
>
|
||||
{knownGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{groupDisplayName(group)}
|
||||
</option>
|
||||
))}
|
||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||
<option value={normalizeGroupId(formData.group)}>
|
||||
{groupDisplayName(formData.group)}
|
||||
</option>
|
||||
)}
|
||||
<option value="__new__">{t('groups.newOption')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">{t('groups.hint')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
@@ -549,73 +601,30 @@ export default function SessionDialog({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
|
||||
<input
|
||||
id="session-width"
|
||||
type="number"
|
||||
value={formData.width}
|
||||
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
|
||||
<input
|
||||
id="session-height"
|
||||
type="number"
|
||||
value={formData.height}
|
||||
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
{formData.protocol === 'rdp' && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="session-group" className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
{t('dialog.group')}
|
||||
</label>
|
||||
<select
|
||||
id="session-group"
|
||||
value={normalizeGroupId(formData.group)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value !== '__new__') {
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const raw = await prompt({
|
||||
title: t('groups.new.title'),
|
||||
message: t('groups.new.message'),
|
||||
placeholder: t('groups.new.placeholder'),
|
||||
confirmText: t('groups.new.confirm'),
|
||||
})
|
||||
if (raw == null) return
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||
})()
|
||||
}}
|
||||
className="ui-input"
|
||||
>
|
||||
{knownGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{groupDisplayName(group)}
|
||||
</option>
|
||||
))}
|
||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||
<option value={normalizeGroupId(formData.group)}>
|
||||
{groupDisplayName(formData.group)}
|
||||
</option>
|
||||
)}
|
||||
<option value="__new__">{t('groups.newOption')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
{t('groups.hint')}
|
||||
</p>
|
||||
</div>
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<DesktopResolutionPicker
|
||||
ns="sessions"
|
||||
width={formData.width}
|
||||
height={formData.height}
|
||||
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
|
||||
/>
|
||||
)}
|
||||
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
|
||||
{formData.protocol === 'rdp' && groupField}
|
||||
</section>
|
||||
|
||||
{/* 凭据 */}
|
||||
@@ -647,7 +656,7 @@ export default function SessionDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.authType === 'password' || !isSsh) && (
|
||||
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
||||
<input
|
||||
@@ -774,6 +783,7 @@ export default function SessionDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isSsh || formData.protocol === 'vnc') && groupField}
|
||||
</section>
|
||||
|
||||
{/* 高级 */}
|
||||
|
||||
@@ -10,7 +10,11 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
sessionProtocol,
|
||||
} from '../../utils/sessionProtocol'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
@@ -273,7 +277,8 @@ export default function WelcomeView({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
{session.username}@{session.host}:
|
||||
{session.port || defaultPortForProtocol(sessionProtocol(session))}
|
||||
</span>
|
||||
</span>
|
||||
{session.group ? (
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* AccountSettings — license email, plan status, activate / devices / clear
|
||||
* (standalone settings nav tab, not under General)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
import {
|
||||
deactivateLicenseDevice,
|
||||
listLicenseDevices,
|
||||
type LicenseDevice,
|
||||
} from '../../platform/licenseApi'
|
||||
|
||||
function looksLikeValidLicenseKey(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
|
||||
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
|
||||
}
|
||||
|
||||
function planLabel(
|
||||
t: (key: string) => string,
|
||||
plan: 'free' | 'pro',
|
||||
planType: string | null
|
||||
): string {
|
||||
if (plan !== 'pro') return t('account.planFree')
|
||||
switch (planType) {
|
||||
case 'yearly':
|
||||
return t('account.planYearly')
|
||||
case 'trial':
|
||||
return t('account.planTrial')
|
||||
case 'perpetual':
|
||||
case 'lifetime':
|
||||
return t('account.planPerpetual')
|
||||
default:
|
||||
return t('account.planPerpetual')
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountSettings() {
|
||||
const { t } = useTranslation('settings')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const plan = useEntitlementStore((s) => s.plan)
|
||||
const email = useEntitlementStore((s) => s.email)
|
||||
const deviceId = useEntitlementStore((s) => s.deviceId)
|
||||
const licenseKey = useEntitlementStore((s) => s.licenseKey)
|
||||
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
||||
const maxDevices = useEntitlementStore((s) => s.maxDevices)
|
||||
const planType = useEntitlementStore((s) => s.planType)
|
||||
const hydrate = useEntitlementStore((s) => s.hydrate)
|
||||
const setEmail = useEntitlementStore((s) => s.setEmail)
|
||||
const activate = useEntitlementStore((s) => s.activate)
|
||||
const clear = useEntitlementStore((s) => s.clear)
|
||||
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||
|
||||
const [emailInput, setEmailInput] = useState(email)
|
||||
const [licenseInput, setLicenseInput] = useState('')
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [devices, setDevices] = useState<LicenseDevice[]>([])
|
||||
const [deviceUsed, setDeviceUsed] = useState(0)
|
||||
const [deviceMax, setDeviceMax] = useState(0)
|
||||
const [devicesLoading, setDevicesLoading] = useState(false)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
useEffect(() => {
|
||||
setEmailInput(email)
|
||||
}, [email])
|
||||
|
||||
const refreshDevices = useCallback(async () => {
|
||||
if (plan !== 'pro' || !licenseKey) {
|
||||
setDevices([])
|
||||
setDeviceUsed(0)
|
||||
setDeviceMax(0)
|
||||
return
|
||||
}
|
||||
setDevicesLoading(true)
|
||||
try {
|
||||
const result = await listLicenseDevices(licenseKey)
|
||||
setDevices(
|
||||
result.devices.map((d) => ({
|
||||
...d,
|
||||
isCurrent: Boolean(deviceId && d.deviceId === deviceId),
|
||||
}))
|
||||
)
|
||||
setDeviceUsed(result.used)
|
||||
setDeviceMax(result.maxDevices || maxDevices || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setDevicesLoading(false)
|
||||
}
|
||||
}, [deviceId, licenseKey, maxDevices, plan])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDevices()
|
||||
}, [refreshDevices])
|
||||
|
||||
const saveEmail = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
try {
|
||||
await setEmail(emailInput.trim())
|
||||
setMessage(t('account.saved'))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [emailInput, setEmail, t])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
if (!looksLikeValidLicenseKey(licenseInput)) {
|
||||
setError(tc('pro.invalidKeyFormat'))
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (emailInput.trim()) await setEmail(emailInput.trim())
|
||||
const result = await activate(licenseInput, emailInput.trim() || undefined)
|
||||
if (result.success) {
|
||||
setMessage(t('account.activateOk'))
|
||||
setLicenseInput('')
|
||||
await refreshDevices()
|
||||
} else {
|
||||
setError(result.error || tc('pro.activateFailed'))
|
||||
}
|
||||
setBusy(false)
|
||||
}, [activate, emailInput, licenseInput, refreshDevices, setEmail, t, tc])
|
||||
|
||||
const handleDeactivate = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
await clear()
|
||||
setDevices([])
|
||||
setMessage(t('account.saved'))
|
||||
setBusy(false)
|
||||
}, [clear, t])
|
||||
|
||||
const handleRemoveDevice = useCallback(
|
||||
async (target: LicenseDevice) => {
|
||||
if (!licenseKey || target.isCurrent) return
|
||||
setRemovingId(target.deviceId)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
try {
|
||||
await deactivateLicenseDevice(licenseKey, target.deviceId)
|
||||
setMessage(t('account.deviceRemoved'))
|
||||
await refreshDevices()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
},
|
||||
[licenseKey, refreshDevices, t]
|
||||
)
|
||||
|
||||
const atSeatLimit = deviceMax > 0 && deviceUsed >= deviceMax
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.title')}</h3>
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<Row
|
||||
label={t('account.plan')}
|
||||
description={
|
||||
plan === 'pro'
|
||||
? expiresAt
|
||||
? t('account.expires', { date: new Date(expiresAt * 1000).toLocaleDateString() })
|
||||
: t('account.lifetime')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-content-primary">
|
||||
{planLabel(t, plan, planType)}
|
||||
{maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
<Row label={t('account.email')} description={t('account.emailDesc')}>
|
||||
<div className="flex min-w-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-56 rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-content-primary outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void saveEmail()}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-content-primary hover:bg-dark-600 disabled:opacity-40"
|
||||
>
|
||||
{t('account.saveEmail')}
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row label={t('account.deviceId')}>
|
||||
<code className="max-w-[14rem] truncate text-xs text-txt-muted" title={deviceId || '—'}>
|
||||
{deviceId || '—'}
|
||||
</code>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.activate')}</h3>
|
||||
<div className="border-dark-700/45 bg-dark-800/50 space-y-3 overflow-hidden rounded-2xl border p-4">
|
||||
<textarea
|
||||
value={licenseInput}
|
||||
onChange={(e) => setLicenseInput(e.target.value)}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder={t('account.pasteLicense')}
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !licenseInput.trim()}
|
||||
onClick={() => void handleActivate()}
|
||||
className="rounded-lg bg-accent-blue px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-40"
|
||||
>
|
||||
{t('account.activate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => openUpgradeTab()}
|
||||
className="rounded-lg border border-dark-600 px-3 py-1.5 text-xs text-content-secondary hover:bg-dark-700"
|
||||
>
|
||||
{t('account.upgrade')}
|
||||
</button>
|
||||
{plan === 'pro' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDeactivate()}
|
||||
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
{t('account.deactivate')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{message && <p className="text-xs text-accent-green">{message}</p>}
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan === 'pro' && licenseKey ? (
|
||||
<div>
|
||||
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">
|
||||
{t('account.deviceList')}
|
||||
{deviceMax > 0 ? ` · ${deviceUsed}/${deviceMax}` : ''}
|
||||
</h3>
|
||||
<div className="border-dark-700/45 bg-dark-800/50 overflow-hidden rounded-2xl border">
|
||||
{atSeatLimit ? (
|
||||
<p className="border-b border-dark-700/35 px-4 py-2.5 text-xs text-feedback-warning">
|
||||
{t('account.seatFull')}
|
||||
</p>
|
||||
) : null}
|
||||
{devicesLoading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-6 text-xs text-txt-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('account.loadingDevices')}
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-txt-muted">{t('account.noDevices')}</p>
|
||||
) : (
|
||||
<ul className="divide-dark-700/35 divide-y">
|
||||
{devices.map((d) => (
|
||||
<li
|
||||
key={d.deviceId || String(d.id)}
|
||||
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-content-primary">
|
||||
{d.deviceName || 'OpsXShell'}
|
||||
{d.isCurrent ? (
|
||||
<span className="ml-2 text-xs text-accent-blue">
|
||||
{t('account.thisDevice')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<code className="mt-0.5 block truncate text-[11px] text-txt-muted">
|
||||
{d.deviceId}
|
||||
</code>
|
||||
</div>
|
||||
{!d.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={removingId === d.deviceId}
|
||||
onClick={() => void handleRemoveDevice(d)}
|
||||
className="rounded-lg border border-red-500/35 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||||
>
|
||||
{removingId === d.deviceId ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t('account.removeDevice')
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
|
||||
<div className="min-w-[11rem] flex-1 basis-[14rem]">
|
||||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||
{description ? <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div> : null}
|
||||
</div>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export default function AiProviderSettings() {
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
{presetsByFamily(familyId).map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
|
||||
import type { AppLanguage } from '../../i18n/detectLocale'
|
||||
import {
|
||||
Monitor,
|
||||
Palette,
|
||||
Terminal,
|
||||
Keyboard,
|
||||
Shield,
|
||||
@@ -18,13 +17,16 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
import AiProviderSettings from './AiProviderSettings'
|
||||
import AccountSettings from './AccountSettings'
|
||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
|
||||
interface TabMeta {
|
||||
id: SettingsTab
|
||||
@@ -41,11 +43,12 @@ interface RuntimeInfo {
|
||||
sshBackend: string
|
||||
dataPath: string
|
||||
credentialPath: string
|
||||
logPath: string
|
||||
}
|
||||
|
||||
const TAB_LIST: TabMeta[] = [
|
||||
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
|
||||
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
|
||||
{ id: 'account', labelKey: 'nav.account', descriptionKey: 'nav.accountDesc', icon: UserRound },
|
||||
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
|
||||
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
|
||||
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
|
||||
@@ -148,25 +151,13 @@ export default function SettingsView() {
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
// ── 搜索过滤 ──────────────────────────────────────────────
|
||||
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
|
||||
const filtered = useMemo((): SettingsTab[] | null => {
|
||||
if (!searchQuery.trim()) return null
|
||||
const q = searchQuery.toLowerCase()
|
||||
const keywords: Record<SettingsTab, string[]> = {
|
||||
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
|
||||
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
|
||||
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
|
||||
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
|
||||
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
|
||||
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
|
||||
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
|
||||
about: [t('nav.about'), 'version', 'about', 'license'],
|
||||
}
|
||||
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
|
||||
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
|
||||
.map(([id]) => id)
|
||||
return matchIds.length > 0 ? matchIds : []
|
||||
}, [searchQuery, t])
|
||||
return filterSettingsTabsByQuery(
|
||||
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
|
||||
searchQuery
|
||||
)
|
||||
}, [searchQuery])
|
||||
|
||||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
||||
useEffect(() => {
|
||||
@@ -182,6 +173,7 @@ export default function SettingsView() {
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
`Log: ${runtimeInfo.logPath}`,
|
||||
].join('\n')
|
||||
: `OpsXShell v${appVersion}`
|
||||
|
||||
@@ -197,10 +189,39 @@ export default function SettingsView() {
|
||||
// ── 渲染内容区 ────────────────────────────────────────────
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
// ──────── 常规 ─────────
|
||||
// ──────── 通用(主题 / 语言 / 工作区)─────────
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title={t('appearance.themeGroup')}>
|
||||
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
|
||||
<select
|
||||
value={settings.theme}
|
||||
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="dark">{t('appearance.dark')}</option>
|
||||
<option value="light">{t('appearance.light')}</option>
|
||||
<option value="system">{t('appearance.system')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('language.label')} description={t('language.description')}>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="zh-CN">{t('language.zhCN')}</option>
|
||||
<option value="zh-TW">{t('language.zhTW')}</option>
|
||||
<option value="en">{t('language.en')}</option>
|
||||
<option value="ja">{t('language.ja')}</option>
|
||||
<option value="ko">{t('language.ko')}</option>
|
||||
<option value="vi">{t('language.vi')}</option>
|
||||
<option value="ru">{t('language.ru')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title={t('general.layout')}>
|
||||
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -238,40 +259,8 @@ export default function SettingsView() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 外观 ─────────
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title={t('appearance.themeGroup')}>
|
||||
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
|
||||
<select
|
||||
value={settings.theme}
|
||||
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="dark">{t('appearance.dark')}</option>
|
||||
<option value="light">{t('appearance.light')}</option>
|
||||
<option value="system">{t('appearance.system')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('language.label')} description={t('language.description')}>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="zh-CN">{t('language.zhCN')}</option>
|
||||
<option value="zh-TW">{t('language.zhTW')}</option>
|
||||
<option value="en">{t('language.en')}</option>
|
||||
<option value="ja">{t('language.ja')}</option>
|
||||
<option value="ko">{t('language.ko')}</option>
|
||||
<option value="vi">{t('language.vi')}</option>
|
||||
<option value="ru">{t('language.ru')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
)
|
||||
case 'account':
|
||||
return <AccountSettings />
|
||||
|
||||
// ──────── 终端 ─────────
|
||||
case 'terminal':
|
||||
@@ -520,6 +509,16 @@ export default function SettingsView() {
|
||||
checked={settings.keepAlive}
|
||||
onChange={(v) => setSetting('keepAlive', v)}
|
||||
/>
|
||||
<SettingRow label={t('ssh.keepAliveInterval')} description={t('ssh.keepAliveIntervalDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={120}
|
||||
value={settings.keepAliveIntervalSec}
|
||||
onChange={(e) => setSetting('keepAliveIntervalSec', parseInt(e.target.value) || 15)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingToggle
|
||||
label={t('ssh.autoReconnect')}
|
||||
description={t('ssh.autoReconnectDesc')}
|
||||
@@ -572,10 +571,10 @@ export default function SettingsView() {
|
||||
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
min={5}
|
||||
max={240}
|
||||
value={Math.round(settings.idleTimeoutMs / 60000)}
|
||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -662,6 +661,12 @@ export default function SettingsView() {
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.logPath ?? t('common:status.reading')}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
@@ -711,9 +716,9 @@ export default function SettingsView() {
|
||||
key={tab.id}
|
||||
data-testid={`settings-tab-${tab.id}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
|
||||
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
|
||||
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
|
||||
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
|
||||
|
||||
@@ -25,13 +25,15 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
|
||||
onClick={onHome}
|
||||
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
|
||||
title={t('nav.home')}
|
||||
aria-label={t('nav.home')}
|
||||
>
|
||||
<Home className="h-4 w-4 text-txt-muted" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(root || '/')}
|
||||
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-secondary"
|
||||
title={rootLabel}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
|
||||
@@ -13,9 +13,13 @@ interface ConnectionPanelProps {
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
error: string | null
|
||||
/** Last remote directory to resume after reconnect (shown as hint). */
|
||||
resumePath?: string | null
|
||||
sessions: Session[]
|
||||
showSelector: boolean
|
||||
onConnect: (sessionId: string) => void
|
||||
/** Prefer same-session reconnect when the tab already knows sessionId. */
|
||||
onReconnect?: () => void
|
||||
onShowSelector: () => void
|
||||
onCloseSelector: () => void
|
||||
}
|
||||
@@ -24,13 +28,16 @@ export function ConnectionPanel({
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
resumePath,
|
||||
sessions,
|
||||
showSelector,
|
||||
onConnect,
|
||||
onReconnect,
|
||||
onShowSelector,
|
||||
onCloseSelector,
|
||||
}: ConnectionPanelProps) {
|
||||
const { t } = useTranslation('sftp')
|
||||
const canResumePath = Boolean(resumePath)
|
||||
|
||||
if (isConnected) {
|
||||
return null
|
||||
@@ -40,22 +47,36 @@ export function ConnectionPanel({
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
|
||||
<span className="text-sm text-accent-blue">{t('connection.connecting')}</span>
|
||||
<span className="text-sm text-accent-blue">
|
||||
{canResumePath
|
||||
? t('connection.reconnectingResume', { path: resumePath })
|
||||
: t('connection.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-red-500/20 bg-red-500/10 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-red-500/20 bg-red-500/10 px-4 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
||||
<span className="text-sm text-red-400 truncate">{error}</span>
|
||||
</div>
|
||||
{canResumePath && (
|
||||
<span className="truncate pl-6 text-[11px] text-red-300/80" title={resumePath ?? undefined}>
|
||||
{t('connection.resumeHint', { path: resumePath })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowSelector}
|
||||
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
|
||||
onClick={() => {
|
||||
if (onReconnect) onReconnect()
|
||||
else onShowSelector()
|
||||
}}
|
||||
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600 flex-shrink-0"
|
||||
>
|
||||
{t('connection.reconnect')}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Monitor, ChevronRight, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Session } from '../../stores/connection'
|
||||
import { isSshSession } from '../../utils/sessionProtocol'
|
||||
|
||||
interface ConnectionSelectorProps {
|
||||
sessions: Session[]
|
||||
@@ -14,6 +15,7 @@ interface ConnectionSelectorProps {
|
||||
|
||||
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
|
||||
const { t } = useTranslation('sftp')
|
||||
const sshSessions = sessions.filter((session) => isSshSession(session))
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
|
||||
@@ -25,7 +27,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-80 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
{sshSessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-txt-muted">
|
||||
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{t('connection.noSessions')}</p>
|
||||
@@ -33,7 +35,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
{sshSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
@@ -41,12 +43,12 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
||||
>
|
||||
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-slate-200 font-medium truncate group-hover:text-content-primary">{session.name}</div>
|
||||
<div className="text-content-primary font-medium truncate group-hover:text-content-primary">{session.name}</div>
|
||||
<div className="text-sm text-txt-muted truncate">
|
||||
{session.username}@{session.host}:{session.port}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-600 group-hover:text-txt-muted transition-colors" />
|
||||
<ChevronRight className="w-5 h-5 text-txt-faint group-hover:text-txt-muted transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/**
|
||||
* ContextMenu - 右键菜单组件
|
||||
*
|
||||
* Rendered via portal to document.body so parent overflow/transform cannot clip it.
|
||||
* Position is fitted to the viewport (flips up/left when near edges).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { CONTEXT_MENU_VIEWPORT_MARGIN, fitContextMenuToViewport } from './contextMenuPosition'
|
||||
|
||||
interface MenuPosition {
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
icon: React.ReactNode
|
||||
@@ -21,9 +32,40 @@ interface ContextMenuProps {
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<MenuPosition>({ left: x, top: y })
|
||||
const [positioned, setPositioned] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const fitToViewport = () => {
|
||||
const menu = menuRef.current
|
||||
if (!menu) return
|
||||
// Prefer scrollHeight/Width so we measure full content even if maxHeight
|
||||
// temporarily constrains the box during the first paint.
|
||||
const width = Math.max(menu.offsetWidth, menu.scrollWidth)
|
||||
const height = Math.max(menu.offsetHeight, menu.scrollHeight)
|
||||
setPosition(fitContextMenuToViewport(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
))
|
||||
setPositioned(true)
|
||||
}
|
||||
|
||||
fitToViewport()
|
||||
// Second pass after fonts/layout settle (still before paint when possible).
|
||||
const raf = window.requestAnimationFrame(fitToViewport)
|
||||
window.addEventListener('resize', fitToViewport)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', fitToViewport)
|
||||
}
|
||||
}, [items, x, y])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
@@ -31,19 +73,26 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
|
||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
||||
className="fixed z-[200] min-w-[180px] overflow-y-auto rounded-lg border border-dark-700 bg-dark-700 py-1 shadow-xl"
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
maxWidth: `calc(100vw - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
|
||||
maxHeight: `calc(100vh - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
|
||||
visibility: positioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
@@ -51,6 +100,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
<div className="my-1 border-t border-dark-600/30" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
@@ -64,6 +114,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
/**
|
||||
* FileListPanel - 文件列表面板
|
||||
*
|
||||
* 本地面板或远程面板的文件列表组件
|
||||
* 远程面板支持拖拽上传
|
||||
* 支持当前文件夹内文件名搜索
|
||||
*
|
||||
* 本地面板或远程面板的文件列表组件。
|
||||
* 栏间传输:自定义指针拖动(不依赖 WKWebView HTML5 drop)。
|
||||
* 系统文件拖入远程:仍走 HTML5 drop。
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
|
||||
Search, X, Eye, EyeOff,
|
||||
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, Download, FolderOpen,
|
||||
Search, X, Eye, EyeOff, RefreshCw, FolderPlus,
|
||||
} from 'lucide-react'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { SortHeader } from './SortHeader'
|
||||
import { FileRow } from './FileRow'
|
||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
import { FileListSkeleton } from '../common/Skeleton'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
|
||||
import { parentPath } from '../../utils/localPath'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import {
|
||||
hasSftpDragEntry,
|
||||
readSftpDragEntry,
|
||||
writeSftpDragEntry,
|
||||
clearSftpDragEntry,
|
||||
dndLog,
|
||||
shortPath,
|
||||
type SftpDragEntry,
|
||||
} from './dragDrop'
|
||||
|
||||
@@ -31,6 +31,8 @@ interface FileListPanelProps {
|
||||
title: string
|
||||
isLocal: boolean
|
||||
path: string
|
||||
/** 主目录(Home 按钮目标);远程应传会话起始家目录,不是 / */
|
||||
homePath?: string
|
||||
files: FileItem[]
|
||||
selected: Set<string>
|
||||
history: string[]
|
||||
@@ -45,20 +47,74 @@ interface FileListPanelProps {
|
||||
onOpen: (file: FileItem) => void
|
||||
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
|
||||
onSort: (field: SortField) => void
|
||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
||||
onCloseContextMenu: () => void
|
||||
/** 操作系统文件拖入远程面板。 */
|
||||
onDropFiles?: (files: FileList, targetPath: string) => void
|
||||
/** OpsXShell 本地/远程栏之间的内部拖拽。 */
|
||||
onDropEntry?: (entry: SftpDragEntry, targetPath: string) => void
|
||||
/** 是否正在加载 */
|
||||
/** 本地/远程栏之间的内部指针拖放(支持多选批量)。 */
|
||||
onDropEntry?: (entries: SftpDragEntry[], targetPath: string) => void
|
||||
/** 系统文件 drop 接受了但读不到路径时回调(用于 toast)。 */
|
||||
onDropFailed?: () => void
|
||||
onRefresh?: () => void
|
||||
onNewFolder?: () => void
|
||||
onFocusPane?: () => void
|
||||
/** 是否正在加载(空列表时显示骨架) */
|
||||
loading?: boolean
|
||||
/** 刷新中:图标旋转 + 状态栏提示,不拆掉现有列表 */
|
||||
refreshing?: boolean
|
||||
listError?: string | null
|
||||
}
|
||||
|
||||
export function FileListPanel({
|
||||
function isAtFilesystemRoot(path: string, isLocal: boolean): boolean {
|
||||
const parent = parentPath(path, isLocal)
|
||||
return parent === path || path === '/' || /^[a-zA-Z]:\\?$/.test(path)
|
||||
}
|
||||
|
||||
const POINTER_DRAG_EVENT = 'opsxshell:sftp-pointer-drag'
|
||||
const POINTER_DRAG_THRESHOLD = 6
|
||||
|
||||
interface PointerDragDetail {
|
||||
source: SftpDragEntry['source'] | null
|
||||
target: SftpDragEntry['source'] | null
|
||||
targetPath: string | null
|
||||
}
|
||||
|
||||
interface PointerDragState {
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
entries: SftpDragEntry[]
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function hasOsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types || []).includes('Files')
|
||||
}
|
||||
|
||||
function pointerDropTarget(clientX: number, clientY: number, source: SftpDragEntry['source']) {
|
||||
const hit = document.elementFromPoint?.(clientX, clientY)
|
||||
const pane = hit?.closest<HTMLElement>('[data-sftp-pane]')
|
||||
if (!pane || pane.dataset.sftpPane === source) return null
|
||||
|
||||
const directory = hit?.closest<HTMLElement>('[data-sftp-directory-path]')
|
||||
const targetPath = directory && pane.contains(directory)
|
||||
? directory.dataset.sftpDirectoryPath
|
||||
: pane.dataset.sftpPath
|
||||
if (!targetPath) return null
|
||||
|
||||
return {
|
||||
pane: pane.dataset.sftpPane as SftpDragEntry['source'],
|
||||
path: targetPath,
|
||||
}
|
||||
}
|
||||
|
||||
function announcePointerDrag(detail: PointerDragDetail) {
|
||||
window.dispatchEvent(new CustomEvent<PointerDragDetail>(POINTER_DRAG_EVENT, { detail }))
|
||||
}
|
||||
|
||||
export const FileListPanel = memo(function FileListPanel({
|
||||
title,
|
||||
isLocal,
|
||||
path,
|
||||
homePath,
|
||||
files,
|
||||
selected,
|
||||
history,
|
||||
@@ -73,22 +129,26 @@ export function FileListPanel({
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
onSort,
|
||||
contextMenu,
|
||||
onCloseContextMenu,
|
||||
onDropFiles,
|
||||
onDropEntry,
|
||||
onDropFailed,
|
||||
onRefresh,
|
||||
onNewFolder,
|
||||
onFocusPane,
|
||||
loading = false,
|
||||
refreshing = false,
|
||||
listError = null,
|
||||
}: FileListPanelProps) {
|
||||
const { t } = useTranslation('sftp')
|
||||
const canGoBack = historyIndex > 0
|
||||
const canGoForward = historyIndex < history.length - 1
|
||||
const canGoUp = !isAtFilesystemRoot(path, isLocal)
|
||||
const resolvedHome = homePath || (isLocal ? history[0] || path : '/')
|
||||
|
||||
// Current-folder filename filter + hidden files toggle
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Clear name filter when directory changes (keep showHidden preference)
|
||||
useEffect(() => {
|
||||
setNameFilter('')
|
||||
}, [path])
|
||||
@@ -109,26 +169,61 @@ export function FileListPanel({
|
||||
)
|
||||
const isFiltering = nameFilter.trim().length > 0
|
||||
|
||||
// Drag state for remote panel upload
|
||||
const parentEntry = useMemo((): FileItem | null => {
|
||||
if (!canGoUp || isFiltering) return null
|
||||
return {
|
||||
name: '..',
|
||||
path: parentPath(path, isLocal),
|
||||
type: 'directory',
|
||||
size: 0,
|
||||
modified: 0,
|
||||
permissions: 'drwxr-xr-x',
|
||||
}
|
||||
}, [canGoUp, isFiltering, isLocal, path])
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
|
||||
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
/** Nested dragenter/leave — OS file HTML5 drop only. */
|
||||
const dragDepthRef = useRef(0)
|
||||
const pointerDragRef = useRef<PointerDragState | null>(null)
|
||||
const suppressNextClickRef = useRef(false)
|
||||
const [pointerPreview, setPointerPreview] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
entries: SftpDragEntry[]
|
||||
canDrop: boolean
|
||||
} | null>(null)
|
||||
|
||||
const acceptsDrag = useCallback((dataTransfer: DataTransfer) => {
|
||||
const internal = readSftpDragEntry(dataTransfer)
|
||||
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
|
||||
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
|
||||
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
|
||||
}, [isLocal, onDropEntry, onDropFiles])
|
||||
const acceptsOsFileDrag = useCallback((dataTransfer: DataTransfer) => {
|
||||
if (isLocal || !onDropFiles) return false
|
||||
return hasOsFiles(dataTransfer)
|
||||
}, [isLocal, onDropFiles])
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragCounterRef.current = 0
|
||||
const resetOsDragState = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setIsDragOver(false)
|
||||
setDropTargetPath(null)
|
||||
setDragSource(null)
|
||||
}, [])
|
||||
|
||||
// Cross-pane highlight for pointer-based internal dragging.
|
||||
useEffect(() => {
|
||||
const onPointerDrag = (event: Event) => {
|
||||
const detail = (event as CustomEvent<PointerDragDetail>).detail
|
||||
const pane = isLocal ? 'local' : 'remote'
|
||||
const active = detail.target === pane
|
||||
setIsDragOver(active)
|
||||
setDragSource(active ? detail.source : null)
|
||||
setDropTargetPath(active ? detail.targetPath : null)
|
||||
}
|
||||
window.addEventListener(POINTER_DRAG_EVENT, onPointerDrag)
|
||||
return () => {
|
||||
window.removeEventListener(POINTER_DRAG_EVENT, onPointerDrag)
|
||||
document.body.classList.remove('sftp-pointer-dragging')
|
||||
}
|
||||
}, [isLocal])
|
||||
|
||||
const clearFilter = useCallback(() => {
|
||||
setNameFilter('')
|
||||
searchInputRef.current?.focus()
|
||||
@@ -146,7 +241,6 @@ export function FileListPanel({
|
||||
}
|
||||
}, [nameFilter])
|
||||
|
||||
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
|
||||
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
@@ -156,81 +250,213 @@ export function FileListPanel({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const consumeOsDrop = useCallback((dataTransfer: DataTransfer, targetPath: string) => {
|
||||
resetOsDragState()
|
||||
const droppedFiles = dataTransfer.files
|
||||
if (!isLocal && droppedFiles && droppedFiles.length > 0) {
|
||||
dndLog('drop-os-files', {
|
||||
pane: 'remote',
|
||||
target: shortPath(targetPath),
|
||||
count: droppedFiles.length,
|
||||
})
|
||||
onDropFiles?.(droppedFiles, targetPath)
|
||||
return
|
||||
}
|
||||
dndLog('drop-os-failed', {
|
||||
pane: isLocal ? 'local' : 'remote',
|
||||
target: shortPath(targetPath),
|
||||
types: Array.from(dataTransfer.types || []),
|
||||
})
|
||||
onDropFailed?.()
|
||||
}, [isLocal, onDropFiles, onDropFailed, resetOsDragState])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
|
||||
dragCounterRef.current++
|
||||
if (dragCounterRef.current === 1) {
|
||||
setIsDragOver(true)
|
||||
dragDepthRef.current += 1
|
||||
setDragSource(null)
|
||||
setIsDragOver(true)
|
||||
if (dragDepthRef.current === 1) {
|
||||
dndLog('os-dragenter', { pane: 'remote', accept: true })
|
||||
}
|
||||
}, [acceptsDrag])
|
||||
}, [acceptsOsFileDrag])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
if (!acceptsOsFileDrag(e.dataTransfer) && dragDepthRef.current === 0) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounterRef.current--
|
||||
if (dragCounterRef.current === 0) {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsDragOver(false)
|
||||
setDropTargetPath(null)
|
||||
}
|
||||
}, [acceptsDrag])
|
||||
}, [acceptsOsFileDrag])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [acceptsDrag])
|
||||
if (!isDragOver) setIsDragOver(true)
|
||||
}, [acceptsOsFileDrag, isDragOver])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
if (!acceptsOsFileDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const entry = readSftpDragEntry(e.dataTransfer)
|
||||
resetDragState()
|
||||
consumeOsDrop(e.dataTransfer, path)
|
||||
}, [acceptsOsFileDrag, consumeOsDrop, path])
|
||||
|
||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
||||
onDropEntry?.(entry, path)
|
||||
return
|
||||
}
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
onDropFiles?.(droppedFiles, path)
|
||||
}
|
||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, path, resetDragState])
|
||||
|
||||
const dropOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
const dropOsOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
|
||||
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const entry = readSftpDragEntry(event.dataTransfer)
|
||||
resetDragState()
|
||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
||||
onDropEntry?.(entry, directory.path)
|
||||
return
|
||||
dndLog('drop-os-on-dir', { dir: shortPath(directory.path) })
|
||||
consumeOsDrop(event.dataTransfer, directory.path)
|
||||
}, [acceptsOsFileDrag, consumeOsDrop])
|
||||
|
||||
const dragEntriesFor = useCallback((file: FileItem): SftpDragEntry[] => {
|
||||
const source: SftpDragEntry['source'] = isLocal ? 'local' : 'remote'
|
||||
const selectedNames = selected.has(file.name) && selected.size > 1
|
||||
? selected
|
||||
: new Set([file.name])
|
||||
const entries = files
|
||||
.filter((item) => selectedNames.has(item.name))
|
||||
.map((item) => ({
|
||||
source,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
}))
|
||||
|
||||
return entries.length > 0 ? entries : [{
|
||||
source,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
}]
|
||||
}, [files, isLocal, selected])
|
||||
|
||||
const resetPointerDrag = useCallback(() => {
|
||||
pointerDragRef.current = null
|
||||
setPointerPreview(null)
|
||||
announcePointerDrag({ source: null, target: null, targetPath: null })
|
||||
clearSftpDragEntry()
|
||||
document.body.classList.remove('sftp-pointer-dragging')
|
||||
}, [])
|
||||
|
||||
const startPointerDrag = useCallback((file: FileItem, event: React.PointerEvent) => {
|
||||
if (event.button !== 0 || !onDropEntry) return
|
||||
// Stop the browser from starting a text selection before the drag threshold.
|
||||
event.preventDefault()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
const entries = dragEntriesFor(file)
|
||||
pointerDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
entries,
|
||||
active: false,
|
||||
}
|
||||
if (!isLocal && event.dataTransfer.files?.length) {
|
||||
onDropFiles?.(event.dataTransfer.files, directory.path)
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
}, [dragEntriesFor, onDropEntry])
|
||||
|
||||
const movePointerDrag = useCallback((event: React.PointerEvent) => {
|
||||
const state = pointerDragRef.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
|
||||
if (!state.active) {
|
||||
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
|
||||
if (distance < POINTER_DRAG_THRESHOLD) return
|
||||
state.active = true
|
||||
suppressNextClickRef.current = true
|
||||
document.body.classList.add('sftp-pointer-dragging')
|
||||
window.getSelection()?.removeAllRanges()
|
||||
dndLog('pointer-drag-start', {
|
||||
source: state.entries[0]?.source,
|
||||
count: state.entries.length,
|
||||
names: state.entries.slice(0, 8).map((entry) => entry.name),
|
||||
})
|
||||
}
|
||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
|
||||
|
||||
event.preventDefault()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
const source = state.entries[0]?.source
|
||||
if (!source) return
|
||||
const target = pointerDropTarget(event.clientX, event.clientY, source)
|
||||
setPointerPreview({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
entries: state.entries,
|
||||
canDrop: Boolean(target),
|
||||
})
|
||||
announcePointerDrag({
|
||||
source,
|
||||
target: target?.pane || null,
|
||||
targetPath: target?.path || null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const finishPointerDrag = useCallback((event: React.PointerEvent) => {
|
||||
const state = pointerDragRef.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
if (state.active) {
|
||||
event.preventDefault()
|
||||
const source = state.entries[0]?.source
|
||||
const target = source ? pointerDropTarget(event.clientX, event.clientY, source) : null
|
||||
if (target) {
|
||||
dndLog('pointer-drop', {
|
||||
source,
|
||||
target: target.pane,
|
||||
targetPath: shortPath(target.path),
|
||||
count: state.entries.length,
|
||||
})
|
||||
onDropEntry?.(state.entries, target.path)
|
||||
} else {
|
||||
dndLog('pointer-drop-cancelled', { source })
|
||||
}
|
||||
setTimeout(() => {
|
||||
suppressNextClickRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
resetPointerDrag()
|
||||
}, [onDropEntry, resetPointerDrag])
|
||||
|
||||
const cancelPointerDrag = useCallback((event: React.PointerEvent) => {
|
||||
const state = pointerDragRef.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||
suppressNextClickRef.current = false
|
||||
resetPointerDrag()
|
||||
}, [resetPointerDrag])
|
||||
|
||||
const statusText = (() => {
|
||||
if (refreshing) return t('status.refreshing')
|
||||
if (loading) return t('status.loading')
|
||||
// listError is shown in the banner above; keep the footer for selection/count.
|
||||
if (selected.size > 0) return t('status.selected', { count: selected.size })
|
||||
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
|
||||
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
|
||||
return t('status.total', { count: visibleFiles.length })
|
||||
})()
|
||||
|
||||
const PreviewIcon = pointerPreview?.entries[0]?.source === 'remote' ? Download : Upload
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={t('panel.ariaLabel', { title })}
|
||||
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
||||
data-sftp-pane={isLocal ? 'local' : 'remote'}
|
||||
data-sftp-path={path}
|
||||
className={`sftp-file-panel flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
||||
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
|
||||
}`}
|
||||
onMouseDown={() => onFocusPane?.()}
|
||||
onFocusCapture={() => onFocusPane?.()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -238,13 +464,36 @@ export function FileListPanel({
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
|
||||
<span className="text-sm font-medium truncate">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary disabled:opacity-50 disabled:pointer-events-none"
|
||||
title={t('toolbar.refresh')}
|
||||
aria-label={t('toolbar.refreshAria')}
|
||||
aria-busy={refreshing || loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${(refreshing || loading) ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{onNewFolder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewFolder}
|
||||
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary"
|
||||
title={t('toolbar.newFolder')}
|
||||
aria-label={t('toolbar.newFolderAria')}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
@@ -277,22 +526,22 @@ export function FileListPanel({
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateUp}
|
||||
className="p-1.5 hover:bg-dark-600 rounded"
|
||||
title={t('nav.up')}
|
||||
disabled={!canGoUp}
|
||||
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||
title={canGoUp ? t('nav.up') : t('nav.upAtRoot')}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb + search */}
|
||||
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Breadcrumb
|
||||
path={path}
|
||||
isLocal={isLocal}
|
||||
onNavigate={onNavigate}
|
||||
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
|
||||
onHome={() => onNavigate(resolvedHome)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
|
||||
@@ -321,27 +570,40 @@ export function FileListPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers — 与 FileRow 共用 px-4 对齐 */}
|
||||
{listError && (
|
||||
<div className="px-3 py-1.5 border-b border-red-500/20 bg-red-500/10 text-xs text-red-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{listError}</span>
|
||||
{canGoUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateUp}
|
||||
className="flex-shrink-0 rounded bg-dark-700 px-2 py-0.5 text-content-secondary hover:bg-dark-600"
|
||||
>
|
||||
{t('empty.goUp')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
|
||||
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
{loading ? (
|
||||
<FileListSkeleton rows={6} />
|
||||
) : files.length === 0 ? (
|
||||
) : files.length === 0 && !parentEntry ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={t('empty.dirTitle')}
|
||||
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
|
||||
action={
|
||||
path !== '/'
|
||||
canGoUp
|
||||
? { label: t('empty.goUp'), onClick: onNavigateUp }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : visibleFiles.length === 0 ? (
|
||||
) : visibleFiles.length === 0 && !parentEntry ? (
|
||||
<EmptyState
|
||||
icon={EyeOff}
|
||||
title={t('empty.hiddenOnlyTitle')}
|
||||
@@ -351,7 +613,7 @@ export function FileListPanel({
|
||||
onClick: () => setShowHidden(true),
|
||||
}}
|
||||
/>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
) : filteredFiles.length === 0 && !parentEntry ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('empty.noMatchTitle')}
|
||||
@@ -359,54 +621,65 @@ export function FileListPanel({
|
||||
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
|
||||
/>
|
||||
) : (
|
||||
filteredFiles.map((file) => (
|
||||
<FileRow
|
||||
key={file.name}
|
||||
file={file}
|
||||
isSelected={selected.has(file.name)}
|
||||
onSelect={() => onSelect(file.name)}
|
||||
onOpen={() => onOpen(file)}
|
||||
onContextMenu={(e) => onContextMenu(e, file)}
|
||||
onDragStart={(event) => {
|
||||
writeSftpDragEntry(event.dataTransfer, {
|
||||
source: isLocal ? 'local' : 'remote',
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
})
|
||||
}}
|
||||
onDragEnd={resetDragState}
|
||||
onDragEnter={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
|
||||
setIsDragOver(true)
|
||||
setDropTargetPath(file.path)
|
||||
} : undefined}
|
||||
onDragLeave={file.type === 'directory' ? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDropTargetPath((current) => current === file.path ? null : current)
|
||||
} : undefined}
|
||||
onDragOver={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
} : undefined}
|
||||
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
|
||||
isDropTarget={dropTargetPath === file.path}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{parentEntry && (
|
||||
<FileRow
|
||||
key="__parent__"
|
||||
file={parentEntry}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
onOpen={() => onNavigate(parentEntry.path)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filteredFiles.map((file) => (
|
||||
<FileRow
|
||||
key={file.name}
|
||||
file={file}
|
||||
isSelected={selected.has(file.name)}
|
||||
onSelect={() => {
|
||||
if (suppressNextClickRef.current) return
|
||||
onSelect(file.name)
|
||||
}}
|
||||
onOpen={() => onOpen(file)}
|
||||
onContextMenu={(e) => onContextMenu(e, file)}
|
||||
onPointerDown={(event) => startPointerDrag(file, event)}
|
||||
onPointerMove={movePointerDrag}
|
||||
onPointerUp={finishPointerDrag}
|
||||
onPointerCancel={cancelPointerDrag}
|
||||
onDragEnter={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
setDragSource(null)
|
||||
setIsDragOver(true)
|
||||
setDropTargetPath(file.path)
|
||||
} : undefined}
|
||||
onDragLeave={file.type === 'directory' ? (event) => {
|
||||
event.preventDefault()
|
||||
setDropTargetPath((current) => current === file.path ? null : current)
|
||||
} : undefined}
|
||||
onDragOver={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsOsFileDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
} : undefined}
|
||||
onDrop={file.type === 'directory' ? (event) => dropOsOnDirectory(event, file) : undefined}
|
||||
isDropTarget={dropTargetPath === file.path}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="flex flex-col items-center gap-2 text-accent-blue">
|
||||
<Upload className="w-10 h-10" />
|
||||
{isLocal || dragSource === 'remote' ? (
|
||||
<Download className="w-10 h-10" />
|
||||
) : (
|
||||
<Upload className="w-10 h-10" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{isLocal || dragSource === 'remote'
|
||||
? t('transfer.dropDownload', { path: dropTargetPath || path })
|
||||
@@ -415,20 +688,31 @@ export function FileListPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pointerPreview && (
|
||||
<div
|
||||
className={`pointer-events-none fixed z-[100] flex max-w-64 items-center gap-2 rounded-md border px-3 py-2 text-xs shadow-xl ${
|
||||
pointerPreview.canDrop
|
||||
? 'border-accent-blue bg-dark-700 text-content-primary'
|
||||
: 'border-dark-600 bg-dark-800 text-txt-muted'
|
||||
}`}
|
||||
style={{ left: pointerPreview.x + 14, top: pointerPreview.y + 14 }}
|
||||
>
|
||||
<PreviewIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{pointerPreview.entries[0]?.name}</span>
|
||||
{pointerPreview.entries.length > 1 && (
|
||||
<span className="flex-shrink-0">+{pointerPreview.entries.length - 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
|
||||
<span>{statusText}</span>
|
||||
<span className="truncate">{statusText}</span>
|
||||
{isFiltering && (
|
||||
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -13,6 +13,10 @@ interface FileRowProps {
|
||||
onSelect: () => void
|
||||
onOpen: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onPointerDown?: (e: React.PointerEvent) => void
|
||||
onPointerMove?: (e: React.PointerEvent) => void
|
||||
onPointerUp?: (e: React.PointerEvent) => void
|
||||
onPointerCancel?: (e: React.PointerEvent) => void
|
||||
onDragStart?: (e: React.DragEvent) => void
|
||||
onDragEnd?: (e: React.DragEvent) => void
|
||||
onDragEnter?: (e: React.DragEvent) => void
|
||||
@@ -28,6 +32,10 @@ export function FileRow({
|
||||
onSelect,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragEnter,
|
||||
@@ -42,22 +50,29 @@ export function FileRow({
|
||||
onClick={onSelect}
|
||||
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
draggable={!!onDragStart}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
data-sftp-entry-path={file.path}
|
||||
data-sftp-directory-path={file.type === 'directory' ? file.path : undefined}
|
||||
draggable={false}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
|
||||
className={`h-10 flex select-none items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
|
||||
isDropTarget
|
||||
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
|
||||
: isSelected
|
||||
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
|
||||
: 'hover:bg-dark-700/40'
|
||||
}`}
|
||||
style={{ WebkitUserSelect: 'none', userSelect: 'none', touchAction: 'none' }}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<div className="sftp-col-name flex-1 flex items-center gap-2 min-w-0">
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
|
||||
) : file.type === 'symlink' ? (
|
||||
@@ -66,13 +81,15 @@ export function FileRow({
|
||||
<File className="w-4 h-4 text-txt-muted flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-content-primary truncate group-hover:text-content-primary">{file.name}</span>
|
||||
{file.type === 'symlink' && <span className="text-xs text-yellow-500">→</span>}
|
||||
{file.type === 'symlink' && <span className="text-xs text-yellow-500 flex-shrink-0">→</span>}
|
||||
</div>
|
||||
<div className="w-32 text-right text-xs text-txt-muted">{formatDate(file.modified)}</div>
|
||||
<div className="w-24 text-right text-xs text-txt-muted">
|
||||
<div className="sftp-col-modified w-28 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
|
||||
{formatDate(file.modified)}
|
||||
</div>
|
||||
<div className="sftp-col-size w-16 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
|
||||
{file.type === 'directory' ? '--' : formatSize(file.size)}
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs text-txt-muted font-mono">
|
||||
<div className="sftp-col-type w-16 flex-shrink-0 text-right text-xs text-txt-muted font-mono truncate">
|
||||
{file.type === 'directory'
|
||||
? t('fileType.folder')
|
||||
: file.type === 'symlink'
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* Remote text file editor (SFTP) with Monaco
|
||||
* Remote text file editor (SFTP) with Monaco.
|
||||
*
|
||||
* Renders as an in-pane overlay (not viewport-fixed) so the app title bar /
|
||||
* tab bar stay free for window dragging.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X, Save, FileCode } from 'lucide-react'
|
||||
import { X, Save, FileCode, RotateCcw } from 'lucide-react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { guessLanguageFromPath } from '../../utils/fileLanguage'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
|
||||
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
|
||||
|
||||
/** Above this, turn off expensive Monaco features (minimap, folding, etc.). */
|
||||
const LIGHT_EDITOR_CHARS = 80_000
|
||||
|
||||
export interface RemoteTextEditorProps {
|
||||
isOpen: boolean
|
||||
sftpId: string
|
||||
@@ -28,6 +38,8 @@ export default function RemoteTextEditor({
|
||||
onSaved,
|
||||
}: RemoteTextEditorProps) {
|
||||
const { t } = useTranslation(['sftp', 'common'])
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
const { effective } = useTheme()
|
||||
const [content, setContent] = useState('')
|
||||
const [original, setOriginal] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -36,6 +48,36 @@ export default function RemoteTextEditor({
|
||||
|
||||
const dirty = content !== original
|
||||
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
|
||||
const lightMode = content.length > LIGHT_EDITOR_CHARS
|
||||
|
||||
const editorOptions = useMemo((): editor.IStandaloneEditorConstructionOptions => {
|
||||
return {
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
||||
minimap: { enabled: !lightMode && content.length > 20_000, scale: 0.75 },
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
renderWhitespace: lightMode ? 'none' : 'selection',
|
||||
smoothScrolling: !lightMode,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
folding: !lightMode,
|
||||
links: !lightMode,
|
||||
matchBrackets: lightMode ? 'never' : 'always',
|
||||
occurrencesHighlight: lightMode ? 'off' : 'singleFile',
|
||||
renderLineHighlight: lightMode ? 'none' : 'line',
|
||||
quickSuggestions: !lightMode,
|
||||
suggestOnTriggerCharacters: !lightMode,
|
||||
parameterHints: { enabled: !lightMode },
|
||||
hover: { enabled: !lightMode },
|
||||
codeLens: false,
|
||||
lightbulb: {
|
||||
enabled: (lightMode ? 'off' : 'onCode') as editor.ShowLightbulbIconMode,
|
||||
},
|
||||
unicodeHighlight: { ambiguousCharacters: false },
|
||||
}
|
||||
}, [content.length, lightMode])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isOpen || !sftpId || !remotePath) return
|
||||
@@ -49,33 +91,17 @@ export default function RemoteTextEditor({
|
||||
setContent(result.content)
|
||||
setOriginal(result.content)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isOpen, sftpId, remotePath])
|
||||
}, [isOpen, sftpId, remotePath, t])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Cmd/Ctrl+S
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (dirty && !saving && !loading) {
|
||||
void handleSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, dirty, saving, loading, content])
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -90,93 +116,169 @@ export default function RemoteTextEditor({
|
||||
setOriginal(content)
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}, [content, onSaved, remotePath, sftpId, t])
|
||||
|
||||
const handleClose = () => {
|
||||
if (dirty && !confirm(t('editor.unsavedClose'))) return
|
||||
const handleClose = useCallback(async () => {
|
||||
if (dirty) {
|
||||
const ok = await confirm({
|
||||
title: t('editor.unsavedTitle'),
|
||||
message: t('editor.unsavedClose'),
|
||||
danger: true,
|
||||
confirmText: t('editor.discardAndClose'),
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}, [dirty, confirm, onClose, t])
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
if (dirty) {
|
||||
const ok = await confirm({
|
||||
title: t('editor.reloadTitle'),
|
||||
message: t('editor.unsavedReload'),
|
||||
danger: true,
|
||||
confirmText: t('editor.discardAndReload'),
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
void load()
|
||||
}, [dirty, confirm, load, t])
|
||||
|
||||
// Cmd/Ctrl+S save · Escape close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (dirty && !saving && !loading) void handleSave()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void handleClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [isOpen, dirty, saving, loading, handleSave, handleClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-dark-600 bg-dark-800 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-content-primary">
|
||||
{fileName}
|
||||
{dirty && <span className="ml-1 text-yellow-400">•</span>}
|
||||
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex flex-col bg-dark-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('editor.title', { name: fileName })}
|
||||
>
|
||||
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
|
||||
<FileCode className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 truncate text-sm font-medium text-content-primary">
|
||||
<span className="truncate">{fileName}</span>
|
||||
{dirty && (
|
||||
<span className="flex-shrink-0 rounded bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-normal text-yellow-400">
|
||||
{t('editor.dirtyBadge')}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-[10px] font-normal uppercase tracking-wide text-txt-faint">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-txt-faint" title={remotePath}>
|
||||
{remotePath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving || loading}
|
||||
onClick={() => void handleSave()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? t('editor.saving') : t('editor.save')}
|
||||
</button>
|
||||
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleReload()}
|
||||
disabled={loading || saving}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-40"
|
||||
title={t('editor.reload')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('editor.reload')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving || loading}
|
||||
onClick={() => void handleSave()}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||
dirty
|
||||
? 'bg-accent-blue text-white hover:bg-blue-500'
|
||||
: 'bg-dark-700 text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? t('editor.saving') : t('editor.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClose()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title={t('common:action.close')}
|
||||
aria-label={t('common:action.close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="flex-shrink-0 border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||
{t('common:status.loading')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
|
||||
) : error && !content ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
|
||||
{t('editor.loadBlocked')}
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||
{t('editor.loadingEditor')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={effective === 'light' ? 'vs' : 'vs-dark'}
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? '')}
|
||||
options={editorOptions}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme="vs-dark"
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? '')}
|
||||
options={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
||||
minimap: { enabled: true, scale: 0.75 },
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
renderWhitespace: 'selection',
|
||||
smoothScrolling: true,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
|
||||
<span>{t('editor.footer')}</span>
|
||||
<span>
|
||||
{t('editor.charCount', { count: content.length.toLocaleString() })}
|
||||
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
|
||||
<span>{t('editor.footer')}</span>
|
||||
<span>
|
||||
{t('editor.charCount', { count: content.length.toLocaleString() })}
|
||||
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
|
||||
</span>
|
||||
</footer>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,30 +4,39 @@
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, Copy, FileText, ShieldCheck,
|
||||
Edit3, FileText, ShieldCheck,
|
||||
Unplug, FileCode,
|
||||
} from 'lucide-react'
|
||||
import { useSFTP } from '../../hooks'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
import { FileListPanel } from './FileListPanel'
|
||||
import { TransferPanel } from './TransferPanel'
|
||||
import { Toolbar } from './Toolbar'
|
||||
import { ConnectionPanel } from './ConnectionPanel'
|
||||
import { TransferRail } from './TransferRail'
|
||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
import { ToastContainer } from '../common/ToastContainer'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import { sortFiles } from './utils'
|
||||
import { usePromptDialog } from '../common/PromptDialog'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import FilePropertiesDialog from './FilePropertiesDialog'
|
||||
import ChmodDialog from './ChmodDialog'
|
||||
import RemoteTextEditor from './RemoteTextEditor'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import { parentPath } from '../../utils/localPath'
|
||||
import { parentPath, joinLocalPath } from '../../utils/localPath'
|
||||
import type { SftpDragEntry } from './dragDrop'
|
||||
import { dndLog, shortPath } from './dragDrop'
|
||||
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
|
||||
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
|
||||
import {
|
||||
conflictNamesFromListing,
|
||||
formatOverwriteMessage,
|
||||
transferDestinationPath,
|
||||
} from './transferConflicts'
|
||||
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
@@ -35,8 +44,8 @@ interface SFTPViewProps {
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
|
||||
const { t } = useTranslation('sftp')
|
||||
export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPViewProps) {
|
||||
const { t } = useTranslation(['sftp', 'errors'])
|
||||
const {
|
||||
sessions,
|
||||
localPath,
|
||||
@@ -59,23 +68,32 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
navigateRemoteBack,
|
||||
navigateRemoteForward,
|
||||
loadRemoteDirectory,
|
||||
remoteHomePath,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
transferQueue,
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
cancelTransfer,
|
||||
removeTransfer,
|
||||
retryTransfer,
|
||||
deleteRemote,
|
||||
renameRemote,
|
||||
mkdirRemote,
|
||||
getRemoteStat,
|
||||
chmodRemote,
|
||||
error,
|
||||
clearError,
|
||||
sftpId,
|
||||
connectionId,
|
||||
localLoading,
|
||||
remoteLoading,
|
||||
} = useSFTP({ tabId })
|
||||
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
const { toast } = useToast()
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
// UI State
|
||||
const [sortField, setSortField] = useState<SortField>('name')
|
||||
@@ -83,6 +101,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
|
||||
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||
const hadConnectedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isConnected) hadConnectedRef.current = true
|
||||
}, [isConnected])
|
||||
|
||||
// Properties dialog state
|
||||
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
|
||||
@@ -93,6 +115,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
|
||||
// Remote text editor
|
||||
const [editFile, setEditFile] = useState<FileItem | null>(null)
|
||||
/** 工具栏「新建文件夹」作用于当前焦点面板 */
|
||||
const [, setActivePane] = useState<'local' | 'remote'>('remote')
|
||||
/** 仅远程列表错误展示在远程面板(连接错误仍走 ConnectionPanel) */
|
||||
const remoteListError = isConnected ? error : null
|
||||
const lastListErrorToast = useRef<string | null>(null)
|
||||
|
||||
// Surface list failures as a short toast (status strip alone is easy to miss).
|
||||
useEffect(() => {
|
||||
if (!remoteListError) {
|
||||
lastListErrorToast.current = null
|
||||
return
|
||||
}
|
||||
if (lastListErrorToast.current === remoteListError) return
|
||||
lastListErrorToast.current = remoteListError
|
||||
toast.warning(remoteListError, 4000)
|
||||
}, [remoteListError, toast])
|
||||
|
||||
// Auto-connect when sessionId is provided and not connected
|
||||
const autoConnectAttempted = useRef(false)
|
||||
@@ -109,6 +147,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Keep pool idle-suspend from arming while this SFTP tab is in front.
|
||||
useEffect(() => {
|
||||
if (!isActive || !connectionId || !isConnected) return
|
||||
getConnectionStore().setActiveTab(connectionId, tabId)
|
||||
}, [isActive, connectionId, isConnected, tabId])
|
||||
|
||||
// Sort handler
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
@@ -155,14 +199,107 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
}, [t, toast])
|
||||
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
|
||||
const result = await uploadFiles(sources, targetPath)
|
||||
const dest = targetPath ?? remotePath
|
||||
let pending = sources
|
||||
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
|
||||
let conflicts =
|
||||
dest === remotePath
|
||||
? conflictNamesFromListing(names, remoteFiles)
|
||||
: []
|
||||
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const remoteTarget = transferDestinationPath('upload', dest, name)
|
||||
try {
|
||||
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
}
|
||||
conflicts = hits
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'upload', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = sources.filter((source) => {
|
||||
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
|
||||
return !skip.has(name)
|
||||
})
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
}
|
||||
}
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await uploadFiles(pending, dest)
|
||||
reportTransfer('upload', result)
|
||||
}, [reportTransfer, uploadFiles])
|
||||
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
|
||||
|
||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||
const result = await downloadFiles(files, targetPath)
|
||||
const dest = targetPath ?? localPath
|
||||
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
|
||||
let pending = selected
|
||||
const names = selected.map((file) => file.name)
|
||||
let conflicts =
|
||||
dest === localPath ? conflictNamesFromListing(names, localFiles) : []
|
||||
if (dest !== localPath && window.opsxApi?.localGetStats) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const localTarget = transferDestinationPath('download', dest, name)
|
||||
try {
|
||||
const result = (await window.opsxApi.localGetStats(localTarget)) as {
|
||||
success?: boolean
|
||||
stats?: unknown
|
||||
}
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
}
|
||||
conflicts = hits
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'download', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = selected.filter((file) => !skip.has(file.name))
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
}
|
||||
}
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await downloadFiles(pending, dest)
|
||||
reportTransfer('download', result)
|
||||
}, [downloadFiles, reportTransfer])
|
||||
}, [
|
||||
confirm,
|
||||
downloadFiles,
|
||||
localFiles,
|
||||
localPath,
|
||||
remoteFiles,
|
||||
remoteSelected,
|
||||
reportTransfer,
|
||||
t,
|
||||
toast,
|
||||
])
|
||||
|
||||
// ===== Context menu handlers =====
|
||||
|
||||
@@ -184,21 +321,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
}
|
||||
}, [prompt, renameRemote, t, toast])
|
||||
|
||||
// Copy path handler
|
||||
const handleCopyPath = useCallback(async (filePath: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filePath)
|
||||
toast.success(t('toast.pathCopied'))
|
||||
} catch {
|
||||
toast.error(t('toast.pathCopyFailed'))
|
||||
}
|
||||
}, [t, toast])
|
||||
|
||||
// Properties handler
|
||||
const handleShowProperties = useCallback((file: FileItem) => {
|
||||
setPropertiesFile(file)
|
||||
}, [])
|
||||
|
||||
const handleDeleteRemote = useCallback(async (files: FileItem[]) => {
|
||||
if (files.length === 0) return
|
||||
const hasDir = files.some((file) => file.type === 'directory')
|
||||
const names = files.slice(0, 3).map((file) => file.name).join(', ')
|
||||
const ok = await confirm({
|
||||
title: t('dialog.deleteTitle'),
|
||||
message:
|
||||
t('dialog.deleteMessage', { count: files.length, names }) +
|
||||
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
|
||||
danger: true,
|
||||
confirmText: t('contextMenu.delete'),
|
||||
})
|
||||
if (!ok) return
|
||||
const success = await deleteRemote(files)
|
||||
if (!success) toast.error(t('toast.deleteFailed'))
|
||||
}, [confirm, deleteRemote, t, toast])
|
||||
|
||||
// Permissions (chmod) handler — fetch stat first, then show dialog
|
||||
const handleChmod = useCallback(async (file: FileItem) => {
|
||||
const stats = await getRemoteStat(file.path)
|
||||
@@ -217,7 +361,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
toast.error(t('toast.chmodFailed'))
|
||||
return false
|
||||
}
|
||||
}, [chmodRemote, toast])
|
||||
}, [chmodRemote, toast, t])
|
||||
|
||||
// Local context menu
|
||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
@@ -235,15 +379,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem]
|
||||
: []),
|
||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
separator: true,
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateLocal, handleCopyPath, handleShowProperties, performUpload])
|
||||
}, [navigateLocal, handleShowProperties, performUpload, t])
|
||||
|
||||
// Remote context menu
|
||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
@@ -259,17 +403,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
label: t('contextMenu.download'),
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file'
|
||||
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
label: t('contextMenu.edit'),
|
||||
action: () => setEditFile(file),
|
||||
action: () => {
|
||||
if (!isWithinRemoteTextEditLimit(file.size)) {
|
||||
toast.warning(t('errors:sftp.editTooLarge'))
|
||||
return
|
||||
}
|
||||
setEditFile(file)
|
||||
},
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
@@ -280,10 +429,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
label: t('contextMenu.permissions'),
|
||||
action: () => handleChmod(file),
|
||||
},
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([file]) },
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) },
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
@@ -294,6 +443,48 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
navigateRemote(parentPath(remotePath, false))
|
||||
}, [remotePath, navigateRemote])
|
||||
|
||||
const sortedLocalFiles = useMemo(
|
||||
() => sortFiles(localFiles, sortField, sortOrder),
|
||||
[localFiles, sortField, sortOrder]
|
||||
)
|
||||
const sortedRemoteFiles = useMemo(
|
||||
() => sortFiles(remoteFiles, sortField, sortOrder),
|
||||
[remoteFiles, sortField, sortOrder]
|
||||
)
|
||||
|
||||
const refreshLocal = useCallback(() => {
|
||||
void loadLocalDirectory(localPath)
|
||||
}, [loadLocalDirectory, localPath])
|
||||
|
||||
const refreshRemote = useCallback(() => {
|
||||
if (!isConnected) return
|
||||
clearError()
|
||||
void loadRemoteDirectory(remotePath)
|
||||
}, [clearError, isConnected, loadRemoteDirectory, remotePath])
|
||||
|
||||
const focusLocalPane = useCallback(() => setActivePane('local'), [])
|
||||
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
|
||||
|
||||
const navigateRemoteClear = useCallback((p: string) => {
|
||||
clearError()
|
||||
navigateRemote(p)
|
||||
}, [clearError, navigateRemote])
|
||||
|
||||
const navigateRemoteBackClear = useCallback(() => {
|
||||
clearError()
|
||||
navigateRemoteBack()
|
||||
}, [clearError, navigateRemoteBack])
|
||||
|
||||
const navigateRemoteForwardClear = useCallback(() => {
|
||||
clearError()
|
||||
navigateRemoteForward()
|
||||
}, [clearError, navigateRemoteForward])
|
||||
|
||||
const navigateRemoteUpClear = useCallback(() => {
|
||||
clearError()
|
||||
navigateRemoteUp()
|
||||
}, [clearError, navigateRemoteUp])
|
||||
|
||||
// Handle file open
|
||||
const handleLocalOpen = useCallback((file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
@@ -333,6 +524,14 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
}
|
||||
}, [localFiles, localPath, localSelected, performUpload, t])
|
||||
|
||||
const railUpload = useCallback(() => {
|
||||
void handleUpload()
|
||||
}, [handleUpload])
|
||||
|
||||
const railDownload = useCallback(() => {
|
||||
void performDownload()
|
||||
}, [performDownload])
|
||||
|
||||
// Handle drag-and-drop upload on remote panel
|
||||
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
@@ -340,6 +539,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const path = (file as File & { path?: string }).path
|
||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||
})
|
||||
dndLog('dispatch-os-upload', {
|
||||
target: shortPath(targetPath),
|
||||
fileCount: files.length,
|
||||
withPath: sources.length,
|
||||
})
|
||||
if (sources.length === 0) {
|
||||
toast.warning(t('toast.noDragPath'))
|
||||
return
|
||||
@@ -347,83 +551,128 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
void performUpload(sources, targetPath)
|
||||
}, [isConnected, performUpload, toast, t])
|
||||
|
||||
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
if (entry.source === 'local') {
|
||||
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
|
||||
if (!isConnected || entries.length === 0) return
|
||||
const localEntries = entries.filter((entry) => entry.source === 'local')
|
||||
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
|
||||
dndLog('dispatch', {
|
||||
target: shortPath(targetPath),
|
||||
upload: localEntries.length,
|
||||
download: remoteEntries.length,
|
||||
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
|
||||
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
|
||||
via: 'pointer',
|
||||
})
|
||||
if (localEntries.length > 0) {
|
||||
void performUpload(
|
||||
[{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }],
|
||||
localEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
isDirectory: entry.type === 'directory',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
}
|
||||
if (remoteEntries.length > 0) {
|
||||
void performDownload(
|
||||
remoteEntries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
} else {
|
||||
void performDownload([{
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
}], targetPath)
|
||||
}
|
||||
}, [isConnected, performDownload, performUpload])
|
||||
|
||||
// Handle new folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
const handleDropFailed = useCallback(() => {
|
||||
dndLog('toast-drop-failed')
|
||||
toast.warning(t('toast.dropFailed'))
|
||||
}, [t, toast])
|
||||
|
||||
const handleNewLocalFolder = useCallback(async () => {
|
||||
const name = await prompt({
|
||||
title: t('dialog.newFolderTitle'),
|
||||
message: t('dialog.newFolderMessage'),
|
||||
placeholder: t('dialog.newFolderPlaceholder'),
|
||||
})
|
||||
if (name && window.opsxApi?.localMkdir) {
|
||||
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
loadLocalDirectory(localPath)
|
||||
})
|
||||
if (!name || !window.opsxApi?.localMkdir) return
|
||||
const fullPath = joinLocalPath(localPath, name.trim())
|
||||
try {
|
||||
await window.opsxApi.localMkdir(fullPath)
|
||||
await loadLocalDirectory(localPath)
|
||||
toast.success(t('toast.folderCreated', { name: name.trim() }))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('errors.mkdirFailed'))
|
||||
}
|
||||
}, [localPath, loadLocalDirectory, prompt, t])
|
||||
}, [localPath, loadLocalDirectory, prompt, t, toast])
|
||||
|
||||
const handleNewRemoteFolder = useCallback(async () => {
|
||||
const name = await prompt({
|
||||
title: t('dialog.newFolderTitle'),
|
||||
message: t('dialog.newFolderMessage'),
|
||||
placeholder: t('dialog.newFolderPlaceholder'),
|
||||
})
|
||||
if (!name) return
|
||||
const ok = await mkdirRemote(name.trim(), remotePath)
|
||||
if (ok) {
|
||||
toast.success(t('toast.folderCreated', { name: name.trim() }))
|
||||
} else {
|
||||
toast.error(t('errors.mkdirFailed'))
|
||||
}
|
||||
}, [mkdirRemote, prompt, remotePath, t, toast])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
<div className="relative flex h-full flex-col bg-dark-900">
|
||||
{/* Connection Status */}
|
||||
<ConnectionPanel
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
error={error}
|
||||
resumePath={!isConnected && hadConnectedRef.current ? remotePath : null}
|
||||
sessions={sessions}
|
||||
showSelector={showConnectionSelector}
|
||||
onConnect={(sessionId) => sessionId && connect(sessionId)}
|
||||
onConnect={(id) => id && connect(id)}
|
||||
onReconnect={() => {
|
||||
clearError()
|
||||
autoConnectAttempted.current = false
|
||||
if (sessionId) {
|
||||
autoConnectAttempted.current = true
|
||||
void connect(sessionId)
|
||||
return
|
||||
}
|
||||
setShowConnectionSelector(true)
|
||||
}}
|
||||
onShowSelector={() => setShowConnectionSelector(true)}
|
||||
onCloseSelector={() => setShowConnectionSelector(false)}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Toolbar
|
||||
isConnected={isConnected}
|
||||
onUpload={handleUpload}
|
||||
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
|
||||
onDownload={() => void performDownload()}
|
||||
onRefresh={() => {
|
||||
loadLocalDirectory(localPath)
|
||||
if (isConnected) loadRemoteDirectory(remotePath)
|
||||
}}
|
||||
onNewFolder={handleNewFolder}
|
||||
/>
|
||||
|
||||
{/* File Panels */}
|
||||
{!isConnected ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Unplug}
|
||||
title={t('panel.notConnectedTitle')}
|
||||
description={t('panel.notConnectedDesc')}
|
||||
description={
|
||||
hadConnectedRef.current && remotePath
|
||||
? t('panel.reconnectResumeDesc', { path: remotePath })
|
||||
: t('panel.notConnectedDesc')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
||||
<div className="flex-1 flex gap-2 p-4 min-h-0">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
title={t('panel.local')}
|
||||
isLocal={true}
|
||||
path={localPath}
|
||||
files={sortFiles(localFiles, sortField, sortOrder)}
|
||||
homePath={localHistory[0] || localPath}
|
||||
files={sortedLocalFiles}
|
||||
selected={localSelected}
|
||||
history={localHistory}
|
||||
historyIndex={localHistoryIndex}
|
||||
@@ -437,9 +686,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
onOpen={handleLocalOpen}
|
||||
onContextMenu={showLocalContextMenu}
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
onDropEntry={handleDropEntry}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={refreshLocal}
|
||||
onNewFolder={handleNewLocalFolder}
|
||||
onFocusPane={focusLocalPane}
|
||||
refreshing={localLoading}
|
||||
loading={localLoading && localFiles.length === 0}
|
||||
/>
|
||||
|
||||
<TransferRail
|
||||
canUpload={isConnected}
|
||||
canDownload={isConnected && remoteSelected.size > 0}
|
||||
onUpload={railUpload}
|
||||
onDownload={railDownload}
|
||||
/>
|
||||
|
||||
{/* Remote Panel */}
|
||||
@@ -447,25 +707,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
title={t('panel.remote')}
|
||||
isLocal={false}
|
||||
path={remotePath}
|
||||
files={sortFiles(remoteFiles, sortField, sortOrder)}
|
||||
homePath={remoteHomePath}
|
||||
files={sortedRemoteFiles}
|
||||
selected={remoteSelected}
|
||||
history={remoteHistory}
|
||||
historyIndex={remoteHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateRemote}
|
||||
onNavigateBack={navigateRemoteBack}
|
||||
onNavigateForward={navigateRemoteForward}
|
||||
onNavigateUp={navigateRemoteUp}
|
||||
onNavigate={navigateRemoteClear}
|
||||
onNavigateBack={navigateRemoteBackClear}
|
||||
onNavigateForward={navigateRemoteForwardClear}
|
||||
onNavigateUp={navigateRemoteUpClear}
|
||||
onSelect={toggleRemoteSelect}
|
||||
onOpen={handleRemoteOpen}
|
||||
onContextMenu={showRemoteContextMenu}
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||
onDropEntry={handleDropEntry}
|
||||
loading={isConnecting}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={isConnected ? refreshRemote : undefined}
|
||||
onNewFolder={handleNewRemoteFolder}
|
||||
onFocusPane={focusRemotePane}
|
||||
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
|
||||
refreshing={remoteLoading}
|
||||
listError={remoteListError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -475,7 +740,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
tasks={transferQueue}
|
||||
isOpen={isTransferPanelOpen}
|
||||
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
||||
onCancel={cancelTransfer}
|
||||
onRemove={removeTransfer}
|
||||
onRetry={retryTransfer}
|
||||
/>
|
||||
|
||||
{/* Global Context Menu */}
|
||||
@@ -488,6 +755,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
/>
|
||||
)}
|
||||
{PromptDialogComponent}
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
{/* File Properties Dialog */}
|
||||
{propertiesFile && (
|
||||
@@ -527,9 +795,6 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
onSaved={() => toast.success(t('editor.saved'))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,10 @@ interface SortHeaderProps {
|
||||
}
|
||||
|
||||
const FIELDS: { key: SortField; className: string }[] = [
|
||||
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
|
||||
{ key: 'modified', className: 'w-32 justify-end' },
|
||||
{ key: 'size', className: 'w-24 justify-end' },
|
||||
{ key: 'type', className: 'w-24 justify-end' },
|
||||
{ key: 'name', className: 'sftp-col-name flex-1 min-w-0 justify-start' },
|
||||
{ key: 'modified', className: 'sftp-col-modified w-28 flex-shrink-0 justify-end' },
|
||||
{ key: 'size', className: 'sftp-col-size w-16 flex-shrink-0 justify-end' },
|
||||
{ key: 'type', className: 'sftp-col-type w-16 flex-shrink-0 justify-end' },
|
||||
]
|
||||
|
||||
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user