Files
shell-rs/src/host/model.rs
T
Xuz ef83812f45 表结构发布前重构,「会话」在代码和数据库里统一改名为 Host
- 数据库升到第 13 版:主机的登录、连接方式、代理和备注合进 hosts 一行,只有一对多的跳板、书签和端口转发单独成表;全部表改为 STRICT,并用 CHECK 写明约束;用凭据的主机不再冗余存用户名,凭据外键不再级联,在用的凭据删不掉
- 表名 sessions → hosts、session_jumps → host_jumps,session_id → host_id,地址列 host → address;schema 比对去掉排版,以后加字段可以直接 ADD COLUMN
- 删除开发期的历史迁移代码:版本 1–12 的库直接拒绝打开、不动文件
- 代码里 Session / SessionId / SessionStore 等改为 Host*,模块 session/ → host/,Host.host → Host.address,菜单命令与界面元素 id 同步改名
- 外部 CLI 的参数、JSON 字段和错误码改为 host(host_not_found),协议版本升到 2
- CLAUDE.md、AGENTS.md、README 与 Agent skill 同步更新
2026-10-01 14:04:30 +08:00

678 lines
23 KiB
Rust

use gpui_kit::{Rgba, SharedString, rgb};
use rand::{Rng as _, distr::Alphanumeric};
use crate::secrets::SecretRef;
use super::CredentialId;
/// Stable identity of a host. Never reused within a process.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HostId(pub u64);
/// The identity a host shows outside the app, such as
/// `Jwg5rHvXCxw89paM`: what 复制 ID copies, so a script or another tool can
/// name the machine. Random letters and digits, not derived from
/// [`HostId`]: that one is an allocator detail that can come back after a
/// restart, while this one is never reused and stays with its host
/// through renames and address changes. It names a host; 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);
impl PublicId {
const LEN: usize = 16;
/// A new id: 16 characters from 62, about 95 random bits.
pub fn generate() -> Self {
let id: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(Self::LEN)
.map(char::from)
.collect();
Self(id.into())
}
/// A new id that `in_use` does not already claim. Sixteen random
/// characters practically never collide, but ids must be unique.
pub fn generate_unused(in_use: impl Fn(&PublicId) -> bool) -> Self {
loop {
let id = Self::generate();
if !in_use(&id) {
return id;
}
}
}
/// An id read back from the database.
pub(crate) fn from_stored(id: String) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PublicId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
/// Stable identity of a host group (a folder in the host tree).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct GroupId(pub u64);
/// How a host logs in on its own, without a credential. A key, or a
/// password shared by several hosts, is a credential.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AuthKind {
/// A password typed into the host form, or asked for at each connection
/// when none was saved.
#[default]
Password,
/// Nothing typed: the server lets the user in as they are, or the SSH
/// agent or a default key in `~/.ssh` does. A server that wants a
/// password is refused rather than asked.
NoPassword,
}
impl AuthKind {
/// The stored spelling. Kept separate from any label so translating the
/// UI cannot rewrite what is already in the database.
pub fn as_str(self) -> &'static str {
match self {
AuthKind::Password => "password",
AuthKind::NoPassword => "no-password",
}
}
/// Parse a stored spelling. Anything else is the default: `credential`,
/// which a host using a credential stores, keeps the default here.
pub fn from_stored(value: &str) -> Self {
match value {
"no-password" => AuthKind::NoPassword,
_ => AuthKind::Password,
}
}
}
/// How a connection reaches a host, as the host form's 「连接方式」 offers it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Route {
/// A TCP connection from this machine.
#[default]
Direct,
/// Through other saved hosts, one after another: this machine logs in to
/// the first, which opens a channel to the second, and so on to the
/// host. Only this list counts; a jump host's own route does not.
///
/// `None` is a jump host that has been deleted. It stays in its place
/// so that the host fails to connect, saying why, rather than quietly
/// skipping a hop or going direct.
Jump(Vec<Option<HostId>>),
/// Through an HTTP or SOCKS5 proxy.
Proxy(ProxySettings),
}
impl Route {
/// The jump hosts this route goes through, deleted ones left out.
pub fn jump_hosts(&self) -> impl Iterator<Item = HostId> + '_ {
let hops = match self {
Route::Jump(hops) => hops.as_slice(),
_ => &[],
};
hops.iter().flatten().copied()
}
}
/// The proxy of a host that connects through one.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProxySettings {
pub kind: ProxyKind,
pub host: SharedString,
pub port: u16,
/// For a proxy that wants a user name and password; its password is in
/// the keychain.
pub user: Option<SharedString>,
}
impl ProxySettings {
pub fn new(kind: ProxyKind, host: impl Into<SharedString>, port: u16) -> Self {
Self {
kind,
host: host.into(),
port,
user: None,
}
}
/// Log in to the proxy as `user`. An empty name is no name.
pub fn with_user(mut self, user: impl Into<SharedString>) -> Self {
let user = user.into();
self.user = (!user.is_empty()).then_some(user);
self
}
/// Where the proxy's password lives in the keychain. Only a proxy with
/// a user name has one; hosts behind the same proxy as the same user
/// share it.
pub fn password_secret(&self) -> Option<SecretRef> {
self.user
.as_ref()
.map(|user| SecretRef::proxy(user.as_ref(), self.host.as_ref(), self.port))
}
}
/// The kind of proxy a host connects through.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ProxyKind {
/// An HTTP proxy, which the connection asks to `CONNECT` to the host.
#[default]
Http,
Socks5,
}
impl ProxyKind {
/// Every kind, in the order the form lists them.
pub const ALL: [ProxyKind; 2] = [ProxyKind::Http, ProxyKind::Socks5];
pub fn label(self) -> &'static str {
match self {
ProxyKind::Http => "HTTP 代理",
ProxyKind::Socks5 => "SOCKS5 代理",
}
}
/// The stored spelling.
pub fn as_str(self) -> &'static str {
match self {
ProxyKind::Http => "http",
ProxyKind::Socks5 => "socks5",
}
}
pub fn from_stored(value: &str) -> Option<Self> {
match value {
"http" => Some(ProxyKind::Http),
"socks5" => Some(ProxyKind::Socks5),
_ => None,
}
}
}
/// Which pane of a host's SFTP tab a bookmark belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BookmarkSide {
Local,
Remote,
}
impl BookmarkSide {
pub fn from_remote(remote: bool) -> Self {
if remote { Self::Remote } else { Self::Local }
}
/// The stored spelling.
pub fn as_str(self) -> &'static str {
match self {
BookmarkSide::Local => "local",
BookmarkSide::Remote => "remote",
}
}
pub fn from_stored(value: &str) -> Option<Self> {
match value {
"local" => Some(BookmarkSide::Local),
"remote" => Some(BookmarkSide::Remote),
_ => None,
}
}
}
/// The operating system running on a host, as reported by the host's own
/// shell after it connects.
///
/// Each variant owns four things that must stay in step: the spelling kept in
/// the database, the name shown to a person, the icon embedded in
/// `app/assets.rs`, and the brand's own colour. Adding a distribution means
/// one line here, one icon file, and one arm in `ssh/probe.rs`.
///
/// The colours are the published brand values, which is why they are literals
/// rather than theme tokens: here the colour *is* the data, the one case the
/// design guides allow. Keeping them in this table is what stops them from
/// leaking into render code.
macro_rules! host_os {
($($variant:ident => $stored:literal, $label:literal, $icon:literal, $brand:expr;)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostOs {
$($variant,)*
}
impl HostOs {
/// Every variant, for tests that must cover the whole table.
pub const ALL: &'static [HostOs] = &[$(HostOs::$variant,)*];
/// How the database spells it. Kept separate from `label` so
/// translating the UI cannot rewrite what is already stored.
pub fn as_str(self) -> &'static str {
match self { $(Self::$variant => $stored,)* }
}
/// What a person sees, in a tooltip or the status bar.
pub fn label(self) -> &'static str {
match self { $(Self::$variant => $label,)* }
}
/// Path into the application asset bundle.
pub fn icon_path(self) -> &'static str {
match self { $(Self::$variant => concat!("icons/os/", $icon, ".svg"),)* }
}
/// The brand's published colour, for the badge behind the mark.
///
/// `None` means the mark is monochrome and should follow the
/// theme instead. That is how Apple's is meant to be drawn, and
/// the honest answer where a project publishes no single colour.
pub fn brand_color(self) -> Option<Rgba> {
let hex: Option<u32> = match self { $(Self::$variant => $brand,)* };
hex.map(rgb)
}
/// What to draw the mark itself in, on top of [`Self::brand_color`].
///
/// White, the way nearly every brand draws its own mark, except on
/// the light ones where the brand uses a dark mark instead.
pub fn brand_foreground(self) -> Option<Rgba> {
self.brand_color().map(|color| {
if is_light(color) {
rgb(BRAND_DARK_MARK)
} else {
rgb(BRAND_LIGHT_MARK)
}
})
}
/// Read back a stored spelling. An unknown one means the row was
/// written by a newer build, so it is treated as undetected.
pub fn from_stored(value: &str) -> Option<Self> {
match value {
$($stored => Some(Self::$variant),)*
_ => None,
}
}
}
};
}
host_os! {
Ubuntu => "ubuntu", "Ubuntu", "ubuntu", Some(0xE9_5420);
Debian => "debian", "Debian", "debian", Some(0xA8_1D33);
Fedora => "fedora", "Fedora", "fedora", Some(0x51_A2DA);
RedHat => "rhel", "Red Hat", "redhat", Some(0xEE_0000);
CentOs => "centos", "CentOS", "centos", Some(0x26_2577);
Rocky => "rocky", "Rocky Linux", "rockylinux", Some(0x10_B981);
// AlmaLinux publishes no single flat colour, so its mark follows the theme.
Alma => "almalinux", "AlmaLinux", "almalinux", None;
Arch => "arch", "Arch Linux", "archlinux", Some(0x17_93D1);
Alpine => "alpine", "Alpine Linux", "alpinelinux", Some(0x0D_597F);
Suse => "suse", "openSUSE", "opensuse", Some(0x73_BA25);
Gentoo => "gentoo", "Gentoo", "gentoo", Some(0x54_487A);
Kali => "kali", "Kali Linux", "kalilinux", Some(0x55_7C94);
Manjaro => "manjaro", "Manjaro", "manjaro", Some(0x35_BFA4);
Raspbian => "raspbian", "Raspberry Pi OS", "raspberrypi", Some(0xA2_2846);
Linux => "linux", "Linux", "linux", Some(0xFC_C624);
MacOs => "macos", "macOS", "apple", None;
Windows => "windows", "Windows", "windows", Some(0x00_78D4);
FreeBsd => "freebsd", "FreeBSD", "freebsd", Some(0xAB_2B28);
OpenBsd => "openbsd", "OpenBSD", "openbsd", Some(0xF2_CA30);
NetBsd => "netbsd", "NetBSD", "netbsd", Some(0xFF_6600);
}
/// Drawn on top of a light brand colour.
const BRAND_DARK_MARK: u32 = 0x00_0000;
/// Drawn on top of every other brand colour.
const BRAND_LIGHT_MARK: u32 = 0xFF_FFFF;
/// Whether a brand colour is light enough that the brand puts a dark mark on
/// it. The threshold sits well above the point where white text would fail,
/// because a logo silhouette is not body text and nearly every brand draws its
/// own mark in white; only the yellows go the other way.
fn is_light(color: Rgba) -> bool {
fn linear(channel: f32) -> f32 {
if channel <= 0.03928 {
channel / 12.92
} else {
((channel + 0.055) / 1.055).powf(2.4)
}
}
let luminance = 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b);
luminance > 0.5
}
/// Connection state of a host. Runtime only: it is never persisted, so a
/// freshly loaded host always starts disconnected.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ConnectionState {
#[default]
Disconnected,
Connecting,
Connected,
}
impl ConnectionState {
pub fn label(self) -> &'static str {
match self {
ConnectionState::Disconnected => "未连接",
ConnectionState::Connecting => "连接中",
ConnectionState::Connected => "已连接",
}
}
pub fn is_connected(self) -> bool {
matches!(self, ConnectionState::Connected)
}
}
/// A saved SSH host. `group` is `None` for a host that sits at the root
/// of the tree rather than inside a folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Host {
pub id: HostId,
/// Set once when the host is created; editing never changes it.
pub public_id: PublicId,
pub name: SharedString,
/// The host name or IP address it is reached at: 「地址」 in the form.
pub address: SharedString,
pub port: u16,
/// The user it logs in as. For a host using a credential this is the
/// credential's user, which the store keeps in step.
pub user: SharedString,
/// How a host logs in on its own. A host using a credential keeps
/// the default here.
pub auth: AuthKind,
/// The credential it logs in with instead of `auth`, if any.
pub credential: Option<CredentialId>,
/// How a connection reaches it.
pub route: Route,
pub group: Option<GroupId>,
/// Whatever the user wants to remember about it; empty for none.
pub notes: SharedString,
/// Order among hosts in the same group.
pub sort_order: i64,
pub state: ConnectionState,
/// Detected on every successful connection and persisted, so the tree can
/// show the right mark before anyone connects. `None` until a probe
/// succeeds, and the host tree falls back to the name's first letter.
pub os: Option<HostOs>,
}
impl Host {
/// A host with a fresh [`PublicId`]. The store makes sure it is not
/// one another host already has.
pub fn new(id: HostId, draft: HostDraft) -> Self {
Self {
id,
public_id: PublicId::generate(),
name: draft.name,
address: draft.address,
port: draft.port,
user: draft.user,
auth: draft.auth,
credential: draft.credential,
route: draft.route,
group: draft.group,
notes: draft.notes,
sort_order: 0,
state: ConnectionState::Disconnected,
os: None,
}
}
/// `user@address:port`, as shown in the status bar.
pub fn endpoint(&self) -> String {
format!("{}@{}:{}", self.user, self.address, self.port)
}
/// Whether the address is a literal IP address rather than a name, so a
/// command can say which of the two it copies.
pub fn address_is_ip(&self) -> bool {
is_ip_address(&self.address)
}
/// Where this host's login password lives in the system keychain.
/// Keyed by the endpoint, so renaming or copying a host keeps the
/// password and two hosts on the same account share one entry.
pub fn password_secret(&self) -> SecretRef {
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
/// The editable fields, for pre-filling the host form.
pub fn draft(&self) -> HostDraft {
HostDraft {
name: self.name.clone(),
address: self.address.clone(),
port: self.port,
user: self.user.clone(),
auth: self.auth,
credential: self.credential,
route: self.route.clone(),
group: self.group,
notes: self.notes.clone(),
}
}
}
/// IPv6 literals are accepted with or without the brackets a URL puts around
/// them.
fn is_ip_address(host: &str) -> bool {
let host = host.trim();
let bare = host
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(host);
bare.parse::<std::net::IpAddr>().is_ok()
}
/// A folder in the host tree. Groups nest: `parent` is `None` for a
/// top-level folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct HostGroup {
pub id: GroupId,
pub name: SharedString,
pub parent: Option<GroupId>,
/// Order among groups with the same parent.
pub sort_order: i64,
/// Whether the group is expanded in the host tree across launches.
pub expanded: bool,
}
impl HostGroup {
pub fn new(id: GroupId, draft: GroupDraft) -> Self {
Self {
id,
name: draft.name,
parent: draft.parent,
sort_order: 0,
expanded: true,
}
}
/// The editable fields, for pre-filling the group form.
pub fn draft(&self) -> GroupDraft {
GroupDraft {
name: self.name.clone(),
parent: self.parent,
}
}
}
/// The values the host form commits.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct HostDraft {
pub name: SharedString,
pub address: SharedString,
pub port: u16,
pub user: SharedString,
pub auth: AuthKind,
pub credential: Option<CredentialId>,
pub route: Route,
pub group: Option<GroupId>,
pub notes: SharedString,
}
impl HostDraft {
pub fn new(
name: impl Into<SharedString>,
address: impl Into<SharedString>,
port: u16,
user: impl Into<SharedString>,
auth: AuthKind,
group: Option<GroupId>,
) -> Self {
Self {
name: name.into(),
address: address.into(),
port,
user: user.into(),
auth,
credential: None,
route: Route::Direct,
group,
notes: SharedString::default(),
}
}
/// 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
}
/// Reach the host some other way than directly.
pub fn with_route(mut self, route: Route) -> Self {
self.route = route;
self
}
pub fn with_notes(mut self, notes: impl Into<SharedString>) -> Self {
self.notes = notes.into();
self
}
/// The keychain entry this draft would log in with. Matches
/// [`Host::password_secret`] once the draft is applied.
pub fn password_secret(&self) -> SecretRef {
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_ids_are_sixteen_letters_and_digits() {
let id = PublicId::generate();
assert_eq!(id.as_str().len(), 16);
assert!(id.as_str().chars().all(|c| c.is_ascii_alphanumeric()));
assert_ne!(PublicId::generate(), id);
let taken = [id.clone()];
assert_ne!(PublicId::generate_unused(|id| taken.contains(id)), id);
}
#[test]
fn ip_literals_are_told_apart_from_host_names() {
assert!(is_ip_address("10.0.1.21"));
assert!(is_ip_address("::1"));
assert!(is_ip_address("[fe80::1]"));
assert!(!is_ip_address("web-01.example.com"));
assert!(!is_ip_address("localhost"));
assert!(!is_ip_address(""));
}
#[test]
fn every_operating_system_round_trips_through_the_database_spelling() {
for os in HostOs::ALL {
assert_eq!(HostOs::from_stored(os.as_str()), Some(*os));
assert!(!os.label().is_empty());
assert!(os.icon_path().starts_with("icons/os/"));
}
}
#[test]
fn the_light_brands_get_a_dark_mark_and_the_rest_get_white() {
// Tux is black on yellow, and OpenBSD's Puffy likewise.
assert_eq!(HostOs::Linux.brand_foreground(), Some(rgb(0x00_0000)));
assert_eq!(HostOs::OpenBsd.brand_foreground(), Some(rgb(0x00_0000)));
// Everything else follows the usual white-on-brand treatment.
assert_eq!(HostOs::Ubuntu.brand_foreground(), Some(rgb(0xFF_FFFF)));
assert_eq!(HostOs::Debian.brand_foreground(), Some(rgb(0xFF_FFFF)));
assert_eq!(HostOs::Windows.brand_foreground(), Some(rgb(0xFF_FFFF)));
}
#[test]
fn a_monochrome_mark_leaves_the_colour_to_the_theme() {
assert_eq!(HostOs::MacOs.brand_color(), None);
assert_eq!(HostOs::MacOs.brand_foreground(), None);
}
#[test]
fn stored_spellings_and_icons_are_unique() {
let mut spellings: Vec<_> = HostOs::ALL.iter().map(|os| os.as_str()).collect();
spellings.sort_unstable();
let count = spellings.len();
spellings.dedup();
assert_eq!(spellings.len(), count, "两个变体用了同一个存储拼写");
}
#[test]
fn an_unknown_spelling_reads_as_undetected() {
assert_eq!(HostOs::from_stored("plan9"), None);
assert_eq!(HostOs::from_stored(""), None);
}
#[test]
fn a_new_host_has_not_been_probed_yet() {
let host = Host::new(
HostId(1),
HostDraft::new("s", "h", 22, "root", AuthKind::Password, None),
);
assert_eq!(host.os, None);
}
#[test]
fn a_password_is_the_default_and_spellings_round_trip() {
assert_eq!(AuthKind::default(), AuthKind::Password);
for auth in [AuthKind::Password, AuthKind::NoPassword] {
assert_eq!(AuthKind::from_stored(auth.as_str()), auth);
}
assert_eq!(AuthKind::from_stored("credential"), AuthKind::Password);
}
}
/// The values the group form commits.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GroupDraft {
pub name: SharedString,
pub parent: Option<GroupId>,
}
impl GroupDraft {
pub fn new(name: impl Into<SharedString>, parent: Option<GroupId>) -> Self {
Self {
name: name.into(),
parent,
}
}
}