diff --git a/CLAUDE.md b/CLAUDE.md index d299af3..12283cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP **界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。 -会话和分组是**真实持久化**的,存在一个 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)也存在这个库里(密码和口令照旧只进钥匙串),会话可以「使用凭据」而不是自己填登录信息。详见 `README.md`。 +会话和分组是**真实持久化**的,存在一个 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/` 下的文件),会话可以「使用凭据」而不是自己填登录信息。详见 `README.md`。 -已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」分「手动输入」(原样保留 自动 / 密码 / 私钥文件,手动里不加 Agent)和「使用凭据」;密钥凭据只引用本机私钥文件的路径,不导入私钥内容;删除仍在用的凭据要确认并写明台数,删除后那些会话改成「手动输入 · 自动」、保留用户名,密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,手动的「自动」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。 +已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」分「手动输入」(原样保留 自动 / 密码 / 私钥文件,手动里不加 Agent)和「使用凭据」;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的会话一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些会话改成「手动输入 · 自动」、保留用户名,密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,手动的「自动」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。 做 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/.md` 为准。 @@ -41,7 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`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())` 渲染。 -- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`,`Session.credential` 是 `Option`)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `forward.rs`)、`login.rs`(`SessionLogin` / `LoginMethod`:查好凭据之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:会话对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 +- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`,`Session.credential` 是 `Option`)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `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`(`SessionLogin` / `LoginMethod`:查好凭据之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:会话对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `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`)。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、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` 区分「网络断了」和「需要人处理」)。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。登录参数 `SshConnectionConfig` 由 `SessionLogin` 构造;`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 秒用 `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`。 - **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `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`)时一律不拦截,读钥匙串里凭据保存的。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 @@ -55,7 +55,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随会话级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。 - `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 -- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog`:类型是 `RadioGroup::horizontal("credential-kind")`,秘密字段用 `session::SecretFields`;提交时先 `insert_credential` / `update_credential` 再 `save_secret`,顺序同会话对话框)。 +- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `RadioGroup::horizontal("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `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`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 @@ -129,6 +129,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 - **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `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` 拿到的都是 `SessionLogin`(`SessionStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键),不是 `Session`。`SessionLogin` 只含影响登录的字段并派生 `PartialEq`,会话 `update` 和 `update_credential` 都按「前后快照不相等」决定发不发 `ConnectionSettingsChanged`,所以只改名称不重连。引用凭据的会话在 `Session.user` 里冗余存一份凭据的用户名(`auth` 存 `Auto`、`key_path` 为空),store 在保存会话(`normalized`)、改凭据(同一个事务里改 `sessions.username`)和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `session.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时会因为缺密码停下。 +- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `SessionStore::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` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的会话不占着端点的密码条目,会话从手动切到密码凭据时旧条目会像换了端点一样被清理;它只删端点条目,从不删凭据的。 - **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 - **对外只给 `PublicId`,不给 `SessionId`。** 会话右键「复制 ID」复制的是 `Session.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `sessions.public_id`(可为空,唯一性由索引 `sessions_public_id` 保证)。新建和复制会话时由 `SessionStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建会话时必须带上原来的 `public_id`,`update_session` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。 @@ -160,4 +161,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(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)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-session`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-session` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身用不关动效的 `open_workspace_with_store`(`the_forward_diagram_keeps_showing_its_flow` 用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new`、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);会话对话框的认证方式在 `within("session-auth-source")` 里按序号 `0usize`(手动输入)/ `1usize`(使用凭据),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(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)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-session`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-session` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;会话对话框的认证方式在 `within("session-auth-source")` 里按序号 `0usize`(手动输入)/ `1usize`(使用凭据),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/Cargo.toml b/Cargo.toml index 7610f68..9189c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,3 +78,10 @@ ttf-parser = { opt-level = 3 } resvg = { opt-level = 3 } usvg = { opt-level = 3 } zed-font-kit = { opt-level = 3 } +# Generating an RSA key and deriving a key from a passphrase take seconds +# unoptimized; the credential form does both. +rsa = { opt-level = 3 } +crypto-bigint = { opt-level = 3 } +crypto-primes = { opt-level = 3 } +bcrypt-pbkdf = { opt-level = 3 } +blowfish = { opt-level = 3 } diff --git a/README.md b/README.md index 3f19270..4840a30 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,21 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 | 类型 | 保存什么 | 登录时 | | --- | --- | --- | | 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 | -| 密钥 | 用户名、本机私钥文件的路径和口令 | 只用这把私钥;口令按私钥文件保存,和手动填私钥的主机共用 | +| 密钥 | 用户名、私钥和口令 | 只用这把私钥;口令按私钥文件保存,和手动填私钥的主机共用 | | SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 | -- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。私钥文件只保存路径,文件移动或删除后需要重新选择。 +- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。 +- **密钥从哪里来**:密钥凭据的「私钥」有三种来源。 + - **本机文件**:选一个私钥文件,只保存它的路径;文件移动或删除后需要重新选择。 + - **粘贴**:贴进 OpenSSH、PEM 或 PuTTY 格式的私钥。贴成公钥或认不出的内容时会说明原因。私钥加了口令时才显示口令栏。 + - **生成新密钥**:选 Ed25519(默认)或 RSA 4096,选中就生成;对话框里显示公钥,旁边可以复制。口令可留空;填了就用它加密私钥,口令存进钥匙串。生成的密钥以凭据名称作为注释。 + - 粘贴和生成的私钥由 ShellRS 写成数据目录下 `keys/` 里的文件(随机文件名,只有自己的账户能读),凭据引用这个文件,编辑时显示为「本机文件」和它的路径。之后再粘贴或生成会直接覆盖这个文件,已连接的主机不会因此重连;改成别的文件或别的类型、或删除凭据时,这个文件连同它的口令一起删除,对话框会先提示。用户自己的私钥文件 ShellRS 从不改动或删除。 + - 空列表和空白处的右键菜单里另有「生成密钥…」,打开的是同一个对话框,已经选好「密钥 · 生成新密钥」。密钥凭据的右键菜单有「复制公钥」,用来把公钥加到服务器的 `~/.ssh/authorized_keys`。 - **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。 - **删除**:右键「删除」。有主机在用时确认框写明数量;删除后这些主机改为「手动输入 · 自动」,保留原来的用户名,密码和私钥不会复制过去,下次连接时按「自动」尝试或询问。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。 - **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。手动输入里的「自动」也会尝试同一个 Agent,连不上时静默跳过(Windows 上同样如此)。 -**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字。** +**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。** ## 设置 @@ -252,7 +258,7 @@ cargo clippy --all-targets -- -D warnings cargo fmt --check ``` -数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,密码和私钥口令使用系统钥匙串(服务名 `shellrs`;账户名是主机端点的 `password:<用户>@<地址>:<端口>`、私钥的 `passphrase:<路径>` 和密码凭据的 `credential:<随机 ID>`)。 +数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,凭据里粘贴或生成的私钥保存在该目录的 `keys/` 下,密码和私钥口令使用系统钥匙串(服务名 `shellrs`;账户名是主机端点的 `password:<用户>@<地址>:<端口>`、私钥的 `passphrase:<路径>` 和密码凭据的 `credential:<随机 ID>`)。 `src/sftp/tests.rs` 使用临时目录与故障注入覆盖上传和下载的文件内容、空目录、链接、覆盖、权限、取消、源文件变化、乱序与短读、续传及替换各阶段恢复,以及删除、重命名、新建和递归改权限不跟随链接。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。 diff --git a/src/app/actions.rs b/src/app/actions.rs index f5d1598..5e20459 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -53,6 +53,8 @@ gpui_kit::actions!( ShowCredentials, /// Open the new-credential dialog. NewCredential, + /// Open the new-credential dialog at a key it generates. + GenerateCredentialKey, /// Open the edit dialog of the credential selected in the credential /// list. EditSelectedCredential, @@ -177,6 +179,9 @@ id_actions! { /// Open the edit-credential dialog. EditCredential(CredentialId); + /// Copy the public half of a key credential's key, for a server's + /// `authorized_keys`. + CopyCredentialPublicKey(CredentialId); /// Ask for confirmation, then delete a credential. The hosts using it /// go back to logging in on their own. DeleteCredential(CredentialId); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5baaa0f..3f7d680 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,7 +6,7 @@ mod paths; pub use actions::*; pub use assets::{AppAssets, CatalogIcon}; -pub use paths::{cli_endpoint, cli_socket_path, data_dir, database_path, settings_path}; +pub use paths::{cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path}; use gpui_kit::component::{Theme, dock::ToggleZoom}; use gpui_kit::*; diff --git a/src/app/paths.rs b/src/app/paths.rs index 314c633..53d3bc8 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -25,6 +25,12 @@ pub fn database_path() -> std::io::Result { Ok(dir.join("shellrs.db")) } +/// Where the private keys pasted into or generated by the credential form +/// are kept, beside the database. Created when the first one is saved. +pub fn keys_dir() -> PathBuf { + data_dir().join("keys") +} + /// The interface settings, a small JSON file beside the database. pub fn settings_path() -> std::io::Result { let dir = data_dir(); diff --git a/src/credential/credential_dialog.rs b/src/credential/credential_dialog.rs index f9a49ee..f48229a 100644 --- a/src/credential/credential_dialog.rs +++ b/src/credential/credential_dialog.rs @@ -1,16 +1,23 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use gpui_kit::component::{ ActiveTheme as _, Sizable as _, WindowExt as _, + clipboard::Clipboard, form::{Field, Form}, - input::{Input, InputState}, + h_flex, + input::{Input, InputEvent, InputState, Textarea, TextareaState}, radio::{Radio, RadioGroup}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use zeroize::Zeroizing; use crate::secrets::SecretRef; use crate::session::{ - CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, SecretFields, SessionStore, + CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, KeyAlgorithm, + PastedKey, PastedKeyError, SecretFields, SessionStore, read_public_key, }; use crate::shared::{commit_footer, form_error}; @@ -20,6 +27,67 @@ const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Age #[cfg(not(windows))] const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。"; +/// What an empty passphrase field means for a key that already exists, and +/// for one about to be generated. +const ASK_FOR_PASSPHRASE: &str = "留空则每次连接都询问"; +const NO_PASSPHRASE: &str = "留空则不加密"; + +/// Which credential dialog to open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CredentialDialog { + New, + /// A new credential, at a key it generates: 「生成密钥…」. + GenerateKey, + Edit(CredentialId), +} + +/// Where a key credential's private key comes from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum KeySource { + /// A file of the user's, which the credential refers to. + #[default] + File, + /// Text pasted into the form, which ShellRS keeps in a file of its own. + Paste, + /// A new key pair, which ShellRS makes and keeps. + Generate, +} + +impl KeySource { + const ALL: [KeySource; 3] = [KeySource::File, KeySource::Paste, KeySource::Generate]; + + fn label(self) -> &'static str { + match self { + KeySource::File => "本机文件", + KeySource::Paste => "粘贴", + KeySource::Generate => "生成新密钥", + } + } + + fn id(self) -> &'static str { + match self { + KeySource::File => "file", + KeySource::Paste => "paste", + KeySource::Generate => "generate", + } + } +} + +/// The key the form is generating or has generated. +enum Generated { + Working, + Ready(Arc), + Failed(SharedString), +} + +/// What the form shows as the key's public half. +enum PublicKeyView { + Line(SharedString), + Generating, + Failed(SharedString), + Unknown, +} + /// The body of the new/edit credential dialog. pub struct CredentialForm { store: Entity, @@ -29,22 +97,46 @@ pub struct CredentialForm { user: Entity, /// The password, key file and passphrase, shared with the host form. fields: Entity, + source: KeySource, + key_text: Entity, + /// What the pasted text is, read again whenever it changes. + pasted: Option>, + algorithm: KeyAlgorithm, + /// The key made for the algorithm it names. A request is numbered, so + /// the answer to an earlier choice of algorithm is dropped. + generated: Option<(KeyAlgorithm, Generated)>, + generation: u64, + /// The key file the form points at and its public half, which is read + /// off the UI thread. + file_public_key: Option<(String, Option)>, + /// Where ShellRS keeps keys. Without it nothing can be pasted or + /// generated. + key_dir: Option, + /// The key file of the credential being edited, when ShellRS keeps it: + /// saving the credential with any other key deletes it. + kept_key: Option, /// How many hosts use the credential being edited, and how many of them /// are connected right now. hosts: usize, connected: usize, + /// A commit is encrypting the new key with its passphrase. + saving: bool, error: Option, _subscriptions: Vec, } impl CredentialForm { pub fn new( - editing: Option, + dialog: CredentialDialog, store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { - let (credential, secrets, hosts, connected) = { + let editing = match dialog { + CredentialDialog::Edit(id) => Some(id), + CredentialDialog::New | CredentialDialog::GenerateKey => None, + }; + let (credential, secrets, key_dir, kept_key, hosts, connected) = { let read = store.read(cx); let credential = editing.and_then(|id| read.credential(id)).cloned(); let using: Vec<_> = editing @@ -54,12 +146,36 @@ impl CredentialForm { .iter() .filter(|session| session.state.is_connected()) .count(); - (credential, read.secrets(), using.len(), connected) + let kept_key = credential + .as_ref() + .filter(|credential| credential.keeps_key_in(read.key_dir())) + .and_then(|credential| credential.key_path.clone()); + ( + credential, + read.secrets(), + read.key_dir().map(Path::to_path_buf), + kept_key, + using.len(), + connected, + ) }; let draft = credential .as_ref() .map(|credential| credential.draft()) .unwrap_or_else(|| CredentialDraft::new("", CredentialKind::default(), DEFAULT_USER)); + // Without a place to keep it, 「生成密钥…」 still opens at a key, + // one that has to be a file. + let generate = dialog == CredentialDialog::GenerateKey; + let source = if generate && key_dir.is_some() { + KeySource::Generate + } else { + KeySource::File + }; + let kind = if generate { + CredentialKind::Key + } else { + draft.kind + }; let name = cx.new(|cx| { InputState::new(window, cx) @@ -82,19 +198,62 @@ impl CredentialForm { ) }); } - let subscriptions = vec![cx.observe(&fields, |_, _, cx| cx.notify())]; - Self { + if source == KeySource::Generate { + fields.update(cx, |fields, cx| { + fields.set_passphrase_placeholder(NO_PASSPHRASE, window, cx) + }); + } + let key_text = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(4, 8) + .placeholder("粘贴私钥,以 -----BEGIN 开头") + }); + let subscriptions = vec![ + cx.observe(&fields, |this, _, cx| { + this.look_up_file_public_key(cx); + cx.notify(); + }), + cx.subscribe(&key_text, |this, state, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let text = state.read(cx).value(); + this.pasted = (!text.trim().is_empty()).then(|| PastedKey::parse(&text)); + cx.notify(); + } + }), + // The generated key's comment is the credential's name. + cx.subscribe(&name, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }), + ]; + let mut form = Self { store, editing, - kind: draft.kind, + kind, name, user, fields, + source, + key_text, + pasted: None, + algorithm: KeyAlgorithm::default(), + generated: None, + generation: 0, + file_public_key: None, + key_dir, + kept_key, hosts, connected, + saving: false, error: None, _subscriptions: subscriptions, + }; + form.look_up_file_public_key(cx); + if source == KeySource::Generate { + form.generate(cx); } + form } /// Put the keyboard where a new credential starts: its name. @@ -110,36 +269,233 @@ impl CredentialForm { } } - /// Validate and write to the store. Returns whether the dialog may close. + fn set_source(&mut self, source: KeySource, window: &mut Window, cx: &mut Context) { + if self.source == source { + return; + } + self.source = source; + self.error = None; + // A passphrase belongs to one key; whatever the field held was for + // the key the form showed before. + let placeholder = match source { + KeySource::Generate => NO_PASSPHRASE, + KeySource::File | KeySource::Paste => ASK_FOR_PASSPHRASE, + }; + self.fields.update(cx, |fields, cx| { + fields.clear_passphrase(window, cx); + fields.set_passphrase_placeholder(placeholder, window, cx); + }); + if source == KeySource::Generate { + self.generate(cx); + } + cx.notify(); + } + + fn set_algorithm(&mut self, algorithm: KeyAlgorithm, cx: &mut Context) { + if self.algorithm != algorithm { + self.algorithm = algorithm; + self.error = None; + self.generate(cx); + cx.notify(); + } + } + + /// Make a key for the chosen algorithm, unless one is made or being + /// made already. Off the UI thread: an RSA key takes a moment. + fn generate(&mut self, cx: &mut Context) { + let algorithm = self.algorithm; + if self + .generated + .as_ref() + .is_some_and(|(made_for, generated)| { + *made_for == algorithm && !matches!(generated, Generated::Failed(_)) + }) + { + return; + } + self.generation += 1; + let generation = self.generation; + self.generated = Some((algorithm, Generated::Working)); + cx.spawn(async move |this, cx| { + let made = cx + .background_executor() + .spawn(async move { GeneratedKey::generate(algorithm) }) + .await; + this.update(cx, |this, cx| { + if this.generation != generation { + return; + } + let generated = match made { + Ok(key) => Generated::Ready(Arc::new(key)), + Err(error) => Generated::Failed(format!("无法生成密钥:{error}").into()), + }; + this.generated = Some((algorithm, generated)); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Read the public half of the key file the form points at, if it + /// changed since the last look. The file system is read off the UI + /// thread. + fn look_up_file_public_key(&mut self, cx: &mut Context) { + let path = self.fields.read(cx).key_path(cx); + if self + .file_public_key + .as_ref() + .is_some_and(|(looked_up, _)| *looked_up == path) + { + return; + } + self.file_public_key = Some((path.clone(), None)); + if path.is_empty() { + return; + } + cx.spawn(async move |this, cx| { + let file = path.clone(); + let line = cx + .background_executor() + .spawn(async move { read_public_key(Path::new(&file)) }) + .await; + this.update(cx, |this, cx| { + if let Some((looked_up, found)) = &mut this.file_public_key + && *looked_up == path + { + *found = line.map(Into::into); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// Validate and write to the store. Returns whether the dialog may + /// close; a key that is still being encrypted closes it when done. pub fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { - let fields = self.fields.read(cx); + if self.saving { + return false; + } + let key_from_text = self.kind == CredentialKind::Key && self.source != KeySource::File; + let (key_file, password_change, passphrase_change, passphrase) = { + let fields = self.fields.read(cx); + ( + fields.key_path(cx), + fields.password_change(cx), + fields.passphrase_change(cx), + Zeroizing::new(fields.passphrase(cx)), + ) + }; + // A pasted or generated key's file is only written once everything + // else checks out; until then the draft stands in for it. + let key_path = if key_from_text { + "(新私钥)".to_string() + } else { + key_file + }; let draft = CredentialDraft::new( self.name.read(cx).value().to_string(), self.kind, self.user.read(cx).value().to_string(), ) - .with_key_path(fields.key_path(cx)) + .with_key_path(key_path) .validated(); let draft = match draft { Ok(draft) => draft, - Err(error) => { - self.error = Some(error.to_string().into()); - cx.notify(); - return false; - } + Err(error) => return self.fail(error.to_string(), cx), }; // Secrets never ride along in the draft, which derives `Debug`. - let password_change = (draft.kind == CredentialKind::Password) - .then(|| fields.password_change(cx)) - .flatten(); - let passphrase_change = draft.key_path.as_ref().and_then(|path| { - Some(( - SecretRef::passphrase(path.as_ref()), - fields.passphrase_change(cx)?, - )) - }); + let password_change = password_change.filter(|_| draft.kind == CredentialKind::Password); + if !key_from_text { + let passphrase_change = passphrase_change.filter(|_| draft.key_path.is_some()); + return self.save(draft, None, password_change, passphrase_change, window, cx); + } + if self.source == KeySource::Paste { + let pasted = match PastedKey::parse(&self.key_text.read(cx).value()) { + Ok(pasted) => pasted, + Err(error) => return self.fail(error.to_string(), cx), + }; + // Only an encrypted key has a passphrase to keep; an empty one + // clears what a key replaced in place had. + let passphrase = + (pasted.is_encrypted() && !passphrase.is_empty()).then(|| passphrase.to_string()); + let text = Zeroizing::new(pasted.text().to_string()); + return self.save(draft, Some(text), None, Some(passphrase), window, cx); + } + + let key = match &self.generated { + Some((algorithm, Generated::Ready(key))) if *algorithm == self.algorithm => key.clone(), + Some((_, Generated::Failed(error))) => return self.fail(error.clone(), cx), + _ => return self.fail("密钥还在生成,请稍候", cx), + }; + let comment = draft.name.clone(); + if passphrase.is_empty() { + return match key.encode(&comment, "") { + Ok(text) => self.save(draft, Some(text), None, Some(None), window, cx), + Err(error) => self.fail(format!("无法保存私钥:{error}"), cx), + }; + } + // Deriving the encryption key from a passphrase is slow on purpose, + // so it happens off the UI thread with the dialog kept open. + self.saving = true; + self.error = None; + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let secret = passphrase.clone(); + let encoded = cx + .background_executor() + .spawn(async move { key.encode(&comment, &secret) }) + .await; + this.update_in(cx, |this, window, cx| { + this.saving = false; + let saved = match encoded { + Ok(text) => { + let passphrase = Some(Some(passphrase.to_string())); + this.save(draft, Some(text), None, passphrase, window, cx) + } + Err(error) => this.fail(format!("无法加密私钥:{error}"), cx), + }; + if saved { + window.close_dialog(cx); + } + }) + .ok(); + }) + .detach(); + false + } + + /// Say what stands in the way of saving. Returns `false`, so the + /// dialog stays open. + fn fail(&mut self, error: impl Into, cx: &mut Context) -> bool { + self.error = Some(error.into()); + cx.notify(); + false + } + + /// Write the key file for a pasted or generated key, then the + /// credential, then its secrets. Returns whether the dialog may close. + fn save( + &mut self, + mut draft: CredentialDraft, + key_text: Option>, + password_change: Option>, + passphrase_change: Option>, + window: &mut Window, + cx: &mut Context, + ) -> bool { let editing = self.editing; + if let Some(text) = key_text { + match self.store.read(cx).save_private_key(editing, &text) { + Ok(path) => draft.key_path = Some(path), + Err(error) => return self.fail(format!("私钥未能保存:{error}"), cx), + } + } + let passphrase_change = passphrase_change + .and_then(|change| Some((SecretRef::passphrase(draft.key_path.as_deref()?), change))); self.store.update(cx, |store, cx| { // The credential first: an edit that reconnects its hosts reads // the keychain later, on the connection's own thread. @@ -161,6 +517,10 @@ impl CredentialForm { // plaintext now rather than waiting for the entity. self.fields .update(cx, |fields, cx| fields.clear(window, cx)); + self.key_text + .update(cx, |text, cx| text.set_value("", window, cx)); + self.pasted = None; + self.generated = None; self.error = None; true } @@ -171,25 +531,246 @@ impl CredentialForm { return None; } let mut note = format!("有 {} 台主机使用此凭据。", self.hosts); + // A new key is no use to them until their servers know it. + if self.kind == CredentialKind::Key && self.source != KeySource::File { + note.push_str("换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。"); + } if self.connected > 0 { note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。"); } Some(note) } + + /// Whether saving now would delete the key ShellRS keeps for the + /// credential being edited: it is set to log in some other way, or with + /// another file. Pasting or generating writes over the kept file instead. + fn drops_kept_key(&self, cx: &App) -> bool { + let Some(kept) = &self.kept_key else { + return false; + }; + match (self.kind, self.source) { + (CredentialKind::Key, KeySource::File) => { + self.fields.read(cx).key_path(cx) != kept.as_ref() + } + (CredentialKind::Key, KeySource::Paste | KeySource::Generate) => false, + (CredentialKind::Password | CredentialKind::Agent, _) => true, + } + } + + /// The public half of the key the form shows. + fn public_key(&self, cx: &App) -> PublicKeyView { + match self.source { + KeySource::File => { + let path = self.fields.read(cx).key_path(cx); + match &self.file_public_key { + Some((looked_up, Some(line))) if *looked_up == path => { + PublicKeyView::Line(line.clone()) + } + _ => PublicKeyView::Unknown, + } + } + KeySource::Paste => match &self.pasted { + Some(Ok(pasted)) => pasted + .public_key_line() + .map_or(PublicKeyView::Unknown, |line| { + PublicKeyView::Line(line.into()) + }), + _ => PublicKeyView::Unknown, + }, + KeySource::Generate => match &self.generated { + Some((algorithm, Generated::Ready(key))) if *algorithm == self.algorithm => { + let name = self.name.read(cx).value(); + PublicKeyView::Line(key.public_key_line(&name).into()) + } + Some((_, Generated::Failed(error))) => PublicKeyView::Failed(error.clone()), + _ => PublicKeyView::Generating, + }, + } + } + + /// The key fields: where the key comes from, the key itself, its public + /// half and its passphrase. + fn key_fields(&self, form: Form, cx: &Context) -> Form { + let source = self.source; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let can_keep = self.key_dir.is_some(); + let fields = self.fields.read(cx); + let kept_here = source == KeySource::File + && self + .kept_key + .as_ref() + .is_some_and(|kept| fields.key_path(cx) == kept.as_ref()); + + let sources = RadioGroup::horizontal("credential-key-source") + .selected_index(KeySource::ALL.iter().position(|each| *each == source)) + .on_change(cx.listener(|this, ix: &usize, window, cx| { + if let Some(source) = KeySource::ALL.get(*ix) { + this.set_source(*source, window, cx); + } + })) + .children(KeySource::ALL.map(|each| { + Radio::new(each.id()).label(each.label()).small().when( + each != KeySource::File && !can_keep, + |radio| { + radio + .disabled(true) + .tooltip("本地数据库没有打开,这次运行无法保存私钥") + }, + ) + })); + // A generated key has nothing to fill in under the choice. + let key = match source { + KeySource::File => Some( + SecretFields::key_path_input( + &self.fields, + "credential-key-path", + "choose-credential-key", + cx, + ) + .into_any_element(), + ), + KeySource::Paste => Some( + div() + .id("credential-key-text") + .test_support() + .w_full() + .child( + Textarea::new(&self.key_text) + .font_family(theme.mono_font_family.clone()) + .text_xs(), + ) + .into_any_element(), + ), + KeySource::Generate => None, + }; + let mut form = form.child( + Field::new() + .label("私钥") + .required(true) + .child(v_flex().w_full().gap_2().child(sources).children(key)) + .when(kept_here, |field| { + field.description("这把私钥由 ShellRS 保存,删除凭据时会一起删除。") + }), + ); + if source == KeySource::Generate { + form = form.child( + Field::new() + .label("算法") + .child( + RadioGroup::horizontal("credential-key-algorithm") + .selected_index( + KeyAlgorithm::ALL + .iter() + .position(|each| *each == self.algorithm), + ) + .on_change(cx.listener(|this, ix: &usize, _, cx| { + if let Some(algorithm) = KeyAlgorithm::ALL.get(*ix) { + this.set_algorithm(*algorithm, cx); + } + })) + .children( + KeyAlgorithm::ALL.map(|each| { + Radio::new(each.label()).label(each.label()).small() + }), + ), + ) + .when(self.algorithm == KeyAlgorithm::Rsa, |field| { + field.description("只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。") + }), + ); + } + form = match self.public_key(cx) { + PublicKeyView::Line(line) => form.child( + Field::new() + .label("公钥") + .child(public_key_box(line, cx)) + .when(source == KeySource::Generate, |field| { + field.description( + "把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。", + ) + }), + ), + PublicKeyView::Generating => form.child( + Field::new().label("公钥").child( + div() + .id("credential-public-key-pending") + .test_support() + .text_sm() + .text_color(muted) + .child("正在生成密钥…"), + ), + ), + PublicKeyView::Failed(error) => form.child( + Field::new().label("公钥").child( + div() + .id("credential-generate-error") + .test_support() + .aria_label(error.clone()) + .text_sm() + .text_color(theme.danger) + .child(error), + ), + ), + PublicKeyView::Unknown => form, + }; + // A pasted key without a passphrase has nothing to ask for. + let encrypted_paste = matches!(&self.pasted, Some(Ok(pasted)) if pasted.is_encrypted()); + if source != KeySource::Paste || encrypted_paste { + form = form.child( + Field::new() + .label("私钥口令") + .child(fields.passphrase_input("credential-passphrase")), + ); + } + form + } +} + +/// The public half of a key, cut short to fit, with a button that copies +/// all of it. +fn public_key_box(line: SharedString, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + h_flex() + .w_full() + .gap_1() + .child( + div() + .id("credential-public-key") + .test_support() + .aria_label(line.clone()) + .flex_1() + .min_w_0() + .h_6() + .px_2() + .flex() + .items_center() + .rounded(theme.radius) + .border_1() + .border_color(theme.border) + .bg(theme.muted) + .font_family(theme.mono_font_family.clone()) + .text_xs() + .child(div().w_full().truncate().child(line.clone())), + ) + .child( + Clipboard::new("copy-public-key") + .value(line) + .tooltip("复制公钥"), + ) } impl Render for CredentialForm { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let kind = self.kind; - let fields = self.fields.read(cx); let muted = cx.theme().muted_foreground; + let keychain = self.fields.read(cx).keychain_available(); let secret_note = match kind { CredentialKind::Agent => None, - _ if fields.keychain_available() => { - Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。") - } + _ if keychain => Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。"), _ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"), }; + let drops_kept_key = self.drops_kept_key(cx); let form = Form::new() .child( Field::new().label("类型").child( @@ -221,22 +802,9 @@ impl Render for CredentialForm { CredentialKind::Password => form.child( Field::new() .label("密码") - .child(fields.password_input("credential-password")), + .child(self.fields.read(cx).password_input("credential-password")), ), - CredentialKind::Key => form - .child(Field::new().label("私钥文件").required(true).child( - SecretFields::key_path_input( - &self.fields, - "credential-key-path", - "choose-credential-key", - cx, - ), - )) - .child( - Field::new() - .label("私钥口令") - .child(fields.passphrase_input("credential-passphrase")), - ), + CredentialKind::Key => self.key_fields(form, cx), CredentialKind::Agent => form, }; v_flex() @@ -257,6 +825,18 @@ impl Render for CredentialForm { .when_some(secret_note, |view, note| { view.child(div().text_sm().text_color(muted).child(note)) }) + .when(drops_kept_key, |view| { + let note = "保存后,ShellRS 保存的原私钥会被删除。"; + view.child( + div() + .id("credential-kept-key-note") + .test_support() + .aria_label(note) + .text_sm() + .text_color(cx.theme().warning) + .child(note), + ) + }) .when_some(self.usage_note(), |view, note| { view.child( div() @@ -268,32 +848,39 @@ impl Render for CredentialForm { .child(note), ) }) + .when(self.saving, |view| { + view.child( + div() + .id("credential-saving") + .test_support() + .text_sm() + .text_color(muted) + .child("正在用口令加密私钥…"), + ) + }) .when_some(self.error.clone(), |view, error| { view.child(form_error(error.clone(), cx).aria_label(error)) }) } } -/// Open the new-credential (`editing == None`) or edit-credential dialog. +/// Open a credential dialog: a new credential, one at a generated key, or +/// the edit dialog of an existing one. pub fn open_credential_dialog( - editing: Option, + dialog: CredentialDialog, store: Entity, window: &mut Window, cx: &mut App, ) { - let form = cx.new(|cx| CredentialForm::new(editing, store, window, cx)); - let title: SharedString = if editing.is_some() { + let form = cx.new(|cx| CredentialForm::new(dialog, store, window, cx)); + let editing = matches!(dialog, CredentialDialog::Edit(_)); + let title: SharedString = if editing { "编辑凭据" } else { "新建凭据" } .into(); - let commit_label: SharedString = if editing.is_some() { - "保存" - } else { - "创建" - } - .into(); + let commit_label: SharedString = if editing { "保存" } else { "创建" }.into(); window.open_dialog(cx, { let form = form.clone(); move |dialog, _, _| { @@ -311,7 +898,7 @@ pub fn open_credential_dialog( }); // Focused in the same update that opened the dialog, which is the one // that sticks: a later request loses to the dialog's own focus. - if editing.is_none() { + if !editing { form.update(cx, |form, cx| form.focus_name(window, cx)); } } diff --git a/src/credential/credential_panel.rs b/src/credential/credential_panel.rs index 54b87ad..0fbec6c 100644 --- a/src/credential/credential_panel.rs +++ b/src/credential/credential_panel.rs @@ -15,8 +15,9 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CREDENTIAL_PANEL_CONTEXT, CatalogIcon, DeleteCredential, EditCredential, - EditSelectedCredential, NewCredential, SelectNextCredential, SelectPreviousCredential, + CREDENTIAL_PANEL_CONTEXT, CatalogIcon, CopyCredentialPublicKey, DeleteCredential, + EditCredential, EditSelectedCredential, GenerateCredentialKey, NewCredential, + SelectNextCredential, SelectPreviousCredential, }; use crate::session::{CredentialId, CredentialKind, SessionStore, matches_credential_query}; @@ -156,7 +157,7 @@ impl CredentialPanel { .filter(|credential| matches_credential_query(credential, &self.query)) .map(|credential| { let hosts = store.sessions_using(credential.id).count(); - let summary = credential.summary(); + let summary = credential.summary(store.key_dir()); let detail = match hosts { 0 => summary, hosts => format!("{summary} · {hosts} 台主机"), @@ -225,20 +226,36 @@ impl CredentialPanel { fn render_empty(&self, cx: &Context) -> AnyElement { let muted = cx.theme().muted_foreground; let target = self.target.clone(); - let (title, hint, button) = if !self.store.read(cx).credentials().is_empty() { + let (title, hint, buttons) = if !self.store.read(cx).credentials().is_empty() { ("没有匹配的凭据", None, None) } else { + let generate = target.clone(); ( "还没有凭据", Some("保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。"), Some( - Button::new("credential-empty-new") - .small() - .icon(IconName::Plus) - .label("新建凭据…") - .on_click(move |_, window, cx| { - target.dispatch_action(&NewCredential, window, cx) - }), + h_flex() + .gap_2() + .flex_wrap() + .justify_center() + .child( + Button::new("credential-empty-new") + .small() + .icon(IconName::Plus) + .label("新建凭据…") + .on_click(move |_, window, cx| { + target.dispatch_action(&NewCredential, window, cx) + }), + ) + .child( + Button::new("credential-empty-generate") + .small() + .icon(CatalogIcon::KeyRound) + .label("生成密钥…") + .on_click(move |_, window, cx| { + generate.dispatch_action(&GenerateCredentialKey, window, cx) + }), + ), ), ) }; @@ -256,8 +273,8 @@ impl CredentialPanel { .when_some(hint, |view, hint| { view.child(div().text_xs().text_center().child(hint)) }) - .when_some(button, |view, button| { - view.child(div().pt_2().child(button)) + .when_some(buttons, |view, buttons| { + view.child(div().pt_2().child(buttons)) }) .into_any_element() } @@ -347,20 +364,35 @@ pub fn kind_icon(kind: CredentialKind) -> Icon { } } -/// The menu of one credential, or of the blank space under the rows. -fn build_context_menu(hit: Option, menu: PopupMenu) -> PopupMenu { - let Some(id) = hit else { - return menu.menu_with_icon( - "新建凭据…", - Icon::new(IconName::Plus), - Box::new(NewCredential), - ); +/// The menu of one credential, or of the blank space under the rows. A key +/// credential's has its public key to copy, for a server's +/// `authorized_keys`. +fn build_context_menu(hit: Option<(CredentialId, CredentialKind)>, menu: PopupMenu) -> PopupMenu { + let Some((id, kind)) = hit else { + return menu + .menu_with_icon( + "新建凭据…", + Icon::new(IconName::Plus), + Box::new(NewCredential), + ) + .menu_with_icon( + "生成密钥…", + Icon::new(CatalogIcon::KeyRound), + Box::new(GenerateCredentialKey), + ); }; menu.menu_with_icon( "编辑凭据…", Icon::new(CatalogIcon::Pencil), Box::new(EditCredential(id)), ) + .when(kind == CredentialKind::Key, |menu| { + menu.menu_with_icon( + "复制公钥", + Icon::new(CatalogIcon::ClipboardCopy), + Box::new(CopyCredentialPublicKey(id)), + ) + }) .separator() .menu_with_icon( "删除", @@ -380,6 +412,7 @@ impl Render for CredentialPanel { let rows = self.rows(cx); let clear_hit = self.menu_hit.clone(); let menu_hit = self.menu_hit.clone(); + let store = self.store.clone(); v_flex() .id("credential-panel") @@ -432,7 +465,15 @@ impl Render for CredentialPanel { clear_hit.set(None); } }) - .context_menu(move |menu, _, _| build_context_menu(menu_hit.get(), menu)), + // Built as the menu opens, so it can look the + // credential up. + .context_menu(move |menu, _, cx| { + let hit = menu_hit.get().and_then(|id| { + let credential = store.read(cx).credential(id)?; + Some((id, credential.kind)) + }); + build_context_menu(hit, menu) + }), ) } } diff --git a/src/credential/mod.rs b/src/credential/mod.rs index c4dd786..f8f110b 100644 --- a/src/credential/mod.rs +++ b/src/credential/mod.rs @@ -1,9 +1,10 @@ //! 凭据: saved logins hosts share. The list the left dock shows and the -//! dialog that edits one; the credentials themselves, and how a host logs in -//! with one, live in `session`. +//! dialog that edits one, which also takes pasted keys and generates new +//! ones; the credentials themselves, the keys ShellRS keeps, and how a host +//! logs in with one live in `session`. mod credential_dialog; mod credential_panel; -pub use credential_dialog::{CredentialForm, open_credential_dialog}; +pub use credential_dialog::{CredentialDialog, CredentialForm, open_credential_dialog}; pub use credential_panel::{CredentialPanel, kind_icon}; diff --git a/src/main.rs b/src/main.rs index 7eb25e0..b9d332c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,11 +61,19 @@ fn main() { /// /// Either way the store gets the system keychain, which is where passwords /// live. A machine without one still runs; the session form says so and -/// disables its password field. +/// disables its password field. Pasted and generated private keys go +/// beside the database. fn open_store() -> (SessionStore, Option) { let secrets = shellrs::secrets::system_secret_store(); match load_store() { - Ok(store) => (store.with_secrets(secrets), None), + // Keys are kept only with a database to remember them: a store + // that forgets everything would leave them behind unused. + Ok(store) => ( + store + .with_secrets(secrets) + .with_key_dir(shellrs::app::keys_dir()), + None, + ), Err(error) => { eprintln!("shellrs: 无法打开本地数据库:{error}"); ( diff --git a/src/session/credential.rs b/src/session/credential.rs index 06c57ea..bbadcf9 100644 --- a/src/session/credential.rs +++ b/src/session/credential.rs @@ -11,6 +11,7 @@ use gpui_kit::SharedString; use crate::secrets::SecretRef; use super::PublicId; +use super::private_key::is_kept_in; /// Stable identity of a credential. Never reused within a process. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -23,8 +24,10 @@ pub enum CredentialKind { /// entry. #[default] Password, - /// A private key file on this machine; its passphrase, if any, is kept - /// under the file's path like every other key's. + /// A private key file on this machine: one of the user's, or one + /// ShellRS keeps in its data directory because the key was pasted or + /// generated. Its passphrase, if any, is kept under the file's path + /// like every other key's. Key, /// Whatever keys the SSH agent holds. Agent, @@ -195,11 +198,24 @@ impl Credential { SecretRef::credential(self.keychain_id.as_str()) } + /// Whether the key is one ShellRS keeps itself in `key_dir`: pasted + /// into the form or generated there. + pub fn keeps_key_in(&self, key_dir: Option<&Path>) -> bool { + match (key_dir, self.key_path.as_deref()) { + (Some(dir), Some(path)) => is_kept_in(dir, Path::new(path)), + _ => false, + } + } + /// What the credential logs in as and with: `root · 密码`, or - /// `deploy · id_ed25519` for a key. - pub fn summary(&self) -> String { + /// `deploy · id_ed25519` for a key file. A key ShellRS keeps in + /// `key_dir` has a random file name nobody knows it by, so it is just + /// `deploy · 密钥`. + pub fn summary(&self, key_dir: Option<&Path>) -> String { let with = match (self.kind, self.key_path.as_deref()) { - (CredentialKind::Key, Some(path)) => file_name(path).to_string(), + (CredentialKind::Key, Some(path)) if !self.keeps_key_in(key_dir) => { + file_name(path).to_string() + } (kind, _) => kind.label().to_string(), }; format!("{} · {with}", self.user) @@ -285,18 +301,27 @@ mod tests { CredentialId(1), CredentialDraft::new("运维", CredentialKind::Password, "root"), ); - assert_eq!(password.summary(), "root · 密码"); + assert_eq!(password.summary(None), "root · 密码"); let key = Credential::new( CredentialId(2), CredentialDraft::new("部署", CredentialKind::Key, "deploy") .with_key_path("/Users/me/.ssh/id_ed25519"), ); - assert_eq!(key.summary(), "deploy · id_ed25519"); + assert_eq!(key.summary(None), "deploy · id_ed25519"); + assert_eq!( + key.summary(Some(Path::new("/Users/me/.ssh"))), + "deploy · 密钥", + "a key ShellRS keeps is not known by its file name" + ); + assert_eq!( + key.summary(Some(Path::new("/Users/me/Library/shellrs/keys"))), + "deploy · id_ed25519" + ); let agent = Credential::new( CredentialId(3), CredentialDraft::new("agent", CredentialKind::Agent, "me"), ); - assert_eq!(agent.summary(), "me · SSH Agent"); + assert_eq!(agent.summary(None), "me · SSH Agent"); } #[test] diff --git a/src/session/mod.rs b/src/session/mod.rs index 35b0730..a8c2c7a 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -8,6 +8,7 @@ mod group_dialog; mod login; mod model; mod outline; +mod private_key; mod secret_fields; mod session_dialog; mod session_panel; @@ -20,6 +21,7 @@ pub use group_dialog::{GroupForm, confirm_delete_group, open_group_dialog}; pub use login::{LoginMethod, SessionLogin}; pub use model::*; pub use outline::{NodeDrop, SessionNode, group_options, matches_query, session_tree_items}; +pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key}; pub use secret_fields::SecretFields; pub use session_dialog::{DeleteHandler, SessionForm, confirm_delete_session, open_session_dialog}; pub use session_panel::{SessionPanel, session_menu}; diff --git a/src/session/private_key.rs b/src/session/private_key.rs new file mode 100644 index 0000000..8d7c8e8 --- /dev/null +++ b/src/session/private_key.rs @@ -0,0 +1,475 @@ +//! Private keys ShellRS is given or makes itself: a key pasted into the +//! credential form, a freshly generated one, the public half people put on +//! their servers, and the files ShellRS keeps such keys in. +//! +//! A key that came as text has no file of its own, so ShellRS writes one +//! into its data directory and the credential refers to that file like to +//! any other. Logging in never needs to know where a key came from. + +use std::fmt; +use std::fs::File; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; + +use russh::keys::ssh_key::LineEnding; +use russh::keys::{Algorithm, PrivateKey, PublicKey}; +use zeroize::Zeroizing; + +use super::PublicId; + +/// The kinds of key the credential form generates. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyAlgorithm { + /// Short, fast, and what OpenSSH itself suggests. + #[default] + Ed25519, + /// For servers too old for Ed25519 (OpenSSH before 6.5). + Rsa, +} + +impl KeyAlgorithm { + /// Every algorithm, in the order the form lists them. + pub const ALL: [KeyAlgorithm; 2] = [KeyAlgorithm::Ed25519, KeyAlgorithm::Rsa]; + + pub fn label(self) -> &'static str { + match self { + KeyAlgorithm::Ed25519 => "Ed25519", + KeyAlgorithm::Rsa => "RSA 4096", + } + } + + fn algorithm(self) -> Algorithm { + match self { + KeyAlgorithm::Ed25519 => Algorithm::Ed25519, + // 4096 bits, the size `ssh-key` makes. + KeyAlgorithm::Rsa => Algorithm::Rsa { hash: None }, + } + } +} + +/// A key pair the credential form just made, not saved anywhere yet. +pub struct GeneratedKey { + key: PrivateKey, +} + +impl GeneratedKey { + /// Blocks: finding the primes of an RSA key takes a while. + pub fn generate(algorithm: KeyAlgorithm) -> anyhow::Result { + let mut rng = russh::keys::key::safe_rng(); + Ok(Self { + key: PrivateKey::random(&mut rng, algorithm.algorithm())?, + }) + } + + /// The public half as one line of `authorized_keys`, ending in + /// `comment` when there is one. + pub fn public_key_line(&self, comment: &str) -> String { + public_key_line(self.key.public_key(), comment) + } + + /// The text of an OpenSSH private key file, encrypted with `passphrase` + /// unless that is empty. + /// + /// Blocks while it derives the encryption key from the passphrase, + /// which is slow on purpose. + pub fn encode(&self, comment: &str, passphrase: &str) -> anyhow::Result> { + let mut key = self.key.clone(); + key.set_comment(comment.trim()); + if !passphrase.is_empty() { + key = key.encrypt(&mut russh::keys::key::safe_rng(), passphrase)?; + } + Ok(key.to_openssh(LineEnding::LF)?) + } +} + +impl fmt::Debug for GeneratedKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GeneratedKey") + .field("algorithm", &self.key.algorithm()) + .finish_non_exhaustive() + } +} + +fn public_key_line(key: &PublicKey, comment: &str) -> String { + let mut key = key.clone(); + key.set_comment(comment.trim()); + key.to_openssh().unwrap_or_default() +} + +/// A private key as pasted into the credential form, with what could be +/// read from it without its passphrase. +pub struct PastedKey { + /// The text as it goes into the key file: blanks around it trimmed, + /// Windows line endings undone, one newline at the end. + text: Zeroizing, + encrypted: bool, + /// Known when the format keeps it in the clear, or the key is not + /// encrypted. + public_key: Option, +} + +/// Why pasted text is not a private key. `Display` is what the form says. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PastedKeyError { + Empty, + /// One line of `id_ed25519.pub`, the half that goes on the server. + PublicKey, + Unreadable, +} + +impl fmt::Display for PastedKeyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + PastedKeyError::Empty => "请粘贴私钥", + PastedKeyError::PublicKey => "这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)", + PastedKeyError::Unreadable => "无法识别这段私钥,支持 OpenSSH、PEM 和 PuTTY 格式", + }) + } +} + +impl std::error::Error for PastedKeyError {} + +impl PastedKey { + pub fn parse(text: &str) -> Result { + let unix = Zeroizing::new(text.replace("\r\n", "\n")); + let trimmed = unix.trim(); + if trimmed.is_empty() { + return Err(PastedKeyError::Empty); + } + let file = Zeroizing::new(format!("{trimmed}\n")); + let first_line = trimmed.lines().next().unwrap_or_default(); + if PublicKey::from_openssh(first_line).is_ok() { + return Err(PastedKeyError::PublicKey); + } + // OpenSSH's own format keeps the public half outside the encrypted + // part, so it is known even before the passphrase is. + if trimmed.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") { + let key = PrivateKey::from_openssh(trimmed).map_err(|_| PastedKeyError::Unreadable)?; + return Ok(Self { + text: file, + encrypted: key.is_encrypted(), + public_key: Some(key.public_key().clone()), + }); + } + match russh::keys::decode_secret_key(trimmed, None) { + Ok(key) => Ok(Self { + text: file, + encrypted: false, + public_key: Some(key.public_key().clone()), + }), + Err(russh::keys::Error::KeyIsEncrypted) => Ok(Self::encrypted(file)), + Err(_) if looks_encrypted(trimmed) => Ok(Self::encrypted(file)), + Err(_) => Err(PastedKeyError::Unreadable), + } + } + + fn encrypted(text: Zeroizing) -> Self { + Self { + text, + encrypted: true, + public_key: None, + } + } + + /// What the key file holds. + pub fn text(&self) -> &str { + &self.text + } + + /// Whether the key needs a passphrase to be used. + pub fn is_encrypted(&self) -> bool { + self.encrypted + } + + /// The public half as one line of `authorized_keys`, with the key's own + /// comment, when the text gives it away without the passphrase. + pub fn public_key_line(&self) -> Option { + self.public_key + .as_ref() + .and_then(|key| key.to_openssh().ok()) + } +} + +impl fmt::Debug for PastedKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PastedKey") + .field("text", &"") + .field("encrypted", &self.encrypted) + .finish() + } +} + +/// Whether a key that would not open without a passphrase says it is +/// encrypted, in the formats that do not hand that back as an error: +/// PKCS#8's `ENCRYPTED PRIVATE KEY`, the older PEM `Proc-Type: 4,ENCRYPTED` +/// and PuTTY's `Encryption:` header. +fn looks_encrypted(text: &str) -> bool { + text.lines().any(|line| { + let line = line.trim(); + line == "-----BEGIN ENCRYPTED PRIVATE KEY-----" + || line == "Proc-Type: 4,ENCRYPTED" + || line + .strip_prefix("Encryption:") + .is_some_and(|cipher| cipher.trim() != "none") + }) +} + +/// The public half of the key in the file at `path`, as one line of +/// `authorized_keys`: read from the private key when its format keeps the +/// public half in the clear, else from `.pub` beside it. +/// +/// Blocks on the file system. +pub fn read_public_key(path: &Path) -> Option { + let from_key = read_small(path) + .and_then(|text| PastedKey::parse(&text).ok()) + .and_then(|key| key.public_key_line()); + from_key.or_else(|| { + let mut public_path = path.as_os_str().to_owned(); + public_path.push(".pub"); + let text = read_small(Path::new(&public_path))?; + let line = text.lines().map(str::trim).find(|line| !line.is_empty())?; + PublicKey::from_openssh(line).ok()?.to_openssh().ok() + }) +} + +/// A file's text, if it is small enough to be a key. Anything else that was +/// picked by mistake is not read whole. +fn read_small(path: &Path) -> Option> { + const LARGEST_KEY: u64 = 64 * 1024; + let mut file = File::open(path).ok()?; + if file.metadata().ok()?.len() > LARGEST_KEY { + return None; + } + let mut text = Zeroizing::new(String::new()); + file.read_to_string(&mut text).ok()?; + Some(text) +} + +/// Write a private key file into `dir`, where ShellRS keeps the keys it was +/// given or made: over `replace` when given, else under a new random name. +/// Only the user may read it, as `ssh` asks of every key. Returns where it +/// went. +/// +/// The text goes to a temporary file first, so a login reading the key at +/// that moment sees the old key or the new one, never half of either. +pub fn write_key_file(dir: &Path, replace: Option<&Path>, text: &str) -> io::Result { + create_private_dir(dir)?; + let path = match replace { + Some(path) => path.to_path_buf(), + None => unused_name(dir), + }; + let temporary = dir.join(format!(".{}.tmp", PublicId::generate())); + let written = create_private_file(&temporary).and_then(|mut file| { + file.write_all(text.as_bytes())?; + file.sync_all() + }); + let result = written.and_then(|()| std::fs::rename(&temporary, &path)); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map(|()| path) +} + +/// Whether `path` is one of the key files ShellRS keeps in `dir`. +pub fn is_kept_in(dir: &Path, path: &Path) -> bool { + path.parent() == Some(dir) +} + +fn unused_name(dir: &Path) -> PathBuf { + loop { + let path = dir.join(PublicId::generate().as_str()); + if !path.exists() { + return path; + } + } +} + +#[cfg(unix)] +fn create_private_dir(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::DirBuilderExt as _; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) +} + +#[cfg(not(unix))] +fn create_private_dir(dir: &Path) -> io::Result<()> { + std::fs::create_dir_all(dir) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +/// On Windows the data directory is the user's own, and a new file takes +/// its permissions. +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ed25519() -> GeneratedKey { + GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap() + } + + fn public_half(text: &str) -> PublicKey { + PrivateKey::from_openssh(text).unwrap().public_key().clone() + } + + #[test] + fn a_generated_key_reads_back_from_its_file_text() { + let key = ed25519(); + let text = key.encode(" 部署 ", "").unwrap(); + let pasted = PastedKey::parse(&text).unwrap(); + assert!(!pasted.is_encrypted()); + let line = key.public_key_line(" 部署 "); + assert!(line.starts_with("ssh-ed25519 "), "{line}"); + assert!(line.ends_with(" 部署"), "{line}"); + assert_eq!(pasted.public_key_line(), Some(line)); + // The comment written into the file is the one the line shows. + let decoded = russh::keys::decode_secret_key(&text, None).unwrap(); + assert_eq!(decoded.comment().as_str_lossy(), "部署"); + } + + #[test] + fn a_passphrase_encrypts_the_key_and_opens_it_again() { + let key = ed25519(); + let plain = key.encode("部署", "").unwrap(); + let text = key.encode("部署", "correct horse").unwrap(); + let pasted = PastedKey::parse(&text).unwrap(); + assert!(pasted.is_encrypted()); + // OpenSSH keeps the public half outside the encryption, without + // the comment, which is encrypted along with the key. + assert_eq!(pasted.public_key_line(), Some(key.public_key_line(""))); + assert!(matches!( + russh::keys::decode_secret_key(&text, None), + Err(russh::keys::Error::KeyIsEncrypted) + )); + let opened = russh::keys::decode_secret_key(&text, Some("correct horse")).unwrap(); + assert_eq!(opened.public_key(), &public_half(&plain)); + } + + #[test] + fn an_rsa_key_is_generated_too() { + let key = GeneratedKey::generate(KeyAlgorithm::Rsa).unwrap(); + assert!(key.public_key_line("").starts_with("ssh-rsa ")); + } + + #[test] + fn pasted_text_is_tidied_into_a_file() { + let text = ed25519().encode("", "").unwrap(); + let windows = format!("\r\n {} \r\n\r\n", text.trim().replace('\n', "\r\n")); + let pasted = PastedKey::parse(&windows).unwrap(); + assert_eq!(pasted.text(), text.as_str()); + assert!(!pasted.text().contains('\r')); + } + + #[test] + fn pasting_something_else_says_what_it_is() { + assert_eq!(PastedKey::parse(" \n ").unwrap_err(), PastedKeyError::Empty); + let public = ed25519().public_key_line("me@laptop"); + assert_eq!( + PastedKey::parse(&public).unwrap_err(), + PastedKeyError::PublicKey + ); + assert_eq!( + PastedKey::parse("hunter2").unwrap_err(), + PastedKeyError::Unreadable + ); + assert_eq!( + PastedKey::parse( + "-----BEGIN OPENSSH PRIVATE KEY-----\nnot base64\n-----END OPENSSH PRIVATE KEY-----" + ) + .unwrap_err(), + PastedKeyError::Unreadable + ); + } + + #[test] + fn older_encrypted_formats_are_taken_without_their_public_half() { + let pem = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00112233445566778899AABBCCDDEEFF\n\nAAAA\n-----END RSA PRIVATE KEY-----"; + let pasted = PastedKey::parse(pem).unwrap(); + assert!(pasted.is_encrypted()); + assert_eq!(pasted.public_key_line(), None); + let pkcs8 = + "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----"; + assert!(PastedKey::parse(pkcs8).unwrap().is_encrypted()); + } + + #[test] + fn the_debug_output_never_shows_the_key() { + let key = ed25519(); + assert!(!format!("{key:?}").contains("OPENSSH")); + let text = key.encode("", "").unwrap(); + let pasted = PastedKey::parse(&text).unwrap(); + let debug = format!("{pasted:?}"); + assert!(!debug.contains("OPENSSH"), "{debug}"); + } + + #[test] + fn the_public_key_comes_from_the_key_file_or_the_pub_beside_it() { + let dir = tempfile::tempdir().unwrap(); + let key = ed25519(); + + let plain = dir.path().join("id_plain"); + std::fs::write(&plain, key.encode("plain", "").unwrap()).unwrap(); + assert_eq!(read_public_key(&plain), Some(key.public_key_line("plain"))); + + // An older format that hides the public half: the .pub has it. + let hidden = dir.path().join("id_hidden"); + std::fs::write( + &hidden, + "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----\n", + ) + .unwrap(); + assert_eq!(read_public_key(&hidden), None); + let line = key.public_key_line("me@laptop"); + std::fs::write(dir.path().join("id_hidden.pub"), format!("{line}\n")).unwrap(); + assert_eq!(read_public_key(&hidden), Some(line)); + + assert_eq!(read_public_key(&dir.path().join("missing")), None); + } + + #[test] + fn a_key_file_is_new_and_private_unless_it_replaces_one() { + let data = tempfile::tempdir().unwrap(); + let dir = data.path().join("keys"); + let first = write_key_file(&dir, None, "one").unwrap(); + let second = write_key_file(&dir, None, "two").unwrap(); + assert_ne!(first, second); + assert!(is_kept_in(&dir, &first)); + assert!(!is_kept_in(&dir, data.path())); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "one"); + + let replaced = write_key_file(&dir, Some(&first), "three").unwrap(); + assert_eq!(replaced, first); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "three"); + // Nothing is left behind from writing through a temporary file. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&first), 0o600); + assert_eq!(mode(&second), 0o600); + assert_eq!(mode(&dir), 0o700); + } + } +} diff --git a/src/session/secret_fields.rs b/src/session/secret_fields.rs index a23fcc1..0b56a99 100644 --- a/src/session/secret_fields.rs +++ b/src/session/secret_fields.rs @@ -65,13 +65,16 @@ impl SecretFields { }); // A passphrase belongs to a key file, so picking another key makes // whatever is in the field meaningless. Clearing it is also the - // visible cue that the new key needs its own passphrase. + // visible cue that the new key needs its own passphrase. Observers + // hear of the new path, which the credential form reads the public + // key from. let key_path_subscription = cx.subscribe_in( &key_path, window, |this, _, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Change) { this.forget_loaded_passphrase(window, cx); + cx.notify(); } }, ); @@ -159,6 +162,28 @@ impl SecretFields { cx.notify(); } + /// Empty the passphrase field, as a key from elsewhere needs a + /// passphrase of its own. Committing an empty field afterwards leaves + /// whatever the keychain has alone. + pub fn clear_passphrase(&mut self, window: &mut Window, cx: &mut Context) { + self.passphrase_loaded = false; + self.passphrase + .update(cx, |input, cx| input.set_value("", window, cx)); + cx.notify(); + } + + /// What the empty passphrase field says leaving it empty does. + pub fn set_passphrase_placeholder( + &mut self, + placeholder: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + self.passphrase.update(cx, |input, cx| { + input.set_placeholder(placeholder, window, cx) + }); + } + /// Ask for a private key file with the system's picker. pub fn choose_key(&mut self, window: &mut Window, cx: &mut Context) { let receiver = cx.prompt_for_paths(PathPromptOptions { diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index 3264f01..1447610 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -1,3 +1,4 @@ +use std::path::{Path, PathBuf}; use std::time::Duration; use gpui_kit::component::{ @@ -68,6 +69,9 @@ pub struct SessionForm { /// credential select's rows. The dialog is modal, so nothing can change /// them while it is open. credentials: Vec, + /// Where ShellRS keeps pasted and generated keys, to name those in the + /// credential's summary. + key_dir: Option, group: Entity>>, /// Parallel to the group select's rows; `None` is the root of the tree. group_ids: Vec>, @@ -89,12 +93,13 @@ impl SessionForm { cx: &mut Context, ) -> Self { let secrets = store.read(cx).secrets(); - let (draft, options, credentials, editing_connected) = { + let (draft, options, credentials, key_dir, editing_connected) = { let read = store.read(cx); ( editing.and_then(|id| read.session(id)).map(Session::draft), group_options(read.groups(), &[]), read.credentials().to_vec(), + read.key_dir().map(Path::to_path_buf), editing .and_then(|id| read.session(id)) .is_some_and(|session| session.state != super::ConnectionState::Disconnected), @@ -157,7 +162,7 @@ impl SessionForm { SelectState::new( credentials .iter() - .map(credential_option) + .map(|credential| credential_option(credential, key_dir.as_deref())) .collect::>(), credential_ix.map(IndexPath::new), window, @@ -203,6 +208,7 @@ impl SessionForm { fields, credential, credentials, + key_dir, group, group_ids, error: None, @@ -514,7 +520,7 @@ impl SessionForm { /// The field that picks a saved credential, with what it logs in as. fn credential_field(&self, cx: &App) -> Field { let summary = match self.selected_credential(cx) { - Some(credential) => Some(credential_summary(credential)), + Some(credential) => Some(credential_summary(credential, self.key_dir.as_deref())), None if self.credentials.is_empty() => { Some("还没有凭据,可在侧栏的「凭据」中新建".into()) } @@ -648,14 +654,17 @@ enum CommittedLogin { } /// A credential as the host form's select lists it: `运维(root · 密码)`. -fn credential_option(credential: &Credential) -> SharedString { - format!("{}({})", credential.name, credential.summary()).into() +fn credential_option(credential: &Credential, key_dir: Option<&Path>) -> SharedString { + format!("{}({})", credential.name, credential.summary(key_dir)).into() } /// What a host using `credential` logs in as and with. -fn credential_summary(credential: &Credential) -> SharedString { +fn credential_summary(credential: &Credential, key_dir: Option<&Path>) -> SharedString { let with = match credential.kind { CredentialKind::Password => "使用凭据保存的密码".to_string(), + CredentialKind::Key if credential.keeps_key_in(key_dir) => { + "使用 ShellRS 保存的私钥".to_string() + } CredentialKind::Key => format!( "使用私钥 {}", credential.key_path.as_deref().unwrap_or_default() diff --git a/src/session/store.rs b/src/session/store.rs index 11c5434..2f32167 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui_kit::{Context, EventEmitter, SharedString}; @@ -6,6 +8,7 @@ use gpui_kit::{Context, EventEmitter, SharedString}; use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore}; use super::database::now_seconds; +use super::private_key::{is_kept_in, write_key_file}; use super::{ AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, HostOs, NodeDrop, @@ -56,6 +59,10 @@ pub struct SessionStore { /// Where passwords go. Defaults to a store that keeps nothing, so unit /// tests never touch the machine's keychain. secrets: SharedSecretStore, + /// 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. + key_dir: Option, } /// How many sessions the start page lists as recently connected. @@ -94,6 +101,7 @@ impl SessionStore { next_credential_id: 1, database: None, secrets: Arc::new(NoSecretStore), + key_dir: None, } } @@ -104,6 +112,13 @@ impl SessionStore { self } + /// Keep pasted and generated private keys in `dir`. `main` puts it + /// beside the database; UI tests use a temporary directory. + pub fn with_key_dir(mut self, dir: impl Into) -> Self { + self.key_dir = Some(dir.into()); + self + } + /// Read everything back from `database` and keep writing to it. Ids carry /// over from disk, so the allocators resume past the largest stored id. pub fn load(database: SessionDatabase) -> rusqlite::Result { @@ -145,6 +160,7 @@ impl SessionStore { bookmarks, database: Some(database), secrets: Arc::new(NoSecretStore), + key_dir: None, }) } @@ -805,6 +821,13 @@ impl SessionStore { { self.save_secret(previous.password_secret(), None, cx); } + if let Some(path) = previous.key_path.as_deref() + && self + .credential(id) + .is_some_and(|credential| credential.key_path.as_deref() != Some(path)) + { + self.forget_key_file(path, cx); + } for (session, before) in logins { self.forget_endpoint_password(before.password.clone(), cx); if self.login(session).as_ref() != Some(&before) { @@ -857,6 +880,9 @@ impl SessionStore { if credential.kind == CredentialKind::Password { self.save_secret(credential.password_secret(), None, cx); } + if let Some(path) = credential.key_path.as_deref() { + self.forget_key_file(path, cx); + } cx.notify(); released } @@ -1117,6 +1143,79 @@ impl SessionStore { self.secrets.clone() } + /// Where pasted and generated private keys are kept, if anywhere. + pub fn key_dir(&self) -> Option<&Path> { + self.key_dir.as_deref() + } + + /// Save the text of a private key pasted into or generated by the form + /// for `credential` (`None` while it is being created) and say where it + /// went. + /// + /// A key file of ShellRS's own that nothing else logs in with is written + /// over: the credential keeps its path, so the hosts using it are not + /// reconnected with a key no server has been given yet. Anything else + /// gets a new file. Writes on the calling thread, like the database: + /// it is one small file, and the credential must not refer to it before + /// it is there. + pub fn save_private_key( + &self, + credential: Option, + text: &str, + ) -> io::Result { + let dir = self + .key_dir + .as_deref() + .ok_or_else(|| io::Error::other("没有可以保存私钥的目录"))?; + let replace = credential + .and_then(|id| self.credential(id)) + .and_then(|credential| credential.key_path.as_deref()) + .filter(|path| self.keeps_key(path) && self.key_file_users(path) == 1) + .map(Path::new); + let path = write_key_file(dir, replace, text)?; + Ok(path.display().to_string().into()) + } + + /// Whether `path` is a key file ShellRS keeps itself. + fn keeps_key(&self, path: &str) -> bool { + self.key_dir + .as_deref() + .is_some_and(|dir| is_kept_in(dir, Path::new(path))) + } + + /// How many credentials and hosts log in with the key file at `path`. + /// A host using a credential has no key file of its own. + fn key_file_users(&self, path: &str) -> usize { + let credentials = self + .credentials + .iter() + .filter(|credential| credential.key_path.as_deref() == Some(path)) + .count(); + let sessions = self + .sessions + .iter() + .filter(|session| session.key_path.as_deref() == Some(path)) + .count(); + credentials + sessions + } + + /// Delete a key file ShellRS keeps, with its saved passphrase, once + /// nothing logs in with it any more. A key file anywhere else is the + /// user's and is never touched. + fn forget_key_file(&mut self, path: &str, cx: &mut Context) { + if !self.keeps_key(path) || self.key_file_users(path) > 0 { + return; + } + if let Err(error) = std::fs::remove_file(path) + && error.kind() != io::ErrorKind::NotFound + { + cx.emit(SessionStoreEvent::PersistFailed( + format!("ShellRS 保存的私钥未能删除:{error}").into(), + )); + } + self.save_secret(SecretRef::passphrase(path), None, cx); + } + /// Write a secret to the system keychain, or delete it when `value` is /// `None`. /// @@ -1564,6 +1663,109 @@ mod tests { ); } + fn key_credential(store: &mut SessionStore, path: &str) -> CredentialId { + store.insert_credential_unnotified( + CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path(path), + ) + } + + fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap() + } + + #[test] + fn a_pasted_key_gets_a_file_of_its_own_and_keeps_it_when_replaced() { + let data = tempfile::tempdir().unwrap(); + let keys = data.path().join("keys"); + let mut store = SessionStore::empty().with_key_dir(&keys); + + // A credential being created: a new file in the key directory. + let path = store.save_private_key(None, "one").unwrap(); + assert!(Path::new(path.as_ref()).starts_with(&keys)); + let credential = key_credential(&mut store, &path); + assert!( + store + .credential(credential) + .unwrap() + .keeps_key_in(store.key_dir()) + ); + + // Another key for it goes over the same file: the hosts using it + // keep their login, and nothing is left behind. + assert_eq!( + store.save_private_key(Some(credential), "two").unwrap(), + path + ); + assert_eq!(read(&path), "two"); + + // Not when a host logs in with that file as well. + let mut host = draft("web", None); + host.auth = AuthKind::Key; + host.key_path = Some(path.clone()); + store.insert_unnotified(host); + let moved = store.save_private_key(Some(credential), "three").unwrap(); + assert_ne!(moved, path); + assert_eq!(read(&path), "two"); + assert_eq!(read(&moved), "three"); + + // A key file of the user's is never written over. + let mine = data.path().join("id_mine"); + std::fs::write(&mine, "mine").unwrap(); + let theirs = key_credential(&mut store, &mine.display().to_string()); + let saved = store.save_private_key(Some(theirs), "new").unwrap(); + assert!(Path::new(saved.as_ref()).starts_with(&keys)); + assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine"); + } + + #[test] + fn without_a_key_directory_no_key_is_saved() { + assert!(SessionStore::empty().save_private_key(None, "one").is_err()); + } + + #[gpui_kit::test] + fn a_kept_key_goes_with_the_last_credential_using_it(cx: &mut gpui_kit::TestAppContext) { + use crate::secrets::SecretStore as _; + use gpui_kit::AppContext as _; + + let data = tempfile::tempdir().unwrap(); + let secrets = Arc::new(crate::secrets::InMemorySecretStore::default()); + let mut store = SessionStore::empty() + .with_secrets(secrets.clone()) + .with_key_dir(data.path().join("keys")); + let kept = store.save_private_key(None, "kept").unwrap(); + let one = key_credential(&mut store, &kept); + let two = key_credential(&mut store, &kept); + let passphrase = SecretRef::passphrase(kept.as_ref()); + secrets.set(&passphrase, "correct horse").unwrap(); + let mine = data.path().join("id_mine"); + std::fs::write(&mine, "mine").unwrap(); + let theirs = key_credential(&mut store, &mine.display().to_string()); + let store = cx.new(|_| store); + + store.update(cx, |store, cx| store.remove_credential(one, cx)); + cx.run_until_parked(); + assert!( + Path::new(kept.as_ref()).exists(), + "another credential still logs in with it" + ); + + store.update(cx, |store, cx| { + store.update_credential( + two, + CredentialDraft::new("部署", CredentialKind::Agent, "deploy"), + cx, + ) + }); + cx.run_until_parked(); + assert!(!Path::new(kept.as_ref()).exists()); + assert!(secrets.get(&passphrase).unwrap().is_none()); + + // The user's own key file stays, whatever becomes of the credential. + store.update(cx, |store, cx| store.remove_credential(theirs, cx)); + cx.run_until_parked(); + assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine"); + } + #[test] fn the_detected_system_survives_an_edit() { let mut store = SessionStore::empty(); diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 46e587d..62a9576 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -397,12 +397,10 @@ impl TerminalEngine { } pub fn status(&self) -> TerminalStatus { - let term = self.runtime.term.lock(); - let cursor = term.grid().cursor.point; TerminalStatus::new( self.lifecycle.clone(), - cursor.line.0.max(0) as usize, - cursor.column.0, + self.size.columns(), + self.size.rows(), ) } diff --git a/src/terminal/model.rs b/src/terminal/model.rs index 3e9eb54..1c2161b 100644 --- a/src/terminal/model.rs +++ b/src/terminal/model.rs @@ -137,19 +137,21 @@ impl fmt::Display for TerminalLifecycle { } } +/// What the window's status bar shows of a terminal: whether it runs, and +/// how many columns and rows its screen has. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TerminalStatus { lifecycle: TerminalLifecycle, - cursor_row: usize, - cursor_column: usize, + columns: usize, + rows: usize, } impl TerminalStatus { - pub fn new(lifecycle: TerminalLifecycle, cursor_row: usize, cursor_column: usize) -> Self { + pub fn new(lifecycle: TerminalLifecycle, columns: usize, rows: usize) -> Self { Self { lifecycle, - cursor_row, - cursor_column, + columns, + rows, } } @@ -157,12 +159,12 @@ impl TerminalStatus { &self.lifecycle } - pub fn cursor_row(&self) -> usize { - self.cursor_row + pub fn columns(&self) -> usize { + self.columns } - pub fn cursor_column(&self) -> usize { - self.cursor_column + pub fn rows(&self) -> usize { + self.rows } } diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 2b1be44..fc51c73 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -18,7 +18,7 @@ use crate::shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items}; use super::{ LatencyLevel, RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, - TerminalLifecycle, TerminalMenuItems, TerminalView, + TerminalLifecycle, TerminalMenuItems, TerminalStatus, TerminalView, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -143,6 +143,11 @@ impl TerminalPanel { self.terminal.read(cx).lifecycle(cx) } + /// The terminal's state and size, for the window's status bar. + pub fn status(&self, cx: &App) -> TerminalStatus { + self.terminal.read(cx).status(cx) + } + pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context) { let Some(login) = self.store.read(cx).login(self.session_id) else { return; diff --git a/src/workspace/credentials.rs b/src/workspace/credentials.rs index acc6dfe..eded7c9 100644 --- a/src/workspace/credentials.rs +++ b/src/workspace/credentials.rs @@ -1,12 +1,18 @@ //! The workspace's share of credentials: the sidebar's switch and the //! commands of the credential list. +use std::path::PathBuf; use std::rc::Rc; +use gpui_kit::component::{WindowExt as _, notification::Notification}; use gpui_kit::*; -use crate::app::{DeleteCredential, EditCredential, NewCredential, ShowCredentials}; -use crate::credential::open_credential_dialog; +use crate::app::{ + CopyCredentialPublicKey, DeleteCredential, EditCredential, GenerateCredentialKey, + NewCredential, ShowCredentials, +}; +use crate::credential::{CredentialDialog, open_credential_dialog}; +use crate::session::read_public_key; use crate::shared::confirm_delete; use super::{sidebar::SidebarMode, workspace_view::Workspace}; @@ -27,7 +33,21 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - open_credential_dialog(None, self.store.clone(), window, cx); + open_credential_dialog(CredentialDialog::New, self.store.clone(), window, cx); + } + + pub(super) fn on_generate_credential_key( + &mut self, + _: &GenerateCredentialKey, + window: &mut Window, + cx: &mut Context, + ) { + open_credential_dialog( + CredentialDialog::GenerateKey, + self.store.clone(), + window, + cx, + ); } pub(super) fn on_edit_credential( @@ -37,10 +57,50 @@ impl Workspace { cx: &mut Context, ) { if self.store.read(cx).credential(action.0).is_some() { - open_credential_dialog(Some(action.0), self.store.clone(), window, cx); + open_credential_dialog( + CredentialDialog::Edit(action.0), + self.store.clone(), + window, + cx, + ); } } + /// Copy the public half of a key credential's key, read from its file + /// off the UI thread, and say whether that worked. + pub(super) fn on_copy_credential_public_key( + &mut self, + action: &CopyCredentialPublicKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some(path) = self + .store + .read(cx) + .credential(action.0) + .and_then(|credential| credential.key_path.clone()) + else { + return; + }; + cx.spawn_in(window, async move |_, cx| { + let file = PathBuf::from(path.as_ref()); + let line = cx + .background_executor() + .spawn(async move { read_public_key(&file) }) + .await; + cx.update(|window, cx| match line { + Some(line) => { + cx.write_to_clipboard(ClipboardItem::new_string(line)); + window.push_notification(Notification::success("已复制公钥"), cx); + } + None => window + .push_notification(Notification::error(format!("无法从 {path} 读出公钥")), cx), + }) + .ok(); + }) + .detach(); + } + /// Ask, naming the hosts that use the credential, then delete it. Those /// hosts keep their connections; they log in on their own next time. pub(super) fn on_delete_credential( diff --git a/src/workspace/status_bar.rs b/src/workspace/status_bar.rs index 1003a94..aeb4197 100644 --- a/src/workspace/status_bar.rs +++ b/src/workspace/status_bar.rs @@ -1,6 +1,6 @@ use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, separator::Separator, - status_bar::StatusBar, + status_bar::StatusBar, tooltip::Tooltip, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; @@ -11,10 +11,13 @@ use crate::session::{ConnectionState, Session}; use crate::terminal::{TerminalLifecycle, TerminalStatus}; /// The active connection/process state on the left and terminal facts on the -/// right. Local terminals report their real emulator cursor coordinates. +/// right, ending in the size of the terminal in front, local or remote. With +/// no terminal in front there is no size to show. #[derive(IntoElement)] pub enum WorkspaceStatus { - Session(Option), + /// The active session, with the terminal in front when that is one of + /// its terminals: the start page and the settings have none. + Session(Option, Option), Local(TerminalStatus), /// An SFTP tab: its own connection and what went wrong in it. Explorer(Option, ExplorerStatus), @@ -41,23 +44,27 @@ impl RenderOnce for WorkspaceStatus { // Red when something is wrong: a dropped connection, or an SFTP // tab's directory that could not be read. let mut alarming = false; - let (text, icon, address, cursor): (SharedString, Icon, Option, String) = match self - { - WorkspaceStatus::Session(active) => match active { + let (text, icon, address, terminal): ( + SharedString, + Icon, + Option, + Option, + ) = match self { + WorkspaceStatus::Session(active, terminal) => match active { Some(session) => { alarming = session.state == ConnectionState::Disconnected; ( format!("{} {}", session.state.label(), session.name).into(), state_icon(session.state, cx), Some(session.address()), - "行 1,列 1".into(), + terminal, ) } None => ( ConnectionState::Disconnected.label().into(), Icon::new(CatalogIcon::Unplug).text_color(muted), None, - "行 1,列 1".into(), + terminal, ), }, WorkspaceStatus::Explorer(session, status) => { @@ -87,7 +94,7 @@ impl RenderOnce for WorkspaceStatus { text.into(), icon, session.map(|session| session.address()), - "行 1,列 1".into(), + None, ) } WorkspaceStatus::Local(status) => { @@ -109,11 +116,7 @@ impl RenderOnce for WorkspaceStatus { format!("{} 本地终端", lifecycle.label()).into(), icon, None, - format!( - "行 {},列 {}", - status.cursor_row() + 1, - status.cursor_column() + 1 - ), + Some(status), ) } }; @@ -139,7 +142,20 @@ impl RenderOnce for WorkspaceStatus { .right("编码 UTF-8") .right(Separator::vertical()) .right("终端 xterm-256color") - .right(Separator::vertical()) - .right(cursor) + .when_some(terminal, |bar, terminal| { + let (columns, rows) = (terminal.columns(), terminal.rows()); + let size = format!("{columns}×{rows}"); + let explained = format!("终端尺寸:{columns} 列,{rows} 行"); + bar.right(Separator::vertical()).right( + div() + .id("status-terminal-size") + .test_support() + .aria_label(size.clone()) + .tooltip(move |window, cx| { + Tooltip::new(explained.clone()).build(window, cx) + }) + .child(size), + ) + }) } } diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 84a0c4d..16a9c82 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2060,11 +2060,18 @@ impl Render for Workspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let active = self.store.read(cx).active().cloned(); let status = match self.active_tab { + Some(CenterTab::Terminal(id)) => { + let terminal = self + .terminals + .get(&id) + .map(|panel| panel.read(cx).status(cx)); + WorkspaceStatus::Session(active, terminal) + } Some(CenterTab::LocalTerminal(id)) => self .local_terminals .get(&id) .map(|panel| WorkspaceStatus::Local(panel.read(cx).status(cx))) - .unwrap_or_else(|| WorkspaceStatus::Session(active)), + .unwrap_or_else(|| WorkspaceStatus::Session(active, None)), // An SFTP tab tells its own connection, not the session's, and // what went wrong in it. Some(CenterTab::Explorer(id)) => match self.explorers.get(&id) { @@ -2073,9 +2080,9 @@ impl Render for Workspace { let session = self.store.read(cx).session(panel.session_id()).cloned(); WorkspaceStatus::Explorer(session, panel.status(cx)) } - None => WorkspaceStatus::Session(active), + None => WorkspaceStatus::Session(active, None), }, - _ => WorkspaceStatus::Session(active), + _ => WorkspaceStatus::Session(active, None), }; let sidebar = self.sidebar_showing(cx); let running_forwards = self.forwards.read(cx).active_count(); @@ -2141,7 +2148,9 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_stop_forward)) .on_action(cx.listener(Self::on_show_credentials)) .on_action(cx.listener(Self::on_new_credential)) + .on_action(cx.listener(Self::on_generate_credential_key)) .on_action(cx.listener(Self::on_edit_credential)) + .on_action(cx.listener(Self::on_copy_credential_public_key)) .on_action(cx.listener(Self::on_delete_credential)) .on_action(cx.listener(Self::on_toggle_theme)) .on_action(cx.listener(Self::on_focus_search)) diff --git a/tests/ui.rs b/tests/ui.rs index ad79088..a1b4942 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -14,11 +14,12 @@ use gpui_kit::{ use shellrs::app::{ CenterTab, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, ConnectGroup, - ConnectSession, CopySessionHost, CopySessionId, DeleteCredential, DeleteForward, DeleteGroup, - DeleteSession, DisconnectSession, DisconnectTerminal, EditCredential, EditForward, EditSession, - ExpandAllGroups, FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch, - InstallCliCommand, NewLocalTerminal, NewSessionInGroup, OpenExplorer, ReconnectTerminal, - RemoveAgentSkill, RenameGroup, RenameTerminal, StartForward, StopForward, ToggleSessionPanel, + ConnectSession, CopyCredentialPublicKey, CopySessionHost, CopySessionId, DeleteCredential, + DeleteForward, DeleteGroup, DeleteSession, DisconnectSession, DisconnectTerminal, + EditCredential, EditForward, EditSession, ExpandAllGroups, FindInTerminal, FindNextInTerminal, + FindPreviousInTerminal, FocusSearch, InstallCliCommand, NewLocalTerminal, NewSessionInGroup, + OpenExplorer, ReconnectTerminal, RemoveAgentSkill, RenameGroup, RenameTerminal, StartForward, + StopForward, ToggleSessionPanel, }; use shellrs::cli::{AgentKind, IntegrationPaths}; use shellrs::connection::{ @@ -32,8 +33,9 @@ use shellrs::forward::{ use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; use shellrs::session::{ AuthKind, ConnectionState, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, - ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, HostOs, LoginMethod, - SessionDatabase, SessionDraft, SessionId, SessionLogin, SessionStore, + ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GeneratedKey, GroupDraft, GroupId, + HostOs, KeyAlgorithm, LoginMethod, PastedKey, SessionDatabase, SessionDraft, SessionId, + SessionLogin, SessionStore, read_public_key, }; use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore}; use shellrs::sftp::{ @@ -77,12 +79,17 @@ fn open_workspace_with_store( } /// Same, with the session dialog's connection test answered by `tester`. +/// +/// Motion is reduced, as in the other fixtures: dialogs would otherwise +/// slide in over real time, and under a loaded test run a field or button +/// can move between being found and being clicked. fn open_workspace_with_tester( cx: &mut TestAppContext, store: SessionStore, tester: Arc, ) -> (WindowHandle, Entity) { cx.update(shellrs::init); + cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| store); @@ -982,8 +989,7 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { window.click("commit", cx); }) .unwrap(); - // The dialog slides in over real time, and the error line is its last - // row: under a loaded test run it can take a few frames to come into view. + // The commit action is dispatched deferred; wait for its error line. cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { window .try_find("form-error") @@ -2917,6 +2923,87 @@ async fn clearing_a_terminal_keeps_only_the_prompt_line(cx: &mut TestAppContext) assert_eq!(factory.written_text(), "root@localhost:~# "); } +#[gpui_kit::test] +async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let terminal = RemoteTerminalId(FIRST_NEW_TERMINAL); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectSession(SessionId(WEB_01))), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running + }) + .await; + let columns = |cx: &App| { + workspace + .read(cx) + .remote_terminal(terminal) + .expect("terminal exists") + .read(cx) + .status(cx) + .columns() + }; + // Whether the status bar shows the terminal's screen as it is now. + let shows_its_size = |window: &mut gpui_kit::Window, cx: &mut App| { + let status = workspace + .read(cx) + .remote_terminal(terminal) + .expect("terminal exists") + .read(cx) + .status(cx); + let size = format!("{}×{}", status.columns(), status.rows()); + window + .try_find("status-terminal-size") + .is_some_and(|element| element.label() == Some(size.as_str())) + }; + // Laid out in the window, not the 80 columns it starts with. + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + columns(cx) > 80 && shows_its_size(window, cx) + }) + .await; + let wide = cx.update(|cx| columns(cx)); + + // A smaller window, a smaller terminal, and the status bar follows. + cx.simulate_window_resize(handle.into(), size(px(900.), px(600.))); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + columns(cx) < wide && shows_its_size(window, cx) + }) + .await; + + // An SFTP tab has no terminal, so no size either. + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(OpenExplorer(SessionId(WEB_01))), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find(("explorer", SFTP_TAB)) + .is_some_and(|explorer| explorer.visible()) + }) + .await; + in_frame(cx, handle, |window, cx| { + assert!(window.find("status-connection").visible()); + assert!(window.try_find("status-terminal-size").is_none()); + window.click("new-local-terminal", cx); + }); + + // A local terminal shows its own. + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + let Some(local) = workspace.read(cx).local_terminal(LocalTerminalId(1)) else { + return false; + }; + let status = local.read(cx).status(cx); + let size = format!("{}×{}", status.columns(), status.rows()); + // Laid out, so no longer the 80×24 it starts with. + size != "80×24" + && window + .try_find("status-terminal-size") + .is_some_and(|element| element.label() == Some(size.as_str())) + }) + .await; +} + #[gpui_kit::test] async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_session(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); @@ -7924,9 +8011,11 @@ async fn the_three_kinds_of_forward_share_one_row(cx: &mut TestAppContext) { /// round is 3.6 s: 1.44 s a hop and 0.72 s of rest. #[gpui_kit::test] async fn the_forward_diagram_keeps_showing_its_flow(cx: &mut TestAppContext) { - // Not the forward fixture: that one reduces motion, which stills the flow. + // Every fixture reduces motion, which stills the flow; this test is + // about the motion. let (store, id) = store_with_forward(); let (handle, _) = open_workspace_with_store(cx, store); + cx.update(|cx| cx.set_reduce_motion(false)); cx.run_until_parked(); // Where the dot is, if it is on either hop. let dot = |cx: &mut TestAppContext| { @@ -8627,3 +8716,243 @@ async fn a_long_credential_row_fits_the_smallest_window(cx: &mut TestAppContext) assert!(row.right() <= list.right(), "{row:?} in {list:?}"); }); } + +/// A store that keeps pasted and generated keys under `data`, as the app +/// keeps them beside its database. +fn store_keeping_keys(data: &std::path::Path, secrets: Arc) -> SessionStore { + SessionStore::empty() + .with_secrets(secrets) + .with_key_dir(data.join("keys")) +} + +/// Open 「生成密钥…」 from the empty credential list and wait for the key. +async fn open_generate_key(cx: &mut TestAppContext, handle: WindowHandle) { + show_credentials(cx, handle).await; + in_frame(cx, handle, |window, cx| { + window.click("credential-empty-generate", cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window.try_find("credential-public-key").is_some() + }) + .await; +} + +#[gpui_kit::test] +async fn a_generated_key_shows_its_public_half_and_is_kept_by_shellrs(cx: &mut TestAppContext) { + let data = tempfile::tempdir().unwrap(); + let secrets = Arc::new(InMemorySecretStore::default()); + let (handle, workspace) = open_workspace_with_credentials( + cx, + store_keeping_keys(data.path(), secrets.clone()), + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + open_generate_key(cx, handle).await; + in_frame(cx, handle, |window, cx| { + // A key credential with its key made already, waiting for a name. + assert_eq!(window.find("credential-name").focused(), Some(true)); + assert!(window.try_find("credential-key-path").is_none()); + assert!(window.find("credential-passphrase").visible()); + window.input("部署", cx); + }); + let line = in_frame(cx, handle, |window, cx| { + let line = window + .find("credential-public-key") + .label() + .unwrap() + .to_string(); + assert!(line.starts_with("ssh-ed25519 "), "{line}"); + // The key's comment is the credential's name. + assert!(line.ends_with(" 部署"), "{line}"); + window.click("commit", cx); + line + }); + wait_for_dialog_to_close(cx, handle).await; + + let credential = cx.update(|cx| workspace.read(cx).store().read(cx).credentials()[0].clone()); + assert_eq!(credential.kind, CredentialKind::Key); + let path = credential.key_path.clone().unwrap(); + let path = std::path::Path::new(path.as_ref()); + assert!(path.starts_with(data.path().join("keys")), "{path:?}"); + // The file holds the key whose public half the dialog showed. + assert_eq!(read_public_key(path), Some(line)); + let text = std::fs::read_to_string(path).unwrap(); + assert!(!PastedKey::parse(&text).unwrap().is_encrypted()); + assert!(secrets.get(&SecretRef::passphrase(path)).unwrap().is_none()); + // The list names it by kind: its file name means nothing to anyone. + in_frame(cx, handle, |window, _| { + assert!(window.find(("credential-row", credential.id.0)).visible()); + }); +} + +#[gpui_kit::test] +async fn a_generated_key_with_a_passphrase_is_saved_encrypted(cx: &mut TestAppContext) { + let data = tempfile::tempdir().unwrap(); + let secrets = Arc::new(InMemorySecretStore::default()); + let (handle, workspace) = open_workspace_with_credentials( + cx, + store_keeping_keys(data.path(), secrets.clone()), + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + open_generate_key(cx, handle).await; + in_frame(cx, handle, |window, cx| { + window.input("部署", cx); + window.click("credential-passphrase", cx); + window.input("correct horse", cx); + window.click("commit", cx); + }); + wait_for_dialog_to_close(cx, handle).await; + + let path = cx.update(|cx| { + workspace.read(cx).store().read(cx).credentials()[0] + .key_path + .clone() + .unwrap() + }); + let text = std::fs::read_to_string(path.as_ref()).unwrap(); + assert!(PastedKey::parse(&text).unwrap().is_encrypted()); + assert!(!text.contains("correct horse")); + assert_eq!( + secrets + .get(&SecretRef::passphrase(path.as_ref())) + .unwrap() + .as_deref() + .map(String::as_str), + Some("correct horse") + ); +} + +#[gpui_kit::test] +async fn a_pasted_key_is_checked_then_kept_by_shellrs(cx: &mut TestAppContext) { + let data = tempfile::tempdir().unwrap(); + let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap(); + let text = key.encode("me@laptop", "").unwrap().to_string(); + let (handle, workspace) = open_workspace_with_credentials( + cx, + store_keeping_keys(data.path(), Arc::new(InMemorySecretStore::default())), + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + show_credentials(cx, handle).await; + in_frame(cx, handle, |window, cx| window.click("new-credential", cx)); + in_frame(cx, handle, |window, cx| { + window.input("个人", cx); + window.within("credential-kind").click(1usize, cx); + }); + in_frame(cx, handle, |window, cx| { + window.within("credential-key-source").click(1usize, cx); + }); + + // The public half, pasted by mistake, is named for what it is. + let public = key.public_key_line("me@laptop"); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string(public.clone()))); + in_frame(cx, handle, |window, cx| { + assert!(window.try_find("credential-key-path").is_none()); + window.click("credential-key-text", cx); + window.press("cmd-v", cx); + }); + in_frame(cx, handle, |window, cx| { + assert!(window.try_find("credential-public-key").is_none()); + window.click("commit", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("form-error").label(), + Some("这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)") + ); + }); + + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string(text.clone()))); + in_frame(cx, handle, |window, cx| { + window.click("credential-key-text", cx); + window.press("cmd-a", cx); + window.press("cmd-v", cx); + }); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find("credential-public-key").label(), + Some(public.as_str()) + ); + // A key without a passphrase has none to ask for. + assert!(window.try_find("credential-passphrase").is_none()); + window.click("commit", cx); + }); + wait_for_dialog_to_close(cx, handle).await; + + let credential = cx.update(|cx| workspace.read(cx).store().read(cx).credentials()[0].clone()); + let path = credential.key_path.clone().unwrap(); + assert!(std::path::Path::new(path.as_ref()).starts_with(data.path().join("keys"))); + assert_eq!(std::fs::read_to_string(path.as_ref()).unwrap(), text); +} + +#[gpui_kit::test] +async fn a_kept_key_is_deleted_when_its_credential_turns_to_a_password(cx: &mut TestAppContext) { + let data = tempfile::tempdir().unwrap(); + let mut store = store_keeping_keys(data.path(), Arc::new(InMemorySecretStore::default())); + let path = store.save_private_key(None, "kept").unwrap(); + let credential = store.insert_credential_unnotified( + CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path(path.clone()), + ); + let (handle, _) = open_workspace_with_credentials( + cx, + store, + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + show_credentials(cx, handle).await; + in_frame(cx, handle, |window, cx| { + window.click(("credential-row", credential.0), cx); + window.dispatch_action(Box::new(EditCredential(credential)), cx); + }); + in_frame(cx, handle, |window, cx| { + // The kept key shows as the file it is. + assert_eq!( + window.find("credential-key-path").value(), + Some(path.as_ref()) + ); + assert!(window.try_find("credential-kept-key-note").is_none()); + window.within("credential-kind").click(0usize, cx); + }); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find("credential-kept-key-note").label(), + Some("保存后,ShellRS 保存的原私钥会被删除。") + ); + window.click("commit", cx); + }); + wait_for_dialog_to_close(cx, handle).await; + assert!(!std::path::Path::new(path.as_ref()).exists()); +} + +#[gpui_kit::test] +async fn a_key_credentials_public_key_is_copied_from_its_file(cx: &mut TestAppContext) { + let data = tempfile::tempdir().unwrap(); + let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap(); + let file = data.path().join("id_deploy"); + std::fs::write(&file, key.encode("deploy@laptop", "").unwrap().as_str()).unwrap(); + let mut store = SessionStore::empty(); + let credential = store.insert_credential_unnotified( + CredentialDraft::new("部署", CredentialKind::Key, "deploy") + .with_key_path(file.display().to_string()), + ); + let (handle, _) = open_workspace_with_credentials( + cx, + store, + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + show_credentials(cx, handle).await; + in_frame(cx, handle, |window, cx| { + window.click(("credential-row", credential.0), cx); + window.dispatch_action(Box::new(CopyCredentialPublicKey(credential)), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.notifications(cx).len() == 1 + }) + .await; + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some(key.public_key_line("deploy@laptop")) + ); +}