密钥凭据可粘贴和生成私钥,状态栏显示终端尺寸

- 密钥凭据的私钥分「本机文件 / 粘贴 / 生成新密钥」三种来源,仍是一个对话框;
  生成支持 Ed25519 与 RSA 4096,可用口令加密,对话框里显示并可复制公钥
- 粘贴和生成的私钥写成数据目录 keys/ 下的 0600 文件,凭据引用其路径;
  再次粘贴或生成时原地覆盖,换掉或删除凭据时随之删除,用户自己的私钥文件从不改动
- 空列表和空白处右键新增「生成密钥…」,密钥凭据右键新增「复制公钥」
- 窗口状态栏右侧由写死的光标位置改为当前终端的尺寸(列×行),
  SFTP 标签、开始页和设置页不显示
- UI 测试的基础夹具关闭动画,修正对话框滑入导致的偶发失败
This commit is contained in:
Xuz committed 2026-10-01 09:06:31 +08:00
1 parent d6d052de9e
commit ee488309f3
23 files changed
+1973 -154

No files matched your search

+6 -5
View File
@@ -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/<name>.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<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`)、`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:<id>` / `s:<id>`;`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<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`)、`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:<id>` / `s:<id>`;`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 校验写 `<data_dir>/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`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
@@ -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<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`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`(Line truncated
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`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 个文件到 …」)与来源清单Line truncated
+7
View File
@@ -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 }
+10 -4
View File
@@ -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` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
+5
View File
@@ -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);
+1 -1
View File
@@ -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::*;
+6
View File
@@ -25,6 +25,12 @@ pub fn database_path() -> std::io::Result<PathBuf> {
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<PathBuf> {
let dir = data_dir();
+642 -55
View File
@@ -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<GeneratedKey>),
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<SessionStore>,
@@ -29,22 +97,46 @@ pub struct CredentialForm {
user: Entity<InputState>,
/// The password, key file and passphrase, shared with the host form.
fields: Entity<SecretFields>,
source: KeySource,
key_text: Entity<TextareaState>,
/// What the pasted text is, read again whenever it changes.
pasted: Option<Result<PastedKey, PastedKeyError>>,
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<SharedString>)>,
/// Where ShellRS keeps keys. Without it nothing can be pasted or
/// generated.
key_dir: Option<PathBuf>,
/// The key file of the credential being edited, when ShellRS keeps it:
/// saving the credential with any other key deletes it.
kept_key: Option<SharedString>,
/// 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<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl CredentialForm {
pub fn new(
editing: Option<CredentialId>,
dialog: CredentialDialog,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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<SharedString>, cx: &mut Context<Self>) -> 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<Zeroizing<String>>,
password_change: Option<Option<String>>,
passphrase_change: Option<Option<String>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<CredentialId>,
dialog: CredentialDialog,
store: Entity<SessionStore>,
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));
}
}
+63 -22
View File
@@ -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<Self>) -> 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<CredentialId>, 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)
}),
)
}
}
+4 -3
View File
@@ -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};
+10 -2
View File
@@ -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<SharedString>) {
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}");
(
+33 -8
View File
@@ -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]
+2
View File
@@ -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};
+475
View File
@@ -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<Self> {
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<Zeroizing<String>> {
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<String>,
encrypted: bool,
/// Known when the format keeps it in the clear, or the key is not
/// encrypted.
public_key: Option<PublicKey>,
}
/// 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<Self, PastedKeyError> {
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<String>) -> 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<String> {
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", &"<redacted>")
.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 `<path>.pub` beside it.
///
/// Blocks on the file system.
pub fn read_public_key(path: &Path) -> Option<String> {
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<Zeroizing<String>> {
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<PathBuf> {
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<File> {
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<File> {
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);
}
}
}
+26 -1
View File
@@ -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>) {
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>,
) {
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<Self>) {
let receiver = cx.prompt_for_paths(PathPromptOptions {
+15 -6
View File
@@ -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<Credential>,
/// Where ShellRS keeps pasted and generated keys, to name those in the
/// credential's summary.
key_dir: Option<PathBuf>,
group: Entity<SelectState<Vec<SharedString>>>,
/// Parallel to the group select's rows; `None` is the root of the tree.
group_ids: Vec<Option<GroupId>>,
@@ -89,12 +93,13 @@ impl SessionForm {
cx: &mut Context<Self>,
) -> 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::<Vec<_>>(),
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()
+202
View File
@@ -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<PathBuf>,
}
/// 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<PathBuf>) -> 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<Self> {
@@ -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<CredentialId>,
text: &str,
) -> io::Result<SharedString> {
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<Self>) {
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();
+2 -4
View File
@@ -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(),
)
}
+11 -9
View File
@@ -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
}
}
+6 -1
View File
@@ -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<Self>) {
let Some(login) = self.store.read(cx).login(self.session_id) else {
return;
+64 -4
View File
@@ -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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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(
+32 -16
View File
@@ -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<Session>),
/// 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<Session>, Option<TerminalStatus>),
Local(TerminalStatus),
/// An SFTP tab: its own connection and what went wrong in it.
Explorer(Option<Session>, 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>, String) = match self
{
WorkspaceStatus::Session(active) => match active {
let (text, icon, address, terminal): (
SharedString,
Icon,
Option<String>,
Option<TerminalStatus>,
) = 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),
)
})
}
}
+12 -3
View File
@@ -2060,11 +2060,18 @@ impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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))
+339 -10
View File
@@ -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<FakeConnectionTester>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
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<InMemorySecretStore>) -> 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<Root>) {
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"))
);
}