凭据(密码 / 密钥 / SSH Agent),主机的认证方式分「手动输入 / 使用凭据」

凭据
- 标题栏「ShellRS」后面加第三个图标「凭据」,侧栏的第三个列表:搜索、
  ↑↓ 与回车编辑、双击编辑、右键编辑 / 删除,空列表里有「新建凭据…」
- 三种:密码(用户名 + 密码)、密钥(用户名 + 本机私钥文件路径 + 口令)、
  SSH Agent(用户名)。凭据带用户名,改了之后用它的主机一起变;改用户名、
  类型或私钥文件会让已连接的主机重连,只改名称不会
- 密码进钥匙串,账户名 credential:<随机 keychain_id>,不用会被复用的数字
  id;口令照旧按私钥文件归属
- 删除仍在用的凭据先确认并写明台数,那些主机改成「手动输入 · 自动」、
  保留用户名,已打开的连接不断开,凭据的密码随之删掉
- 库升到 v9:新增 credentials 与 session_credentials 两张表(不给 sessions
  加列,ALTER TABLE 会破坏「升级后与新库逐字相同」),迁移改成按 STEPS
  逐级升级

主机对话框
- 「认证方式」改为「手动输入 / 使用凭据」单选。手动输入原样保留
  自动 / 密码 / 私钥文件;使用凭据时选凭据,用户名和秘密都来自凭据,
  「测试连接」读钥匙串里凭据保存的密码
- 密码、私钥文件、口令三个字段连同异步预填和 secret_change 抽成
  session::SecretFields,主机和凭据两个对话框共用
- 地址与端口同一行的四列布局

登录
- 新的 SessionLogin:store 在 UI 线程查好凭据生成的登录快照,终端、SFTP、
  端口转发的 provider 和外部 CLI 的 CliTarget 都改收它;主机和凭据的修改
  按前后快照是否相等决定是否重连
- SSH Agent 凭据只走 agent,连不上、没有钥匙、服务器都不接受时明确报错;
  agent 位置可注入(AgentLocation),Windows 接 OpenSSH Agent 的命名管道,
  手动的「自动」也走同一个 connect_agent
- LoginTest 分 typed(表单说了算)和 saved(读钥匙串)

测试
- 单元:凭据模型、登录快照、v8→v9 迁移、store 的凭据接口与用户名同步、
  删除规则
- 协议:密码凭据只读自己的钥匙串条目;进程内 russh agent 服务端覆盖
  Agent 登录成功和几种失败
- UI:侧栏切换、新建 / 编辑 / 删除凭据、类型决定字段、主机使用凭据、
  测试连接、终端拿到的登录、改用户名重连;新建主机的校验测试改为等待
  错误行出现(对话框滑入时它在最底下一行)

文档:README 新增「凭据」一节;CLAUDE.md 同步架构和不变量,并更正
转发对话框「选完主机移走焦点」的描述(代码里从未有这个处理)
This commit is contained in:
Xuz committed 2026-10-01 07:45:58 +08:00
1 parent 1179317907
commit d6d052de9e
45 files changed
+4206 -732

No files matched your search

