密钥凭据可粘贴和生成私钥,状态栏显示终端尺寸
- 密钥凭据的私钥分「本机文件 / 粘贴 / 生成新密钥」三种来源,仍是一个对话框; 生成支持 Ed25519 与 RSA 4096,可用口令加密,对话框里显示并可复制公钥 - 粘贴和生成的私钥写成数据目录 keys/ 下的 0600 文件,凭据引用其路径; 再次粘贴或生成时原地覆盖,换掉或删除凭据时随之删除,用户自己的私钥文件从不改动 - 空列表和空白处右键新增「生成密钥…」,密钥凭据右键新增「复制公钥」 - 窗口状态栏右侧由写死的光标位置改为当前终端的尺寸(列×行), SFTP 标签、开始页和设置页不显示 - UI 测试的基础夹具关闭动画,修正对话框滑入导致的偶发失败
This commit is contained in:
1 parent
d6d052de9e
commit
ee488309f3
23 files changed
+1973
-154
No files matched your search
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
|
||||
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user