凭据 - 标题栏「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 同步架构和不变量,并更正 转发对话框「选完主机移走焦点」的描述(代码里从未有这个处理)
307 lines
11 KiB
Rust
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()))
|
|
);
|
|
}
|
|
}
|