+2 -2
View File
@@ -41,7 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`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`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
@@ -55,7 +55,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `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`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用 `password_in_use`(按查好凭据之后的登录快照 `SessionLogin` 判断)看还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
- **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
+16 -14
View File
@@ -8,9 +8,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
**界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。详见 `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)也存在这个库里(密码和口令照旧只进钥匙串),会话可以「使用凭据」而不是自己填登录信息。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的两个图标(主机 / 端口转发)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。
已确认的产品决定: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 接口和凭据排序。
做 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,11 +41,11 @@ 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>`)、`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`)、`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。密码和私钥口令先查 `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` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `forward/` — 端口转发。规则的模型在 `session/forward.rs`,这里是运行和界面。
@@ -55,7 +55,8 @@ 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)加本机回显服务,驱动真实的工作线程。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` 两个图标的 `ButtonGroup`,有转发在跑时后者带数量)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `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`,顺序同会话对话框)。
- `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` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
@@ -97,12 +98,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。
- **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。
- **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。
- **侧栏是一个面板、两个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel` 和 `ForwardPanel` 两个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。两个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。
- **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。
- **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 8。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本逐步升级(目前只有 7→8:建 `forwards` 表);已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows` 用冻结的 v7 schema 验证升级后的表定义与新库逐字相同。
- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 9。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本按 `STEPS` 一步接一步升级(7→8 建 `forwards` 表,8→9 建 `credentials` 和 `session_credentials` 表),一个库可以连升几级;已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`、`credential_tables!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows` 与 `a_version_8_database_gains_the_credential_tables_and_keeps_its_rows` 用冻结的旧 schema 验证升级后的表定义与新库逐字相同。**不要给已有的表 `ALTER TABLE ADD COLUMN`**:SQLite 会把新列拼在原来的右括号前面(`\n, 列定义)`),新库的 `SCHEMA` 只有照抄这种格式才能逐字相同;要加字段就像会话和凭据的关联那样另建一张表。
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
@@ -127,8 +128,9 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。
- **持久化写穿,内存说了算。** `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`),因为整行已经有一个,两个会叠在一起。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
- **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` 三个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **登录快照在 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` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时会因为缺密码停下。
- **秘密只进钥匙串,不进数据库。** `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 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
@@ -148,8 +150,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **会话行的 tooltip 开在行的右侧。** 悬停会话行 500 ms 后,行右侧显示 `user@host:port`(`session_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `SessionPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-session-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组和端口转发三个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」已经绕开:表单订阅 `SelectEvent::Confirm`,选完就把焦点移到监听端口(`ForwardForm::focus_bind_port`;下拉自己在发事件之后才聚焦自己,事件是延后送达的,所以我们的聚焦生效)。主机对话框的「认证方式」「分组」还没有处理(「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这两个下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。
- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「认证类型」「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred`(优先级 0)画的,开始页也是;通知层若作为普通子元素,就会画在对话框背景底下,只能隐约看到。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= 1,低于菜单等弹出层的 `POPUP_PRIORITY` = 100)。无头测试看不到绘制顺序(被盖住的元素照样能点中),这条只能靠肉眼确认。
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
@@ -158,4 +160,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`、标题栏的侧栏切换 `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
+27 -5
View File
@@ -1,14 +1,19 @@
# ShellRS
基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。
基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、可在多台主机间共用的登录凭据、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。
标题栏「ShellRS」后面的两个图标切换左侧栏显示的内容:主机列表,或端口转发列表。侧栏隐藏时点其中一个会把它重新展开。
标题栏「ShellRS」后面的三个图标切换左侧栏显示的内容:主机列表、端口转发列表或凭据列表。侧栏隐藏时点其中一个会把它重新展开。
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
## 主机列表
左侧栏里保存的每一项是一台主机:地址、端口、用户名和认证方式。同一台机器用不同用户登录,就保存成两台主机。
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
主机对话框的「认证方式」有两种:
- **手动输入**:在对话框里填用户名,选认证类型「自动」「密码」或「私钥文件」。「自动」依次尝试 SSH Agent、`~/.ssh` 下的默认私钥和密码。填的密码和私钥口令保存在系统钥匙串。
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`。右键主机可连接、打开 SFTP、编辑、复制、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
@@ -31,13 +36,30 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **状态**:行尾显示正在连接、运行中(及当前连接数)、正在重连或已停止的原因。某个连接转发不了(例如服务器连不上目标)只在该行提示,转发本身继续运行。
- **登录**:使用主机的配置、钥匙串里保存的密码和 ShellRS 自己的主机信任记录。首次连接的主机、需要输入的密码会弹出和终端一样的询问框,并注明是哪条转发在连接;取消询问等于停止这条转发。
- **断线重连**:连接中断后按 1、3、10 秒自动重连三次,重连期间本机监听端口保留,新进来的连接会被直接关闭。自动重连不弹任何询问:需要重新输入密码、确认主机密钥时停止并写明原因,手动启动即可。三次都失败也会停止,并弹出通知。
- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址和认证方式,这条转发会按新设置重新启动;只改名称或自动开启不会。
- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址和认证方式(包括这台主机所用凭据的用户名、类型或私钥文件),这条转发会按新设置重新启动;只改名称或自动开启不会。
- **删除主机**:删除主机(或包含它的分组)会连同经由它的转发规则一起删除,运行中的先停止,确认框里写明数量。
- **动态转发**支持 SOCKS5(无认证,`CONNECT`,IPv4、域名、IPv6)和 SOCKS4 / 4a。域名交给服务器解析。
- **监听地址**默认 `127.0.0.1`。填其他地址时,本地和动态转发会被同一网络中的其他设备使用;远程转发需要服务器的 `sshd_config` 设置 `GatewayPorts clientspecified` 或 `yes`,其他机器才连得上;默认是 `no`,这时转发照常运行、不报错,但只监听服务器自己的 `127.0.0.1`(和手写 `ssh -R` 一样)。对话框在填了非回环地址时会提示这一点。1024 以下的端口需要管理员(服务器上是 root)权限。不支持端口 0(由系统挑选端口)。
**尚未实现:通过外部 CLI 管理转发、规则排序。系统的「减少动态效果」设置目前不会关掉示意图的动画。**
## 凭据
点标题栏的凭据(钥匙)图标,左侧栏换成凭据列表。一条凭据是一套可以让多台主机共用的登录信息,包括名称、用户名和登录方式:
| 类型 | 保存什么 | 登录时 |
| --- | --- | --- |
| 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 |
| 密钥 | 用户名、本机私钥文件的路径和口令 | 只用这把私钥;口令按私钥文件保存,和手动填私钥的主机共用 |
| SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 |
- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。私钥文件只保存路径,文件移动或删除后需要重新选择。
- **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。
- **删除**:右键「删除」。有主机在用时确认框写明数量;删除后这些主机改为「手动输入 · 自动」,保留原来的用户名,密码和私钥不会复制过去,下次连接时按「自动」尝试或询问。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。
- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。手动输入里的「自动」也会尝试同一个 Agent,连不上时静默跳过(Windows 上同样如此)。
**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字。**
## 设置
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
@@ -230,7 +252,7 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机和端口转发规则保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,凭据使用系统钥匙串(服务名 `shellrs`)。
数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,密码和私钥口令使用系统钥匙串(服务名 `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` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
+18 -1
View File
@@ -6,7 +6,7 @@ use gpui_kit::*;
use crate::{
cli::AgentKind,
explorer::{ExplorerId, FileSizeFormat},
session::{ForwardId, GroupId, NodeDrop, SessionId, SessionNode},
session::{CredentialId, ForwardId, GroupId, NodeDrop, SessionId, SessionNode},
terminal::{LocalTerminalId, RemoteTerminalId},
};
@@ -49,6 +49,17 @@ gpui_kit::actions!(
SelectPreviousForward,
/// Move the forward list's selection to the row below.
SelectNextForward,
/// Show the credential list in the left dock.
ShowCredentials,
/// Open the new-credential dialog.
NewCredential,
/// Open the edit dialog of the credential selected in the credential
/// list.
EditSelectedCredential,
/// Move the credential list's selection to the row above.
SelectPreviousCredential,
/// Move the credential list's selection to the row below.
SelectNextCredential,
/// Switch between the light and dark theme.
ToggleTheme,
/// Move keyboard focus to the search field of the list the left dock
@@ -163,6 +174,12 @@ id_actions! {
EditForward(ForwardId);
/// Ask for confirmation, then delete a port-forwarding rule.
DeleteForward(ForwardId);
/// Open the edit-credential dialog.
EditCredential(CredentialId);
/// Ask for confirmation, then delete a credential. The hosts using it
/// go back to logging in on their own.
DeleteCredential(CredentialId);
}
/// Move a session-tree row by dropping it beside a peer or into a group.
+5 -1
View File
@@ -42,7 +42,11 @@ icon_assets!(
Laptop,
Lock,
CircleAlert,
Target
Target,
KeyRound,
RectangleEllipsis,
FileKey,
UserRoundKey
]
);
+13
View File
@@ -18,6 +18,8 @@ pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel";
pub const RECENT_SESSIONS_CONTEXT: &str = "RecentSessions";
/// Key context of the port-forwarding list.
pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel";
/// Key context of the credential list.
pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel";
/// Key contexts of the two SFTP file lists.
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
@@ -77,6 +79,17 @@ fn key_bindings() -> Vec<KeyBinding> {
KeyBinding::new("enter", ToggleSelectedForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("up", SelectPreviousForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new("down", SelectNextForward, Some(FORWARD_PANEL_CONTEXT)),
KeyBinding::new(
"enter",
EditSelectedCredential,
Some(CREDENTIAL_PANEL_CONTEXT),
),
KeyBinding::new(
"up",
SelectPreviousCredential,
Some(CREDENTIAL_PANEL_CONTEXT),
),
KeyBinding::new("down", SelectNextCredential, Some(CREDENTIAL_PANEL_CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)),
#[cfg(target_os = "macos")]
+9 -10
View File
@@ -8,9 +8,8 @@ use std::{
};
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
use super::server::CliBackend;
use super::server::{CliBackend, CliTarget};
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::session::Session;
use crate::sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
TransferAnswer, TransferChoice, TransferPhase, TransferProgress, TransferQuestionKind,
@@ -39,11 +38,11 @@ impl SshCliBackend {
/// question the way an unattended copy should.
fn transfer(
&self,
session: &Session,
target: &CliTarget,
command: SftpCommand,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
let transport = self.sftp.create(session);
let transport = self.sftp.create(target.login());
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
let worker = std::thread::Builder::new()
@@ -119,11 +118,11 @@ impl SshCliBackend {
impl CliBackend for SshCliBackend {
fn exec(
&self,
session: &Session,
target: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
let config = SshConnectionConfig::from(session);
let config = SshConnectionConfig::from(target.login());
match run_command(&self.connector, &config, command, output) {
Ok(ExecExit::Code(code)) => Ok(i32::try_from(code).unwrap_or(255)),
Ok(ExecExit::Signal(signal)) => {
@@ -147,25 +146,25 @@ impl CliBackend for SshCliBackend {
fn upload(
&self,
session: &Session,
target: &CliTarget,
source: &Path,
destination: &str,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
let request = UploadRequest::scp(source.to_path_buf(), remote_path(destination)?);
self.transfer(session, SftpCommand::Upload(request), progress)
self.transfer(target, SftpCommand::Upload(request), progress)
}
fn download(
&self,
session: &Session,
target: &CliTarget,
source: &str,
destination: &Path,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
let request = DownloadRequest::scp(remote_path(source)?, destination.to_path_buf())
.map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?;
self.transfer(session, SftpCommand::Download(request), progress)
self.transfer(target, SftpCommand::Download(request), progress)
}
}
+26 -10
View File
@@ -19,7 +19,7 @@ use super::protocol::{
CliError, Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo,
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
};
use crate::session::{Session, SessionStore, matches_query};
use crate::session::{Session, SessionLogin, SessionStore, matches_query};
use crate::ssh::ExecStream;
/// Does what a CLI request asks. Every method blocks: each request has a
@@ -30,7 +30,7 @@ pub trait CliBackend: Send + Sync + 'static {
/// and the command should be abandoned.
fn exec(
&self,
session: &Session,
target: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError>;
@@ -40,7 +40,7 @@ pub trait CliBackend: Send + Sync + 'static {
/// should stop.
fn upload(
&self,
session: &Session,
target: &CliTarget,
source: &Path,
destination: &str,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
@@ -50,23 +50,25 @@ pub trait CliBackend: Send + Sync + 'static {
/// does.
fn download(
&self,
session: &Session,
target: &CliTarget,
source: &str,
destination: &Path,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
}
/// A saved session as the CLI sees it.
/// A saved session as the CLI sees it, with the login it connects with
/// resolved when the target list was built.
#[derive(Clone, Debug)]
pub struct CliTarget {
info: SessionInfo,
session: Session,
login: SessionLogin,
}
impl CliTarget {
/// `group` is the session's full group path, if it has one.
pub fn new(session: &Session, group: Option<String>) -> Self {
pub fn new(session: &Session, login: SessionLogin, group: Option<String>) -> Self {
Self {
info: SessionInfo {
id: session.public_id.to_string(),
@@ -78,9 +80,19 @@ impl CliTarget {
os: session.os.map(|os| os.as_str().to_string()),
},
session: session.clone(),
login,
}
}
pub fn session(&self) -> &Session {
&self.session
}
/// How a request logs in to the session's host.
pub fn login(&self) -> &SessionLogin {
&self.login
}
/// Every session in `store`, with its group path.
pub fn all(store: &SessionStore) -> Vec<Self> {
store
@@ -91,7 +103,11 @@ impl CliTarget {
.group
.map(|id| store.group_names(id))
.unwrap_or_default();
Self::new(session, (!names.is_empty()).then(|| names.join("/")))
Self::new(
session,
store.login_of(session),
(!names.is_empty()).then(|| names.join("/")),
)
})
.collect()
}
@@ -440,7 +456,7 @@ fn respond(
let target = find(shared, &session)?;
let code = shared
.backend
.exec(&target.session, &command, &mut |stream, bytes| {
.exec(&target, &command, &mut |stream, bytes| {
let kind = match stream {
ExecStream::Stdout => FrameKind::Stdout,
ExecStream::Stderr => FrameKind::Stderr,
@@ -460,7 +476,7 @@ fn respond(
}
shared
.backend
.upload(&target.session, &source, &destination, &mut |counters| {
.upload(&target, &source, &destination, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
@@ -476,7 +492,7 @@ fn respond(
}
shared
.backend
.download(&target.session, &source, &destination, &mut |counters| {
.download(&target, &source, &destination, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
+8 -8
View File
@@ -17,7 +17,7 @@ use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use crate::app::cli_endpoint;
use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId};
use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionLogin};
use crate::ssh::ExecStream;
/// Records what it was asked and answers from a script.
@@ -29,14 +29,14 @@ struct FakeBackend {
impl CliBackend for FakeBackend {
fn exec(
&self,
session: &Session,
target: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
self.calls
.lock()
.unwrap()
.push(format!("exec {} {command}", session.name));
.push(format!("exec {} {command}", target.session().name));
if command == "unreachable" {
return Err(CliError::new(ErrorCode::HostKeyUnknown, "尚未信任"));
}
@@ -47,14 +47,14 @@ impl CliBackend for FakeBackend {
fn upload(
&self,
session: &Session,
target: &CliTarget,
source: &Path,
destination: &str,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
self.calls.lock().unwrap().push(format!(
"upload {} {} {destination}",
session.name,
target.session().name,
source.display()
));
progress(TransferCounters {
@@ -75,7 +75,7 @@ impl CliBackend for FakeBackend {
fn download(
&self,
_: &Session,
_: &CliTarget,
_: &str,
_: &Path,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
@@ -107,8 +107,8 @@ fn fixture() -> Fixture {
let web = session(1, "web-01", "10.0.1.12");
let db = session(2, "db-01", "10.0.2.5");
server.set_targets(vec![
CliTarget::new(&web, Some("生产".into())),
CliTarget::new(&db, Some("生产/数据库".into())),
CliTarget::new(&web, SessionLogin::of(&web, None), Some("生产".into())),
CliTarget::new(&db, SessionLogin::of(&db, None), Some("生产/数据库".into())),
]);
server.set_enabled(true);
Fixture {
+45 -43
View File
@@ -215,84 +215,86 @@ impl std::fmt::Debug for ConnectionPromptReply {
/// What a connection test logs in with: the session form's current values,
/// saved or not. Secrets stay zeroized and never print.
pub struct LoginTest {
host: String,
port: u16,
user: String,
auth: crate::session::AuthKind,
key_path: Option<std::path::PathBuf>,
login: crate::session::SessionLogin,
/// The secrets typed into the form, for a login typed there. `None` for
/// a login through a credential, which uses what the credential saved.
typed: Option<TypedSecrets>,
}
#[derive(Default)]
struct TypedSecrets {
password: Option<Zeroizing<String>>,
passphrase: Option<Zeroizing<String>>,
}
impl LoginTest {
pub fn new(
host: impl Into<String>,
port: u16,
user: impl Into<String>,
auth: crate::session::AuthKind,
) -> Self {
/// A login typed into the form. The form decides its password and its
/// key's passphrase: an empty field means none, whatever is saved.
pub fn typed(login: crate::session::SessionLogin) -> Self {
Self {
host: host.into(),
port,
user: user.into(),
auth,
key_path: None,
password: None,
passphrase: None,
login,
typed: Some(TypedSecrets::default()),
}
}
pub fn with_key_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.key_path = Some(path.into());
self
/// A login through a saved credential, with the secrets saved for it.
pub fn saved(login: crate::session::SessionLogin) -> Self {
Self { login, typed: None }
}
/// The password typed into the form. Only a typed login has one.
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = Some(Zeroizing::new(password.into()));
if let Some(typed) = &mut self.typed {
typed.password = Some(Zeroizing::new(password.into()));
}
self
}
/// The passphrase typed into the form. Only a typed login has one.
pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
self.passphrase = Some(Zeroizing::new(passphrase.into()));
if let Some(typed) = &mut self.typed {
typed.passphrase = Some(Zeroizing::new(passphrase.into()));
}
self
}
pub fn login(&self) -> &crate::session::SessionLogin {
&self.login
}
pub fn host(&self) -> &str {
&self.host
&self.login.host
}
pub fn port(&self) -> u16 {
self.port
self.login.port
}
pub fn user(&self) -> &str {
&self.user
&self.login.user
}
pub fn auth(&self) -> crate::session::AuthKind {
self.auth
}
pub fn key_path(&self) -> Option<&std::path::Path> {
self.key_path.as_deref()
/// Whether the form, not the keychain, decides the secrets.
pub fn is_typed(&self) -> bool {
self.typed.is_some()
}
pub fn password(&self) -> Option<&str> {
self.password.as_deref().map(String::as_str)
self.typed
.as_ref()
.and_then(|typed| typed.password.as_deref())
.map(String::as_str)
}
pub fn passphrase(&self) -> Option<&str> {
self.passphrase.as_deref().map(String::as_str)
self.typed
.as_ref()
.and_then(|typed| typed.passphrase.as_deref())
.map(String::as_str)
}
}
impl std::fmt::Debug for LoginTest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoginTest")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("auth", &self.auth)
.field("key_path", &self.key_path)
.field("password", &self.password.as_ref().map(|_| "<redacted>"))
.field(
"passphrase",
&self.passphrase.as_ref().map(|_| "<redacted>"),
)
.field("login", &self.login)
.field("typed", &self.is_typed())
.field("password", &self.password().map(|_| "<redacted>"))
.field("passphrase", &self.passphrase().map(|_| "<redacted>"))
.finish()
}
}
+317
View File
@@ -0,0 +1,317 @@
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
form::{Field, Form},
input::{Input, InputState},
radio::{Radio, RadioGroup},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::secrets::SecretRef;
use crate::session::{
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, SecretFields, SessionStore,
};
use crate::shared::{commit_footer, form_error};
/// Where the SSH agent is found, as the form explains it.
#[cfg(windows)]
const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。";
#[cfg(not(windows))]
const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。";
/// The body of the new/edit credential dialog.
pub struct CredentialForm {
store: Entity<SessionStore>,
editing: Option<CredentialId>,
kind: CredentialKind,
name: Entity<InputState>,
user: Entity<InputState>,
/// The password, key file and passphrase, shared with the host form.
fields: Entity<SecretFields>,
/// How many hosts use the credential being edited, and how many of them
/// are connected right now.
hosts: usize,
connected: usize,
error: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl CredentialForm {
pub fn new(
editing: Option<CredentialId>,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (credential, secrets, hosts, connected) = {
let read = store.read(cx);
let credential = editing.and_then(|id| read.credential(id)).cloned();
let using: Vec<_> = editing
.map(|id| read.sessions_using(id).collect())
.unwrap_or_default();
let connected = using
.iter()
.filter(|session| session.state.is_connected())
.count();
(credential, read.secrets(), using.len(), connected)
};
let draft = credential
.as_ref()
.map(|credential| credential.draft())
.unwrap_or_else(|| CredentialDraft::new("", CredentialKind::default(), DEFAULT_USER));
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如 生产环境 root")
.default_value(draft.name.clone())
});
let user = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(DEFAULT_USER)
.default_value(draft.user.clone())
});
let fields = cx.new(|cx| SecretFields::new(secrets, draft.key_path.clone(), window, cx));
if let Some(credential) = &credential {
fields.update(cx, |fields, cx| {
fields.load_saved(
(credential.kind == CredentialKind::Password)
.then(|| credential.password_secret()),
credential.key_path.clone(),
cx,
)
});
}
let subscriptions = vec![cx.observe(&fields, |_, _, cx| cx.notify())];
Self {
store,
editing,
kind: draft.kind,
name,
user,
fields,
hosts,
connected,
error: None,
_subscriptions: subscriptions,
}
}
/// Put the keyboard where a new credential starts: its name.
fn focus_name(&self, window: &mut Window, cx: &mut Context<Self>) {
self.name.update(cx, |input, cx| input.focus(window, cx));
}
fn set_kind(&mut self, kind: CredentialKind, cx: &mut Context<Self>) {
if self.kind != kind {
self.kind = kind;
self.error = None;
cx.notify();
}
}
/// Validate and write to the store. Returns whether the dialog may close.
pub fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let fields = self.fields.read(cx);
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))
.validated();
let draft = match draft {
Ok(draft) => draft,
Err(error) => {
self.error = Some(error.to_string().into());
cx.notify();
return false;
}
};
// 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 editing = self.editing;
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.
let id = match editing {
Some(id) => store.update_credential(id, draft, cx).then_some(id),
None => Some(store.insert_credential(draft, cx)),
};
let secret = id
.and_then(|id| store.credential(id))
.map(|credential| credential.password_secret());
if let (Some(secret), Some(change)) = (secret, password_change) {
store.save_secret(secret, change, cx);
}
if let Some((secret, change)) = passphrase_change {
store.save_secret(secret, change, cx);
}
});
// The dialog is about to close and take the form with it; drop the
// plaintext now rather than waiting for the entity.
self.fields
.update(cx, |fields, cx| fields.clear(window, cx));
self.error = None;
true
}
/// What editing the credential does to the hosts using it.
fn usage_note(&self) -> Option<String> {
if self.hosts == 0 {
return None;
}
let mut note = format!("有 {} 台主机使用此凭据。", self.hosts);
if self.connected > 0 {
note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。");
}
Some(note)
}
}
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 secret_note = match kind {
CredentialKind::Agent => None,
_ if fields.keychain_available() => {
Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。")
}
_ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"),
};
let form = Form::new()
.child(
Field::new().label("类型").child(
RadioGroup::horizontal("credential-kind")
.selected_index(CredentialKind::ALL.iter().position(|each| *each == kind))
.on_change(cx.listener(|this, ix: &usize, _, cx| {
if let Some(kind) = CredentialKind::ALL.get(*ix) {
this.set_kind(*kind, cx);
}
}))
.children(
CredentialKind::ALL
.map(|each| Radio::new(each.as_str()).label(each.label()).small()),
),
),
)
.child(
Field::new()
.label("名称")
.required(true)
.child(Input::new(&self.name).id("credential-name").small()),
)
.child(
Field::new()
.label("用户名")
.child(Input::new(&self.user).id("credential-user").small()),
);
let form = match kind {
CredentialKind::Password => form.child(
Field::new()
.label("密码")
.child(fields.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::Agent => form,
};
v_flex()
.gap_3()
.w_full()
.child(form)
.when(kind == CredentialKind::Agent, |view| {
view.child(
div()
.id("credential-agent-note")
.test_support()
.aria_label(AGENT_NOTE)
.text_sm()
.text_color(muted)
.child(AGENT_NOTE),
)
})
.when_some(secret_note, |view, note| {
view.child(div().text_sm().text_color(muted).child(note))
})
.when_some(self.usage_note(), |view, note| {
view.child(
div()
.id("credential-usage")
.test_support()
.aria_label(note.clone())
.text_sm()
.text_color(muted)
.child(note),
)
})
.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.
pub fn open_credential_dialog(
editing: Option<CredentialId>,
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() {
"编辑凭据"
} else {
"新建凭据"
}
.into();
let commit_label: SharedString = if editing.is_some() {
"保存"
} else {
"创建"
}
.into();
window.open_dialog(cx, {
let form = form.clone();
move |dialog, _, _| {
dialog
.title(title.clone())
// Closed by its buttons or Escape, not by a click beside it.
.overlay_closable(false)
.child(form.clone())
.footer(commit_footer("commit", commit_label.clone()))
.on_ok({
let form = form.clone();
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
})
}
});
// 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() {
form.update(cx, |form, cx| form.focus_name(window, cx));
}
}
+438
View File
@@ -0,0 +1,438 @@
use std::{cell::Cell, collections::HashSet, rc::Rc};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
button::Button,
h_flex,
input::{Input, InputEvent, InputState},
list::ListItem,
menu::{ContextMenuExt as _, PopupMenu},
scroll::ScrollableElement as _,
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CREDENTIAL_PANEL_CONTEXT, CatalogIcon, DeleteCredential, EditCredential,
EditSelectedCredential, NewCredential, SelectNextCredential, SelectPreviousCredential,
};
use crate::session::{CredentialId, CredentialKind, SessionStore, matches_credential_query};
/// The credential list the left dock shows in place of the sessions: every
/// saved login and how many hosts use it.
///
/// The credentials live in the shared `SessionStore`; this panel observes it
/// and owns only its selection and search.
pub struct CredentialPanel {
store: Entity<SessionStore>,
/// The workspace's focus handle: actions dispatched on it reach the
/// workspace handlers whatever is focused, which after a dialog closes
/// may be nothing at all.
target: FocusHandle,
search: Entity<InputState>,
query: String,
selected: Option<CredentialId>,
/// The credentials the store held at the last change, which is how one
/// just created in the dialog gets selected without the dialog reporting
/// back.
known: HashSet<CredentialId>,
/// The row a right click landed on, for the menu about to open; `None`
/// for the blank space below the rows.
menu_hit: Rc<Cell<Option<CredentialId>>>,
scroll_handle: ScrollHandle,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
/// One row of the list: a plain snapshot taken in `render`.
struct CredentialRow {
id: CredentialId,
kind: CredentialKind,
name: SharedString,
/// 「root · 密码 · 3 台主机」.
detail: SharedString,
/// The detail with the whole key path, for the tooltip.
tooltip: SharedString,
}
impl CredentialPanel {
pub fn new(
store: Entity<SessionStore>,
target: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索凭据")
.clean_on_escape()
});
let known = store
.read(cx)
.credentials()
.iter()
.map(|credential| credential.id)
.collect();
let subscriptions = vec![
cx.observe(&store, |this, _, cx| this.on_store_changed(cx)),
cx.subscribe(&search, |this, state, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = state.read(cx).value().to_string();
cx.notify();
}
}),
];
Self {
store,
target,
search,
query: String::new(),
selected: None,
known,
menu_hit: Rc::new(Cell::new(None)),
scroll_handle: ScrollHandle::new(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
/// Select whatever credential the store just gained, and let go of a
/// selection whose credential is gone.
fn on_store_changed(&mut self, cx: &mut Context<Self>) {
let current: Vec<CredentialId> = self
.store
.read(cx)
.credentials()
.iter()
.map(|credential| credential.id)
.collect();
if let Some(created) = current.iter().rev().find(|id| !self.known.contains(id)) {
self.selected = Some(*created);
}
if self.selected.is_some_and(|id| !current.contains(&id)) {
self.selected = None;
}
self.known = current.into_iter().collect();
cx.notify();
}
pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search.update(cx, |input, cx| input.focus(window, cx));
}
/// The credential the list has selected, if it is still there.
pub fn selected(&self) -> Option<CredentialId> {
self.selected
}
/// What the dock's title bar shows while this list is up.
pub fn title(&self) -> impl IntoElement + use<> {
h_flex()
.gap_1()
.child(Icon::new(CatalogIcon::KeyRound).small())
.child("凭据")
}
/// The dock's toolbar while this list is up.
pub fn toolbar_buttons(&self) -> Vec<Button> {
let target = self.target.clone();
vec![
Button::new("new-credential")
.icon(IconName::Plus)
.tooltip("新建凭据…")
.accessibility_label("新建凭据…")
.on_click(move |_, window, cx| target.dispatch_action(&NewCredential, window, cx)),
]
}
/// The rows the search lets through, in list order.
fn rows(&self, cx: &App) -> Vec<CredentialRow> {
let store = self.store.read(cx);
store
.credentials()
.iter()
.filter(|credential| matches_credential_query(credential, &self.query))
.map(|credential| {
let hosts = store.sessions_using(credential.id).count();
let summary = credential.summary();
let detail = match hosts {
0 => summary,
hosts => format!("{summary} · {hosts} 台主机"),
};
let tooltip = match credential.key_path.as_deref() {
Some(path) => format!("{} · {path}", credential.user),
None => detail.clone(),
};
CredentialRow {
id: credential.id,
kind: credential.kind,
name: credential.name.clone(),
detail: detail.into(),
tooltip: tooltip.into(),
}
})
.collect()
}
fn on_edit_selected(
&mut self,
_: &EditSelectedCredential,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(id) = self.selected {
self.target.dispatch_action(&EditCredential(id), window, cx);
}
}
fn on_select_previous(
&mut self,
_: &SelectPreviousCredential,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.move_selection(false, cx);
}
fn on_select_next(&mut self, _: &SelectNextCredential, _: &mut Window, cx: &mut Context<Self>) {
self.move_selection(true, cx);
}
/// Move the selection one visible row, stopping at either end. With
/// nothing selected it lands on the first row going down and on the last
/// going up.
fn move_selection(&mut self, down: bool, cx: &mut Context<Self>) {
let rows: Vec<CredentialId> = self.rows(cx).iter().map(|row| row.id).collect();
let Some(last) = rows.len().checked_sub(1) else {
return;
};
let current = self
.selected
.and_then(|id| rows.iter().position(|row| *row == id));
let next = match (current, down) {
(Some(ix), true) => (ix + 1).min(last),
(Some(ix), false) => ix.saturating_sub(1),
(None, true) => 0,
(None, false) => last,
};
self.selected = Some(rows[next]);
self.scroll_handle.scroll_to_item(next);
cx.notify();
}
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() {
("没有匹配的凭据", None, None)
} else {
(
"还没有凭据",
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)
}),
),
)
};
v_flex()
.id("credential-empty")
.test_support()
.aria_label(title)
.items_center()
.gap_2()
.px_4()
.py_8()
.text_color(muted)
.child(Icon::new(CatalogIcon::KeyRound).large())
.child(div().text_sm().child(title))
.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))
})
.into_any_element()
}
fn render_row(&self, row: CredentialRow, cx: &Context<Self>) -> ListItem {
let theme = cx.theme();
let id = row.id;
let selected = self.selected == Some(id);
let tooltip = row.tooltip.clone();
ListItem::new(("credential-row", id.0))
.w_full()
.px_2()
.py_1p5()
.rounded(theme.radius)
.confirmed(selected)
.aria_selected(selected)
.when(selected, |item| {
item.bg(crate::app::session_tree_selection_color(theme))
})
.child(
h_flex()
.gap_2()
.items_start()
.child(
// A fixed slot, so names line up whatever the icon.
h_flex()
.flex_shrink_0()
.size_5()
.justify_center()
.text_color(theme.muted_foreground)
.child(kind_icon(row.kind).small()),
)
.child(
// Zero wide, growing and clipped, like the forward
// list's: text that may grow otherwise counts at
// its full width in the row's content-sized
// wrappers.
v_flex()
.w_0()
.flex_grow(1.)
.overflow_x_hidden()
.gap_0p5()
.child(div().truncate().child(row.name))
.child(
div()
.text_xs()
.text_color(theme.muted_foreground)
.truncate()
.child(row.detail),
),
),
)
.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
// A right click selects the row, as in Finder and Explorer, so
// the menu visibly belongs to it.
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _, window, cx| {
this.menu_hit.set(Some(id));
this.selected = Some(id);
// The menu's commands are dispatched from what is
// focused, which after a dialog may be nothing.
this.focus_handle.focus(window, cx);
cx.notify();
}),
)
.on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
this.selected = Some(id);
this.focus_handle.focus(window, cx);
cx.notify();
if event.click_count() == 2 {
this.target.dispatch_action(&EditCredential(id), window, cx);
}
}))
}
}
/// The icon of a kind of credential, the same in the list and wherever else
/// one is named: a masked field, a key file, the agent holding the user's
/// keys.
pub fn kind_icon(kind: CredentialKind) -> Icon {
match kind {
CredentialKind::Password => Icon::new(CatalogIcon::RectangleEllipsis),
CredentialKind::Key => Icon::new(CatalogIcon::FileKey),
CredentialKind::Agent => Icon::new(CatalogIcon::UserRoundKey),
}
}
/// 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),
);
};
menu.menu_with_icon(
"编辑凭据…",
Icon::new(CatalogIcon::Pencil),
Box::new(EditCredential(id)),
)
.separator()
.menu_with_icon(
"删除",
Icon::new(CatalogIcon::Trash),
Box::new(DeleteCredential(id)),
)
}
impl Focusable for CredentialPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CredentialPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows(cx);
let clear_hit = self.menu_hit.clone();
let menu_hit = self.menu_hit.clone();
v_flex()
.id("credential-panel")
.key_context(CREDENTIAL_PANEL_CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_edit_selected))
.on_action(cx.listener(Self::on_select_previous))
.on_action(cx.listener(Self::on_select_next))
.size_full()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
div().p_2().child(
Input::new(&self.search)
.id("credential-search")
.small()
.cleanable(true)
.prefix(Icon::new(IconName::Search).small()),
),
)
.child(
// The menu hangs off the list, not off the rows, like the
// session tree's and the forward list's.
div()
.id("credential-list")
.test_support()
.flex_1()
.min_h_0()
.child(
v_flex()
.id("credential-rows")
.size_full()
.px_1()
.gap_0p5()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.vertical_scrollbar(&self.scroll_handle)
.map(|list| {
if rows.is_empty() {
list.child(self.render_empty(cx))
} else {
list.children(
rows.into_iter().map(|row| self.render_row(row, cx)),
)
}
}),
)
.capture_any_mouse_down(move |event, _, _| {
if matches!(event.button, MouseButton::Left | MouseButton::Right) {
clear_hit.set(None);
}
})
.context_menu(move |menu, _, _| build_context_menu(menu_hit.get(), menu)),
)
}
}
+9
View File
@@ -0,0 +1,9 @@
//! 凭据: 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`.
mod credential_dialog;
mod credential_panel;
pub use credential_dialog::{CredentialForm, open_credential_dialog};
pub use credential_panel::{CredentialPanel, kind_icon};
+4 -5
View File
@@ -97,12 +97,11 @@ impl ExplorerPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let session = store
let login = store
.read(cx)
.session(session_id)
.cloned()
.login(session_id)
.expect("workspace checked session");
let endpoint = format!("{}@{}:{}", session.user, session.host, session.port);
let endpoint = login.endpoint();
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
.places()
@@ -140,7 +139,7 @@ impl ExplorerPanel {
});
let (commands, receiver) = async_channel::unbounded();
let (sender, events) = async_channel::unbounded();
let transport = provider.create(&session);
let transport = provider.create(&login);
let failed = sender.clone();
if let Err(error) = std::thread::Builder::new()
.name("shellrs-sftp".into())
+3 -3
View File
@@ -210,11 +210,11 @@ impl ForwardManager {
let Some(rule) = store.forward(id) else {
return;
};
let Some(session) = store.session(rule.session) else {
let Some(login) = store.login(rule.session) else {
return;
};
let session_id = session.id;
let transport = self.provider.create(rule, session);
let session_id = rule.session;
let transport = self.provider.create(rule, &login);
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
self.failures.remove(&id);
+3 -3
View File
@@ -24,7 +24,7 @@ use crate::{
secrets::{InMemorySecretStore, SecretRef, SecretStore as _},
session::{
AuthKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, Session,
SessionDraft, SessionId,
SessionDraft, SessionId, SessionLogin,
},
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
};
@@ -328,7 +328,7 @@ impl Fixture {
target.map(|port| ForwardEndpoint::new("127.0.0.1", port)),
),
);
let transport = provider.create(&rule, &self.session());
let transport = provider.create(&rule, &SessionLogin::of(&self.session(), None));
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(command_receiver, event_sender));
@@ -848,7 +848,7 @@ fn a_connection_that_forwards_nothing_refuses_channels_from_the_server() {
// A terminal's or an SFTP tab's kind of connection.
let (_shutdown, shutdown) = watch::channel(false);
let prompts = Arc::new(SshPrompts::new(Arc::new(|_| false), shutdown).non_interactive());
let config = SshConnectionConfig::from(&fixture.session());
let config = SshConnectionConfig::from(&SessionLogin::of(&fixture.session(), None));
let (handle, _) = fixture.connector.connect(&config, prompts).await.unwrap();
let remote = free_port();
handle
+4 -3
View File
@@ -7,7 +7,7 @@ use async_channel::{Receiver, Sender};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{ForwardRule, Session},
session::{ForwardRule, SessionLogin},
};
/// What the list tells a running forward.
@@ -56,8 +56,9 @@ pub trait ForwardTransport: Send + 'static {
}
pub trait ForwardTransportProvider: Send + Sync + 'static {
/// A transport for `rule` as it stands now, through `session`.
fn create(&self, rule: &ForwardRule, session: &Session) -> Box<dyn ForwardTransport>;
/// A transport for `rule` as it stands now, logging in to its session
/// with `login`.
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport>;
}
pub type SharedForwardTransportProvider = Arc<dyn ForwardTransportProvider>;
+3 -3
View File
@@ -26,7 +26,7 @@ use super::{
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptKind},
session::{ForwardEndpoint, ForwardKind, ForwardRule, Session},
session::{ForwardEndpoint, ForwardKind, ForwardRule, SessionLogin},
ssh::{
ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle,
SshPrompts, describe_login_error, is_network_error,
@@ -78,10 +78,10 @@ impl SshForwardTransportProvider {
}
impl ForwardTransportProvider for SshForwardTransportProvider {
fn create(&self, rule: &ForwardRule, session: &Session) -> Box<dyn ForwardTransport> {
fn create(&self, rule: &ForwardRule, login: &SessionLogin) -> Box<dyn ForwardTransport> {
Box::new(SshForwardTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(session),
config: SshConnectionConfig::from(login),
kind: rule.kind,
bind: rule.bind.clone(),
target: rule.target.clone(),
+3 -2
View File
@@ -2,13 +2,14 @@
//!
//! Modules are organized by capability so they can become crates later:
//! `session` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `secrets`
//! (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `connection` 与
//! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential`
//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `connection` 与
//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源).
pub mod app;
pub mod cli;
pub mod connection;
pub mod credential;
pub mod explorer;
pub mod forward;
pub mod secrets;
+19 -1
View File
@@ -22,7 +22,7 @@ pub const SERVICE: &str = "shellrs";
///
/// 按「用哪个用户连到哪个端点」而不是「哪条保存的主机」归属:主机改名、复制都
/// 不丢密码,端点和用户相同的几条共用一条;私钥口令按文件路径归属,同一把钥匙
/// 只问一次。
/// 只问一次。密码凭据的密码归凭据自己,用它的主机共用这一条。
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SecretRef {
/// 某个 SSH 端点的登录密码。
@@ -33,6 +33,8 @@ pub enum SecretRef {
},
/// 某个私钥文件的口令。
Passphrase { key_path: String },
/// 某条密码凭据的密码,按凭据随机生成、永不复用的 `keychain_id` 归属。
Credential { keychain_id: String },
}
impl SecretRef {
@@ -50,11 +52,18 @@ impl SecretRef {
}
}
pub fn credential(keychain_id: impl Into<String>) -> Self {
Self::Credential {
keychain_id: keychain_id.into(),
}
}
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
pub fn account(&self) -> String {
match self {
Self::Password { user, host, port } => format!("password:{user}@{host}:{port}"),
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
}
}
}
@@ -102,11 +111,20 @@ mod tests {
assert_eq!(secret.account(), "passphrase:/Users/me/.ssh/id_ed25519");
}
#[test]
fn credential_accounts_name_the_keychain_id() {
let secret = SecretRef::credential("Jwg5rHvXCxw89paM");
assert_eq!(secret.account(), "credential:Jwg5rHvXCxw89paM");
}
#[test]
fn accounts_of_different_kinds_never_collide() {
let password = SecretRef::password("root", "10.0.1.12", 22);
let passphrase = SecretRef::passphrase("root@10.0.1.12:22");
let credential = SecretRef::credential("root@10.0.1.12:22");
assert_ne!(password.account(), passphrase.account());
assert_ne!(password.account(), credential.account());
assert_ne!(passphrase.account(), credential.account());
}
#[test]
+328
View File
@@ -0,0 +1,328 @@
//! Saved logins that hosts share: a user name with a password, a private key
//! file or the SSH agent. They live with the sessions because hosts refer to
//! them and they are stored in the same database; the `credential` module
//! that lists and edits them depends on this one, never the reverse.
use std::fmt;
use std::path::Path;
use gpui_kit::SharedString;
use crate::secrets::SecretRef;
use super::PublicId;
/// Stable identity of a credential. Never reused within a process.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CredentialId(pub u64);
/// What a credential logs in with.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum CredentialKind {
/// A password kept in the system keychain under the credential's own
/// 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.
Key,
/// Whatever keys the SSH agent holds.
Agent,
}
impl CredentialKind {
/// Every kind, in the order the form lists them.
pub const ALL: [CredentialKind; 3] = [
CredentialKind::Password,
CredentialKind::Key,
CredentialKind::Agent,
];
pub fn label(self) -> &'static str {
match self {
CredentialKind::Password => "密码",
CredentialKind::Key => "密钥",
CredentialKind::Agent => "SSH Agent",
}
}
/// The stored spelling. Kept separate from `label` so translating the UI
/// cannot rewrite what is already in the database.
pub fn as_str(self) -> &'static str {
match self {
CredentialKind::Password => "password",
CredentialKind::Key => "key",
CredentialKind::Agent => "agent",
}
}
pub fn from_stored(value: &str) -> Option<Self> {
match value {
"password" => Some(CredentialKind::Password),
"key" => Some(CredentialKind::Key),
"agent" => Some(CredentialKind::Agent),
_ => None,
}
}
}
/// Why a draft cannot be saved. `Display` is the text the form shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CredentialDraftError {
Name,
KeyPath,
}
impl fmt::Display for CredentialDraftError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
CredentialDraftError::Name => "请输入名称",
CredentialDraftError::KeyPath => "密钥凭据需要选择私钥文件",
})
}
}
impl std::error::Error for CredentialDraftError {}
/// The user name a login falls back to when none is given, as in the host
/// form.
pub const DEFAULT_USER: &str = "root";
/// The values the credential form commits. Never holds a secret: those go
/// straight to the keychain.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CredentialDraft {
pub name: SharedString,
pub kind: CredentialKind,
pub user: SharedString,
/// The private key file. Set exactly for [`CredentialKind::Key`].
pub key_path: Option<SharedString>,
}
impl CredentialDraft {
pub fn new(
name: impl Into<SharedString>,
kind: CredentialKind,
user: impl Into<SharedString>,
) -> Self {
Self {
name: name.into(),
kind,
user: user.into(),
key_path: None,
}
}
pub fn with_key_path(mut self, path: impl Into<SharedString>) -> Self {
let path = path.into();
self.key_path = (!path.trim().is_empty()).then_some(path);
self
}
/// The draft with surrounding blanks removed, an empty user name taken
/// as [`DEFAULT_USER`] and a key file kept only by a key credential, or
/// the first thing wrong with it.
pub fn validated(mut self) -> Result<Self, CredentialDraftError> {
self.name = self.name.trim().to_string().into();
if self.name.is_empty() {
return Err(CredentialDraftError::Name);
}
let user = self.user.trim();
self.user = if user.is_empty() {
DEFAULT_USER.into()
} else {
user.to_string().into()
};
self.key_path = match self.kind {
CredentialKind::Key => Some(
self.key_path
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
.ok_or(CredentialDraftError::KeyPath)?
.into(),
),
CredentialKind::Password | CredentialKind::Agent => None,
};
Ok(self)
}
}
/// A saved credential.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Credential {
pub id: CredentialId,
/// Names the credential's password in the keychain. Random rather than
/// derived from `id`, which can come back after a restart: a new
/// credential must never find the password an old one left behind.
pub keychain_id: PublicId,
pub name: SharedString,
pub kind: CredentialKind,
pub user: SharedString,
pub key_path: Option<SharedString>,
/// Position in the credential list.
pub sort_order: i64,
}
impl Credential {
/// A credential with a fresh keychain id. The store makes sure it is not
/// one another credential already has.
pub fn new(id: CredentialId, draft: CredentialDraft) -> Self {
Self {
id,
keychain_id: PublicId::generate(),
name: draft.name,
kind: draft.kind,
user: draft.user,
key_path: draft.key_path,
sort_order: 0,
}
}
/// The editable fields, for pre-filling the credential form.
pub fn draft(&self) -> CredentialDraft {
CredentialDraft {
name: self.name.clone(),
kind: self.kind,
user: self.user.clone(),
key_path: self.key_path.clone(),
}
}
/// Where a password credential keeps its password.
pub fn password_secret(&self) -> SecretRef {
SecretRef::credential(self.keychain_id.as_str())
}
/// What the credential logs in as and with: `root · 密码`, or
/// `deploy · id_ed25519` for a key.
pub fn summary(&self) -> String {
let with = match (self.kind, self.key_path.as_deref()) {
(CredentialKind::Key, Some(path)) => file_name(path).to_string(),
(kind, _) => kind.label().to_string(),
};
format!("{} · {with}", self.user)
}
}
/// The last component of a key file's path, which is how people know their
/// keys apart.
fn file_name(path: &str) -> &str {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path)
}
/// Whether a credential matches what was typed into the credential list's
/// search box: its name, user, kind or key file.
pub fn matches_credential_query(credential: &Credential, query: &str) -> bool {
let query = query.trim().to_lowercase();
if query.is_empty() {
return true;
}
[
Some(credential.name.as_ref()),
Some(credential.user.as_ref()),
Some(credential.kind.label()),
credential.key_path.as_deref(),
]
.into_iter()
.flatten()
.any(|text| text.to_lowercase().contains(&query))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kind_round_trips_through_the_database_spelling() {
for kind in CredentialKind::ALL {
assert_eq!(CredentialKind::from_stored(kind.as_str()), Some(kind));
}
assert_eq!(CredentialKind::from_stored("otp"), None);
}
#[test]
fn a_draft_needs_a_name_and_a_key_file_for_a_key() {
let saved = CredentialDraft::new(" 运维 ", CredentialKind::Password, " ")
.validated()
.unwrap();
assert_eq!(saved.name.as_ref(), "运维");
assert_eq!(saved.user.as_ref(), DEFAULT_USER);
assert_eq!(
CredentialDraft::new(" ", CredentialKind::Password, "root").validated(),
Err(CredentialDraftError::Name)
);
assert_eq!(
CredentialDraft::new("部署", CredentialKind::Key, "deploy").validated(),
Err(CredentialDraftError::KeyPath)
);
let key = CredentialDraft::new("部署", CredentialKind::Key, "deploy")
.with_key_path(" /tmp/id_ed25519 ")
.validated()
.unwrap();
assert_eq!(key.key_path.as_deref(), Some("/tmp/id_ed25519"));
}
#[test]
fn only_a_key_credential_keeps_a_key_file() {
for kind in [CredentialKind::Password, CredentialKind::Agent] {
let draft = CredentialDraft::new("x", kind, "root")
.with_key_path("/tmp/id_ed25519")
.validated()
.unwrap();
assert_eq!(draft.key_path, None);
}
}
#[test]
fn the_summary_names_the_user_and_what_it_logs_in_with() {
let password = Credential::new(
CredentialId(1),
CredentialDraft::new("运维", CredentialKind::Password, "root"),
);
assert_eq!(password.summary(), "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");
let agent = Credential::new(
CredentialId(3),
CredentialDraft::new("agent", CredentialKind::Agent, "me"),
);
assert_eq!(agent.summary(), "me · SSH Agent");
}
#[test]
fn each_credential_has_its_own_keychain_entry() {
let draft = CredentialDraft::new("运维", CredentialKind::Password, "root");
let one = Credential::new(CredentialId(1), draft.clone());
let other = Credential::new(CredentialId(1), draft);
assert_ne!(one.password_secret(), other.password_secret());
assert_eq!(
one.password_secret().account(),
format!("credential:{}", one.keychain_id)
);
}
#[test]
fn the_search_looks_at_the_name_the_user_the_kind_and_the_key_file() {
let credential = Credential::new(
CredentialId(1),
CredentialDraft::new("生产部署", CredentialKind::Key, "deploy")
.with_key_path("/Users/me/.ssh/id_prod"),
);
assert!(matches_credential_query(&credential, ""));
assert!(matches_credential_query(&credential, "生产"));
assert!(matches_credential_query(&credential, "DEPLOY"));
assert!(matches_credential_query(&credential, "密钥"));
assert!(matches_credential_query(&credential, "id_prod"));
assert!(!matches_credential_query(&credential, "root"));
}
}
+360 -22
View File
@@ -10,8 +10,9 @@ use std::{collections::HashSet, path::Path};
use rusqlite::{Connection, params};
use super::{
AuthKind, BookmarkSide, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule,
GroupDraft, GroupId, HostOs, PublicId, Session, SessionDraft, SessionGroup, SessionId,
AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind,
ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId,
HostOs, PublicId, Session, SessionDraft, SessionGroup, SessionId,
};
/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary
@@ -30,7 +31,7 @@ fn from_sql(id: i64) -> u64 {
///
/// The steps start at 7 rather than 1 because the databases of development
/// builds were already at 7 when the older steps were folded into `SCHEMA`.
const SCHEMA_VERSION: i64 = 8;
const SCHEMA_VERSION: i64 = 9;
/// The port-forwarding rules, added in version 8. A macro rather than a
/// constant so that `SCHEMA` and the step from version 7 are built from the
@@ -60,6 +61,56 @@ CREATE INDEX forwards_session_id ON forwards(session_id);"
};
}
/// The credentials and which session uses which, added in version 9. Built
/// like `forwards_table!`, for the same reason.
///
/// The link is a table of its own rather than a column on `sessions`: adding
/// a column rewrites the table's recorded definition into a shape the full
/// schema could only match by copying it. A session uses at most one
/// credential; deleting either side drops the link. Only a key credential
/// names a key file.
macro_rules! credential_tables {
() => {
"\
CREATE TABLE credentials (
id INTEGER PRIMARY KEY,
keychain_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')),
username TEXT NOT NULL,
key_path TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
CHECK ((kind = 'key') = (key_path IS NOT NULL))
);
CREATE TABLE session_credentials (
session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE CASCADE
);
CREATE INDEX session_credentials_credential_id ON session_credentials(credential_id);"
};
}
/// The steps from each older version to the next, in order. Each is one
/// transaction that ends by recording the version it reached.
const STEPS: [(i64, &str); 2] = [
(
7,
concat!(
"BEGIN;\n",
forwards_table!(),
"\nPRAGMA user_version = 8;\nCOMMIT;"
),
),
(
8,
concat!(
"BEGIN;\n",
credential_tables!(),
"\nPRAGMA user_version = 9;\nCOMMIT;"
),
),
];
/// The whole schema, as a new database gets it.
///
/// Groups nest through `parent_id`, and both references to a group cascade:
@@ -102,7 +153,9 @@ CREATE TABLE bookmarks (
UNIQUE (session_id, side, path)
);
",
forwards_table!()
forwards_table!(),
"\n",
credential_tables!()
);
/// Everything one launch reads back from disk.
@@ -117,6 +170,8 @@ pub struct StoredData {
pub bookmarks: Vec<(SessionId, BookmarkSide, String)>,
/// Port-forwarding rules in the order the forward list shows them.
pub forwards: Vec<ForwardRule>,
/// Credentials in the order the credential list shows them.
pub credentials: Vec<Credential>,
}
pub struct SessionDatabase {
@@ -166,8 +221,10 @@ impl SessionDatabase {
let sessions = self
.connection
.prepare(
"SELECT id, name, host, port, username, auth, group_id, key_path, os, sort_order, \
public_id FROM sessions ORDER BY id",
"SELECT s.id, s.name, s.host, s.port, s.username, s.auth, s.group_id, \
s.key_path, s.os, s.sort_order, s.public_id, c.credential_id \
FROM sessions s LEFT JOIN session_credentials c ON c.session_id = s.id \
ORDER BY s.id",
)?
.query_map([], |row| {
let id: i64 = row.get(0)?;
@@ -179,6 +236,7 @@ impl SessionDatabase {
let group: Option<i64> = row.get(6)?;
let key_path: Option<String> = row.get(7)?;
let os: Option<String> = row.get(8)?;
let credential: Option<i64> = row.get(11)?;
let mut session = Session::new(
SessionId(from_sql(id)),
SessionDraft::new(
@@ -194,6 +252,7 @@ impl SessionDatabase {
session.os = os.as_deref().and_then(HostOs::from_stored);
session.sort_order = row.get(9)?;
session.public_id = PublicId::from_stored(row.get(10)?);
session.credential = credential.map(|id| CredentialId(from_sql(id)));
Ok(session)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -267,12 +326,42 @@ impl SessionDatabase {
.filter_map(Result::transpose)
.collect::<rusqlite::Result<Vec<_>>>()?;
let credentials = self
.connection
.prepare(
"SELECT id, keychain_id, name, kind, username, key_path, sort_order \
FROM credentials ORDER BY sort_order, id",
)?
.query_map([], |row| {
let id: i64 = row.get(0)?;
let keychain_id: String = row.get(1)?;
let name: String = row.get(2)?;
let kind: String = row.get(3)?;
let user: String = row.get(4)?;
let key_path: Option<String> = row.get(5)?;
let sort_order: i64 = row.get(6)?;
// A kind this build does not know was written by a newer one;
// it is left out, and the hosts using it fall back to their
// own login rather than trying it as something else.
Ok(CredentialKind::from_stored(&kind).map(|kind| {
let mut draft = CredentialDraft::new(name, kind, user);
draft.key_path = key_path.map(Into::into);
let mut credential = Credential::new(CredentialId(from_sql(id)), draft);
credential.keychain_id = PublicId::from_stored(keychain_id);
credential.sort_order = sort_order;
credential
}))
})?
.filter_map(Result::transpose)
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(StoredData {
groups,
sessions,
recent,
bookmarks,
forwards,
credentials,
})
}
@@ -328,8 +417,10 @@ impl SessionDatabase {
Ok(())
}
/// Insert a session together with its link to a credential.
pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
let transaction = self.connection.unchecked_transaction()?;
transaction.execute(
"INSERT INTO sessions (id, name, host, port, username, auth, group_id, key_path, os, sort_order, public_id) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
@@ -346,14 +437,17 @@ impl SessionDatabase {
session.public_id.as_str(),
],
)?;
Ok(())
link_credential(&transaction, session)?;
transaction.commit()
}
/// Rewrites the editable fields. `last_connected_at` and `os` are left
/// alone: they are written by `touch_connected` and `set_host_os`, and
/// neither is part of the session form. `public_id` never changes.
/// Rewrites the editable fields and the link to a credential.
/// `last_connected_at` and `os` are left alone: they are written by
/// `touch_connected` and `set_host_os`, and neither is part of the session
/// form. `public_id` never changes.
pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
let transaction = self.connection.unchecked_transaction()?;
transaction.execute(
"UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \
auth = ?6, group_id = ?7, key_path = ?8, sort_order = ?9 WHERE id = ?1",
params![
@@ -368,7 +462,12 @@ impl SessionDatabase {
session.sort_order,
],
)?;
Ok(())
transaction.execute(
"DELETE FROM session_credentials WHERE session_id = ?1",
params![to_sql(session.id.0)],
)?;
link_credential(&transaction, session)?;
transaction.commit()
}
pub fn remove_session(&self, id: SessionId) -> rusqlite::Result<()> {
@@ -504,6 +603,64 @@ impl SessionDatabase {
Ok(())
}
pub fn insert_credential(&self, credential: &Credential) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT INTO credentials (id, keychain_id, name, kind, username, key_path, sort_order) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
to_sql(credential.id.0),
credential.keychain_id.as_str(),
credential.name.as_ref(),
credential.kind.as_str(),
credential.user.as_ref(),
credential.key_path.as_deref(),
credential.sort_order,
],
)?;
Ok(())
}
/// Rewrite a credential, and the user name of every session using it in
/// the same transaction: the sessions keep a copy of it.
pub fn update_credential(&self, credential: &Credential) -> rusqlite::Result<()> {
let transaction = self.connection.unchecked_transaction()?;
transaction.execute(
"UPDATE credentials SET name = ?2, kind = ?3, username = ?4, key_path = ?5, \
sort_order = ?6 WHERE id = ?1",
params![
to_sql(credential.id.0),
credential.name.as_ref(),
credential.kind.as_str(),
credential.user.as_ref(),
credential.key_path.as_deref(),
credential.sort_order,
],
)?;
transaction.execute(
"UPDATE sessions SET username = ?2 WHERE id IN \
(SELECT session_id FROM session_credentials WHERE credential_id = ?1)",
params![to_sql(credential.id.0), credential.user.as_ref()],
)?;
transaction.commit()
}
/// Delete a credential. The sessions using it go back to logging in on
/// their own, automatically, keeping the user name they had; the links
/// go through `ON DELETE CASCADE`.
pub fn remove_credential(&self, id: CredentialId) -> rusqlite::Result<()> {
let transaction = self.connection.unchecked_transaction()?;
transaction.execute(
"UPDATE sessions SET auth = ?2, key_path = NULL WHERE id IN \
(SELECT session_id FROM session_credentials WHERE credential_id = ?1)",
params![to_sql(id.0), AuthKind::Auto.as_str()],
)?;
transaction.execute(
"DELETE FROM credentials WHERE id = ?1",
params![to_sql(id.0)],
)?;
transaction.commit()
}
/// Record the operating system a probe found on the host. `None` clears
/// it, which is what a failed probe on a rebuilt host leaves behind.
pub fn set_host_os(&self, id: SessionId, os: Option<HostOs>) -> rusqlite::Result<()> {
@@ -525,22 +682,33 @@ impl SessionDatabase {
}
}
/// Record which credential `session` uses, if any. Its old link, if it had
/// one, is already gone.
fn link_credential(connection: &Connection, session: &Session) -> rusqlite::Result<()> {
if let Some(credential) = session.credential {
connection.execute(
"INSERT INTO session_credentials (session_id, credential_id) VALUES (?1, ?2)",
params![to_sql(session.id.0), to_sql(credential.0)],
)?;
}
Ok(())
}
/// Bring the database to `SCHEMA_VERSION`. A new file has version 0 and gets
/// the whole schema; an older one is taken forward one version at a time.
/// Each step is one transaction, so a file is never left between versions.
fn migrate(connection: &Connection) -> rusqlite::Result<()> {
let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
let mut version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
if version == 0 {
return connection.execute_batch(&format!(
"BEGIN;\n{SCHEMA}\nPRAGMA user_version = {SCHEMA_VERSION};\nCOMMIT;"
));
}
if version == 7 {
connection.execute_batch(concat!(
"BEGIN;\n",
forwards_table!(),
"\nPRAGMA user_version = 8;\nCOMMIT;"
))?;
for (from, step) in STEPS {
if version == from {
connection.execute_batch(step)?;
version = from + 1;
}
}
Ok(())
}
@@ -688,6 +856,7 @@ PRAGMA user_version = 7;";
let data = db.load().unwrap();
assert_eq!(data.sessions[0].name.as_ref(), "web");
assert!(data.forwards.is_empty());
assert!(data.credentials.is_empty());
// Upgrading ends with exactly the schema a new file gets.
let fresh = SessionDatabase::in_memory().unwrap();
assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection));
@@ -697,6 +866,174 @@ PRAGMA user_version = 7;";
assert!(SessionDatabase::open(&path).is_ok());
}
/// The schema as version 8 had it: version 7's plus the forwards table.
const SCHEMA_V8_FORWARDS: &str = "
CREATE TABLE forwards (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'dynamic')),
bind_host TEXT NOT NULL,
bind_port INTEGER NOT NULL CHECK (bind_port BETWEEN 1 AND 65535),
target_host TEXT,
target_port INTEGER,
auto_start INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
CHECK ((kind = 'dynamic' AND target_host IS NULL AND target_port IS NULL)
OR (kind <> 'dynamic' AND target_host IS NOT NULL
AND target_port BETWEEN 1 AND 65535))
);
CREATE INDEX forwards_session_id ON forwards(session_id);
PRAGMA user_version = 8;";
#[test]
fn a_version_8_database_gains_the_credential_tables_and_keeps_its_rows() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shellrs.db");
{
let old = Connection::open(&path).unwrap();
old.execute_batch(SCHEMA_V7).unwrap();
old.execute_batch(SCHEMA_V8_FORWARDS).unwrap();
old.execute(
"INSERT INTO sessions (id, name, host, port, username, auth, key_path, public_id) \
VALUES (1, 'web', 'example.test', 22, 'deploy', 'key', '/tmp/id', 'abcdefgh12345678')",
[],
)
.unwrap();
old.execute(
"INSERT INTO forwards (id, session_id, kind, bind_host, bind_port) \
VALUES (1, 1, 'dynamic', '127.0.0.1', 1080)",
[],
)
.unwrap();
}
let db = SessionDatabase::open(&path).unwrap();
let version: i64 = db
.connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
let data = db.load().unwrap();
assert_eq!(data.sessions[0].user.as_ref(), "deploy");
assert_eq!(data.sessions[0].auth, AuthKind::Key);
assert_eq!(data.sessions[0].credential, None);
assert_eq!(data.forwards.len(), 1);
assert!(data.credentials.is_empty());
let fresh = SessionDatabase::in_memory().unwrap();
assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection));
drop(db);
assert!(SessionDatabase::open(&path).is_ok());
}
fn credential(id: u64, name: &str, kind: CredentialKind) -> Credential {
let draft = CredentialDraft::new(name, kind, "deploy");
let draft = match kind {
CredentialKind::Key => draft.with_key_path("/tmp/id_deploy"),
_ => draft,
};
Credential::new(CredentialId(id), draft)
}
fn using(mut session: Session, credential: &Credential) -> Session {
session.credential = Some(credential.id);
session.user = credential.user.clone();
session.auth = AuthKind::Auto;
session
}
#[test]
fn credentials_round_trip_and_hosts_keep_their_link() {
let db = SessionDatabase::in_memory().unwrap();
let mut agent = credential(1, "agent", CredentialKind::Agent);
agent.sort_order = 1;
db.insert_credential(&agent).unwrap();
let key = credential(2, "部署", CredentialKind::Key);
db.insert_credential(&key).unwrap();
let web = using(session(1, "web", None), &key);
db.insert_session(&web).unwrap();
db.insert_session(&session(2, "db", None)).unwrap();
let data = db.load().unwrap();
// `sort_order` first, then id.
assert_eq!(data.credentials, [key.clone(), agent.clone()]);
assert_eq!(data.sessions[0].credential, Some(key.id));
assert_eq!(data.sessions[1].credential, None);
// Moving a host to another credential replaces its link; leaving it
// takes the link away.
db.update_session(&using(web.clone(), &agent)).unwrap();
assert_eq!(db.load().unwrap().sessions[0].credential, Some(agent.id));
let mut manual = web.clone();
manual.credential = None;
db.update_session(&manual).unwrap();
assert_eq!(db.load().unwrap().sessions[0].credential, None);
// Editing a credential renames its user on every host using it.
db.update_session(&web).unwrap();
let mut renamed = key.clone();
renamed.name = "生产部署".into();
renamed.user = "admin".into();
db.update_credential(&renamed).unwrap();
let data = db.load().unwrap();
assert_eq!(data.credentials[0], renamed);
assert_eq!(data.sessions[0].user.as_ref(), "admin");
assert_eq!(data.sessions[1].user.as_ref(), "root");
// Deleting a host drops its link with it.
db.remove_session(SessionId(1)).unwrap();
let links: i64 = db
.connection
.query_row("SELECT COUNT(*) FROM session_credentials", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(links, 0);
}
#[test]
fn removing_a_credential_turns_its_hosts_into_auto_and_drops_the_link() {
let db = SessionDatabase::in_memory().unwrap();
let key = credential(1, "部署", CredentialKind::Key);
db.insert_credential(&key).unwrap();
let mut web = using(session(1, "web", None), &key);
// Against the invariant, to show the delete does not rely on it.
web.auth = AuthKind::Key;
web.key_path = Some("/tmp/id_other".into());
db.insert_session(&web).unwrap();
db.insert_session(&session(2, "db", None)).unwrap();
db.remove_credential(key.id).unwrap();
let data = db.load().unwrap();
assert!(data.credentials.is_empty());
assert_eq!(data.sessions[0].credential, None);
assert_eq!(data.sessions[0].auth, AuthKind::Auto);
assert_eq!(data.sessions[0].key_path, None);
assert_eq!(data.sessions[0].user.as_ref(), "deploy");
// A host that never used it is untouched.
assert_eq!(data.sessions[1].auth, AuthKind::Password);
}
#[test]
fn the_database_refuses_a_credential_whose_key_file_does_not_fit_its_kind() {
let db = SessionDatabase::in_memory().unwrap();
let mut key_without_file = credential(1, "部署", CredentialKind::Key);
key_without_file.key_path = None;
assert!(db.insert_credential(&key_without_file).is_err());
let mut password_with_file = credential(2, "运维", CredentialKind::Password);
password_with_file.key_path = Some("/tmp/id".into());
assert!(db.insert_credential(&password_with_file).is_err());
// A host cannot use a credential that is not there.
let mut orphan = session(1, "web", None);
orphan.credential = Some(CredentialId(99));
assert!(db.insert_session(&orphan).is_err());
// And the failed insert left no half of the host behind.
assert!(db.load().unwrap().sessions.is_empty());
}
fn forward(id: u64, session: u64, kind: ForwardKind, port: u16) -> ForwardRule {
let target = kind
.has_target()
@@ -1054,8 +1391,9 @@ PRAGMA user_version = 7;";
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert!(tables.contains(&"sessions".to_string()));
assert!(tables.contains(&"forwards".to_string()));
for table in ["sessions", "forwards", "credentials", "session_credentials"] {
assert!(tables.contains(&table.to_string()), "{table}");
}
let mut columns = Vec::new();
for table in tables {
let mut statement = db
+214
View File
@@ -0,0 +1,214 @@
//! How a connection logs in to a host, with the credential the host uses, if
//! any, already looked up. The store builds one on the UI thread and hands it
//! to the terminal, SFTP, forward and CLI workers, which cannot read the
//! store themselves.
use std::path::PathBuf;
use crate::secrets::SecretRef;
use super::{AuthKind, Credential, CredentialKind, Session};
/// Which authentication methods a login tries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoginMethod {
/// The SSH agent, then the default keys in `~/.ssh`, then a password.
Auto,
Password,
/// One private key file.
Key,
/// Only the SSH agent's keys.
Agent,
}
/// Everything a login needs. Two equal values log in the same way, which is
/// how the store tells whether an edit has to reconnect a host: it holds
/// nothing that only changes what the user sees, such as a name.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionLogin {
pub host: String,
pub port: u16,
pub user: String,
pub method: LoginMethod,
/// The private key of [`LoginMethod::Key`], and `None` for every other
/// method.
pub key_path: Option<PathBuf>,
/// The keychain entry the password step reads: the endpoint's own, or a
/// password credential's. Key and agent logins keep the endpoint's for a
/// server that asks for a password after the key.
pub password: SecretRef,
}
impl SessionLogin {
/// A login typed into the host form rather than taken from a credential.
pub fn manual(
host: impl Into<String>,
port: u16,
user: impl Into<String>,
auth: AuthKind,
key_path: Option<PathBuf>,
) -> Self {
let host = host.into();
let user = user.into();
let (method, key_path) = match auth {
AuthKind::Auto => (LoginMethod::Auto, None),
AuthKind::Password => (LoginMethod::Password, None),
AuthKind::Key => (LoginMethod::Key, key_path),
};
Self {
password: SecretRef::password(&user, &host, port),
host,
port,
user,
method,
key_path,
}
}
/// A login through `credential` to `host:port`.
pub fn with_credential(host: impl Into<String>, port: u16, credential: &Credential) -> Self {
let host = host.into();
let user = credential.user.to_string();
let (method, key_path, password) = match credential.kind {
CredentialKind::Password => (LoginMethod::Password, None, credential.password_secret()),
CredentialKind::Key => (
LoginMethod::Key,
credential.key_path.as_deref().map(PathBuf::from),
SecretRef::password(&user, &host, port),
),
CredentialKind::Agent => (
LoginMethod::Agent,
None,
SecretRef::password(&user, &host, port),
),
};
Self {
host,
port,
user,
method,
key_path,
password,
}
}
/// How `session` logs in, given the credential it uses. `credential` is
/// ignored unless it is the one the session names, so a session whose
/// credential has gone logs in with what the form holds.
pub fn of(session: &Session, credential: Option<&Credential>) -> Self {
match credential.filter(|credential| session.credential == Some(credential.id)) {
Some(credential) => {
Self::with_credential(session.host.as_ref(), session.port, credential)
}
None => Self::manual(
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth,
session.key_path.as_deref().map(PathBuf::from),
),
}
}
/// `user@host:port`, for messages and as the key of resumable transfers.
pub fn endpoint(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
#[cfg(test)]
mod tests {
use super::super::{CredentialDraft, CredentialId, SessionDraft, SessionId};
use super::*;
fn session(auth: AuthKind) -> Session {
Session::new(
SessionId(1),
SessionDraft::new("web", "10.0.0.1", 22, "root", auth, None),
)
}
fn credential(kind: CredentialKind) -> Credential {
let draft = CredentialDraft::new("运维", kind, "deploy");
let draft = match kind {
CredentialKind::Key => draft.with_key_path("/tmp/id_deploy"),
_ => draft,
};
Credential::new(CredentialId(7), draft)
}
fn using(kind: CredentialKind) -> (Session, Credential) {
let credential = credential(kind);
let mut session = session(AuthKind::Auto);
session.credential = Some(credential.id);
(session, credential)
}
#[test]
fn a_manual_host_logs_in_with_its_endpoint_password() {
let login = SessionLogin::of(&session(AuthKind::Password), None);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.user, "root");
assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22));
assert_eq!(login.endpoint(), "root@10.0.0.1:22");
}
#[test]
fn auto_ignores_a_leftover_key_path() {
let mut host = session(AuthKind::Auto);
host.key_path = Some("/tmp/id_old".into());
assert_eq!(SessionLogin::of(&host, None).key_path, None);
host.auth = AuthKind::Key;
assert_eq!(
SessionLogin::of(&host, None).key_path,
Some(PathBuf::from("/tmp/id_old"))
);
}
#[test]
fn a_password_credential_logs_in_with_its_own_keychain_entry() {
let (session, credential) = using(CredentialKind::Password);
let login = SessionLogin::of(&session, Some(&credential));
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.user, "deploy");
assert_eq!(login.password, credential.password_secret());
}
#[test]
fn key_and_agent_credentials_keep_the_endpoint_for_a_partial_success() {
let (session, credential) = using(CredentialKind::Key);
let key = SessionLogin::of(&session, Some(&credential));
assert_eq!(key.method, LoginMethod::Key);
assert_eq!(key.key_path, Some(PathBuf::from("/tmp/id_deploy")));
assert_eq!(key.password, SecretRef::password("deploy", "10.0.0.1", 22));
let (session, credential) = using(CredentialKind::Agent);
let agent = SessionLogin::of(&session, Some(&credential));
assert_eq!(agent.method, LoginMethod::Agent);
assert_eq!(agent.key_path, None);
assert_eq!(
agent.password,
SecretRef::password("deploy", "10.0.0.1", 22)
);
}
#[test]
fn a_credential_the_host_does_not_name_is_not_used() {
let credential = credential(CredentialKind::Password);
let login = SessionLogin::of(&session(AuthKind::Auto), Some(&credential));
assert_eq!(login.method, LoginMethod::Auto);
assert_eq!(login.user, "root");
}
#[test]
fn renaming_a_credential_does_not_change_the_login() {
let (session, mut credential) = using(CredentialKind::Password);
let before = SessionLogin::of(&session, Some(&credential));
credential.name = "别的名字".into();
assert_eq!(SessionLogin::of(&session, Some(&credential)), before);
credential.user = "admin".into();
assert_ne!(SessionLogin::of(&session, Some(&credential)), before);
}
}
+6
View File
@@ -1,20 +1,26 @@
//! 主机管理: the saved hosts, which the code calls sessions. The session
//! model, its store, and the session panel/dialog.
mod credential;
mod database;
mod forward;
mod group_dialog;
mod login;
mod model;
mod outline;
mod secret_fields;
mod session_dialog;
mod session_panel;
mod store;
pub use credential::*;
pub use database::{SessionDatabase, StoredData};
pub use forward::*;
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 secret_fields::SecretFields;
pub use session_dialog::{DeleteHandler, SessionForm, confirm_delete_session, open_session_dialog};
pub use session_panel::{SessionPanel, session_menu};
pub use store::{SessionStore, SessionStoreEvent};
+21
View File
@@ -3,6 +3,8 @@ use rand::{Rng as _, distr::Alphanumeric};
use crate::secrets::SecretRef;
use super::CredentialId;
/// Stable identity of a session. Never reused within a process.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SessionId(pub u64);
@@ -14,6 +16,8 @@ pub struct SessionId(pub u64);
/// restart, while this one is never reused and stays with its session
/// through renames and address changes. It names a session; it grants
/// nothing.
///
/// A credential uses one the same way to name its password in the keychain.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PublicId(SharedString);
@@ -288,9 +292,15 @@ pub struct Session {
pub name: SharedString,
pub host: SharedString,
pub port: u16,
/// The user it logs in as. For a session using a credential this is the
/// credential's user, which the store keeps in step.
pub user: SharedString,
/// How a session typed into the form logs in. A session using a
/// credential keeps [`AuthKind::Auto`] and no key file here.
pub auth: AuthKind,
pub key_path: Option<SharedString>,
/// The credential it logs in with instead of `auth`, if any.
pub credential: Option<CredentialId>,
pub group: Option<GroupId>,
/// Order among sessions in the same group.
pub sort_order: i64,
@@ -314,6 +324,7 @@ impl Session {
user: draft.user,
auth: draft.auth,
key_path: draft.key_path,
credential: draft.credential,
group: draft.group,
sort_order: 0,
state: ConnectionState::Disconnected,
@@ -348,6 +359,7 @@ impl Session {
user: self.user.clone(),
auth: self.auth,
key_path: self.key_path.clone(),
credential: self.credential,
group: self.group,
}
}
@@ -408,6 +420,7 @@ pub struct SessionDraft {
pub user: SharedString,
pub auth: AuthKind,
pub key_path: Option<SharedString>,
pub credential: Option<CredentialId>,
pub group: Option<GroupId>,
}
@@ -427,10 +440,18 @@ impl SessionDraft {
user: user.into(),
auth,
key_path: None,
credential: None,
group,
}
}
/// Log in with a saved credential instead of `auth`. The store takes the
/// user name from the credential when the draft is saved.
pub fn with_credential(mut self, credential: CredentialId) -> Self {
self.credential = Some(credential);
self
}
/// The keychain entry this draft would log in with. Matches
/// [`Session::password_secret`] once the draft is applied.
pub fn password_secret(&self) -> SecretRef {
+306
View File
@@ -0,0 +1,306 @@
//! The password, private key and passphrase fields a login form shares: the
//! host form for a login typed there, the credential form for a saved one.
//! Each form lays the fields out in its own grid under its own ids; this owns
//! their state, reads back what the keychain saved for them, and says what a
//! commit should write.
use gpui_kit::component::{
Sizable as _,
button::Button,
h_flex,
input::{Input, InputContentType, InputEvent, InputState},
};
use gpui_kit::*;
use zeroize::Zeroizing;
use crate::secrets::{SecretRef, SharedSecretStore};
/// What the keychain had for the login being edited.
#[derive(Default)]
struct SavedSecrets {
password: Option<Zeroizing<String>>,
passphrase: Option<Zeroizing<String>>,
/// Which entries were looked up at all. An entry that was not looked up
/// leaves its field meaning nothing when empty.
looked_for_password: bool,
looked_for_passphrase: bool,
}
pub struct SecretFields {
password: Entity<InputState>,
key_path: Entity<InputState>,
passphrase: Entity<InputState>,
/// Where saved secrets are read from. Writes go through the store, which
/// owns the one error channel.
secrets: SharedSecretStore,
/// Whether the saved secret has been read back yet. Until it has, an
/// empty field means "still loading", not "the user cleared it", so
/// committing early never deletes anything.
password_loaded: bool,
passphrase_loaded: bool,
_key_path_subscription: Subscription,
}
impl SecretFields {
pub fn new(
secrets: SharedSecretStore,
key_path: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let password = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder("留空则每次连接都询问")
});
let key_path = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("选择 OpenSSH 私钥文件")
.default_value(key_path.unwrap_or_default())
});
let passphrase = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder("留空则每次连接都询问")
});
// 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.
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);
}
},
);
Self {
password,
key_path,
passphrase,
secrets,
password_loaded: false,
passphrase_loaded: false,
_key_path_subscription: key_path_subscription,
}
}
/// Whether secrets can be saved on this machine at all. Without a
/// keychain the secret fields are disabled.
pub fn keychain_available(&self) -> bool {
self.secrets.is_available()
}
/// Read what the keychain holds for the login being edited, off the UI
/// thread: the call blocks and on macOS may raise a system authorization
/// dialog. The form stays usable while it runs. `password` is the entry
/// the password field edits; the passphrase is the saved key file's.
pub fn load_saved(
&self,
password: Option<SecretRef>,
key_path: Option<SharedString>,
cx: &mut Context<Self>,
) {
let secrets = self.secrets.clone();
cx.spawn(async move |this, cx| {
let loaded = cx
.background_executor()
.spawn(async move {
SavedSecrets {
looked_for_password: password.is_some(),
looked_for_passphrase: key_path.is_some(),
password: password.and_then(|secret| secrets.get(&secret).ok().flatten()),
passphrase: key_path
.map(|path| SecretRef::passphrase(path.as_ref()))
.and_then(|secret| secrets.get(&secret).ok().flatten()),
}
})
.await;
this.update_in(cx, |this, window, cx| this.fill_saved(loaded, window, cx))
.ok();
})
.detach();
}
/// Put the saved secrets into their fields. A field the user has already
/// typed into wins: the read is asynchronous and may land late.
fn fill_saved(&mut self, loaded: SavedSecrets, window: &mut Window, cx: &mut Context<Self>) {
if loaded.looked_for_password {
if let Some(password) = loaded.password
&& self.password.read(cx).value().is_empty()
{
self.password
.update(cx, |input, cx| input.set_value(&*password, window, cx));
}
self.password_loaded = true;
}
if loaded.looked_for_passphrase {
if let Some(passphrase) = loaded.passphrase
&& self.passphrase.read(cx).value().is_empty()
{
self.passphrase
.update(cx, |input, cx| input.set_value(&*passphrase, window, cx));
}
self.passphrase_loaded = true;
}
cx.notify();
}
/// Drop a passphrase that belonged to a key file the form no longer
/// points at, so it cannot be written under the new key's name.
fn forget_loaded_passphrase(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.passphrase_loaded {
return;
}
self.passphrase_loaded = false;
self.passphrase
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}
/// 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 {
files: true,
directories: false,
multiple: false,
prompt: Some("选择 SSH 私钥".into()),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
this.update_in(cx, |this, window, cx| {
this.key_path.update(cx, |input, cx| {
input.set_value(path.to_string_lossy().into_owned(), window, cx)
});
cx.notify();
})
.ok();
})
.detach();
}
/// The key file, without surrounding blanks.
pub fn key_path(&self, cx: &App) -> String {
self.key_path.read(cx).value().trim().to_string()
}
/// The password as typed. Blanks around it are part of it.
pub fn password(&self, cx: &App) -> String {
self.password.read(cx).value().to_string()
}
pub fn passphrase(&self, cx: &App) -> String {
self.passphrase.read(cx).value().to_string()
}
/// What a commit does with the password's keychain entry.
pub fn password_change(&self, cx: &App) -> Option<Option<String>> {
secret_change(self.password(cx), self.password_loaded)
}
/// What a commit does with the passphrase's keychain entry.
pub fn passphrase_change(&self, cx: &App) -> Option<Option<String>> {
secret_change(self.passphrase(cx), self.passphrase_loaded)
}
/// Drop the plaintext from the fields. Called as the dialog closes,
/// rather than waiting for the form to go.
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
for input in [&self.password, &self.passphrase] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
}
pub fn password_input(&self, id: &'static str) -> Input {
self.secret_input(&self.password, id)
}
pub fn passphrase_input(&self, id: &'static str) -> Input {
self.secret_input(&self.passphrase, id)
}
fn secret_input(&self, state: &Entity<InputState>, id: &'static str) -> Input {
Input::new(state)
.id(id)
.small()
.mask_toggle()
.content_type(InputContentType::Password)
.disabled(!self.keychain_available())
}
/// The key file's field with its 「选择…」 button beside it.
pub fn key_path_input(
fields: &Entity<Self>,
id: &'static str,
choose_id: &'static str,
cx: &App,
) -> impl IntoElement + use<> {
let key_path = fields.read(cx).key_path.clone();
let fields = fields.clone();
h_flex()
.gap_2()
.w_full()
.child(Input::new(&key_path).id(id).small().flex_1())
.child(
Button::new(choose_id)
.label("选择…")
.small()
.on_click(move |_, window, cx| {
fields.update(cx, |fields, cx| fields.choose_key(window, cx))
}),
)
}
}
/// What to do with a secret field on commit: `None` leaves the keychain
/// alone, `Some(None)` deletes the entry, `Some(Some(value))` writes it.
///
/// An empty field only means "delete" once the saved value has been read
/// back. Before that it just means the read has not landed, so committing
/// straight away can never wipe a saved password.
fn secret_change(value: String, loaded: bool) -> Option<Option<String>> {
if !value.is_empty() {
Some(Some(value))
} else if loaded {
Some(None)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::secret_change;
#[test]
fn a_filled_field_is_written() {
assert_eq!(
secret_change("hunter2".into(), false),
Some(Some("hunter2".into()))
);
assert_eq!(
secret_change("hunter2".into(), true),
Some(Some("hunter2".into()))
);
}
#[test]
fn an_emptied_field_deletes_only_once_the_saved_value_was_read() {
assert_eq!(secret_change(String::new(), true), Some(None));
assert_eq!(secret_change(String::new(), false), None);
}
#[test]
fn surrounding_whitespace_is_part_of_the_secret() {
assert_eq!(
secret_change(" spaced ".into(), true),
Some(Some(" spaced ".into()))
);
}
}
+363 -357
View File
@@ -6,33 +6,48 @@ use gpui_kit::component::{
dialog::{Cancel, Confirm, DialogButtonProps, DialogFooter},
form::{Field, Form},
h_flex,
input::{Input, InputContentType, InputEvent, InputState},
input::{Input, InputState},
notification::Notification,
radio::{Radio, RadioGroup},
select::{Select, SelectState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use zeroize::Zeroizing;
use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt};
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::secrets::SecretRef;
use super::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionStore, group_options};
use super::secret_fields::SecretFields;
use super::{
AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, Session,
SessionDraft, SessionId, SessionLogin, SessionStore, group_options,
};
pub use crate::shared::DeleteHandler;
use crate::shared::{confirm_delete, form_error, parse_port};
/// The label of the row that puts a session at the root of the tree.
pub const NO_GROUP_LABEL: &str = "(无分组)";
/// What the keychain had for the session being edited.
#[derive(Default)]
struct SavedSecrets {
password: Option<Zeroizing<String>>,
passphrase: Option<Zeroizing<String>>,
/// Whether a key file was configured at all. Without one nothing was
/// looked up, so an empty passphrase field means nothing either.
looked_for_passphrase: bool,
/// Where a host's login comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthSource {
/// Typed into this form.
Manual,
/// A saved credential.
Credential,
}
impl AuthSource {
/// Both, in the order the form lists them.
const ALL: [AuthSource; 2] = [AuthSource::Manual, AuthSource::Credential];
fn label(self) -> &'static str {
match self {
AuthSource::Manual => "手动输入",
AuthSource::Credential => "使用凭据",
}
}
}
/// The body of the new/edit session dialog. Owns the field states and
@@ -43,11 +58,16 @@ pub struct SessionForm {
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
source: AuthSource,
user: Entity<InputState>,
auth: Entity<SelectState<Vec<&'static str>>>,
key_path: Entity<InputState>,
password: Entity<InputState>,
passphrase: Entity<InputState>,
/// The password, key file and passphrase of a login typed here.
fields: Entity<SecretFields>,
credential: Entity<SelectState<Vec<SharedString>>>,
/// The credentials as they were when the form opened, parallel to the
/// credential select's rows. The dialog is modal, so nothing can change
/// them while it is open.
credentials: Vec<Credential>,
group: Entity<SelectState<Vec<SharedString>>>,
/// Parallel to the group select's rows; `None` is the root of the tree.
group_ids: Vec<Option<GroupId>>,
@@ -56,16 +76,7 @@ pub struct SessionForm {
/// Logs in with the form's current values for 「测试连接」.
tester: SharedConnectionTester,
editing_connected: bool,
/// Where saved secrets are read from. Writes go through the store, which
/// owns the one error channel.
secrets: SharedSecretStore,
/// Whether the saved secret has been read back yet. Until it has, an empty
/// field means "still loading", not "the user cleared it", so committing
/// early never deletes anything.
password_loaded: bool,
passphrase_loaded: bool,
_auth_subscription: Subscription,
_key_path_subscription: Subscription,
_subscriptions: Vec<Subscription>,
}
impl SessionForm {
@@ -78,11 +89,12 @@ impl SessionForm {
cx: &mut Context<Self>,
) -> Self {
let secrets = store.read(cx).secrets();
let (draft, options, editing_connected) = {
let (draft, options, credentials, 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(),
editing
.and_then(|id| read.session(id))
.is_some_and(|session| session.state != super::ConnectionState::Disconnected),
@@ -97,7 +109,7 @@ impl SessionForm {
group_names.push(path);
}
let draft = draft.unwrap_or_else(|| {
SessionDraft::new("", "", 22, "root", AuthKind::Auto, preselect_group)
SessionDraft::new("", "", 22, DEFAULT_USER, AuthKind::Auto, preselect_group)
});
let name = cx.new(|cx| {
@@ -117,7 +129,7 @@ impl SessionForm {
});
let user = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("root")
.placeholder(DEFAULT_USER)
.default_value(draft.user.clone())
});
let auth_ix = AuthKind::ALL
@@ -135,154 +147,78 @@ impl SessionForm {
cx,
)
});
let key_path = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("选择 OpenSSH 私钥文件")
.default_value(draft.key_path.clone().unwrap_or_default())
let fields = cx.new(|cx| SecretFields::new(secrets, draft.key_path.clone(), window, cx));
let credential_ix = draft.credential.and_then(|id| {
credentials
.iter()
.position(|credential| credential.id == id)
});
let auth_subscription = cx.observe(&auth, |_, _, cx| cx.notify());
let password = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder("留空则每次连接都询问")
let credential = cx.new(|cx| {
SelectState::new(
credentials
.iter()
.map(credential_option)
.collect::<Vec<_>>(),
credential_ix.map(IndexPath::new),
window,
cx,
)
.searchable(true)
});
let passphrase = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder("留空则每次连接都询问")
});
// 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.
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);
}
},
);
let group_ix = group_ids
.iter()
.position(|group| *group == draft.group)
.unwrap_or(0);
let group =
cx.new(|cx| SelectState::new(group_names, Some(IndexPath::new(group_ix)), window, cx));
let subscriptions = vec![
cx.observe(&auth, |_, _, cx| cx.notify()),
cx.observe(&credential, |_, _, cx| cx.notify()),
cx.observe(&fields, |_, _, cx| cx.notify()),
];
let this = Self {
if editing.is_some() {
// The typed login's secrets, even for a host that uses a
// credential now: they are what switching back would show.
fields.update(cx, |fields, cx| {
let key_path = (draft.auth == AuthKind::Key)
.then(|| draft.key_path.clone())
.flatten();
fields.load_saved(Some(draft.password_secret()), key_path, cx)
});
}
Self {
store,
editing,
name,
host,
port,
source: if credential_ix.is_some() {
AuthSource::Credential
} else {
AuthSource::Manual
},
user,
auth,
key_path,
password,
passphrase,
fields,
credential,
credentials,
group,
group_ids,
error: None,
testing_connection: false,
tester,
editing_connected,
secrets,
password_loaded: false,
passphrase_loaded: false,
_auth_subscription: auth_subscription,
_key_path_subscription: key_path_subscription,
};
if editing.is_some() {
this.load_saved_secrets(&draft, cx);
_subscriptions: subscriptions,
}
this
}
/// Read what the keychain holds for the session being edited, off the UI
/// thread: the call blocks and on macOS may raise a system authorization
/// dialog. The form stays usable while it runs.
fn load_saved_secrets(&self, draft: &SessionDraft, cx: &mut Context<Self>) {
let secrets = self.secrets.clone();
let endpoint = draft.password_secret();
let key_path = draft.key_path.clone();
cx.spawn(async move |this, cx| {
let loaded = cx
.background_executor()
.spawn(async move {
SavedSecrets {
password: secrets.get(&endpoint).ok().flatten(),
passphrase: key_path
.as_ref()
.map(|path| SecretRef::passphrase(path.as_ref()))
.and_then(|secret| secrets.get(&secret).ok().flatten()),
looked_for_passphrase: key_path.is_some(),
}
})
.await;
this.update_in(cx, |this, window, cx| this.fill_saved(loaded, window, cx))
.ok();
})
.detach();
}
/// Put the saved secrets into their fields. A field the user has already
/// typed into wins: the read is asynchronous and may land late.
fn fill_saved(&mut self, loaded: SavedSecrets, window: &mut Window, cx: &mut Context<Self>) {
if let Some(password) = loaded.password
&& self.password.read(cx).value().is_empty()
{
self.password
.update(cx, |input, cx| input.set_value(&*password, window, cx));
fn set_source(&mut self, source: AuthSource, cx: &mut Context<Self>) {
if self.source != source {
self.source = source;
self.error = None;
cx.notify();
}
self.password_loaded = true;
if loaded.looked_for_passphrase {
if let Some(passphrase) = loaded.passphrase
&& self.passphrase.read(cx).value().is_empty()
{
self.passphrase
.update(cx, |input, cx| input.set_value(&*passphrase, window, cx));
}
self.passphrase_loaded = true;
}
cx.notify();
}
/// Drop a passphrase that belonged to a key file the form no longer points
/// at, so it cannot be written under the new key's name.
fn forget_loaded_passphrase(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.passphrase_loaded {
return;
}
self.passphrase_loaded = false;
self.passphrase
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}
fn choose_key(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
let receiver = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("选择 SSH 私钥".into()),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
this.update_in(cx, |this, window, cx| {
this.key_path.update(cx, |input, cx| {
input.set_value(path.to_string_lossy().into_owned(), window, cx)
});
cx.notify();
})
.ok();
})
.detach();
}
/// The authentication method the form has selected.
@@ -294,36 +230,58 @@ impl SessionForm {
.unwrap_or_default()
}
/// The credential the form has selected.
fn selected_credential(&self, cx: &App) -> Option<&Credential> {
self.credential
.read(cx)
.selected_index(cx)
.and_then(|ix| self.credentials.get(ix.row))
}
/// The address and port, or why they will not do.
fn endpoint(&self, cx: &App) -> Result<(String, u16), &'static str> {
let host = self.host.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
match (host.is_empty(), port) {
(true, _) => Err("请输入地址"),
(_, None) => Err("端口必须是 1 到 65535 之间的数字"),
(_, Some(port)) => Ok((host, port)),
}
}
/// The login the form's current values describe, saved or not, or why
/// there is nothing to test yet.
fn login_test(&self, cx: &App) -> Result<LoginTest, &'static str> {
let host = self.host.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
let (host, port) = self.endpoint(cx)?;
if self.source == AuthSource::Credential {
let credential = self.selected_credential(cx).ok_or("请选择凭据")?;
return Ok(LoginTest::saved(SessionLogin::with_credential(
host, port, credential,
)));
}
let user = self.user.read(cx).value().trim().to_string();
if user.is_empty() {
return Err("请输入用户名");
}
let auth = self.auth(cx);
let key_path = self.key_path.read(cx).value().trim().to_string();
let port = match (host.is_empty(), port, user.is_empty()) {
(true, _, _) => return Err("请输入地址"),
(_, None, _) => return Err("端口必须是 1 到 65535 之间的数字"),
(_, _, true) => return Err("请输入用户名"),
(_, Some(port), _) => port,
};
let fields = self.fields.read(cx);
let key_path = fields.key_path(cx);
if auth == AuthKind::Key && key_path.is_empty() {
return Err("私钥认证需要选择私钥文件");
}
let mut request = LoginTest::new(host, port, user, auth);
let key_path = (auth == AuthKind::Key).then(|| key_path.into());
let mut request = LoginTest::typed(SessionLogin::manual(host, port, user, auth, key_path));
// Only what the chosen method uses, which is also what the form shows.
if uses_password(auth) {
let password = self.password.read(cx).value();
let password = fields.password(cx);
if !password.is_empty() {
request = request.with_password(password.to_string());
request = request.with_password(password);
}
}
if auth == AuthKind::Key {
request = request.with_key_path(key_path);
let passphrase = self.passphrase.read(cx).value();
let passphrase = fields.passphrase(cx);
if !passphrase.is_empty() {
request = request.with_passphrase(passphrase.to_string());
request = request.with_passphrase(passphrase);
}
}
Ok(request)
@@ -405,31 +363,44 @@ impl SessionForm {
.detach();
}
/// How the form says the host logs in, or what is missing.
fn committed_login(&self, cx: &App) -> Result<CommittedLogin, &'static str> {
match self.source {
AuthSource::Manual => {
let auth = self.auth(cx);
let key_path = self.fields.read(cx).key_path(cx);
if auth == AuthKind::Key && key_path.is_empty() {
return Err("私钥认证需要选择私钥文件");
}
Ok(CommittedLogin::Typed { auth, key_path })
}
AuthSource::Credential => self
.selected_credential(cx)
.map(|credential| CommittedLogin::Saved {
credential: credential.id,
user: credential.user.clone(),
})
.ok_or("请选择凭据"),
}
}
/// Validate and write to the store. Returns whether the dialog may close.
pub fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let name = self.name.read(cx).value().trim().to_string();
let host = self.host.read(cx).value().trim().to_string();
let user = self.user.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
let auth = self.auth(cx);
let key_path = self.key_path.read(cx).value().trim().to_string();
let error = if name.is_empty() {
Some("请输入名称")
} else if host.is_empty() {
Some("请输入地址")
} else if port.is_none() {
Some("端口必须是 1 到 65535 之间的数字")
} else if auth == AuthKind::Key && key_path.is_empty() {
Some("私钥认证需要选择私钥文件")
let checked = if name.is_empty() {
Err("请输入名称")
} else {
None
self.endpoint(cx)
.and_then(|endpoint| Ok((endpoint, self.committed_login(cx)?)))
};
let ((host, port), login) = match checked {
Ok(checked) => checked,
Err(error) => {
self.error = Some(error.into());
cx.notify();
return false;
}
};
if let Some(error) = error {
self.error = Some(error.into());
cx.notify();
return false;
}
let group = self
.group
@@ -437,44 +408,37 @@ impl SessionForm {
.selected_index(cx)
.and_then(|ix| self.group_ids.get(ix.row).copied())
.unwrap_or(None);
let mut draft = SessionDraft::new(
name,
host,
port.unwrap_or(22),
if user.is_empty() {
"root".to_string()
} else {
user
},
auth,
group,
);
if auth == AuthKind::Key {
draft = draft.with_key_path(key_path);
}
// Secrets never ride along in the draft, which derives `Debug`. They
// go to the keychain separately, under the endpoint the draft names.
let password_secret = draft.password_secret();
let passphrase_secret = draft
.key_path
.as_ref()
.map(|path| SecretRef::passphrase(path.as_ref()));
let password_change = if uses_password(auth) {
secret_change(
self.password.read(cx).value().to_string(),
self.password_loaded,
)
} else {
None
};
let passphrase_change = if auth == AuthKind::Key {
secret_change(
self.passphrase.read(cx).value().to_string(),
self.passphrase_loaded,
)
} else {
None
// go to the keychain separately, under the endpoint the draft names;
// a login through a credential leaves the host's own entries alone.
let mut secret_changes: Vec<(SecretRef, Option<String>)> = Vec::new();
let draft = match login {
CommittedLogin::Saved { credential, user } => {
SessionDraft::new(name, host, port, user, AuthKind::Auto, group)
.with_credential(credential)
}
CommittedLogin::Typed { auth, key_path } => {
let user = self.user.read(cx).value().trim().to_string();
let user = if user.is_empty() {
DEFAULT_USER.to_string()
} else {
user
};
let mut draft = SessionDraft::new(name, host, port, user, auth, group);
let fields = self.fields.read(cx);
if uses_password(auth)
&& let Some(change) = fields.password_change(cx)
{
secret_changes.push((draft.password_secret(), change));
}
if auth == AuthKind::Key {
if let Some(change) = fields.passphrase_change(cx) {
secret_changes.push((SecretRef::passphrase(&key_path), change));
}
draft = draft.with_key_path(key_path);
}
draft
}
};
let editing = self.editing;
@@ -489,120 +453,174 @@ impl SessionForm {
}
// `update` above may have dropped the entry for the endpoint the
// session just left; this writes the one it moved to.
if let Some(change) = password_change {
store.save_secret(password_secret, change, cx);
}
if let (Some(secret), Some(change)) = (passphrase_secret, passphrase_change) {
for (secret, change) in secret_changes {
store.save_secret(secret, change, cx);
}
});
// The dialog is about to close and take the form with it; drop the
// plaintext now rather than waiting for the entity.
self.password
.update(cx, |input, cx| input.set_value("", window, cx));
self.passphrase
.update(cx, |input, cx| input.set_value("", window, cx));
self.fields
.update(cx, |fields, cx| fields.clear(window, cx));
self.error = None;
true
}
/// The fields of a login typed into the form.
fn manual_fields(&self, form: Form, cx: &mut Context<Self>) -> Form {
let auth = self.auth(cx);
let fields = self.fields.read(cx);
form.child(
Field::new()
.label("用户名")
.col_span(4)
.child(Input::new(&self.user).id("session-user").small()),
)
.child(
Field::new()
.label("认证类型")
.col_span(4)
.child(Select::new(&self.auth).id("session-auth").small()),
)
.when(uses_password(auth), |form| {
form.child(
Field::new()
.label("密码")
.col_span(4)
.child(fields.password_input("session-password")),
)
})
.when(auth == AuthKind::Key, |form| {
form.child(
Field::new()
.label("私钥文件")
.required(true)
.col_span(4)
.child(SecretFields::key_path_input(
&self.fields,
"session-key-path",
"choose-key",
cx,
)),
)
.child(
Field::new()
.label("私钥口令")
.col_span(4)
.child(fields.passphrase_input("session-passphrase")),
)
})
}
/// 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)),
None if self.credentials.is_empty() => {
Some("还没有凭据,可在侧栏的「凭据」中新建".into())
}
None => None,
};
Field::new()
.label("凭据")
.required(true)
.col_span(4)
.child(
Select::new(&self.credential)
.id("session-credential")
.placeholder("请选择凭据")
.search_placeholder("搜索凭据")
.empty(|_, cx| {
div()
.py_4()
.text_sm()
.text_center()
.text_color(cx.theme().muted_foreground)
.child("还没有凭据")
})
.small(),
)
.when_some(summary, |field, summary: SharedString| {
field.description_fn(move |_, _| {
div()
.id("session-credential-summary")
.test_support()
.aria_label(summary.clone())
.child(summary.clone())
})
})
}
}
impl Render for SessionForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let auth = self.auth(cx);
let keychain = self.secrets.is_available();
let source = self.source;
let keychain = self.fields.read(cx).keychain_available();
let secret_note = if keychain {
"密码保存在系统钥匙串,不会写入 ShellRS 的数据库。"
} else {
"系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"
};
// Four columns so the address and its port share a row, as they are
// written (`host:port`); every other field takes a row of its own.
let form = Form::new()
.columns(4)
.child(
Field::new()
.label("名称")
.required(true)
.col_span(4)
.child(Input::new(&self.name).id("session-name").small()),
)
.child(
Field::new()
.label("地址")
.required(true)
.col_span(3)
.child(Input::new(&self.host).id("session-host").small()),
)
.child(
Field::new()
.label("端口")
.child(Input::new(&self.port).id("session-port").small()),
)
.child(
Field::new().label("认证方式").col_span(4).child(
RadioGroup::horizontal("session-auth-source")
.selected_index(AuthSource::ALL.iter().position(|each| *each == source))
.on_change(cx.listener(|this, ix: &usize, _, cx| {
if let Some(source) = AuthSource::ALL.get(*ix) {
this.set_source(*source, cx);
}
}))
.children(
AuthSource::ALL
.map(|each| Radio::new(each.label()).label(each.label()).small()),
),
),
);
let form = match source {
AuthSource::Manual => self.manual_fields(form, cx),
AuthSource::Credential => form.child(self.credential_field(cx)),
};
v_flex()
.gap_3()
.w_full()
.child(
Form::new()
.child(
Field::new()
.label("名称")
.required(true)
.child(Input::new(&self.name).id("session-name").small()),
)
.child(
Field::new()
.label("地址")
.required(true)
.child(Input::new(&self.host).id("session-host").small()),
)
.child(
Field::new()
.label("端口")
.child(Input::new(&self.port).id("session-port").small()),
)
.child(
Field::new()
.label("用户名")
.child(Input::new(&self.user).id("session-user").small()),
)
.child(
Field::new()
.label("认证方式")
.child(Select::new(&self.auth).id("session-auth").small()),
)
.when(uses_password(auth), |form| {
form.child(
Field::new().label("密码").child(
Input::new(&self.password)
.id("session-password")
.small()
.mask_toggle()
.content_type(InputContentType::Password)
.disabled(!keychain),
),
)
})
.when(auth == AuthKind::Key, |form| {
form.child(
Field::new().label("私钥文件").required(true).child(
h_flex()
.gap_2()
.w_full()
.child(
Input::new(&self.key_path)
.id("session-key-path")
.small()
.flex_1(),
)
.child(
Button::new("choose-key")
.label("选择…")
.small()
.on_click(cx.listener(Self::choose_key)),
),
),
)
.child(
Field::new().label("私钥口令").child(
Input::new(&self.passphrase)
.id("session-passphrase")
.small()
.mask_toggle()
.content_type(InputContentType::Password)
.disabled(!keychain),
),
)
})
.child(
Field::new()
.label("分组")
.child(Select::new(&self.group).small()),
),
)
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(secret_note),
form.child(
Field::new()
.label("分组")
.col_span(4)
.child(Select::new(&self.group).small()),
),
)
.when(source == AuthSource::Manual, |form| {
form.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(secret_note),
)
})
.when(self.editing_connected, |form| {
form.child(
div()
@@ -617,6 +635,36 @@ impl Render for SessionForm {
}
}
/// How a host the form commits logs in.
enum CommittedLogin {
Typed {
auth: AuthKind,
key_path: String,
},
Saved {
credential: CredentialId,
user: SharedString,
},
}
/// A credential as the host form's select lists it: `运维(root · 密码)`.
fn credential_option(credential: &Credential) -> SharedString {
format!("{}({})", credential.name, credential.summary()).into()
}
/// What a host using `credential` logs in as and with.
fn credential_summary(credential: &Credential) -> SharedString {
let with = match credential.kind {
CredentialKind::Password => "使用凭据保存的密码".to_string(),
CredentialKind::Key => format!(
"使用私钥 {}",
credential.key_path.as_deref().unwrap_or_default()
),
CredentialKind::Agent => "使用 SSH Agent 中的密钥".to_string(),
};
format!("以 {} 登录,{with}", credential.user).into()
}
/// A trust question from the test's worker, with where to send the answer.
type TrustQuestion = (UnknownHostPrompt, std::sync::mpsc::Sender<bool>);
@@ -673,22 +721,6 @@ fn uses_password(auth: AuthKind) -> bool {
matches!(auth, AuthKind::Auto | AuthKind::Password)
}
/// What to do with a secret field on commit: `None` leaves the keychain
/// alone, `Some(None)` deletes the entry, `Some(Some(value))` writes it.
///
/// An empty field only means "delete" once the saved value has been read
/// back. Before that it just means the read has not landed, so committing
/// straight away can never wipe a saved password.
fn secret_change(value: String, loaded: bool) -> Option<Option<String>> {
if !value.is_empty() {
Some(Some(value))
} else if loaded {
Some(None)
} else {
None
}
}
/// Open the new-session (`editing == None`) or edit-session dialog.
/// `preselect_group` fills in the group field of a new session, so creating
/// one from a group's context menu lands it in that group. `tester` backs the
@@ -815,7 +847,7 @@ fn describe_session_delete(
#[cfg(test)]
mod tests {
use super::{AuthKind, describe_session_delete, secret_change, uses_password};
use super::{AuthKind, describe_session_delete, uses_password};
#[test]
fn deleting_a_session_says_what_goes_with_it() {
@@ -844,30 +876,4 @@ mod tests {
assert!(uses_password(AuthKind::Password));
assert!(!uses_password(AuthKind::Key));
}
#[test]
fn a_filled_field_is_written() {
assert_eq!(
secret_change("hunter2".into(), false),
Some(Some("hunter2".into()))
);
assert_eq!(
secret_change("hunter2".into(), true),
Some(Some("hunter2".into()))
);
}
#[test]
fn an_emptied_field_deletes_only_once_the_saved_value_was_read() {
assert_eq!(secret_change(String::new(), true), Some(None));
assert_eq!(secret_change(String::new(), false), None);
}
#[test]
fn surrounding_whitespace_is_part_of_the_secret() {
assert_eq!(
secret_change(" spaced ".into(), true),
Some(Some(" spaced ".into()))
);
}
}
+418 -48
View File
@@ -7,14 +7,21 @@ use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore};
use super::database::now_seconds;
use super::{
AuthKind, BookmarkSide, ConnectionState, ForwardDraft, ForwardId, ForwardRule, GroupDraft,
GroupId, HostOs, NodeDrop, PublicId, Session, SessionDatabase, SessionDraft, SessionGroup,
SessionId, SessionNode, StoredData,
AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId,
CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, HostOs, NodeDrop,
PublicId, Session, SessionDatabase, SessionDraft, SessionGroup, SessionId, SessionLogin,
SessionNode, StoredData,
};
/// The single source of truth for sessions, groups and the rules that belong
/// to sessions (bookmarks, port forwards). Created once by the
/// workspace and shared with every panel and dialog; consumers observe it.
/// The single source of truth for sessions, groups, the rules that belong
/// to sessions (bookmarks, port forwards) and the credentials sessions log in
/// with. Created once by the workspace and shared with every panel and
/// dialog; consumers observe it.
///
/// A session using a credential keeps a copy of the credential's user name,
/// so everything that shows or keys by `user@host:port` reads the session
/// alone. The store keeps the copy in step: it takes it from the credential
/// when a session is saved and rewrites it when the credential changes.
///
/// Mutators that take a `Context` notify observers and mirror the change into
/// the database; the `*_unnotified` variants are pure in-memory and exist for
@@ -41,6 +48,9 @@ pub struct SessionStore {
/// Port-forwarding rules, in the order the forward list shows them.
forwards: Vec<ForwardRule>,
next_forward_id: u64,
/// Credentials, in the order the credential list shows them.
credentials: Vec<Credential>,
next_credential_id: u64,
/// `None` for a memory-only store, as used by tests.
database: Option<SessionDatabase>,
/// Where passwords go. Defaults to a store that keeps nothing, so unit
@@ -56,8 +66,9 @@ const MAX_RECENT: usize = 10;
pub enum SessionStoreEvent {
/// A change was applied in memory but could not be written to disk.
PersistFailed(SharedString),
/// A live terminal must reconnect because its SSH endpoint or
/// authentication configuration changed.
/// A live terminal must reconnect because the way the session logs in
/// changed: its endpoint, its own authentication, or the credential it
/// uses.
ConnectionSettingsChanged(SessionId),
/// A forwarding rule now listens or connects somewhere else, so a
/// running forward has to restart to follow it.
@@ -79,6 +90,8 @@ impl SessionStore {
bookmarks: HashMap::new(),
forwards: Vec::new(),
next_forward_id: 1,
credentials: Vec::new(),
next_credential_id: 1,
database: None,
secrets: Arc::new(NoSecretStore),
}
@@ -96,21 +109,35 @@ impl SessionStore {
pub fn load(database: SessionDatabase) -> rusqlite::Result<Self> {
let StoredData {
groups,
sessions,
mut sessions,
mut recent,
bookmarks: stored_bookmarks,
forwards,
credentials,
} = database.load()?;
recent.truncate(MAX_RECENT);
let mut bookmarks: HashMap<_, Vec<String>> = HashMap::new();
for (session, side, path) in stored_bookmarks {
bookmarks.entry((session, side)).or_default().push(path);
}
// A write that failed after the fact can leave a host's copy of its
// credential's user name behind, or a link to a credential this
// build could not read. Memory is what counts, so it is put right
// here and reaches the file with the host's next save.
for session in &mut sessions {
let draft = normalized(&credentials, session.draft());
session.user = draft.user;
session.auth = draft.auth;
session.key_path = draft.key_path;
session.credential = draft.credential;
}
Ok(Self {
next_group_id: groups.iter().map(|group| group.id.0).max().unwrap_or(0) + 1,
next_session_id: sessions.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
next_forward_id: forwards.iter().map(|rule| rule.id.0).max().unwrap_or(0) + 1,
next_credential_id: credentials.iter().map(|c| c.id.0).max().unwrap_or(0) + 1,
forwards,
credentials,
groups,
sessions,
active: None,
@@ -290,6 +317,7 @@ impl SessionStore {
}
pub fn insert_unnotified(&mut self, draft: SessionDraft) -> SessionId {
let draft = normalized(&self.credentials, draft);
let sort_order = self.last_session_order(draft.group, None);
let id = SessionId(self.next_session_id);
self.next_session_id += 1;
@@ -307,28 +335,20 @@ impl SessionStore {
/// Replace the editable fields of a session; connection state is kept.
pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context<Self>) -> bool {
let previous_endpoint = self.session(id).map(Session::password_secret);
let connection_changed = self.session(id).is_some_and(|session| {
session.host != draft.host
|| session.port != draft.port
|| session.user != draft.user
|| session.auth != draft.auth
|| session.key_path != draft.key_path
});
let previous = self.login(id);
let updated = self.update_unnotified(id, draft);
if updated {
if let Some(session) = self.session(id) {
self.persist("保存主机", cx, |db| db.update_session(session));
}
// The session moved to another endpoint, so its old keychain
// entry is an orphan unless another session still logs in there.
if let Some(previous) = previous_endpoint
&& !password_in_use(&self.sessions, &previous)
{
self.save_secret(previous, None, cx);
}
if connection_changed {
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id));
if let Some(previous) = previous {
// The session no longer reads the password it did, say
// because it moved to another endpoint, so that keychain
// entry is an orphan unless another session still uses it.
self.forget_endpoint_password(previous.password.clone(), cx);
if self.login(id).as_ref() != Some(&previous) {
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id));
}
}
cx.notify();
}
@@ -336,6 +356,7 @@ impl SessionStore {
}
pub fn update_unnotified(&mut self, id: SessionId, draft: SessionDraft) -> bool {
let draft = normalized(&self.credentials, draft);
let new_order = self.last_session_order(draft.group, Some(id));
let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else {
return false;
@@ -360,14 +381,12 @@ impl SessionStore {
}
pub fn remove(&mut self, id: SessionId, cx: &mut Context<Self>) -> bool {
let endpoint = self.session(id).map(Session::password_secret);
let login = self.login(id);
let removed = self.remove_unnotified(id);
if removed {
self.persist("删除主机", cx, |db| db.remove_session(id));
if let Some(endpoint) = endpoint
&& !password_in_use(&self.sessions, &endpoint)
{
self.save_secret(endpoint, None, cx);
if let Some(login) = login {
self.forget_endpoint_password(login.password, cx);
}
cx.notify();
}
@@ -692,6 +711,174 @@ impl SessionStore {
self.forwards.len() != before
}
/// Every credential, in list order.
pub fn credentials(&self) -> &[Credential] {
&self.credentials
}
pub fn credential(&self, id: CredentialId) -> Option<&Credential> {
self.credentials
.iter()
.find(|credential| credential.id == id)
}
/// The sessions that log in with `credential`.
pub fn sessions_using(&self, credential: CredentialId) -> impl Iterator<Item = &Session> {
self.sessions
.iter()
.filter(move |session| session.credential == Some(credential))
}
/// How a session logs in, with its credential looked up. What the
/// terminal, SFTP, forward and CLI workers are given.
pub fn login(&self, id: SessionId) -> Option<SessionLogin> {
self.session(id).map(|session| self.login_of(session))
}
pub fn login_of(&self, session: &Session) -> SessionLogin {
SessionLogin::of(
session,
session.credential.and_then(|id| self.credential(id)),
)
}
/// Add a credential at the end of the list.
pub fn insert_credential(
&mut self,
draft: CredentialDraft,
cx: &mut Context<Self>,
) -> CredentialId {
let id = self.insert_credential_unnotified(draft);
if let Some(credential) = self.credential(id) {
self.persist("新建凭据", cx, |db| db.insert_credential(credential));
}
cx.notify();
id
}
pub fn insert_credential_unnotified(&mut self, draft: CredentialDraft) -> CredentialId {
let sort_order = self
.credentials
.iter()
.map(|credential| credential.sort_order)
.max()
.unwrap_or(-1)
+ 1;
let id = CredentialId(self.next_credential_id);
self.next_credential_id += 1;
let mut credential = Credential::new(id, draft);
credential.keychain_id = PublicId::generate_unused(|candidate| {
self.credentials
.iter()
.any(|credential| &credential.keychain_id == candidate)
});
credential.sort_order = sort_order;
self.credentials.push(credential);
id
}
/// Replace the editable fields of a credential. Every session using it
/// takes the new user name, and those whose login changed reconnect.
pub fn update_credential(
&mut self,
id: CredentialId,
draft: CredentialDraft,
cx: &mut Context<Self>,
) -> bool {
let Some(previous) = self.credential(id).cloned() else {
return false;
};
let logins: Vec<(SessionId, SessionLogin)> = self
.sessions_using(id)
.map(|session| (session.id, self.login_of(session)))
.collect();
if !self.update_credential_unnotified(id, draft) {
return false;
}
if let Some(credential) = self.credential(id) {
self.persist("保存凭据", cx, |db| db.update_credential(credential));
}
if previous.kind == CredentialKind::Password
&& self
.credential(id)
.is_some_and(|credential| credential.kind != CredentialKind::Password)
{
self.save_secret(previous.password_secret(), None, cx);
}
for (session, before) in logins {
self.forget_endpoint_password(before.password.clone(), cx);
if self.login(session).as_ref() != Some(&before) {
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(session));
}
}
cx.notify();
true
}
pub fn update_credential_unnotified(
&mut self,
id: CredentialId,
draft: CredentialDraft,
) -> bool {
let Some(credential) = self
.credentials
.iter_mut()
.find(|credential| credential.id == id)
else {
return false;
};
let keychain_id = credential.keychain_id.clone();
let sort_order = credential.sort_order;
*credential = Credential::new(id, draft);
credential.keychain_id = keychain_id;
credential.sort_order = sort_order;
let user = credential.user.clone();
for session in &mut self.sessions {
if session.credential == Some(id) {
session.user = user.clone();
}
}
true
}
/// Delete a credential. The sessions using it log in on their own again,
/// automatically and as the same user, and are returned. They are not
/// reconnected: deleting a credential does not drop a working connection.
pub fn remove_credential(
&mut self,
id: CredentialId,
cx: &mut Context<Self>,
) -> Vec<SessionId> {
let Some(credential) = self.credential(id).cloned() else {
return Vec::new();
};
let released = self.remove_credential_unnotified(id);
self.persist("删除凭据", cx, |db| db.remove_credential(id));
if credential.kind == CredentialKind::Password {
self.save_secret(credential.password_secret(), None, cx);
}
cx.notify();
released
}
pub fn remove_credential_unnotified(&mut self, id: CredentialId) -> Vec<SessionId> {
let before = self.credentials.len();
self.credentials.retain(|credential| credential.id != id);
if self.credentials.len() == before {
return Vec::new();
}
let mut released = Vec::new();
for session in &mut self.sessions {
if session.credential == Some(id) {
session.credential = None;
session.auth = AuthKind::Auto;
session.key_path = None;
released.push(session.id);
}
}
released
}
/// Persist a group's expanded or collapsed state after a tree interaction.
pub fn set_group_expanded(
&mut self,
@@ -845,9 +1032,7 @@ impl SessionStore {
// One delete mirrors the whole subtree: both foreign keys cascade.
self.persist("删除分组", cx, |db| db.remove_group(id));
for endpoint in endpoints {
if !password_in_use(&self.sessions, &endpoint) {
self.save_secret(endpoint, None, cx);
}
self.forget_endpoint_password(endpoint, cx);
}
cx.notify();
removed
@@ -951,6 +1136,8 @@ impl SessionStore {
(SecretRef::Password { .. }, false) => "密码未能从系统钥匙串删除",
(SecretRef::Passphrase { .. }, true) => "私钥口令未能写入系统钥匙串",
(SecretRef::Passphrase { .. }, false) => "私钥口令未能从系统钥匙串删除",
(SecretRef::Credential { .. }, true) => "凭据的密码未能写入系统钥匙串",
(SecretRef::Credential { .. }, false) => "凭据的密码未能从系统钥匙串删除",
};
cx.spawn(async move |this, cx| {
let result = cx
@@ -974,13 +1161,13 @@ impl SessionStore {
.detach();
}
/// The distinct keychain endpoints these sessions log into.
/// The distinct password entries these sessions log in with.
fn endpoints_of(&self, ids: &[SessionId]) -> Vec<SecretRef> {
let mut endpoints: Vec<SecretRef> = Vec::new();
for secret in ids
.iter()
.filter_map(|id| self.session(*id))
.map(Session::password_secret)
.filter_map(|id| self.login(*id))
.map(|login| login.password)
{
if !endpoints.contains(&secret) {
endpoints.push(secret);
@@ -989,6 +1176,24 @@ impl SessionStore {
endpoints
}
/// Delete an endpoint's saved password once no session logs in with it.
/// A credential's password is left alone: it goes with the credential,
/// not with the sessions using it.
fn forget_endpoint_password(&mut self, secret: SecretRef, cx: &mut Context<Self>) {
if matches!(secret, SecretRef::Password { .. }) && !self.password_in_use(&secret) {
self.save_secret(secret, None, cx);
}
}
/// Whether any session still logs in with this password entry. Entries
/// are shared, so one may only be cleaned up once the last session using
/// it is gone.
fn password_in_use(&self, secret: &SecretRef) -> bool {
self.sessions
.iter()
.any(|session| self.login_of(session).password == *secret)
}
/// Write a change through to the database, when there is one. A failed
/// write becomes an event; the in-memory change stands.
fn persist(
@@ -1059,13 +1264,22 @@ fn reordered<Id: Copy + Ord>(
Some(ids)
}
/// Whether any session still logs into the endpoint this secret belongs to.
/// Keychain entries are shared by endpoint, so one may only be cleaned up once
/// the last session using it is gone.
fn password_in_use(sessions: &[Session], secret: &SecretRef) -> bool {
sessions
.iter()
.any(|session| session.password_secret() == *secret)
/// `draft` as the store keeps it. A session using a credential logs in as
/// the credential's user and keeps nothing of its own login; one naming a
/// credential that is not there logs in on its own.
fn normalized(credentials: &[Credential], mut draft: SessionDraft) -> SessionDraft {
let credential = draft
.credential
.and_then(|id| credentials.iter().find(|credential| credential.id == id));
match credential {
Some(credential) => {
draft.user = credential.user.clone();
draft.auth = AuthKind::Auto;
draft.key_path = None;
}
None => draft.credential = None,
}
draft
}
#[cfg(test)]
@@ -1194,6 +1408,162 @@ mod tests {
);
}
fn password_credential(store: &mut SessionStore, user: &str) -> CredentialId {
store.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
user,
))
}
#[test]
fn a_host_using_a_credential_logs_in_as_its_user() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let mut form = draft("web", None).with_credential(credential);
// Whatever the form still held of its own login is dropped.
form.auth = AuthKind::Key;
form.key_path = Some("/tmp/id".into());
let web = store.insert_unnotified(form);
let session = store.session(web).unwrap();
assert_eq!(session.credential, Some(credential));
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(session.key_path, None);
let login = store.login(web).unwrap();
assert_eq!(login.user, "deploy");
assert_eq!(
login.password,
store.credential(credential).unwrap().password_secret()
);
assert_eq!(store.sessions_using(credential).count(), 1);
}
#[test]
fn a_draft_naming_a_missing_credential_is_saved_as_manual() {
let mut store = SessionStore::empty();
let web = store.insert_unnotified(draft("web", None).with_credential(CredentialId(9)));
let session = store.session(web).unwrap();
assert_eq!(session.credential, None);
assert_eq!(session.user.as_ref(), "root");
}
#[test]
fn renaming_a_credentials_user_renames_every_host_using_it() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let db = store.insert_unnotified(draft("db", None));
let mut edited = store.credential(credential).unwrap().draft();
edited.user = "admin".into();
assert!(store.update_credential_unnotified(credential, edited));
assert_eq!(store.session(web).unwrap().user.as_ref(), "admin");
assert_eq!(store.session(web).unwrap().address(), "admin@10.0.0.1:22");
assert_eq!(store.session(db).unwrap().user.as_ref(), "root");
assert!(!store.update_credential_unnotified(
CredentialId(9),
CredentialDraft::new("x", CredentialKind::Agent, "x")
));
}
#[test]
fn changing_only_a_credentials_name_changes_no_login() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let before = store.login(web);
let keychain_id = store.credential(credential).unwrap().keychain_id.clone();
let mut edited = store.credential(credential).unwrap().draft();
edited.name = "生产运维".into();
assert!(store.update_credential_unnotified(credential, edited));
assert_eq!(store.login(web), before);
// The keychain entry stays the credential's.
assert_eq!(
store.credential(credential).unwrap().keychain_id,
keychain_id
);
}
#[test]
fn deleting_a_credential_leaves_its_hosts_manual_auto_with_the_user() {
let mut store = SessionStore::empty();
let credential = store.insert_credential_unnotified(
CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"),
);
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let db = store.insert_unnotified(draft("db", None));
assert_eq!(store.remove_credential_unnotified(credential), [web]);
assert!(store.credentials().is_empty());
let session = store.session(web).unwrap();
assert_eq!(session.credential, None);
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(session.key_path, None);
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(store.session(db).unwrap().user.as_ref(), "root");
assert!(store.remove_credential_unnotified(credential).is_empty());
}
#[test]
fn a_copy_of_a_host_uses_the_same_credential() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let copy = store.duplicate_unnotified(web).unwrap();
assert_eq!(store.session(copy).unwrap().credential, Some(credential));
assert_eq!(store.sessions_using(credential).count(), 2);
}
#[test]
fn a_host_with_a_password_credential_does_not_keep_the_endpoint_password_in_use() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "root");
let web = store.insert_unnotified(draft("web", None));
let endpoint = store.session(web).unwrap().password_secret();
assert!(store.password_in_use(&endpoint));
store.update_unnotified(web, draft("web", None).with_credential(credential));
// Same user, host and port, but the password comes from elsewhere.
assert_eq!(store.session(web).unwrap().password_secret(), endpoint);
assert!(!store.password_in_use(&endpoint));
let secret = store.credential(credential).unwrap().password_secret();
assert!(store.password_in_use(&secret));
}
#[test]
fn load_resumes_the_credential_id_sequence_and_repairs_usernames() {
let database = SessionDatabase::in_memory().unwrap();
let credential = Credential::new(
CredentialId(4),
CredentialDraft::new("运维", CredentialKind::Agent, "deploy"),
);
database.insert_credential(&credential).unwrap();
let mut stale = Session::new(SessionId(1), draft("web", None));
stale.credential = Some(credential.id);
stale.auth = AuthKind::Password;
database.insert_session(&stale).unwrap();
let mut store = SessionStore::load(database).unwrap();
let session = store.session(SessionId(1)).unwrap();
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(
store.credential(CredentialId(4)).unwrap().keychain_id,
credential.keychain_id
);
assert_eq!(
store.insert_credential_unnotified(CredentialDraft::new(
"x",
CredentialKind::Agent,
"x"
)),
CredentialId(5)
);
}
#[test]
fn the_detected_system_survives_an_edit() {
let mut store = SessionStore::empty();
@@ -1226,10 +1596,10 @@ mod tests {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(draft("web", None));
let endpoint = store.session(id).unwrap().password_secret();
assert!(password_in_use(store.sessions(), &endpoint));
assert!(store.password_in_use(&endpoint));
store.remove_unnotified(id);
assert!(!password_in_use(store.sessions(), &endpoint));
assert!(!store.password_in_use(&endpoint));
}
#[test]
@@ -1241,7 +1611,7 @@ mod tests {
assert_eq!(endpoint, store.session(second).unwrap().password_secret());
store.remove_unnotified(first);
assert!(password_in_use(store.sessions(), &endpoint));
assert!(store.password_in_use(&endpoint));
}
#[test]
@@ -1251,7 +1621,7 @@ mod tests {
let endpoint = store.session(id).unwrap().password_secret();
let moved = SessionDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Auto, None);
store.update_unnotified(id, moved);
assert!(!password_in_use(store.sessions(), &endpoint));
assert!(!store.password_in_use(&endpoint));
}
#[test]
+2 -2
View File
@@ -3,7 +3,7 @@ use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
secrets::InMemorySecretStore,
session::{AuthKind, Session, SessionDraft, SessionId},
session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin},
ssh::SshConnector,
};
use russh::{
@@ -260,7 +260,7 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
None,
),
);
let transport = provider.create(&session);
let transport = provider.create(&SessionLogin::of(&session, None));
let (commands, rx) = async_channel::unbounded();
let (tx, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
+2 -2
View File
@@ -4,7 +4,7 @@ use super::{
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::Session,
session::SessionLogin,
};
use anyhow::Result;
use async_channel::{Receiver, Sender};
@@ -96,6 +96,6 @@ pub trait SftpTransport: Send + 'static {
) -> Result<()>;
}
pub trait SftpTransportProvider: Send + Sync + 'static {
fn create(&self, session: &Session) -> Box<dyn SftpTransport>;
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport>;
}
pub type SharedSftpTransportProvider = Arc<dyn SftpTransportProvider>;
+3 -3
View File
@@ -12,7 +12,7 @@ use super::{
upload::UploadBatch,
};
use crate::{
session::Session,
session::SessionLogin,
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
};
use anyhow::{Result, anyhow};
@@ -43,10 +43,10 @@ impl SshSftpTransportProvider {
}
}
impl SftpTransportProvider for SshSftpTransportProvider {
fn create(&self, session: &Session) -> Box<dyn SftpTransport> {
fn create(&self, login: &SessionLogin) -> Box<dyn SftpTransport> {
Box::new(SshSftpTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(session),
config: SshConnectionConfig::from(login),
journal: Journal::new(self.resume_dir.clone()),
download_journal: DownloadJournal::new(self.download_resume_dir.clone()),
})
+187 -99
View File
@@ -4,7 +4,7 @@ use crate::{
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
},
secrets::{SecretRef, SharedSecretStore},
session::{AuthKind, Session},
session::{LoginMethod, SessionLogin},
};
use anyhow::{Context as _, Result, anyhow, bail};
use russh::keys::{
@@ -31,39 +31,46 @@ use zeroize::Zeroizing;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
const AUTH_RETRIES: usize = 3;
/// How long to wait for the agent to answer the door. A named pipe that
/// stays busy would otherwise be retried forever.
const AGENT_TIMEOUT: Duration = Duration::from_secs(5);
const CONNECT_FAILED: &str = "无法建立 SSH 连接,请检查地址、端口和主机密钥";
static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
/// What one connection logs in with: a host's login as the store resolved
/// it on the UI thread, credential and all.
#[derive(Clone)]
pub struct SshConnectionConfig {
host: String,
port: u16,
user: String,
auth: AuthKind,
key_path: Option<PathBuf>,
login: SessionLogin,
}
impl From<&Session> for SshConnectionConfig {
fn from(session: &Session) -> Self {
impl From<&SessionLogin> for SshConnectionConfig {
fn from(login: &SessionLogin) -> Self {
Self {
host: session.host.to_string(),
port: session.port,
user: session.user.to_string(),
auth: session.auth,
key_path: session
.key_path
.as_ref()
.map(|path| PathBuf::from(path.as_ref())),
login: login.clone(),
}
}
}
/// Where the SSH agent listens.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum AgentLocation {
/// The user's own agent: the socket `SSH_AUTH_SOCK` names on macOS and
/// Linux, the OpenSSH agent service's pipe on Windows.
#[default]
System,
/// An agent at this socket or pipe. Tests use it rather than change the
/// process's environment under other tests.
At(PathBuf),
}
/// Cloneable trust and credential service. Every clone shares the trust-file lock.
#[derive(Clone)]
pub struct SshConnector {
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
secrets: SharedSecretStore,
agent: AgentLocation,
}
impl SshConnector {
pub fn new(path: impl Into<PathBuf>, secrets: SharedSecretStore) -> Self {
@@ -71,8 +78,15 @@ impl SshConnector {
known_hosts_path: path.into(),
known_hosts_lock: Arc::new(Mutex::new(())),
secrets,
agent: AgentLocation::System,
}
}
/// Use the agent at `agent` instead of the user's own.
pub fn with_agent(mut self, agent: AgentLocation) -> Self {
self.agent = agent;
self
}
/// Must run on a worker: keychain and private-key reads are blocking.
pub async fn connect(
&self,
@@ -122,9 +136,10 @@ impl SshConnector {
forwarded: Option<mpsc::UnboundedSender<ForwardedTcpip>>,
) -> Result<(client::Handle<SshClientHandler>, String)> {
let fingerprint = Arc::new(Mutex::new(String::new()));
let login = &config.login;
let handler = SshClientHandler {
host: config.host.clone(),
port: config.port,
host: login.host.clone(),
port: login.port,
known_hosts_path: self.known_hosts_path.clone(),
known_hosts_lock: self.known_hosts_lock.clone(),
broker: broker.clone(),
@@ -138,7 +153,7 @@ impl SshConnector {
nodelay: true,
..Default::default()
}),
(config.host.as_str(), config.port),
(login.host.as_str(), login.port),
handler,
);
let mut shutdown = broker.shutdown_receiver();
@@ -147,7 +162,7 @@ impl SshConnector {
_ = shutdown.changed() => bail!("连接已取消"),
};
tokio::select! {
result = authenticate(&mut handle, config, secrets, &broker) => result?,
result = authenticate(&mut handle, login, secrets, &self.agent, &broker) => result?,
_ = shutdown.changed() => bail!("连接已取消"),
}
let fingerprint = lock(&fingerprint).clone();
@@ -155,24 +170,8 @@ impl SshConnector {
}
}
impl SshConnectionConfig {
pub fn new(
host: impl Into<String>,
port: u16,
user: impl Into<String>,
auth: AuthKind,
key_path: Option<PathBuf>,
) -> Self {
Self {
host: host.into(),
port,
user: user.into(),
auth,
key_path,
}
}
pub fn endpoint(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
self.login.endpoint()
}
}
@@ -532,12 +531,15 @@ fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, Pub
async fn authenticate(
handle: &mut SshHandle,
config: &SshConnectionConfig,
login: &SessionLogin,
secrets: &SharedSecretStore,
agent: &AgentLocation,
broker: &SshPrompts,
) -> Result<()> {
let user = login.user.as_str();
let method = login.method;
let first = handle
.authenticate_none(&config.user)
.authenticate_none(user)
.await
.map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?;
if first.success() {
@@ -546,21 +548,33 @@ async fn authenticate(
let mut methods = remaining_methods(first);
let mut partial = false;
if config.auth == AuthKind::Auto
&& methods.contains(&MethodKind::PublicKey)
&& let Some(result) = try_agent(handle, &config.user).await?
{
if result.success() {
return Ok(());
// Automatic logins try the agent quietly and move on; a login that is
// only the agent says what went wrong with it.
if matches!(method, LoginMethod::Auto | LoginMethod::Agent) {
if methods.contains(&MethodKind::PublicKey) {
match try_agent(handle, user, agent).await? {
Ok(result) => {
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
}
Err(problem) if method == LoginMethod::Agent => bail!(problem.to_string()),
Err(_) => {}
}
if method == LoginMethod::Agent && !partial {
bail!("服务器未接受 SSH Agent 中的密钥");
}
} else if method == LoginMethod::Agent {
bail!("服务器不接受公钥登录,无法使用 SSH Agent");
}
partial = is_partial(&result);
methods = remaining_methods(result);
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Key)
if matches!(method, LoginMethod::Auto | LoginMethod::Key)
&& methods.contains(&MethodKind::PublicKey)
{
let paths = key_paths(config)?;
let paths = key_paths(login)?;
for path in paths {
let Some(key) = load_private_key(&path, secrets, broker).await? else {
continue;
@@ -571,10 +585,7 @@ async fn authenticate(
.map_err(|_| anyhow!("无法协商 RSA 签名算法"))?
.flatten();
let result = handle
.authenticate_publickey(
&config.user,
PrivateKeyWithHashAlg::new(Arc::new(key), hash),
)
.authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), hash))
.await
.map_err(|_| anyhow!("私钥认证失败"))?;
if result.success() {
@@ -586,18 +597,18 @@ async fn authenticate(
break;
}
}
if config.auth == AuthKind::Key && !partial {
if method == LoginMethod::Key && !partial {
bail!("服务器未接受指定的私钥");
}
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial {
if matches!(method, LoginMethod::Auto | LoginMethod::Password) || partial {
if methods.contains(&MethodKind::Password) {
// Try what the session has saved before bothering anyone.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &password_secret(config)) {
if let Some(saved) = saved_secret(secrets, &login.password) {
let result = handle
.authenticate_password(&config.user, saved.to_string())
.authenticate_password(user, saved.to_string())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
@@ -628,7 +639,7 @@ async fn authenticate(
)
.await?;
let result = handle
.authenticate_password(&config.user, answer.into_inner())
.authenticate_password(user, answer.into_inner())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
@@ -644,7 +655,7 @@ async fn authenticate(
}
if methods.contains(&MethodKind::KeyboardInteractive) {
for _ in 0..AUTH_RETRIES {
if keyboard_interactive(handle, &config.user, broker).await? {
if keyboard_interactive(handle, user, broker).await? {
return Ok(());
}
}
@@ -653,46 +664,128 @@ async fn authenticate(
bail!("认证失败:服务器未接受可用的认证方式")
}
async fn try_agent(handle: &mut SshHandle, user: &str) -> Result<Option<AuthResult>> {
#[cfg(unix)]
{
use russh::keys::agent::client::AgentClient;
type Agent = russh::keys::agent::client::AgentClient<
Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>,
>;
let Ok(mut agent) = AgentClient::connect_env().await else {
return Ok(None);
};
let Ok(identities) = agent.request_identities().await else {
return Ok(None);
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))?
.flatten();
let mut last = None;
for identity in identities {
let key = identity.public_key().into_owned();
match handle
.authenticate_publickey_with(user, key, hash, &mut agent)
.await
{
Ok(result) if result.success() => return Ok(Some(result)),
Ok(result) => last = Some(result),
Err(_) => continue,
}
/// Why the agent's keys could not be offered at all. `Display` is the text a
/// person reads. The cause is folded into the words rather than chained, so
/// a missing agent is never mistaken for a network failure worth retrying.
#[derive(Debug)]
enum AgentProblem {
Unreachable(String),
Empty,
/// Every key failed to sign: a locked agent, or one that asked to
/// confirm and was told no.
Unsigned,
}
impl std::fmt::Display for AgentProblem {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentProblem::Unreachable(reason) => write!(formatter, "无法连接 SSH Agent:{reason}"),
AgentProblem::Empty => formatter.write_str("SSH Agent 中没有密钥,请先用 ssh-add 添加"),
AgentProblem::Unsigned => formatter.write_str("SSH Agent 拒绝了签名请求"),
}
Ok(last)
}
#[cfg(not(unix))]
{
let _ = (handle, user);
Ok(None)
}
}
fn key_paths(config: &SshConnectionConfig) -> Result<Vec<PathBuf>> {
if config.auth == AuthKind::Key {
return config
/// Offer the agent's keys one after another. The result is the server's
/// answer to the last key offered: a success, or the refusal that says what
/// the server will take next.
async fn try_agent(
handle: &mut SshHandle,
user: &str,
location: &AgentLocation,
) -> Result<Result<AuthResult, AgentProblem>> {
let mut agent = match connect_agent(location).await {
Ok(agent) => agent,
Err(problem) => return Ok(Err(problem)),
};
let identities = match agent.request_identities().await {
Ok(identities) if identities.is_empty() => return Ok(Err(AgentProblem::Empty)),
Ok(identities) => identities,
Err(error) => return Ok(Err(AgentProblem::Unreachable(error.to_string()))),
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))?
.flatten();
let mut last = None;
for identity in identities {
let key = identity.public_key().into_owned();
match handle
.authenticate_publickey_with(user, key, hash, &mut agent)
.await
{
Ok(result) if result.success() => return Ok(Ok(result)),
Ok(result) => last = Some(result),
Err(_) => continue,
}
}
Ok(last.ok_or(AgentProblem::Unsigned))
}
#[cfg(unix)]
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
use russh::keys::agent::client::AgentClient;
let path = match location {
AgentLocation::At(path) => path.clone(),
AgentLocation::System => match std::env::var_os("SSH_AUTH_SOCK") {
Some(path) if !path.is_empty() => PathBuf::from(path),
_ => {
return Err(AgentProblem::Unreachable(
"没有设置 SSH_AUTH_SOCK".to_string(),
));
}
},
};
match tokio::time::timeout(AGENT_TIMEOUT, AgentClient::connect_uds(&path)).await {
Ok(Ok(agent)) => Ok(agent.dynamic()),
Ok(Err(russh::keys::Error::IO(error))) if error.kind() == std::io::ErrorKind::NotFound => {
Err(AgentProblem::Unreachable(format!(
"{} 不存在",
path.display()
)))
}
Ok(Err(error)) => Err(AgentProblem::Unreachable(error.to_string())),
Err(_) => Err(AgentProblem::Unreachable("没有响应".to_string())),
}
}
#[cfg(windows)]
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
use russh::keys::agent::client::AgentClient;
/// Where the OpenSSH Authentication Agent service listens.
const SYSTEM_PIPE: &str = r"\\.\pipe\openssh-ssh-agent";
let pipe = match location {
AgentLocation::At(path) => path.as_os_str().to_owned(),
AgentLocation::System => SYSTEM_PIPE.into(),
};
match tokio::time::timeout(AGENT_TIMEOUT, AgentClient::connect_named_pipe(&pipe)).await {
Ok(Ok(agent)) => Ok(agent.dynamic()),
Ok(Err(russh::keys::Error::IO(error))) if error.kind() == std::io::ErrorKind::NotFound => {
Err(AgentProblem::Unreachable(
"OpenSSH Authentication Agent 服务没有运行".to_string(),
))
}
Ok(Err(error)) => Err(AgentProblem::Unreachable(error.to_string())),
Err(_) => Err(AgentProblem::Unreachable("没有响应".to_string())),
}
}
#[cfg(not(any(unix, windows)))]
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
let _ = location;
Err(AgentProblem::Unreachable("这个平台不支持".to_string()))
}
fn key_paths(login: &SessionLogin) -> Result<Vec<PathBuf>> {
if login.method == LoginMethod::Key {
return login
.key_path
.clone()
.map(|path| vec![path])
@@ -752,11 +845,6 @@ async fn load_private_key(
bail!("私钥口令错误次数过多")
}
/// Where this connection's password lives in the system keychain.
fn password_secret(config: &SshConnectionConfig) -> SecretRef {
SecretRef::password(&config.user, &config.host, config.port)
}
/// Read a saved secret. A keychain that errors, is locked, or holds an empty
/// value counts as nothing saved: the connection then falls back to asking,
/// which is always better than refusing to connect.
+2 -2
View File
@@ -5,8 +5,8 @@ mod probe;
mod tester;
mod transport;
pub use connection::{
ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle, SshPrompts,
is_network_error,
AgentLocation, ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle,
SshPrompts, is_network_error,
};
pub use exec::{ExecError, ExecErrorKind, ExecExit, ExecStream, run_command};
pub use tester::{SshConnectionTester, describe_login_error};
+18 -30
View File
@@ -65,13 +65,7 @@ impl SshConnectionTester {
return Err(format!("无法解析主机 {}", request.host()));
}
let config = SshConnectionConfig::new(
request.host(),
request.port(),
request.user(),
request.auth(),
request.key_path().map(Into::into),
);
let config = SshConnectionConfig::from(request.login());
let secrets: SharedSecretStore =
Arc::new(FormSecrets::new(&request, self.connector.secrets().clone()));
let host_trust = Arc::new(Mutex::new(HostTrust::default()));
@@ -183,43 +177,37 @@ fn describe_connect_error(error: &std::io::Error) -> String {
}
}
/// The secrets a test logs in with. The form is authoritative for the login
/// password and for the passphrase of the key it names, whether or not they
/// were saved; anything else (a default key's passphrase) is read from the
/// keychain. Nothing is ever written.
/// The secrets a test logs in with. For a login typed into the form, the
/// form is authoritative for the login password and for the passphrase of
/// the key it names, whether or not they were saved; anything else (a
/// default key's passphrase) is read from the keychain. A login through a
/// credential reads everything from the keychain. Nothing is ever written.
struct FormSecrets {
password: (SecretRef, Option<Zeroizing<String>>),
passphrase: Option<(SecretRef, Option<Zeroizing<String>>)>,
typed: Vec<(SecretRef, Option<Zeroizing<String>>)>,
fallback: SharedSecretStore,
}
impl FormSecrets {
fn new(request: &LoginTest, fallback: SharedSecretStore) -> Self {
let owned = |value: Option<&str>| value.map(|value| Zeroizing::new(value.to_string()));
Self {
password: (
SecretRef::password(request.user(), request.host(), request.port()),
owned(request.password()),
),
passphrase: request
.key_path()
.map(|path| (SecretRef::passphrase(path), owned(request.passphrase()))),
fallback,
let mut typed = Vec::new();
if request.is_typed() {
let login = request.login();
typed.push((login.password.clone(), owned(request.password())));
if let Some(path) = &login.key_path {
typed.push((SecretRef::passphrase(path), owned(request.passphrase())));
}
}
Self { typed, fallback }
}
}
impl SecretStore for FormSecrets {
fn get(&self, secret: &SecretRef) -> anyhow::Result<Option<Zeroizing<String>>> {
if *secret == self.password.0 {
return Ok(self.password.1.clone());
match self.typed.iter().find(|(key, _)| key == secret) {
Some((_, value)) => Ok(value.clone()),
None => self.fallback.get(secret),
}
if let Some((key, value)) = &self.passphrase
&& secret == key
{
return Ok(value.clone());
}
self.fallback.get(secret)
}
fn set(&self, _: &SecretRef, _: &str) -> anyhow::Result<()> {
+207 -10
View File
@@ -3,7 +3,7 @@ use super::{
probe::{HostOsProbe, ProbeOutcome},
};
use crate::{
session::Session,
session::SessionLogin,
terminal::{
Latency, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
@@ -42,9 +42,9 @@ impl SshTerminalTransportProvider {
}
}
impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory {
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory {
Arc::new(SshTerminalTransport {
config: SshConnectionConfig::from(session),
config: SshConnectionConfig::from(login),
connector: self.connector.clone(),
})
}
@@ -323,7 +323,7 @@ mod tests {
use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND};
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
session::AuthKind,
session::{AuthKind, Session},
};
use russh::keys::{PublicKey, known_hosts::learn_known_hosts_path};
use russh::server::{self, Server as _};
@@ -697,7 +697,7 @@ mod tests {
let mut report = ConnectionReport::default();
let provider =
SshTerminalTransportProvider::with_connector(SshConnector::new(known_hosts, secrets));
let factory = provider.factory_for(&session);
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
@@ -775,7 +775,7 @@ mod tests {
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&session);
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let worker = thread::spawn(move || {
@@ -848,7 +848,7 @@ mod tests {
// A second connection must trust the saved key without prompting and
// an active close must return the worker within the shutdown bound.
let factory = provider.factory_for(&session);
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let (command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
@@ -944,7 +944,13 @@ mod tests {
}
fn login_request(port: u16) -> crate::connection::LoginTest {
crate::connection::LoginTest::new("127.0.0.1", port, "tester", AuthKind::Password)
crate::connection::LoginTest::typed(SessionLogin::manual(
"127.0.0.1",
port,
"tester",
AuthKind::Password,
None,
))
}
/// The CLI's `exec` against the test server, on a thread of its own so
@@ -959,7 +965,7 @@ mod tests {
Vec<u8>,
) {
let connector = SshConnector::new(known_hosts, keychain);
let config = SshConnectionConfig::from(session);
let config = SshConnectionConfig::from(&SessionLogin::of(session, None));
let (done_tx, done_rx) = mpsc::channel();
thread::spawn(move || {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
@@ -1042,6 +1048,197 @@ mod tests {
assert!(!known_hosts.exists());
}
/// A connection test through `connector`, trusting the host it meets.
fn test_through(
connector: SshConnector,
request: crate::connection::LoginTest,
) -> Result<(), String> {
use crate::connection::ConnectionTester as _;
crate::ssh::SshConnectionTester::new(connector).test(request, Box::new(|_| true))
}
fn credential(kind: crate::session::CredentialKind) -> crate::session::Credential {
crate::session::Credential::new(
crate::session::CredentialId(1),
crate::session::CredentialDraft::new("运维", kind, "tester"),
)
}
#[test]
fn a_password_credential_logs_in_with_its_own_keychain_entry() {
let Some(server) = start_server(TestAuth::Password) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let directory = tempfile::tempdir().unwrap();
let credential = credential(crate::session::CredentialKind::Password);
let keychain = Arc::new(InMemorySecretStore::default());
keychain
.set(&credential.password_secret(), TEST_PASSWORD)
.unwrap();
// The endpoint's own entry is wrong: it must not be the one read.
keychain
.set(
&SecretRef::password("tester", "127.0.0.1", server.port),
"wrong",
)
.unwrap();
let login = SessionLogin::with_credential("127.0.0.1", server.port, &credential);
let connector = SshConnector::new(directory.path().join("known_hosts"), keychain);
assert_eq!(
test_through(connector, crate::connection::LoginTest::saved(login)),
Ok(())
);
}
/// An SSH agent on a socket of its own holding `keys`, for as long as
/// the value lives.
#[cfg(unix)]
struct TestAgent {
path: std::path::PathBuf,
_directory: tempfile::TempDir,
_runtime: tokio::runtime::Runtime,
}
#[cfg(unix)]
fn start_agent(keys: &[russh::keys::PrivateKey]) -> TestAgent {
use russh::keys::agent::client::AgentClient;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("agent.sock");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let listener = runtime
.block_on(async { tokio::net::UnixListener::bind(&path) })
.unwrap();
runtime.spawn(async move {
let incoming = futures::stream::poll_fn(move |cx| {
listener
.poll_accept(cx)
.map(|accepted| Some(accepted.map(|(stream, _)| stream)))
});
let _ = russh::keys::agent::server::serve(incoming, ()).await;
});
runtime.block_on(async {
let mut client = AgentClient::connect_uds(&path).await.unwrap();
for key in keys {
client.add_identity(key, &[]).await.unwrap();
}
});
TestAgent {
path,
_directory: directory,
_runtime: runtime,
}
}
#[cfg(unix)]
fn random_key() -> russh::keys::PrivateKey {
let mut rng = russh::keys::key::safe_rng();
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap()
}
/// Log in to `server` as an agent credential would, with the agent at
/// `agent`.
#[cfg(unix)]
fn agent_login(port: u16, agent: std::path::PathBuf, known_hosts: &Path) -> Result<(), String> {
let login = SessionLogin::with_credential(
"127.0.0.1",
port,
&credential(crate::session::CredentialKind::Agent),
);
let connector = SshConnector::new(known_hosts, Arc::new(NoSecretStore))
.with_agent(crate::ssh::AgentLocation::At(agent));
test_through(connector, crate::connection::LoginTest::saved(login))
}
#[cfg(unix)]
#[test]
fn an_agent_credential_logs_in_with_the_agents_key() {
let key = random_key();
let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let directory = tempfile::tempdir().unwrap();
// Another key first: the agent's keys are offered until one fits.
let agent = start_agent(&[random_key(), key]);
assert_eq!(
agent_login(
server.port,
agent.path.clone(),
&directory.path().join("known_hosts")
),
Ok(())
);
}
#[cfg(unix)]
#[test]
fn automatic_login_tries_the_same_agent() {
let key = random_key();
let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let directory = tempfile::tempdir().unwrap();
let agent = start_agent(&[key]);
let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::Auto, None);
let connector = SshConnector::new(
directory.path().join("known_hosts"),
Arc::new(NoSecretStore),
)
.with_agent(crate::ssh::AgentLocation::At(agent.path.clone()));
assert_eq!(
test_through(connector, crate::connection::LoginTest::typed(login)),
Ok(())
);
}
#[cfg(unix)]
#[test]
fn an_agent_credential_says_what_stood_in_the_way() {
let key = random_key();
let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let missing = directory.path().join("no-agent.sock");
let error = agent_login(server.port, missing.clone(), &known_hosts).unwrap_err();
assert_eq!(
error,
format!("无法连接 SSH Agent:{} 不存在", missing.display())
);
let empty = start_agent(&[]);
assert_eq!(
agent_login(server.port, empty.path.clone(), &known_hosts),
Err("SSH Agent 中没有密钥,请先用 ssh-add 添加".to_string())
);
let stranger = start_agent(&[random_key()]);
assert_eq!(
agent_login(server.port, stranger.path.clone(), &known_hosts),
Err("服务器未接受 SSH Agent 中的密钥".to_string())
);
let Some(password_server) = start_server(TestAuth::Password) else {
panic!("loopback became unavailable during the agent test")
};
let agent = start_agent(&[key]);
assert_eq!(
agent_login(password_server.port, agent.path.clone(), &known_hosts),
Err("服务器不接受公钥登录,无法使用 SSH Agent".to_string())
);
}
#[test]
fn exec_without_a_saved_password_says_what_is_missing() {
let Some(server) = start_server(TestAuth::Password) else {
@@ -1489,7 +1686,7 @@ mod tests {
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&session);
let factory = provider.factory_for(&SessionLogin::of(&session, None));
let (_command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
+12 -8
View File
@@ -54,16 +54,20 @@ impl TerminalPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let session = store
.read(cx)
.session(session_id)
.cloned()
.expect("terminal sessions must exist in the store");
let (session, login) = {
let store = store.read(cx);
let session = store
.session(session_id)
.cloned()
.expect("terminal sessions must exist in the store");
let login = store.login_of(&session);
(session, login)
};
let terminal = cx.new(|cx| {
let mut terminal = TerminalView::new(
("terminal", id.0),
format!("{} 的终端", session.name),
remote_provider.factory_for(&session),
remote_provider.factory_for(&login),
window,
cx,
);
@@ -140,10 +144,10 @@ impl TerminalPanel {
}
pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.store.read(cx).session(self.session_id).cloned() else {
let Some(login) = self.store.read(cx).login(self.session_id) else {
return;
};
let factory = self.remote_provider.factory_for(&session);
let factory = self.remote_provider.factory_for(&login);
self.terminal.update(cx, |terminal, cx| {
terminal.restart_with_factory(factory, cx)
});
+6 -6
View File
@@ -5,7 +5,7 @@ use async_channel::Sender;
use super::{Latency, TerminalSize};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::session::{HostOs, Session};
use crate::session::{HostOs, SessionLogin};
/// Commands accepted by every terminal byte-stream transport.
pub enum TerminalTransportCommand {
@@ -68,11 +68,11 @@ pub trait TerminalTransportFactory: Send + Sync + 'static {
fn create(&self) -> Box<dyn TerminalTransport>;
}
/// Creates a fresh remote factory from the latest saved session. Keeping this
/// boundary at the workspace makes reconnects pick up edits immediately and
/// lets UI tests inject a deterministic fake.
/// Creates a fresh remote factory from the latest saved login of a session.
/// Keeping this boundary at the workspace makes reconnects pick up edits
/// immediately and lets UI tests inject a deterministic fake.
pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory;
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory;
}
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
@@ -90,7 +90,7 @@ impl FixedRemoteTerminalTransportProvider {
}
impl RemoteTerminalTransportProvider for FixedRemoteTerminalTransportProvider {
fn factory_for(&self, _: &Session) -> SharedTerminalTransportFactory {
fn factory_for(&self, _: &SessionLogin) -> SharedTerminalTransportFactory {
self.factory.clone()
}
}
+101
View File
@@ -0,0 +1,101 @@
//! The workspace's share of credentials: the sidebar's switch and the
//! commands of the credential list.
use std::rc::Rc;
use gpui_kit::*;
use crate::app::{DeleteCredential, EditCredential, NewCredential, ShowCredentials};
use crate::credential::open_credential_dialog;
use crate::shared::confirm_delete;
use super::{sidebar::SidebarMode, workspace_view::Workspace};
impl Workspace {
pub(super) fn on_show_credentials(
&mut self,
_: &ShowCredentials,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_sidebar(SidebarMode::Credentials, window, cx);
}
pub(super) fn on_new_credential(
&mut self,
_: &NewCredential,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_credential_dialog(None, self.store.clone(), window, cx);
}
pub(super) fn on_edit_credential(
&mut self,
action: &EditCredential,
window: &mut Window,
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);
}
}
/// 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(
&mut self,
action: &DeleteCredential,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = action.0;
let store = self.store.read(cx);
let Some(name) = store
.credential(id)
.map(|credential| credential.name.clone())
else {
return;
};
let hosts = store.sessions_using(id).count();
let store = self.store.clone();
confirm_delete(
&name,
describe_credential_delete(hosts),
Rc::new(move |_, cx| {
store.update(cx, |store, cx| {
store.remove_credential(id, cx);
});
}),
window,
cx,
);
}
}
/// What the delete dialog says happens to the hosts using the credential.
/// `None` when no host uses it.
fn describe_credential_delete(hosts: usize) -> Option<SharedString> {
(hosts > 0).then(|| {
format!(
"有 {hosts} 台主机正在使用此凭据。删除后它们改为手动输入,认证类型为「自动」,用户名不变。"
)
.into()
})
}
#[cfg(test)]
mod tests {
use super::describe_credential_delete;
#[test]
fn deleting_a_credential_says_how_many_hosts_and_what_they_become() {
assert_eq!(describe_credential_delete(0), None);
assert_eq!(
describe_credential_delete(3).as_deref(),
Some(
"有 3 台主机正在使用此凭据。删除后它们改为手动输入,认证类型为「自动」,用户名不变。"
)
);
}
}
+6 -1
View File
@@ -28,7 +28,12 @@ impl Workspace {
}
/// Show one of the sidebar's lists, opening the sidebar if it is hidden.
fn show_sidebar(&mut self, mode: SidebarMode, window: &mut Window, cx: &mut Context<Self>) {
pub(super) fn show_sidebar(
&mut self,
mode: SidebarMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.dock_area.read(cx).is_dock_open(DockPlacement::Left) {
self.dock_area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Left, window, cx);
+1
View File
@@ -1,6 +1,7 @@
//! The window shell: title bar, dock area with its sidebar, status bar, and
//! the start page (recent sessions) the center shows while no tab is open.
mod credentials;
mod dock_skin;
mod forwards;
mod recent_sessions;
+21 -5
View File
@@ -7,6 +7,7 @@ use gpui_kit::component::{
use gpui_kit::*;
use crate::app::{CatalogIcon, OpenSettings};
use crate::credential::CredentialPanel;
use crate::forward::ForwardPanel;
use crate::session::SessionPanel;
@@ -16,28 +17,36 @@ pub enum SidebarMode {
#[default]
Sessions,
Forwards,
Credentials,
}
/// The left dock's one panel: the session tree or the port-forwarding list,
/// whichever the title bar's switch picked, over the 设置 button they share.
/// The left dock's one panel: the session tree, the port-forwarding list or
/// the credential list, whichever the title bar's switch picked, over the
/// 设置 button they share.
///
/// One panel showing either list rather than two panels swapped in the dock:
/// swapping would take the displaced list out of the dock (and the focus
/// with it), and two panels in one tab group always draw a tab bar. Both
/// lists stay alive while hidden, so each keeps its search, its selection
/// with it), and two panels in one tab group always draw a tab bar. Every
/// list stays alive while hidden, so each keeps its search, its selection
/// and its scroll position.
pub struct Sidebar {
mode: SidebarMode,
sessions: Entity<SessionPanel>,
forwards: Entity<ForwardPanel>,
credentials: Entity<CredentialPanel>,
}
impl Sidebar {
pub fn new(sessions: Entity<SessionPanel>, forwards: Entity<ForwardPanel>) -> Self {
pub fn new(
sessions: Entity<SessionPanel>,
forwards: Entity<ForwardPanel>,
credentials: Entity<CredentialPanel>,
) -> Self {
Self {
mode: SidebarMode::default(),
sessions,
forwards,
credentials,
}
}
@@ -64,6 +73,9 @@ impl Sidebar {
SidebarMode::Forwards => self
.forwards
.update(cx, |panel, cx| panel.focus_search(window, cx)),
SidebarMode::Credentials => self
.credentials
.update(cx, |panel, cx| panel.focus_search(window, cx)),
}
}
}
@@ -75,6 +87,7 @@ impl Focusable for Sidebar {
match self.mode {
SidebarMode::Sessions => self.sessions.read(cx).focus_handle(cx),
SidebarMode::Forwards => self.forwards.read(cx).focus_handle(cx),
SidebarMode::Credentials => self.credentials.read(cx).focus_handle(cx),
}
}
}
@@ -101,6 +114,7 @@ impl Panel for Sidebar {
.sessions
.update(cx, |panel, cx| panel.title(window, cx).into_any_element()),
SidebarMode::Forwards => self.forwards.read(cx).title().into_any_element(),
SidebarMode::Credentials => self.credentials.read(cx).title().into_any_element(),
}
}
@@ -114,6 +128,7 @@ impl Panel for Sidebar {
.sessions
.update(cx, |panel, cx| panel.toolbar_buttons(window, cx)),
SidebarMode::Forwards => Some(self.forwards.read(cx).toolbar_buttons()),
SidebarMode::Credentials => Some(self.credentials.read(cx).toolbar_buttons()),
}
}
@@ -131,6 +146,7 @@ impl Render for Sidebar {
let list = match self.mode {
SidebarMode::Sessions => self.sessions.clone().into_any_element(),
SidebarMode::Forwards => self.forwards.clone().into_any_element(),
SidebarMode::Credentials => self.credentials.clone().into_any_element(),
};
v_flex()
.size_full()
+12 -4
View File
@@ -7,8 +7,8 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, NewLocalTerminal, NewSession, ShowForwards, ShowSessions, ToggleSessionPanel,
ToggleTheme,
CatalogIcon, NewLocalTerminal, NewSession, ShowCredentials, ShowForwards, ShowSessions,
ToggleSessionPanel, ToggleTheme,
};
use super::sidebar::SidebarMode;
@@ -77,11 +77,19 @@ pub fn render_title_bar(
}
}),
)
.child(
Button::new("show-credentials")
.icon(CatalogIcon::KeyRound)
.tooltip("凭据")
.accessibility_label("凭据")
.selected(sidebar == Some(SidebarMode::Credentials)),
)
.on_click(move |picked: &Vec<usize>, window, cx| {
match picked.first() {
Some(0) => mode.dispatch_action(&ShowSessions, window, cx),
Some(_) => mode.dispatch_action(&ShowForwards, window, cx),
None => {}
Some(1) => mode.dispatch_action(&ShowForwards, window, cx),
Some(2) => mode.dispatch_action(&ShowCredentials, window, cx),
_ => {}
}
}),
),
+9 -1
View File
@@ -35,6 +35,7 @@ use crate::connection::{
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
ConnectionSecret, SharedConnectionTester,
};
use crate::credential::CredentialPanel;
use crate::explorer::{ExplorerId, ExplorerPanel, ExplorerPanelEvent, confirm_close_transfer};
use crate::forward::{
ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider,
@@ -298,7 +299,10 @@ impl Workspace {
cx,
)
});
let sidebar = cx.new(|_| Sidebar::new(session_panel.clone(), forward_panel));
let credential_panel =
cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx));
let sidebar =
cx.new(|_| Sidebar::new(session_panel.clone(), forward_panel, credential_panel));
// Start with focus in the session panel so window-level actions have a
// dispatch path. The workspace's own handle is never focused: the
// dialog layer is its child, and a focused ancestor would keep the
@@ -2135,6 +2139,10 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_delete_forward))
.on_action(cx.listener(Self::on_start_forward))
.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_edit_credential))
.on_action(cx.listener(Self::on_delete_credential))
.on_action(cx.listener(Self::on_toggle_theme))
.on_action(cx.listener(Self::on_focus_search))
.on_action(cx.listener(Self::on_zoom_in))
+9 -5
View File
@@ -14,7 +14,7 @@ use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, SessionInfo, TransferCounters, TransferSummary,
};
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId};
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin};
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
@@ -29,7 +29,7 @@ struct FakeBackend {
impl CliBackend for FakeBackend {
fn exec(
&self,
_: &Session,
_: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
@@ -52,7 +52,7 @@ impl CliBackend for FakeBackend {
fn upload(
&self,
_: &Session,
_: &CliTarget,
_: &Path,
_: &str,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
@@ -62,7 +62,7 @@ impl CliBackend for FakeBackend {
fn download(
&self,
_: &Session,
_: &CliTarget,
_: &str,
_: &Path,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
@@ -106,7 +106,11 @@ fn the_command_finds_the_app_through_its_data_directory() {
SessionId(1),
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Auto, None),
);
server.set_targets(vec![CliTarget::new(&web, None)]);
server.set_targets(vec![CliTarget::new(
&web,
SessionLogin::of(&web, None),
None,
)]);
server.set_enabled(true);
let id = web.public_id.to_string();
+620 -13
View File
@@ -14,11 +14,11 @@ use gpui_kit::{
use shellrs::app::{
CenterTab, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, ConnectGroup,
ConnectSession, CopySessionHost, CopySessionId, DeleteForward, DeleteGroup, DeleteSession,
DisconnectSession, DisconnectTerminal, EditForward, EditSession, ExpandAllGroups,
FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallCliCommand,
NewLocalTerminal, NewSessionInGroup, OpenExplorer, ReconnectTerminal, RemoveAgentSkill,
RenameGroup, RenameTerminal, StartForward, StopForward, ToggleSessionPanel,
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,
};
use shellrs::cli::{AgentKind, IntegrationPaths};
use shellrs::connection::{
@@ -31,8 +31,9 @@ use shellrs::forward::{
};
use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _};
use shellrs::session::{
AuthKind, ConnectionState, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule,
GroupDraft, GroupId, HostOs, Session, SessionDatabase, SessionDraft, SessionId, SessionStore,
AuthKind, ConnectionState, CredentialDraft, CredentialId, CredentialKind, ForwardDraft,
ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, HostOs, LoginMethod,
SessionDatabase, SessionDraft, SessionId, SessionLogin, SessionStore,
};
use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore};
use shellrs::sftp::{
@@ -40,7 +41,8 @@ use shellrs::sftp::{
SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest,
};
use shellrs::terminal::{
FixedRemoteTerminalTransportProvider, Latency, LocalTerminalId, RemoteTerminalId, TerminalFont,
FixedRemoteTerminalTransportProvider, Latency, LocalTerminalId, RemoteTerminalId,
RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalFont,
TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand,
TerminalTransportEvent, TerminalTransportFactory,
};
@@ -957,6 +959,21 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) {
assert!(window.find("commit").visible());
assert!(window.try_find("form-error").is_none());
// The address and its port share a row; the name above them and the
// user name below each take the row's whole width.
let [name, host, port, user] = [
"session-name",
"session-host",
"session-port",
"session-user",
]
.map(|id| window.find(id).bounds());
assert_eq!(host.top(), port.top());
assert!(host.right() < port.left() && host.size.width > port.size.width);
assert!(name.bottom() < host.top() && user.top() > host.bottom());
assert_eq!((name.left(), name.right()), (host.left(), port.right()));
assert_eq!((user.left(), user.right()), (name.left(), name.right()));
// An empty form is rejected and the dialog stays open. The commit
// action is dispatched deferred, so the error shows after effects run.
// The dialog's focus trap owns focus until a field is clicked.
@@ -965,11 +982,17 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) {
window.click("commit", cx);
})
.unwrap();
cx.run_until_parked();
// 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.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window
.try_find("form-error")
.is_some_and(|error| error.visible())
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("form-error").visible());
assert!(window.find("commit").visible());
window.click("session-name", cx);
@@ -3961,7 +3984,7 @@ struct FakeSftpProvider {
slow_home: bool,
}
impl SftpTransportProvider for FakeSftpProvider {
fn create(&self, _: &shellrs::session::Session) -> Box<dyn SftpTransport> {
fn create(&self, _: &SessionLogin) -> Box<dyn SftpTransport> {
Box::new(FakeSftpTransport {
requests: self.requests.clone(),
downloads: self.downloads.clone(),
@@ -6824,11 +6847,11 @@ impl FakeForwardProvider {
}
impl ForwardTransportProvider for FakeForwardProvider {
fn create(&self, rule: &ForwardRule, session: &Session) -> Box<dyn ForwardTransport> {
fn create(&self, rule: &ForwardRule, _: &SessionLogin) -> Box<dyn ForwardTransport> {
Box::new(FakeForwardTransport {
script: self.script.clone(),
rule: rule.clone(),
session: session.id,
session: rule.session,
runs: self.runs.clone(),
})
}
@@ -7809,10 +7832,14 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
("new-session", "session-name"),
("new-group", "group-name"),
("new-forward", "forward-name"),
("new-credential", "credential-name"),
] {
if open == "new-forward" {
show_forwards(cx, handle).await;
}
if open == "new-credential" {
show_credentials(cx, handle).await;
}
in_frame(cx, handle, |window, cx| window.click(open, cx));
in_frame(cx, handle, |window, cx| {
window.click(field, cx);
@@ -8020,3 +8047,583 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T
})
.unwrap();
}
// ---------------------------------------------------------------------------
// 凭据
// ---------------------------------------------------------------------------
/// Hands every remote terminal the same fake, recording the login each one
/// was started with.
#[derive(Default)]
struct RecordingRemoteProvider {
factory: Arc<FakeTerminalFactory>,
logins: Mutex<Vec<SessionLogin>>,
}
impl RecordingRemoteProvider {
fn logins(&self) -> Vec<SessionLogin> {
self.logins
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
impl RemoteTerminalTransportProvider for RecordingRemoteProvider {
fn factory_for(&self, login: &SessionLogin) -> SharedTerminalTransportFactory {
self.logins
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(login.clone());
self.factory.clone()
}
}
/// A workspace over `store`, its remote terminals recorded by `remote` and
/// its connection test answered by `tester`. Dialogs do not slide, so their
/// fields stay where they were found.
fn open_workspace_with_credentials(
cx: &mut TestAppContext,
store: SessionStore,
remote: Arc<RecordingRemoteProvider>,
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);
let view = cx.new(|cx| {
Workspace::new_with_services(
store,
cx.new(|_| SettingsStore::in_memory()),
remote,
Arc::new(FakeTerminalFactory::default()),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
tester,
Arc::new(FakeForwardProvider::default()),
window,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
cx.run_until_parked();
(handle, workspace.expect("workspace created"))
}
/// Show the credential list and wait for it to be up.
async fn show_credentials(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
in_frame(cx, handle, |window, cx| {
window.click("show-credentials", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("credential-search").is_some()
})
.await;
}
/// Wait for the open dialog to close.
async fn wait_for_dialog_to_close(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
cx.run_until_parked();
}
/// A store with one password credential, 「运维」 as `deploy`, and the host
/// db-01 logging in with it; the keychain holds the credential's password.
fn store_with_credential(
secrets: Arc<InMemorySecretStore>,
) -> (SessionStore, CredentialId, SessionId) {
let mut store = SessionStore::empty();
let credential = store.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"deploy",
));
secrets
.set(
&store.credential(credential).unwrap().password_secret(),
"hunter2",
)
.unwrap();
let session = store.insert_unnotified(
SessionDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Auto, None)
.with_credential(credential),
);
(store.with_secrets(secrets), credential, session)
}
#[gpui_kit::test]
async fn the_title_bar_switches_the_sidebar_to_credentials(cx: &mut TestAppContext) {
let (handle, _) = open_workspace_with_credentials(
cx,
SessionStore::seed(),
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
show_credentials(cx, handle).await;
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("show-credentials").checked(), Some(true));
assert_eq!(window.find("show-sessions").checked(), Some(false));
assert_eq!(window.find("show-forwards").checked(), Some(false));
assert_eq!(window.find("credential-empty").label(), Some("还没有凭据"));
// The dock's toolbar follows the list; the settings footer stays.
assert!(window.find("new-credential").visible());
assert!(window.try_find("new-group").is_none());
assert!(window.find("open-settings").visible());
assert!(window.try_find("session-search").is_none());
});
// The search shortcut goes to the list that is up.
in_frame(cx, handle, |window, cx| {
window.activate_window();
window.dispatch_action(Box::new(FocusSearch), cx);
});
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("credential-search").focused(), Some(true));
});
in_frame(cx, handle, |window, cx| window.click("show-sessions", cx));
in_frame(cx, handle, |window, _| {
assert!(window.find("session-search").visible());
assert!(window.try_find("credential-search").is_none());
assert_eq!(window.find("show-credentials").checked(), Some(false));
});
}
#[gpui_kit::test]
async fn a_new_password_credential_keeps_its_password_in_the_keychain(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
SessionStore::empty().with_secrets(secrets.clone()),
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
show_credentials(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.click("credential-empty-new", cx)
});
in_frame(cx, handle, |window, cx| {
// A new credential starts at its name, as a password credential.
assert_eq!(window.find("credential-name").focused(), Some(true));
assert!(window.find("credential-password").visible());
window.input("运维", cx);
window.click("credential-user", cx);
window.press("cmd-a", cx);
window.input("deploy", cx);
window.click("credential-password", cx);
window.input("hunter2", cx);
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
let credential = cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
assert_eq!(store.credentials().len(), 1);
let credential = store.credentials()[0].clone();
assert_eq!(credential.name.as_ref(), "运维");
assert_eq!(credential.user.as_ref(), "deploy");
assert_eq!(credential.kind, CredentialKind::Password);
assert!(
!format!("{credential:?}").contains("hunter2"),
"凭据本身不该带着密码"
);
credential
});
assert_eq!(
secrets
.get(&credential.password_secret())
.unwrap()
.as_deref()
.map(String::as_str),
Some("hunter2")
);
// The new credential is in the list, selected.
in_frame(cx, handle, |window, _| {
let row = window.find(("credential-row", credential.id.0));
assert!(row.visible());
assert_eq!(row.selected(), Some(true));
});
}
#[gpui_kit::test]
async fn the_credential_kind_decides_which_fields_show(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_credentials(
cx,
SessionStore::empty(),
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| {
assert!(window.try_find("credential-key-path").is_none());
assert!(window.try_find("credential-agent-note").is_none());
// Nothing but a name is missing, and that is said first.
window.click("commit", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(window.find("form-error").label(), Some("请输入名称"));
window.click("credential-name", cx);
window.input("部署", cx);
window.within("credential-kind").click(1usize, cx);
});
in_frame(cx, handle, |window, cx| {
assert!(window.try_find("credential-password").is_none());
assert!(window.find("credential-passphrase").visible());
window.click("commit", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find("form-error").label(),
Some("密钥凭据需要选择私钥文件")
);
window.click("choose-credential-key", cx);
});
assert!(cx.did_prompt_for_paths());
cx.simulate_path_prompt_response(|_| Some(vec!["/tmp/id_deploy".into()]));
cx.run_until_parked();
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find("credential-key-path").value(),
Some("/tmp/id_deploy")
);
window.within("credential-kind").click(2usize, cx);
});
in_frame(cx, handle, |window, cx| {
assert!(window.find("credential-agent-note").visible());
assert!(window.try_find("credential-key-path").is_none());
assert!(window.try_find("credential-password").is_none());
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let credential = &store.credentials()[0];
assert_eq!(credential.kind, CredentialKind::Agent);
// The key file picked on the way is not kept by an agent credential.
assert_eq!(credential.key_path, None);
assert_eq!(credential.user.as_ref(), "root");
});
}
#[gpui_kit::test]
async fn the_credential_list_moves_with_the_arrow_keys_and_edits_on_enter(cx: &mut TestAppContext) {
let mut store = SessionStore::empty();
let first = store.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"root",
));
let second = store.insert_credential_unnotified(CredentialDraft::new(
"个人",
CredentialKind::Agent,
"me",
));
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", first.0), cx)
});
in_frame(cx, handle, |window, cx| window.press("down", cx));
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find(("credential-row", second.0)).selected(),
Some(true)
);
assert_eq!(
window.find(("credential-row", first.0)).selected(),
Some(false)
);
window.press("enter", cx);
});
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("credential-name").value(), Some("个人"));
// An agent credential opens with its own kind picked.
assert!(window.find("credential-agent-note").visible());
});
}
#[gpui_kit::test]
async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (store, credential, _) = store_with_credential(secrets.clone());
let (handle, workspace) = open_workspace_with_credentials(
cx,
store,
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| window.click("new-session", cx));
in_frame(cx, handle, |window, cx| {
window.click("session-name", cx);
window.input("web-01", cx);
window.click("session-host", cx);
window.input("10.0.1.12", cx);
window.within("session-auth-source").click(1usize, cx);
});
in_frame(cx, handle, |window, cx| {
// The credential brings the user and the secret: neither is asked.
assert!(window.try_find("session-user").is_none());
assert!(window.try_find("session-password").is_none());
assert_eq!(
window.find("session-credential").value(),
Some("请选择凭据")
);
window.click("commit", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(window.find("form-error").label(), Some("请选择凭据"));
window.within("session-credential").click("input", cx);
});
for key in ["down", "enter"] {
in_frame(cx, handle, |window, cx| window.press(key, cx));
}
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find("session-credential").value(),
Some("运维(deploy · 密码)")
);
assert_eq!(
window.find("session-credential-summary").label(),
Some("以 deploy 登录,使用凭据保存的密码")
);
// Off the select first: a focused select opens on the commit.
window.click("session-name", cx);
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let created = store
.sessions()
.iter()
.find(|session| session.name == "web-01")
.expect("web-01 inserted");
assert_eq!(created.credential, Some(credential));
assert_eq!(created.user.as_ref(), "deploy");
assert_eq!(created.auth, AuthKind::Auto);
});
// Nothing of the host's own went to the keychain.
assert_eq!(secrets.len(), 1);
}
#[gpui_kit::test]
async fn testing_a_connection_with_a_credential_uses_the_saved_login(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (store, _, session) = store_with_credential(secrets);
let tester = Arc::new(FakeConnectionTester::default());
let (handle, _) = open_workspace_with_credentials(
cx,
store,
Arc::new(RecordingRemoteProvider::default()),
tester.clone(),
);
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(EditSession(session)), cx)
});
in_frame(cx, handle, |window, cx| {
// A host using a credential opens that way.
assert_eq!(
window.find("session-credential").value(),
Some("运维(deploy · 密码)")
);
assert!(window.try_find("session-user").is_none());
window.click("test-connection", cx);
});
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
window.render_frame(cx);
window.notifications(cx).len() == 1
})
.await;
// The credential's user, and no password from the form: the saved one
// is read by the login itself.
assert_eq!(
tester.requests(),
[("10.0.2.5".to_string(), 22, "deploy".to_string(), None)]
);
}
#[gpui_kit::test]
async fn a_terminal_logs_in_with_its_credentials_login(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (store, credential, session) = store_with_credential(secrets);
let secret = store.credential(credential).unwrap().password_secret();
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, _) = open_workspace_with_credentials(
cx,
store,
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ConnectSession(session)), cx)
});
let logins = remote.logins();
assert_eq!(logins.len(), 1);
assert_eq!(logins[0].user, "deploy");
assert_eq!(logins[0].method, LoginMethod::Password);
assert_eq!(logins[0].password, secret);
}
#[gpui_kit::test]
async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (mut store, credential, session) = store_with_credential(secrets);
let other = store.insert_unnotified(SessionDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Auto,
None,
));
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
store,
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
for id in [session, other] {
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ConnectSession(id)), cx)
});
}
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.store()
.read(cx)
.session(session)
.is_some_and(|session| session.state.is_connected())
})
.await;
assert_eq!(remote.logins().len(), 2);
show_credentials(cx, handle).await;
// A new name changes no login: nothing reconnects.
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| {
assert_eq!(
window.find("credential-usage").label(),
Some(
"有 1 台主机使用此凭据。修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。"
)
);
window.click("credential-name", cx);
window.press("cmd-a", cx);
window.input("生产运维", cx);
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
assert_eq!(remote.logins().len(), 2);
// A new user is a new login for the host using it, and only for it.
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| {
window.click("credential-user", cx);
window.press("cmd-a", cx);
window.input("admin", cx);
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
let logins = remote.logins();
assert_eq!(logins.len(), 3);
assert_eq!(logins[2].user, "admin");
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
assert_eq!(
store.session(session).unwrap().address(),
"admin@10.0.2.5:22"
);
assert_eq!(store.session(other).unwrap().user.as_ref(), "root");
});
}
#[gpui_kit::test]
async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on_their_own(
cx: &mut TestAppContext,
) {
let secrets = Arc::new(InMemorySecretStore::default());
let (store, credential, session) = store_with_credential(secrets.clone());
let secret = store.credential(credential).unwrap().password_secret();
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
store,
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ConnectSession(session)), cx)
});
show_credentials(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.click(("credential-row", credential.0), cx);
window.dispatch_action(Box::new(DeleteCredential(credential)), cx);
});
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find(("credential-row", credential.0)).is_none()
})
.await;
cx.run_until_parked();
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
assert!(store.credentials().is_empty());
let host = store.session(session).unwrap();
assert_eq!(host.credential, None);
assert_eq!(host.auth, AuthKind::Auto);
assert_eq!(host.user.as_ref(), "deploy");
});
// The working connection was left alone, and the password went with the
// credential.
assert_eq!(remote.logins().len(), 1);
assert!(secrets.get(&secret).unwrap().is_none());
}
#[gpui_kit::test]
async fn a_long_credential_row_fits_the_smallest_window(cx: &mut TestAppContext) {
let mut store = SessionStore::seed();
let id = store.insert_credential_unnotified(
CredentialDraft::new(
"生产环境所有数据库服务器共用的只读巡检账号(不要用于写操作)",
CredentialKind::Key,
"readonly-inspector",
)
.with_key_path("/Users/someone/.ssh/a_rather_long_private_key_file_name_ed25519"),
);
let (handle, _) = open_sized_workspace_with_forwards(
cx,
store,
Arc::new(FakeForwardProvider::default()),
size(px(960.), px(600.)),
);
cx.run_until_parked();
show_credentials(cx, handle).await;
in_frame(cx, handle, |window, _| {
let list = window.find("credential-list").bounds();
let row = window.find(("credential-row", id.0)).bounds();
assert!(row.right() <= list.right(), "{row:?} in {list:?}");
});
}