mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
Dropping a RemoteWatch sent WatchClose with a blocking call, and the last handle is usually let go on the UI thread: from Tty7App::render via scm_sync_watchers, and from scm_watch_opened when an open lands after its subscription moved on. Every window froze for a round trip each time, up to WatchClose's 5 s deadline on a quiet link. Sampling a live instance caught ~1.1 s of such stalls in 10 s. - ControlClient::post sends a request without registering for its reply; the reader already drops replies nobody is waiting for. - RemoteWatch::drop posts WatchClose instead of calling it. - pane_workspace_for built the full SSH spec, keychain lookups included, only to strip the secrets again. It now builds it from NoCredentials, which takes the securityd trips off pane_liveness::sweep. Claude-Session: https://claude.ai/code/session_01YX786Hyr4ivijWxv66zVkf
1487 lines
50 KiB
Rust
1487 lines
50 KiB
Rust
use std::collections::HashMap;
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use gpui::{App, AppContext as _, BorrowAppContext as _, Global};
|
|
|
|
use crate::core::config::Config;
|
|
use crate::core::session::{RemoteRef, RemoteTarget, WorkspaceId};
|
|
use crate::daemon::control::{ControlHello, ControlRequest, ReplyOk};
|
|
use crate::daemon::install::{
|
|
InstallConfirm, InstallDecision, InstallPhase, InstallProgress, InstallRequest,
|
|
MismatchedRemoteDaemon,
|
|
};
|
|
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, NativeSshSpec};
|
|
use crate::daemon::router::RouteHeader;
|
|
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
|
use tty7_core::host::remote::RemoteHost;
|
|
use tty7_core::host::{Host as _, HostId};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct HostChoice {
|
|
pub target: RemoteTarget,
|
|
pub label: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
pub fn available_hosts(cx: &App) -> Vec<HostChoice> {
|
|
let mut out: Vec<HostChoice> = Vec::new();
|
|
let mut seen: Vec<String> = Vec::new();
|
|
|
|
out.extend(local_stdio_host());
|
|
|
|
for profile in &cx.global::<Config>().ssh_profiles {
|
|
let target = RemoteTarget::Profile { id: profile.id };
|
|
seen.push(profile.name.clone());
|
|
out.push(HostChoice {
|
|
detail: endpoint_label(&profile.user, &profile.host, profile.port),
|
|
label: profile_label(profile),
|
|
target,
|
|
});
|
|
}
|
|
|
|
out.extend(wsl_hosts(cx));
|
|
|
|
for imported in crate::core::ssh_config::import_profiles() {
|
|
let alias = imported.profile.name.clone();
|
|
if alias.trim().is_empty() || seen.iter().any(|s| s == &alias) {
|
|
continue;
|
|
}
|
|
out.push(HostChoice {
|
|
detail: endpoint_label(
|
|
&imported.profile.user,
|
|
&imported.profile.host,
|
|
imported.profile.port,
|
|
),
|
|
label: alias,
|
|
target: RemoteTarget::Alias {
|
|
alias: imported.profile.name,
|
|
},
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A label for a bare target when no route snapshot is at hand (a pane's
|
|
/// `PaneWorkspace` carries only the target): the live listing while it
|
|
/// exists, the target's own spelling when that is human-readable, and the
|
|
/// deleted-profile placeholder for the bare-UUID case (#485).
|
|
pub fn target_label(cx: &App, target: &RemoteTarget) -> String {
|
|
label_from_hosts(&available_hosts(cx), target)
|
|
}
|
|
|
|
/// `target_label`'s rule applied to a listing the caller already has. The
|
|
/// switcher builds `available_hosts` once per frame and names several targets
|
|
/// from it; re-listing per target would re-read `~/.ssh/config` off the disk
|
|
/// on the render path, which is the cost `route_label` goes out of its way to
|
|
/// avoid. One rule, two entry points — so a name shown next to a pane and the
|
|
/// same name shown in the switcher cannot drift apart (#485).
|
|
pub fn label_from_hosts(hosts: &[HostChoice], target: &RemoteTarget) -> String {
|
|
if let Some(choice) = hosts.iter().find(|h| h.target == *target) {
|
|
return choice.label.clone();
|
|
}
|
|
match target {
|
|
RemoteTarget::Profile { .. } => t(L10nKey::RemoteProfileGone).to_string(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Whether a route can still be turned into a connection: a `Profile` target
|
|
/// dangles once the profile leaves the config, an `Alias` once the name
|
|
/// leaves `~/.ssh/config` (#485). The route supervisor asks this four times a
|
|
/// second; the alias half is mtime-cached in `ssh_config`.
|
|
pub fn route_resolvable(cx: &App, target: &RemoteTarget) -> bool {
|
|
target.resolvable(&cx.global::<Config>().ssh_profiles, |alias| {
|
|
crate::core::ssh_config::alias_still_resolves(alias)
|
|
})
|
|
}
|
|
|
|
/// The name a route entry answers to: the live listing while the route still
|
|
/// exists, then the remembered snapshot, and — for a profile entry saved
|
|
/// before snapshots existed — a placeholder, but never the bare profile UUID
|
|
/// (#485).
|
|
pub fn route_label(cx: &App, host: &RemoteRef) -> String {
|
|
match live_label(cx, &host.target) {
|
|
Some(label) => label,
|
|
None => host.route_label(t(L10nKey::RemoteProfileGone)),
|
|
}
|
|
}
|
|
|
|
/// What the live config calls a target, reading memory only. Deliberately not
|
|
/// `available_hosts`: `route_label` runs on the render path of every remote
|
|
/// window (the workspace strip), and that listing re-reads `~/.ssh/config`
|
|
/// off the disk. The two targets it answers are the two whose name lives
|
|
/// somewhere other than the target itself; the rest spell themselves the same
|
|
/// way the listing labels them, so falling through costs nothing.
|
|
fn live_label(cx: &App, target: &RemoteTarget) -> Option<String> {
|
|
match target {
|
|
RemoteTarget::Profile { id } => cx
|
|
.global::<Config>()
|
|
.ssh_profiles
|
|
.iter()
|
|
.find(|p| p.id == *id)
|
|
.map(profile_label),
|
|
RemoteTarget::Wsl { .. } => wsl_hosts(cx)
|
|
.into_iter()
|
|
.find(|h| h.target == *target)
|
|
.map(|h| h.label),
|
|
RemoteTarget::Alias { .. }
|
|
| RemoteTarget::Direct { .. }
|
|
| RemoteTarget::LocalStdio { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// What a profile calls itself in a host listing: its name, or the address
|
|
/// when it was saved without one.
|
|
fn profile_label(profile: &crate::core::ssh_profile::SshProfile) -> String {
|
|
if profile.name.trim().is_empty() {
|
|
profile.host.clone()
|
|
} else {
|
|
profile.name.clone()
|
|
}
|
|
}
|
|
|
|
pub fn filter_hosts(hosts: &[HostChoice], query: &str) -> Vec<HostChoice> {
|
|
let query = query.trim();
|
|
if query.is_empty() {
|
|
return hosts.to_vec();
|
|
}
|
|
let mut scored: Vec<(i32, &HostChoice)> = hosts
|
|
.iter()
|
|
.filter_map(|host| host_score(query, host).map(|score| (score, host)))
|
|
.collect();
|
|
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
|
scored.into_iter().map(|(_, host)| host.clone()).collect()
|
|
}
|
|
|
|
fn host_score(query: &str, host: &HostChoice) -> Option<i32> {
|
|
let label = crate::ui::palette::fuzzy_score(query, &host.label);
|
|
let detail = crate::ui::palette::fuzzy_score(query, &host.detail).map(|score| score - 3);
|
|
label.into_iter().chain(detail).max()
|
|
}
|
|
|
|
pub const LOCAL_STDIO_ENV: &str = "TTY7_LOCAL_STDIO_SERVER";
|
|
|
|
fn local_stdio_host() -> Option<HostChoice> {
|
|
let program = std::env::var(LOCAL_STDIO_ENV)
|
|
.ok()
|
|
.filter(|p| !p.is_empty())?;
|
|
let target = RemoteTarget::LocalStdio {
|
|
program: program.clone(),
|
|
args: vec!["--stdio".to_string()],
|
|
};
|
|
Some(HostChoice {
|
|
label: format!("{target}"),
|
|
detail: format!("{program} --stdio ({})", t(L10nKey::RemoteThisComputer)),
|
|
target,
|
|
})
|
|
}
|
|
|
|
fn wsl_detail() -> String {
|
|
format!("WSL · {}", t(L10nKey::RemoteThisComputer))
|
|
}
|
|
|
|
fn wsl_hosts(cx: &App) -> Vec<HostChoice> {
|
|
let names = cx
|
|
.try_global::<WslDistros>()
|
|
.map(|state| state.names.as_slice())
|
|
.unwrap_or_default();
|
|
wsl_choices(names)
|
|
}
|
|
|
|
fn wsl_choices(names: &[String]) -> Vec<HostChoice> {
|
|
names
|
|
.iter()
|
|
.map(|distro| HostChoice {
|
|
target: RemoteTarget::Wsl {
|
|
distro: distro.clone(),
|
|
},
|
|
label: distro.clone(),
|
|
detail: wsl_detail(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct WslDistros {
|
|
names: Vec<String>,
|
|
probed_at: Option<Instant>,
|
|
in_flight: bool,
|
|
}
|
|
|
|
impl Global for WslDistros {}
|
|
|
|
const WSL_TTL: Duration = Duration::from_secs(30);
|
|
|
|
pub fn sweep_wsl(cx: &mut App) {
|
|
if !cfg!(windows) {
|
|
return;
|
|
}
|
|
{
|
|
let state = cx.default_global::<WslDistros>();
|
|
if state.in_flight || state.probed_at.is_some_and(|at| at.elapsed() < WSL_TTL) {
|
|
return;
|
|
}
|
|
}
|
|
cx.update_global::<WslDistros, _>(|state, _| state.in_flight = true);
|
|
cx.spawn(async move |cx| {
|
|
let probed = cx
|
|
.background_spawn(async { crate::core::shells::wsl_distros_probed() })
|
|
.await;
|
|
let _ = cx.update(|cx| {
|
|
cx.update_global::<WslDistros, _>(|state, _| adopt_probe(state, probed));
|
|
cx.refresh_windows();
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn adopt_probe(state: &mut WslDistros, probed: Option<Vec<String>>) {
|
|
if let Some(names) = probed {
|
|
state.names = names;
|
|
}
|
|
state.probed_at = Some(Instant::now());
|
|
state.in_flight = false;
|
|
}
|
|
|
|
fn endpoint_label(user: &str, host: &str, port: u16) -> String {
|
|
let base = if user.is_empty() {
|
|
host.to_string()
|
|
} else {
|
|
format!("{user}@{host}")
|
|
};
|
|
if port == 22 {
|
|
base
|
|
} else {
|
|
format!("{base}:{port}")
|
|
}
|
|
}
|
|
|
|
pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String> {
|
|
spec_from(target, cx, &crate::core::keychain::OsCredentialStore)
|
|
}
|
|
|
|
/// [`spec_for`] with no secrets in it, built without asking the keychain for
|
|
/// any: for callers that only describe the route, which run on the UI thread
|
|
/// and would strip the password again the moment they had it.
|
|
pub fn public_spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String> {
|
|
spec_from(target, cx, &crate::core::keychain::NoCredentials)
|
|
}
|
|
|
|
fn spec_from(
|
|
target: &RemoteTarget,
|
|
cx: &App,
|
|
store: &dyn crate::core::keychain::CredentialStore,
|
|
) -> Result<NativeSshSpec, String> {
|
|
let cfg = cx.global::<Config>();
|
|
match target {
|
|
RemoteTarget::Profile { id } => {
|
|
let profile = cfg
|
|
.ssh_profiles
|
|
.iter()
|
|
.find(|p| p.id == *id)
|
|
.ok_or_else(|| t(L10nKey::RemoteProfileMissing).to_string())?;
|
|
Ok(crate::ui::ssh_connect::build_native_ssh_spec(
|
|
profile,
|
|
&cfg.ssh_profiles,
|
|
store,
|
|
cfg.verify_host_keys,
|
|
))
|
|
}
|
|
RemoteTarget::Alias { alias } => {
|
|
let resolved = crate::core::ssh_config::resolve_alias_to_profile(alias)
|
|
.ok_or_else(|| t_fmt(L10nKey::RemoteAliasMissing, &[("alias", alias)]))?;
|
|
Ok(crate::ui::ssh_connect::native_spec_from_transient_profile(
|
|
&resolved.profile,
|
|
resolved.proxy_jump,
|
|
store,
|
|
cfg.verify_host_keys,
|
|
&crate::ui::ssh_connect::config_alias_resolver,
|
|
))
|
|
}
|
|
RemoteTarget::Direct { user, host, port } => {
|
|
let mut profile = crate::core::ssh_profile::SshProfile::new(host.clone());
|
|
profile.host = host.clone();
|
|
profile.user = user.clone();
|
|
profile.port = *port;
|
|
Ok(crate::ui::ssh_connect::build_native_ssh_spec(
|
|
&profile,
|
|
&cfg.ssh_profiles,
|
|
store,
|
|
cfg.verify_host_keys,
|
|
))
|
|
}
|
|
RemoteTarget::Wsl { .. } => Err(t(L10nKey::RemoteWslNoSsh).to_string()),
|
|
RemoteTarget::LocalStdio { .. } => Err(t(L10nKey::RemoteLocalStdioNoSsh).to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn control_route(target: &RemoteTarget, cx: &App) -> Result<RouteHeader, String> {
|
|
let header = match target {
|
|
RemoteTarget::LocalStdio { program, args } => RouteHeader::local_stdio(
|
|
program.clone(),
|
|
&args.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
),
|
|
RemoteTarget::Wsl { distro } => RouteHeader::wsl(distro.clone()),
|
|
_ => spec_for(target, cx).map(RouteHeader::ssh)?,
|
|
};
|
|
note_origin(&header.target, target);
|
|
Ok(header)
|
|
}
|
|
|
|
pub struct Connected {
|
|
pub host: Arc<RemoteHost>,
|
|
pub home: PathBuf,
|
|
pub rows: Vec<RemoteWorkspaceRow>,
|
|
}
|
|
|
|
pub fn connect_blocking(
|
|
target: &RemoteTarget,
|
|
header: RouteHeader,
|
|
label: &str,
|
|
) -> Result<Connected, String> {
|
|
note_origin(&header.target, target);
|
|
crate::daemon::spawn::ensure_running().map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteDaemonStartFailed,
|
|
&[("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
|
|
let stream = crate::daemon::transport::connect().map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteDaemonUnreachable,
|
|
&[("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
|
|
let mut stream = stream;
|
|
crate::daemon::router::negotiate(&mut stream, &header).map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteHostUnreachable,
|
|
&[("machine", label), ("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
|
|
let hello = ControlHello::host_rpc(new_session_token(), client_hostname());
|
|
let host = handshake(stream, &target.connection_key(), &hello).map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteHostNotTty7,
|
|
&[("machine", label), ("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
|
|
let rows = list_workspaces(&host).map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteWorkspaceListFailed,
|
|
&[("machine", label), ("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
let home = host.home();
|
|
refresh_agent_hooks_once(&host, &home);
|
|
Ok(Connected { host, home, rows })
|
|
}
|
|
|
|
static HOOKS_REFRESHED: Mutex<Vec<HostId>> = Mutex::new(Vec::new());
|
|
|
|
fn refresh_agent_hooks_once(host: &Arc<RemoteHost>, home: &std::path::Path) {
|
|
let id = host.id();
|
|
match HOOKS_REFRESHED.lock() {
|
|
Ok(mut seen) if !seen.contains(&id) => seen.push(id),
|
|
_ => return,
|
|
}
|
|
let (host, home) = (Arc::clone(host), home.to_path_buf());
|
|
std::thread::spawn(move || {
|
|
let refreshed = crate::core::agent_hooks::refresh_remote_hooks(&*host, home);
|
|
if refreshed > 0 {
|
|
log::info!("refreshed {refreshed} stale agent hook integration(s) on {id:?}");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn handshake(
|
|
stream: crate::daemon::transport::Stream,
|
|
connection_key: &str,
|
|
hello: &ControlHello,
|
|
) -> io::Result<Arc<RemoteHost>> {
|
|
RemoteHost::over_unix(stream, connection_key, hello)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn handshake(
|
|
stream: crate::daemon::transport::Stream,
|
|
connection_key: &str,
|
|
hello: &ControlHello,
|
|
) -> io::Result<Arc<RemoteHost>> {
|
|
RemoteHost::over_tcp(stream, connection_key, hello)
|
|
}
|
|
|
|
pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspaceRow>> {
|
|
match host.client().call(ControlRequest::MachineGet)? {
|
|
ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)),
|
|
other => Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
t_fmt(
|
|
L10nKey::RemoteMachineTreeUnexpectedReply,
|
|
&[("reply", &format!("{other:?}"))],
|
|
),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn new_session_token() -> String {
|
|
uuid::Uuid::new_v4().to_string()
|
|
}
|
|
|
|
fn client_hostname() -> String {
|
|
static NAME: OnceLock<String> = OnceLock::new();
|
|
NAME.get_or_init(|| {
|
|
// Windows publishes the name in the environment, so the usual case
|
|
// spawns nothing at all. That matters here: a console program started
|
|
// from a GUI process flashes a console window on screen even when its
|
|
// output is piped, and this runs while the user is looking at the
|
|
// connect dialog.
|
|
#[cfg(windows)]
|
|
if let Some(name) = std::env::var_os("COMPUTERNAME") {
|
|
let name = name.to_string_lossy().trim().to_string();
|
|
if !name.is_empty() {
|
|
return name;
|
|
}
|
|
}
|
|
let mut cmd = std::process::Command::new("hostname");
|
|
tty7_core::core::proc::hide_console(&mut cmd)
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or_else(|| "a tty7 client".to_string())
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RemoteWorkspaceRow {
|
|
pub id: WorkspaceId,
|
|
pub name: String,
|
|
pub panes: usize,
|
|
pub last_active: u64,
|
|
}
|
|
|
|
pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec<RemoteWorkspaceRow> {
|
|
let mut rows: Vec<RemoteWorkspaceRow> = machine
|
|
.workspaces
|
|
.iter()
|
|
.map(|ws| RemoteWorkspaceRow {
|
|
id: ws.id,
|
|
name: crate::ui::machine_mirror::display_name_of(ws, &machine.panes),
|
|
panes: ws.tabs.iter().map(|t| t.root.pane_ids().len()).sum(),
|
|
last_active: ws.last_active,
|
|
})
|
|
.collect();
|
|
rows.sort_by_key(|row| std::cmp::Reverse(row.last_active));
|
|
rows
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct HostLinks {
|
|
hosts: HashMap<HostId, Arc<RemoteHost>>,
|
|
homes: HashMap<HostId, PathBuf>,
|
|
}
|
|
|
|
impl Global for HostLinks {}
|
|
|
|
impl HostLinks {
|
|
pub fn get(cx: &mut App, id: HostId) -> Option<Arc<RemoteHost>> {
|
|
cx.default_global::<HostLinks>().hosts.get(&id).cloned()
|
|
}
|
|
|
|
/// The home directory the host reported when the link came up — the only
|
|
/// thing that can say what a `~` in one of its paths means (#580). Read
|
|
/// through `try_global` rather than `default_global` so a caller holding
|
|
/// nothing but `&App` (every renderer that draws a path) can ask; an
|
|
/// absent table and an empty one are the same answer here.
|
|
pub fn home(cx: &App, id: HostId) -> Option<PathBuf> {
|
|
cx.try_global::<HostLinks>()?.homes.get(&id).cloned()
|
|
}
|
|
|
|
/// Whether the daemon behind this host's link advertised `feature` on its
|
|
/// control hello — the per-host sibling of `local_daemon_supports`. `false`
|
|
/// while no link is up: a caller that cannot see the hello must assume
|
|
/// nothing of the far end, and every feature is gated so that "assume
|
|
/// nothing" degrades to the old behavior rather than breaking.
|
|
pub fn peer_supports(cx: &App, id: HostId, feature: &str) -> bool {
|
|
cx.try_global::<HostLinks>()
|
|
.and_then(|table| table.hosts.get(&id))
|
|
.is_some_and(|host| host.peer().has_feature(feature))
|
|
}
|
|
|
|
pub fn insert(cx: &mut App, host: Arc<RemoteHost>, home: PathBuf) {
|
|
let id = host.id();
|
|
crate::ui::host_registry::HostRegistry::insert(cx, Arc::clone(&host).into_shared());
|
|
let table = cx.default_global::<HostLinks>();
|
|
table.hosts.insert(id, host);
|
|
table.homes.insert(id, home);
|
|
}
|
|
|
|
pub fn remove(cx: &mut App, id: HostId) {
|
|
let table = cx.default_global::<HostLinks>();
|
|
table.hosts.remove(&id);
|
|
table.homes.remove(&id);
|
|
crate::ui::host_registry::HostRegistry::remove(cx, id);
|
|
}
|
|
|
|
pub fn len(cx: &mut App) -> usize {
|
|
cx.default_global::<HostLinks>().hosts.len()
|
|
}
|
|
}
|
|
|
|
pub fn install_detail(request: &InstallRequest) -> String {
|
|
t_fmt(
|
|
L10nKey::RemoteInstallDetail,
|
|
&[
|
|
("machine", &request.host),
|
|
("path", &request.remote_path),
|
|
(
|
|
"version",
|
|
&format!("{} ({})", request.version, request.asset),
|
|
),
|
|
("size", &human_bytes(request.size_bytes)),
|
|
("from", &request.source_url),
|
|
("sha256", &request.sha256),
|
|
("path_label", t(L10nKey::RemoteInstallPathLabel)),
|
|
("version_label", t(L10nKey::RemoteInstallVersionLabel)),
|
|
("size_label", t(L10nKey::RemoteInstallSizeLabel)),
|
|
("from_label", t(L10nKey::RemoteInstallFromLabel)),
|
|
("sha_label", t(L10nKey::RemoteInstallShaLabel)),
|
|
("silent_upgrades", t(L10nKey::RemoteInstallSilentUpgrades)),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn install_title(request: &InstallRequest) -> String {
|
|
t_fmt(L10nKey::RemoteInstallTitle, &[("machine", &request.host)])
|
|
}
|
|
|
|
pub fn human_bytes(n: u64) -> String {
|
|
const KIB: f64 = 1024.0;
|
|
let n = n as f64;
|
|
if n < KIB {
|
|
return format!("{} {}", n as u64, t(L10nKey::RemoteInstallBytes));
|
|
}
|
|
let units = ["KiB", "MiB", "GiB"];
|
|
let mut value = n / KIB;
|
|
for (i, unit) in units.iter().enumerate() {
|
|
if value < KIB || i == units.len() - 1 {
|
|
return format!("{value:.1} {unit}");
|
|
}
|
|
value /= KIB;
|
|
}
|
|
unreachable!("the loop returns on its last iteration")
|
|
}
|
|
|
|
const CONSENT_TIMEOUT: Duration = Duration::from_secs(180);
|
|
|
|
pub struct PendingInstall {
|
|
pub request: InstallRequest,
|
|
reply: std::sync::mpsc::SyncSender<InstallDecision>,
|
|
}
|
|
|
|
impl PendingInstall {
|
|
pub fn answer(self, decision: InstallDecision) {
|
|
let _ = self.reply.send(decision);
|
|
}
|
|
}
|
|
|
|
static MAILBOX: Mutex<Vec<PendingInstall>> = Mutex::new(Vec::new());
|
|
|
|
pub struct GuiInstallConfirm;
|
|
|
|
impl InstallConfirm for GuiInstallConfirm {
|
|
fn confirm(&self, request: &InstallRequest) -> InstallDecision {
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
|
{
|
|
let Ok(mut mailbox) = MAILBOX.lock() else {
|
|
return InstallDecision::Decline;
|
|
};
|
|
mailbox.push(PendingInstall {
|
|
request: request.clone(),
|
|
reply: tx,
|
|
});
|
|
}
|
|
rx.recv_timeout(CONSENT_TIMEOUT)
|
|
.unwrap_or(InstallDecision::Decline)
|
|
}
|
|
}
|
|
|
|
static PROGRESS: Mutex<Vec<(HostId, InstallPhase)>> = Mutex::new(Vec::new());
|
|
|
|
pub struct GuiInstallProgress;
|
|
|
|
impl InstallProgress for GuiInstallProgress {
|
|
fn report(&self, host: &str, phase: InstallPhase) {
|
|
let id = origin_host(host).unwrap_or_else(|| HostId::from_connection_key(host));
|
|
let Ok(mut slots) = PROGRESS.lock() else {
|
|
return;
|
|
};
|
|
match slots.iter_mut().find(|(known, _)| *known == id) {
|
|
Some(slot) => slot.1 = phase,
|
|
None => slots.push((id, phase)),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn install_progress_for(host: HostId) -> Option<InstallPhase> {
|
|
let slots = PROGRESS.lock().ok()?;
|
|
slots
|
|
.iter()
|
|
.find(|(known, _)| *known == host)
|
|
.map(|(_, phase)| *phase)
|
|
}
|
|
|
|
pub fn clear_install_progress(host: HostId) {
|
|
if let Ok(mut slots) = PROGRESS.lock() {
|
|
slots.retain(|(known, _)| *known != host);
|
|
}
|
|
}
|
|
|
|
pub fn register(cx: &mut App) {
|
|
crate::daemon::install::set_install_confirm(Arc::new(GuiInstallConfirm));
|
|
crate::daemon::install::set_install_progress(Arc::new(GuiInstallProgress));
|
|
crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth));
|
|
let _ = HostLinks::len(cx);
|
|
}
|
|
|
|
pub fn take_pending_install() -> Option<PendingInstall> {
|
|
MAILBOX.lock().ok()?.pop()
|
|
}
|
|
|
|
pub struct PendingAuth {
|
|
pub host: HostId,
|
|
pub prompt: AuthPromptKind,
|
|
/// Which connection is asking, when the route is an SSH hop. The sheet
|
|
/// files and forgets keychain entries under this; a route that is not SSH
|
|
/// (WSL, a local stdio server) has no endpoint to name and gets `None`.
|
|
///
|
|
/// Without it the sheet fell back to port 22 and a hard-coded "not
|
|
/// auto-supplied", so a routed prompt for a non-22 endpoint wrote its
|
|
/// password under the wrong key and a rejected stored one was never
|
|
/// noticed, let alone cleared.
|
|
pub endpoint: Option<crate::ui::ssh_prompt::PromptEndpoint>,
|
|
/// The route already carried a stored password into this attempt, so a
|
|
/// password prompt arriving anyway means the server turned it down.
|
|
pub auto_supplied_password: bool,
|
|
reply: std::sync::mpsc::SyncSender<AuthResponse>,
|
|
/// Alive for exactly as long as someone is waiting on `reply`. The asker
|
|
/// holds the only strong count and drops it the moment it stops waiting —
|
|
/// answered or timed out — so this reads whether an answer can still land.
|
|
asker: std::sync::Weak<()>,
|
|
}
|
|
|
|
impl PendingAuth {
|
|
pub fn answer(self, response: AuthResponse) {
|
|
let _ = self.reply.send(response);
|
|
}
|
|
|
|
/// The connection attempt that asked this has given up on it.
|
|
///
|
|
/// Nothing else retires a prompt: it sits in the mailbox, in the parked
|
|
/// queue behind another host's sheet, or on screen, until someone answers
|
|
/// it. An attempt that stopped waiting leaves it there, so a reconnect
|
|
/// loop that kept asking while nobody was at the keyboard left one behind
|
|
/// per attempt. The user came back to a sheet, typed the password into it
|
|
/// — into nothing — and the next one was already queued behind it; the
|
|
/// one live prompt among them connected, and the dead ones went on coming
|
|
/// up one after another however they were closed (#820).
|
|
pub fn is_abandoned(&self) -> bool {
|
|
self.asker.strong_count() == 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn for_test(
|
|
host: HostId,
|
|
prompt: AuthPromptKind,
|
|
) -> (
|
|
PendingAuth,
|
|
std::sync::mpsc::Receiver<AuthResponse>,
|
|
Arc<()>,
|
|
) {
|
|
let (reply, rx) = std::sync::mpsc::sync_channel(1);
|
|
let asker = Arc::new(());
|
|
let pending = PendingAuth {
|
|
host,
|
|
prompt,
|
|
endpoint: None,
|
|
auto_supplied_password: false,
|
|
reply,
|
|
asker: Arc::downgrade(&asker),
|
|
};
|
|
(pending, rx, asker)
|
|
}
|
|
}
|
|
|
|
static AUTH_MAILBOX: Mutex<Vec<PendingAuth>> = Mutex::new(Vec::new());
|
|
|
|
struct RouteOrigin {
|
|
key: String,
|
|
target: RemoteTarget,
|
|
host: HostId,
|
|
}
|
|
|
|
static ORIGINS: Mutex<Vec<RouteOrigin>> = Mutex::new(Vec::new());
|
|
|
|
pub fn note_origin(route: &crate::daemon::router::RouteTarget, target: &RemoteTarget) {
|
|
let key = route.origin_key();
|
|
let Ok(mut origins) = ORIGINS.lock() else {
|
|
return;
|
|
};
|
|
let host = target.host_id();
|
|
match origins.iter_mut().find(|o| o.key == key) {
|
|
Some(existing) => {
|
|
existing.target = target.clone();
|
|
existing.host = host;
|
|
}
|
|
None => origins.push(RouteOrigin {
|
|
key,
|
|
target: target.clone(),
|
|
host,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn origin_host(key: &str) -> Option<HostId> {
|
|
let origins = ORIGINS.lock().ok()?;
|
|
origins.iter().find(|o| o.key == key).map(|o| o.host)
|
|
}
|
|
|
|
pub fn origin_target(key: &str) -> Option<RemoteTarget> {
|
|
let origins = ORIGINS.lock().ok()?;
|
|
origins
|
|
.iter()
|
|
.find(|o| o.key == key)
|
|
.map(|o| o.target.clone())
|
|
}
|
|
|
|
/// How long a routed prompt is waited on. No longer than the daemon's own
|
|
/// handshake waits for it: past that, the attempt behind the sheet has already
|
|
/// failed, and a password typed into it would go nowhere.
|
|
const AUTH_TIMEOUT: Duration = crate::daemon::ssh::broker::PROMPT_TIMEOUT;
|
|
|
|
pub struct GuiRouteAuth;
|
|
|
|
impl crate::daemon::router::RouteAuthResponder for GuiRouteAuth {
|
|
fn respond(
|
|
&self,
|
|
machine: &crate::daemon::router::RouteTarget,
|
|
prompt: &AuthPromptKind,
|
|
) -> AuthResponse {
|
|
ask_routed(machine, prompt, AUTH_TIMEOUT)
|
|
}
|
|
}
|
|
|
|
fn ask_routed(
|
|
machine: &crate::daemon::router::RouteTarget,
|
|
prompt: &AuthPromptKind,
|
|
timeout: Duration,
|
|
) -> AuthResponse {
|
|
let key = machine.origin_key();
|
|
let host = origin_host(&key).unwrap_or_else(|| HostId::from_connection_key(&key));
|
|
let (endpoint, auto_supplied_password) = match machine {
|
|
crate::daemon::router::RouteTarget::Ssh(spec) => (
|
|
Some(crate::ui::ssh_prompt::PromptEndpoint {
|
|
user: spec.user.clone(),
|
|
host: spec.host.clone(),
|
|
port: spec.port,
|
|
}),
|
|
spec.password.is_some(),
|
|
),
|
|
_ => (None, false),
|
|
};
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
|
// Dropped on the way out of this function, whichever way it leaves —
|
|
// that is what marks the prompt abandoned for the pump and the sheet.
|
|
let asker = Arc::new(());
|
|
{
|
|
let Ok(mut mailbox) = AUTH_MAILBOX.lock() else {
|
|
return AuthResponse::Cancelled;
|
|
};
|
|
mailbox.push(PendingAuth {
|
|
host,
|
|
prompt: prompt.clone(),
|
|
endpoint,
|
|
auto_supplied_password,
|
|
reply: tx,
|
|
asker: Arc::downgrade(&asker),
|
|
});
|
|
}
|
|
rx.recv_timeout(timeout).unwrap_or(AuthResponse::Cancelled)
|
|
}
|
|
|
|
pub fn take_pending_auth() -> Option<PendingAuth> {
|
|
AUTH_MAILBOX.lock().ok()?.pop()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn post_pending_auth(pending: PendingAuth) {
|
|
AUTH_MAILBOX.lock().unwrap().push(pending);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) static MAILBOX_TURN: Mutex<()> = Mutex::new(());
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn claim_mailbox() -> std::sync::MutexGuard<'static, ()> {
|
|
MAILBOX_TURN.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
pub fn mismatch_answers() -> [gpui::PromptButton; 2] {
|
|
crate::ui::confirm_answers(t(L10nKey::RemoteMismatchReplaceServer), t(L10nKey::Cancel))
|
|
}
|
|
|
|
pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String {
|
|
let running = match (&m.running_version, &m.running_exe) {
|
|
(Some(v), Some(exe)) => t_fmt(
|
|
L10nKey::RemoteMismatchVersionFromExe,
|
|
&[("version", v), ("exe", exe)],
|
|
),
|
|
(Some(v), None) => v.clone(),
|
|
(None, Some(exe)) => t_fmt(L10nKey::RemoteMismatchUnknownBuildFromExe, &[("exe", exe)]),
|
|
(None, None) => t(L10nKey::RemoteMismatchUnknownBuild).to_string(),
|
|
};
|
|
t_fmt(
|
|
L10nKey::RemoteMismatchDetail,
|
|
&[
|
|
("machine", &m.host),
|
|
("running", &running),
|
|
("wanted", &m.wanted_version),
|
|
("replace_server", t(L10nKey::RemoteMismatchReplaceServer)),
|
|
("cancel", t(L10nKey::Cancel)),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub fn mismatch_title(m: &MismatchedRemoteDaemon) -> String {
|
|
t_fmt(L10nKey::RemoteMismatchTitle, &[("machine", &m.host)])
|
|
}
|
|
|
|
pub fn mismatch_target(m: &MismatchedRemoteDaemon) -> Option<RemoteTarget> {
|
|
origin_target(&m.host)
|
|
}
|
|
|
|
/// A handshake that failed on the control dialect reaches the UI as the protocol
|
|
/// layer's own wording, which reads like the far end is not tty7 at all. It is —
|
|
/// it is just a build the other side of a dialect bump — so say that instead, and
|
|
/// say which side has to move. Anything else is shown as it came.
|
|
pub fn dialect_complaint(error: &str, machine: &str) -> Option<String> {
|
|
let refusal = crate::daemon::control::parse_dialect_refusal(error)?;
|
|
let key = if refusal.peer < refusal.ours {
|
|
L10nKey::RemoteServerOutdated
|
|
} else {
|
|
L10nKey::RemoteServerTooNew
|
|
};
|
|
Some(t_fmt(
|
|
key,
|
|
&[("machine", machine), ("build", &refusal.peer_build)],
|
|
))
|
|
}
|
|
|
|
pub fn restart_server_blocking(header: RouteHeader, label: &str) -> Result<(), String> {
|
|
let action = header.action;
|
|
crate::daemon::spawn::ensure_running().map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteDaemonStartFailed,
|
|
&[("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
let mut stream = crate::daemon::transport::connect().map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteDaemonUnreachable,
|
|
&[("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
let ack = crate::daemon::router::negotiate(&mut stream, &header).map_err(|e| {
|
|
t_fmt(
|
|
L10nKey::RemoteServerRestartFailed,
|
|
&[("machine", label), ("error", &e.to_string())],
|
|
)
|
|
})?;
|
|
if !ack.performed(action) {
|
|
return Err(t_fmt(L10nKey::RemoteDaemonTooOld, &[("machine", label)]));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The one rule for "what do we call a target we cannot resolve" (#485),
|
|
/// pinned where both `target_label` and the switcher's group listing read
|
|
/// it from.
|
|
#[test]
|
|
fn an_unresolvable_profile_is_named_never_spelled_as_its_uuid() {
|
|
let id = uuid::Uuid::new_v4();
|
|
let gone = RemoteTarget::Profile { id };
|
|
|
|
let label = label_from_hosts(&[], &gone);
|
|
assert!(
|
|
!label.contains(&id.to_string()),
|
|
"a bare profile UUID reached the UI: {label}"
|
|
);
|
|
assert_eq!(label, t(L10nKey::RemoteProfileGone));
|
|
|
|
// While the profile is configured, its own name wins.
|
|
let listed = vec![HostChoice {
|
|
target: gone.clone(),
|
|
label: "lager".into(),
|
|
detail: "qhw@222.29.101.16".into(),
|
|
}];
|
|
assert_eq!(label_from_hosts(&listed, &gone), "lager");
|
|
|
|
// Targets that spell themselves readably never need the placeholder.
|
|
let alias = RemoteTarget::Alias {
|
|
alias: "build-box".into(),
|
|
};
|
|
assert_eq!(label_from_hosts(&[], &alias), "build-box");
|
|
}
|
|
|
|
fn request() -> InstallRequest {
|
|
InstallRequest {
|
|
host: "me@build-box:22".into(),
|
|
version: "0.9.1".into(),
|
|
asset: "tty7-server-linux-x86_64-musl",
|
|
source_url: "https://example.invalid/v0.9.1/tty7-server".into(),
|
|
remote_path: "/home/me/.local/share/tty7/bin/tty7-server-0.9.1".into(),
|
|
size_bytes: 9_437_184,
|
|
sha256: "abc123".into(),
|
|
}
|
|
}
|
|
|
|
fn refusal(peer: u32, ours: u32) -> String {
|
|
format!(
|
|
"java answered, but not as a tty7 server: control peer (build 26.7.7-nightly) \
|
|
speaks control v{peer}, this build speaks v{ours}"
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_dialect_refusal_is_restated_as_which_side_is_behind() {
|
|
let behind = dialect_complaint(&refusal(4, 5), "java").expect("a refusal is recognised");
|
|
assert!(
|
|
behind.contains("java") && behind.contains("26.7.7-nightly"),
|
|
"{behind}"
|
|
);
|
|
assert!(
|
|
!behind.contains("control v"),
|
|
"the dialect numbers mean nothing to the reader: {behind}"
|
|
);
|
|
|
|
let ahead = dialect_complaint(&refusal(6, 5), "java").expect("a refusal is recognised");
|
|
assert_ne!(
|
|
ahead, behind,
|
|
"a server newer than this client needs the opposite advice"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_other_failure_is_shown_as_it_came() {
|
|
assert_eq!(
|
|
dialect_complaint("Connection refused (os error 61)", "java"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_install_prompt_states_every_field_of_the_request() {
|
|
crate::ui::i18n::set_locale("en");
|
|
let request = request();
|
|
let detail = install_detail(&request);
|
|
for needle in [
|
|
request.remote_path.as_str(),
|
|
request.version.as_str(),
|
|
request.asset,
|
|
request.source_url.as_str(),
|
|
request.sha256.as_str(),
|
|
] {
|
|
assert!(
|
|
detail.contains(needle),
|
|
"{needle:?} missing from:\n{detail}"
|
|
);
|
|
}
|
|
assert!(detail.contains("9.0 MiB"), "{detail}");
|
|
assert_eq!(
|
|
install_title(&request),
|
|
t_fmt(
|
|
L10nKey::RemoteInstallTitle,
|
|
&[("machine", "me@build-box:22")]
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn human_bytes_reads_in_binary_units() {
|
|
crate::ui::i18n::set_locale("en");
|
|
assert_eq!(
|
|
human_bytes(512),
|
|
format!("512 {}", t(L10nKey::RemoteInstallBytes))
|
|
);
|
|
assert_eq!(human_bytes(1024), "1.0 KiB");
|
|
assert_eq!(human_bytes(1_572_864), "1.5 MiB");
|
|
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unanswered_install_request_declines() {
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
|
let pending = PendingInstall {
|
|
request: request(),
|
|
reply: tx,
|
|
};
|
|
drop(pending);
|
|
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn answering_an_install_request_delivers_the_decision() {
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
|
PendingInstall {
|
|
request: request(),
|
|
reply: tx,
|
|
}
|
|
.answer(InstallDecision::Approve);
|
|
assert_eq!(rx.recv().unwrap(), InstallDecision::Approve);
|
|
}
|
|
|
|
#[test]
|
|
fn the_gui_handler_parks_the_request_for_the_ui_to_answer() {
|
|
while take_pending_install().is_some() {}
|
|
let handle = std::thread::spawn(|| GuiInstallConfirm.confirm(&request()));
|
|
let pending = loop {
|
|
if let Some(p) = take_pending_install() {
|
|
break p;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
};
|
|
assert_eq!(pending.request.host, "me@build-box:22");
|
|
assert!(take_pending_install().is_none(), "handed out twice");
|
|
pending.answer(InstallDecision::Approve);
|
|
assert_eq!(handle.join().unwrap(), InstallDecision::Approve);
|
|
}
|
|
|
|
fn native_spec(user: &str, host: &str, port: u16) -> NativeSshSpec {
|
|
let mut profile = crate::core::ssh_profile::SshProfile::new(host.to_string());
|
|
profile.host = host.to_string();
|
|
profile.user = user.to_string();
|
|
profile.port = port;
|
|
crate::ui::ssh_connect::build_native_ssh_spec(
|
|
&profile,
|
|
&[],
|
|
&crate::core::keychain::InMemoryCredentialStore::new(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
/// `SshProfile::new` takes a *name*, and the address lives in a separate
|
|
/// `host` field; every production caller assigns both. `native_spec` used
|
|
/// to assign only the name, so the spec it handed back addressed nobody
|
|
/// and `ConnectionKey::from_spec` spelled it `me@:22` — one key for every
|
|
/// machine in this module that talks to port 22.
|
|
///
|
|
/// `ORIGINS` is keyed by exactly that string, so the two tests below that
|
|
/// note an origin and read it back were writing to and reading from the
|
|
/// same slot. Whichever noted last won, and the other was handed the wrong
|
|
/// machine: 17 failures in 20 runs of this module alone, 6 in 20 of the
|
|
/// whole binary, and none when either test ran by itself.
|
|
#[test]
|
|
fn two_machines_do_not_share_one_route_origin_key() {
|
|
use crate::daemon::router::RouteTarget;
|
|
|
|
let build = RouteTarget::Ssh(Box::new(native_spec("me", "build-box", 22)));
|
|
let twin = RouteTarget::Ssh(Box::new(native_spec("me", "twin-box", 22)));
|
|
|
|
assert_eq!(
|
|
build.origin_key(),
|
|
"me@build-box:22",
|
|
"a route origin key names the machine it dials"
|
|
);
|
|
assert_ne!(
|
|
build.origin_key(),
|
|
twin.origin_key(),
|
|
"two machines sharing one key make `note_origin` overwrite the other's"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_routed_auth_prompt_carries_the_machine_that_raised_it() {
|
|
let _turn = claim_mailbox();
|
|
while take_pending_auth().is_some() {}
|
|
let target = RemoteTarget::direct("me", "build-box", 22);
|
|
let route =
|
|
crate::daemon::router::RouteTarget::Ssh(Box::new(native_spec("me", "build-box", 22)));
|
|
note_origin(&route, &target);
|
|
let origin_key = route.origin_key();
|
|
|
|
let handle = std::thread::spawn(move || {
|
|
use crate::daemon::router::RouteAuthResponder as _;
|
|
GuiRouteAuth.respond(
|
|
&route,
|
|
&AuthPromptKind::Password {
|
|
user: "me".into(),
|
|
host: "build-box".into(),
|
|
},
|
|
)
|
|
});
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let pending = loop {
|
|
if let Some(p) = take_pending_auth() {
|
|
break p;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"no routed prompt arrived within 10s. `respond` pushes one \
|
|
unconditionally, so an empty mailbox means something else \
|
|
drained it first — `pump_auth_sheets` takes all of it, and it \
|
|
runs from any gpui test here that drives a tick. Responder \
|
|
thread finished: {}",
|
|
handle.is_finished(),
|
|
);
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
};
|
|
assert_eq!(
|
|
pending.host,
|
|
target.host_id(),
|
|
"the prompt names the machine noted under {:?}",
|
|
origin_key
|
|
);
|
|
pending.answer(AuthResponse::Secret("hunter2".into()));
|
|
assert_eq!(
|
|
handle.join().unwrap(),
|
|
AuthResponse::Secret("hunter2".into())
|
|
);
|
|
}
|
|
|
|
/// #820. A routed prompt outlives the attempt that asked it unless
|
|
/// something says the attempt is gone. Once the asker has stopped waiting
|
|
/// the prompt has to read as abandoned, or the pump keeps it and raises a
|
|
/// sheet whose password goes nowhere.
|
|
#[test]
|
|
fn a_routed_prompt_reads_abandoned_once_its_asker_gives_up() {
|
|
let _turn = claim_mailbox();
|
|
while take_pending_auth().is_some() {}
|
|
let target = RemoteTarget::direct("me", "gone-box", 22);
|
|
let route =
|
|
crate::daemon::router::RouteTarget::Ssh(Box::new(native_spec("me", "gone-box", 22)));
|
|
note_origin(&route, &target);
|
|
|
|
let handle = std::thread::spawn(move || {
|
|
ask_routed(
|
|
&route,
|
|
&AuthPromptKind::Password {
|
|
user: "me".into(),
|
|
host: "gone-box".into(),
|
|
},
|
|
Duration::from_millis(50),
|
|
)
|
|
});
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let pending = loop {
|
|
if let Some(p) = take_pending_auth() {
|
|
break p;
|
|
}
|
|
assert!(Instant::now() < deadline, "no routed prompt arrived");
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
};
|
|
assert_eq!(
|
|
handle.join().unwrap(),
|
|
AuthResponse::Cancelled,
|
|
"nobody answered in time"
|
|
);
|
|
assert!(
|
|
pending.is_abandoned(),
|
|
"the asker has given up, so the prompt must say so"
|
|
);
|
|
}
|
|
|
|
/// The other side: a prompt somebody is still waiting on is not
|
|
/// abandoned, and stops being live only when its asker goes.
|
|
#[test]
|
|
fn a_routed_prompt_is_live_while_its_asker_waits() {
|
|
let (pending, _rx, asker) = PendingAuth::for_test(
|
|
HostId(0x820),
|
|
AuthPromptKind::Password {
|
|
user: "me".into(),
|
|
host: "live-box".into(),
|
|
},
|
|
);
|
|
assert!(!pending.is_abandoned());
|
|
drop(asker);
|
|
assert!(pending.is_abandoned());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pane_and_its_workspace_resolve_to_the_same_machine() {
|
|
use crate::daemon::router::{RouteHeader, RouteTarget as RT};
|
|
|
|
let target = RemoteTarget::direct("me", "twin-box", 22);
|
|
let control = RouteHeader::ssh(native_spec("me", "twin-box", 22));
|
|
let pane = RouteHeader::ssh(native_spec("me", "twin-box", 22)).for_pane();
|
|
note_origin(&control.target, &target);
|
|
assert_eq!(
|
|
origin_host(&pane.target.origin_key()),
|
|
Some(target.host_id()),
|
|
"a pane's header names the machine its workspace's does"
|
|
);
|
|
|
|
let local = RemoteTarget::LocalStdio {
|
|
program: "/opt/tty7-server".into(),
|
|
args: vec!["--stdio".into()],
|
|
};
|
|
let control = RT::LocalStdio {
|
|
program: "/opt/tty7-server".into(),
|
|
args: vec!["--stdio".into()],
|
|
};
|
|
let pane = RT::LocalStdio {
|
|
program: "/opt/tty7-server".into(),
|
|
args: vec!["--stdio".into(), "--pane".into()],
|
|
};
|
|
note_origin(&control, &local);
|
|
assert_eq!(origin_host(&pane.origin_key()), Some(local.host_id()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_mismatch_record_resolves_back_to_the_machine_it_is_about() {
|
|
let target = RemoteTarget::direct("me", "skew-box", 2222);
|
|
let spec = native_spec("me", "skew-box", 2222);
|
|
let label = crate::daemon::ssh::ConnectionKey::from_spec(&spec)
|
|
.as_str()
|
|
.to_string();
|
|
note_origin(
|
|
&crate::daemon::router::RouteTarget::Ssh(Box::new(spec)),
|
|
&target,
|
|
);
|
|
|
|
let mismatch = MismatchedRemoteDaemon {
|
|
host: label,
|
|
running_version: Some("0.8.0".into()),
|
|
running_exe: None,
|
|
wanted_version: "0.9.1".into(),
|
|
};
|
|
assert_eq!(mismatch_target(&mismatch), Some(target));
|
|
|
|
assert_eq!(
|
|
mismatch_target(&MismatchedRemoteDaemon {
|
|
host: "me@never-seen:22".into(),
|
|
..mismatch
|
|
}),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_mismatch_prompt_names_the_host_and_both_versions() {
|
|
crate::ui::i18n::set_locale("en");
|
|
let m = MismatchedRemoteDaemon {
|
|
host: "me@build-box:22".into(),
|
|
running_version: Some("0.8.0".into()),
|
|
running_exe: Some("/home/me/.local/share/tty7/bin/tty7-server-0.8.0".into()),
|
|
wanted_version: "0.9.1".into(),
|
|
};
|
|
let detail = mismatch_detail(&m);
|
|
assert!(detail.contains("0.8.0"), "{detail}");
|
|
assert!(detail.contains("0.9.1"), "{detail}");
|
|
assert!(detail.contains("me@build-box:22"), "{detail}");
|
|
assert_eq!(
|
|
mismatch_title(&m),
|
|
t_fmt(
|
|
L10nKey::RemoteMismatchTitle,
|
|
&[("machine", "me@build-box:22")]
|
|
)
|
|
);
|
|
|
|
let unknown = MismatchedRemoteDaemon {
|
|
running_version: None,
|
|
running_exe: None,
|
|
..m
|
|
};
|
|
assert!(
|
|
mismatch_detail(&unknown).contains(t(L10nKey::RemoteMismatchUnknownBuild)),
|
|
"{detail}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_mismatch_detail_explains_every_answer_the_prompt_offers() {
|
|
crate::ui::i18n::set_locale("en");
|
|
let detail = mismatch_detail(&MismatchedRemoteDaemon {
|
|
host: "me@build-box:22".into(),
|
|
running_version: Some("0.8.0".into()),
|
|
running_exe: None,
|
|
wanted_version: "0.9.1".into(),
|
|
});
|
|
for answer in mismatch_answers() {
|
|
let label = answer.label();
|
|
assert!(
|
|
detail.contains(label.as_ref()),
|
|
"{label} is unexplained: {detail}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn endpoint_labels_hide_the_default_port() {
|
|
assert_eq!(endpoint_label("me", "box.local", 22), "me@box.local");
|
|
assert_eq!(endpoint_label("me", "box.local", 2222), "me@box.local:2222");
|
|
assert_eq!(endpoint_label("", "box.local", 22), "box.local");
|
|
}
|
|
|
|
#[test]
|
|
fn rows_from_the_tree_sort_newest_first_and_derive_names() {
|
|
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace};
|
|
let older = WorkspaceId::new();
|
|
let newer = WorkspaceId::new();
|
|
let machine = Machine {
|
|
workspaces: vec![
|
|
Workspace {
|
|
id: older,
|
|
name: Some("api".into()),
|
|
last_active: 100,
|
|
tabs: vec![Tab::leaf(1)],
|
|
..Default::default()
|
|
},
|
|
Workspace {
|
|
id: newer,
|
|
name: None,
|
|
last_active: 500,
|
|
tabs: vec![Tab::leaf(2)],
|
|
..Default::default()
|
|
},
|
|
],
|
|
panes: vec![
|
|
PaneRecord::new(1),
|
|
PaneRecord {
|
|
cwd: Some("/srv/checkout".into()),
|
|
..PaneRecord::new(2)
|
|
},
|
|
],
|
|
};
|
|
let rows = rows_from_machine(&machine);
|
|
assert_eq!(rows.len(), 2);
|
|
assert_eq!(rows[0].id, newer, "newest first");
|
|
assert_eq!(
|
|
rows[0].name, "checkout",
|
|
"no user name falls back to the first pane's directory"
|
|
);
|
|
assert_eq!(rows[0].panes, 1);
|
|
assert_eq!(rows[1].name, "api", "a user-set name wins");
|
|
}
|
|
|
|
fn host(label: &str, detail: &str) -> HostChoice {
|
|
HostChoice {
|
|
target: RemoteTarget::Alias {
|
|
alias: label.to_string(),
|
|
},
|
|
label: label.to_string(),
|
|
detail: detail.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_query_keeps_every_machine_in_order() {
|
|
let hosts = vec![
|
|
host("gate2jup", "root@18.143.92.244"),
|
|
host("aws-xy", "root@52.199.113.213"),
|
|
];
|
|
let all = filter_hosts(&hosts, " ");
|
|
assert_eq!(all, hosts);
|
|
}
|
|
|
|
#[test]
|
|
fn a_query_matches_the_name_or_the_endpoint() {
|
|
let hosts = vec![
|
|
host("gate2jup", "root@18.143.92.244"),
|
|
host("aws-xy", "root@52.199.113.213"),
|
|
host("orb", "default@127.0.0.1:32222"),
|
|
];
|
|
|
|
let by_name = filter_hosts(&hosts, "jup");
|
|
assert_eq!(by_name.len(), 1);
|
|
assert_eq!(by_name[0].label, "gate2jup");
|
|
|
|
let by_address = filter_hosts(&hosts, "52.199");
|
|
assert_eq!(by_address.len(), 1);
|
|
assert_eq!(by_address[0].label, "aws-xy");
|
|
|
|
let mixed = filter_hosts(&hosts, "or");
|
|
assert_eq!(mixed[0].label, "orb", "a name match beats an endpoint one");
|
|
}
|
|
|
|
#[test]
|
|
fn a_query_nothing_matches_returns_nothing() {
|
|
let hosts = vec![host("gate2jup", "root@18.143.92.244")];
|
|
assert!(filter_hosts(&hosts, "zzz").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_wsl_row_names_the_distro_verbatim() {
|
|
let rows = wsl_choices(&["Ubuntu-22.04".to_string(), "Arch".to_string()]);
|
|
assert_eq!(rows.len(), 2);
|
|
assert_eq!(rows[0].label, "Ubuntu-22.04");
|
|
assert_eq!(
|
|
rows[0].target,
|
|
RemoteTarget::Wsl {
|
|
distro: "Ubuntu-22.04".to_string()
|
|
}
|
|
);
|
|
assert_eq!(rows[0].target.connection_key(), "wsl:Ubuntu-22.04");
|
|
assert_eq!(rows[1].label, "Arch");
|
|
}
|
|
|
|
#[test]
|
|
fn no_distros_is_no_rows() {
|
|
assert!(wsl_choices(&[]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_distro_is_found_by_name_or_by_wsl() {
|
|
let mut hosts = vec![host("gate2jup", "root@18.143.92.244")];
|
|
hosts.extend(wsl_choices(&["Ubuntu".to_string()]));
|
|
|
|
let by_name = filter_hosts(&hosts, "ubun");
|
|
assert_eq!(by_name.len(), 1);
|
|
assert_eq!(by_name[0].label, "Ubuntu");
|
|
|
|
let by_kind = filter_hosts(&hosts, "wsl");
|
|
assert_eq!(by_kind.len(), 1);
|
|
assert_eq!(by_kind[0].label, "Ubuntu");
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_probe_keeps_the_distros_it_already_had() {
|
|
let mut state = WslDistros {
|
|
names: vec!["Ubuntu-24.04".to_string()],
|
|
..Default::default()
|
|
};
|
|
adopt_probe(&mut state, None);
|
|
|
|
assert_eq!(state.names, vec!["Ubuntu-24.04".to_string()]);
|
|
assert!(state.probed_at.is_some(), "the TTL still restarts");
|
|
assert!(!state.in_flight, "the next sweep is allowed to run");
|
|
}
|
|
|
|
#[test]
|
|
fn an_answered_probe_replaces_the_list_even_when_it_is_empty() {
|
|
let mut state = WslDistros {
|
|
names: vec!["Ubuntu-24.04".to_string()],
|
|
..Default::default()
|
|
};
|
|
adopt_probe(&mut state, Some(Vec::new()));
|
|
assert!(state.names.is_empty());
|
|
|
|
adopt_probe(&mut state, Some(vec!["Arch".to_string()]));
|
|
assert_eq!(state.names, vec!["Arch".to_string()]);
|
|
}
|
|
}
|