Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d36c8210 | ||
|
|
c0bc970de8 | ||
|
|
e7fcc7ca40 | ||
|
|
269fab6550 | ||
|
|
3c0871eb4e | ||
|
|
87c5cfb120 | ||
|
|
ba63b3d73f | ||
|
|
de0fafe6ee | ||
|
|
7886cb7138 |
No files matched your search
+22
-1
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和 ShellRS 的更新对话框里,用 Markdown 书写,面向使用者。
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
@@ -17,6 +17,27 @@ ShellRS 的第一个公开版本。
|
||||
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
|
||||
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
|
||||
|
||||
## [0.1.0-beta.7]
|
||||
|
||||
### 修复
|
||||
|
||||
- 连不上部分堡垒机:Apache SSHD 等不支持 curve25519、只能提供 2048 位 DH 组的 SSH 服务器,以前握手时会被断开。现在密钥交换和 OpenSSH 的默认一致,支持 ECDH(NIST 曲线),DH 组交换也接受 2048 位的组;OpenSSH 默认能连上的服务器,ShellRS 都能连上。
|
||||
- 连接失败时写明具体原因,比如连接被拒绝、连接超时,或者和服务器没有共同的算法(列出服务器支持哪些),不再只有一句「无法建立 SSH 连接,请检查地址、端口和主机密钥」。
|
||||
- Docker:同一个镜像打了多个标签时,每个标签单独一行,查看详情和删除都对应这一行的标签,删除只去掉这个标签。只有仓库名、没有标签的镜像显示为「仓库:<none>」,不再被当成「仓库:latest」。带仓库地址的镜像(如 `registry.example.com/app:1.0`)现在也能删除和查看详情。
|
||||
|
||||
## [0.1.0-beta.6]
|
||||
|
||||
### 新增
|
||||
|
||||
- 从堡垒机打开:JumpServer 等堡垒机可以像调用 Xshell 一样调用 ShellRS(`ShellRS ssh://用户@地址:端口`,或 `-url ssh://… -newtab 名称`),打开一个终端标签连过去。这是临时连接:不保存到主机列表,标签都关掉后就没了;链接里的密码只留在内存里。ShellRS 已经在运行时,链接交给它打开,不会再起一个。堡垒机里要填的程序:Windows 是安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`),macOS 是 `/Applications/ShellRS.app/Contents/MacOS/shellrs`。
|
||||
- 外部 CLI:`shellrs list` 也列出标签开着的临时连接(标为临时),Agent 可以用它执行命令、传输文件。
|
||||
- 外部 CLI:Agent Skills 可以安装到 WorkBuddy。
|
||||
|
||||
### 改进
|
||||
|
||||
- 新版本下载好后,标题栏右上角的更新按钮改成绿色,更醒目。更新对话框不再列出更新说明,改为「查看更新内容」打开官网的更新日志,旁边是「重启并安装」。
|
||||
- Docker:有多个容器的 compose 项目默认折叠,只有一个容器且在运行的项目默认展开。
|
||||
|
||||
## [0.1.0-beta.5]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -8,9 +8,9 @@ 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 和测试连接都照它走。功能的详细说明见 `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://` 链接拉起 ShellRS,开一个不保存的「临时连接」(见下面「临时连接不保存」)。功能的详细说明见 `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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
||||
已确认的产品决定: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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
|
||||
|
||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||
|
||||
@@ -42,11 +42,11 @@ 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<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), 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:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
|
||||
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), 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:<id>` / `s:<id>`;`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()` 给临时主机用,见下面「临时连接不保存」)。
|
||||
- `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` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `<data_dir>/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<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「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 校验写 `<data_dir>/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`,服务器回成功或失败都算Line truncated
|
||||
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
|
||||
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TransientSecretStore`(`transient.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Transient { host }`(临时连接链接里的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
|
||||
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
|
||||
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
|
||||
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
|
||||
@@ -64,10 +64,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
|
||||
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
|
||||
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
|
||||
- `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` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`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` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
|
||||
- `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_link_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`,写 `<data_dir>/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_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。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`,或形如 `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 上是 `<data_dir>/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`、客户端的打开与属主校验。
|
||||
@@ -118,7 +118,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`、`SnippetPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它)。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
|
||||
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,几个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
|
||||
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
|
||||
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-`),加双引号。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
|
||||
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-/`,`/` 是给带仓库地址的镜像名的),加双引号。`docker images` 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 `Image::key` 认:有标签的是 `仓库:标签`,没有标签的是镜像 ID(只有仓库名的 `nginx` 会被 Docker 当成 `nginx:latest`,显示成 `nginx:<none>`,也只按 ID 算在用);卡片 id、详情(`image inspect`)和删除(`rmi`,有别的标签时只去掉这一个)都用它,`DockerTable::new` 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
|
||||
- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;`replace` 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。
|
||||
- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。
|
||||
- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL <pid> 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc/<pid>/cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。
|
||||
@@ -130,10 +130,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
|
||||
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
|
||||
- **表结构(`SCHEMA_VERSION` 是 14)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 `snippet_categories` 和 `snippets`(`category_id` 级联;`scope_group_id` / `scope_host_id` 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 `groups` / `hosts` 级联,内存里 `remove_unnotified` / `remove_group_unnotified` 照样丢掉;最后一列 `run_on_click` 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),`STEPS` 里是 13 → 14 这一步(`Step::Sql`,步骤是历史,`SCHEMA` 以后再改它也不跟着改),`a_version_13_file_gets_the_snippets_and_keeps_its_hosts` 用冻结的 13 版 schema 守着。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
|
||||
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
|
||||
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path(), link)`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate { open }`(带着启动时的链接)后直接退出。不带链接时它序列化成和以前一字不差的 `{"type":"activate"}`(`skip_serializing_if`),旧版收到带 `open` 的也只当普通的 Activate(serde 忽略多余字段),所以**不升** `PROTOCOL_VERSION`:升了会让 PATH 里已装的 `shellrs-cli` 全部报版本不符。
|
||||
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex<Option<Vec<OpenLink>>>`);请求线程够不着窗口,工作区每 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://` 链接、不改安装包)。
|
||||
- `HostStore` 把它放在单独的 `transient` 里:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `transient_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(临时连接)」;它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
|
||||
- 登录用「密码」方式,`login_of` 把密码引用换成 `SecretRef::Transient { host }`:链接里的密码只在 `TransientSecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
|
||||
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_link_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_transient`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
|
||||
- 链接解析失败(不是 `ssh://`、缺用户名 / 地址、端口不对)弹「无法打开 SSH 链接」通知,文字不带链接原文(可能有密码)。第二个进程在 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` 时在数据目录下)。
|
||||
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存,再调用部署挂钩(`SITE_DEPLOY_HOOK`)让官网 shellrs.com 重新构建。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `docs/release.md`。
|
||||
@@ -160,7 +166,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
|
||||
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
|
||||
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
|
||||
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。唯一不进钥匙串的是临时连接链接里的密码(`SecretRef::Transient`):只在 `TransientSecretStore` 的内存里,随临时主机一起忘掉。
|
||||
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
|
||||
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
|
||||
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
|
||||
@@ -194,4 +200,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
|
||||
## 测试约定
|
||||
|
||||
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltiLine truncated
|
||||
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的临时连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
|
||||
Generated
+3
-1
@@ -7686,7 +7686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.5"
|
||||
version = "0.1.0-beta.7"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -7705,6 +7705,7 @@ dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"rand 0.9.5",
|
||||
"reqwest",
|
||||
@@ -7720,6 +7721,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"trash",
|
||||
"unicode-width",
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-registry 0.6.1",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.5"
|
||||
version = "0.1.0-beta.7"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
@@ -44,6 +44,9 @@ rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] }
|
||||
unicode-width = "0.2"
|
||||
# `ssh://user:password@host:port` links, as bastion hosts open Xshell with.
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
zeroize = "1"
|
||||
keyring = "4"
|
||||
trash = "5"
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
|
||||
**A CLI for AI agents**
|
||||
|
||||
- `shellrs list` / `exec` / `upload` / `download`. The running ShellRS logs in on the command's behalf, so the command itself never handles a password or private key.
|
||||
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex or OpenCode from the settings page.
|
||||
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page.
|
||||
|
||||
**Security and updates**
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
**给 AI Agent 用的 CLI**
|
||||
|
||||
- `shellrs list` / `exec` / `upload` / `download`,由正在运行的 ShellRS 代为登录,命令本身不接触密码和私钥。
|
||||
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode 安装 Agent Skill。
|
||||
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
|
||||
|
||||
**安全与更新**
|
||||
|
||||
|
||||
+24
-4
@@ -107,7 +107,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- Linux:链接到 `~/.local/bin/shellrs`。
|
||||
- macOS、Linux 上「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
|
||||
- Windows:图形程序没有控制台,命令是单独的控制台程序 `shellrs-cli.exe`(与 `shellrs.exe` 放在一起)。「安装」把它复制成 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`,并把这个文件夹加入当前用户的 PATH(不需要管理员),重新打开的终端和 Agent 才能找到它。ShellRS 升级后启动时会自动更新这份副本;更新不了时显示「更新」。副本正被使用时也能更新。「移除」删掉副本并把文件夹移出 PATH。
|
||||
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
|
||||
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode、WorkBuddy 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
|
||||
|
||||
命令(`shellrs --help` 查看完整说明):
|
||||
|
||||
@@ -118,12 +118,32 @@ shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
```
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接(`"temporary": true`),见「从堡垒机打开(临时连接)」。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
## 从堡垒机打开(临时连接)
|
||||
|
||||
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS:把 ShellRS 设成 SSH 客户端,参数照 Xshell 的写法填。ShellRS 收到后打开一个终端标签连过去:
|
||||
|
||||
```sh
|
||||
ShellRS ssh://用户[:密码]@地址[:端口]
|
||||
ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
|
||||
```
|
||||
|
||||
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。
|
||||
- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接、右侧栏的工具)和保存的主机一样。
|
||||
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
|
||||
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,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 已在运行时,系统会把参数丢掉。
|
||||
- Linux:AppImage 本身。
|
||||
- 外部 CLI 的 `shellrs list` 也列出临时连接(JSON 里 `"temporary": true`,表格的分组一栏写「(临时连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的临时连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
|
||||
远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时和 SFTP 标签已连接时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。SFTP 传输进行中也照常测量,读数会随传输占满带宽而升高。批量关闭时,正在上传的 SFTP 标签仍会先确认。
|
||||
@@ -147,7 +167,7 @@ shellrs download <ID> <远程路径> <本地路径>
|
||||
|
||||
bash 在 shell 退出时才把这次执行的命令写进历史文件,所以当前终端里刚执行的命令要等它退出后才会出现。列表在打开历史命令或切换终端时读取,之后点刷新按钮才重新读取;历史文件很长时只读最后 512 KB。
|
||||
|
||||
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,有容器在运行的项目默认展开;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
|
||||
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,只有一个容器、而且它在运行的项目默认展开,有多个容器的项目默认折叠;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
|
||||
|
||||
停止、重启和删除都会先确认,启动直接执行,结果以通知显示,之后列表重新读取。不是以 root 登录、也不在 docker 组里时,通过免密码的 sudo 执行。列表在打开 Docker 或切换终端时读取,之后点刷新按钮才重新读取。主机没有安装 Docker 或 Docker 没有运行时会写明。
|
||||
|
||||
@@ -316,7 +336,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 |
|
||||
| Linux x64 | AppImage | 支持 |
|
||||
|
||||
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启并安装」就换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
|
||||
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现绿色的更新按钮。点开后,「查看更新内容」打开官网的更新日志,「重启并安装」换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
|
||||
|
||||
「设置 › 关于」里的「应用更新」只有三行:
|
||||
|
||||
|
||||
+4
-1
@@ -121,12 +121,15 @@ gpui_kit::actions!(
|
||||
CheckForUpdates,
|
||||
/// Download the newer ShellRS that was found.
|
||||
DownloadUpdate,
|
||||
/// Show the newer ShellRS: what changed, and restarting into it.
|
||||
/// Show the newer ShellRS: restarting into it, and where to read
|
||||
/// what changed.
|
||||
ShowUpdate,
|
||||
/// Restart into the downloaded ShellRS.
|
||||
RestartToUpdate,
|
||||
/// Open the page to download ShellRS by hand.
|
||||
OpenDownloadPage,
|
||||
/// Open the website's changelog.
|
||||
OpenChangelog,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ icon_assets!(
|
||||
Ban,
|
||||
CircleDot,
|
||||
FileText,
|
||||
Zap
|
||||
Zap,
|
||||
ExternalLink
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ The exit code is in `$LASTEXITCODE`.
|
||||
- `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
|
||||
- The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`.
|
||||
- Long-running tasks should use tmux, nohup, or systemd on the remote host.
|
||||
- Hosts with `"temporary": true` in `shellrs list` were opened in ShellRS from a bastion host's link rather than saved. Their id stays the same while their tab is open in ShellRS, so keep using it for every command; there is no need to list before each one. Once the tab is closed the id is gone (`host_not_found`), and opening the host from the bastion again gives it a new id: list again only then. Every command logs in to them again, which a bastion host may refuse if it gave out a one-time login; report that to the user instead of retrying.
|
||||
- `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
|
||||
- Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist.
|
||||
|
||||
|
||||
+13
-8
@@ -10,6 +10,7 @@ use std::{
|
||||
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
use super::link::OpenLink;
|
||||
use super::protocol::{
|
||||
Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters,
|
||||
TransferSummary, parse_json, read_frame, write_json,
|
||||
@@ -61,14 +62,14 @@ pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
|
||||
/// forward. One too busy to answer is running all the same.
|
||||
const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ask the ShellRS listening at `endpoint` to bring its window forward;
|
||||
/// whether there is one. `false` means nothing is running there and the
|
||||
/// caller is free to start.
|
||||
/// Ask the ShellRS listening at `endpoint` to bring its window forward,
|
||||
/// and to open `open` there; whether there is one. `false` means nothing is
|
||||
/// running there and the caller is free to start.
|
||||
///
|
||||
/// Whatever takes the connection counts as running, whether or not it
|
||||
/// understands the request: an older ShellRS answers with an error, and a
|
||||
/// second copy on the same data would be worse than a window left behind.
|
||||
pub fn activate_running_app(endpoint: &Path) -> bool {
|
||||
pub fn activate_running_app(endpoint: &Path, open: Option<OpenLink>) -> bool {
|
||||
let Ok(stream) = connect(endpoint) else {
|
||||
return false;
|
||||
};
|
||||
@@ -78,7 +79,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
|
||||
let asked = std::thread::Builder::new()
|
||||
.name("shellrs-activate".into())
|
||||
.spawn(move || {
|
||||
let _ = done.send(ask_to_come_forward(&stream));
|
||||
let _ = done.send(ask_to_come_forward(&stream, open));
|
||||
});
|
||||
if asked.is_ok() {
|
||||
let _ = heard.recv_timeout(ACTIVATION_TIMEOUT);
|
||||
@@ -88,7 +89,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
|
||||
|
||||
/// Send the request and wait for the answer, whatever it says: by then the
|
||||
/// app has the request.
|
||||
fn ask_to_come_forward<S>(stream: S) -> io::Result<()>
|
||||
fn ask_to_come_forward<S>(stream: S, open: Option<OpenLink>) -> io::Result<()>
|
||||
where
|
||||
S: Copy + io::Read + Write,
|
||||
{
|
||||
@@ -97,7 +98,7 @@ where
|
||||
&mut writer,
|
||||
&Envelope {
|
||||
version: PROTOCOL_VERSION,
|
||||
request: Request::Activate,
|
||||
request: Request::Activate { open },
|
||||
},
|
||||
)?;
|
||||
read_frame(&mut BufReader::new(stream)).map(|_| ())
|
||||
@@ -252,7 +253,11 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
[
|
||||
host.id.clone(),
|
||||
host.name.clone(),
|
||||
host.group.clone().unwrap_or_default(),
|
||||
if host.temporary {
|
||||
"(临时连接)".to_string()
|
||||
} else {
|
||||
host.group.clone().unwrap_or_default()
|
||||
},
|
||||
host.address(),
|
||||
host.os
|
||||
.as_deref()
|
||||
|
||||
+13
-1
@@ -23,15 +23,17 @@ pub enum AgentKind {
|
||||
Codex,
|
||||
ClaudeCode,
|
||||
OpenCode,
|
||||
WorkBuddy,
|
||||
}
|
||||
|
||||
impl AgentKind {
|
||||
/// In the order the settings page lists them.
|
||||
pub const ALL: [AgentKind; 4] = [
|
||||
pub const ALL: [AgentKind; 5] = [
|
||||
AgentKind::Generic,
|
||||
AgentKind::Codex,
|
||||
AgentKind::ClaudeCode,
|
||||
AgentKind::OpenCode,
|
||||
AgentKind::WorkBuddy,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -40,6 +42,7 @@ impl AgentKind {
|
||||
AgentKind::Codex => "Codex",
|
||||
AgentKind::ClaudeCode => "Claude Code",
|
||||
AgentKind::OpenCode => "OpenCode",
|
||||
AgentKind::WorkBuddy => "WorkBuddy",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ impl AgentKind {
|
||||
AgentKind::Codex => "codex",
|
||||
AgentKind::ClaudeCode => "claude",
|
||||
AgentKind::OpenCode => "opencode",
|
||||
AgentKind::WorkBuddy => "workbuddy",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +72,7 @@ impl AgentKind {
|
||||
AgentKind::Codex => &[".codex", "skills"],
|
||||
AgentKind::ClaudeCode => &[".claude", "skills"],
|
||||
AgentKind::OpenCode => &[".config", "opencode", "skills"],
|
||||
AgentKind::WorkBuddy => &[".workbuddy", "skills"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,6 +446,13 @@ mod tests {
|
||||
.join("shellrs")
|
||||
.join("SKILL.md")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.skill_file(AgentKind::WorkBuddy),
|
||||
home.join(".workbuddy")
|
||||
.join("skills")
|
||||
.join("shellrs")
|
||||
.join("SKILL.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
//! The links ShellRS is opened with, the way bastion hosts open Xshell:
|
||||
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`.
|
||||
//!
|
||||
//! 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
|
||||
//! with it in a notification. Opened again, ShellRS hands the link to the
|
||||
//! one already running, inside [`super::Request::Activate`].
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A link to connect to, as the caller wrote it.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OpenLink {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OpenLink")
|
||||
.field("url", &without_password(&self.url))
|
||||
.field("tab", &self.tab)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// `url` with its password replaced, for logs. Never the text itself when
|
||||
/// it cannot be read: where the password ends is unknown then.
|
||||
fn without_password(url: &str) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match url::Url::parse(url) {
|
||||
Ok(mut parsed) => {
|
||||
if parsed.password().is_some() {
|
||||
let _ = parsed.set_password(Some("***"));
|
||||
}
|
||||
parsed.to_string()
|
||||
}
|
||||
Err(_) => "<无法解析的链接>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Xshell's options. `-newwin` asks for a window of its own, which means
|
||||
/// nothing to ShellRS's one window.
|
||||
const URL_OPTION: &str = "-url";
|
||||
const TAB_OPTION: &str = "-newtab";
|
||||
const WINDOW_OPTION: &str = "-newwin";
|
||||
|
||||
/// 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.
|
||||
pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
|
||||
let args: Vec<String> = args
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let first = args.first()?;
|
||||
if !is_option(first) && !looks_like_link(first) {
|
||||
return None;
|
||||
}
|
||||
let mut given = None;
|
||||
let mut bare = None;
|
||||
let mut tab = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
let option = arg.to_lowercase();
|
||||
if option == URL_OPTION {
|
||||
let url = args.next();
|
||||
given = given.or(url);
|
||||
} else if option == TAB_OPTION {
|
||||
// Its name, unless the next argument is something else.
|
||||
if args
|
||||
.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());
|
||||
}
|
||||
}
|
||||
} else if option == WINDOW_OPTION || arg.starts_with('-') {
|
||||
// Ignored, like any option ShellRS does not know.
|
||||
} else if bare.is_none() && looks_like_link(&arg) {
|
||||
bare = Some(arg);
|
||||
}
|
||||
}
|
||||
Some(OpenLink {
|
||||
url: given.or(bare).unwrap_or_default(),
|
||||
tab,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_option(arg: &str) -> bool {
|
||||
[URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
return false;
|
||||
};
|
||||
let mut chars = scheme.chars();
|
||||
chars
|
||||
.next()
|
||||
.is_some_and(|first| first.is_ascii_alphabetic())
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn link(args: &[&str]) -> Option<OpenLink> {
|
||||
let args: Vec<OsString> = args.iter().map(OsString::from).collect();
|
||||
link_arguments(&args)
|
||||
}
|
||||
|
||||
fn open(url: &str, tab: Option<&str>) -> Option<OpenLink> {
|
||||
Some(OpenLink {
|
||||
url: url.into(),
|
||||
tab: tab.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_its_own_opens() {
|
||||
let url = "ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024";
|
||||
assert_eq!(link(&[url]), open(url, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xshells_options_are_understood_in_any_case_and_order() {
|
||||
let url = "ssh://deploy:secret@10.0.0.9:2222";
|
||||
assert_eq!(
|
||||
link(&["-url", url, "-newtab", "生产库"]),
|
||||
open(url, Some("生产库"))
|
||||
);
|
||||
assert_eq!(
|
||||
link(&["-NewTab", " 生产库 ", "-newwin", "-URL", url]),
|
||||
open(url, Some("生产库"))
|
||||
);
|
||||
// `-newtab` without a name.
|
||||
assert_eq!(link(&["-newtab", url]), open(url, None));
|
||||
assert_eq!(link(&["-newwin", url, "-newtab"]), open(url, None));
|
||||
// `-url` wins over a bare link, and the first of each counts.
|
||||
assert_eq!(
|
||||
link(&["ssh://a@b", "-url", url, "-url", "ssh://c@d"]),
|
||||
open(url, None)
|
||||
);
|
||||
// Unknown options are passed over, their values with them unless
|
||||
// they look like a link.
|
||||
assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_without_a_link_still_open_so_the_app_can_say_why() {
|
||||
assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库")));
|
||||
assert_eq!(link(&["-url"]), open("", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_arguments_are_left_to_the_command() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["list"],
|
||||
&["exec", "web", "curl http://example.com"],
|
||||
&["upload", "web", "./a", "/tmp/"],
|
||||
&["--help"],
|
||||
&["-h"],
|
||||
&["--version"],
|
||||
&["ssh-not-a-link"],
|
||||
&["://nothing-before"],
|
||||
] {
|
||||
assert_eq!(link(args), None, "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_hides_the_password() {
|
||||
let shown = format!(
|
||||
"{:?}",
|
||||
OpenLink {
|
||||
url: "ssh://deploy:hunter2@10.0.0.9".into(),
|
||||
tab: None,
|
||||
}
|
||||
);
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains("deploy"), "{shown}");
|
||||
let unreadable = format!(
|
||||
"{:?}",
|
||||
OpenLink {
|
||||
url: "ssh://deploy:hunter2@[bad".into(),
|
||||
tab: None,
|
||||
}
|
||||
);
|
||||
assert!(!unreadable.contains("hunter2"), "{unreadable}");
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -9,6 +9,7 @@ mod install;
|
||||
#[cfg(any(windows, test))]
|
||||
mod install_windows;
|
||||
mod integration;
|
||||
mod link;
|
||||
#[cfg(windows)]
|
||||
mod pipe_windows;
|
||||
mod protocol;
|
||||
@@ -32,6 +33,7 @@ pub use install::{
|
||||
update_outdated_binary, update_outdated_skills,
|
||||
};
|
||||
pub use integration::{CliIntegration, IntegrationStatus};
|
||||
pub use link::{OpenLink, link_arguments};
|
||||
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
|
||||
pub use server::{CliBackend, CliServer, CliTarget};
|
||||
|
||||
@@ -287,8 +289,9 @@ impl<W: Write> Write for ConsoleText<W> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the process was started as the command rather than the app:
|
||||
/// with arguments, apart from the one macOS adds when launching an app.
|
||||
/// The arguments the process was started with, apart from the one macOS
|
||||
/// adds when launching an app; `None` without any. The command's, unless
|
||||
/// [`link_arguments`] finds a link to open in them.
|
||||
pub fn command_line_arguments() -> Option<Vec<OsString>> {
|
||||
let args: Vec<OsString> = std::env::args_os()
|
||||
.skip(1)
|
||||
|
||||
+50
-5
@@ -13,6 +13,8 @@ use std::{
|
||||
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
|
||||
use super::link::OpenLink;
|
||||
|
||||
/// Bumped when a request or reply changes shape. The command and the app
|
||||
/// come from the same build, so they differ only while an older copy still
|
||||
/// runs, or on Windows while the copy on the PATH has not been updated.
|
||||
@@ -124,10 +126,16 @@ pub enum Request {
|
||||
source: String,
|
||||
destination: PathBuf,
|
||||
},
|
||||
/// Bring the app's window forward. Not the `shellrs` command's: ShellRS
|
||||
/// sends it when it is opened while it is already running, so it is
|
||||
/// answered whatever 启用外部 CLI says.
|
||||
Activate,
|
||||
/// Bring the app's window forward, and open `open` in it. Not the
|
||||
/// `shellrs` command's: ShellRS sends it when it is opened while it is
|
||||
/// already running, so it is answered whatever 启用外部 CLI says.
|
||||
///
|
||||
/// Without a link it is `{"type":"activate"}`, as it always was. A
|
||||
/// ShellRS from before links ignores `open` and only comes forward.
|
||||
Activate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
open: Option<OpenLink>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -151,7 +159,7 @@ pub enum Reply {
|
||||
},
|
||||
}
|
||||
|
||||
/// A saved host as the CLI lists it.
|
||||
/// A host as the CLI lists it: a saved one, or one opened from a link.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
/// The host's public ID, the one 复制 ID copies.
|
||||
@@ -164,6 +172,12 @@ pub struct HostInfo {
|
||||
pub port: u16,
|
||||
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
|
||||
pub os: Option<String>,
|
||||
/// Opened from a bastion host's link (临时连接) rather than saved: listed
|
||||
/// while its tab is open, under one ID for as long; opened again, it is
|
||||
/// another host with another ID. Missing from an older app's answer,
|
||||
/// which listed saved hosts only.
|
||||
#[serde(default)]
|
||||
pub temporary: bool,
|
||||
}
|
||||
|
||||
impl HostInfo {
|
||||
@@ -300,4 +314,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activating_without_a_link_is_what_it_always_was() {
|
||||
let plain = Envelope {
|
||||
version: PROTOCOL_VERSION,
|
||||
request: Request::Activate { open: None },
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&plain).unwrap(),
|
||||
r#"{"version":2,"request":{"type":"activate"}}"#
|
||||
);
|
||||
let read: Envelope = parse_json(br#"{"version":2,"request":{"type":"activate"}}"#).unwrap();
|
||||
assert_eq!(read, plain);
|
||||
|
||||
let with_link = Request::Activate {
|
||||
open: Some(OpenLink {
|
||||
url: "ssh://token@10.0.0.9:2222".into(),
|
||||
tab: Some("堡垒机".into()),
|
||||
}),
|
||||
};
|
||||
let wire = serde_json::to_vec(&with_link).unwrap();
|
||||
assert_eq!(parse_json::<Request>(&wire).unwrap(), with_link);
|
||||
|
||||
// How a ShellRS from before links read it: still a plain Activate.
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum Before {
|
||||
Activate,
|
||||
}
|
||||
assert_eq!(parse_json::<Before>(&wire).unwrap(), Before::Activate);
|
||||
}
|
||||
}
|
||||
+51
-28
@@ -6,7 +6,7 @@ use std::{
|
||||
io::{self, BufReader},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
@@ -15,6 +15,7 @@ use std::{
|
||||
#[cfg(unix)]
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::link::OpenLink;
|
||||
use super::protocol::{
|
||||
CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request,
|
||||
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
|
||||
@@ -78,12 +79,19 @@ impl CliTarget {
|
||||
host: host.address.to_string(),
|
||||
port: host.port,
|
||||
os: host.os.map(|os| os.as_str().to_string()),
|
||||
temporary: false,
|
||||
},
|
||||
host: host.clone(),
|
||||
login,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marked as opened from a link, not saved.
|
||||
fn temporary(mut self) -> Self {
|
||||
self.info.temporary = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &Host {
|
||||
&self.host
|
||||
}
|
||||
@@ -93,23 +101,27 @@ impl CliTarget {
|
||||
&self.login
|
||||
}
|
||||
|
||||
/// Every host in `store`, with its group path.
|
||||
/// Every host in `store`, with its group path, then the ones opened
|
||||
/// from a link. A request logs in to those afresh too, with the link's
|
||||
/// user and password; a bastion host may refuse a login it gave out for
|
||||
/// one use.
|
||||
pub fn all(store: &HostStore) -> Vec<Self> {
|
||||
store
|
||||
.hosts()
|
||||
let saved = store.hosts().iter().map(|host| {
|
||||
let names = host
|
||||
.group
|
||||
.map(|id| store.group_names(id))
|
||||
.unwrap_or_default();
|
||||
Self::new(
|
||||
host,
|
||||
store.login_of(host),
|
||||
(!names.is_empty()).then(|| names.join("/")),
|
||||
)
|
||||
});
|
||||
let temporary = store
|
||||
.transient_hosts()
|
||||
.iter()
|
||||
.map(|host| {
|
||||
let names = host
|
||||
.group
|
||||
.map(|id| store.group_names(id))
|
||||
.unwrap_or_default();
|
||||
Self::new(
|
||||
host,
|
||||
store.login_of(host),
|
||||
(!names.is_empty()).then(|| names.join("/")),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
.map(|host| Self::new(host, store.login_of(host), None).temporary());
|
||||
saved.chain(temporary).collect()
|
||||
}
|
||||
|
||||
/// The host tree's search, plus the group path and the ID.
|
||||
@@ -130,9 +142,10 @@ struct Shared {
|
||||
enabled: AtomicBool,
|
||||
targets: RwLock<Vec<CliTarget>>,
|
||||
backend: Arc<dyn CliBackend>,
|
||||
/// Set when ShellRS was opened again while this one runs, until the
|
||||
/// app has brought its window forward.
|
||||
activation: AtomicBool,
|
||||
/// Set when ShellRS was opened again while this one runs, with the
|
||||
/// links it was opened with, until the app has come forward and opened
|
||||
/// them.
|
||||
activation: Mutex<Option<Vec<OpenLink>>>,
|
||||
}
|
||||
|
||||
/// Listens on the CLI socket until dropped. Always listening, even with
|
||||
@@ -152,7 +165,7 @@ impl CliServer {
|
||||
enabled: AtomicBool::new(false),
|
||||
targets: RwLock::new(Vec::new()),
|
||||
backend,
|
||||
activation: AtomicBool::new(false),
|
||||
activation: Mutex::new(None),
|
||||
});
|
||||
let listener = listen(endpoint, shared.clone())?;
|
||||
Ok(Self {
|
||||
@@ -173,11 +186,16 @@ impl CliServer {
|
||||
.unwrap_or_else(|error| error.into_inner()) = targets;
|
||||
}
|
||||
|
||||
/// Whether ShellRS was opened again since this was last asked. The
|
||||
/// request threads cannot reach the window, so the app asks here on a
|
||||
/// timer and brings its window forward when the answer is yes.
|
||||
pub fn take_activation(&self) -> bool {
|
||||
self.shared.activation.swap(false, Ordering::AcqRel)
|
||||
/// Whether ShellRS was opened again since this was last asked, and the
|
||||
/// links it was opened with, oldest first: `Some` and empty for a plain
|
||||
/// re-open. The request threads cannot reach the window, so the app asks
|
||||
/// here on a timer, then comes forward and opens them.
|
||||
pub fn take_activation(&self) -> Option<Vec<OpenLink>> {
|
||||
self.shared
|
||||
.activation
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,8 +440,13 @@ fn respond(
|
||||
.map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?;
|
||||
// Before the checks below: coming forward means the same in every
|
||||
// version, and it is ShellRS being opened again, not the external CLI.
|
||||
if envelope.request == Request::Activate {
|
||||
shared.activation.store(true, Ordering::Release);
|
||||
if let Request::Activate { open } = &envelope.request {
|
||||
shared
|
||||
.activation
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get_or_insert_with(Vec::new)
|
||||
.extend(open.clone());
|
||||
return Ok(Reply::Activated);
|
||||
}
|
||||
if envelope.version != PROTOCOL_VERSION {
|
||||
@@ -498,7 +521,7 @@ fn respond(
|
||||
.map(Reply::TransferDone)
|
||||
}
|
||||
// Answered above, before anything was checked.
|
||||
Request::Activate => Ok(Reply::Activated),
|
||||
Request::Activate { .. } => Ok(Reply::Activated),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+98
-8
@@ -10,12 +10,14 @@ use std::{
|
||||
use clap::Parser as _;
|
||||
|
||||
use super::client::{self, Console};
|
||||
use super::link::OpenLink;
|
||||
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
|
||||
use super::server::{CliBackend, CliServer, CliTarget};
|
||||
use super::{Cli, Command};
|
||||
use super::{ConsoleText, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin};
|
||||
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
|
||||
/// Records what it was asked and answers from a script.
|
||||
@@ -328,12 +330,12 @@ fn opening_shellrs_again_brings_the_running_one_forward() {
|
||||
let fixture = fixture();
|
||||
// Whatever 启用外部 CLI says: this is not the external CLI.
|
||||
fixture.server.set_enabled(false);
|
||||
assert!(!fixture.server.take_activation());
|
||||
assert_eq!(fixture.server.take_activation(), None);
|
||||
|
||||
assert!(client::activate_running_app(&fixture.socket));
|
||||
assert!(fixture.server.take_activation());
|
||||
assert!(client::activate_running_app(&fixture.socket, None));
|
||||
assert_eq!(fixture.server.take_activation(), Some(Vec::new()));
|
||||
// Heard once, acted on once.
|
||||
assert!(!fixture.server.take_activation());
|
||||
assert_eq!(fixture.server.take_activation(), None);
|
||||
assert!(fixture.backend.calls.lock().unwrap().is_empty());
|
||||
|
||||
// The switch still guards everything else.
|
||||
@@ -344,13 +346,101 @@ fn opening_shellrs_again_brings_the_running_one_forward() {
|
||||
.unwrap()
|
||||
.starts_with("shellrs: [not_enabled]")
|
||||
);
|
||||
assert!(!fixture.server.take_activation());
|
||||
assert_eq!(fixture.server.take_activation(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_shellrs_with_links_hands_them_to_the_running_one_in_order() {
|
||||
let fixture = fixture();
|
||||
fixture.server.set_enabled(false);
|
||||
let link = |url: &str, tab: Option<&str>| OpenLink {
|
||||
url: url.into(),
|
||||
tab: tab.map(str::to_string),
|
||||
};
|
||||
let first = link("ssh://token:p%40ss@172.16.0.28:12024", Some("堡垒机"));
|
||||
let second = link("ssh://root@10.0.0.9", None);
|
||||
assert!(client::activate_running_app(
|
||||
&fixture.socket,
|
||||
Some(first.clone())
|
||||
));
|
||||
assert!(client::activate_running_app(&fixture.socket, None));
|
||||
assert!(client::activate_running_app(
|
||||
&fixture.socket,
|
||||
Some(second.clone())
|
||||
));
|
||||
assert_eq!(fixture.server.take_activation(), Some(vec![first, second]));
|
||||
assert_eq!(fixture.server.take_activation(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
|
||||
let mut store = HostStore::empty();
|
||||
let saved = store.insert_unnotified(HostDraft::new(
|
||||
"web",
|
||||
"10.0.1.12",
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Password,
|
||||
None,
|
||||
));
|
||||
let link = store.insert_transient_unnotified(
|
||||
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
|
||||
);
|
||||
let targets = CliTarget::all(&store);
|
||||
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
|
||||
assert_eq!(ids, [saved, link]);
|
||||
// With the link's password, which only memory holds.
|
||||
assert_eq!(targets[1].login().password, SecretRef::transient(link.0));
|
||||
|
||||
let fixture = fixture();
|
||||
fixture.server.set_targets(targets);
|
||||
let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
|
||||
assert_eq!(code, 0);
|
||||
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
|
||||
let listed: Vec<(&str, bool)> = hosts
|
||||
.iter()
|
||||
.map(|host| (host.name.as_str(), host.temporary))
|
||||
.collect();
|
||||
assert_eq!(listed, [("web", false), ("堡垒机", true)]);
|
||||
let temporary = store.host(link).unwrap().public_id.to_string();
|
||||
assert_eq!(hosts[1].id, temporary);
|
||||
assert_eq!(hosts[1].address(), "token@10.0.0.9:2222");
|
||||
|
||||
// Reached by that ID like a saved host.
|
||||
let (code, _, _) = run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
host: temporary,
|
||||
command: "uptime".into(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(code, 3);
|
||||
|
||||
// A table says so where the group goes.
|
||||
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
|
||||
assert!(
|
||||
table.lines().nth(2).unwrap().contains("(临时连接)"),
|
||||
"{table}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_apps_list_reads_as_saved_hosts() {
|
||||
let host: HostInfo = serde_json::from_str(
|
||||
r#"{"id":"VmLkf1snMOuPKJ07","name":"web","group":null,"user":"root","host":"10.0.1.12","port":22,"os":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!host.temporary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nothing_running_shellrs_is_free_to_start() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!client::activate_running_app(&cli_endpoint(dir.path())));
|
||||
assert!(!client::activate_running_app(
|
||||
&cli_endpoint(dir.path()),
|
||||
None
|
||||
));
|
||||
|
||||
// Nor does a socket left behind by a crash count as a running app.
|
||||
#[cfg(unix)]
|
||||
@@ -359,7 +449,7 @@ fn with_nothing_running_shellrs_is_free_to_start() {
|
||||
// No process may start meanwhile and keep the socket open.
|
||||
let _forks = crate::testing::no_forks();
|
||||
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
|
||||
assert!(!client::activate_running_app(&stale));
|
||||
assert!(!client::activate_running_app(&stale, None));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub struct DockerPanel {
|
||||
loading: bool,
|
||||
tab: DockerTab,
|
||||
/// Compose projects folded or unfolded by hand, by name. The rest are
|
||||
/// unfolded when something in them runs.
|
||||
/// as [`Project::unfolded_at_first`] says.
|
||||
unfolded: HashMap<String, bool>,
|
||||
/// The list's lines differ in height (a project with its containers,
|
||||
/// a heading, a container), so it is a list that measures them.
|
||||
@@ -158,7 +158,7 @@ impl DockerPanel {
|
||||
self.unfolded
|
||||
.get(&project.name)
|
||||
.copied()
|
||||
.unwrap_or(project.running > 0)
|
||||
.unwrap_or_else(|| project.unfolded_at_first())
|
||||
}
|
||||
|
||||
fn show(&mut self, tab: DockerTab, cx: &mut Context<Self>) {
|
||||
@@ -369,7 +369,7 @@ impl DockerPanel {
|
||||
DockerRow::Image(index) => {
|
||||
let image = &table.images()[*index];
|
||||
render_object(
|
||||
format!("docker-image:{}", image.id).into(),
|
||||
format!("docker-image:{}", image.key).into(),
|
||||
image.summary(),
|
||||
&dispatch,
|
||||
cx,
|
||||
|
||||
+66
-4
@@ -41,14 +41,15 @@ pub fn command() -> String {
|
||||
}
|
||||
|
||||
/// Whether `id` is an ID or a name Docker gave or allows, and nothing else,
|
||||
/// so that it can go into a command.
|
||||
/// so that it can go into a command. An image's `repository:tag` may name
|
||||
/// a registry, so `/` is allowed too (`registry.example.com/app:1.0`).
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 255
|
||||
&& id.starts_with(|character: char| character.is_ascii_alphanumeric())
|
||||
&& id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "_.:-".contains(character))
|
||||
.all(|character| character.is_ascii_alphanumeric() || "_.:-/".contains(character))
|
||||
}
|
||||
|
||||
/// `script` with `$d` for `docker`, saying how it went in an `@@status`
|
||||
@@ -274,10 +275,20 @@ fn volume(object: &Value) -> Volume {
|
||||
|
||||
fn image(object: &Value) -> Image {
|
||||
let (repository, tag) = (text(object, "Repository"), text(object, "Tag"));
|
||||
let id = text(object, "ID").trim_start_matches("sha256:").to_owned();
|
||||
let tagged = repository != "<none>" && !matches!(tag, "<none>" | "");
|
||||
Image {
|
||||
id: text(object, "ID").trim_start_matches("sha256:").to_owned(),
|
||||
// 「nginx」 alone would be read as nginx:latest, another image.
|
||||
key: if tagged {
|
||||
format!("{repository}:{tag}")
|
||||
} else {
|
||||
id.clone()
|
||||
},
|
||||
id,
|
||||
// 「nginx:<none>」 as `docker images` puts it: plain 「nginx」 would
|
||||
// say nginx:latest, and the containers of that would count as its.
|
||||
reference: (repository != "<none>").then(|| match tag {
|
||||
"<none>" | "" => repository.to_owned(),
|
||||
"<none>" | "" => format!("{repository}:<none>"),
|
||||
tag => format!("{repository}:{tag}"),
|
||||
}),
|
||||
size: format_size(text(object, "Size")),
|
||||
@@ -714,6 +725,54 @@ v2.25.0
|
||||
assert!(table.volumes()[0].used_by.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_several_tags_is_a_line_for_each_named_by_its_tag() {
|
||||
let table = table(
|
||||
"@@version\n26.0.0\n@@containers\n@@volumes\n@@images\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"latest\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"1.27\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"aaa111\",\"Repository\":\"registry.example.com/web/nginx\",\"Tag\":\"prod\",\"Size\":\"187MB\"}\n\
|
||||
{\"ID\":\"bbb222\",\"Repository\":\"nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
|
||||
{\"ID\":\"bbb222\",\"Repository\":\"mirror/nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
|
||||
@@networks\n",
|
||||
);
|
||||
let keys: Vec<&str> = table
|
||||
.images()
|
||||
.iter()
|
||||
.map(|image| image.key.as_str())
|
||||
.collect();
|
||||
// 「nginx」 with no tag is not nginx:latest: it goes by its ID, and
|
||||
// once, though two repositories hold it.
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"nginx:1.27",
|
||||
"nginx:latest",
|
||||
"registry.example.com/web/nginx:prod",
|
||||
"bbb222"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
table.images()[3].reference.as_deref(),
|
||||
Some("mirror/nginx:<none>")
|
||||
);
|
||||
let summary = table
|
||||
.summary_of(DockerObject::Image, "nginx:1.27")
|
||||
.expect("by its tag");
|
||||
assert_eq!(summary.name, "nginx:1.27");
|
||||
assert_eq!(summary.id, "nginx:1.27");
|
||||
// Removing a line removes that tag, wherever the registry is.
|
||||
assert_eq!(
|
||||
remove_command(DockerObject::Image, "registry.example.com/web/nginx:prod").as_deref(),
|
||||
Some(
|
||||
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/local/bin:/opt/homebrew/bin; \
|
||||
if test \"$(id -u)\" = 0 || docker version >/dev/null 2>&1; then d=docker; \
|
||||
else d=\"sudo -n docker\"; fi; $d rmi \"registry.example.com/web/nginx:prod\" 2>&1; \
|
||||
echo @@status $?'"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_that_will_not_answer_says_why() {
|
||||
assert_eq!(
|
||||
@@ -853,7 +912,10 @@ v2.25.0
|
||||
assert!(valid_id("f8664a4a9b8d"));
|
||||
assert!(valid_id("vw-data"));
|
||||
assert!(valid_id("sha256:4a3b5c"));
|
||||
assert!(valid_id("registry.example.com:5000/team/app:1.0"));
|
||||
assert!(!valid_id("-rf"));
|
||||
assert!(!valid_id("/etc"));
|
||||
assert!(!valid_id("a\"b"));
|
||||
assert!(!valid_id("a b"));
|
||||
assert!(!valid_id("$(reboot)"));
|
||||
assert_eq!(remove_command(DockerObject::Volume, "a;b"), None);
|
||||
|
||||
+61
-9
@@ -88,8 +88,13 @@ pub struct Volume {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
/// 「4a3b5c6d7e8f」.
|
||||
/// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is
|
||||
/// two of its lines, one per tag.
|
||||
pub id: String,
|
||||
/// What a command names this line by, unique in the list: 「nginx:1.27」,
|
||||
/// or the ID for an image without a tag. Removing a tag removes the
|
||||
/// image only with its last one, as `docker rmi nginx:1.27` does.
|
||||
pub key: String,
|
||||
/// 「nginx:1.27」, `None` for a dangling image (「<none>」).
|
||||
pub reference: Option<String>,
|
||||
/// 「187.69 MB」.
|
||||
@@ -127,6 +132,16 @@ pub struct Project {
|
||||
pub running: usize,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Whether the list shows its containers until folded or unfolded by
|
||||
/// hand: only a project of one container, running. A project of
|
||||
/// several would push the rest of the list down; the card already says
|
||||
/// how many of them run.
|
||||
pub fn unfolded_at_first(&self) -> bool {
|
||||
self.running > 0 && self.containers.len() == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// What can be done to containers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ContainerCommand {
|
||||
@@ -296,14 +311,23 @@ impl DockerTable {
|
||||
}) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id)))
|
||||
});
|
||||
}
|
||||
// Named ones first, by name; the dangling after.
|
||||
// Tagged ones first, by name; then those without a tag, the ones
|
||||
// without a repository either last.
|
||||
images.sort_by(|a, b| {
|
||||
(a.reference.is_none(), &a.reference, &a.id).cmp(&(
|
||||
b.reference.is_none(),
|
||||
&b.reference,
|
||||
&b.id,
|
||||
))
|
||||
let order = |image: &Image| {
|
||||
(
|
||||
image.key == image.id,
|
||||
image.reference.is_none(),
|
||||
image.reference.clone(),
|
||||
image.id.clone(),
|
||||
)
|
||||
};
|
||||
order(a).cmp(&order(b))
|
||||
});
|
||||
// One card per key: an image without a tag in two repositories is
|
||||
// one image.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
images.retain(|image| seen.insert(image.key.clone()));
|
||||
|
||||
for network in &mut networks {
|
||||
network.used_by = using(&|container| container.networks.contains(&network.name));
|
||||
@@ -342,7 +366,7 @@ impl DockerTable {
|
||||
DockerObject::Image => self
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.id == id)
|
||||
.find(|image| image.key == id)
|
||||
.map(Image::summary),
|
||||
DockerObject::Network => self
|
||||
.networks
|
||||
@@ -486,7 +510,7 @@ impl Image {
|
||||
let in_use = !self.used_by.is_empty();
|
||||
ObjectSummary {
|
||||
object: DockerObject::Image,
|
||||
id: self.id.clone(),
|
||||
id: self.key.clone(),
|
||||
name: self
|
||||
.reference
|
||||
.clone()
|
||||
@@ -708,6 +732,33 @@ mod tests {
|
||||
assert_eq!(table.counts(), [4, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_running_project_of_one_container_starts_unfolded() {
|
||||
let table = DockerTable::new(
|
||||
None,
|
||||
None,
|
||||
vec![
|
||||
container("php56", ContainerState::Running, Some("php-56")),
|
||||
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
|
||||
container("db", ContainerState::Running, Some("shop")),
|
||||
container("web", ContainerState::Running, Some("shop")),
|
||||
container("worker", ContainerState::Exited, Some("shop")),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let unfolded: Vec<(&str, bool)> = table
|
||||
.projects()
|
||||
.iter()
|
||||
.map(|project| (project.name.as_str(), project.unfolded_at_first()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
unfolded,
|
||||
[("php-56", true), ("shop", false), ("vaultwarden", false)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_containers_use_is_marked() {
|
||||
let mut web = container("web", ContainerState::Running, None);
|
||||
@@ -722,6 +773,7 @@ mod tests {
|
||||
};
|
||||
let image = |id: &str, reference: Option<&str>| Image {
|
||||
id: id.into(),
|
||||
key: reference.unwrap_or(id).into(),
|
||||
reference: reference.map(str::to_owned),
|
||||
size: String::new(),
|
||||
created: String::new(),
|
||||
|
||||
@@ -13,6 +13,7 @@ mod outline;
|
||||
mod private_key;
|
||||
mod secret_fields;
|
||||
mod snippet;
|
||||
mod ssh_link;
|
||||
mod store;
|
||||
|
||||
pub use credential::*;
|
||||
@@ -27,4 +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 store::{HostStore, HostStoreEvent};
|
||||
@@ -0,0 +1,222 @@
|
||||
//! `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 a
|
||||
//! 临时连接 that lives only as long as its tabs.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
use url::{Host as UrlHost, ParseError, Url};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{AuthKind, HostDraft};
|
||||
|
||||
/// The port when the link names none.
|
||||
const SSH_PORT: u16 = 22;
|
||||
|
||||
/// What a link says to connect to.
|
||||
pub struct SshLink {
|
||||
/// The tab's name: what the bastion host called it (Xshell's
|
||||
/// `-newtab`), or else the address.
|
||||
pub name: String,
|
||||
/// Without brackets, for an IPv6 address.
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// Kept in memory only, never in the keychain or the database.
|
||||
pub password: Option<Zeroizing<String>>,
|
||||
}
|
||||
|
||||
impl SshLink {
|
||||
/// Read `link`, named `tab` when the caller gave a name. The error says
|
||||
/// what is wrong without repeating the link, which may hold a password.
|
||||
pub fn parse(link: &str, tab: Option<&str>) -> Result<Self, String> {
|
||||
let link = link.trim();
|
||||
if link.is_empty() {
|
||||
return Err("启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口]。".into());
|
||||
}
|
||||
let malformed = || "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口]。".to_string();
|
||||
// Without `://`, `user:password@host` would read as the scheme
|
||||
// `user`, and the error would show it.
|
||||
if !link.contains("://") {
|
||||
return Err(malformed());
|
||||
}
|
||||
let url = Url::parse(link).map_err(|error| match error {
|
||||
ParseError::InvalidPort => "链接里的端口无效。".to_string(),
|
||||
ParseError::EmptyHost => "链接里没有地址。".to_string(),
|
||||
_ => malformed(),
|
||||
})?;
|
||||
if url.scheme() != "ssh" {
|
||||
return Err(format!("只支持 ssh:// 链接,不支持 {}://。", url.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.
|
||||
let address = match url.host() {
|
||||
Some(UrlHost::Domain(domain)) => decode(domain)?,
|
||||
Some(UrlHost::Ipv4(address)) => address.to_string(),
|
||||
Some(UrlHost::Ipv6(address)) => address.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
if address.is_empty() {
|
||||
return Err("链接里没有地址。".into());
|
||||
}
|
||||
let port = match url.port() {
|
||||
Some(0) => return Err("链接里的端口无效。".into()),
|
||||
Some(port) => port,
|
||||
None => SSH_PORT,
|
||||
};
|
||||
let user = decode(url.username())?;
|
||||
if user.is_empty() {
|
||||
return Err("链接里没有用户名,应为 ssh://用户@地址[:端口]。".into());
|
||||
}
|
||||
let password = match url.password() {
|
||||
Some(password) => Some(Zeroizing::new(decode(password)?)),
|
||||
None => None,
|
||||
}
|
||||
.filter(|password| !password.is_empty());
|
||||
let name = tab
|
||||
.map(str::trim)
|
||||
.filter(|tab| !tab.is_empty())
|
||||
.map_or_else(|| address.clone(), str::to_string);
|
||||
Ok(Self {
|
||||
name,
|
||||
address,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
/// The host to connect to. It logs in by password: the link's, or else
|
||||
/// asked for when the server wants one.
|
||||
pub fn draft(&self) -> HostDraft {
|
||||
HostDraft::new(
|
||||
self.name.clone(),
|
||||
self.address.clone(),
|
||||
self.port,
|
||||
self.user.clone(),
|
||||
AuthKind::Password,
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SshLink {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SshLink")
|
||||
.field("name", &self.name)
|
||||
.field("address", &self.address)
|
||||
.field("port", &self.port)
|
||||
.field("user", &self.user)
|
||||
.field("password", &self.password.as_ref().map(|_| "***"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a link percent-encodes: `p%40ss` is `p@ss`.
|
||||
fn decode(text: &str) -> Result<String, String> {
|
||||
percent_decode_str(text)
|
||||
.decode_utf8()
|
||||
.map(|text| text.into_owned())
|
||||
.map_err(|_| "链接里有无法识别的字符。".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(link: &str) -> SshLink {
|
||||
SshLink::parse(link, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bastion_token_logs_in_as_the_user() {
|
||||
let link = parse("ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024");
|
||||
assert_eq!(link.user, "b478e26f-811b-4a90-81c3-74929127898a");
|
||||
assert_eq!(link.address, "172.16.0.28");
|
||||
assert_eq!(link.port, 12024);
|
||||
assert!(link.password.is_none());
|
||||
assert_eq!(link.name, "172.16.0.28");
|
||||
|
||||
let draft = link.draft();
|
||||
assert_eq!(draft.address.as_ref(), "172.16.0.28");
|
||||
assert_eq!(draft.user.as_ref(), "b478e26f-811b-4a90-81c3-74929127898a");
|
||||
assert_eq!(draft.port, 12024);
|
||||
assert_eq!(draft.auth, AuthKind::Password);
|
||||
assert_eq!(draft.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_name_and_an_encoded_password_come_through() {
|
||||
let link = SshLink::parse(
|
||||
"SSH://deploy:p%40ss%3Aword@db.example.com",
|
||||
Some(" 生产库 "),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link.name, "生产库");
|
||||
assert_eq!(link.user, "deploy");
|
||||
assert_eq!(link.address, "db.example.com");
|
||||
assert_eq!(link.port, 22);
|
||||
assert_eq!(
|
||||
link.password.as_deref().map(String::as_str),
|
||||
Some("p@ss:word")
|
||||
);
|
||||
// An empty password is none.
|
||||
assert!(parse("ssh://deploy:@db.example.com").password.is_none());
|
||||
// An empty tab name is no name.
|
||||
assert_eq!(
|
||||
SshLink::parse("ssh://deploy@db", Some(" ")).unwrap().name,
|
||||
"db"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_ends_at_the_last_at_sign() {
|
||||
// How JumpServer names the account and the asset in one user name.
|
||||
let link = parse("ssh://admin@root@10.1.1.1@jms.example.com:2222");
|
||||
assert_eq!(link.user, "admin@root@10.1.1.1");
|
||||
assert_eq!(link.address, "jms.example.com");
|
||||
assert_eq!(link.port, 2222);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ipv6_address_loses_its_brackets() {
|
||||
let link = parse("ssh://root@[::1]:2200/");
|
||||
assert_eq!(link.address, "::1");
|
||||
assert_eq!(link.port, 2200);
|
||||
assert_eq!(link.name, "::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_ascii_domain_is_decoded() {
|
||||
assert_eq!(parse("ssh://root@测试.example").address, "测试.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_wrong_is_said_without_repeating_the_link() {
|
||||
for (link, expected) in [
|
||||
("", "没有要连接的地址"),
|
||||
(" ", "没有要连接的地址"),
|
||||
("sftp://root:secret@h", "只支持 ssh://"),
|
||||
("ssh://h:22", "没有用户名"),
|
||||
("ssh://:secret@h", "没有用户名"),
|
||||
("ssh://root:secret@", "没有地址"),
|
||||
("ssh://root:secret@h:0", "端口无效"),
|
||||
("ssh://root:secret@h:65536", "端口无效"),
|
||||
("ssh://root:secret@h:port", "端口无效"),
|
||||
("root:secret@h", "格式不对"),
|
||||
] {
|
||||
let error = SshLink::parse(link, None).unwrap_err();
|
||||
assert!(error.contains(expected), "{link:?}: {error}");
|
||||
assert!(!error.contains("secret"), "{link:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_shows_the_password() {
|
||||
let link = parse("ssh://root:hunter2@h");
|
||||
let shown = format!("{link:?}");
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains("***"), "{shown}");
|
||||
}
|
||||
}
|
||||
+248
-31
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use gpui_kit::{Context, EventEmitter, SharedString};
|
||||
|
||||
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore};
|
||||
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TransientSecretStore};
|
||||
|
||||
use super::database::now_seconds;
|
||||
use super::private_key::{is_kept_in, write_key_file};
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId,
|
||||
CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, Host, HostDatabase,
|
||||
HostDraft, HostGroup, HostId, HostLogin, HostNode, HostOs, JumpLogin, LoginRoute, NodeDrop,
|
||||
ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, StoredData,
|
||||
ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, SshLink, StoredData,
|
||||
};
|
||||
|
||||
mod snippets;
|
||||
@@ -42,9 +42,17 @@ mod snippets;
|
||||
/// Passwords and private-key passphrases never reach the database. They go to
|
||||
/// the system keychain through `secrets`, on a background thread, and report
|
||||
/// failures through the same event.
|
||||
///
|
||||
/// A host opened from an `ssh://` link (a 临时连接, see [`SshLink`]) is kept
|
||||
/// apart from 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 link's password stays in `secrets`' memory.
|
||||
pub struct HostStore {
|
||||
groups: Vec<HostGroup>,
|
||||
hosts: Vec<Host>,
|
||||
/// Hosts opened from an `ssh://` link: not saved, not listed. Their ids
|
||||
/// come from `next_host_id` like a saved host's, so they never meet.
|
||||
transient: Vec<Host>,
|
||||
next_host_id: u64,
|
||||
next_group_id: u64,
|
||||
active: Option<HostId>,
|
||||
@@ -65,9 +73,10 @@ pub struct HostStore {
|
||||
next_snippet_category_id: u64,
|
||||
/// `None` for a memory-only store, as used by tests.
|
||||
database: Option<HostDatabase>,
|
||||
/// Where passwords go. Defaults to a store that keeps nothing, so unit
|
||||
/// tests never touch the machine's keychain.
|
||||
secrets: SharedSecretStore,
|
||||
/// Where passwords go: the keychain given to `with_secrets`, under a
|
||||
/// layer that keeps the links' passwords in memory. Defaults to a store
|
||||
/// that keeps nothing, so unit tests never touch the machine's keychain.
|
||||
secrets: Arc<TransientSecretStore>,
|
||||
/// Where the private keys pasted into or generated by the credential
|
||||
/// form are kept. `None` without a database to remember them, which is
|
||||
/// also how most tests run: the form then offers key files only.
|
||||
@@ -99,6 +108,7 @@ impl HostStore {
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
transient: Vec::new(),
|
||||
next_host_id: 1,
|
||||
next_group_id: 1,
|
||||
active: None,
|
||||
@@ -113,7 +123,7 @@ impl HostStore {
|
||||
snippet_categories: Vec::new(),
|
||||
next_snippet_category_id: 1,
|
||||
database: None,
|
||||
secrets: Arc::new(NoSecretStore),
|
||||
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
|
||||
key_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -121,7 +131,7 @@ impl HostStore {
|
||||
/// Attach the system keychain. `main` does this once at startup; the UI
|
||||
/// tests attach an in-memory store instead.
|
||||
pub fn with_secrets(mut self, secrets: SharedSecretStore) -> Self {
|
||||
self.secrets = secrets;
|
||||
self.secrets = Arc::new(TransientSecretStore::new(secrets));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -176,11 +186,12 @@ impl HostStore {
|
||||
snippet_categories,
|
||||
groups,
|
||||
hosts,
|
||||
transient: Vec::new(),
|
||||
active: None,
|
||||
recent,
|
||||
bookmarks,
|
||||
database: Some(database),
|
||||
secrets: Arc::new(NoSecretStore),
|
||||
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
|
||||
key_dir: None,
|
||||
})
|
||||
}
|
||||
@@ -258,12 +269,81 @@ impl HostStore {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
/// The saved hosts. Never the ones opened from a link.
|
||||
pub fn hosts(&self) -> &[Host] {
|
||||
&self.hosts
|
||||
}
|
||||
|
||||
/// A saved host, or one opened from a link.
|
||||
pub fn host(&self, id: HostId) -> Option<&Host> {
|
||||
self.hosts.iter().find(|s| s.id == id)
|
||||
self.saved_host(id)
|
||||
.or_else(|| self.transient.iter().find(|host| host.id == id))
|
||||
}
|
||||
|
||||
/// A saved host only: what the database's foreign keys may point at.
|
||||
fn saved_host(&self, id: HostId) -> Option<&Host> {
|
||||
self.hosts.iter().find(|host| host.id == id)
|
||||
}
|
||||
|
||||
fn host_mut(&mut self, id: HostId) -> Option<&mut Host> {
|
||||
self.hosts
|
||||
.iter_mut()
|
||||
.chain(self.transient.iter_mut())
|
||||
.find(|host| host.id == id)
|
||||
}
|
||||
|
||||
/// The hosts opened from a link, oldest first. Only the external CLI
|
||||
/// lists them; nothing else that lists hosts does.
|
||||
pub fn transient_hosts(&self) -> &[Host] {
|
||||
&self.transient
|
||||
}
|
||||
|
||||
/// Whether `id` was opened from a link and is not saved.
|
||||
pub fn is_transient(&self, id: HostId) -> bool {
|
||||
self.transient.iter().any(|host| host.id == id)
|
||||
}
|
||||
|
||||
/// Connect to what an `ssh://` link names without saving it: a host the
|
||||
/// tabs find by id, kept in memory until [`Self::remove_transient`],
|
||||
/// with the link's password in memory too.
|
||||
pub fn insert_transient(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
|
||||
let id = self.insert_transient_unnotified(link);
|
||||
cx.notify();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn insert_transient_unnotified(&mut self, link: SshLink) -> HostId {
|
||||
let id = HostId(self.next_host_id);
|
||||
self.next_host_id += 1;
|
||||
let mut host = Host::new(id, link.draft());
|
||||
// What the external CLI knows it by while it lasts.
|
||||
host.public_id = self.unused_public_id();
|
||||
self.transient.push(host);
|
||||
if let Some(password) = link.password {
|
||||
self.secrets.remember(id.0, password);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Forget a host opened from a link, with its password and the
|
||||
/// bookmarks its SFTP tabs made. Its tabs are gone by now.
|
||||
pub fn remove_transient(&mut self, id: HostId, cx: &mut Context<Self>) {
|
||||
if self.remove_transient_unnotified(id) {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_transient_unnotified(&mut self, id: HostId) -> bool {
|
||||
let Some(index) = self.transient.iter().position(|host| host.id == id) else {
|
||||
return false;
|
||||
};
|
||||
self.transient.remove(index);
|
||||
self.secrets.forget(id.0);
|
||||
self.bookmarks.retain(|(host, _), _| *host != id);
|
||||
if self.active == Some(id) {
|
||||
self.active = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn group(&self, id: GroupId) -> Option<&HostGroup> {
|
||||
@@ -368,7 +448,12 @@ impl HostStore {
|
||||
|
||||
/// A [`PublicId`] no host in the store has yet.
|
||||
fn unused_public_id(&self) -> PublicId {
|
||||
PublicId::generate_unused(|id| self.hosts.iter().any(|s| &s.public_id == id))
|
||||
PublicId::generate_unused(|id| {
|
||||
self.hosts
|
||||
.iter()
|
||||
.chain(&self.transient)
|
||||
.any(|host| &host.public_id == id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the editable fields of a host; connection state is kept.
|
||||
@@ -572,7 +657,11 @@ impl HostStore {
|
||||
if !self.add_bookmark_unnotified(id, side, path) {
|
||||
return false;
|
||||
}
|
||||
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
|
||||
// A link's host is not in the database: its bookmarks live as long
|
||||
// as it does.
|
||||
if !self.is_transient(id) {
|
||||
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
@@ -599,7 +688,9 @@ impl HostStore {
|
||||
if !self.remove_bookmark_unnotified(id, side, path) {
|
||||
return false;
|
||||
}
|
||||
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
|
||||
if !self.is_transient(id) {
|
||||
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
@@ -631,9 +722,11 @@ impl HostStore {
|
||||
if !self.move_bookmark_unnotified(id, side, path, to) {
|
||||
return false;
|
||||
}
|
||||
self.persist("调整书签顺序", cx, |db| {
|
||||
db.set_bookmark_order(id, side, self.bookmarks(id, side))
|
||||
});
|
||||
if !self.is_transient(id) {
|
||||
self.persist("调整书签顺序", cx, |db| {
|
||||
db.set_bookmark_order(id, side, self.bookmarks(id, side))
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
@@ -690,7 +783,7 @@ impl HostStore {
|
||||
}
|
||||
|
||||
pub fn insert_forward_unnotified(&mut self, draft: ForwardDraft) -> Option<ForwardId> {
|
||||
self.host(draft.host)?;
|
||||
self.saved_host(draft.host)?;
|
||||
let sort_order = self
|
||||
.forwards
|
||||
.iter()
|
||||
@@ -730,7 +823,7 @@ impl HostStore {
|
||||
}
|
||||
|
||||
pub fn update_forward_unnotified(&mut self, id: ForwardId, draft: ForwardDraft) -> bool {
|
||||
if self.host(draft.host).is_none() {
|
||||
if self.saved_host(draft.host).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(rule) = self.forwards.iter_mut().find(|rule| rule.id == id) else {
|
||||
@@ -782,8 +875,15 @@ impl HostStore {
|
||||
}
|
||||
|
||||
pub fn login_of(&self, host: &Host) -> HostLogin {
|
||||
self.direct_login(host)
|
||||
.with_route(self.route_login(&host.route))
|
||||
let mut login = self
|
||||
.direct_login(host)
|
||||
.with_route(self.route_login(&host.route));
|
||||
// A link's password is the link's own, never a saved host's for
|
||||
// the same `user@host:port`.
|
||||
if self.is_transient(host.id) {
|
||||
login.password = SecretRef::transient(host.id.0);
|
||||
}
|
||||
login
|
||||
}
|
||||
|
||||
/// How `host` logs in once the connection has reached it.
|
||||
@@ -798,7 +898,7 @@ impl HostStore {
|
||||
Route::Direct => LoginRoute::Direct,
|
||||
Route::Jump(hops) => LoginRoute::Jump(
|
||||
hops.iter()
|
||||
.map(|hop| match hop.and_then(|id| self.host(id)) {
|
||||
.map(|hop| match hop.and_then(|id| self.saved_host(id)) {
|
||||
Some(host) => JumpLogin::Host {
|
||||
name: host.name.to_string(),
|
||||
login: Box::new(self.direct_login(host)),
|
||||
@@ -1082,7 +1182,7 @@ impl HostStore {
|
||||
}
|
||||
}
|
||||
HostNode::Host(id) => {
|
||||
if self.host(id).is_none() {
|
||||
if self.saved_host(id).is_none() {
|
||||
return false;
|
||||
}
|
||||
let target = match target {
|
||||
@@ -1115,7 +1215,7 @@ impl HostStore {
|
||||
fn node_parent(&self, node: HostNode) -> Option<Option<GroupId>> {
|
||||
match node {
|
||||
HostNode::Group(id) => self.group(id).map(|g| g.parent),
|
||||
HostNode::Host(id) => self.host(id).map(|s| s.group),
|
||||
HostNode::Host(id) => self.saved_host(id).map(|s| s.group),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,7 +1260,7 @@ impl HostStore {
|
||||
if !self.set_state_unnotified(id, state) {
|
||||
return;
|
||||
}
|
||||
if state.is_connected() {
|
||||
if state.is_connected() && !self.is_transient(id) {
|
||||
self.persist("记录最近连接", cx, |db| {
|
||||
db.touch_connected(id, now_seconds())
|
||||
});
|
||||
@@ -1168,17 +1268,19 @@ impl HostStore {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns whether the state changed. Becoming connected also moves the
|
||||
/// host to the front of the recent list.
|
||||
/// Returns whether the state changed. Becoming connected also moves a
|
||||
/// saved host to the front of the recent list; a link's host never
|
||||
/// joins it.
|
||||
pub fn set_state_unnotified(&mut self, id: HostId, state: ConnectionState) -> bool {
|
||||
let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else {
|
||||
let transient = self.is_transient(id);
|
||||
let Some(host) = self.host_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
if host.state == state {
|
||||
return false;
|
||||
}
|
||||
host.state = state;
|
||||
if state.is_connected() {
|
||||
if state.is_connected() && !transient {
|
||||
self.recent.retain(|recent| *recent != id);
|
||||
self.recent.insert(0, id);
|
||||
self.recent.truncate(MAX_RECENT);
|
||||
@@ -1192,13 +1294,15 @@ impl HostStore {
|
||||
if !self.set_host_os_unnotified(id, os) {
|
||||
return;
|
||||
}
|
||||
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
|
||||
if !self.is_transient(id) {
|
||||
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns whether the recorded system changed.
|
||||
pub fn set_host_os_unnotified(&mut self, id: HostId, os: Option<HostOs>) -> bool {
|
||||
let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else {
|
||||
let Some(host) = self.host_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
if host.os == os {
|
||||
@@ -1300,7 +1404,7 @@ impl HostStore {
|
||||
value: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let secrets = self.secrets.clone();
|
||||
let secrets = self.secrets();
|
||||
let failure = match (&secret, value.is_some()) {
|
||||
(SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串",
|
||||
(SecretRef::Password { .. }, false) => "密码未能从系统钥匙串删除",
|
||||
@@ -1310,6 +1414,8 @@ impl HostStore {
|
||||
(SecretRef::Credential { .. }, false) => "凭据的密码未能从系统钥匙串删除",
|
||||
(SecretRef::Proxy { .. }, true) => "代理的密码未能写入系统钥匙串",
|
||||
(SecretRef::Proxy { .. }, false) => "代理的密码未能从系统钥匙串删除",
|
||||
// Kept in memory, which does not fail.
|
||||
(SecretRef::Transient { .. }, _) => "临时连接的密码未能记下",
|
||||
};
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
@@ -1460,7 +1566,7 @@ impl HostStore {
|
||||
let hops: Vec<_> = hops
|
||||
.into_iter()
|
||||
.filter(|hop| hop.is_none() || *hop != own)
|
||||
.map(|hop| hop.filter(|id| self.host(*id).is_some()))
|
||||
.map(|hop| hop.filter(|id| self.saved_host(*id).is_some()))
|
||||
.collect();
|
||||
if hops.is_empty() {
|
||||
Route::Direct
|
||||
@@ -2453,4 +2559,115 @@ mod tests {
|
||||
);
|
||||
assert_eq!(store.insert_unnotified(draft("new", None)), HostId(10));
|
||||
}
|
||||
|
||||
fn link(text: &str) -> SshLink {
|
||||
SshLink::parse(text, Some("堡垒机")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_host_is_found_by_id_but_never_listed() {
|
||||
let keychain = Arc::new(crate::secrets::InMemorySecretStore::default());
|
||||
let mut store = HostStore::empty().with_secrets(keychain.clone());
|
||||
let saved = store.insert_unnotified(draft("web", None));
|
||||
let id = store.insert_transient_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
|
||||
assert_ne!(id, saved);
|
||||
assert!(store.is_transient(id));
|
||||
assert!(!store.is_transient(saved));
|
||||
assert_eq!(store.hosts().len(), 1);
|
||||
assert_eq!(store.host(id).unwrap().name.as_ref(), "堡垒机");
|
||||
|
||||
// It logs in with the link's password, which only memory holds.
|
||||
let login = store.login(id).unwrap();
|
||||
assert_eq!(
|
||||
(login.host.as_str(), login.port, login.user.as_str()),
|
||||
("10.0.0.9", 2222, "token")
|
||||
);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::transient(id.0));
|
||||
assert_eq!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&login.password)
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
.map(String::as_str),
|
||||
Some("p@ss")
|
||||
);
|
||||
assert!(keychain.is_empty());
|
||||
|
||||
// Connecting is not remembered as a recent connection.
|
||||
assert!(store.set_state_unnotified(id, ConnectionState::Connected));
|
||||
assert_eq!(store.host(id).unwrap().state, ConnectionState::Connected);
|
||||
assert!(store.recent_hosts().next().is_none());
|
||||
assert!(store.set_host_os_unnotified(id, Some(HostOs::Ubuntu)));
|
||||
assert!(store.add_bookmark_unnotified(id, BookmarkSide::Remote, "/tmp"));
|
||||
store.active = Some(id);
|
||||
|
||||
assert!(store.remove_transient_unnotified(id));
|
||||
assert!(!store.remove_transient_unnotified(id));
|
||||
assert!(store.host(id).is_none());
|
||||
assert!(store.active().is_none());
|
||||
assert!(store.bookmarks(id, BookmarkSide::Remote).is_empty());
|
||||
assert!(store.secrets().get(&login.password).unwrap().is_none());
|
||||
// Only memory gave it an id: the next one still moves on.
|
||||
assert!(store.insert_unnotified(draft("db", None)).0 > id.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_host_cannot_lean_on_a_link_host() {
|
||||
let mut store = HostStore::empty();
|
||||
let id = store.insert_transient_unnotified(link("ssh://token@10.0.0.9"));
|
||||
let behind = store.insert_unnotified(
|
||||
HostDraft::new("db", "10.0.0.8", 22, "root", AuthKind::Password, None)
|
||||
.with_route(jump_through(&[id])),
|
||||
);
|
||||
// Saved as a jump host that is not there, like a deleted one.
|
||||
assert_eq!(store.host(behind).unwrap().route, Route::Jump(vec![None]));
|
||||
assert!(
|
||||
store
|
||||
.insert_forward_unnotified(forward_draft(id, 8080))
|
||||
.is_none()
|
||||
);
|
||||
// Nor is it moved into the tree.
|
||||
assert!(!store.move_node_unnotified(HostNode::Host(id), NodeDrop::Root));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn a_link_host_never_reaches_the_database(cx: &mut gpui_kit::TestAppContext) {
|
||||
use gpui_kit::AppContext as _;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let path = data.path().join("shellrs.db");
|
||||
let store = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
||||
let store = cx.new(|_| store);
|
||||
let events = Rc::new(RefCell::new(Vec::new()));
|
||||
let _subscription = cx.update(|cx| {
|
||||
let events = events.clone();
|
||||
cx.subscribe(&store, move |_, event: &HostStoreEvent, _| {
|
||||
events.borrow_mut().push(event.clone());
|
||||
})
|
||||
});
|
||||
|
||||
let id = store.update(cx, |store, cx| {
|
||||
let id = store.insert_transient(link("ssh://token@10.0.0.9"), cx);
|
||||
store.set_state(id, ConnectionState::Connected, cx);
|
||||
store.set_host_os(id, Some(HostOs::Debian), cx);
|
||||
store.add_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
|
||||
store.add_bookmark(id, BookmarkSide::Remote, "/tmp", cx);
|
||||
store.move_bookmark(id, BookmarkSide::Remote, "/tmp", 0, cx);
|
||||
store.remove_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
|
||||
id
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(events.borrow().is_empty(), "{:?}", events.borrow());
|
||||
assert!(store.read_with(cx, |store, _| store.host(id).is_some()));
|
||||
|
||||
let reopened = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
||||
assert!(reopened.hosts().is_empty());
|
||||
assert!(reopened.host(id).is_none());
|
||||
assert!(reopened.recent_hosts().next().is_none());
|
||||
assert!(reopened.bookmarks(id, BookmarkSide::Remote).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ impl HostStore {
|
||||
let gone = match draft.scope {
|
||||
SnippetScope::All => false,
|
||||
SnippetScope::Group(group) => self.group(group).is_none(),
|
||||
SnippetScope::Host(host) => self.host(host).is_none(),
|
||||
SnippetScope::Host(host) => self.saved_host(host).is_none(),
|
||||
};
|
||||
if gone {
|
||||
draft.scope = SnippetScope::All;
|
||||
|
||||
+12
-3
@@ -9,17 +9,23 @@ use shellrs::host::{HostDatabase, HostStore};
|
||||
use shellrs::settings::SettingsStore;
|
||||
|
||||
fn main() {
|
||||
let args = shellrs::cli::command_line_arguments().unwrap_or_default();
|
||||
// `ShellRS ssh://user@host:port` or `ShellRS -url … -newtab 名称`: a
|
||||
// bastion host opening ShellRS the way it opens Xshell. On every build,
|
||||
// a Windows release build above all, where bastion hosts run it.
|
||||
let link = shellrs::cli::link_arguments(&args);
|
||||
// `shellrs list`, `shellrs exec …`: the command, not the app. Not in a
|
||||
// Windows release build, which has nowhere to print: there the command
|
||||
// is `shellrs-cli.exe`, which 设置 → 外部 CLI puts on the PATH.
|
||||
#[cfg(any(not(windows), debug_assertions))]
|
||||
if let Some(args) = shellrs::cli::command_line_arguments() {
|
||||
if link.is_none() && !args.is_empty() {
|
||||
std::process::exit(shellrs::cli::main(args));
|
||||
}
|
||||
// One ShellRS per data directory. Each keeps the hosts in memory and
|
||||
// writes its changes through, so a second one would write over the
|
||||
// first one's; opened again, ShellRS brings the running one forward.
|
||||
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) {
|
||||
// first one's; opened again, ShellRS brings the running one forward
|
||||
// and hands it the link.
|
||||
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path(), link.clone()) {
|
||||
return;
|
||||
}
|
||||
let app = gpui_kit::application().with_assets(shellrs::app::AppAssets);
|
||||
@@ -53,6 +59,9 @@ fn main() {
|
||||
for problem in [problem, settings_problem].into_iter().flatten() {
|
||||
shellrs::workspace::notify_once_open(Notification::error(problem), window, cx);
|
||||
}
|
||||
if let Some(link) = link {
|
||||
shellrs::workspace::open_link_once_open(&workspace, link, window, cx);
|
||||
}
|
||||
cx.new(|cx| Root::new(workspace, window, cx))
|
||||
})
|
||||
.expect("failed to open window");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod keychain;
|
||||
mod memory;
|
||||
mod transient;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -14,6 +15,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
pub use keychain::KeychainSecretStore;
|
||||
pub use memory::{InMemorySecretStore, NoSecretStore};
|
||||
pub use transient::TransientSecretStore;
|
||||
|
||||
/// 钥匙串里的服务名,所有条目共用。改了会让已保存的秘密全部失联。
|
||||
pub const SERVICE: &str = "shellrs";
|
||||
@@ -42,6 +44,10 @@ pub enum SecretRef {
|
||||
host: String,
|
||||
port: u16,
|
||||
},
|
||||
/// 临时连接(堡垒机用 `ssh://` 链接打开的、不保存的主机)链接里带的密码,
|
||||
/// 按这台临时主机的 id 归属。只在 [`TransientSecretStore`] 的内存里,
|
||||
/// 从不进钥匙串。
|
||||
Transient { host: u64 },
|
||||
}
|
||||
|
||||
impl SecretRef {
|
||||
@@ -73,6 +79,10 @@ impl SecretRef {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transient(host: u64) -> Self {
|
||||
Self::Transient { host }
|
||||
}
|
||||
|
||||
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
|
||||
pub fn account(&self) -> String {
|
||||
match self {
|
||||
@@ -80,6 +90,7 @@ impl SecretRef {
|
||||
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
|
||||
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
|
||||
Self::Proxy { user, host, port } => format!("proxy:{user}@{host}:{port}"),
|
||||
Self::Transient { host } => format!("transient:{host}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +157,7 @@ mod tests {
|
||||
SecretRef::passphrase("root@10.0.1.12:22"),
|
||||
SecretRef::credential("root@10.0.1.12:22"),
|
||||
SecretRef::proxy("root", "10.0.1.12", 22),
|
||||
SecretRef::transient(22),
|
||||
]
|
||||
.map(|secret| secret.account());
|
||||
for (ix, account) in accounts.iter().enumerate() {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Result;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{SecretRef, SecretStore, SharedSecretStore};
|
||||
|
||||
/// 钥匙串外面包的一层:临时连接的密码([`SecretRef::Transient`])只记在这层
|
||||
/// 内存里,其余的原样交给里面的钥匙串。
|
||||
///
|
||||
/// `HostStore` 把注入的秘密存储包成这样,连接器读到的也是它,所以终端和 SFTP
|
||||
/// 用得上链接里的密码,而这个密码不会落进钥匙串;没有钥匙串的机器上照样能用。
|
||||
pub struct TransientSecretStore {
|
||||
keychain: SharedSecretStore,
|
||||
passwords: Mutex<HashMap<u64, Zeroizing<String>>>,
|
||||
}
|
||||
|
||||
impl TransientSecretStore {
|
||||
pub fn new(keychain: SharedSecretStore) -> Self {
|
||||
Self {
|
||||
keychain,
|
||||
passwords: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn passwords(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Zeroizing<String>>> {
|
||||
self.passwords
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
/// 记下临时主机 `host` 的密码。只碰内存,可以在 UI 线程上调用。
|
||||
pub fn remember(&self, host: u64, password: Zeroizing<String>) {
|
||||
self.passwords().insert(host, password);
|
||||
}
|
||||
|
||||
/// 忘掉临时主机 `host` 的密码。只碰内存。
|
||||
pub fn forget(&self, host: u64) {
|
||||
self.passwords().remove(&host);
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for TransientSecretStore {
|
||||
fn get(&self, secret: &SecretRef) -> Result<Option<Zeroizing<String>>> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => Ok(self.passwords().get(host).cloned()),
|
||||
_ => self.keychain.get(secret),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, secret: &SecretRef, value: &str) -> Result<()> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => {
|
||||
self.remember(*host, Zeroizing::new(value.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
_ => self.keychain.set(secret, value),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, secret: &SecretRef) -> Result<()> {
|
||||
match secret {
|
||||
SecretRef::Transient { host } => {
|
||||
self.forget(*host);
|
||||
Ok(())
|
||||
}
|
||||
_ => self.keychain.delete(secret),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
self.keychain.is_available()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::secrets::{InMemorySecretStore, NoSecretStore};
|
||||
|
||||
#[test]
|
||||
fn link_passwords_stay_in_memory_and_the_rest_reaches_the_keychain() {
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
let store = TransientSecretStore::new(keychain.clone());
|
||||
let link = SecretRef::transient(7);
|
||||
store.remember(7, Zeroizing::new("token".into()));
|
||||
assert_eq!(
|
||||
store.get(&link).unwrap().as_deref().map(String::as_str),
|
||||
Some("token")
|
||||
);
|
||||
assert!(keychain.is_empty());
|
||||
|
||||
let saved = SecretRef::password("root", "10.0.1.12", 22);
|
||||
store.set(&saved, "hunter2").unwrap();
|
||||
assert_eq!(keychain.len(), 1);
|
||||
assert_eq!(
|
||||
store.get(&saved).unwrap().as_deref().map(String::as_str),
|
||||
Some("hunter2")
|
||||
);
|
||||
// Another host's password is not the link's.
|
||||
assert!(store.get(&SecretRef::transient(8)).unwrap().is_none());
|
||||
|
||||
store.forget(7);
|
||||
assert!(store.get(&link).unwrap().is_none());
|
||||
assert_eq!(keychain.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_keychain_link_passwords_still_work() {
|
||||
let store = TransientSecretStore::new(Arc::new(NoSecretStore));
|
||||
assert!(!store.is_available());
|
||||
store.set(&SecretRef::transient(1), "token").unwrap();
|
||||
assert!(store.get(&SecretRef::transient(1)).unwrap().is_some());
|
||||
assert!(
|
||||
store
|
||||
.set(&SecretRef::password("root", "h", 22), "x")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -627,7 +627,10 @@ fn skill_actions_item(installable: bool) -> SettingItem {
|
||||
|
||||
/// One row per agent, each with where its skill goes and whether it is
|
||||
/// there.
|
||||
fn skills_item(files: Option<[String; 4]>, status: Option<IntegrationStatus>) -> SettingItem {
|
||||
fn skills_item(
|
||||
files: Option<[String; AgentKind::ALL.len()]>,
|
||||
status: Option<IntegrationStatus>,
|
||||
) -> SettingItem {
|
||||
SettingItem::render(move |_, _, cx| {
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -641,7 +644,7 @@ fn skills_item(files: Option<[String; 4]>, status: Option<IntegrationStatus>) ->
|
||||
.child(skill_row(*agent, file, skill, cx))
|
||||
}))
|
||||
})
|
||||
.keywords(["skill", "Agent", "Codex", "Claude", "OpenCode"])
|
||||
.keywords(["skill", "Agent", "Codex", "Claude", "OpenCode", "WorkBuddy"])
|
||||
}
|
||||
|
||||
fn skill_row(
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
host::HostLogin,
|
||||
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts},
|
||||
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts, describe_login_error},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_channel::{Receiver, Sender};
|
||||
@@ -261,7 +261,7 @@ impl SshSftpTransport {
|
||||
Ok(connected) => *client.write().await = Some(connected),
|
||||
Err(error) => {
|
||||
let _ = events
|
||||
.send(SftpEvent::Disconnected(error.to_string()))
|
||||
.send(SftpEvent::Disconnected(describe_login_error(&error)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ impl SshSftpTransport {
|
||||
Ok(connected) => *client.write().await = Some(connected),
|
||||
Err(error) => {
|
||||
events
|
||||
.send(SftpEvent::Disconnected(error.to_string()))
|
||||
.send(SftpEvent::Disconnected(describe_login_error(&error)))
|
||||
.await?;
|
||||
events.send(SftpEvent::Idle).await?;
|
||||
busy.store(false, Ordering::Release);
|
||||
|
||||
@@ -314,11 +314,50 @@ trait TunnelStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> TunnelStream for T {}
|
||||
|
||||
/// The key exchanges ShellRS offers, in OpenSSH's order: russh's own list
|
||||
/// leaves out the NIST curves, which servers without curve25519 (Apache
|
||||
/// SSHD, as many bastion hosts run) settle on with OpenSSH.
|
||||
const KEX_ORDER: &[russh::kex::Name] = &[
|
||||
russh::kex::MLKEM768X25519_SHA256,
|
||||
russh::kex::CURVE25519,
|
||||
russh::kex::CURVE25519_PRE_RFC_8731,
|
||||
russh::kex::ECDH_SHA2_NISTP256,
|
||||
russh::kex::ECDH_SHA2_NISTP384,
|
||||
russh::kex::ECDH_SHA2_NISTP521,
|
||||
russh::kex::DH_GEX_SHA256,
|
||||
russh::kex::DH_G16_SHA512,
|
||||
russh::kex::DH_G18_SHA512,
|
||||
russh::kex::DH_G17_SHA512,
|
||||
russh::kex::DH_G15_SHA512,
|
||||
russh::kex::DH_G14_SHA256,
|
||||
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
|
||||
russh::kex::EXTENSION_SUPPORT_AS_SERVER,
|
||||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
|
||||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
|
||||
];
|
||||
|
||||
/// The smallest group a group exchange accepts, as OpenSSH's: russh asks
|
||||
/// for 3072 bits, which Apache SSHD on an older Java cannot give (its
|
||||
/// largest is 2048) and so ends the connection.
|
||||
const MIN_DH_GROUP_BITS: usize = 2048;
|
||||
|
||||
/// Connects wherever OpenSSH with its default settings does.
|
||||
fn ssh_config() -> Arc<client::Config> {
|
||||
let defaults = client::GexParams::default();
|
||||
Arc::new(client::Config {
|
||||
keepalive_interval: Some(KEEPALIVE_INTERVAL),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
preferred: russh::Preferred {
|
||||
kex: KEX_ORDER.into(),
|
||||
..russh::Preferred::default()
|
||||
},
|
||||
gex: client::GexParams::new(
|
||||
MIN_DH_GROUP_BITS,
|
||||
defaults.preferred_group_size(),
|
||||
defaults.max_group_size(),
|
||||
)
|
||||
.expect("2048 bits is the smallest group russh allows"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Loopback-only tests of reaching a host through jump hosts and proxies,
|
||||
//! against in-process SSH servers and proxies. No user hosts, no keychain.
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, OnceLock, Weak},
|
||||
time::Duration,
|
||||
@@ -49,6 +50,9 @@ struct Accepting {
|
||||
user: &'static str,
|
||||
password: &'static str,
|
||||
state: Arc<Mutex<RouteState>>,
|
||||
/// Groups for group exchange no larger than 2048 bits, as Apache SSHD
|
||||
/// has on an older Java: asked for larger, it has none to give.
|
||||
small_groups: bool,
|
||||
}
|
||||
|
||||
struct RouteHandler {
|
||||
@@ -73,6 +77,25 @@ impl server::Server for Accepting {
|
||||
impl server::Handler for RouteHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn lookup_dh_gex_group(
|
||||
&mut self,
|
||||
asked: &russh::client::GexParams,
|
||||
) -> Result<Option<russh::kex::dh::groups::DhGroup>> {
|
||||
use russh::kex::dh::groups::{DH_GROUP14, DH_GROUP16};
|
||||
let groups: &[_] = if self.accepting.small_groups {
|
||||
&[DH_GROUP14]
|
||||
} else {
|
||||
&[DH_GROUP14, DH_GROUP16]
|
||||
};
|
||||
Ok(groups
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|group| {
|
||||
(asked.min_group_size()..=asked.max_group_size()).contains(&group.bit_size())
|
||||
})
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<server::Auth> {
|
||||
Ok(
|
||||
if user == self.accepting.user && password == self.accepting.password {
|
||||
@@ -134,6 +157,15 @@ fn listen(runtime: &tokio::runtime::Runtime) -> Option<TcpListener> {
|
||||
}
|
||||
|
||||
fn start_server(user: &'static str, password: &'static str) -> Option<RouteServer> {
|
||||
start_server_offering(user, password, None)
|
||||
}
|
||||
|
||||
/// A server that only knows the key exchanges `kex`, when given.
|
||||
fn start_server_offering(
|
||||
user: &'static str,
|
||||
password: &'static str,
|
||||
kex: Option<&'static [russh::kex::Name]>,
|
||||
) -> Option<RouteServer> {
|
||||
let runtime = runtime();
|
||||
let listener = listen(&runtime)?;
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
@@ -142,6 +174,7 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
|
||||
user,
|
||||
password,
|
||||
state: state.clone(),
|
||||
small_groups: kex.is_some(),
|
||||
};
|
||||
let mut rng = russh::keys::key::safe_rng();
|
||||
let key = russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
|
||||
@@ -150,6 +183,10 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
|
||||
auth_rejection_time: Duration::ZERO,
|
||||
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||
keys: vec![key],
|
||||
preferred: russh::Preferred {
|
||||
kex: kex.map_or(russh::Preferred::DEFAULT.kex, Cow::Borrowed),
|
||||
..russh::Preferred::default()
|
||||
},
|
||||
..server::Config::default()
|
||||
});
|
||||
runtime.spawn(async move {
|
||||
@@ -604,6 +641,37 @@ fn a_host_behind_an_http_proxy_is_reached_through_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// What Apache SSHD on an older Java offers, as the user's bastion host
|
||||
/// does: group exchange whose largest group is 2048 bits, or NIST curves.
|
||||
/// OpenSSH reaches both; ShellRS must too.
|
||||
#[test]
|
||||
fn a_server_with_only_2048_bit_groups_or_nist_curves_is_reached() {
|
||||
const GROUP_EXCHANGE: &[russh::kex::Name] = &[russh::kex::DH_GEX_SHA256];
|
||||
const NIST_CURVES: &[russh::kex::Name] = &[
|
||||
russh::kex::ECDH_SHA2_NISTP521,
|
||||
russh::kex::ECDH_SHA2_NISTP384,
|
||||
russh::kex::ECDH_SHA2_NISTP256,
|
||||
];
|
||||
for kex in [GROUP_EXCHANGE, NIST_CURVES] {
|
||||
let Some(server) = start_server_offering(TARGET_USER, TARGET_PASSWORD, Some(kex)) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let request = LoginTest::typed(manual("127.0.0.1", server.port, TARGET_USER))
|
||||
.with_password(TARGET_PASSWORD);
|
||||
assert_eq!(
|
||||
test_login(
|
||||
&directory.path().join("known_hosts"),
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
request
|
||||
),
|
||||
Ok(()),
|
||||
"{kex:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback port nothing listens on.
|
||||
fn closed_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
|
||||
+42
-3
@@ -156,8 +156,9 @@ fn describe_test_failure(error: &anyhow::Error, host_trust: HostTrust) -> String
|
||||
}
|
||||
|
||||
/// Why a login failed, from the error alone: where on the way to the host
|
||||
/// it failed, a credential that was missing, the network's own answer, or
|
||||
/// whatever the error says.
|
||||
/// it failed, a credential that was missing, the network's own answer, the
|
||||
/// algorithms the server would not agree on, or whatever the error says,
|
||||
/// down to its cause: 「无法建立 SSH 连接」 alone does not say what to fix.
|
||||
pub fn describe_login_error(error: &anyhow::Error) -> String {
|
||||
if let Some(failure) = error.downcast_ref::<RouteFailure>() {
|
||||
return failure.to_string();
|
||||
@@ -173,10 +174,27 @@ pub fn describe_login_error(error: &anyhow::Error) -> String {
|
||||
// russh wraps the socket's own error rather than chaining it.
|
||||
Some(russh::Error::IO(io)) => return describe_connect_error(io),
|
||||
Some(russh::Error::ConnectionTimeout) => return "连接超时".into(),
|
||||
Some(russh::Error::NoCommonAlgo { kind, theirs, .. }) => {
|
||||
return format!(
|
||||
"无法建立 SSH 连接:和服务器没有共同的{}算法,服务器支持的是 {}",
|
||||
algorithm_kind(kind),
|
||||
theirs.join("、")
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
error.to_string()
|
||||
format!("{error:#}")
|
||||
}
|
||||
|
||||
fn algorithm_kind(kind: &russh::AlgorithmKind) -> &'static str {
|
||||
match kind {
|
||||
russh::AlgorithmKind::Kex => "密钥交换",
|
||||
russh::AlgorithmKind::Key => "主机密钥",
|
||||
russh::AlgorithmKind::Cipher => "加密",
|
||||
russh::AlgorithmKind::Mac => "消息校验(MAC)",
|
||||
russh::AlgorithmKind::Compression => "压缩",
|
||||
}
|
||||
}
|
||||
|
||||
/// A connect error in plain words. The common kinds get an explanation;
|
||||
@@ -321,5 +339,26 @@ mod tests {
|
||||
reason(anyhow!("服务器未接受指定的私钥")),
|
||||
"服务器未接受指定的私钥"
|
||||
);
|
||||
// With what lies under it, which says what to fix.
|
||||
let kex = anyhow::Error::from(russh::Error::Kex)
|
||||
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
|
||||
assert_eq!(
|
||||
reason(kex),
|
||||
"无法建立 SSH 连接,请检查地址、端口和主机密钥: Key exchange failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_of_other_algorithms_says_which_it_has() {
|
||||
let mac = anyhow::Error::from(russh::Error::NoCommonAlgo {
|
||||
kind: russh::AlgorithmKind::Mac,
|
||||
ours: vec!["hmac-sha2-256".into()],
|
||||
theirs: vec!["hmac-sha1".into(), "hmac-md5".into()],
|
||||
})
|
||||
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
|
||||
assert_eq!(
|
||||
reason(mac),
|
||||
"无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 hmac-sha1、hmac-md5"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts},
|
||||
latency::{LATENCY_INTERVAL, round_trip},
|
||||
probe::{HostOsProbe, ProbeOutcome},
|
||||
tester::describe_login_error,
|
||||
};
|
||||
use crate::{
|
||||
connection::Latency,
|
||||
@@ -147,7 +148,12 @@ impl SshTerminalTransport {
|
||||
}
|
||||
});
|
||||
|
||||
let (handle, _) = self.connector.connect(&self.config, broker.clone()).await?;
|
||||
// Said in the terminal: down to the cause, which says what to fix.
|
||||
let (handle, _) = self
|
||||
.connector
|
||||
.connect(&self.config, broker.clone())
|
||||
.await
|
||||
.map_err(|error| anyhow!(describe_login_error(&error)))?;
|
||||
let mut shutdown = broker.shutdown_receiver();
|
||||
let mut channel = tokio::select! {
|
||||
result = handle.channel_open_session() => {
|
||||
|
||||
@@ -122,14 +122,14 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
|
||||
let store = self.store.read(cx);
|
||||
TabMenu {
|
||||
id: self.id,
|
||||
host_id: self.host_id,
|
||||
host_is_ip: self
|
||||
.store
|
||||
.read(cx)
|
||||
host_is_ip: store
|
||||
.host(self.host_id)
|
||||
.is_some_and(|host| host.address_is_ip()),
|
||||
editable: !store.is_transient(self.host_id),
|
||||
group: self.tab_group.clone(),
|
||||
panel: cx.entity_id(),
|
||||
}
|
||||
@@ -317,6 +317,8 @@ struct TabMenu {
|
||||
id: RemoteTerminalId,
|
||||
host_id: HostId,
|
||||
host_is_ip: bool,
|
||||
/// A saved host; one opened from a link has nothing to edit.
|
||||
editable: bool,
|
||||
group: Option<WeakEntity<TabGroup>>,
|
||||
panel: EntityId,
|
||||
}
|
||||
@@ -350,7 +352,7 @@ impl TabMenu {
|
||||
} else {
|
||||
"复制主机名"
|
||||
};
|
||||
let menu = menu
|
||||
let mut menu = menu
|
||||
.menu_with_icon(
|
||||
"重命名标签…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
@@ -375,13 +377,15 @@ impl TabMenu {
|
||||
"重新连接",
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
Box::new(ReconnectTerminal(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
);
|
||||
if self.editable {
|
||||
menu = menu.menu_with_icon(
|
||||
"编辑主机…",
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(EditHost(host_id)),
|
||||
)
|
||||
.separator();
|
||||
);
|
||||
}
|
||||
let menu = menu.separator();
|
||||
close_tab_items(
|
||||
menu,
|
||||
CenterTab::Terminal(id),
|
||||
|
||||
@@ -27,6 +27,9 @@ pub const MANIFEST_URL: &str = "https://dl.shellrs.com/update/v1/{channel}.json"
|
||||
/// Where a person downloads ShellRS by hand.
|
||||
pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download";
|
||||
|
||||
/// What each version changed.
|
||||
pub const CHANGELOG_PAGE: &str = "https://shellrs.com/changelog";
|
||||
|
||||
/// The minisign public keys a manifest may be signed with: the key in use,
|
||||
/// and a spare kept offline so the first can be replaced. Rotating means
|
||||
/// shipping a release that knows the new key before signing with it.
|
||||
|
||||
+35
-44
@@ -1,5 +1,6 @@
|
||||
//! The update dialog: what the newer ShellRS brings, how far its download
|
||||
//! is, and restarting into it.
|
||||
//! The update dialog: how far the newer ShellRS's download is, and
|
||||
//! restarting into it. What it changes is not listed here: a button opens
|
||||
//! the website's changelog.
|
||||
//!
|
||||
//! Opened from the title bar button and from 设置 › 关于. What a restart
|
||||
//! interrupts is written here, above the button, so restarting needs no
|
||||
@@ -10,23 +11,20 @@ use std::rc::Rc;
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Sizable as _, WindowExt as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
dialog::{DialogClose, DialogFooter},
|
||||
dialog::DialogFooter,
|
||||
progress::Progress,
|
||||
text::TextView,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{DownloadUpdate, OpenDownloadPage, RestartToUpdate};
|
||||
use crate::app::{CatalogIcon, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate};
|
||||
|
||||
use super::status::{RestartImpact, UpdateStep, percent, restart_note};
|
||||
use super::updater::{Phase, Stage, UpdateSnapshot, Updater};
|
||||
|
||||
/// The dialog's width, in rems.
|
||||
const DIALOG_WIDTH: f32 = 32.;
|
||||
/// The notes scroll beyond this height, in rems.
|
||||
const NOTES_HEIGHT: f32 = 18.;
|
||||
|
||||
/// Counts what a restart would interrupt, at the time the dialog draws.
|
||||
pub type ImpactCounter = Rc<dyn Fn(&App) -> RestartImpact>;
|
||||
@@ -54,46 +52,31 @@ impl Render for UpdateDialog {
|
||||
let snapshot = self.updater.read(cx).snapshot();
|
||||
let impact = (self.impact)(cx);
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let release = snapshot.release.clone();
|
||||
let versions = match snapshot
|
||||
.release
|
||||
.as_ref()
|
||||
.and_then(|release| release.published_on())
|
||||
{
|
||||
Some(day) => format!("当前版本 {} · 新版本发布于 {day}", snapshot.current),
|
||||
None => format!("当前版本 {}", snapshot.current),
|
||||
};
|
||||
v_flex()
|
||||
.id("update-dialog")
|
||||
.test_support()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(muted)
|
||||
.child(match (&release, &snapshot.manual) {
|
||||
(Some(release), _) => match release.published_on() {
|
||||
Some(day) => format!("当前版本 {} · 发布于 {day}", snapshot.current),
|
||||
None => format!("当前版本 {}", snapshot.current),
|
||||
},
|
||||
_ => format!("当前版本 {}", snapshot.current),
|
||||
}),
|
||||
)
|
||||
.children(
|
||||
release
|
||||
.as_ref()
|
||||
.filter(|release| !release.notes.trim().is_empty())
|
||||
.map(|release| {
|
||||
div()
|
||||
.id("update-notes")
|
||||
.test_support()
|
||||
.aria_label(release.notes.clone())
|
||||
.max_h(rems(NOTES_HEIGHT))
|
||||
.overflow_y_scroll()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.p_3()
|
||||
.child(
|
||||
TextView::markdown(
|
||||
SharedString::from(format!("update-notes-{}", release.version)),
|
||||
release.notes.clone(),
|
||||
)
|
||||
.selectable(true),
|
||||
)
|
||||
}),
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.when(snapshot.phase == Phase::Ready, |lines| {
|
||||
lines.child(
|
||||
div()
|
||||
.id("update-ready")
|
||||
.test_support()
|
||||
.child("新版本已下载,重启 ShellRS 即可完成更新。"),
|
||||
)
|
||||
})
|
||||
.child(div().text_color(muted).child(versions)),
|
||||
)
|
||||
.when_some(progress(&snapshot), |body, (label, value)| {
|
||||
body.child(
|
||||
@@ -193,7 +176,8 @@ pub fn open_update_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// 稍后, and the one step that applies now.
|
||||
/// Where to read what changed, and the one step that applies now. The
|
||||
/// dialog closes by its close button or Escape.
|
||||
fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
|
||||
let action: Option<(&'static str, &'static str, Box<dyn Action>)> =
|
||||
snapshot.step().map(|step| -> (_, _, Box<dyn Action>) {
|
||||
@@ -213,9 +197,16 @@ fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
|
||||
),
|
||||
}
|
||||
});
|
||||
let close = if action.is_some() { "稍后" } else { "关闭" };
|
||||
let changelog = dispatch.clone();
|
||||
DialogFooter::new()
|
||||
.child(DialogClose::new().trigger(move |button| button.label(close)))
|
||||
.child(
|
||||
Button::new("open-changelog")
|
||||
.icon(CatalogIcon::ExternalLink)
|
||||
.label("查看更新内容")
|
||||
.on_click(move |_, window, cx| {
|
||||
changelog.dispatch_action(&OpenChangelog, window, cx);
|
||||
}),
|
||||
)
|
||||
.children(action.map(|(id, label, action)| {
|
||||
let dispatch = dispatch.clone();
|
||||
Button::new(id)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 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.
|
||||
|
||||
use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::cli::OpenLink;
|
||||
use crate::host::{HostId, SshLink};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
|
||||
impl Workspace {
|
||||
/// Connect to what `link` names in a terminal tab of its own, or say
|
||||
/// what is wrong with it.
|
||||
pub fn open_link(&mut self, link: OpenLink, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match SshLink::parse(&link.url, link.tab.as_deref()) {
|
||||
Ok(link) => {
|
||||
let host = self
|
||||
.store
|
||||
.update(cx, |store, cx| store.insert_transient(link, cx));
|
||||
self.connect_host(host, window, cx);
|
||||
}
|
||||
Err(problem) => window
|
||||
.push_notification(Notification::error(problem).title("无法打开 SSH 链接"), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a host opened from a link once its last tab has closed,
|
||||
/// with whatever it was still asking.
|
||||
pub(super) fn forget_unused_link_host(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.store.read(cx).is_transient(host) || self.has_tabs(host, cx) {
|
||||
return;
|
||||
}
|
||||
self.cancel_prompts_for_host(host, window, cx);
|
||||
self.store
|
||||
.update(cx, |store, cx| store.remove_transient(host, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the link ShellRS was started with, once the window is there: an
|
||||
/// error notification needs the window's `Root`, which is only in place
|
||||
/// after the closure building the window returns (see
|
||||
/// [`super::notify_once_open`]).
|
||||
pub fn open_link_once_open(
|
||||
workspace: &Entity<Workspace>,
|
||||
link: OpenLink,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let workspace = workspace.downgrade();
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| workspace.open_link(link, window, cx))
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
mod credentials;
|
||||
mod dock_skin;
|
||||
mod forwards;
|
||||
mod links;
|
||||
mod recent_hosts;
|
||||
mod sidebar;
|
||||
mod snippets;
|
||||
@@ -15,4 +16,5 @@ mod tools;
|
||||
mod updates;
|
||||
mod workspace_view;
|
||||
|
||||
pub use links::open_link_once_open;
|
||||
pub use workspace_view::{Workspace, notify_once_open, window_options};
|
||||
+18
-19
@@ -1,6 +1,5 @@
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, IconName, Selectable as _, Sizable as _, TitleBar,
|
||||
badge::Badge,
|
||||
button::{Button, ButtonGroup, ButtonVariants as _},
|
||||
h_flex,
|
||||
};
|
||||
@@ -11,6 +10,7 @@ use crate::app::{
|
||||
CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate,
|
||||
ToggleHostPanel, ToggleTheme,
|
||||
};
|
||||
use crate::shared::tinted;
|
||||
use crate::update::UpdateBadge;
|
||||
|
||||
use super::sidebar::SidebarMode;
|
||||
@@ -146,25 +146,24 @@ pub fn render_title_bar(
|
||||
}),
|
||||
)
|
||||
.children(update.map(|update| {
|
||||
// A dot on the icon, in the colour of what it says.
|
||||
Badge::new()
|
||||
.dot()
|
||||
.color(if update.trouble {
|
||||
cx.theme().warning
|
||||
} else {
|
||||
cx.theme().success
|
||||
// The one coloured button up here, in the colour of
|
||||
// what it says: the only sign that an update waits.
|
||||
Button::new("update-available")
|
||||
.custom(tinted(
|
||||
if update.trouble {
|
||||
cx.theme().warning
|
||||
} else {
|
||||
cx.theme().success
|
||||
},
|
||||
cx,
|
||||
))
|
||||
.small()
|
||||
.icon(CatalogIcon::CircleArrowUp)
|
||||
.tooltip(update.label.clone())
|
||||
.accessibility_label(update.label)
|
||||
.on_click(move |_, window, cx| {
|
||||
show_update.dispatch_action(&ShowUpdate, window, cx)
|
||||
})
|
||||
.child(
|
||||
Button::new("update-available")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(CatalogIcon::CircleArrowUp)
|
||||
.tooltip(update.label.clone())
|
||||
.accessibility_label(update.label)
|
||||
.on_click(move |_, window, cx| {
|
||||
show_update.dispatch_action(&ShowUpdate, window, cx)
|
||||
}),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
Button::new("theme-toggle")
|
||||
|
||||
@@ -6,9 +6,12 @@ use std::rc::Rc;
|
||||
use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{CheckForUpdates, DownloadUpdate, OpenDownloadPage, RestartToUpdate, ShowUpdate};
|
||||
use crate::app::{
|
||||
CheckForUpdates, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate, ShowUpdate,
|
||||
};
|
||||
use crate::host::ConnectionState;
|
||||
use crate::terminal::TerminalLifecycle;
|
||||
use crate::update::build_info::CHANGELOG_PAGE;
|
||||
use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
@@ -76,6 +79,16 @@ impl Workspace {
|
||||
cx.open_url(&page);
|
||||
}
|
||||
|
||||
/// The whole changelog, not one version's section.
|
||||
pub(super) fn on_open_changelog(
|
||||
&mut self,
|
||||
_: &OpenChangelog,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.open_url(CHANGELOG_PAGE);
|
||||
}
|
||||
|
||||
/// The title bar shows the update button only in some phases; the
|
||||
/// version after an update is said once the new one runs.
|
||||
pub(super) fn on_updater_event(
|
||||
|
||||
@@ -259,12 +259,16 @@ impl Workspace {
|
||||
loop {
|
||||
cx.background_executor().timer(ACTIVATION_POLL).await;
|
||||
let open = this.update_in(cx, |this, window, cx| {
|
||||
let asked = this
|
||||
let Some(links) = this
|
||||
.cli_server
|
||||
.as_ref()
|
||||
.is_some_and(|server| server.take_activation());
|
||||
if asked {
|
||||
crate::app::bring_forward(window, cx);
|
||||
.and_then(|server| server.take_activation())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
crate::app::bring_forward(window, cx);
|
||||
for link in links {
|
||||
this.open_link(link, window, cx);
|
||||
}
|
||||
});
|
||||
if open.is_err() {
|
||||
@@ -563,7 +567,7 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// Whether a host has any tab open in the center.
|
||||
fn has_tabs(&self, host: HostId, cx: &App) -> bool {
|
||||
pub(super) fn has_tabs(&self, host: HostId, cx: &App) -> bool {
|
||||
self.terminals_of(host, cx).next().is_some() || !self.explorers_of(host, cx).is_empty()
|
||||
}
|
||||
|
||||
@@ -931,7 +935,7 @@ impl Workspace {
|
||||
) {
|
||||
self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx);
|
||||
}
|
||||
fn cancel_prompts_for_host(
|
||||
pub(super) fn cancel_prompts_for_host(
|
||||
&mut self,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
@@ -1002,7 +1006,12 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_host(&mut self, host_id: HostId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(super) fn connect_host(
|
||||
&mut self,
|
||||
host_id: HostId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.store.read(cx).host(host_id).is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -1666,7 +1675,15 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.store.read(cx).host(action.0).is_some() {
|
||||
// A host opened from a link is not saved, so there is nothing to
|
||||
// edit.
|
||||
if self
|
||||
.store
|
||||
.read(cx)
|
||||
.hosts()
|
||||
.iter()
|
||||
.any(|host| host.id == action.0)
|
||||
{
|
||||
open_host_dialog(
|
||||
Some(action.0),
|
||||
None,
|
||||
@@ -1705,7 +1722,15 @@ impl Workspace {
|
||||
|
||||
fn on_delete_host(&mut self, action: &DeleteHost, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = action.0;
|
||||
let Some(host) = self.store.read(cx).host(id).cloned() else {
|
||||
// Saved hosts only: one opened from a link goes with its tabs.
|
||||
let Some(host) = self
|
||||
.store
|
||||
.read(cx)
|
||||
.hosts()
|
||||
.iter()
|
||||
.find(|host| host.id == id)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let workspace = cx.entity().downgrade();
|
||||
@@ -1975,6 +2000,7 @@ fn new_terminal_panel(
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_active(None, cx));
|
||||
}
|
||||
this.forget_unused_link_host(*host_id, window, cx);
|
||||
}
|
||||
TerminalPanelEvent::StatusChanged(_, host_id) => {
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
@@ -2084,6 +2110,7 @@ fn new_explorer_panel(
|
||||
this.set_active_tab(None, window, cx);
|
||||
}
|
||||
this.refresh_host_connection_state(*host_id, cx);
|
||||
this.forget_unused_link_host(*host_id, window, cx);
|
||||
}
|
||||
ExplorerPanelEvent::StateChanged(id, host_id) => {
|
||||
if let Some(panel) = this.explorers.get(id) {
|
||||
@@ -2215,6 +2242,7 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_show_update))
|
||||
.on_action(cx.listener(Self::on_restart_to_update))
|
||||
.on_action(cx.listener(Self::on_open_download_page))
|
||||
.on_action(cx.listener(Self::on_open_changelog))
|
||||
.on_action(cx.listener(Self::on_copy_agent_skill))
|
||||
.on_action(cx.listener(Self::on_close_settings))
|
||||
.on_action(cx.listener(Self::on_close_active_tab))
|
||||
|
||||
+65
-1
@@ -2,17 +2,20 @@
|
||||
//! with piped stdio, finding the app through `SHELLRS_DATA_DIR`. A fake
|
||||
//! backend stands in for SSH, so this runs on every platform, including the
|
||||
//! Windows CI where it is the one test of the named pipe between processes.
|
||||
//! Also ShellRS itself opened with a link, as a bastion host opens it.
|
||||
|
||||
use std::{
|
||||
io::{self, Write as _},
|
||||
path::Path,
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use shellrs::app::cli_endpoint;
|
||||
use shellrs::cli::{
|
||||
CliBackend, CliError, CliServer, CliTarget, HostInfo, TransferCounters, TransferSummary,
|
||||
CliBackend, CliError, CliServer, CliTarget, HostInfo, OpenLink, TransferCounters,
|
||||
TransferSummary,
|
||||
};
|
||||
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
|
||||
use shellrs::ssh::ExecStream;
|
||||
@@ -144,3 +147,64 @@ fn the_command_finds_the_app_through_its_data_directory() {
|
||||
.starts_with("shellrs: [not_enabled]")
|
||||
);
|
||||
}
|
||||
|
||||
/// Open ShellRS itself, as a bastion host does, beside the app whose data
|
||||
/// lives in `data_dir`; its exit code. Handing its link over, it exits at
|
||||
/// once. One that does not is killed rather than left running as a second
|
||||
/// app with a window of its own.
|
||||
fn open_shellrs(data_dir: &Path, args: &[&str]) -> Option<i32> {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_shellrs"))
|
||||
.args(args)
|
||||
.env("SHELLRS_DATA_DIR", data_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
return status.code();
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("ShellRS opened with {args:?} did not hand its link over");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let server = CliServer::start(
|
||||
cli_endpoint(data_dir.path()),
|
||||
Arc::new(FakeBackend::default()),
|
||||
)
|
||||
.unwrap();
|
||||
// 启用外部 CLI is off: being opened again is not the external CLI.
|
||||
let xshell = "ssh://deploy:p%40ss@10.0.0.9:2222";
|
||||
let token = "ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024";
|
||||
assert_eq!(
|
||||
open_shellrs(
|
||||
data_dir.path(),
|
||||
&["-url", xshell, "-newtab", "跳板机", "-newwin"]
|
||||
),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(open_shellrs(data_dir.path(), &[token]), Some(0));
|
||||
assert_eq!(
|
||||
server.take_activation(),
|
||||
Some(vec![
|
||||
OpenLink {
|
||||
url: xshell.into(),
|
||||
tab: Some("跳板机".into()),
|
||||
},
|
||||
OpenLink {
|
||||
url: token.into(),
|
||||
tab: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -553,3 +553,171 @@ async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestApp
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The first terminal and the first host of an empty store, which a link
|
||||
/// opens.
|
||||
const LINK_TERMINAL: u64 = 1;
|
||||
const LINK_HOST: HostId = HostId(1);
|
||||
|
||||
fn open_link(
|
||||
cx: &mut TestAppContext,
|
||||
handle: WindowHandle<Root>,
|
||||
workspace: &Entity<Workspace>,
|
||||
url: &str,
|
||||
tab: Option<&str>,
|
||||
) {
|
||||
let link = shellrs::cli::OpenLink {
|
||||
url: url.into(),
|
||||
tab: tab.map(str::to_string),
|
||||
};
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
workspace.update(cx, |workspace, cx| workspace.open_link(link, window, cx));
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut TestAppContext) {
|
||||
use shellrs::app::{CloseExplorer, CloseTerminal};
|
||||
use shellrs::host::BookmarkSide;
|
||||
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let path = data.path().join("shellrs.db");
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
let store = HostStore::load(HostDatabase::open(&path).unwrap())
|
||||
.unwrap()
|
||||
.with_secrets(keychain.clone());
|
||||
let remote = Arc::new(RecordingRemoteProvider::default());
|
||||
let (handle, workspace) = open_workspace_with_credentials(
|
||||
cx,
|
||||
store,
|
||||
remote.clone(),
|
||||
Arc::new(FakeConnectionTester::default()),
|
||||
);
|
||||
|
||||
open_link(
|
||||
cx,
|
||||
handle,
|
||||
&workspace,
|
||||
"ssh://token:p%40ss@10.0.0.9:2222",
|
||||
Some("db-prod"),
|
||||
);
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.find("status-connection").label() == Some("已连接 db-prod")
|
||||
})
|
||||
.await;
|
||||
|
||||
let login = remote.logins().pop().expect("connected");
|
||||
assert_eq!(
|
||||
(login.host.as_str(), login.port, login.user.as_str()),
|
||||
("10.0.0.9", 2222, "token")
|
||||
);
|
||||
assert_eq!(login.method, LoginMethod::Password);
|
||||
assert_eq!(login.route, LoginRoute::Direct);
|
||||
assert_eq!(login.password, SecretRef::transient(LINK_HOST.0));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
window.find(("terminal-tab", LINK_TERMINAL)).label(),
|
||||
Some("db-prod")
|
||||
);
|
||||
// Not saved: no row in the tree.
|
||||
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
||||
})
|
||||
.unwrap();
|
||||
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
||||
store.read_with(cx, |store, _| {
|
||||
assert!(store.is_transient(LINK_HOST));
|
||||
assert!(store.hosts().is_empty());
|
||||
assert!(store.recent_hosts().next().is_none());
|
||||
// The link's password is in memory, not in the keychain.
|
||||
assert_eq!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::transient(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
.map(String::as_str),
|
||||
Some("p@ss")
|
||||
);
|
||||
});
|
||||
assert!(keychain.is_empty());
|
||||
|
||||
// There is nothing to edit.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(Box::new(EditHost(LINK_HOST)), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find("host-name").is_none());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Its SFTP tab works, bookmarks and all, without writing to disk.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(Box::new(OpenExplorer(LINK_HOST)), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
store.update(cx, |store, cx| {
|
||||
assert!(store.add_bookmark(LINK_HOST, BookmarkSide::Remote, "/var/log", cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.notifications(cx).is_empty());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// The host stays while a tab of it does.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
assert!(store.read_with(cx, |store, _| store.host(LINK_HOST).is_some()));
|
||||
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());
|
||||
assert!(
|
||||
store
|
||||
.secrets()
|
||||
.get(&SecretRef::transient(LINK_HOST.0))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
|
||||
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
||||
assert!(saved.hosts().is_empty());
|
||||
assert!(saved.recent_hosts().next().is_none());
|
||||
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
|
||||
}
|
||||
|
||||
#[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(
|
||||
cx,
|
||||
HostStore::empty(),
|
||||
Arc::new(RecordingRemoteProvider::default()),
|
||||
Arc::new(FakeConnectionTester::default()),
|
||||
);
|
||||
open_link(cx, handle, &workspace, "ssh://10.0.0.9:2222", None);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(window.notifications(cx).len(), 1);
|
||||
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
|
||||
})
|
||||
.unwrap();
|
||||
workspace.read_with(cx, |workspace, cx| {
|
||||
assert!(workspace.store().read(cx).host(LINK_HOST).is_none());
|
||||
});
|
||||
}
|
||||
+61
-4
@@ -101,7 +101,7 @@ async fn containers_show_by_project_and_the_rest_on_their_own(cx: &mut TestAppCo
|
||||
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
|
||||
Some("Docker")
|
||||
);
|
||||
// The project with something running is unfolded; the stopped one
|
||||
// The running project of one container is unfolded; the stopped one
|
||||
// is not.
|
||||
assert_eq!(
|
||||
listed(window, &LINES),
|
||||
@@ -193,7 +193,7 @@ async fn the_other_tabs_list_volumes_images_and_networks(cx: &mut TestAppContext
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("docker-image:4a3b5c6d7e8f").label(),
|
||||
window.find("docker-image:hello-world:latest").label(),
|
||||
Some("hello-world:latest · 4a3b5c6d7e8f · 13.3 kB · 2026-09-07 · 使用中")
|
||||
);
|
||||
});
|
||||
@@ -326,6 +326,63 @@ const NETWORK: &str = r#"[{"Name":"bridge","Id":"n1","Driver":"bridge","Scope":"
|
||||
"IPAM":{"Config":[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]},
|
||||
"Containers":{"ccc333":{"Name":"forex-web","IPv4Address":"172.17.0.2/16"}}}]"#;
|
||||
|
||||
/// One image under two tags, and one without a tag.
|
||||
const TAGGED_TWICE: &str = r#"@@version
|
||||
26.0.0
|
||||
@@compose
|
||||
v2.25.0
|
||||
@@containers
|
||||
@@volumes
|
||||
@@images
|
||||
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"latest"}
|
||||
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"1.27"}
|
||||
{"CreatedAt":"2026-08-01 08:00:00 +0800 CST","ID":"ccc333ddd444","Repository":"nginx","Size":"180MB","Tag":"<none>"}
|
||||
@@networks
|
||||
"#;
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn an_image_under_two_tags_is_two_cards_each_removed_by_its_tag(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[
|
||||
TAGGED_TWICE,
|
||||
"Untagged: nginx:1.27\n@@status 0\n",
|
||||
TAGGED_TWICE,
|
||||
]));
|
||||
let handle = open_docker(cx, factory.clone());
|
||||
wait_for_reading(cx, handle).await;
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("docker-tabs").click(2usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert_eq!(
|
||||
window.find("docker-image:nginx:1.27").label(),
|
||||
Some("nginx:1.27 · aaa111bbb222 · 187 MB · 2026-09-07")
|
||||
);
|
||||
assert_eq!(
|
||||
window.find("docker-image:nginx:latest").label(),
|
||||
Some("nginx:latest · aaa111bbb222 · 187 MB · 2026-09-07")
|
||||
);
|
||||
// Without a tag, by its ID.
|
||||
assert_eq!(
|
||||
window.find("docker-image:ccc333ddd444").label(),
|
||||
Some("nginx:<none> · ccc333ddd444 · 180 MB · 2026-08-01")
|
||||
);
|
||||
});
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("docker-image:nginx:1.27-remove", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
||||
cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| {
|
||||
factory.exec_count() >= 2
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
factory.exec_commands()[1].contains("$d rmi \"nginx:1.27\""),
|
||||
"{:?}",
|
||||
factory.exec_commands()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::answering(&[
|
||||
@@ -360,7 +417,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
|
||||
window.within("docker-tabs").click(2usize, cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("docker-image:4a3b5c6d7e8f", cx)
|
||||
window.click("docker-image:hello-world:latest", cx)
|
||||
});
|
||||
wait_for_label(cx, handle, "image-basics:平台", "linux/amd64").await;
|
||||
in_frame(cx, handle, |window, _| {
|
||||
@@ -371,7 +428,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
|
||||
Some("PATH=/usr/bin")
|
||||
);
|
||||
});
|
||||
assert!(factory.exec_commands()[2].contains("$d image inspect \"4a3b5c6d7e8f\""));
|
||||
assert!(factory.exec_commands()[2].contains("$d image inspect \"hello-world:latest\""));
|
||||
in_frame(cx, handle, |window, cx| window.press("escape", cx));
|
||||
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
|
||||
+12
-7
@@ -376,7 +376,7 @@ async fn with_automatic_updates_off_a_found_update_waits_for_download(cx: &mut T
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_update_dialog_shows_the_notes_and_restarts_into_the_new_version(
|
||||
async fn the_update_dialog_links_the_changelog_and_restarts_into_the_new_version(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let (handle, workspace) = open_workspace(cx);
|
||||
@@ -393,15 +393,20 @@ async fn the_update_dialog_shows_the_notes_and_restarts_into_the_new_version(
|
||||
window.click("update-available", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
let notes = window
|
||||
.find("update-notes")
|
||||
.label()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(notes.contains("在线升级"), "{notes}");
|
||||
assert!(window.try_find("update-ready").is_some());
|
||||
assert!(window.try_find("restart-to-update").is_some());
|
||||
});
|
||||
|
||||
// What changed is on the website's changelog; the dialog stays.
|
||||
in_frame(cx, handle, |window, cx| window.click("open-changelog", cx));
|
||||
assert_eq!(
|
||||
cx.opened_url().as_deref(),
|
||||
Some("https://shellrs.com/changelog")
|
||||
);
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("update-dialog").is_some());
|
||||
});
|
||||
|
||||
let restart = cx.expect_restart();
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("restart-to-update", cx)
|
||||
|
||||
Reference in new issue
Block a user