Files
shell-rs/src/session/secret_fields.rs
T
Xuz d6d052de9e 凭据(密码 / 密钥 / 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 同步架构和不变量,并更正
转发对话框「选完主机移走焦点」的描述(代码里从未有这个处理)
2026-10-01 07:45:58 +08:00

307 lines
11 KiB
Rust

//! 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()))
);
}
}