From 2feae2fe3bca7e6ddbcc7d8b18e464f7372b5301 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 3 Oct 2026 15:44:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=96=E9=83=A8=E8=BF=9E=E6=8E=A5=EF=BC=9Asf?= =?UTF-8?q?tp://=20=E9=93=BE=E6=8E=A5=E6=89=93=E5=BC=80=20SFTP=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=EF=BC=8C=E5=85=BC=E5=AE=B9=20WinSCP=20=E7=9A=84?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 堡垒机像调 WinSCP 一样拉起 ShellRS(`ShellRS sftp://用户[:密码]@地址[:端口]`, 前面可以有 `/sessionname=名称`),开一个不保存的 SFTP 标签,左侧栏收起; 其余规则和 ssh:// 的外部连接一样。WinSCP 的其他开关忽略,链接里的路径暂不认。 补了 SFTP 不带密码、只凭账号(堡垒机令牌)登录的协议测试。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 14 ++--- docs/manual.md | 14 ++--- src/cli/link.rs | 90 +++++++++++++++++++++++++++++---- src/host/mod.rs | 2 +- src/host/ssh_link.rs | 65 ++++++++++++++++++++---- src/host/store.rs | 15 +++--- src/sftp/protocol_tests.rs | 81 +++++++++++++++++++++++++---- src/workspace/links.rs | 26 ++++++---- src/workspace/workspace_view.rs | 10 +++- tests/cli_bin.rs | 10 ++++ tests/ui/connection.rs | 57 +++++++++++++++++++++ 11 files changed, 323 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 10456b6..bdbab94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP **保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。 -主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接拉起 ShellRS,开一个不保存、只用终端的「外部连接」(见下面「外部连接不保存,只开终端」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 +主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标(`success` 色的浅色按钮,出错时 `warning` 色;原来是普通图标加小绿点,用户嫌不显眼),点开是更新对话框(不列更新内容,用户要的:页脚两个按钮「查看更新内容」打开官网的 `https://shellrs.com/changelog`(`build_info::CHANGELOG_PAGE`,用户要的:整页,不带版本锚点,也不用清单的 `notes_url`)和「重启并安装」,没有「稍后」,用关闭按钮或 Esc 关;正文是一句状态、当前版本和发布日期、重启会中断什么,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);只有一个容器且在跑的项目默认展开,多个容器的(用户要的,展开会把下面的挤下去,卡片上已写着「运行中/总数」)和都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(`invisible` 加卡片的 `group_hover`),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。 @@ -42,7 +42,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 -- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://用户[:密码]@地址[:端口]`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 +- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。 - **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 @@ -67,7 +67,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 - - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`,或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 + - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 - `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。 - `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。 - `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。 @@ -134,14 +134,14 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。 -- **外部连接不保存,只开终端。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去(用户定的:和 Xshell 的 `-url` 一样是临时的;只做 `ssh://`、不做 `sftp://`;只认命令行参数,不注册系统的 `ssh://` 链接、不改安装包)。 +- **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。 - `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(外部连接)」(做临时连接时要让 `HostInfo` 分得出两种);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。 - - **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。 + - **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。 - 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。 - 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。 - - **打开时终端占满窗口**(用户要的「终端默认全屏」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。 + - **打开时标签占满窗口**(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。 - **命令片段照常全有**:片段面板列出库里的全部片段,不看是哪台主机(`store.snippets()`),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。 - - 链接解析失败(不是 `ssh://`、缺用户名 / 地址、端口不对)弹「无法打开 SSH 链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。 + - 链接解析失败(不是 `ssh://` / `sftp://`、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。 - `tests/cli_bin.rs` 的 `shellrs_opened_with_a_link_hands_it_to_the_running_app` 用真的 `shellrs` 二进制交接链接(含中文 `-newtab`,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。 - **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。 - **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。 diff --git a/docs/manual.md b/docs/manual.md index f2bcc70..aa8ef51 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -126,19 +126,21 @@ shellrs download <远程路径> <本地路径> ## 从堡垒机打开(外部连接) -JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS:把 ShellRS 设成 SSH 客户端,参数照 Xshell 的写法填。ShellRS 收到后打开一个终端标签连过去: +JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS 打开终端,也可以像调用 WinSCP 一样调用它打开 SFTP:把 ShellRS 设成 SSH 客户端或 SFTP 客户端,参数照 Xshell 或 WinSCP 的写法填。ShellRS 收到 `ssh://` 链接就打开一个终端标签连过去,收到 `sftp://` 链接就打开一个 SFTP 标签: ```sh ShellRS ssh://用户[:密码]@地址[:端口] ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名 +ShellRS sftp://用户[:密码]@地址[:端口] +ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] ``` -- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。 -- **终端占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。 -- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。 -- **只用终端这一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话,所以外部连接不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。 +- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),以及 WinSCP 的 `/sessionname=标签名`(也可以写成 `-sessionname=`),不分大小写;`/newinstance`、`/ini=nul` 等别的选项忽略。只支持 `ssh://` 和 `sftp://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。`sftp://` 链接里的路径(WinSCP 用它指定打开哪个目录)目前不认,远程一侧照常打开主目录。 +- **标签占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。 +- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。 +- **只用一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话。SFTP 标签本来就只开一个;终端则不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。 - 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。 -- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。 +- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://` 或 `sftp://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。 - 堡垒机要调用的程序: - Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。 - macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。 diff --git a/src/cli/link.rs b/src/cli/link.rs index c0c7949..037b4f0 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -1,5 +1,7 @@ //! The links ShellRS is opened with, the way bastion hosts open Xshell: -//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`. +//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`; +//! and WinSCP: `ShellRS sftp://user@host:port`, or with +//! `/sessionname=名称` in front. //! //! Only told apart from the `shellrs` command's arguments here; the link //! itself is read by the app (`host::SshLink`), which says what is wrong @@ -17,7 +19,7 @@ pub struct OpenLink { /// May hold a password. Empty when the arguments named no link, so the /// app can say so: a second ShellRS on Windows has no console to. pub url: String, - /// What to call the tab: Xshell's `-newtab`. + /// What to call the tab: Xshell's `-newtab`, WinSCP's `/sessionname`. #[serde(default, skip_serializing_if = "Option::is_none")] pub tab: Option, } @@ -54,17 +56,35 @@ const URL_OPTION: &str = "-url"; const TAB_OPTION: &str = "-newtab"; const WINDOW_OPTION: &str = "-newwin"; +/// WinSCP's switches, written `/name[=value]` or `-name[=value]`: +/// `/sessionname=名称` names the tab, the others mean nothing to ShellRS +/// (`/newinstance` asks for a window of its own, `/ini=nul` not to read +/// WinSCP's settings, `/privatekey=` and `/hostkey=` are ShellRS's to +/// know). +const WINSCP_SWITCHES: &[&str] = &[ + SESSION_NAME, + "newinstance", + "privatekey", + "hostkey", + "passphrase", + "rawsettings", + "ini", + "log", + "timeout", +]; +const SESSION_NAME: &str = "sessionname"; + /// The link `args` (without the program) open, or `None` when they are the /// `shellrs` command's. Only the first argument decides, so `shellrs exec /// web "curl http://x"` stays a command: a link launch starts with one of -/// Xshell's options or with the link itself. +/// Xshell's options, one of WinSCP's switches or the link itself. pub fn link_arguments(args: &[OsString]) -> Option { let args: Vec = args .iter() .map(|arg| arg.to_string_lossy().into_owned()) .collect(); let first = args.first()?; - if !is_option(first) && !looks_like_link(first) { + if !is_option(first) && winscp_switch(first).is_none() && !looks_like_link(first) { return None; } let mut given = None; @@ -82,11 +102,11 @@ pub fn link_arguments(args: &[OsString]) -> Option { .peek() .is_some_and(|next| !next.starts_with('-') && !looks_like_link(next)) { - let name = args.next().unwrap_or_default(); - let name = name.trim(); - if !name.is_empty() { - tab = Some(name.to_string()); - } + tab = tab_name(&args.next().unwrap_or_default()).or(tab); + } + } else if let Some((switch, value)) = winscp_switch(&arg) { + if switch == SESSION_NAME { + tab = value.and_then(tab_name).or(tab); } } else if option == WINDOW_OPTION || arg.starts_with('-') { // Ignored, like any option ShellRS does not know. @@ -104,6 +124,26 @@ fn is_option(arg: &str) -> bool { [URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str()) } +/// The name and value of one of WinSCP's switches, the name in lower case. +fn winscp_switch(arg: &str) -> Option<(String, Option<&str>)> { + let switch = arg.strip_prefix(['/', '-'])?; + let (name, value) = match switch.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (switch, None), + }; + let name = name.to_lowercase(); + WINSCP_SWITCHES + .contains(&name.as_str()) + .then_some((name, value)) +} + +/// A tab's name as given, without the spaces or quotes around it; `None` +/// when that leaves nothing. +fn tab_name(given: &str) -> Option { + let name = given.trim().trim_matches('"').trim(); + (!name.is_empty()).then(|| name.to_string()) +} + /// `scheme://…`, whatever the scheme: the app says which ones it opens. fn looks_like_link(arg: &str) -> bool { let Some((scheme, _)) = arg.split_once("://") else { @@ -162,6 +202,36 @@ mod tests { assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None)); } + #[test] + fn winscps_switches_are_understood_too() { + // How JumpServer opens WinSCP: the link alone. + let url = "sftp://b478e26f:secret@172.16.0.28:12024"; + assert_eq!(link(&[url]), open(url, None)); + assert_eq!( + link(&["/sessionname=生产库", url]), + open(url, Some("生产库")) + ); + assert_eq!( + link(&[ + "/newinstance", + "/ini=nul", + "-SessionName=\"文件 服务器\"", + url + ]), + open(url, Some("文件 服务器")) + ); + assert_eq!( + link(&[ + "/rawsettings", + "Compression=1", + url, + "/hostkey=ssh-ed25519 255 x" + ]), + open(url, None) + ); + assert_eq!(link(&["/sessionname=生产库"]), open("", Some("生产库"))); + } + #[test] fn options_without_a_link_still_open_so_the_app_can_say_why() { assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库"))); @@ -180,6 +250,8 @@ mod tests { &["--version"], &["ssh-not-a-link"], &["://nothing-before"], + &["/tmp/sessionname"], + &["/usr/bin/sftp://x"], ] { assert_eq!(link(args), None, "{args:?}"); } diff --git a/src/host/mod.rs b/src/host/mod.rs index 2e40bae..6615b1f 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -28,5 +28,5 @@ pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_qu pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key}; pub use secret_fields::SecretFields; pub use snippet::*; -pub use ssh_link::SshLink; +pub use ssh_link::{LinkKind, SshLink}; pub use store::{HostStore, HostStoreEvent}; diff --git a/src/host/ssh_link.rs b/src/host/ssh_link.rs index 094fdec..478f09f 100644 --- a/src/host/ssh_link.rs +++ b/src/host/ssh_link.rs @@ -1,6 +1,7 @@ //! `ssh://user[:password]@host[:port]`: how a bastion host opens Xshell, and -//! so ShellRS, on a host the user has not saved. ShellRS connects to it as an -//! 外部连接: not saved, gone with its tabs, the terminal alone. +//! so ShellRS, on a host the user has not saved; `sftp://…` the same way is +//! how it opens WinSCP. ShellRS connects to it as an 外部连接: not saved, +//! gone with its tabs, a terminal or an SFTP tab alone. use std::fmt; @@ -13,8 +14,18 @@ use super::{AuthKind, HostDraft}; /// The port when the link names none. const SSH_PORT: u16 = 22; +/// What a link opens, by its scheme. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkKind { + /// `ssh://`, as for Xshell: a terminal. + Ssh, + /// `sftp://`, as for WinSCP: an SFTP tab. + Sftp, +} + /// What a link says to connect to. pub struct SshLink { + pub kind: LinkKind, /// The tab's name: what the bastion host called it (Xshell's /// `-newtab`), or else the address. pub name: String, @@ -32,9 +43,15 @@ impl SshLink { pub fn parse(link: &str, tab: Option<&str>) -> Result { let link = link.trim(); if link.is_empty() { - return Err("启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口]。".into()); + return Err( + "启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。" + .into(), + ); } - let malformed = || "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口]。".to_string(); + let malformed = || { + "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。" + .to_string() + }; // Without `://`, `user:password@host` would read as the scheme // `user`, and the error would show it. if !link.contains("://") { @@ -45,9 +62,15 @@ impl SshLink { ParseError::EmptyHost => "链接里没有地址。".to_string(), _ => malformed(), })?; - if url.scheme() != "ssh" { - return Err(format!("只支持 ssh:// 链接,不支持 {}://。", url.scheme())); - } + let kind = match url.scheme() { + "ssh" => LinkKind::Ssh, + "sftp" => LinkKind::Sftp, + scheme => { + return Err(format!( + "只支持 ssh:// 和 sftp:// 链接,不支持 {scheme}://。" + )); + } + }; // `ssh` is not one of the schemes the URL standard knows, so an IPv4 // address and a domain both come back as an opaque domain, which is // percent-encoded when it is not ASCII. @@ -67,7 +90,10 @@ impl SshLink { }; let user = decode(url.username())?; if user.is_empty() { - return Err("链接里没有用户名,应为 ssh://用户@地址[:端口]。".into()); + return Err(format!( + "链接里没有用户名,应为 {}://用户@地址[:端口]。", + url.scheme() + )); } let password = match url.password() { Some(password) => Some(Zeroizing::new(decode(password)?)), @@ -79,6 +105,7 @@ impl SshLink { .filter(|tab| !tab.is_empty()) .map_or_else(|| address.clone(), str::to_string); Ok(Self { + kind, name, address, port, @@ -104,6 +131,7 @@ impl SshLink { impl fmt::Debug for SshLink { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SshLink") + .field("kind", &self.kind) .field("name", &self.name) .field("address", &self.address) .field("port", &self.port) @@ -137,6 +165,7 @@ mod tests { assert_eq!(link.port, 12024); assert!(link.password.is_none()); assert_eq!(link.name, "172.16.0.28"); + assert_eq!(link.kind, LinkKind::Ssh); let draft = link.draft(); assert_eq!(draft.address.as_ref(), "172.16.0.28"); @@ -170,6 +199,22 @@ mod tests { ); } + #[test] + fn an_sftp_link_is_read_alike_and_opens_sftp() { + // How JumpServer opens WinSCP. + let link = parse("sftp://b478e26f:p%40ss@172.16.0.28:12024"); + assert_eq!(link.kind, LinkKind::Sftp); + assert_eq!(link.user, "b478e26f"); + assert_eq!(link.address, "172.16.0.28"); + assert_eq!(link.port, 12024); + assert_eq!(link.password.as_deref().map(String::as_str), Some("p@ss")); + assert_eq!(link.draft().auth, AuthKind::Password); + // The port is SSH's when not given, and a path is passed over. + let link = parse("SFTP://root@files.example/srv/data/"); + assert_eq!(link.kind, LinkKind::Sftp); + assert_eq!((link.address.as_str(), link.port), ("files.example", 22)); + } + #[test] fn the_user_ends_at_the_last_at_sign() { // How JumpServer names the account and the asset in one user name. @@ -197,8 +242,10 @@ mod tests { for (link, expected) in [ ("", "没有要连接的地址"), (" ", "没有要连接的地址"), - ("sftp://root:secret@h", "只支持 ssh://"), + ("scp://root:secret@h", "只支持 ssh:// 和 sftp://"), + ("ftp://root:secret@h", "只支持 ssh:// 和 sftp://"), ("ssh://h:22", "没有用户名"), + ("sftp://h:22", "应为 sftp://用户@地址"), ("ssh://:secret@h", "没有用户名"), ("ssh://root:secret@", "没有地址"), ("ssh://root:secret@h:0", "端口无效"), diff --git a/src/host/store.rs b/src/host/store.rs index e1c2463..032c601 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -47,8 +47,9 @@ mod snippets; /// the saved ones: found by id like any host, so its tabs work as a saved /// host's do, but never listed, never written to the database and gone with /// its last tab; its password stays in `secrets`' memory. The only kind so -/// far is the external one, which a bastion host opened with an `ssh://` -/// link (外部连接, see [`SshLink`]) and which gets the terminal alone. A +/// far is the external one, which a bastion host opened with an `ssh://` or +/// `sftp://` link (外部连接, see [`SshLink`]) and whose terminals get one +/// channel alone. A /// 临时连接 typed into ShellRS, to come, will be temporary with every /// feature. pub struct HostStore { @@ -312,15 +313,15 @@ impl HostStore { } /// Whether `id` is a temporary host a link from outside opened (外部连接), - /// which gets the terminal alone. + /// whose terminals get their own channel alone. pub fn is_external(&self, id: HostId) -> bool { self.external.contains(&id) } - /// Connect to what an `ssh://` link from outside names without saving - /// it (外部连接): a temporary host the tabs find by id, kept in memory - /// until [`Self::remove_temporary`], with the link's password in memory - /// too. + /// Connect to what an `ssh://` or `sftp://` link from outside names + /// without saving it (外部连接): a temporary host the tabs find by id, + /// kept in memory until [`Self::remove_temporary`], with the link's + /// password in memory too. pub fn insert_external(&mut self, link: SshLink, cx: &mut Context) -> HostId { let id = self.insert_external_unnotified(link); cx.notify(); diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs index 03bd743..fb8219c 100644 --- a/src/sftp/protocol_tests.rs +++ b/src/sftp/protocol_tests.rs @@ -2,8 +2,8 @@ use super::*; use crate::{ connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, Latency}, - host::{AuthKind, Host, HostDraft, HostId, HostLogin}, - secrets::InMemorySecretStore, + host::{AuthKind, Host, HostDraft, HostId, HostLogin, HostStore, SshLink}, + secrets::{InMemorySecretStore, SharedSecretStore}, ssh::SshConnector, }; use russh::{ @@ -61,8 +61,19 @@ impl Drop for Handler { self.ended.fetch_add(1, Ordering::SeqCst); } } +/// Who the server lets in without asking anything: a bastion host's token, +/// which is the user name alone. +const TOKEN_USER: &str = "b478e26f-811b-4a90-81c3-74929127898a"; + impl server::Handler for Handler { type Error = anyhow::Error; + async fn auth_none(&mut self, user: &str) -> Result { + Ok(if user == TOKEN_USER { + server::Auth::Accept + } else { + server::Auth::reject() + }) + } async fn auth_password( &mut self, user: &str, @@ -244,14 +255,6 @@ impl Drop for Worker { } } fn worker(port: u16, data: &std::path::Path) -> Worker { - let provider = SshSftpTransportProvider::new( - SshConnector::new( - data.join("known_hosts"), - Arc::new(InMemorySecretStore::default()), - ), - data.join("upload-resume"), - data.join("download-resume"), - ); let host = Host::new( HostId(1), HostDraft::new( @@ -263,7 +266,19 @@ fn worker(port: u16, data: &std::path::Path) -> Worker { None, ), ); - let transport = provider.create(&HostLogin::of(&host, None)); + worker_for( + &HostLogin::of(&host, None), + Arc::new(InMemorySecretStore::default()), + data, + ) +} +fn worker_for(login: &HostLogin, secrets: SharedSecretStore, data: &std::path::Path) -> Worker { + let provider = SshSftpTransportProvider::new( + SshConnector::new(data.join("known_hosts"), secrets), + data.join("upload-resume"), + data.join("download-resume"), + ); + let transport = provider.create(login); let (commands, rx) = async_channel::unbounded(); let (tx, events) = async_channel::unbounded(); let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap()); @@ -438,6 +453,50 @@ fn a_connection_reports_its_round_trip_right_after_connecting() { }); } +/// A bastion host lets WinSCP, and so ShellRS, in on its token alone: an +/// `sftp://token@…` link without a password connects without asking for +/// one, as Xshell's `ssh://` does. +#[test] +fn a_link_without_a_password_connects_where_the_server_asks_for_none() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let Some(server) = server(temp.path().into(), 0, false).await else { + return; + }; + let mut store = HostStore::empty(); + let link = format!("sftp://{TOKEN_USER}@127.0.0.1:{}", server.port); + let host = store.insert_external_unnotified(SshLink::parse(&link, None).unwrap()); + let worker = worker_for(&store.login(host).unwrap(), store.secrets(), temp.path()); + loop { + let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv()) + .await + .expect("worker timed out") + .unwrap(); + match event { + SftpEvent::Prompt(prompt) => { + assert!( + matches!(prompt.kind(), ConnectionPromptKind::UnknownHost(_)), + "asked for more than trusting the host key" + ); + worker + .commands + .send(SftpCommand::PromptReply { + request_id: prompt.request_id(), + reply: ConnectionPromptReply::TrustAndSave, + }) + .await + .unwrap(); + } + SftpEvent::Connected { .. } => break, + SftpEvent::Disconnected(error) => panic!("connect failed: {error}"), + _ => {} + } + } + assert_eq!(server.connections.load(Ordering::SeqCst), 1); + assert_eq!(server.shells.load(Ordering::SeqCst), 0); + }); +} + #[test] fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() { runtime().block_on(async { diff --git a/src/workspace/links.rs b/src/workspace/links.rs index ed877aa..45841b9 100644 --- a/src/workspace/links.rs +++ b/src/workspace/links.rs @@ -1,36 +1,42 @@ //! The workspace's share of the links ShellRS is opened with (外部连接): //! a bastion host runs `ShellRS ssh://user@host:port`, the way it runs -//! Xshell, and ShellRS connects without saving the host. The host goes with -//! its last tab. +//! Xshell, or `ShellRS sftp://…`, the way it runs WinSCP, and ShellRS +//! connects without saving the host. The host goes with its last tab. use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Notification}; use gpui_kit::*; use crate::cli::OpenLink; -use crate::host::{HostId, SshLink}; +use crate::host::{HostId, LinkKind, SshLink}; use super::workspace_view::Workspace; impl Workspace { - /// Connect to what `link` names in a terminal tab of its own, across - /// the window, or say what is wrong with it. + /// Connect to what `link` names in a tab of its own across the window, + /// a terminal or an SFTP tab by the link's scheme, or say what is wrong + /// with it. pub fn open_link(&mut self, link: OpenLink, window: &mut Window, cx: &mut Context) { match SshLink::parse(&link.url, link.tab.as_deref()) { Ok(link) => { + let kind = link.kind; let host = self .store .update(cx, |store, cx| store.insert_external(link, cx)); - self.connect_host(host, window, cx); - // The terminal is what the bastion host opened ShellRS for: - // the hosts make way, as with ⌘B, which brings them back. + match kind { + LinkKind::Ssh => self.connect_host(host, window, cx), + LinkKind::Sftp => self.open_explorer(host, window, cx), + } + // The tab is what the bastion host opened ShellRS for: the + // hosts make way, as with ⌘B, which brings them back. if self.dock_area.read(cx).is_dock_open(DockPlacement::Left) { self.dock_area.update(cx, |area, cx| { area.toggle_dock(DockPlacement::Left, window, cx); }); } } - Err(problem) => window - .push_notification(Notification::error(problem).title("无法打开 SSH 链接"), cx), + Err(problem) => { + window.push_notification(Notification::error(problem).title("无法打开链接"), cx) + } } } diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index ea83c71..98d23cc 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -1039,7 +1039,15 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let host_id = action.0; + self.open_explorer(action.0, window, cx); + } + + pub(super) fn open_explorer( + &mut self, + host_id: HostId, + window: &mut Window, + cx: &mut Context, + ) { if self.store.read(cx).host(host_id).is_none() { return; } diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs index dde3363..01f304e 100644 --- a/tests/cli_bin.rs +++ b/tests/cli_bin.rs @@ -194,6 +194,12 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() { Some(0) ); assert_eq!(open_shellrs(data_dir.path(), &[token]), Some(0)); + // As for WinSCP. + let winscp = "sftp://deploy:p%40ss@10.0.0.9:2222"; + assert_eq!( + open_shellrs(data_dir.path(), &["/sessionname=文件", winscp]), + Some(0) + ); assert_eq!( server.take_activation(), Some(vec![ @@ -205,6 +211,10 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() { url: token.into(), tab: None, }, + OpenLink { + url: winscp.into(), + tab: Some("文件".into()), + }, ]) ); } diff --git a/tests/ui/connection.rs b/tests/ui/connection.rs index 6a2d46a..246f0d8 100644 --- a/tests/ui/connection.rs +++ b/tests/ui/connection.rs @@ -711,6 +711,63 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty()); } +#[gpui_kit::test] +async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) { + use shellrs::app::CloseExplorer; + + let remote = Arc::new(RecordingRemoteProvider::default()); + let (handle, workspace) = open_workspace_with_credentials( + cx, + HostStore::empty(), + remote.clone(), + Arc::new(FakeConnectionTester::default()), + ); + // How JumpServer opens WinSCP: the link alone. + open_link( + cx, + handle, + &workspace, + "sftp://token:p%40ss@10.0.0.9:2222", + None, + ); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 10.0.0.9") + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find(("explorer-tab", SFTP_TAB)).label(), + Some("10.0.0.9 · SFTP") + ); + // An SFTP tab alone, across the window. + assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none()); + assert_eq!(window.find("show-hosts").checked(), Some(false)); + assert!(window.try_find(("host-row", LINK_HOST.0)).is_none()); + }) + .unwrap(); + assert!(remote.logins().is_empty()); + let store = workspace.read_with(cx, |workspace, _| workspace.store().clone()); + store.read_with(cx, |store, _| { + assert!(store.is_external(LINK_HOST)); + let login = store.login(LINK_HOST).unwrap(); + assert_eq!( + (login.host.as_str(), login.port, login.user.as_str()), + ("10.0.0.9", 2222, "token") + ); + assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0)); + }); + + // It goes with its tab. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx); + }) + .unwrap(); + cx.run_until_parked(); + store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none())); +} + #[gpui_kit::test] async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace_with_credentials(