From 41bbebf7a25a30b812b5b8240a06d919b2a7acd2 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 20:24:25 -0300 Subject: [PATCH 01/27] Add Client::Machine and Origin::Machine: another Machine holds only the Runs it starts --- core/localhost-cli/src/mcp/tools.rs | 1 + core/localhost-core/src/origin.rs | 2 ++ core/localhost-core/src/remote/mod.rs | 8 ++++- core/localhost-core/src/remote/server.rs | 2 +- core/localhost-core/tests/shared.rs | 42 ++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/core/localhost-cli/src/mcp/tools.rs b/core/localhost-cli/src/mcp/tools.rs index 92da5aa..58d0469 100644 --- a/core/localhost-cli/src/mcp/tools.rs +++ b/core/localhost-cli/src/mcp/tools.rs @@ -714,6 +714,7 @@ fn started_by(origin: Origin) -> &'static str { Origin::MainWindow | Origin::MenuBar => "app", Origin::Cli => "cli", Origin::Agent => "agent", + Origin::Machine => "machine", } } diff --git a/core/localhost-core/src/origin.rs b/core/localhost-core/src/origin.rs index 6460804..2bea8ff 100644 --- a/core/localhost-core/src/origin.rs +++ b/core/localhost-core/src/origin.rs @@ -14,4 +14,6 @@ pub enum Origin { Cli, /// A coding agent, through `localhost mcp`. Agent, + /// The localhost app of another Machine, through `localhost remote`. + Machine, } diff --git a/core/localhost-core/src/remote/mod.rs b/core/localhost-core/src/remote/mod.rs index fe480ef..44e9100 100644 --- a/core/localhost-core/src/remote/mod.rs +++ b/core/localhost-core/src/remote/mod.rs @@ -35,6 +35,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; pub use client::Remote; +pub(crate) use server::forward_batches; pub use server::serve; use crate::{ @@ -42,7 +43,7 @@ use crate::{ }; /// Changes whenever a message changes shape; both ends must speak the same. -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; /// What a connection is, which decides what its disconnecting stops. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -56,6 +57,10 @@ pub enum Client { Agent, /// The `localhost` command. Cli, + /// The app of another Machine, through `localhost remote`. Not an app + /// here: it holds only the Runs it starts, so a laptop that sleeps or + /// quits changes nothing else this Machine keeps running. + Machine, } impl Client { @@ -66,6 +71,7 @@ impl Client { Client::App => Origin::MainWindow, Client::Agent => Origin::Agent, Client::Cli => Origin::Cli, + Client::Machine => Origin::Machine, } } } diff --git a/core/localhost-core/src/remote/server.rs b/core/localhost-core/src/remote/server.rs index 3605fce..8c83fd8 100644 --- a/core/localhost-core/src/remote/server.rs +++ b/core/localhost-core/src/remote/server.rs @@ -471,7 +471,7 @@ impl Connection { /// Sends what `receiver` delivers in batches, at most one per /// `LOG_BATCH_INTERVAL`, until it ends (the last batch says so) or `send` /// returns false. -async fn forward_batches(mut receiver: mpsc::UnboundedReceiver, send: impl Fn(Vec, bool) -> bool) { +pub(crate) async fn forward_batches(mut receiver: mpsc::UnboundedReceiver, send: impl Fn(Vec, bool) -> bool) { loop { let Some(first) = receiver.recv().await else { send(Vec::new(), true); diff --git a/core/localhost-core/tests/shared.rs b/core/localhost-core/tests/shared.rs index 554f838..6396391 100644 --- a/core/localhost-core/tests/shared.rs +++ b/core/localhost-core/tests/shared.rs @@ -115,6 +115,48 @@ fn nothing_stops_while_an_app_is_connected_then_what_nobody_holds_does() { }); } +#[test] +fn another_machine_holds_only_what_it_started_and_keeps_nothing_else_alive() { + within_patience(async { + let setup = setup(PATIENCE).await; + let web = setup.service("web", vec![]); + let api = setup.service("api", vec![]); + let agent = setup.connect(Client::Agent).await; + let machine = setup.connect(Client::Machine).await; + let watcher = setup.connect(Client::Cli).await; + + agent.start(web.id, None, Origin::Agent).await.unwrap(); + machine.start(api.id, None, Origin::Machine).await.unwrap(); + wait_for(&watcher, web.id, ServiceState::Running).await; + wait_for(&watcher, api.id, ServiceState::Running).await; + + // Unlike an app, a viewing Machine keeps nobody else's Runs. + drop(agent); + wait_for(&watcher, web.id, ServiceState::Stopped).await; + assert_eq!(watcher.status(api.id).state, ServiceState::Running); + + // A laptop that went away: what it started stops, as for any client. + drop(machine); + wait_for(&watcher, api.id, ServiceState::Stopped).await; + }); +} + +#[test] +fn an_app_on_the_machine_keeps_what_another_machine_started() { + within_patience(async { + let setup = setup(PATIENCE).await; + let web = setup.service("web", vec![]); + let app = setup.connect(Client::App).await; + let machine = setup.connect(Client::Machine).await; + + machine.start(web.id, None, Origin::Machine).await.unwrap(); + wait_for(&app, web.id, ServiceState::Running).await; + drop(machine); + settle().await; + assert_eq!(app.status(web.id).state, ServiceState::Running); + }); +} + #[test] fn a_client_keeps_what_it_started_and_its_dependencies() { within_patience(async { From 60bdb63d64c37040f597b90593cf033ace3ee900 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 20:24:25 -0300 Subject: [PATCH 02/27] Serve this Machine's Projects to another's app with `localhost remote --stdio`, and reach it over the system's ssh with Forwards --- core/localhost-cli/Cargo.toml | 1 + core/localhost-cli/src/arguments.rs | 8 + core/localhost-cli/src/main.rs | 2 + core/localhost-cli/src/mcp/mod.rs | 15 +- core/localhost-cli/src/remote.rs | 46 +++ core/localhost-cli/tests/machines_ssh.rs | 256 +++++++++++++ core/localhost-core/src/error.rs | 20 +- core/localhost-core/src/lib.rs | 2 + core/localhost-core/src/machine/connection.rs | 346 ++++++++++++++++++ core/localhost-core/src/machine/mod.rs | 168 +++++++++ core/localhost-core/src/machine/protocol.rs | 137 +++++++ core/localhost-core/src/machine/relay.rs | 202 ++++++++++ core/localhost-core/src/machine/ssh.rs | 257 +++++++++++++ core/localhost-core/tests/machines.rs | 159 ++++++++ 14 files changed, 1613 insertions(+), 6 deletions(-) create mode 100644 core/localhost-cli/src/remote.rs create mode 100644 core/localhost-cli/tests/machines_ssh.rs create mode 100644 core/localhost-core/src/machine/connection.rs create mode 100644 core/localhost-core/src/machine/mod.rs create mode 100644 core/localhost-core/src/machine/protocol.rs create mode 100644 core/localhost-core/src/machine/relay.rs create mode 100644 core/localhost-core/src/machine/ssh.rs create mode 100644 core/localhost-core/tests/machines.rs diff --git a/core/localhost-cli/Cargo.toml b/core/localhost-cli/Cargo.toml index 93e60b1..ba802e2 100644 --- a/core/localhost-cli/Cargo.toml +++ b/core/localhost-cli/Cargo.toml @@ -19,3 +19,4 @@ tokio = { version = "1.53.2", features = ["io-std", "io-util", "rt-multi-thread" libc = "0.2.190" serde_json = "1.0.154" tempfile = "3.27.0" +tokio = { version = "1.53.2", features = ["net"] } diff --git a/core/localhost-cli/src/arguments.rs b/core/localhost-cli/src/arguments.rs index 4e04ef6..b21aa9d 100644 --- a/core/localhost-cli/src/arguments.rs +++ b/core/localhost-cli/src/arguments.rs @@ -50,6 +50,10 @@ pub enum Command { Daemon { storage_root: String, }, + /// This Machine's Projects and Services, served over standard input and + /// output to the app of another Machine, which runs it over SSH: not for + /// people, so not in the usage. + Remote, } /// Reads the arguments after the program name. `Err` says what is wrong. @@ -68,6 +72,7 @@ pub fn parse(arguments: impl IntoIterator) -> Result Ok(Command::Projects), ("mcp", []) => Ok(Command::Mcp { read_only: false }), ("mcp", [option]) if option == "--read-only" => Ok(Command::Mcp { read_only: true }), + ("remote", [option]) if option == "--stdio" => Ok(Command::Remote), ("daemon", [storage_root]) => Ok(Command::Daemon { storage_root: storage_root.clone() }), ("status", [project]) => Ok(Command::Status { project: project.clone() }), ("logs", [project]) => Ok(Command::Logs { project: project.clone(), service: None }), @@ -81,6 +86,7 @@ pub fn parse(arguments: impl IntoIterator) -> Result Err("Usage: localhost up [service…]".into()), ("mcp", _) => Err("Usage: localhost mcp [--read-only]".into()), ("daemon", _) => Err("Usage: localhost daemon ".into()), + ("remote", _) => Err("Usage: localhost remote --stdio".into()), _ => Err(format!("Unknown command `{command}`.")), } } @@ -110,6 +116,8 @@ mod tests { assert_eq!(parsed(&["mcp"]), Ok(Command::Mcp { read_only: false })); assert_eq!(parsed(&["mcp", "--read-only"]), Ok(Command::Mcp { read_only: true })); assert_eq!(parsed(&["daemon", "/tmp/root"]), Ok(Command::Daemon { storage_root: "/tmp/root".into() })); + assert_eq!(parsed(&["remote", "--stdio"]), Ok(Command::Remote)); + assert!(parsed(&["remote"]).is_err()); assert_eq!(parsed(&["--help"]), Ok(Command::Help)); assert_eq!(parsed(&["-V"]), Ok(Command::Version)); } diff --git a/core/localhost-cli/src/main.rs b/core/localhost-cli/src/main.rs index 38ea476..a75dd4b 100644 --- a/core/localhost-cli/src/main.rs +++ b/core/localhost-cli/src/main.rs @@ -7,6 +7,7 @@ mod arguments; mod logs; mod mcp; mod projects; +mod remote; mod resolve; mod signals; mod status; @@ -58,6 +59,7 @@ fn run(command: Command, terminal: &Terminal) -> Result { Command::Logs { project, service } => logs::run(&open()?, &project, service.as_deref(), terminal)?, Command::Up { project, services } => return up::run(open()?, &project, &services, terminal), Command::Mcp { read_only } => return mcp::run(open()?, read_only), + Command::Remote => return remote::run(open()?), Command::Daemon { storage_root } => localhost_core::remote::shared::run(storage_root.as_ref())?, } Ok(ExitCode::SUCCESS) diff --git a/core/localhost-cli/src/mcp/mod.rs b/core/localhost-cli/src/mcp/mod.rs index 2576f78..5f724f8 100644 --- a/core/localhost-cli/src/mcp/mod.rs +++ b/core/localhost-cli/src/mcp/mod.rs @@ -77,8 +77,12 @@ async fn serve(library: ProjectLibrary, read_only: bool) -> Result<(), String> { let storage_root = default_storage_root(); let library = Arc::new(library); let logs = Arc::new(LogStore::new(&storage_root)); - let targets = - Targets::open(library.clone(), &storage_root, ServiceManagerOptions::default(), launcher(&storage_root)); + let targets = Targets::open( + library.clone(), + &storage_root, + ServiceManagerOptions::default(), + launcher(&storage_root, Client::Agent), + ); // Now, so the first tool call doesn't wait for the core to start. If it // can't, each call that needs it says why. let _ = targets.connect(&ExecutionTarget::ThisMachine).await; @@ -138,15 +142,16 @@ async fn serve(library: ProjectLibrary, read_only: bool) -> Result<(), String> { } /// This machine through the shared core, started as `localhost daemon` if -/// it isn't running. A WSL distro's Projects are run by the Windows app only. -fn launcher(storage_root: &Path) -> Launcher { +/// it isn't running, connected as `client`. A WSL distro's Projects are run +/// by the Windows app only. +pub(crate) fn launcher(storage_root: &Path, client: Client) -> Launcher { let storage_root = storage_root.to_path_buf(); Box::new(move |target| match target { ExecutionTarget::ThisMachine => { let executable = std::env::current_exe().unwrap_or_else(|_| "localhost".into()); let mut daemon = std::process::Command::new(executable); daemon.arg("daemon").arg(&storage_root); - shared::link(&storage_root, Client::Agent, daemon) + shared::link(&storage_root, client, daemon) } ExecutionTarget::Wsl { distro } => { Link::Unavailable(format!("“{distro}” is reached only from the Windows app")) diff --git a/core/localhost-cli/src/remote.rs b/core/localhost-cli/src/remote.rs new file mode 100644 index 0000000..752922d --- /dev/null +++ b/core/localhost-cli/src/remote.rs @@ -0,0 +1,46 @@ +//! `localhost remote --stdio`: this Machine's Projects and Services, served +//! to the app of another Machine, which runs this command over SSH +//! (`localhost_core::machine`). Standard output carries the protocol only. +//! +//! It is a client of this Machine's shared core, as `localhost mcp` is, so +//! the other Machine sees what agents here start, and they see what it +//! does. When the connection ends (standard input closes, SSH hangs up, or +//! the app goes silent) the core stops what only it started, unless an app +//! here is open. + +use std::process::ExitCode; +use std::sync::Arc; + +use localhost_core::machine::relay; +use localhost_core::remote::Client; +use localhost_core::{ExecutionTarget, ProjectLibrary, ServiceManagerOptions, Targets, default_storage_root}; + +use crate::signals::Signals; + +pub fn run(library: ProjectLibrary) -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("Couldn't start: {error}"))?; + runtime.block_on(async { + let storage_root = default_storage_root(); + let library = Arc::new(library); + let targets = Targets::open( + library.clone(), + &storage_root, + ServiceManagerOptions::default(), + crate::mcp::launcher(&storage_root, Client::Machine), + ); + // Now, so the statuses the app subscribes to are this Machine's. + targets.connect(&ExecutionTarget::ThisMachine).await.map_err(|error| error.message())?; + let mut signals = Signals::new().map_err(|error| error.to_string())?; + tokio::select! { + _ = relay::serve(tokio::io::stdin(), tokio::io::stdout(), library, targets, relay::SILENCE_TIMEOUT) => {} + _ = signals.next() => {} + } + Ok::<(), String>(()) + })?; + // Standard input is read on a blocking thread, which may wait forever. + runtime.shutdown_timeout(std::time::Duration::from_secs(1)); + Ok(ExitCode::SUCCESS) +} diff --git a/core/localhost-cli/tests/machines_ssh.rs b/core/localhost-cli/tests/machines_ssh.rs new file mode 100644 index 0000000..eeecfd6 --- /dev/null +++ b/core/localhost-cli/tests/machines_ssh.rs @@ -0,0 +1,256 @@ +//! Reaching another Machine through the real `ssh`: a private `sshd` on a +//! loopback port, run as this user with throwaway keys, whose sessions find +//! this build's `localhost` first on the `PATH`. Nothing of the user's own +//! SSH setup is read or written. Skipped where there is no `sshd`. +#![cfg(target_os = "linux")] + +use std::net::{Ipv4Addr, SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::time::Duration; + +use localhost_core::machine::SshLink; +use localhost_core::{ + Id, Key, LocalhostError, Machine, MachineProblem, MemorySecretStore, NewService, Origin, ProjectLibrary, + ServiceState, +}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const PATIENCE: Duration = Duration::from_secs(60); +const SSHD: &str = "/usr/sbin/sshd"; + +/// The other Machine: a home with a Project, and an `sshd` serving it. +struct OtherMachine { + folder: TempDir, + sshd: Child, +} + +impl Drop for OtherMachine { + fn drop(&mut self) { + let _ = self.sshd.kill(); + let _ = self.sshd.wait(); + // Its shared core exits once idle; don't leave it waiting. + let _ = Command::new("pkill").arg("-f").arg(self.data().to_str().unwrap()).status(); + } +} + +fn free_port() -> u16 { + TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap().local_addr().unwrap().port() +} + +fn keygen(path: &Path) { + let status = Command::new("ssh-keygen").args(["-q", "-t", "ed25519", "-N", ""]).arg("-f").arg(path).status(); + assert!(status.unwrap().success()); +} + +impl OtherMachine { + /// `None` where this computer can't run an `sshd`. + fn start() -> Option { + if !Path::new(SSHD).exists() || Command::new("ssh").arg("-V").output().is_err() { + eprintln!("skipped: no sshd or ssh here"); + return None; + } + let folder = tempfile::tempdir().unwrap(); + let path = folder.path(); + let bin = path.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(path.join("data/localhost")).unwrap(); + // `localhost` as each test needs it: this build, an older one, or none. + let script = format!( + "#!/bin/sh\ncase \"$LH_VARIANT\" in\n old) echo 'Unknown command `remote`.' >&2; echo 'Run `localhost --help` for usage.' >&2; exit 2;;\n missing) echo 'sh: 1: localhost: not found' >&2; exit 127;;\nesac\nexec {} \"$@\"\n", + env!("CARGO_BIN_EXE_localhost") + ); + std::fs::write(bin.join("localhost"), script).unwrap(); + std::fs::set_permissions(bin.join("localhost"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + keygen(&path.join("host_key")); + keygen(&path.join("client_key")); + std::fs::copy(path.join("client_key.pub"), path.join("authorized_keys")).unwrap(); + + let port = free_port(); + let p = path.display(); + std::fs::write( + path.join("sshd_config"), + format!( + "Port {port}\nListenAddress 127.0.0.1\nHostKey {p}/host_key\nAuthorizedKeysFile {p}/authorized_keys\n\ + PidFile {p}/sshd.pid\nUsePAM no\nStrictModes no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n\ + AcceptEnv LH_VARIANT\nSetEnv PATH={p}/bin:/usr/bin:/bin XDG_DATA_HOME={p}/data\n" + ), + ) + .unwrap(); + let host_key = std::fs::read_to_string(path.join("host_key.pub")).unwrap(); + let host_key: Vec<&str> = host_key.split_whitespace().take(2).collect(); + std::fs::write(path.join("known_hosts"), format!("[127.0.0.1]:{port} {}\n", host_key.join(" "))).unwrap(); + let user = std::env::var("USER").unwrap_or_else(|_| "root".into()); + let host = |name: &str, extra: &str| { + format!( + "Host {name}\n HostName 127.0.0.1\n Port {port}\n User {user}\n IdentityFile {p}/client_key\n IdentitiesOnly yes\n \ + UserKnownHostsFile {p}/known_hosts\n StrictHostKeyChecking yes\n{extra}" + ) + }; + let config = [ + host("devbox", ""), + host("oldbox", " SetEnv LH_VARIANT=old\n"), + host("barebox", " SetEnv LH_VARIANT=missing\n"), + host("strangebox", "").replace(&format!("{p}/known_hosts"), "/dev/null"), + ]; + std::fs::write(path.join("ssh_config"), config.join("")).unwrap(); + std::fs::write(path.join("ssh"), format!("#!/bin/sh\nexec ssh -F {p}/ssh_config \"$@\"\n")).unwrap(); + std::fs::set_permissions(path.join("ssh"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + + let sshd = Command::new(SSHD) + .arg("-D") + .arg("-f") + .arg(path.join("sshd_config")) + .arg("-E") + .arg(path.join("sshd.log")) + .spawn() + .ok()?; + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while std::net::TcpStream::connect(("127.0.0.1", port)).is_err() { + if std::time::Instant::now() > deadline { + eprintln!( + "skipped: sshd didn't start: {}", + std::fs::read_to_string(path.join("sshd.log")).unwrap_or_default() + ); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Some(OtherMachine { folder, sshd }) + } + + fn path(&self) -> &Path { + self.folder.path() + } + + fn data(&self) -> PathBuf { + self.path().join("data") + } + + fn ssh(&self) -> PathBuf { + self.path().join("ssh") + } + + /// Its Project Configuration, as an agent there would have saved it. + fn library(&self) -> ProjectLibrary { + ProjectLibrary::open(&self.data().join("localhost"), std::sync::Arc::new(MemorySecretStore::new())).unwrap() + } + + fn machine(&self, destination: &str) -> Machine { + Machine { id: Id::new(), name: destination.into(), destination: destination.into() } + } + + async fn connect( + &self, + destination: &str, + ) -> Result<(std::sync::Arc, localhost_core::machine::MachineConnection), LocalhostError> { + let (ssh, control) = (self.ssh(), self.path().join("control")); + let link = SshLink::connect(self.machine(destination), &ssh, &control); + tokio::time::timeout(PATIENCE, link).await.expect("ssh hung") + } +} + +fn problem(result: Result) -> MachineProblem { + match result { + Err(LocalhostError::MachineUnavailable { problem, .. }) => problem, + Err(other) => panic!("not a Machine problem: {other:?}"), + Ok(_) => panic!("it connected"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn controls_the_other_machines_services_over_ssh() { + let Some(other) = OtherMachine::start() else { return }; + let library = other.library(); + let folder = other.path().join("app"); + std::fs::create_dir_all(&folder).unwrap(); + let project = + library.add_project("App", Key::new("app").unwrap(), folder.to_str().unwrap(), vec![], Origin::Agent).unwrap(); + let web = library + .add_service( + project.id, + NewService::new("Web", Key::new("web").unwrap(), "exec sleep 60"), + vec![], + vec![], + Origin::Agent, + ) + .unwrap(); + + let (link, connection) = other.connect("devbox").await.unwrap(); + assert_eq!(connection.hello().version, localhost_core::VERSION); + let (projects, _) = connection.projects().await.unwrap(); + assert_eq!(projects.iter().map(|project| project.key.as_str()).collect::>(), ["app"]); + + let status = connection.start(web.id, None).await.unwrap(); + assert_eq!(status.run.unwrap().origin, Origin::Machine); + let (statuses, mut events) = connection.subscribe().await.unwrap(); + if statuses.get(&web.id).map(|status| status.state) != Some(ServiceState::Running) { + tokio::time::timeout(PATIENCE, async { + while let Some(event) = events.recv().await { + if let localhost_core::ServiceEvent::ServiceStateChanged(status) = event + && status.state == ServiceState::Running + { + return; + } + } + }) + .await + .expect("it never ran"); + } + assert_eq!(connection.stop(web.id).await.unwrap().state, ServiceState::Stopped); + + // Ending the link ends the connection, and the reverse. + link.end(); + tokio::time::timeout(PATIENCE, connection.closed()).await.expect("still connected"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn forwards_a_port_of_the_other_machine_to_this_one() { + let Some(other) = OtherMachine::start() else { return }; + let (link, _connection) = other.connect("devbox").await.unwrap(); + + // A dev server there; here both ends are this computer, so the Forward + // takes another local port. + let server = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let port = server.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = server.accept().await { + tokio::spawn(async move { + let mut buffer = [0u8; 4]; + if stream.read_exact(&mut buffer).await.is_ok() { + let _ = stream.write_all(b"pong").await; + } + }); + } + }); + + // A port in use here is a conflict, never a Forward beside it. + let taken = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let taken_port = taken.local_addr().unwrap().port(); + assert!(matches!( + link.forward_to(port, taken_port).await, + Err(LocalhostError::PortConflict { port, .. }) if port == taken_port + )); + + let local_port = free_port(); + let forward = link.forward_to(port, local_port).await.unwrap(); + assert_eq!(link.forwards(), [forward]); + let mut stream = tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, local_port)).await.unwrap(); + stream.write_all(b"ping").await.unwrap(); + let mut reply = [0u8; 4]; + tokio::time::timeout(PATIENCE, stream.read_exact(&mut reply)).await.unwrap().unwrap(); + assert_eq!(&reply, b"pong"); + + link.cancel_forward(forward).await; + assert!(link.forwards().is_empty()); + assert!(std::net::TcpStream::connect((Ipv4Addr::LOCALHOST, local_port)).is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn says_why_a_machine_cant_be_reached() { + let Some(other) = OtherMachine::start() else { return }; + assert_eq!(problem(other.connect("strangebox").await), MachineProblem::HostKeyNotTrusted); + assert_eq!(problem(other.connect("oldbox").await), MachineProblem::TooOld(None)); + assert_eq!(problem(other.connect("barebox").await), MachineProblem::NotInstalled); +} diff --git a/core/localhost-core/src/error.rs b/core/localhost-core/src/error.rs index 4d11cc4..3cb0cbb 100644 --- a/core/localhost-core/src/error.rs +++ b/core/localhost-core/src/error.rs @@ -2,7 +2,7 @@ use std::fmt; use serde::{Deserialize, Serialize}; -use crate::{Id, Key, Listener, ProjectFileProblem}; +use crate::{Id, Key, Listener, MachineProblem, ProjectFileProblem}; /// Every failure the core reports. Clients match on these cases; they are /// never flattened into strings inside the core. @@ -119,6 +119,21 @@ pub enum LocalhostError { command: String, output: String, }, + + // Machines + /// Another Machine (by name) can't be reached over SSH, or the + /// connection to it was lost. + MachineUnavailable { + machine: String, + problem: MachineProblem, + }, + /// The Port can't be forwarded from another Machine: SSH's reason. + ForwardFailed { + port: u16, + reason: String, + }, + /// The other end runs an older localhost, which can't do this. + Unsupported, } impl LocalhostError { @@ -193,6 +208,9 @@ impl LocalhostError { format!("`{command}` failed: {output}. Try again, or set up the server by hand.") } CommandPathTaken(path) => format!("{path} already exists and isn’t localhost’s. Remove it first, or leave it."), + MachineUnavailable { machine, problem } => problem.message(machine), + ForwardFailed { port, reason } => format!("Port {port} couldn’t be forwarded: {reason}."), + Unsupported => "The other Machine runs an older localhost that can’t do this. Update localhost there.".into(), InvalidProjectFile { problem, field, line } => { let line = line.map(|line| format!("Line {line}")); let field = (!field.is_empty()).then(|| field.join(" › ")); diff --git a/core/localhost-core/src/lib.rs b/core/localhost-core/src/lib.rs index a2280ce..6294085 100644 --- a/core/localhost-core/src/lib.rs +++ b/core/localhost-core/src/lib.rs @@ -23,6 +23,7 @@ mod library; mod listener; mod local_url; mod logs; +pub mod machine; mod model; mod origin; mod output; @@ -59,6 +60,7 @@ pub use library::{KeyClash, ProfileChanges, ProjectChanges, ProjectLibrary, Proj pub use listener::{Listener, ListenerKind, ListenerOwner, PortWarning, port_warnings}; pub use local_url::LocalUrl; pub use logs::{LogBuffer, LogStore}; +pub use machine::{Machine, MachineProblem}; pub use model::{ EnvironmentProfile, ExecutionTarget, NewService, Project, Service, VariableInput, VariableLayer, VariableValue, Variables, service_label, suggested_project_key, diff --git a/core/localhost-core/src/machine/connection.rs b/core/localhost-core/src/machine/connection.rs new file mode 100644 index 0000000..6759b5a --- /dev/null +++ b/core/localhost-core/src/machine/connection.rs @@ -0,0 +1,346 @@ +//! The viewing app's side of the Machine protocol: another Machine's +//! Projects, statuses, controls and logs, over any byte stream. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, oneshot, watch}; + +use super::MachineProblem; +use super::protocol::{MachineCall, MachineHello, MachineMessage, MachineRequest}; +use crate::{Id, LocalhostError, OutputLine, Project, ServiceEvent, ServiceOutputLine, ServiceStatus}; + +/// How often the app tells the other end it is still there, well within +/// `relay::SILENCE_TIMEOUT`, and how long an answer may take before the +/// connection counts as lost: a laptop that slept, a network that changed. +pub const PING_INTERVAL: Duration = Duration::from_secs(10); +pub const PING_TIMEOUT: Duration = Duration::from_secs(20); + +/// A connection to another Machine. Cheap to clone; every clone is the same +/// connection. Once it is lost, calls fail with `MachineUnavailable`, the +/// streams end, and `closed` returns. Must be used inside a tokio runtime. +#[derive(Clone)] +pub struct MachineConnection { + inner: Arc, +} + +struct Inner { + machine: String, + hello: MachineHello, + outgoing: mpsc::UnboundedSender, + /// Ends the writer, so the other end sees its input close. + stop_writing: Arc, + next_id: AtomicU64, + state: Mutex, + closed: watch::Sender, +} + +#[derive(Default)] +struct State { + is_connected: bool, + pending: HashMap>>, + projects: Vec>>, + events: Vec>, + run_logs: HashMap>, + services_logs: HashMap>, +} + +impl MachineConnection { + /// Connects over `reader` and `writer` to the Machine called `machine`: + /// reads its hello, then pings it every `PING_INTERVAL`. + pub async fn connect(machine: &str, reader: R, mut writer: W) -> Result + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let unavailable = |problem| LocalhostError::MachineUnavailable { machine: machine.to_string(), problem }; + let mut lines = BufReader::new(reader).lines(); + let hello = loop { + match lines.next_line().await { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(MachineMessage::Hello(hello)) => break hello, + // A login banner or a profile's echo on standard output. + _ => continue, + }, + Ok(None) | Err(_) => return Err(unavailable(MachineProblem::Lost)), + } + }; + + let (outgoing, mut queue) = mpsc::unbounded_channel::(); + let stop_writing = Arc::new(tokio::sync::Notify::new()); + let stop = stop_writing.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + line = queue.recv() => { + let Some(line) = line else { break }; + if writer.write_all(line.as_bytes()).await.is_err() || writer.flush().await.is_err() { + return; + } + } + _ = stop.notified() => break, + } + } + let _ = writer.shutdown().await; + }); + let (closed, _) = watch::channel(false); + let connection = MachineConnection { + inner: Arc::new(Inner { + machine: machine.to_string(), + hello, + outgoing, + stop_writing, + next_id: AtomicU64::new(1), + state: Mutex::new(State { is_connected: true, ..State::default() }), + closed, + }), + }; + + let weak = Arc::downgrade(&connection.inner); + tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + let Some(inner) = weak.upgrade() else { return }; + MachineConnection { inner }.receive(&line); + } + if let Some(inner) = weak.upgrade() { + MachineConnection { inner }.disconnect(); + } + }); + + let weak = Arc::downgrade(&connection.inner); + tokio::spawn(async move { + loop { + tokio::time::sleep(PING_INTERVAL).await; + let Some(inner) = weak.upgrade() else { return }; + let connection = MachineConnection { inner }; + if !connection.is_connected() { + return; + } + let ping = connection.call::<()>(MachineRequest::Ping); + if !matches!(tokio::time::timeout(PING_TIMEOUT, ping).await, Ok(Ok(()))) { + connection.disconnect(); + return; + } + } + }); + Ok(connection) + } + + pub fn hello(&self) -> &MachineHello { + &self.inner.hello + } + + pub fn is_connected(&self) -> bool { + self.state().is_connected + } + + /// Returns once the connection is lost. + pub async fn closed(&self) { + let mut closed = self.inner.closed.subscribe(); + let _ = closed.wait_for(|closed| *closed).await; + } + + /// Ends the connection, as if it were lost. + pub fn close(&self) { + self.disconnect(); + } + + /// The other Machine's Projects, then every Project after each change. + pub async fn projects(&self) -> Result<(Vec, mpsc::UnboundedReceiver>), LocalhostError> { + let (sender, receiver) = mpsc::unbounded_channel(); + self.state().projects.push(sender); + Ok((self.call(MachineRequest::Projects).await?, receiver)) + } + + /// Every status there, then every Service event after them. + pub async fn subscribe( + &self, + ) -> Result<(HashMap, mpsc::UnboundedReceiver), LocalhostError> { + let (sender, receiver) = mpsc::unbounded_channel(); + self.state().events.push(sender); + Ok((self.call(MachineRequest::Subscribe).await?, receiver)) + } + + pub async fn start(&self, service_id: Id, on_port: Option) -> Result { + self.call(MachineRequest::Start { service_id, on_port }).await + } + + pub async fn stop(&self, service_id: Id) -> Result { + self.call(MachineRequest::Stop { service_id }).await + } + + pub async fn restart(&self, service_id: Id) -> Result { + self.call(MachineRequest::Restart { service_id }).await + } + + pub async fn start_all(&self, project_id: Id) -> Result<(), LocalhostError> { + self.call(MachineRequest::StartAll { project_id }).await + } + + pub async fn stop_all(&self, project_id: Id) -> Result<(), LocalhostError> { + self.call(MachineRequest::StopAll { project_id }).await + } + + /// A Run's buffered lines, then every line after them until it ends. + pub async fn subscribe_to_run( + &self, + run_id: Id, + ) -> Result<(Vec, mpsc::UnboundedReceiver), LocalhostError> { + let stream = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = mpsc::unbounded_channel(); + self.state().run_logs.insert(stream, sender); + let lines = self.call(MachineRequest::OpenRunLog { stream, run_id }).await.inspect_err(|_| { + self.state().run_logs.remove(&stream); + })?; + self.close_when_dropped(stream); + Ok((lines, receiver)) + } + + /// Several Services' lines, buffered then live, as one stream. + pub async fn subscribe_to_services( + &self, + service_ids: HashSet, + ) -> Result<(Vec, mpsc::UnboundedReceiver), LocalhostError> { + let stream = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = mpsc::unbounded_channel(); + self.state().services_logs.insert(stream, sender); + let lines = self.call(MachineRequest::OpenServicesLog { stream, service_ids }).await.inspect_err(|_| { + self.state().services_logs.remove(&stream); + })?; + self.close_when_dropped(stream); + Ok((lines, receiver)) + } + + // MARK: Plumbing + + fn state(&self) -> MutexGuard<'_, State> { + self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn unavailable(&self, problem: MachineProblem) -> LocalhostError { + LocalhostError::MachineUnavailable { machine: self.inner.machine.clone(), problem } + } + + async fn call(&self, request: MachineRequest) -> Result { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + { + let mut state = self.state(); + if !state.is_connected { + return Err(self.unavailable(MachineProblem::Lost)); + } + state.pending.insert(id, sender); + } + let mut line = serde_json::to_string(&MachineCall { id, request }).expect("a request always encodes"); + line.push('\n'); + if self.inner.outgoing.send(line).is_err() { + self.state().pending.remove(&id); + return Err(self.unavailable(MachineProblem::Lost)); + } + let value = receiver.await.map_err(|_| self.unavailable(MachineProblem::Lost))??; + serde_json::from_value(value).map_err(|_| LocalhostError::Unsupported) + } + + fn receive(&self, line: &str) { + let message = match serde_json::from_str::(line) { + Ok(message) => message, + Err(error) => { + // A reply this version can't read, e.g. an error added later: + // its call fails rather than waiting forever. + if let Some(id) = serde_json::from_str::(line) + .ok() + .and_then(|value| value.get("id").and_then(serde_json::Value::as_u64)) + && let Some(pending) = self.state().pending.remove(&id) + { + let _ = pending.send(Err(LocalhostError::Unsupported)); + } + eprintln!("localhost: ignoring a message from {} it can't read: {error}", self.inner.machine); + return; + } + }; + let mut state = self.state(); + match message { + MachineMessage::Hello(_) | MachineMessage::Unknown => {} + MachineMessage::Reply { id, result } => { + if let Some(pending) = state.pending.remove(&id) { + let _ = pending.send(result); + } + } + MachineMessage::Projects { projects } => { + state.projects.retain(|subscriber| subscriber.send(projects.clone()).is_ok()); + } + MachineMessage::Event { event } => { + state.events.retain(|subscriber| subscriber.send(event.clone()).is_ok()); + } + MachineMessage::RunLog { stream, lines, is_finished } => { + if let Some(sender) = state.run_logs.get(&stream) { + let is_open = lines.into_iter().all(|line| sender.send(line).is_ok()); + if is_finished || !is_open { + state.run_logs.remove(&stream); + } + } + } + MachineMessage::ServicesLog { stream, lines } => { + if let Some(sender) = state.services_logs.get(&stream) + && !lines.into_iter().all(|line| sender.send(line).is_ok()) + { + state.services_logs.remove(&stream); + } + } + } + } + + /// Fails every pending call and ends every stream. + fn disconnect(&self) { + let pending = { + let mut state = self.state(); + if !state.is_connected { + return; + } + let pending: Vec<_> = state.pending.drain().map(|(_, sender)| sender).collect(); + *state = State::default(); + pending + }; + for sender in pending { + let _ = sender.send(Err(self.unavailable(MachineProblem::Lost))); + } + self.inner.stop_writing.notify_one(); + self.inner.closed.send_replace(true); + } + + /// Tells the other end to stop a log stream once its receiver is dropped. + fn close_when_dropped(&self, stream: u64) { + let weak = Arc::downgrade(&self.inner); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + let Some(inner) = weak.upgrade() else { return }; + let connection = MachineConnection { inner }; + let is_gone = { + let state = connection.state(); + !state.is_connected + || match (state.run_logs.get(&stream), state.services_logs.get(&stream)) { + (Some(sender), _) => sender.is_closed(), + (_, Some(sender)) => sender.is_closed(), + (None, None) => true, + } + }; + if is_gone { + { + let mut state = connection.state(); + state.run_logs.remove(&stream); + state.services_logs.remove(&stream); + } + if connection.is_connected() { + let _ = connection.call::<()>(MachineRequest::CloseLog { stream }).await; + } + return; + } + } + }); + } +} diff --git a/core/localhost-core/src/machine/mod.rs b/core/localhost-core/src/machine/mod.rs new file mode 100644 index 0000000..ff94a73 --- /dev/null +++ b/core/localhost-core/src/machine/mod.rs @@ -0,0 +1,168 @@ +//! Machines: other computers whose localhost this app shows, reached over +//! SSH as the user already reaches them (`~/.ssh/config`, keys, the agent, +//! Tailscale or any other network). Each Machine keeps its own Project +//! Configuration, Secrets, shared core and Runs; this app only views and +//! controls them. +//! +//! The app runs `ssh localhost remote --stdio` (`ssh`), which +//! serves the other Machine's Projects, statuses and logs over the Machine +//! protocol (`protocol`, `relay`), and talks to it through a +//! [`MachineConnection`]. That SSH connection is also a ControlMaster, so a +//! Port of the other Machine is forwarded to this one over it, and every +//! Forward ends with it. Nothing listens on the network that didn't before: +//! the other Machine's shared core stays on its own user's socket. + +mod connection; +pub(crate) mod protocol; +pub mod relay; +mod ssh; + +use serde::{Deserialize, Serialize}; + +pub use connection::MachineConnection; +pub use protocol::{MACHINE_PROTOCOL_VERSION, MachineHello}; +pub use ssh::{Forward, SshLink}; + +/// Why another Machine can't be reached, as the app explains it. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub enum MachineProblem { + /// There is no `ssh` command on this computer. + SshNotInstalled, + /// Its host key isn't in `known_hosts` yet. localhost never trusts one + /// on the user's behalf. + HostKeyNotTrusted, + /// Its host key differs from the one in `known_hosts`. + HostKeyChanged, + /// SSH would have to ask for a password or passphrase, and can't. + SignInNeedsPrompt, + HostNotFound, + /// The network refused or timed out: SSH's words. + Unreachable(String), + /// No `localhost` command there, or not on the `PATH` SSH gives. + NotInstalled, + /// Its localhost has no `localhost remote` (the version it reported, if any). + TooOld(Option), + /// It was connected and the connection ended. + Lost, + /// Anything else SSH said. + Other(String), +} + +impl MachineProblem { + /// What went wrong reaching `machine`, and what to do next. + pub fn message(&self, machine: &str) -> String { + use MachineProblem::*; + match self { + SshNotInstalled => "There is no ssh command on this computer. Install OpenSSH, then try again.".into(), + HostKeyNotTrusted => format!( + "{machine}’s host key isn’t trusted yet. Connect once from a terminal with `ssh {machine}`, then try again." + ), + HostKeyChanged => format!( + "{machine}’s host key changed. If you expected that, update ~/.ssh/known_hosts from a terminal; otherwise don’t connect." + ), + SignInNeedsPrompt => format!( + "SSH can’t sign in to {machine} without asking for a password or passphrase. Add your key to the SSH agent with ssh-add, then try again." + ), + HostNotFound => { + format!("{machine} wasn’t found. Check its name in ~/.ssh/config, and that you’re on its network.") + } + Unreachable(reason) => format!("localhost can’t reach {machine}: {reason}."), + NotInstalled => format!( + "localhost isn’t installed on {machine}, or isn’t on its PATH. Install it there, then try again." + ), + TooOld(Some(version)) => { + format!( + "{machine} runs localhost {version}, which can’t be viewed from another Machine. Update localhost there." + ) + } + TooOld(None) => { + format!("{machine} runs a localhost that can’t be viewed from another Machine. Update localhost there.") + } + Lost => format!("The connection to {machine} was lost."), + Other(said) => format!("SSH couldn’t connect to {machine}: {said}"), + } + } + + /// What SSH's standard error (`said`) and exit `code` mean, after the + /// connection ended before the other end said hello. + pub(crate) fn from_ssh(said: &str, code: Option) -> MachineProblem { + let lower = said.to_lowercase(); + let last_line = said.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("").trim(); + if lower.contains("remote host identification has changed") { + MachineProblem::HostKeyChanged + } else if lower.contains("host key verification failed") { + MachineProblem::HostKeyNotTrusted + } else if lower.contains("permission denied") { + MachineProblem::SignInNeedsPrompt + } else if lower.contains("could not resolve hostname") { + MachineProblem::HostNotFound + } else if lower.contains("connection refused") + || lower.contains("timed out") + || lower.contains("no route to host") + || lower.contains("network is unreachable") + { + MachineProblem::Unreachable(last_line.trim_start_matches("ssh: ").to_string()) + } else if code == Some(127) + || lower.contains("localhost: command not found") + || lower.contains("localhost: not found") + { + MachineProblem::NotInstalled + } else if lower.contains("unknown command `remote`") { + MachineProblem::TooOld(None) + } else if last_line.is_empty() { + MachineProblem::Lost + } else { + MachineProblem::Other(last_line.to_string()) + } + } +} + +/// Another Machine, as this app remembers it. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct Machine { + pub id: crate::Id, + /// What the app calls it: by default its destination. + pub name: String, + /// What `ssh` connects to: a `Host` of `~/.ssh/config`, or `user@host`. + pub destination: String, +} + +#[cfg(test)] +mod tests { + use super::MachineProblem::{self, *}; + + #[test] + fn reads_what_ssh_says() { + let problem = |said: &str, code| MachineProblem::from_ssh(said, code); + assert_eq!(problem("Host key verification failed.\r\n", Some(255)), HostKeyNotTrusted); + assert_eq!( + problem( + "@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed.", + Some(255) + ), + HostKeyChanged + ); + assert_eq!(problem("marcel@devbox: Permission denied (publickey,password).", Some(255)), SignInNeedsPrompt); + assert_eq!( + problem("ssh: Could not resolve hostname devbx: Name or service not known", Some(255)), + HostNotFound + ); + assert_eq!( + problem("ssh: connect to host 10.0.0.9 port 22: Connection refused", Some(255)), + Unreachable("connect to host 10.0.0.9 port 22: Connection refused".into()) + ); + assert_eq!(problem("bash: line 1: localhost: command not found", Some(127)), NotInstalled); + assert_eq!(problem("Unknown command `remote`.\nRun `localhost --help` for usage.", Some(2)), TooOld(None)); + assert_eq!( + problem("kex_exchange_identification: read: Connection reset by peer", Some(255)), + Other("kex_exchange_identification: read: Connection reset by peer".into()) + ); + assert_eq!(problem("", Some(255)), Lost); + } +} diff --git a/core/localhost-core/src/machine/protocol.rs b/core/localhost-core/src/machine/protocol.rs new file mode 100644 index 0000000..2549f79 --- /dev/null +++ b/core/localhost-core/src/machine/protocol.rs @@ -0,0 +1,137 @@ +//! What the app of one Machine and `localhost remote --stdio` on another +//! say to each other: JSON, one message per line, as the shared core's +//! protocol, but versioned on its own. The two ends are two computers, so +//! they often run different versions of localhost. This protocol only ever +//! grows: a field added later has a default, a request one end doesn't know +//! is answered with `Unsupported`, and a message it doesn't know is skipped. +//! `MACHINE_PROTOCOL_VERSION` says what the other end may ask for. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::{Id, LocalhostError, OutputLine, Project, ServiceEvent, ServiceOutputLine}; + +/// Grows by one whenever a request or message is added. Never reset. +pub const MACHINE_PROTOCOL_VERSION: u32 = 1; + +/// The first line `localhost remote --stdio` writes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineHello { + pub machine_protocol: u32, + /// The version of localhost it was built from. + pub version: String, + /// The Machine's own name for itself, e.g. its host name. + #[serde(default)] + pub host_name: String, +} + +impl MachineHello { + pub fn current() -> MachineHello { + MachineHello { + machine_protocol: MACHINE_PROTOCOL_VERSION, + version: crate::VERSION.to_string(), + host_name: host_name(), + } + } +} + +#[cfg(unix)] +fn host_name() -> String { + std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .or_else(|| { + let output = std::process::Command::new("hostname").output().ok()?; + String::from_utf8(output.stdout).ok() + }) + .map(|name| name.trim().to_string()) + .unwrap_or_default() +} + +#[cfg(windows)] +fn host_name() -> String { + std::env::var("COMPUTERNAME").unwrap_or_default() +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct MachineCall { + pub id: u64, + #[serde(flatten)] + pub request: MachineRequest, +} + +/// What the viewing app asks. Every change is made as `Origin::Machine`: +/// the other end decides the Origin, not the app. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "request", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub(crate) enum MachineRequest { + /// Replies with nothing: the connection is alive. + Ping, + /// Replies with the Projects; then `Projects` follows every change. + Projects, + /// Replies with every status; then every Service event follows. + Subscribe, + Start { + service_id: Id, + on_port: Option, + }, + Stop { + service_id: Id, + }, + Restart { + service_id: Id, + }, + StartAll { + project_id: Id, + }, + StopAll { + project_id: Id, + }, + /// Replies with the Run's buffered lines; the rest arrive as `RunLog`. + OpenRunLog { + stream: u64, + run_id: Id, + }, + /// Replies with the buffered lines; the rest arrive as `ServicesLog`. + OpenServicesLog { + stream: u64, + service_ids: HashSet, + }, + CloseLog { + stream: u64, + }, + /// A request from a newer app. + #[serde(other)] + Unsupported, +} + +/// What `localhost remote --stdio` writes. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub(crate) enum MachineMessage { + Hello(MachineHello), + Reply { + id: u64, + result: Result, + }, + /// Every Project, after a change. + Projects { + projects: Vec, + }, + Event { + event: ServiceEvent, + }, + RunLog { + stream: u64, + lines: Vec, + is_finished: bool, + }, + ServicesLog { + stream: u64, + lines: Vec, + }, + /// A message from a newer Machine. + #[serde(other)] + Unknown, +} diff --git a/core/localhost-core/src/machine/relay.rs b/core/localhost-core/src/machine/relay.rs new file mode 100644 index 0000000..1a06b5c --- /dev/null +++ b/core/localhost-core/src/machine/relay.rs @@ -0,0 +1,202 @@ +//! The other Machine's side: `localhost remote --stdio`, which an app runs +//! over SSH. It is one more client of this Machine's shared core, as +//! `localhost mcp` is, with this Machine's own `ProjectLibrary` and secret +//! store: the app sees the Projects saved here, and a start reads the +//! Secret values here. No Secret value is ever written to the app. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::Serialize; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use super::protocol::{MachineCall, MachineHello, MachineMessage, MachineRequest}; +use crate::remote::forward_batches; +use crate::{LocalhostError, Origin, ProjectLibrary, Targets}; + +/// How long the app may stay silent before the connection counts as lost. +/// The app pings far more often; a laptop that went to sleep never closes +/// its SSH connection, and its Runs here would otherwise be held forever. +pub const SILENCE_TIMEOUT: Duration = Duration::from_secs(45); + +const ORIGIN: Origin = Origin::Machine; + +/// Answers the app on `input` and `output` until it closes or goes silent +/// for `silence_timeout`. `targets` reaches this Machine's shared core as +/// `Client::Machine`, so closing it lets the core stop what only this +/// connection started. +pub async fn serve(input: R, output: W, library: Arc, targets: Targets, silence_timeout: Duration) +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let (sender, receiver) = mpsc::unbounded_channel(); + let writer = tokio::spawn(write_messages(receiver, output)); + let _ = sender.send(MachineMessage::Hello(MachineHello::current())); + let relay = Arc::new(Relay { library, targets, output: sender, streams: Mutex::new(HashMap::new()) }); + + let mut lines = BufReader::new(input).lines(); + let mut forwarders = Vec::new(); + loop { + let line = match tokio::time::timeout(silence_timeout, lines.next_line()).await { + Ok(Ok(Some(line))) => line, + Ok(_) => break, + Err(_) => { + eprintln!("localhost: the app was silent for {silence_timeout:?}, disconnecting"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let call = match serde_json::from_str::(&line) { + Ok(call) => call, + Err(error) => { + eprintln!("localhost: ignoring a request it can't read: {error}"); + continue; + } + }; + match call.request { + MachineRequest::Projects => forwarders.push(relay.forward_projects(call.id)), + MachineRequest::Subscribe => forwarders.push(relay.forward_events(call.id)), + request => { + let relay = relay.clone(); + tokio::spawn(async move { + let result = relay.handle(request).await; + let _ = relay.output.send(MachineMessage::Reply { id: call.id, result }); + }); + } + } + } + + for forwarder in forwarders { + forwarder.abort(); + } + for (_, stream) in relay.streams.lock().unwrap().drain() { + stream.abort(); + } + drop(relay); + let _ = tokio::time::timeout(Duration::from_secs(1), writer).await; +} + +struct Relay { + library: Arc, + targets: Targets, + output: mpsc::UnboundedSender, + streams: Mutex>>, +} + +fn value(value: impl Serialize) -> Result { + Ok(serde_json::to_value(value).expect("a reply always encodes")) +} + +fn done() -> Result { + Ok(serde_json::Value::Null) +} + +impl Relay { + async fn handle(self: &Arc, request: MachineRequest) -> Result { + let targets = &self.targets; + match request { + MachineRequest::Projects | MachineRequest::Subscribe => unreachable!("answered by forwarders"), + MachineRequest::Ping => done(), + MachineRequest::Start { service_id, on_port } => value(targets.start(service_id, on_port, ORIGIN).await?), + MachineRequest::Stop { service_id } => value(targets.stop(service_id, ORIGIN).await?), + MachineRequest::Restart { service_id } => value(targets.restart(service_id, ORIGIN).await?), + MachineRequest::StartAll { project_id } => { + targets.start_all(project_id, ORIGIN).await?; + done() + } + MachineRequest::StopAll { project_id } => { + targets.stop_all(project_id, ORIGIN).await?; + done() + } + MachineRequest::OpenRunLog { stream, run_id } => { + let (lines, receiver) = targets.subscribe_to_run(run_id).await?; + let output = self.output.clone(); + self.open_stream( + stream, + forward_batches(receiver, move |lines, is_finished| { + output.send(MachineMessage::RunLog { stream, lines, is_finished }).is_ok() + }), + ); + value(lines) + } + MachineRequest::OpenServicesLog { stream, service_ids } => { + let (lines, receiver) = targets.subscribe_to_services(service_ids).await?; + let output = self.output.clone(); + self.open_stream( + stream, + forward_batches(receiver, move |lines, _| { + output.send(MachineMessage::ServicesLog { stream, lines }).is_ok() + }), + ); + value(lines) + } + MachineRequest::CloseLog { stream } => { + if let Some(stream) = self.streams.lock().unwrap().remove(&stream) { + stream.abort(); + } + done() + } + MachineRequest::Unsupported => Err(LocalhostError::Unsupported), + } + } + + /// Replies with the Projects, then sends them again after every change: + /// an edit here, or one another client of the shared core announced. + fn forward_projects(self: &Arc, id: u64) -> JoinHandle<()> { + let (projects, mut events) = self.library.subscribe(); + let _ = self.output.send(MachineMessage::Reply { id, result: value(projects) }); + let relay = self.clone(); + tokio::spawn(async move { + while events.recv().await.is_some() { + while events.try_recv().is_ok() {} + let projects = relay.library.projects(); + if relay.output.send(MachineMessage::Projects { projects }).is_err() { + return; + } + } + }) + } + + /// Replies with every status, then sends every event after them. + fn forward_events(self: &Arc, id: u64) -> JoinHandle<()> { + let (statuses, mut events) = self.targets.subscribe(); + let _ = self.output.send(MachineMessage::Reply { id, result: value(statuses) }); + let output = self.output.clone(); + tokio::spawn(async move { + while let Some(event) = events.recv().await { + if output.send(MachineMessage::Event { event }).is_err() { + return; + } + } + }) + } + + fn open_stream(self: &Arc, stream: u64, forward: impl Future + Send + 'static) { + let relay = Arc::downgrade(self); + let task = tokio::spawn(async move { + forward.await; + if let Some(relay) = relay.upgrade() { + relay.streams.lock().unwrap().remove(&stream); + } + }); + if let Some(previous) = self.streams.lock().unwrap().insert(stream, task) { + previous.abort(); + } + } +} + +async fn write_messages(mut receiver: mpsc::UnboundedReceiver, mut output: W) { + while let Some(message) = receiver.recv().await { + let mut line = serde_json::to_vec(&message).expect("a message always encodes"); + line.push(b'\n'); + if output.write_all(&line).await.is_err() || output.flush().await.is_err() { + return; + } + } +} diff --git a/core/localhost-core/src/machine/ssh.rs b/core/localhost-core/src/machine/ssh.rs new file mode 100644 index 0000000..48347c7 --- /dev/null +++ b/core/localhost-core/src/machine/ssh.rs @@ -0,0 +1,257 @@ +//! Reaching another Machine with the system's `ssh`, so `~/.ssh/config`, +//! keys, the agent, `ProxyJump` and whatever network the user has all +//! apply, and localhost adds no sign-in of its own. +//! +//! One `ssh` process per Machine runs `localhost remote --stdio` there and +//! is also a ControlMaster: Forwards are added to it with `ssh -O forward`, +//! and end with it. Its socket is in a folder only this user can open, +//! since whoever reaches it reaches the other Machine. +//! +//! `ssh` never asks anything (`BatchMode`), and host keys are checked as +//! the user configured: an unknown one is a `MachineProblem`, never trusted +//! on the user's behalf. Forwards from the user's own configuration are +//! cleared, and only loopback addresses here are ever bound. + +use std::collections::HashSet; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; + +use super::{Machine, MachineConnection, MachineProblem}; +use crate::LocalhostError; + +/// What runs on the other Machine. +const REMOTE_COMMAND: &str = "localhost remote --stdio"; + +/// How many of SSH's last lines of standard error are kept, to explain a failure. +const KEPT_LINES: usize = 20; + +/// The options every `ssh` gets, besides the destination. +fn options() -> Vec { + ["BatchMode=yes", "ForwardAgent=no", "ForwardX11=no", "LogLevel=ERROR"] + .into_iter() + .flat_map(|option| ["-o".to_string(), option.to_string()]) + .collect() +} + +/// The master's own options besides. It clears the forwards of the user's +/// configuration, so only those localhost asks for are made; the control +/// commands mustn't, since that clears their own `-L` too. +fn master_options() -> Vec { + [ + "ServerAliveInterval=15", + "ServerAliveCountMax=3", + "ConnectTimeout=15", + "ClearAllForwardings=yes", + "ControlPersist=no", + ] + .into_iter() + .flat_map(|option| ["-o".to_string(), option.to_string()]) + .collect() +} + +/// A Port of another Machine, forwarded to a port of this one: the same, +/// unless that one was taken. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +pub struct Forward { + pub port: u16, + pub local_port: u16, +} + +/// The `ssh` process of one Machine, and its Forwards. Dropping it ends the +/// connection and every Forward. +pub struct SshLink { + machine: Machine, + ssh: PathBuf, + control_path: PathBuf, + child: Mutex>, + forwards: Mutex>, +} + +impl SshLink { + /// Connects to `machine` with the `ssh` at `ssh`, keeping its control + /// socket in `control_folder`. Fails with what SSH said. + pub async fn connect( + machine: Machine, + ssh: &Path, + control_folder: &Path, + ) -> Result<(Arc, MachineConnection), LocalhostError> { + let unavailable = |problem| LocalhostError::MachineUnavailable { machine: machine.name.clone(), problem }; + create_private_folder(control_folder).map_err(|error| unavailable(MachineProblem::Other(error.to_string())))?; + let control_path = control_folder.join(machine.id.to_string().split('-').next().unwrap_or("machine")); + let _ = std::fs::remove_file(&control_path); + + let mut command = Command::new(ssh); + command + .arg("-T") + .arg("-M") + .arg("-S") + .arg(&control_path) + .args(options()) + .args(master_options()) + .arg("--") + .arg(&machine.destination) + .arg(REMOTE_COMMAND) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command.spawn().map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => unavailable(MachineProblem::SshNotInstalled), + _ => unavailable(MachineProblem::Other(error.to_string())), + })?; + let (Some(input), Some(output), Some(errors)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) + else { + return Err(unavailable(MachineProblem::Other("its input and output aren’t available".into()))); + }; + let said = Arc::new(Mutex::new(Vec::::new())); + let kept = said.clone(); + let stderr = tokio::spawn(async move { + let mut lines = BufReader::new(errors).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let mut kept = kept.lock().unwrap(); + kept.push(line); + if kept.len() > KEPT_LINES { + kept.remove(0); + } + } + }); + + match MachineConnection::connect(&machine.name, output, input).await { + Ok(connection) => { + let link = Arc::new(SshLink { + machine, + ssh: ssh.to_path_buf(), + control_path, + child: Mutex::new(Some(child)), + forwards: Mutex::new(HashSet::new()), + }); + // The process ends when the connection does, and the reverse. + let watched = Arc::downgrade(&link); + let closed = connection.clone(); + tokio::spawn(async move { + closed.closed().await; + if let Some(link) = watched.upgrade() { + link.end(); + } + }); + Ok((link, connection)) + } + Err(_) => { + let code = child.wait().await.ok().and_then(|status| status.code()); + let _ = stderr.await; + let said = said.lock().unwrap().join("\n"); + Err(unavailable(MachineProblem::from_ssh(&said, code))) + } + } + } + + pub fn machine(&self) -> &Machine { + &self.machine + } + + /// Forwards `port` of the other Machine to the same port here. The + /// same number, because `*.localhost` names, OAuth callbacks and + /// cookies expect it. + pub async fn forward(&self, port: u16) -> Result { + self.forward_to(port, port).await + } + + /// Forwards `port` of the other Machine to `local_port` on this + /// computer's loopback addresses. The other end is `localhost` as that + /// Machine resolves it, so a Service listening on either of its loopback + /// addresses, or on every address, is reached. + pub async fn forward_to(&self, port: u16, local_port: u16) -> Result { + let forward = Forward { port, local_port }; + if self.forwards.lock().unwrap().contains(&forward) { + return Ok(forward); + } + // `ssh` would only report a taken port in the master's output, and + // on macOS could bind beside a Service listening on every address, + // hiding it from this computer's browser. + if !is_free(local_port) || crate::port_probe::is_accepting(local_port).await { + return Err(LocalhostError::PortConflict { port: local_port, owner: None }); + } + self.control("forward", &format!("127.0.0.1:{local_port}:localhost:{port}"), port).await?; + // Browsers may try `::1` first for `localhost`. Not every computer has it. + if TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, local_port))).is_ok() { + let _ = self.control("forward", &format!("[::1]:{local_port}:localhost:{port}"), port).await; + } + self.forwards.lock().unwrap().insert(forward); + Ok(forward) + } + + /// Ends `forward`, if it is one of this connection's. + pub async fn cancel_forward(&self, forward: Forward) { + if self.forwards.lock().unwrap().remove(&forward) { + let Forward { port, local_port } = forward; + let _ = self.control("cancel", &format!("127.0.0.1:{local_port}:localhost:{port}"), port).await; + let _ = self.control("cancel", &format!("[::1]:{local_port}:localhost:{port}"), port).await; + } + } + + pub fn forwards(&self) -> Vec { + let mut forwards: Vec = self.forwards.lock().unwrap().iter().copied().collect(); + forwards.sort_unstable_by_key(|forward| (forward.port, forward.local_port)); + forwards + } + + /// Ends the connection, and with it every Forward. + pub fn end(&self) { + if let Some(mut child) = self.child.lock().unwrap().take() { + let _ = child.start_kill(); + } + self.forwards.lock().unwrap().clear(); + let _ = std::fs::remove_file(&self.control_path); + } + + /// Asks the master to `operation` (`forward`, `cancel`) the local forward `spec`. + async fn control(&self, operation: &str, spec: &str, port: u16) -> Result<(), LocalhostError> { + let output = Command::new(&self.ssh) + .arg("-S") + .arg(&self.control_path) + .arg("-O") + .arg(operation) + .arg("-L") + .arg(spec) + .args(options()) + .arg("--") + .arg(&self.machine.destination) + .stdin(Stdio::null()) + .output() + .await + .map_err(|error| LocalhostError::ForwardFailed { port, reason: error.to_string() })?; + if output.status.success() { + return Ok(()); + } + let said = String::from_utf8_lossy(&output.stderr); + let reason = said.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("ssh failed").trim(); + Err(LocalhostError::ForwardFailed { port, reason: reason.to_string() }) + } +} + +impl Drop for SshLink { + fn drop(&mut self) { + self.end(); + } +} + +/// Whether nothing on this computer listens on `port` of IPv4's loopback. +fn is_free(port: u16) -> bool { + TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).is_ok() +} + +fn create_private_folder(folder: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(folder)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(folder, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} diff --git a/core/localhost-core/tests/machines.rs b/core/localhost-core/tests/machines.rs new file mode 100644 index 0000000..2726c7d --- /dev/null +++ b/core/localhost-core/tests/machines.rs @@ -0,0 +1,159 @@ +//! Machines: the app of one Machine viewing and controlling another's +//! Projects through `machine::relay` (what `localhost remote --stdio` +//! serves over SSH), here over an in-memory pipe, with real processes. +#![cfg(unix)] + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::{Fixture, PATIENCE, key, test_options}; +use localhost_core::machine::{MachineConnection, relay}; +use localhost_core::{ + Link, LocalhostError, MachineProblem, NewService, Origin, Project, ProjectLibrary, ServiceEvent, ServiceState, + Targets, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::task::JoinHandle; + +/// The other Machine: its library and shared core (here in this process), +/// and the relay serving them to one app. +struct Other { + _fixture: Fixture, + library: Arc, + project: Project, + relay: JoinHandle<()>, +} + +fn other_machine() -> (Fixture, Arc, Targets, Project) { + let fixture = Fixture::new(); + let library = Arc::new(fixture.library()); + let folder = fixture.path().join("app"); + std::fs::create_dir_all(&folder).unwrap(); + let project = library.add_project("App", key("app"), folder.to_str().unwrap(), vec![], Origin::Agent).unwrap(); + let targets = Targets::open(library.clone(), fixture.path(), test_options(), Box::new(|_| Link::InProcess)); + (fixture, library, targets, project) +} + +async fn connect(silence: Duration) -> (Other, MachineConnection) { + let (fixture, library, targets, project) = other_machine(); + let (app_end, relay_end) = tokio::io::duplex(256 * 1024); + let (input, output) = tokio::io::split(relay_end); + let relay = tokio::spawn(relay::serve(input, output, library.clone(), targets, silence)); + let (reader, writer) = tokio::io::split(app_end); + let connection = MachineConnection::connect("devbox", reader, writer).await.unwrap(); + (Other { _fixture: fixture, library, project, relay }, connection) +} + +fn add(other: &Other, name: &str, command: &str) -> localhost_core::Service { + let service = NewService::new(name, key(name), command); + other.library.add_service(other.project.id, service, vec![], vec![], Origin::Agent).unwrap() +} + +async fn until(receiver: &mut UnboundedReceiver, condition: impl Fn(&T) -> bool) -> T { + tokio::time::timeout(PATIENCE, async { + loop { + let item = receiver.recv().await.expect("the stream ended"); + if condition(&item) { + return item; + } + } + }) + .await + .expect("timed out") +} + +fn reached(state: ServiceState) -> impl Fn(&ServiceEvent) -> bool { + move |event| matches!(event, ServiceEvent::ServiceStateChanged(status) if status.state == state) +} + +#[tokio::test(flavor = "multi_thread")] +async fn shows_the_other_machines_projects_as_they_change() { + let (other, connection) = connect(relay::SILENCE_TIMEOUT).await; + assert_eq!(connection.hello().machine_protocol, localhost_core::machine::MACHINE_PROTOCOL_VERSION); + + let (projects, mut changes) = connection.projects().await.unwrap(); + assert_eq!(projects.iter().map(|project| project.key.as_str()).collect::>(), ["app"]); + + // An agent there adds a Service. + add(&other, "web", "exec sleep 60"); + let projects = until(&mut changes, |projects| projects[0].services.len() == 1).await; + assert_eq!(projects[0].services[0].key.as_str(), "web"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn controls_services_there_as_another_machine() { + let (other, connection) = connect(relay::SILENCE_TIMEOUT).await; + let web = add(&other, "web", "echo hello from devbox; exec sleep 60"); + let (statuses, mut events) = connection.subscribe().await.unwrap(); + assert!(statuses.values().all(|status| !status.state.is_active())); + + let status = connection.start(web.id, None).await.unwrap(); + assert_eq!(status.run.as_ref().unwrap().origin, Origin::Machine); + until(&mut events, reached(ServiceState::Running)).await; + + let run_id = status.run.unwrap().id; + let (lines, mut more) = connection.subscribe_to_run(run_id).await.unwrap(); + if !lines.iter().any(|line| line.text == "hello from devbox") { + until(&mut more, |line| line.text == "hello from devbox").await; + } + + let status = connection.stop(web.id).await.unwrap(); + assert_eq!(status.state, ServiceState::Stopped); +} + +#[tokio::test(flavor = "multi_thread")] +async fn calls_fail_once_the_connection_is_lost() { + let (other, connection) = connect(relay::SILENCE_TIMEOUT).await; + let web = add(&other, "web", "exec sleep 60"); + other.relay.abort(); + drop(other.relay); + tokio::time::timeout(PATIENCE, connection.closed()).await.expect("the loss went unnoticed"); + + let error = connection.start(web.id, None).await.unwrap_err(); + assert_eq!(error, LocalhostError::MachineUnavailable { machine: "devbox".into(), problem: MachineProblem::Lost }); + assert!(!connection.is_connected()); +} + +/// The raw protocol, as an app of another version speaks it. +async fn raw(silence: Duration) -> (Other, impl AsyncBufReadExt + Unpin, impl AsyncWriteExt + Unpin) { + let (fixture, library, targets, project) = other_machine(); + let (app_end, relay_end) = tokio::io::duplex(256 * 1024); + let (input, output) = tokio::io::split(relay_end); + let relay = tokio::spawn(relay::serve(input, output, library.clone(), targets, silence)); + let (reader, writer) = tokio::io::split(app_end); + (Other { _fixture: fixture, library, project, relay }, BufReader::new(reader), writer) +} + +#[tokio::test(flavor = "multi_thread")] +async fn answers_a_newer_apps_request_as_unsupported() { + let (_other, mut reader, mut writer) = raw(relay::SILENCE_TIMEOUT).await; + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert!(line.contains("\"kind\":\"hello\""), "{line}"); + + writer.write_all(b"{\"id\":7,\"request\":\"teleport\",\"to\":\"mars\"}\n").await.unwrap(); + line.clear(); + reader.read_line(&mut line).await.unwrap(); + let reply: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(reply["id"], 7); + assert_eq!(reply["result"]["Err"]["kind"], "unsupported"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn lets_go_of_an_app_that_went_silent() { + // A laptop that slept never closes its SSH connection. + let (other, mut reader, _writer) = raw(Duration::from_millis(300)).await; + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + tokio::time::timeout(PATIENCE, other.relay).await.expect("still waiting for a silent app").unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ends_when_the_app_closes_the_connection() { + let (other, connection) = connect(relay::SILENCE_TIMEOUT).await; + connection.close(); + tokio::time::timeout(PATIENCE, other.relay).await.expect("the relay didn't end").unwrap(); +} From 64495091920ef12df9674b7335635c64d2b531de Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 20:28:33 -0300 Subject: [PATCH 03/27] Keep the list of other Machines in machines.json, and keep each connected, reconnecting after a loss --- core/localhost-cli/tests/machines_ssh.rs | 51 +++ core/localhost-core/src/error.rs | 10 + core/localhost-core/src/machine/machines.rs | 471 ++++++++++++++++++++ core/localhost-core/src/machine/mod.rs | 2 + 4 files changed, 534 insertions(+) create mode 100644 core/localhost-core/src/machine/machines.rs diff --git a/core/localhost-cli/tests/machines_ssh.rs b/core/localhost-cli/tests/machines_ssh.rs index eeecfd6..6d03b33 100644 --- a/core/localhost-cli/tests/machines_ssh.rs +++ b/core/localhost-cli/tests/machines_ssh.rs @@ -254,3 +254,54 @@ async fn says_why_a_machine_cant_be_reached() { assert_eq!(problem(other.connect("oldbox").await), MachineProblem::TooOld(None)); assert_eq!(problem(other.connect("barebox").await), MachineProblem::NotInstalled); } + +#[tokio::test(flavor = "multi_thread")] +async fn connects_again_after_the_connection_is_lost() { + use localhost_core::machine::{MachineState, Machines, MachinesEvent}; + let Some(other) = OtherMachine::start() else { return }; + let app_root = other.path().join("app-storage"); + let machines = Machines::open(&app_root, &other.ssh()).unwrap(); + let devbox = machines.add(None, "devbox").unwrap(); + let (_, _, mut events) = machines.subscribe(); + let connected = |event: &MachinesEvent| { + matches!(event, MachinesEvent::StateChanged { state: MachineState::Connected { .. }, .. }) + }; + machines.connect(devbox.id).unwrap(); + until_event(&mut events, "connected", connected).await; + let (projects, _) = machines.connection(devbox.id).unwrap().projects().await.unwrap(); + assert!(projects.is_empty()); + + // The network drops, or the laptop slept past the heartbeat. + machines.connection(devbox.id).unwrap().close(); + let lost = |event: &MachinesEvent| { + matches!( + event, + MachinesEvent::StateChanged { + state: MachineState::Unavailable { problem: MachineProblem::Lost, is_retrying: true }, + .. + } + ) + }; + until_event(&mut events, "lost", lost).await; + until_event(&mut events, "reconnected", connected).await; + assert!(machines.connection(devbox.id).is_ok()); + + machines.disconnect(devbox.id); + assert_eq!(machines.state(devbox.id), MachineState::NotConnected); + assert!(machines.connection(devbox.id).is_err()); +} + +async fn until_event( + events: &mut tokio::sync::mpsc::UnboundedReceiver, + what: &str, + condition: impl Fn(&localhost_core::machine::MachinesEvent) -> bool, +) { + let wait = async { + while let Some(event) = events.recv().await { + if condition(&event) { + return; + } + } + }; + tokio::time::timeout(PATIENCE, wait).await.unwrap_or_else(|_| panic!("never {what}")); +} diff --git a/core/localhost-core/src/error.rs b/core/localhost-core/src/error.rs index 3cb0cbb..3b25578 100644 --- a/core/localhost-core/src/error.rs +++ b/core/localhost-core/src/error.rs @@ -121,6 +121,11 @@ pub enum LocalhostError { }, // Machines + /// Not something `ssh` can connect to: empty, with spaces, or like an option. + InvalidMachineDestination(String), + /// Another Machine is already reached at this destination. + DuplicateMachine(String), + MachineNotFound(Id), /// Another Machine (by name) can't be reached over SSH, or the /// connection to it was lost. MachineUnavailable { @@ -208,6 +213,11 @@ impl LocalhostError { format!("`{command}` failed: {output}. Try again, or set up the server by hand.") } CommandPathTaken(path) => format!("{path} already exists and isn’t localhost’s. Remove it first, or leave it."), + InvalidMachineDestination(raw) => format!( + "“{raw}” isn’t something ssh can connect to. Use a Host from ~/.ssh/config, or user@host." + ), + DuplicateMachine(destination) => format!("{destination} is already one of your Machines."), + MachineNotFound(_) => "The Machine no longer exists.".into(), MachineUnavailable { machine, problem } => problem.message(machine), ForwardFailed { port, reason } => format!("Port {port} couldn’t be forwarded: {reason}."), Unsupported => "The other Machine runs an older localhost that can’t do this. Update localhost there.".into(), diff --git a/core/localhost-core/src/machine/machines.rs b/core/localhost-core/src/machine/machines.rs new file mode 100644 index 0000000..4d219af --- /dev/null +++ b/core/localhost-core/src/machine/machines.rs @@ -0,0 +1,471 @@ +//! The other Machines an app shows: which they are, kept in `machines.json` +//! in the storage root, and the connection to each while the app wants it. +//! +//! A connection that is lost is made again on its own, after a pause that +//! grows (`RETRY_DELAYS`), or at once on `wake` (the computer woke, the +//! network changed). That only reconnects; it never starts or stops a +//! Service. A problem that waiting won't fix (an untrusted host key, a +//! sign-in that needs a prompt, no localhost there) waits for the user to +//! connect again. + +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::sync::{Notify, mpsc}; +use tokio::task::JoinHandle; + +use super::{Forward, Machine, MachineConnection, MachineProblem, SshLink}; +use crate::{Id, LocalhostError}; + +const FILE_NAME: &str = "machines.json"; +const SCHEMA_VERSION: i64 = 1; + +/// How long to wait before each new attempt after a connection is lost. +const RETRY_DELAYS: [Duration; 5] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(15), + Duration::from_secs(30), +]; + +/// Where the app is with one Machine. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub enum MachineState { + NotConnected, + Connecting, + /// Connected to the localhost of this version there. + Connected { + version: String, + }, + /// Not connected, for this reason. `is_retrying`: another attempt comes + /// on its own. + Unavailable { + problem: MachineProblem, + is_retrying: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +pub enum MachinesEvent { + /// The list changed: added, removed or renamed. + MachinesChanged { + machines: Vec, + }, + StateChanged { + machine_id: Id, + state: MachineState, + }, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct File { + schema_version: i64, + machines: Vec, +} + +/// Every other Machine, and the connections to them. Cheap to clone. Must +/// be used inside a tokio runtime. +#[derive(Clone)] +pub struct Machines { + inner: Arc, +} + +struct Inner { + path: PathBuf, + control_folder: PathBuf, + ssh: PathBuf, + state: Mutex, +} + +#[derive(Default)] +struct State { + machines: Vec, + slots: HashMap, + subscribers: Vec>, +} + +struct Slot { + state: MachineState, + link: Option>, + connection: Option, + keeper: Option>, + retry_now: Arc, +} + +impl Default for Slot { + fn default() -> Slot { + Slot { + state: MachineState::NotConnected, + link: None, + connection: None, + keeper: None, + retry_now: Arc::new(Notify::new()), + } + } +} + +impl Machines { + /// The Machines saved in `storage_root`, reached with the `ssh` at `ssh`. + pub fn open(storage_root: &Path, ssh: &Path) -> Result { + let path = storage_root.join(FILE_NAME); + let machines = match fs::read(&path) { + Ok(data) => { + let file: File = serde_json::from_slice(&data).map_err(|error| LocalhostError::PersistenceFailed { + path: path.display().to_string(), + reason: error.to_string(), + })?; + if file.schema_version != SCHEMA_VERSION { + return Err(LocalhostError::UnsupportedSchemaVersion { + version: file.schema_version, + path: path.display().to_string(), + }); + } + file.machines + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => { + return Err(LocalhostError::PersistenceFailed { + path: path.display().to_string(), + reason: error.to_string(), + }); + } + }; + Ok(Machines { + inner: Arc::new(Inner { + path, + control_folder: storage_root.join("machines"), + ssh: ssh.to_path_buf(), + state: Mutex::new(State { machines, ..State::default() }), + }), + }) + } + + pub fn machines(&self) -> Vec { + self.inner.state().machines.clone() + } + + /// The Machines, their states, and every change after them. + pub fn subscribe(&self) -> (Vec, HashMap, mpsc::UnboundedReceiver) { + let mut state = self.inner.state(); + let (sender, receiver) = mpsc::unbounded_channel(); + state.subscribers.push(sender); + let states = state.machines.iter().map(|machine| (machine.id, state.machine_state(machine.id))).collect(); + (state.machines.clone(), states, receiver) + } + + pub fn state(&self, machine_id: Id) -> MachineState { + self.inner.state().machine_state(machine_id) + } + + /// Remembers the Machine `ssh` reaches as `destination`, called `name` + /// (by default its destination). Doesn't connect. + pub fn add(&self, name: Option, destination: &str) -> Result { + let destination = destination.trim(); + // Passed after `--`, but an option-like destination is never meant. + if destination.is_empty() || destination.starts_with('-') || destination.chars().any(char::is_whitespace) { + return Err(LocalhostError::InvalidMachineDestination(destination.to_string())); + } + let name = name.map(|name| name.trim().to_string()).filter(|name| !name.is_empty()); + let machine = Machine { + id: Id::new(), + name: name.unwrap_or_else(|| destination.to_string()), + destination: destination.into(), + }; + let mut state = self.inner.state(); + if state.machines.iter().any(|existing| existing.destination == machine.destination) { + return Err(LocalhostError::DuplicateMachine(machine.destination)); + } + let mut machines = state.machines.clone(); + machines.push(machine.clone()); + self.inner.save(&machines)?; + state.machines = machines.clone(); + state.publish(MachinesEvent::MachinesChanged { machines }); + Ok(machine) + } + + /// Disconnects and forgets the Machine. Nothing changes on it. + pub fn remove(&self, machine_id: Id) -> Result<(), LocalhostError> { + self.disconnect(machine_id); + let mut state = self.inner.state(); + let machines: Vec = + state.machines.iter().filter(|machine| machine.id != machine_id).cloned().collect(); + self.inner.save(&machines)?; + state.machines = machines.clone(); + state.slots.remove(&machine_id); + state.publish(MachinesEvent::MachinesChanged { machines }); + Ok(()) + } + + /// Connects to the Machine, and keeps connecting when the connection is + /// lost, until `disconnect`. Returns at once; the state says how it goes. + pub fn connect(&self, machine_id: Id) -> Result<(), LocalhostError> { + let mut state = self.inner.state(); + let machine = state.machine(machine_id)?; + let slot = state.slots.entry(machine_id).or_default(); + if slot.keeper.as_ref().is_some_and(|keeper| !keeper.is_finished()) { + slot.retry_now.notify_one(); + return Ok(()); + } + let retry_now = slot.retry_now.clone(); + let weak = Arc::downgrade(&self.inner); + slot.keeper = Some(tokio::spawn(keep_connected(weak, machine, retry_now))); + Ok(()) + } + + /// Ends the connection to the Machine, and its Forwards. Its Runs are + /// left to its own rules: an app open there keeps them. + pub fn disconnect(&self, machine_id: Id) { + let link = { + let mut state = self.inner.state(); + let Some(slot) = state.slots.get_mut(&machine_id) else { return }; + if let Some(keeper) = slot.keeper.take() { + keeper.abort(); + } + slot.connection = None; + let link = slot.link.take(); + state.set(machine_id, MachineState::NotConnected); + link + }; + if let Some(link) = link { + link.end(); + } + } + + /// Tries every connection that is waiting to retry now: the computer + /// woke up, or its network changed. + pub fn wake(&self) { + for slot in self.inner.state().slots.values() { + slot.retry_now.notify_one(); + } + } + + /// The connection to the Machine, if connected. + pub fn connection(&self, machine_id: Id) -> Result { + let state = self.inner.state(); + let machine = state.machine(machine_id)?; + match state.slots.get(&machine_id).and_then(|slot| slot.connection.clone()) { + Some(connection) if connection.is_connected() => Ok(connection), + _ => Err(LocalhostError::MachineUnavailable { machine: machine.name, problem: MachineProblem::Lost }), + } + } + + /// Forwards the Machine's `port` to `local_port` here (`SshLink::forward_to`). + pub async fn forward(&self, machine_id: Id, port: u16, local_port: u16) -> Result { + self.link(machine_id)?.forward_to(port, local_port).await + } + + pub async fn cancel_forward(&self, machine_id: Id, forward: Forward) { + if let Ok(link) = self.link(machine_id) { + link.cancel_forward(forward).await; + } + } + + pub fn forwards(&self, machine_id: Id) -> Vec { + self.link(machine_id).map(|link| link.forwards()).unwrap_or_default() + } + + fn link(&self, machine_id: Id) -> Result, LocalhostError> { + let state = self.inner.state(); + let machine = state.machine(machine_id)?; + state + .slots + .get(&machine_id) + .and_then(|slot| slot.link.clone()) + .ok_or(LocalhostError::MachineUnavailable { machine: machine.name, problem: MachineProblem::Lost }) + } +} + +impl Inner { + fn state(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Writes the list atomically: a reader sees the old file or the new one. + fn save(&self, machines: &[Machine]) -> Result<(), LocalhostError> { + let failure = |error: std::io::Error| LocalhostError::PersistenceFailed { + path: self.path.display().to_string(), + reason: error.to_string(), + }; + let file = File { schema_version: SCHEMA_VERSION, machines: machines.to_vec() }; + let mut data = serde_json::to_vec_pretty(&file).expect("Machines always encode"); + data.push(b'\n'); + let folder = self.path.parent().unwrap_or(Path::new(".")); + fs::create_dir_all(folder).map_err(failure)?; + let temporary = folder.join(format!(".{FILE_NAME}.tmp-{}", Id::new())); + let result = (|| { + let mut handle = fs::File::create(&temporary)?; + handle.write_all(&data)?; + handle.sync_all()?; + fs::rename(&temporary, &self.path) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(failure(error)); + } + Ok(()) + } +} + +impl State { + fn machine(&self, machine_id: Id) -> Result { + self.machines + .iter() + .find(|machine| machine.id == machine_id) + .cloned() + .ok_or(LocalhostError::MachineNotFound(machine_id)) + } + + fn machine_state(&self, machine_id: Id) -> MachineState { + self.slots.get(&machine_id).map_or(MachineState::NotConnected, |slot| slot.state.clone()) + } + + fn set(&mut self, machine_id: Id, machine_state: MachineState) { + let slot = self.slots.entry(machine_id).or_default(); + if slot.state == machine_state { + return; + } + slot.state = machine_state.clone(); + self.publish(MachinesEvent::StateChanged { machine_id, state: machine_state }); + } + + fn publish(&mut self, event: MachinesEvent) { + self.subscribers.retain(|subscriber| subscriber.send(event.clone()).is_ok()); + } +} + +/// Whether waiting could fix `problem`, so it is worth trying again unasked. +fn is_passing(problem: &MachineProblem) -> bool { + matches!( + problem, + MachineProblem::Lost | MachineProblem::Unreachable(_) | MachineProblem::HostNotFound | MachineProblem::Other(_) + ) +} + +/// Connects to `machine` until told to stop (the task is aborted), again +/// after each loss. +async fn keep_connected(inner: std::sync::Weak, machine: Machine, retry_now: Arc) { + let mut attempt = 0; + loop { + let Some(strong) = inner.upgrade() else { return }; + strong.state().set(machine.id, MachineState::Connecting); + let (ssh, control_folder) = (strong.ssh.clone(), strong.control_folder.clone()); + drop(strong); + + let problem = match SshLink::connect(machine.clone(), &ssh, &control_folder).await { + Ok((link, connection)) => { + attempt = 0; + let version = connection.hello().version.clone(); + { + let Some(strong) = inner.upgrade() else { return }; + let mut state = strong.state(); + let slot = state.slots.entry(machine.id).or_default(); + slot.link = Some(link); + slot.connection = Some(connection.clone()); + state.set(machine.id, MachineState::Connected { version }); + } + connection.closed().await; + let Some(strong) = inner.upgrade() else { return }; + let link = { + let mut state = strong.state(); + let slot = state.slots.entry(machine.id).or_default(); + slot.connection = None; + slot.link.take() + }; + if let Some(link) = link { + link.end(); + } + MachineProblem::Lost + } + Err(LocalhostError::MachineUnavailable { problem, .. }) => problem, + Err(other) => MachineProblem::Other(other.message()), + }; + + let is_retrying = is_passing(&problem); + if let Some(strong) = inner.upgrade() { + strong.state().set(machine.id, MachineState::Unavailable { problem, is_retrying }); + } + if !is_retrying { + return; + } + let delay = RETRY_DELAYS[attempt.min(RETRY_DELAYS.len() - 1)]; + attempt += 1; + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = retry_now.notified() => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn remembers_machines_across_launches() { + let root = tempfile::tempdir().unwrap(); + let machines = Machines::open(root.path(), Path::new("ssh")).unwrap(); + let devbox = machines.add(None, " devbox ").unwrap(); + assert_eq!((devbox.name.as_str(), devbox.destination.as_str()), ("devbox", "devbox")); + let lab = machines.add(Some("Lab".into()), "me@lab.example.com").unwrap(); + + let reopened = Machines::open(root.path(), Path::new("ssh")).unwrap(); + assert_eq!(reopened.machines(), [devbox.clone(), lab.clone()]); + assert_eq!(reopened.state(devbox.id), MachineState::NotConnected); + + reopened.remove(devbox.id).unwrap(); + assert_eq!(Machines::open(root.path(), Path::new("ssh")).unwrap().machines(), [lab]); + } + + #[tokio::test] + async fn refuses_destinations_ssh_would_misread() { + let root = tempfile::tempdir().unwrap(); + let machines = Machines::open(root.path(), Path::new("ssh")).unwrap(); + for destination in ["", " ", "-oProxyCommand=evil", "dev box"] { + assert!(matches!(machines.add(None, destination), Err(LocalhostError::InvalidMachineDestination(_)))); + } + machines.add(None, "devbox").unwrap(); + assert_eq!( + machines.add(Some("Again".into()), "devbox"), + Err(LocalhostError::DuplicateMachine("devbox".into())) + ); + } + + #[tokio::test] + async fn says_why_it_cant_connect_and_waits_when_waiting_wont_help() { + let root = tempfile::tempdir().unwrap(); + // An `ssh` that fails as an unknown host key does. + let ssh = root.path().join("ssh"); + fs::write(&ssh, "#!/bin/sh\necho 'Host key verification failed.' >&2\nexit 255\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&ssh, fs::Permissions::from_mode(0o755)).unwrap(); + } + let machines = Machines::open(root.path(), &ssh).unwrap(); + let devbox = machines.add(None, "devbox").unwrap(); + let (_, _, mut events) = machines.subscribe(); + machines.connect(devbox.id).unwrap(); + let expected = MachineState::Unavailable { problem: MachineProblem::HostKeyNotTrusted, is_retrying: false }; + tokio::time::timeout(Duration::from_secs(20), async { + while let Some(event) = events.recv().await { + if event == (MachinesEvent::StateChanged { machine_id: devbox.id, state: expected.clone() }) { + return; + } + } + }) + .await + .expect("never said why"); + assert!(machines.connection(devbox.id).is_err()); + } +} diff --git a/core/localhost-core/src/machine/mod.rs b/core/localhost-core/src/machine/mod.rs index ff94a73..ee443c1 100644 --- a/core/localhost-core/src/machine/mod.rs +++ b/core/localhost-core/src/machine/mod.rs @@ -13,6 +13,7 @@ //! the other Machine's shared core stays on its own user's socket. mod connection; +mod machines; pub(crate) mod protocol; pub mod relay; mod ssh; @@ -20,6 +21,7 @@ mod ssh; use serde::{Deserialize, Serialize}; pub use connection::MachineConnection; +pub use machines::{MachineState, Machines, MachinesEvent}; pub use protocol::{MACHINE_PROTOCOL_VERSION, MachineHello}; pub use ssh::{Forward, SshLink}; From 7984b31b4d4b62709e053883eeff8ea3077947f4 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 21:47:46 -0300 Subject: [PATCH 04/27] Add an example that views another Machine from a terminal, to try one before the apps show it --- core/localhost-core/examples/machine.rs | 203 ++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 core/localhost-core/examples/machine.rs diff --git a/core/localhost-core/examples/machine.rs b/core/localhost-core/examples/machine.rs new file mode 100644 index 0000000..7dc0dc0 --- /dev/null +++ b/core/localhost-core/examples/machine.rs @@ -0,0 +1,203 @@ +//! Views another Machine from a terminal, through the same `Machines` the +//! apps use, to try a Machine before any app shows one: +//! +//! ```sh +//! cargo run -p localhost-core --example machine -- devbox +//! ``` +//! +//! It lists the other Machine's Projects and Services and follows their +//! changes. Type `start`, `stop`, `restart` or `open` and a +//! `/` Key pair; `open` forwards the Service's Port and +//! prints its URL. `quit` (or Ctrl-D) ends the connection and its Forwards. +//! The Machine list is kept in a temporary folder, never in localhost's own. + +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; + +use localhost_core::machine::{MachineState, Machines, MachinesEvent}; +use localhost_core::{Id, LocalhostError, Project, ServiceEvent, ServiceState, ServiceStatus}; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() { + let Some(destination) = std::env::args().nth(1) else { + eprintln!("Usage: cargo run -p localhost-core --example machine -- "); + std::process::exit(2); + }; + if let Err(error) = run(&destination).await { + eprintln!("{}", error.message()); + std::process::exit(1); + } +} + +async fn run(destination: &str) -> Result<(), LocalhostError> { + let storage_root = std::env::temp_dir().join(format!("localhost-machine-example-{}", std::process::id())); + let machines = Machines::open(&storage_root, Path::new("ssh"))?; + let machine = machines.add(None, destination)?; + let (_, _, mut machine_events) = machines.subscribe(); + machines.connect(machine.id)?; + println!("Connecting to {}…", machine.name); + loop { + match machine_events.recv().await { + Some(MachinesEvent::StateChanged { state: MachineState::Connected { version }, .. }) => { + println!("Connected: localhost {version} there."); + break; + } + Some(MachinesEvent::StateChanged { state: MachineState::Unavailable { problem, is_retrying }, .. }) => { + println!("{}", problem.message(&machine.name)); + if !is_retrying { + return Ok(()); + } + } + Some(_) => {} + None => return Ok(()), + } + } + + let connection = machines.connection(machine.id)?; + let (mut projects, mut project_updates) = connection.projects().await?; + let (mut statuses, mut service_events) = connection.subscribe().await?; + print_projects(&projects, &statuses); + println!("\nstart|stop|restart|open /, or quit"); + + let mut input = lines_typed(); + loop { + tokio::select! { + line = input.recv() => { + let Some(line) = line else { break }; + let words: Vec<&str> = line.split_whitespace().collect(); + let (verb, target) = match words.as_slice() { + ["quit"] => break, + [verb, target] => (*verb, *target), + [] => continue, + _ => { println!("start|stop|restart|open /, or quit"); continue } + }; + let Some((project, service)) = find(&projects, target) else { + println!("No Service {target} there."); + continue; + }; + let result = match verb { + "start" => connection.start(service, None).await.map(|_| ()), + "stop" => connection.stop(service).await.map(|_| ()), + "restart" => connection.restart(service).await.map(|_| ()), + "open" => open(&machines, machine.id, &projects, &statuses, project, service).await, + _ => { println!("start|stop|restart|open /, or quit"); continue } + }; + if let Err(error) = result { + println!("{}", error.message()); + } + } + Some(updated) = project_updates.recv() => { + projects = updated; + println!("The Projects there changed."); + print_projects(&projects, &statuses); + } + Some(event) = service_events.recv() => { + if let ServiceEvent::ServiceStateChanged(status) = &event { + let run = status.run.as_ref(); + println!( + "{} → {:?}{}{}", + name(&projects, status.service_id), + status.state, + run.map(|run| format!(", started by {:?}", run.origin)).unwrap_or_default(), + run.and_then(|run| run.stop_origin).map(|origin| format!(", stopped by {origin:?}")).unwrap_or_default(), + ); + if let Some(failure) = run.and_then(|run| run.failure.as_ref()) { + println!(" {}", failure.message()); + } + statuses.insert(status.service_id, status.clone()); + } + } + Some(MachinesEvent::StateChanged { state, .. }) = machine_events.recv() => { + match state { + MachineState::Unavailable { problem, is_retrying } => { + println!("{}{}", problem.message(&machine.name), if is_retrying { " Trying again…" } else { "" }); + } + MachineState::Connected { .. } => { + // A new connection: its Projects and statuses afresh. + println!("Connected again."); + let connection = machines.connection(machine.id)?; + (projects, project_updates) = connection.projects().await?; + (statuses, service_events) = connection.subscribe().await?; + print_projects(&projects, &statuses); + } + _ => {} + } + } + } + } + machines.disconnect(machine.id); + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = std::fs::remove_dir_all(&storage_root); + Ok(()) +} + +/// What's typed, line by line, read on a thread of its own. +fn lines_typed() -> mpsc::UnboundedReceiver { + let (sender, receiver) = mpsc::unbounded_channel(); + std::thread::spawn(move || { + for line in std::io::stdin().lines() { + let Ok(line) = line else { break }; + if sender.send(line).is_err() { + break; + } + } + }); + receiver +} + +/// Forwards the Service's Port, the same number here, and prints its URL. +async fn open( + machines: &Machines, + machine_id: Id, + projects: &[Project], + statuses: &HashMap, + project_id: Id, + service_id: Id, +) -> Result<(), LocalhostError> { + let service = projects + .iter() + .find(|project| project.id == project_id) + .and_then(|project| project.services.iter().find(|service| service.id == service_id)) + .expect("found above"); + let run = statuses.get(&service_id).and_then(|status| status.run.as_ref()); + let Some(port) = run.and_then(|run| run.port).or(service.port) else { + println!("{} has no Port.", service.key); + return Ok(()); + }; + let forward = machines.forward(machine_id, port, port).await?; + println!("Forwarded {} there to {} here.", forward.port, forward.local_port); + if let Some(url) = service.url_to_open(run) { + println!("Open {url}"); + } + Ok(()) +} + +fn find(projects: &[Project], target: &str) -> Option<(Id, Id)> { + let (project_key, service_key) = target.split_once('/')?; + let project = projects.iter().find(|project| project.key.to_string() == project_key)?; + let service = project.services.iter().find(|service| service.key.to_string() == service_key)?; + Some((project.id, service.id)) +} + +fn name(projects: &[Project], service_id: Id) -> String { + projects + .iter() + .find_map(|project| { + let service = project.services.iter().find(|service| service.id == service_id)?; + Some(format!("{}/{}", project.key, service.key)) + }) + .unwrap_or_else(|| service_id.to_string()) +} + +fn print_projects(projects: &[Project], statuses: &HashMap) { + for project in projects { + println!("\n{} ({})", project.name, project.key); + for service in &project.services { + let state = statuses.get(&service.id).map_or(ServiceState::Stopped, |status| status.state); + let port = service.port.map(|port| format!(" :{port}")).unwrap_or_default(); + println!(" {}/{} {state:?}{port}", project.key, service.key); + } + } +} From f05a422c149953a9e0a3eb0fbcbd2510dde8c6c0 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:18:36 -0300 Subject: [PATCH 05/27] Name the ControlMaster's socket relative to its folder, so a long folder doesn't exceed a Unix socket's path limit --- core/localhost-cli/tests/machines_ssh.rs | 27 ++++++++++++++++++++++-- core/localhost-core/src/machine/ssh.rs | 22 +++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/core/localhost-cli/tests/machines_ssh.rs b/core/localhost-cli/tests/machines_ssh.rs index 6d03b33..d5987df 100644 --- a/core/localhost-cli/tests/machines_ssh.rs +++ b/core/localhost-cli/tests/machines_ssh.rs @@ -1,7 +1,8 @@ //! Reaching another Machine through the real `ssh`: a private `sshd` on a //! loopback port, run as this user with throwaway keys, whose sessions find //! this build's `localhost` first on the `PATH`. Nothing of the user's own -//! SSH setup is read or written. Skipped where there is no `sshd`. +//! SSH setup is read or written, nor their zsh files, which could change +//! that `PATH`. Skipped where there is no `sshd`. #![cfg(target_os = "linux")] use std::net::{Ipv4Addr, SocketAddr, TcpListener}; @@ -74,7 +75,7 @@ impl OtherMachine { format!( "Port {port}\nListenAddress 127.0.0.1\nHostKey {p}/host_key\nAuthorizedKeysFile {p}/authorized_keys\n\ PidFile {p}/sshd.pid\nUsePAM no\nStrictModes no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n\ - AcceptEnv LH_VARIANT\nSetEnv PATH={p}/bin:/usr/bin:/bin XDG_DATA_HOME={p}/data\n" + AcceptEnv LH_VARIANT\nSetEnv PATH={p}/bin:/usr/bin:/bin XDG_DATA_HOME={p}/data ZDOTDIR={p}\n" ), ) .unwrap(); @@ -247,6 +248,28 @@ async fn forwards_a_port_of_the_other_machine_to_this_one() { assert!(std::net::TcpStream::connect((Ipv4Addr::LOCALHOST, local_port)).is_err()); } +/// A Unix socket's path is limited to about 104 bytes, and macOS's +/// temporary and home folders can be long. +#[tokio::test(flavor = "multi_thread")] +async fn keeps_its_control_socket_in_a_folder_of_any_length() { + let Some(other) = OtherMachine::start() else { return }; + let control = + other.path().join("a-folder-whose-path-is-longer-than-a-unix-socket-path-may-be").join("x".repeat(60)); + assert!(control.as_os_str().len() > 120); + let ssh = other.ssh(); + let link = SshLink::connect(other.machine("devbox"), &ssh, &control); + let (link, connection) = tokio::time::timeout(PATIENCE, link).await.expect("ssh hung").unwrap(); + assert!(connection.is_connected()); + + let server = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = server.local_addr().unwrap().port(); + let local_port = free_port(); + let forward = link.forward_to(port, local_port).await.unwrap(); + assert!(std::net::TcpStream::connect((Ipv4Addr::LOCALHOST, local_port)).is_ok()); + link.cancel_forward(forward).await; + assert!(std::net::TcpStream::connect((Ipv4Addr::LOCALHOST, local_port)).is_err()); +} + #[tokio::test(flavor = "multi_thread")] async fn says_why_a_machine_cant_be_reached() { let Some(other) = OtherMachine::start() else { return }; diff --git a/core/localhost-core/src/machine/ssh.rs b/core/localhost-core/src/machine/ssh.rs index 48347c7..4b82ccc 100644 --- a/core/localhost-core/src/machine/ssh.rs +++ b/core/localhost-core/src/machine/ssh.rs @@ -68,7 +68,9 @@ pub struct Forward { pub struct SshLink { machine: Machine, ssh: PathBuf, - control_path: PathBuf, + control_folder: PathBuf, + /// The control socket's name, relative to `control_folder`. + control_name: String, child: Mutex>, forwards: Mutex>, } @@ -83,15 +85,19 @@ impl SshLink { ) -> Result<(Arc, MachineConnection), LocalhostError> { let unavailable = |problem| LocalhostError::MachineUnavailable { machine: machine.name.clone(), problem }; create_private_folder(control_folder).map_err(|error| unavailable(MachineProblem::Other(error.to_string())))?; - let control_path = control_folder.join(machine.id.to_string().split('-').next().unwrap_or("machine")); - let _ = std::fs::remove_file(&control_path); + let control_name = machine.id.to_string().split('-').next().unwrap_or("machine").to_string(); + let _ = std::fs::remove_file(control_folder.join(&control_name)); let mut command = Command::new(ssh); + // The socket is named relative to the folder: a Unix socket's path + // is limited to about 104 bytes, and `ssh` adds 17 while making it, + // which a temporary or home folder can exceed (macOS's do). command + .current_dir(control_folder) .arg("-T") .arg("-M") .arg("-S") - .arg(&control_path) + .arg(&control_name) .args(options()) .args(master_options()) .arg("--") @@ -127,7 +133,8 @@ impl SshLink { let link = Arc::new(SshLink { machine, ssh: ssh.to_path_buf(), - control_path, + control_folder: control_folder.to_path_buf(), + control_name, child: Mutex::new(Some(child)), forwards: Mutex::new(HashSet::new()), }); @@ -207,14 +214,15 @@ impl SshLink { let _ = child.start_kill(); } self.forwards.lock().unwrap().clear(); - let _ = std::fs::remove_file(&self.control_path); + let _ = std::fs::remove_file(self.control_folder.join(&self.control_name)); } /// Asks the master to `operation` (`forward`, `cancel`) the local forward `spec`. async fn control(&self, operation: &str, spec: &str, port: u16) -> Result<(), LocalhostError> { let output = Command::new(&self.ssh) + .current_dir(&self.control_folder) .arg("-S") - .arg(&self.control_path) + .arg(&self.control_name) .arg("-O") .arg(operation) .arg("-L") From e6dae7b21a6066a58ea8bea4b04cc5899f1d3019 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:40:26 -0300 Subject: [PATCH 06/27] Read the Host names of ~/.ssh/config, following Include, to suggest as a Machine's destination --- core/localhost-core/src/machine/mod.rs | 2 + core/localhost-core/src/machine/ssh_config.rs | 160 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 core/localhost-core/src/machine/ssh_config.rs diff --git a/core/localhost-core/src/machine/mod.rs b/core/localhost-core/src/machine/mod.rs index ee443c1..94a4c6d 100644 --- a/core/localhost-core/src/machine/mod.rs +++ b/core/localhost-core/src/machine/mod.rs @@ -17,6 +17,7 @@ mod machines; pub(crate) mod protocol; pub mod relay; mod ssh; +mod ssh_config; use serde::{Deserialize, Serialize}; @@ -24,6 +25,7 @@ pub use connection::MachineConnection; pub use machines::{MachineState, Machines, MachinesEvent}; pub use protocol::{MACHINE_PROTOCOL_VERSION, MachineHello}; pub use ssh::{Forward, SshLink}; +pub use ssh_config::ssh_config_hosts; /// Why another Machine can't be reached, as the app explains it. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/core/localhost-core/src/machine/ssh_config.rs b/core/localhost-core/src/machine/ssh_config.rs new file mode 100644 index 0000000..bfd49e4 --- /dev/null +++ b/core/localhost-core/src/machine/ssh_config.rs @@ -0,0 +1,160 @@ +//! The hosts the user has named in `~/.ssh/config`, to suggest as a +//! Machine's destination. Only read, never written. `ssh` itself decides +//! what each name means; this only lists them. + +use std::path::{Path, PathBuf}; + +/// How deep `Include`s are followed, as `ssh` limits them. +const MAX_DEPTH: usize = 16; + +/// The `Host` names of the user's `~/.ssh/config` and the files it +/// includes, in order, without patterns (`*`, `?`, `!`), which name no one +/// host. +pub fn ssh_config_hosts() -> Vec { + hosts_in(&crate::paths::home_directory().join(".ssh")) +} + +/// The hosts of `ssh_folder`/config. +pub(crate) fn hosts_in(ssh_folder: &Path) -> Vec { + let mut hosts = Vec::new(); + read(&ssh_folder.join("config"), ssh_folder, 0, &mut hosts); + hosts +} + +fn read(file: &Path, ssh_folder: &Path, depth: usize, hosts: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let Ok(text) = std::fs::read_to_string(file) else { return }; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // `Keyword value` or `Keyword=value`. + let split = line.find(|character: char| character.is_whitespace() || character == '=').unwrap_or(line.len()); + let (keyword, rest) = line.split_at(split); + let rest = rest.trim_start(); + let rest = rest.strip_prefix('=').unwrap_or(rest).trim(); + if keyword.eq_ignore_ascii_case("host") { + for name in words(rest) { + // A pattern names no one host, and a destination has no spaces. + let is_usable = !name.contains(['*', '?', '!']) && !name.contains(char::is_whitespace); + if is_usable && !hosts.contains(&name) { + hosts.push(name); + } + } + } else if keyword.eq_ignore_ascii_case("include") { + for pattern in words(rest) { + for included in expand(&pattern, ssh_folder) { + read(&included, ssh_folder, depth + 1, hosts); + } + } + } + } +} + +/// The words of a value, some of them quoted. +fn words(value: &str) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut is_quoted = false; + for character in value.chars() { + match character { + '"' => is_quoted = !is_quoted, + character if character.is_whitespace() && !is_quoted => { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } + character => word.push(character), + } + } + if !word.is_empty() { + words.push(word); + } + words +} + +/// The files an `Include` names: relative to `~/.ssh`, `~` expanded, with +/// `*` and `?` in its last part, sorted as `ssh` reads them. +fn expand(pattern: &str, ssh_folder: &Path) -> Vec { + let path = match pattern.strip_prefix("~/") { + Some(rest) => crate::paths::home_directory().join(rest), + None if Path::new(pattern).is_absolute() => PathBuf::from(pattern), + None => ssh_folder.join(pattern), + }; + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return Vec::new() }; + if !name.contains(['*', '?']) { + return vec![path]; + } + let Some(folder) = path.parent() else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(folder) else { return Vec::new() }; + let mut files: Vec = entries + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_str().is_some_and(|file| matches(name, file))) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .collect(); + files.sort(); + files +} + +/// Whether `text` matches the glob `pattern` of `*` and `?`. +fn matches(pattern: &str, text: &str) -> bool { + let (pattern, text): (Vec, Vec) = (pattern.chars().collect(), text.chars().collect()); + let (mut p, mut t) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while t < text.len() { + if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) { + p += 1; + t += 1; + } else if p < pattern.len() && pattern[p] == '*' { + star = Some((p, t)); + p += 1; + } else if let Some((star_p, star_t)) = star { + p = star_p + 1; + t = star_t + 1; + star = Some((star_p, star_t + 1)); + } else { + return false; + } + } + pattern[p..].iter().all(|&character| character == '*') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lists_named_hosts_following_includes() { + let folder = tempfile::tempdir().unwrap(); + let ssh = folder.path(); + std::fs::create_dir_all(ssh.join("config.d")).unwrap(); + std::fs::write( + ssh.join("config"), + "# mine\nInclude config.d/*\nHost devbox lab\n HostName 100.64.0.2\nHost *.internal !bastion\n\ + host=pi\nMatch host foo\nHost \"quoted box\" devbox\nInclude missing\nInclude config\n", + ) + .unwrap(); + std::fs::write(ssh.join("config.d/10-work"), "Host work-vm\n User me\nHost ?\n").unwrap(); + std::fs::write(ssh.join("config.d/20-home"), "HOST nas\n").unwrap(); + + assert_eq!(hosts_in(ssh), ["work-vm", "nas", "devbox", "lab", "pi"]); + } + + #[test] + fn has_no_hosts_without_a_config() { + assert!(hosts_in(&tempfile::tempdir().unwrap().path().join(".ssh")).is_empty()); + } + + #[test] + fn matches_globs() { + assert!(matches("*", "anything")); + assert!(matches("*.conf", "work.conf")); + assert!(!matches("*.conf", "work.conf.bak")); + assert!(matches("h?st", "host")); + assert!(!matches("h?st", "hoost")); + } +} From d1866390fb3ba2bb400560019c8c49620985f55c Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:40:30 -0300 Subject: [PATCH 07/27] Show an SSH failure localhost doesn't recognize once and wait for the user, instead of retrying it forever --- core/localhost-core/src/machine/machines.rs | 7 +++---- core/localhost-core/src/machine/mod.rs | 11 +++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/core/localhost-core/src/machine/machines.rs b/core/localhost-core/src/machine/machines.rs index 4d219af..ef228cb 100644 --- a/core/localhost-core/src/machine/machines.rs +++ b/core/localhost-core/src/machine/machines.rs @@ -345,11 +345,10 @@ impl State { } /// Whether waiting could fix `problem`, so it is worth trying again unasked. +/// What SSH says that localhost doesn't recognize is shown once instead: a +/// mistake in `~/.ssh/config` stays one however often it is tried. fn is_passing(problem: &MachineProblem) -> bool { - matches!( - problem, - MachineProblem::Lost | MachineProblem::Unreachable(_) | MachineProblem::HostNotFound | MachineProblem::Other(_) - ) + matches!(problem, MachineProblem::Lost | MachineProblem::Unreachable(_) | MachineProblem::HostNotFound) } /// Connects to `machine` until told to stop (the task is aborted), again diff --git a/core/localhost-core/src/machine/mod.rs b/core/localhost-core/src/machine/mod.rs index 94a4c6d..d1e2cd4 100644 --- a/core/localhost-core/src/machine/mod.rs +++ b/core/localhost-core/src/machine/mod.rs @@ -51,7 +51,8 @@ pub enum MachineProblem { TooOld(Option), /// It was connected and the connection ended. Lost, - /// Anything else SSH said. + /// Anything else SSH said, e.g. a mistake in `~/.ssh/config`. Not + /// tried again on its own. Other(String), } @@ -107,6 +108,8 @@ impl MachineProblem { || lower.contains("timed out") || lower.contains("no route to host") || lower.contains("network is unreachable") + || lower.contains("connection reset") + || lower.contains("connection closed by") { MachineProblem::Unreachable(last_line.trim_start_matches("ssh: ").to_string()) } else if code == Some(127) @@ -165,7 +168,11 @@ mod tests { assert_eq!(problem("Unknown command `remote`.\nRun `localhost --help` for usage.", Some(2)), TooOld(None)); assert_eq!( problem("kex_exchange_identification: read: Connection reset by peer", Some(255)), - Other("kex_exchange_identification: read: Connection reset by peer".into()) + Unreachable("kex_exchange_identification: read: Connection reset by peer".into()) + ); + assert_eq!( + problem("/Users/me/.ssh/config line 4: Bad configuration option: hostnme", Some(255)), + Other("/Users/me/.ssh/config line 4: Bad configuration option: hostnme".into()) ); assert_eq!(problem("", Some(255)), Lost); } From f7fb640ee561989d5b7271a38dc26349364bd791 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:41:21 -0300 Subject: [PATCH 08/27] Keep each Machine's Projects and statuses in Machines across reconnects, add a Machine only once it is reached, and open its Services through a Forward --- core/localhost-cli/tests/machines_ssh.rs | 80 ++- core/localhost-core/examples/machine.rs | 123 ++--- core/localhost-core/src/listener.rs | 23 + core/localhost-core/src/machine/machines.rs | 547 +++++++++++++++++--- core/localhost-core/src/machine/mod.rs | 2 +- core/localhost-core/src/machine/ssh.rs | 3 +- 6 files changed, 598 insertions(+), 180 deletions(-) diff --git a/core/localhost-cli/tests/machines_ssh.rs b/core/localhost-cli/tests/machines_ssh.rs index d5987df..f0e7ee6 100644 --- a/core/localhost-cli/tests/machines_ssh.rs +++ b/core/localhost-cli/tests/machines_ssh.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::time::Duration; -use localhost_core::machine::SshLink; +use localhost_core::machine::{MachineState, Machines, MachinesEvent, SshLink}; use localhost_core::{ Id, Key, LocalhostError, Machine, MachineProblem, MemorySecretStore, NewService, Origin, ProjectLibrary, ServiceState, @@ -278,21 +278,83 @@ async fn says_why_a_machine_cant_be_reached() { assert_eq!(problem(other.connect("barebox").await), MachineProblem::NotInstalled); } +/// What the apps do: add the Machine, show its Projects and statuses as +/// they change, control a Service, and open it through a Forward. +#[tokio::test(flavor = "multi_thread")] +async fn shows_another_machine_as_the_apps_do() { + let Some(other) = OtherMachine::start() else { return }; + let library = other.library(); + let folder = other.path().join("site"); + std::fs::create_dir_all(&folder).unwrap(); + let project = library + .add_project("Site", Key::new("site").unwrap(), folder.to_str().unwrap(), vec![], Origin::Agent) + .unwrap(); + let port = free_port(); + let web = NewService { + port: Some(port.into()), + port_can_change: false, + ..NewService::new("Web", Key::new("web").unwrap(), "exec python3 -m http.server $PORT --bind 127.0.0.1") + }; + let web = library.add_service(project.id, web, vec![], vec![], Origin::Agent).unwrap(); + + let machines = Machines::open(&other.path().join("app-storage"), &other.ssh()).unwrap(); + let (snapshots, mut events) = machines.subscribe(); + assert!(snapshots.is_empty()); + let devbox = tokio::time::timeout(PATIENCE, machines.add(Some("Dev box".into()), "devbox")) + .await + .expect("ssh hung") + .unwrap(); + until_event(&mut events, "showed its Projects", |event| { + matches!(event, MachinesEvent::ProjectsChanged { projects, .. } if projects.iter().any(|shown| shown.id == project.id)) + }) + .await; + until_event(&mut events, "connected", |event| { + matches!(event, MachinesEvent::StateChanged { state: MachineState::Connected { .. }, .. }) + }) + .await; + + machines.start(devbox.id, web.id, None).await.unwrap(); + until_event(&mut events, "ran", |event| { + matches!( + event, + MachinesEvent::ServiceChanged { event: localhost_core::ServiceEvent::ServiceStateChanged(status), .. } + if status.service_id == web.id && status.state == ServiceState::Running + ) + }) + .await; + let (snapshots, _) = machines.subscribe(); + let shown = &snapshots[0]; + assert_eq!((shown.machine.name.as_str(), shown.projects.len()), ("Dev box", 1)); + let status = shown.statuses.iter().find(|status| status.service_id == web.id).unwrap(); + assert_eq!((status.state, status.run.as_ref().unwrap().origin), (ServiceState::Running, Origin::Machine)); + + // Both ends are this computer, so the Service itself holds its port + // here: Open says who, rather than forwarding beside it. + match machines.open_service(devbox.id, web.id).await { + Err(LocalhostError::PortConflict { port: taken, owner: Some(owner) }) => { + assert_eq!(taken, port); + assert!(owner.ports.contains(&port), "{owner:?}"); + } + other => panic!("opened beside the Service: {other:?}"), + } + + assert_eq!(machines.stop(devbox.id, web.id).await.unwrap().state, ServiceState::Stopped); + machines.remove(devbox.id).unwrap(); + assert!(machines.machines().is_empty()); + assert!(machines.connection(devbox.id).is_err()); +} + #[tokio::test(flavor = "multi_thread")] async fn connects_again_after_the_connection_is_lost() { - use localhost_core::machine::{MachineState, Machines, MachinesEvent}; let Some(other) = OtherMachine::start() else { return }; let app_root = other.path().join("app-storage"); let machines = Machines::open(&app_root, &other.ssh()).unwrap(); - let devbox = machines.add(None, "devbox").unwrap(); - let (_, _, mut events) = machines.subscribe(); + let (_, mut events) = machines.subscribe(); let connected = |event: &MachinesEvent| { matches!(event, MachinesEvent::StateChanged { state: MachineState::Connected { .. }, .. }) }; - machines.connect(devbox.id).unwrap(); + let devbox = tokio::time::timeout(PATIENCE, machines.add(None, "devbox")).await.expect("ssh hung").unwrap(); until_event(&mut events, "connected", connected).await; - let (projects, _) = machines.connection(devbox.id).unwrap().projects().await.unwrap(); - assert!(projects.is_empty()); // The network drops, or the laptop slept past the heartbeat. machines.connection(devbox.id).unwrap().close(); @@ -315,9 +377,9 @@ async fn connects_again_after_the_connection_is_lost() { } async fn until_event( - events: &mut tokio::sync::mpsc::UnboundedReceiver, + events: &mut tokio::sync::mpsc::UnboundedReceiver, what: &str, - condition: impl Fn(&localhost_core::machine::MachinesEvent) -> bool, + condition: impl Fn(&MachinesEvent) -> bool, ) { let wait = async { while let Some(event) = events.recv().await { diff --git a/core/localhost-core/examples/machine.rs b/core/localhost-core/examples/machine.rs index 7dc0dc0..2047d8f 100644 --- a/core/localhost-core/examples/machine.rs +++ b/core/localhost-core/examples/machine.rs @@ -8,7 +8,7 @@ //! It lists the other Machine's Projects and Services and follows their //! changes. Type `start`, `stop`, `restart` or `open` and a //! `/` Key pair; `open` forwards the Service's Port and -//! prints its URL. `quit` (or Ctrl-D) ends the connection and its Forwards. +//! prints its URL, as Open in Browser does. `quit` (or Ctrl-D) ends the connection and its Forwards. //! The Machine list is kept in a temporary folder, never in localhost's own. use std::collections::HashMap; @@ -34,31 +34,12 @@ async fn main() { async fn run(destination: &str) -> Result<(), LocalhostError> { let storage_root = std::env::temp_dir().join(format!("localhost-machine-example-{}", std::process::id())); let machines = Machines::open(&storage_root, Path::new("ssh"))?; - let machine = machines.add(None, destination)?; - let (_, _, mut machine_events) = machines.subscribe(); - machines.connect(machine.id)?; - println!("Connecting to {}…", machine.name); - loop { - match machine_events.recv().await { - Some(MachinesEvent::StateChanged { state: MachineState::Connected { version }, .. }) => { - println!("Connected: localhost {version} there."); - break; - } - Some(MachinesEvent::StateChanged { state: MachineState::Unavailable { problem, is_retrying }, .. }) => { - println!("{}", problem.message(&machine.name)); - if !is_retrying { - return Ok(()); - } - } - Some(_) => {} - None => return Ok(()), - } - } - - let connection = machines.connection(machine.id)?; - let (mut projects, mut project_updates) = connection.projects().await?; - let (mut statuses, mut service_events) = connection.subscribe().await?; - print_projects(&projects, &statuses); + let (_, mut machine_events) = machines.subscribe(); + println!("Connecting to {destination}…"); + // Added only once it is reached; then kept connected. + let machine = machines.add(None, destination).await?; + let mut projects = Vec::new(); + let mut statuses = HashMap::new(); println!("\nstart|stop|restart|open /, or quit"); let mut input = lines_typed(); @@ -73,28 +54,42 @@ async fn run(destination: &str) -> Result<(), LocalhostError> { [] => continue, _ => { println!("start|stop|restart|open /, or quit"); continue } }; - let Some((project, service)) = find(&projects, target) else { + let Some(service) = find(&projects, target) else { println!("No Service {target} there."); continue; }; let result = match verb { - "start" => connection.start(service, None).await.map(|_| ()), - "stop" => connection.stop(service).await.map(|_| ()), - "restart" => connection.restart(service).await.map(|_| ()), - "open" => open(&machines, machine.id, &projects, &statuses, project, service).await, + "start" => machines.start(machine.id, service, None).await.map(|_| ()), + "stop" => machines.stop(machine.id, service).await.map(|_| ()), + "restart" => machines.restart(machine.id, service).await.map(|_| ()), + "open" => match machines.open_service(machine.id, service).await { + Ok(Some(url)) => { println!("Open {url}"); Ok(()) } + Ok(None) => { println!("{target} has nothing to open."); Ok(()) } + Err(error) => Err(error), + }, _ => { println!("start|stop|restart|open /, or quit"); continue } }; if let Err(error) = result { println!("{}", error.message()); } } - Some(updated) = project_updates.recv() => { - projects = updated; - println!("The Projects there changed."); - print_projects(&projects, &statuses); - } - Some(event) = service_events.recv() => { - if let ServiceEvent::ServiceStateChanged(status) = &event { + Some(event) = machine_events.recv() => match event { + MachinesEvent::StateChanged { state, .. } => match state { + MachineState::Connected { version } => println!("Connected: localhost {version} there."), + MachineState::Unavailable { problem, is_retrying } => { + println!("{}{}", problem.message(&machine.name), if is_retrying { " Trying again…" } else { "" }); + } + _ => {} + }, + MachinesEvent::ProjectsChanged { projects: updated, .. } => { + projects = updated; + print_projects(&projects, &statuses); + } + MachinesEvent::StatusesChanged { statuses: updated, .. } => { + statuses = updated.into_iter().map(|status| (status.service_id, status)).collect(); + print_projects(&projects, &statuses); + } + MachinesEvent::ServiceChanged { event: ServiceEvent::ServiceStateChanged(status), .. } => { let run = status.run.as_ref(); println!( "{} → {:?}{}{}", @@ -106,25 +101,10 @@ async fn run(destination: &str) -> Result<(), LocalhostError> { if let Some(failure) = run.and_then(|run| run.failure.as_ref()) { println!(" {}", failure.message()); } - statuses.insert(status.service_id, status.clone()); + statuses.insert(status.service_id, status); } - } - Some(MachinesEvent::StateChanged { state, .. }) = machine_events.recv() => { - match state { - MachineState::Unavailable { problem, is_retrying } => { - println!("{}{}", problem.message(&machine.name), if is_retrying { " Trying again…" } else { "" }); - } - MachineState::Connected { .. } => { - // A new connection: its Projects and statuses afresh. - println!("Connected again."); - let connection = machines.connection(machine.id)?; - (projects, project_updates) = connection.projects().await?; - (statuses, service_events) = connection.subscribe().await?; - print_projects(&projects, &statuses); - } - _ => {} - } - } + _ => {} + }, } } machines.disconnect(machine.id); @@ -147,38 +127,11 @@ fn lines_typed() -> mpsc::UnboundedReceiver { receiver } -/// Forwards the Service's Port, the same number here, and prints its URL. -async fn open( - machines: &Machines, - machine_id: Id, - projects: &[Project], - statuses: &HashMap, - project_id: Id, - service_id: Id, -) -> Result<(), LocalhostError> { - let service = projects - .iter() - .find(|project| project.id == project_id) - .and_then(|project| project.services.iter().find(|service| service.id == service_id)) - .expect("found above"); - let run = statuses.get(&service_id).and_then(|status| status.run.as_ref()); - let Some(port) = run.and_then(|run| run.port).or(service.port) else { - println!("{} has no Port.", service.key); - return Ok(()); - }; - let forward = machines.forward(machine_id, port, port).await?; - println!("Forwarded {} there to {} here.", forward.port, forward.local_port); - if let Some(url) = service.url_to_open(run) { - println!("Open {url}"); - } - Ok(()) -} - -fn find(projects: &[Project], target: &str) -> Option<(Id, Id)> { +fn find(projects: &[Project], target: &str) -> Option { let (project_key, service_key) = target.split_once('/')?; let project = projects.iter().find(|project| project.key.to_string() == project_key)?; let service = project.services.iter().find(|service| service.key.to_string() == service_key)?; - Some((project.id, service.id)) + Some(service.id) } fn name(projects: &[Project], service_id: Id) -> String { diff --git a/core/localhost-core/src/listener.rs b/core/localhost-core/src/listener.rs index e50864e..cd16028 100644 --- a/core/localhost-core/src/listener.rs +++ b/core/localhost-core/src/listener.rs @@ -206,6 +206,29 @@ pub(crate) fn scan() -> (Vec, HashMap) (platform::scan_listeners(), platform::managed_jobs()) } +/// The process listening on `port` of this machine, as a Listener of no +/// Run or Project, to say who holds a port localhost can't use. Blocking. +pub(crate) fn holding(port: u16) -> Option { + let (processes, jobs) = scan(); + let process = processes.into_iter().find(|process| process.ports.contains(&port))?; + let launchd_label = jobs.get(&process.pid).or_else(|| jobs.get(&process.process_group_id)).cloned(); + let owner = ListenerOwner::Unmanaged; + let kind = ListenerKind::of(process.executable_path.as_deref(), launchd_label.as_deref(), owner); + Some(Listener { + pid: process.pid, + process_group_id: process.process_group_id, + name: process.name, + command: process.command, + executable_path: process.executable_path, + ports: process.ports, + working_directory: process.working_directory, + owner, + launchd_label, + kind, + start_time: process.start_time, + }) +} + /// Stops a Listener localhost didn't start: SIGINT, then SIGTERM, then SIGKILL. /// On Windows there is no graceful step (Ctrl-Break can't reach another /// console): the process and its descendants are terminated. diff --git a/core/localhost-core/src/machine/machines.rs b/core/localhost-core/src/machine/machines.rs index ef228cb..8ee8c7e 100644 --- a/core/localhost-core/src/machine/machines.rs +++ b/core/localhost-core/src/machine/machines.rs @@ -1,18 +1,21 @@ //! The other Machines an app shows: which they are, kept in `machines.json` -//! in the storage root, and the connection to each while the app wants it. +//! in the storage root, the connection to each while the app wants it, and +//! what each shows through it: its Projects and every Service's status. //! -//! A connection that is lost is made again on its own, after a pause that -//! grows (`RETRY_DELAYS`), or at once on `wake` (the computer woke, the -//! network changed). That only reconnects; it never starts or stops a -//! Service. A problem that waiting won't fix (an untrusted host key, a -//! sign-in that needs a prompt, no localhost there) waits for the user to -//! connect again. - -use std::collections::HashMap; +//! That view outlives a connection. A connection that is lost is made again +//! on its own, after a pause that grows (`RETRY_DELAYS`), or at once on +//! `wake` (the computer woke, the network changed); the Projects and +//! statuses are then read afresh. That only reconnects; it never starts or +//! stops a Service. A problem that waiting won't fix (an untrusted host key, +//! a sign-in that needs a prompt, no localhost there, anything SSH says that +//! localhost doesn't know) is shown once and waits for the user to connect +//! again. + +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -20,7 +23,7 @@ use tokio::sync::{Notify, mpsc}; use tokio::task::JoinHandle; use super::{Forward, Machine, MachineConnection, MachineProblem, SshLink}; -use crate::{Id, LocalhostError}; +use crate::{Id, LocalhostError, OutputLine, Project, ServiceEvent, ServiceOutputLine, ServiceStatus}; const FILE_NAME: &str = "machines.json"; const SCHEMA_VERSION: i64 = 1; @@ -54,8 +57,26 @@ pub enum MachineState { }, } -#[derive(Clone, Debug, PartialEq, Eq)] +/// One Machine as an app shows it. Its Projects and statuses are those of +/// its last connection, kept while it reconnects. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct MachineSnapshot { + pub machine: Machine, + pub state: MachineState, + pub projects: Vec, + /// Services that never ran there are left out. + pub statuses: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] #[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +#[cfg_attr(feature = "specta", derive(specta::Type))] +// Not boxed: UniFFI carries no `Box`, and events go one at a time. +#[allow(clippy::large_enum_variant)] pub enum MachinesEvent { /// The list changed: added, removed or renamed. MachinesChanged { @@ -65,6 +86,20 @@ pub enum MachinesEvent { machine_id: Id, state: MachineState, }, + /// Its Projects, after it connected or they changed there. + ProjectsChanged { + machine_id: Id, + projects: Vec, + }, + /// Every status there, read afresh after it connected. + StatusesChanged { + machine_id: Id, + statuses: Vec, + }, + ServiceChanged { + machine_id: Id, + event: ServiceEvent, + }, } #[derive(Serialize, Deserialize)] @@ -77,6 +112,7 @@ struct File { /// Every other Machine, and the connections to them. Cheap to clone. Must /// be used inside a tokio runtime. #[derive(Clone)] +#[cfg_attr(feature = "ffi", derive(uniffi::Object))] pub struct Machines { inner: Arc, } @@ -101,6 +137,8 @@ struct Slot { connection: Option, keeper: Option>, retry_now: Arc, + projects: Vec, + statuses: HashMap, } impl Default for Slot { @@ -111,6 +149,8 @@ impl Default for Slot { connection: None, keeper: None, retry_now: Arc::new(Notify::new()), + projects: Vec::new(), + statuses: HashMap::new(), } } } @@ -155,39 +195,57 @@ impl Machines { self.inner.state().machines.clone() } - /// The Machines, their states, and every change after them. - pub fn subscribe(&self) -> (Vec, HashMap, mpsc::UnboundedReceiver) { + /// Every Machine as it is now, and every change after that. + pub fn subscribe(&self) -> (Vec, mpsc::UnboundedReceiver) { let mut state = self.inner.state(); let (sender, receiver) = mpsc::unbounded_channel(); state.subscribers.push(sender); - let states = state.machines.iter().map(|machine| (machine.id, state.machine_state(machine.id))).collect(); - (state.machines.clone(), states, receiver) + let snapshots = state.machines.iter().map(|machine| state.snapshot(machine)).collect(); + (snapshots, receiver) } pub fn state(&self, machine_id: Id) -> MachineState { self.inner.state().machine_state(machine_id) } - /// Remembers the Machine `ssh` reaches as `destination`, called `name` - /// (by default its destination). Doesn't connect. - pub fn add(&self, name: Option, destination: &str) -> Result { - let destination = destination.trim(); - // Passed after `--`, but an option-like destination is never meant. - if destination.is_empty() || destination.starts_with('-') || destination.chars().any(char::is_whitespace) { - return Err(LocalhostError::InvalidMachineDestination(destination.to_string())); - } - let name = name.map(|name| name.trim().to_string()).filter(|name| !name.is_empty()); - let machine = Machine { - id: Id::new(), - name: name.unwrap_or_else(|| destination.to_string()), - destination: destination.into(), - }; + /// Connects to the Machine `ssh` reaches as `destination`, and only once + /// that works remembers it, called `name` (by default its destination), + /// and keeps it connected. Fails with why it can't be reached. + pub async fn add(&self, name: Option, destination: &str) -> Result { + let machine = self.inner.state().new_machine(name, destination)?; + let (link, connection) = SshLink::connect(machine.clone(), &self.inner.ssh, &self.inner.control_folder).await?; let mut state = self.inner.state(); - if state.machines.iter().any(|existing| existing.destination == machine.destination) { - return Err(LocalhostError::DuplicateMachine(machine.destination)); + if let Err(error) = state.insert(&self.inner, machine.clone()) { + link.end(); + return Err(error); } - let mut machines = state.machines.clone(); - machines.push(machine.clone()); + let slot = state.slots.entry(machine.id).or_default(); + let weak = Arc::downgrade(&self.inner); + slot.keeper = + Some(tokio::spawn(keep_connected(weak, machine.id, slot.retry_now.clone(), Some((link, connection))))); + Ok(machine) + } + + /// Remembers a Machine without connecting to it. + #[cfg(test)] + fn remember(&self, name: Option, destination: &str) -> Result { + let mut state = self.inner.state(); + let machine = state.new_machine(name, destination)?; + state.insert(&self.inner, machine.clone())?; + Ok(machine) + } + + /// Calls the Machine `name`; an empty one is its destination. + pub fn rename(&self, machine_id: Id, name: &str) -> Result { + let mut state = self.inner.state(); + let mut machine = state.machine(machine_id)?; + let name = name.trim(); + machine.name = if name.is_empty() { machine.destination.clone() } else { name.to_string() }; + let machines: Vec = state + .machines + .iter() + .map(|existing| if existing.id == machine_id { machine.clone() } else { existing.clone() }) + .collect(); self.inner.save(&machines)?; state.machines = machines.clone(); state.publish(MachinesEvent::MachinesChanged { machines }); @@ -209,9 +267,10 @@ impl Machines { /// Connects to the Machine, and keeps connecting when the connection is /// lost, until `disconnect`. Returns at once; the state says how it goes. + /// While it is waiting to try again, it tries now. pub fn connect(&self, machine_id: Id) -> Result<(), LocalhostError> { let mut state = self.inner.state(); - let machine = state.machine(machine_id)?; + state.machine(machine_id)?; let slot = state.slots.entry(machine_id).or_default(); if slot.keeper.as_ref().is_some_and(|keeper| !keeper.is_finished()) { slot.retry_now.notify_one(); @@ -219,10 +278,17 @@ impl Machines { } let retry_now = slot.retry_now.clone(); let weak = Arc::downgrade(&self.inner); - slot.keeper = Some(tokio::spawn(keep_connected(weak, machine, retry_now))); + slot.keeper = Some(tokio::spawn(keep_connected(weak, machine_id, retry_now, None))); Ok(()) } + /// Ends the connection, even a working one, and makes it again. + pub fn reconnect(&self, machine_id: Id) -> Result<(), LocalhostError> { + self.inner.state().machine(machine_id)?; + self.disconnect(machine_id); + self.connect(machine_id) + } + /// Ends the connection to the Machine, and its Forwards. Its Runs are /// left to its own rules: an app open there keeps them. pub fn disconnect(&self, machine_id: Id) { @@ -256,10 +322,78 @@ impl Machines { let machine = state.machine(machine_id)?; match state.slots.get(&machine_id).and_then(|slot| slot.connection.clone()) { Some(connection) if connection.is_connected() => Ok(connection), - _ => Err(LocalhostError::MachineUnavailable { machine: machine.name, problem: MachineProblem::Lost }), + _ => Err(LocalhostError::MachineUnavailable { machine: machine.name, problem: state.problem(machine_id) }), } } + // MARK: Its Services + + pub async fn start( + &self, + machine_id: Id, + service_id: Id, + on_port: Option, + ) -> Result { + self.connection(machine_id)?.start(service_id, on_port).await + } + + pub async fn stop(&self, machine_id: Id, service_id: Id) -> Result { + self.connection(machine_id)?.stop(service_id).await + } + + pub async fn restart(&self, machine_id: Id, service_id: Id) -> Result { + self.connection(machine_id)?.restart(service_id).await + } + + pub async fn start_all(&self, machine_id: Id, project_id: Id) -> Result<(), LocalhostError> { + self.connection(machine_id)?.start_all(project_id).await + } + + pub async fn stop_all(&self, machine_id: Id, project_id: Id) -> Result<(), LocalhostError> { + self.connection(machine_id)?.stop_all(project_id).await + } + + /// A Run's buffered lines there, then every line after them until it ends. + pub async fn subscribe_to_run( + &self, + machine_id: Id, + run_id: Id, + ) -> Result<(Vec, mpsc::UnboundedReceiver), LocalhostError> { + self.connection(machine_id)?.subscribe_to_run(run_id).await + } + + /// Several Services' lines there, buffered then live, as one stream. + pub async fn subscribe_to_services( + &self, + machine_id: Id, + service_ids: HashSet, + ) -> Result<(Vec, mpsc::UnboundedReceiver), LocalhostError> { + self.connection(machine_id)?.subscribe_to_services(service_ids).await + } + + /// The URL to open for a Service of the Machine on this computer, as + /// for a Service here (`Service::url_to_open`), its Port forwarded to the + /// same port here first. `None` when it has nothing to open. A URL that + /// isn't local to the Machine is returned as it is. + pub async fn open_service(&self, machine_id: Id, service_id: Id) -> Result, LocalhostError> { + let url = { + let state = self.inner.state(); + let slot = state.slots.get(&machine_id).ok_or(LocalhostError::MachineNotFound(machine_id))?; + let service = slot + .projects + .iter() + .flat_map(|project| &project.services) + .find(|service| service.id == service_id) + .ok_or(LocalhostError::ServiceNotFound(service_id))?; + service.url_to_open(slot.statuses.get(&service_id).and_then(|status| status.run.as_ref())) + }; + let Some(url) = url else { return Ok(None) }; + if let Some(port) = local_port(&url) { + self.forward(machine_id, port, port).await?; + } + Ok(Some(url)) + } + /// Forwards the Machine's `port` to `local_port` here (`SshLink::forward_to`). pub async fn forward(&self, machine_id: Id, port: u16, local_port: u16) -> Result { self.link(machine_id)?.forward_to(port, local_port).await @@ -282,7 +416,36 @@ impl Machines { .slots .get(&machine_id) .and_then(|slot| slot.link.clone()) - .ok_or(LocalhostError::MachineUnavailable { machine: machine.name, problem: MachineProblem::Lost }) + .ok_or(LocalhostError::MachineUnavailable { machine: machine.name, problem: state.problem(machine_id) }) + } +} + +/// The port of `url` when its host is the Machine's own (`localhost`, a +/// loopback address, or a `*.localhost` name), which a Forward reaches. +fn local_port(url: &str) -> Option { + let (scheme, rest) = url.split_once("://")?; + let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())]; + let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host); + let (host, port) = match authority.strip_prefix('[') { + Some(bracketed) => { + let (host, after) = bracketed.split_once(']')?; + (host, after.strip_prefix(':')) + } + None => match authority.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }, + }; + let host = host.to_ascii_lowercase(); + let is_local = + matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0") || host.ends_with(".localhost"); + if !is_local { + return None; + } + match port { + Some(port) => port.parse().ok(), + None if scheme.eq_ignore_ascii_case("https") => Some(443), + None => Some(80), } } @@ -326,10 +489,59 @@ impl State { .ok_or(LocalhostError::MachineNotFound(machine_id)) } + /// A Machine to add, if `destination` is one `ssh` reads as meant and no + /// other Machine has. + fn new_machine(&self, name: Option, destination: &str) -> Result { + let destination = destination.trim(); + // Passed after `--`, but an option-like destination is never meant. + if destination.is_empty() || destination.starts_with('-') || destination.chars().any(char::is_whitespace) { + return Err(LocalhostError::InvalidMachineDestination(destination.to_string())); + } + if self.machines.iter().any(|existing| existing.destination == destination) { + return Err(LocalhostError::DuplicateMachine(destination.to_string())); + } + let name = name.map(|name| name.trim().to_string()).filter(|name| !name.is_empty()); + Ok(Machine { + id: Id::new(), + name: name.unwrap_or_else(|| destination.to_string()), + destination: destination.into(), + }) + } + + fn insert(&mut self, inner: &Inner, machine: Machine) -> Result<(), LocalhostError> { + if self.machines.iter().any(|existing| existing.destination == machine.destination) { + return Err(LocalhostError::DuplicateMachine(machine.destination)); + } + let mut machines = self.machines.clone(); + machines.push(machine); + inner.save(&machines)?; + self.machines = machines.clone(); + self.publish(MachinesEvent::MachinesChanged { machines }); + Ok(()) + } + fn machine_state(&self, machine_id: Id) -> MachineState { self.slots.get(&machine_id).map_or(MachineState::NotConnected, |slot| slot.state.clone()) } + /// Why the Machine isn't connected, for an error. + fn problem(&self, machine_id: Id) -> MachineProblem { + match self.machine_state(machine_id) { + MachineState::Unavailable { problem, .. } => problem, + _ => MachineProblem::Lost, + } + } + + fn snapshot(&self, machine: &Machine) -> MachineSnapshot { + let slot = self.slots.get(&machine.id); + MachineSnapshot { + machine: machine.clone(), + state: self.machine_state(machine.id), + projects: slot.map(|slot| slot.projects.clone()).unwrap_or_default(), + statuses: slot.map(|slot| slot.statuses.values().cloned().collect()).unwrap_or_default(), + } + } + fn set(&mut self, machine_id: Id, machine_state: MachineState) { let slot = self.slots.entry(machine_id).or_default(); if slot.state == machine_state { @@ -351,38 +563,31 @@ fn is_passing(problem: &MachineProblem) -> bool { matches!(problem, MachineProblem::Lost | MachineProblem::Unreachable(_) | MachineProblem::HostNotFound) } -/// Connects to `machine` until told to stop (the task is aborted), again -/// after each loss. -async fn keep_connected(inner: std::sync::Weak, machine: Machine, retry_now: Arc) { +/// Connects to the Machine until told to stop (the task is aborted), again +/// after each loss. `established`: a connection made already, by `add`. +async fn keep_connected( + inner: Weak, + machine_id: Id, + retry_now: Arc, + mut established: Option<(Arc, MachineConnection)>, +) { let mut attempt = 0; loop { - let Some(strong) = inner.upgrade() else { return }; - strong.state().set(machine.id, MachineState::Connecting); - let (ssh, control_folder) = (strong.ssh.clone(), strong.control_folder.clone()); - drop(strong); - - let problem = match SshLink::connect(machine.clone(), &ssh, &control_folder).await { - Ok((link, connection)) => { - attempt = 0; - let version = connection.hello().version.clone(); - { - let Some(strong) = inner.upgrade() else { return }; - let mut state = strong.state(); - let slot = state.slots.entry(machine.id).or_default(); - slot.link = Some(link); - slot.connection = Some(connection.clone()); - state.set(machine.id, MachineState::Connected { version }); - } - connection.closed().await; + let connected = match established.take() { + Some(connected) => Ok(connected), + None => { let Some(strong) = inner.upgrade() else { return }; - let link = { - let mut state = strong.state(); - let slot = state.slots.entry(machine.id).or_default(); - slot.connection = None; - slot.link.take() - }; - if let Some(link) = link { - link.end(); + let Ok(machine) = strong.state().machine(machine_id) else { return }; + strong.state().set(machine_id, MachineState::Connecting); + let (ssh, control_folder) = (strong.ssh.clone(), strong.control_folder.clone()); + drop(strong); + SshLink::connect(machine, &ssh, &control_folder).await + } + }; + let problem = match connected { + Ok((link, connection)) => { + if follow(&inner, machine_id, link, connection).await { + attempt = 0; } MachineProblem::Lost } @@ -391,9 +596,9 @@ async fn keep_connected(inner: std::sync::Weak, machine: Machine, retry_n }; let is_retrying = is_passing(&problem); - if let Some(strong) = inner.upgrade() { - strong.state().set(machine.id, MachineState::Unavailable { problem, is_retrying }); - } + let Some(strong) = inner.upgrade() else { return }; + strong.state().set(machine_id, MachineState::Unavailable { problem, is_retrying }); + drop(strong); if !is_retrying { return; } @@ -406,6 +611,91 @@ async fn keep_connected(inner: std::sync::Weak, machine: Machine, retry_n } } +/// Reads the Machine's Projects and statuses over a new connection, then +/// follows their changes until it ends. Returns whether it was connected. +async fn follow(inner: &Weak, machine_id: Id, link: Arc, connection: MachineConnection) -> bool { + { + let Some(strong) = inner.upgrade() else { return false }; + let mut state = strong.state(); + let slot = state.slots.entry(machine_id).or_default(); + // Only the slot holds the link: once the Machines are gone, the + // connection ends with it. + slot.link = Some(link); + slot.connection = Some(connection.clone()); + } + let read = async { + let (projects, project_updates) = connection.projects().await?; + let (statuses, service_events) = connection.subscribe().await?; + Ok::<_, LocalhostError>((projects, project_updates, statuses, service_events)) + }; + let is_connected = match read.await { + Ok((projects, mut project_updates, statuses, mut service_events)) => { + { + let Some(strong) = inner.upgrade() else { return false }; + let mut state = strong.state(); + let slot = state.slots.entry(machine_id).or_default(); + slot.projects = projects.clone(); + slot.statuses = statuses; + let statuses = slot.statuses.values().cloned().collect(); + state.publish(MachinesEvent::ProjectsChanged { machine_id, projects }); + state.publish(MachinesEvent::StatusesChanged { machine_id, statuses }); + let version = connection.hello().version.clone(); + state.set(machine_id, MachineState::Connected { version }); + } + loop { + tokio::select! { + Some(projects) = project_updates.recv() => { + let Some(strong) = inner.upgrade() else { return true }; + let mut state = strong.state(); + state.slots.entry(machine_id).or_default().projects = projects.clone(); + state.publish(MachinesEvent::ProjectsChanged { machine_id, projects }); + } + Some(event) = service_events.recv() => { + let Some(strong) = inner.upgrade() else { return true }; + let mut state = strong.state(); + apply(&mut state.slots.entry(machine_id).or_default().statuses, &event); + state.publish(MachinesEvent::ServiceChanged { machine_id, event }); + } + _ = connection.closed() => break, + } + } + true + } + Err(_) => { + connection.close(); + false + } + }; + if let Some(strong) = inner.upgrade() { + let link = { + let mut state = strong.state(); + let slot = state.slots.entry(machine_id).or_default(); + slot.connection = None; + slot.link.take() + }; + if let Some(link) = link { + link.end(); + } + } + is_connected +} + +/// Updates the statuses as the Service event says. +fn apply(statuses: &mut HashMap, event: &ServiceEvent) { + match event { + ServiceEvent::ServiceStateChanged(status) => { + statuses.insert(status.service_id, status.clone()); + } + ServiceEvent::RunStarted(run) | ServiceEvent::RunEnded(run) => { + if let Some(status) = statuses.get_mut(&run.service_id) + && status.run.as_ref().is_some_and(|current| current.id == run.id) + { + status.run = Some(run.clone()); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -414,9 +704,9 @@ mod tests { async fn remembers_machines_across_launches() { let root = tempfile::tempdir().unwrap(); let machines = Machines::open(root.path(), Path::new("ssh")).unwrap(); - let devbox = machines.add(None, " devbox ").unwrap(); + let devbox = machines.remember(None, " devbox ").unwrap(); assert_eq!((devbox.name.as_str(), devbox.destination.as_str()), ("devbox", "devbox")); - let lab = machines.add(Some("Lab".into()), "me@lab.example.com").unwrap(); + let lab = machines.remember(Some("Lab".into()), "me@lab.example.com").unwrap(); let reopened = Machines::open(root.path(), Path::new("ssh")).unwrap(); assert_eq!(reopened.machines(), [devbox.clone(), lab.clone()]); @@ -431,11 +721,11 @@ mod tests { let root = tempfile::tempdir().unwrap(); let machines = Machines::open(root.path(), Path::new("ssh")).unwrap(); for destination in ["", " ", "-oProxyCommand=evil", "dev box"] { - assert!(matches!(machines.add(None, destination), Err(LocalhostError::InvalidMachineDestination(_)))); + assert!(matches!(machines.remember(None, destination), Err(LocalhostError::InvalidMachineDestination(_)))); } - machines.add(None, "devbox").unwrap(); + machines.remember(None, "devbox").unwrap(); assert_eq!( - machines.add(Some("Again".into()), "devbox"), + machines.remember(Some("Again".into()), "devbox"), Err(LocalhostError::DuplicateMachine("devbox".into())) ); } @@ -444,16 +734,10 @@ mod tests { async fn says_why_it_cant_connect_and_waits_when_waiting_wont_help() { let root = tempfile::tempdir().unwrap(); // An `ssh` that fails as an unknown host key does. - let ssh = root.path().join("ssh"); - fs::write(&ssh, "#!/bin/sh\necho 'Host key verification failed.' >&2\nexit 255\n").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&ssh, fs::Permissions::from_mode(0o755)).unwrap(); - } + let ssh = failing_ssh(root.path(), "Host key verification failed."); let machines = Machines::open(root.path(), &ssh).unwrap(); - let devbox = machines.add(None, "devbox").unwrap(); - let (_, _, mut events) = machines.subscribe(); + let devbox = machines.remember(None, "devbox").unwrap(); + let (_, mut events) = machines.subscribe(); machines.connect(devbox.id).unwrap(); let expected = MachineState::Unavailable { problem: MachineProblem::HostKeyNotTrusted, is_retrying: false }; tokio::time::timeout(Duration::from_secs(20), async { @@ -467,4 +751,99 @@ mod tests { .expect("never said why"); assert!(machines.connection(devbox.id).is_err()); } + + /// An `ssh` at `root`/ssh that writes `said` to standard error and fails. + fn failing_ssh(root: &Path, said: &str) -> PathBuf { + let ssh = root.join("ssh"); + fs::write(&ssh, format!("#!/bin/sh\necho '{said}' >&2\nexit 255\n")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&ssh, fs::Permissions::from_mode(0o755)).unwrap(); + // A test forking on another thread may hold the file open for + // writing a moment longer, so it can't be run yet ("Text file busy"). + for _ in 0..100 { + match std::process::Command::new(&ssh).output() { + Err(error) if error.raw_os_error() == Some(26) => std::thread::sleep(Duration::from_millis(20)), + _ => break, + } + } + } + ssh + } + + #[cfg(unix)] + #[tokio::test] + async fn adds_a_machine_only_once_it_is_reached() { + let root = tempfile::tempdir().unwrap(); + let ssh = failing_ssh(root.path(), "ssh: Could not resolve hostname devbx: Name or service not known"); + let machines = Machines::open(root.path(), &ssh).unwrap(); + let error = machines.add(Some("Dev".into()), "devbx").await.unwrap_err(); + assert_eq!( + error, + LocalhostError::MachineUnavailable { machine: "Dev".into(), problem: MachineProblem::HostNotFound } + ); + assert!(machines.machines().is_empty()); + assert!(Machines::open(root.path(), &ssh).unwrap().machines().is_empty()); + } + + #[cfg(unix)] + #[tokio::test] + async fn shows_what_ssh_says_once_instead_of_retrying_it() { + let root = tempfile::tempdir().unwrap(); + let ssh = failing_ssh(root.path(), "/home/me/.ssh/config: line 3: Bad configuration option: hostnme"); + let machines = Machines::open(root.path(), &ssh).unwrap(); + let devbox = machines.remember(None, "devbox").unwrap(); + let (_, mut events) = machines.subscribe(); + machines.connect(devbox.id).unwrap(); + let mut attempts = 0; + let mut last = None; + let _ = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(event) = events.recv().await { + if let MachinesEvent::StateChanged { state, .. } = event { + if state == MachineState::Connecting { + attempts += 1; + } + last = Some(state); + } + } + }) + .await; + assert_eq!(attempts, 1, "tried again what waiting won't fix"); + assert_eq!( + last, + Some(MachineState::Unavailable { + problem: MachineProblem::Other( + "/home/me/.ssh/config: line 3: Bad configuration option: hostnme".into() + ), + is_retrying: false, + }) + ); + } + + #[tokio::test] + async fn renames_a_machine() { + let root = tempfile::tempdir().unwrap(); + let machines = Machines::open(root.path(), Path::new("ssh")).unwrap(); + let devbox = machines.remember(None, "me@devbox").unwrap(); + let (_, mut events) = machines.subscribe(); + assert_eq!(machines.rename(devbox.id, " Dev box ").unwrap().name, "Dev box"); + assert!(matches!(events.recv().await, Some(MachinesEvent::MachinesChanged { .. }))); + assert_eq!(machines.rename(devbox.id, " ").unwrap().name, "me@devbox"); + let reopened = Machines::open(root.path(), Path::new("ssh")).unwrap(); + assert_eq!(reopened.machines()[0].name, "me@devbox"); + assert!(matches!(machines.rename(Id::new(), "x"), Err(LocalhostError::MachineNotFound(_)))); + } + + #[test] + fn forwards_only_urls_local_to_the_machine() { + assert_eq!(local_port("http://localhost:3000/app"), Some(3000)); + assert_eq!(local_port("http://127.0.0.1:5173"), Some(5173)); + assert_eq!(local_port("http://[::1]:8080/"), Some(8080)); + assert_eq!(local_port("http://shop.localhost:3000"), Some(3000)); + assert_eq!(local_port("https://localhost"), Some(443)); + assert_eq!(local_port("http://LOCALHOST"), Some(80)); + assert_eq!(local_port("https://app.example.com:3000"), None); + assert_eq!(local_port("not a url"), None); + } } diff --git a/core/localhost-core/src/machine/mod.rs b/core/localhost-core/src/machine/mod.rs index d1e2cd4..2d0f3e5 100644 --- a/core/localhost-core/src/machine/mod.rs +++ b/core/localhost-core/src/machine/mod.rs @@ -22,7 +22,7 @@ mod ssh_config; use serde::{Deserialize, Serialize}; pub use connection::MachineConnection; -pub use machines::{MachineState, Machines, MachinesEvent}; +pub use machines::{MachineSnapshot, MachineState, Machines, MachinesEvent}; pub use protocol::{MACHINE_PROTOCOL_VERSION, MachineHello}; pub use ssh::{Forward, SshLink}; pub use ssh_config::ssh_config_hosts; diff --git a/core/localhost-core/src/machine/ssh.rs b/core/localhost-core/src/machine/ssh.rs index 4b82ccc..3452247 100644 --- a/core/localhost-core/src/machine/ssh.rs +++ b/core/localhost-core/src/machine/ssh.rs @@ -182,7 +182,8 @@ impl SshLink { // on macOS could bind beside a Service listening on every address, // hiding it from this computer's browser. if !is_free(local_port) || crate::port_probe::is_accepting(local_port).await { - return Err(LocalhostError::PortConflict { port: local_port, owner: None }); + let owner = tokio::task::spawn_blocking(move || crate::listener::holding(local_port)).await.ok().flatten(); + return Err(LocalhostError::PortConflict { port: local_port, owner: owner.map(Box::new) }); } self.control("forward", &format!("127.0.0.1:{local_port}:localhost:{port}"), port).await?; // Browsers may try `::1` first for `localhost`. Not every computer has it. From d29e1007410b56f9fecfdeab0da0ee2f03e33e73 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:51:57 -0300 Subject: [PATCH 09/27] Give ssh a typed user@host:port as the ssh:// destination it reads a port from --- core/localhost-core/src/machine/ssh.rs | 37 ++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/core/localhost-core/src/machine/ssh.rs b/core/localhost-core/src/machine/ssh.rs index 3452247..b646706 100644 --- a/core/localhost-core/src/machine/ssh.rs +++ b/core/localhost-core/src/machine/ssh.rs @@ -101,7 +101,7 @@ impl SshLink { .args(options()) .args(master_options()) .arg("--") - .arg(&machine.destination) + .arg(ssh_destination(&machine.destination)) .arg(REMOTE_COMMAND) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -230,7 +230,7 @@ impl SshLink { .arg(spec) .args(options()) .arg("--") - .arg(&self.machine.destination) + .arg(ssh_destination(&self.machine.destination)) .stdin(Stdio::null()) .output() .await @@ -250,6 +250,22 @@ impl Drop for SshLink { } } +/// What `ssh` is given for a Machine's `destination`: `user@host:port`, as +/// people write it, in the `ssh://` form `ssh` reads a port from; a `Host` +/// of `~/.ssh/config` or `user@host` as it is. +fn ssh_destination(destination: &str) -> String { + let Some((host, port)) = destination.rsplit_once(':') else { return destination.to_string() }; + let is_port = !port.is_empty() && port.len() <= 5 && port.chars().all(|character| character.is_ascii_digit()); + let address = host.rsplit_once('@').map_or(host, |(_, address)| address); + // An IPv6 address only with brackets: `[::1]:2222`. + let is_host = + !address.is_empty() && (!address.contains(':') || (address.starts_with('[') && address.ends_with(']'))); + if destination.starts_with("ssh://") || !is_port || !is_host { + return destination.to_string(); + } + format!("ssh://{destination}") +} + /// Whether nothing on this computer listens on `port` of IPv4's loopback. fn is_free(port: u16) -> bool { TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).is_ok() @@ -264,3 +280,20 @@ fn create_private_folder(folder: &Path) -> std::io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::ssh_destination; + + #[test] + fn gives_ssh_a_port_in_the_form_it_reads() { + assert_eq!(ssh_destination("devbox"), "devbox"); + assert_eq!(ssh_destination("me@devbox"), "me@devbox"); + assert_eq!(ssh_destination("me@devbox:2222"), "ssh://me@devbox:2222"); + assert_eq!(ssh_destination("10.0.0.9:22"), "ssh://10.0.0.9:22"); + assert_eq!(ssh_destination("me@[fd00::9]:2222"), "ssh://me@[fd00::9]:2222"); + assert_eq!(ssh_destination("ssh://me@devbox:2222"), "ssh://me@devbox:2222"); + assert_eq!(ssh_destination("fd00::9"), "fd00::9"); + assert_eq!(ssh_destination("devbox:"), "devbox:"); + } +} From 466c0fedad2a7c591020b7ae5d1290f47a9ef1b5 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:51:57 -0300 Subject: [PATCH 10/27] Export Machines to the macOS app: its events, controls, logs and Open through a Forward, with the hosts of ~/.ssh/config --- core/localhost-core/src/ffi/functions.rs | 14 ++ core/localhost-core/src/ffi/objects.rs | 150 ++++++++++++++++++- core/localhost-core/src/ffi/subscriptions.rs | 7 + 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/core/localhost-core/src/ffi/functions.rs b/core/localhost-core/src/ffi/functions.rs index c48b4bb..426a03a 100644 --- a/core/localhost-core/src/ffi/functions.rs +++ b/core/localhost-core/src/ffi/functions.rs @@ -35,6 +35,12 @@ pub fn localhost_error_message(error: &LocalhostError) -> String { error.message() } +/// Why `machine` can't be reached, and what to do next. +#[uniffi::export] +pub fn machine_problem_message(problem: &crate::MachineProblem, machine: &str) -> String { + problem.message(machine) +} + #[uniffi::export] pub fn project_file_problem_message(problem: &ProjectFileProblem) -> String { problem.message() @@ -172,6 +178,14 @@ pub async fn scan_project(project: Project) -> Vec { blocking(move || ProjectScanner::default().scan_project(&project)).await } +// MARK: Machines + +/// The hosts named in `~/.ssh/config`, to suggest as a Machine's destination. +#[uniffi::export] +pub async fn ssh_config_hosts() -> Vec { + blocking(crate::machine::ssh_config_hosts).await +} + // MARK: Text #[uniffi::export] diff --git a/core/localhost-core/src/ffi/objects.rs b/core/localhost-core/src/ffi/objects.rs index 164f560..2ff3faf 100644 --- a/core/localhost-core/src/ffi/objects.rs +++ b/core/localhost-core/src/ffi/objects.rs @@ -9,11 +9,14 @@ use std::path::Path; use std::sync::Arc; use super::runtime::{blocking, on_runtime}; -use super::subscriptions::{FoundOrphansEvents, LibraryEvents, OutputLines, ServiceEvents, ServiceOutputLines}; +use super::subscriptions::{ + FoundOrphansEvents, LibraryEvents, MachinesEvents, OutputLines, ServiceEvents, ServiceOutputLines, +}; +use crate::machine::{Forward, MachineSnapshot, Machines}; use crate::remote::{Client, shared}; use crate::{ AgentClient, AgentSetup, AgentStatus, CommandLineStatus, EnvironmentProfile, ExecutionTarget, Id, Key, KeyClash, - Link, Listener, LocalhostError, LogStore, NewService, Origin, Orphan, OutputLine, ProfileChanges, Project, + Link, Listener, LocalhostError, LogStore, Machine, NewService, Origin, Orphan, OutputLine, ProfileChanges, Project, ProjectChanges, ProjectDocument, ProjectLibrary, RunRegistry, RunStats, Service, ServiceChanges, ServiceManager, ServiceManagerOptions, ServiceOutputLine, ServiceStatus, StopPolicy, SystemSecretStore, Targets, UnreadableProjectFile, VariableInput, VariableLayer, @@ -589,6 +592,149 @@ impl Targets { } } +// MARK: Machines + +#[derive(uniffi::Record)] +pub struct MachinesSubscription { + pub machines: Vec, + pub events: Arc, +} + +/// The other Machines the app shows, reached with the system's `ssh`. One +/// object for all of them: a Machine's controls and logs go through it by +/// the Machine's id, so they survive its reconnections. Every change is made +/// as `Origin::Machine` on the other Machine, which decides it. +#[uniffi::export] +impl Machines { + /// The Machines saved in `storage_root`. + #[uniffi::constructor] + pub fn ffi_open(storage_root: String) -> Result, LocalhostError> { + Machines::open(Path::new(&storage_root), Path::new("ssh")).map(Arc::new) + } + + #[uniffi::method] + pub fn ffi_machines(&self) -> Vec { + self.machines() + } + + #[uniffi::method] + pub fn ffi_subscribe(&self) -> MachinesSubscription { + let (machines, receiver) = self.subscribe(); + MachinesSubscription { machines, events: MachinesEvents::new(receiver) } + } + + /// Connects first, and remembers the Machine only once that works. + #[uniffi::method] + pub async fn ffi_add( + self: Arc, + name: Option, + destination: String, + ) -> Result { + on_runtime(async move { self.add(name, &destination).await }).await + } + + #[uniffi::method] + pub async fn ffi_rename(self: Arc, machine_id: Id, name: String) -> Result { + blocking(move || self.rename(machine_id, &name)).await + } + + #[uniffi::method] + pub async fn ffi_remove(self: Arc, machine_id: Id) -> Result<(), LocalhostError> { + on_runtime(async move { self.remove(machine_id) }).await + } + + #[uniffi::method] + pub fn ffi_connect(&self, machine_id: Id) -> Result<(), LocalhostError> { + let _runtime = super::runtime::enter(); + self.connect(machine_id) + } + + #[uniffi::method] + pub fn ffi_reconnect(&self, machine_id: Id) -> Result<(), LocalhostError> { + let _runtime = super::runtime::enter(); + self.reconnect(machine_id) + } + + #[uniffi::method] + pub fn ffi_disconnect(&self, machine_id: Id) { + let _runtime = super::runtime::enter(); + self.disconnect(machine_id) + } + + /// The computer woke or its network changed: tries waiting connections now. + #[uniffi::method] + pub fn ffi_wake(&self) { + self.wake() + } + + #[uniffi::method] + pub async fn ffi_start( + self: Arc, + machine_id: Id, + service_id: Id, + on_port: Option, + ) -> Result { + on_runtime(async move { self.start(machine_id, service_id, on_port).await }).await + } + + #[uniffi::method] + pub async fn ffi_stop(self: Arc, machine_id: Id, service_id: Id) -> Result { + on_runtime(async move { self.stop(machine_id, service_id).await }).await + } + + #[uniffi::method] + pub async fn ffi_restart(self: Arc, machine_id: Id, service_id: Id) -> Result { + on_runtime(async move { self.restart(machine_id, service_id).await }).await + } + + #[uniffi::method] + pub async fn ffi_start_all(self: Arc, machine_id: Id, project_id: Id) -> Result<(), LocalhostError> { + on_runtime(async move { self.start_all(machine_id, project_id).await }).await + } + + #[uniffi::method] + pub async fn ffi_stop_all(self: Arc, machine_id: Id, project_id: Id) -> Result<(), LocalhostError> { + on_runtime(async move { self.stop_all(machine_id, project_id).await }).await + } + + #[uniffi::method] + pub async fn ffi_subscribe_to_run( + self: Arc, + machine_id: Id, + run_id: Id, + ) -> Result { + let (lines, receiver) = on_runtime(async move { self.subscribe_to_run(machine_id, run_id).await }).await?; + Ok(RunSubscription { lines, updates: OutputLines::new(receiver) }) + } + + #[uniffi::method] + pub async fn ffi_subscribe_to_services( + self: Arc, + machine_id: Id, + service_ids: Vec, + ) -> Result { + let service_ids = service_ids.into_iter().collect::>(); + let (lines, receiver) = + on_runtime(async move { self.subscribe_to_services(machine_id, service_ids).await }).await?; + Ok(ServicesSubscription { lines, updates: ServiceOutputLines::new(receiver) }) + } + + /// The URL to open for the Service, its Port forwarded here first. + #[uniffi::method] + pub async fn ffi_open_service( + self: Arc, + machine_id: Id, + service_id: Id, + ) -> Result, LocalhostError> { + on_runtime(async move { self.open_service(machine_id, service_id).await }).await + } + + #[uniffi::method] + pub fn ffi_forwards(&self, machine_id: Id) -> Vec { + self.forwards(machine_id) + } +} + // MARK: ProjectDocument #[uniffi::export] diff --git a/core/localhost-core/src/ffi/subscriptions.rs b/core/localhost-core/src/ffi/subscriptions.rs index 01f7679..8c1dc86 100644 --- a/core/localhost-core/src/ffi/subscriptions.rs +++ b/core/localhost-core/src/ffi/subscriptions.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use tokio::sync::{Mutex, mpsc, watch}; use super::runtime::on_runtime; +use crate::machine::MachinesEvent; use crate::{Orphan, OutputLine, ProjectLibraryEvent, ServiceEvent, ServiceOutputLine}; macro_rules! subscription { @@ -73,6 +74,12 @@ subscription!( FoundOrphansEvents, Vec ); +subscription!( + /// Other Machines added, removed or renamed, their states, and what + /// each shows: its Projects and Service events. + MachinesEvents, + MachinesEvent +); #[cfg(test)] mod tests { From 708bc639f6595fc0370f29f80a67996510750d44 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:51:57 -0300 Subject: [PATCH 11/27] Add Machines to the Swift layer: typed calls, a LogSource for either place, and MachinesStore, a main-actor mirror --- .../Sources/LocalhostCore/Machines.swift | 271 ++++++++++++++++++ .../Tests/LocalhostCoreTests/LayerTests.swift | 22 ++ 2 files changed, 293 insertions(+) create mode 100644 apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift diff --git a/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift new file mode 100644 index 0000000..b9f356a --- /dev/null +++ b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift @@ -0,0 +1,271 @@ +import Foundation +import Observation + +// Other Machines: computers whose localhost this app shows, reached with the +// system's `ssh`. The core keeps each one connected, reconnecting after a +// loss, and mirrors its Projects and statuses; changes there are made as +// `Origin.machine`, which the other Machine decides. These add Swift labels, +// Int ports, typed errors and streams. + +extension Machines { + /// The Machines saved in `storageRoot`'s `machines.json`. + public convenience init(storageRoot: URL = ProjectLibrary.defaultStorageRoot) throws(LocalhostError) { + let machines = try core { try Machines.ffiOpen(storageRoot: storageRoot.path(percentEncoded: false)) } + self.init(unsafeFromHandle: machines.uniffiCloneHandle()) + } + + public func machines() -> [Machine] { + ffiMachines() + } + + /// Every Machine as it is now, and a stream of every change after that. + public func subscribe() -> (snapshot: [MachineSnapshot], events: AsyncStream) { + let subscription = ffiSubscribe() + return (subscription.machines, subscription.events.stream) + } + + /// Connects to the Machine `ssh` reaches as `destination` (a `Host` of + /// `~/.ssh/config`, or `user@host`), and only once that works remembers + /// it and keeps it connected. Throws `.MachineUnavailable` with why not. + @discardableResult + public func add(destination: String, name: String? = nil) async throws(LocalhostError) -> Machine { + try await core { try await ffiAdd(name: name, destination: destination) } + } + + /// An empty name is its destination. + @discardableResult + public func rename(_ machineID: UUID, to name: String) async throws(LocalhostError) -> Machine { + try await core { try await ffiRename(machineId: machineID, name: name) } + } + + /// Disconnects and forgets the Machine. Nothing changes on it. + public func remove(_ machineID: UUID) async throws(LocalhostError) { + try await core { try await ffiRemove(machineId: machineID) } + } + + /// Connects, and keeps reconnecting after a loss; while it waits to try + /// again, tries now. The Machine's state says how it goes. + public func connect(_ machineID: UUID) throws(LocalhostError) { + try core { try ffiConnect(machineId: machineID) } + } + + /// Ends the connection, even a working one, and makes it again. + public func reconnect(_ machineID: UUID) throws(LocalhostError) { + try core { try ffiReconnect(machineId: machineID) } + } + + public func disconnect(_ machineID: UUID) { + ffiDisconnect(machineId: machineID) + } + + /// The computer woke or its network changed: tries waiting connections now. + public func wake() { + ffiWake() + } + + // MARK: Its Services + + @discardableResult + public func start(_ serviceID: UUID, on machineID: UUID, onPort port: Int? = nil) async throws(LocalhostError) -> ServiceStatus { + let port = try port.map { port throws(LocalhostError) in + guard let port = UInt16(exactly: port), port > 0 else { throw .InvalidPort(Int64(port)) } + return port + } + return try await core { try await ffiStart(machineId: machineID, serviceId: serviceID, onPort: port) } + } + + @discardableResult + public func stop(_ serviceID: UUID, on machineID: UUID) async throws(LocalhostError) -> ServiceStatus { + try await core { try await ffiStop(machineId: machineID, serviceId: serviceID) } + } + + @discardableResult + public func restart(_ serviceID: UUID, on machineID: UUID) async throws(LocalhostError) -> ServiceStatus { + try await core { try await ffiRestart(machineId: machineID, serviceId: serviceID) } + } + + public func startAll(inProject projectID: UUID, on machineID: UUID) async throws(LocalhostError) { + try await core { try await ffiStartAll(machineId: machineID, projectId: projectID) } + } + + public func stopAll(inProject projectID: UUID, on machineID: UUID) async throws(LocalhostError) { + try await core { try await ffiStopAll(machineId: machineID, projectId: projectID) } + } + + /// The URL to open for the Service here, its Port forwarded to the same + /// port on this Mac first; `nil` when it has nothing to open. Throws + /// `.PortConflict` when that port is taken here. + public func openURL(ofService serviceID: UUID, on machineID: UUID) async throws(LocalhostError) -> URL? { + let url = try await core { try await ffiOpenService(machineId: machineID, serviceId: serviceID) } + return url.flatMap { URL(string: $0) } + } + + /// The Machine's logs, for a log view. + public func logs(on machineID: UUID) -> MachineLogs { + MachineLogs(machines: self, machineID: machineID) + } + + /// The hosts named in `~/.ssh/config` (following `Include`, without + /// patterns), to suggest as a destination. Only read. + public static func sshConfigHosts() async -> [String] { + await LocalhostCore.sshConfigHosts() + } +} + +extension Machine: Identifiable {} + +extension MachineSnapshot: Identifiable { + public var id: UUID { machine.id } +} + +extension MachineProblem { + /// Why `machine` can't be reached, and what to do next. + public func message(machine: String) -> String { + machineProblemMessage(problem: self, machine: machine) + } +} + +extension MachinesEvents { + var stream: AsyncStream { makeStream(next: next, close: close) } +} + +// MARK: Logs + +/// Where a log view reads Runs' output: this Mac's shared core (`Targets`), +/// or another Machine (`MachineLogs`). +public protocol LogSource: Sendable { + func subscribe(toRun runID: UUID) async throws(LocalhostError) -> (lines: [OutputLine], updates: AsyncStream) + func subscribe( + toServices serviceIDs: Set + ) async throws(LocalhostError) -> (lines: [ServiceOutputLine], updates: AsyncStream) +} + +extension Targets: LogSource {} + +/// One Machine's logs, through its connection. +public struct MachineLogs: LogSource { + public let machines: Machines + public let machineID: UUID + + public func subscribe(toRun runID: UUID) async throws(LocalhostError) -> (lines: [OutputLine], updates: AsyncStream) { + let subscription = try await core { try await machines.ffiSubscribeToRun(machineId: machineID, runId: runID) } + return (subscription.lines, subscription.updates.stream) + } + + public func subscribe( + toServices serviceIDs: Set + ) async throws(LocalhostError) -> (lines: [ServiceOutputLine], updates: AsyncStream) { + let subscription = try await core { + try await machines.ffiSubscribeToServices(machineId: machineID, serviceIds: Array(serviceIDs)) + } + return (subscription.lines, subscription.updates.stream) + } +} + +// MARK: Mirror + +/// A main-actor mirror of every other Machine, for SwiftUI to observe: each +/// one's state, Projects and statuses. It only reflects `Machines`; changes +/// go through it. +@MainActor +@Observable +public final class MachinesStore { + /// One Machine as the app shows it. Its Projects and statuses are those + /// of its last connection, kept while it reconnects. + public struct Shown: Identifiable, Sendable { + public var machine: Machine + public var state: MachineState + public var projects: [Project] + public var statuses: [UUID: ServiceStatus] + + public var id: UUID { machine.id } + + public var isConnected: Bool { + if case .connected = state { true } else { false } + } + + public func status(of serviceID: UUID) -> ServiceStatus { + statuses[serviceID] ?? .neverRun(serviceID) + } + + public func project(_ projectID: UUID) -> Project? { + projects.first { $0.id == projectID } + } + + public func service(_ serviceID: UUID) -> (project: Project, service: Service)? { + for project in projects { + if let service = project.service(id: serviceID) { return (project, service) } + } + return nil + } + } + + /// In the order they were added. + public private(set) var machines: [Shown] = [] + @ObservationIgnored private var observation: Task? + + public init(machines source: Machines) { + observation = Task { [weak self] in + let (snapshot, events) = source.subscribe() + self?.machines = snapshot.map { Shown($0) } + for await event in events { + guard let self else { return } + self.apply(event) + } + } + } + + isolated deinit { + observation?.cancel() + } + + public func machine(_ machineID: UUID) -> Shown? { + machines.first { $0.id == machineID } + } + + private func apply(_ event: MachinesEvent) { + switch event { + case .machinesChanged(let list): + machines = list.map { machine in + if var shown = self.machine(machine.id) { + shown.machine = machine + return shown + } + return Shown(machine: machine, state: .notConnected, projects: [], statuses: [:]) + } + case .stateChanged(let machineID, let state): + update(machineID) { $0.state = state } + case .projectsChanged(let machineID, let projects): + update(machineID) { $0.projects = projects } + case .statusesChanged(let machineID, let statuses): + update(machineID) { $0.statuses = Dictionary(statuses.map { ($0.serviceID, $0) }, uniquingKeysWith: { $1 }) } + case .serviceChanged(let machineID, let event): + update(machineID) { shown in + switch event { + case .serviceStateChanged(let status): + shown.statuses[status.serviceID] = status + case .runStarted(let run), .runEnded(let run): + if shown.statuses[run.serviceID]?.run?.id == run.id { + shown.statuses[run.serviceID]?.run = run + } + } + } + } + } + + private func update(_ machineID: UUID, _ change: (inout Shown) -> Void) { + guard let index = machines.firstIndex(where: { $0.id == machineID }) else { return } + change(&machines[index]) + } +} + +extension MachinesStore.Shown { + init(_ snapshot: MachineSnapshot) { + self.init( + machine: snapshot.machine, + state: snapshot.state, + projects: snapshot.projects, + statuses: Dictionary(snapshot.statuses.map { ($0.serviceID, $0) }, uniquingKeysWith: { $1 }) + ) + } +} diff --git a/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift b/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift index 88998c6..da03c88 100644 --- a/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift +++ b/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift @@ -240,3 +240,25 @@ private final class TemporaryRoot { try await targets.removeLogs(ofService: service.id) } } + +@Suite struct MachineTests { + /// What the layer adds; reaching a Machine over `ssh` is tested in core/. + @Test @MainActor func refusesADestinationSSHWouldMisreadAndSavesNothing() async throws { + let root = try TemporaryRoot() + let machines = try Machines(storageRoot: root.url) + let store = MachinesStore(machines: machines) + await #expect(throws: LocalhostError.InvalidMachineDestination("-oProxyCommand=evil")) { + try await machines.add(destination: "-oProxyCommand=evil") + } + #expect(machines.machines().isEmpty) + try await Task.sleep(for: .milliseconds(50)) + #expect(store.machines.isEmpty) + #expect(throws: LocalhostError.self) { try machines.connect(UUID()) } + } + + @Test func explainsWhyAMachineCantBeReached() { + #expect(MachineProblem.hostKeyNotTrusted.message(machine: "devbox").contains("ssh devbox")) + let error = LocalhostError.MachineUnavailable(machine: "devbox", problem: .notInstalled) + #expect(error.message.contains("isn’t installed on devbox")) + } +} From 4b192708560d8a2166f9535e49e1db6c2b33a74a Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:51:57 -0300 Subject: [PATCH 12/27] =?UTF-8?q?Show=20other=20Machines=20in=20the=20macO?= =?UTF-8?q?S=20app:=20Settings=20=E2=80=BA=20Machines,=20a=20sidebar=20sec?= =?UTF-8?q?tion=20each,=20their=20logs,=20and=20Open=20through=20a=20Forwa?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/macos/App/AppModel+Machines.swift | 121 +++++++++++ apps/macos/App/AppModel.swift | 173 +++++++++++++--- apps/macos/App/CommandPalette.swift | 3 +- apps/macos/App/ExternalActions.swift | 3 +- apps/macos/App/LocalhostApp.swift | 58 +++--- apps/macos/App/Views/Accessibility.swift | 4 +- apps/macos/App/Views/Controls.swift | 8 +- apps/macos/App/Views/LogPane.swift | 55 +++-- apps/macos/App/Views/MachinesSettings.swift | 203 +++++++++++++++++++ apps/macos/App/Views/ProjectDetailView.swift | 142 ++++++++----- apps/macos/App/Views/ProjectSidebar.swift | 117 +++++++++-- apps/macos/App/Views/SettingsView.swift | 7 +- 12 files changed, 744 insertions(+), 150 deletions(-) create mode 100644 apps/macos/App/AppModel+Machines.swift create mode 100644 apps/macos/App/Views/MachinesSettings.swift diff --git a/apps/macos/App/AppModel+Machines.swift b/apps/macos/App/AppModel+Machines.swift new file mode 100644 index 0000000..8d3bc80 --- /dev/null +++ b/apps/macos/App/AppModel+Machines.swift @@ -0,0 +1,121 @@ +import AppKit +import LocalhostCore +import SwiftUI + +// Other Machines: Settings › Machines adds, renames and removes them; the +// sidebar shows each one's Projects below this Mac's. The core keeps each +// connected and reconnects after a loss; it never starts or stops anything +// on its own. + +extension AppModel { + /// The Machines as shown, in the order they were added. + var shownMachines: [MachinesStore.Shown] { + machineStore?.machines ?? [] + } + + func shownMachine(_ machineID: UUID) -> MachinesStore.Shown? { + machineStore?.machine(machineID) + } + + /// Active Services on connected Machines started from a Machine: those + /// this Mac may hold, which stop there once it disconnects, unless + /// localhost is open on that Machine. + var activeMachineServiceCount: Int { + shownMachines.filter(\.isConnected).reduce(0) { count, shown in + count + shown.statuses.values.filter { $0.state.isActive && $0.run?.origin == .machine }.count + } + } + + /// Connects to every Machine at launch, and tries those waiting to + /// reconnect again as soon as the Mac wakes. + func connectToMachines() { + guard let machines else { return } + for machine in machines.machines() { + try? machines.connect(machine.id) + } + _ = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { _ in + machines.wake() + } + } + + /// Has VoiceOver say when a Service of another Machine settles, as + /// `announceStates` does here: with its Machine and Project unless they + /// are the selected ones; Failed interrupts. + func announceMachineStates() { + guard let machines else { return } + Task { [weak self] in + var lastStates: [String: ServiceState] = [:] + for await event in machines.subscribe().events { + guard let self, case .serviceChanged(let machineID, .serviceStateChanged(let status)) = event else { continue } + let key = "\(machineID)/\(status.serviceID)" + defer { lastStates[key] = status.state } + guard status.state != lastStates[key], !status.state.isActive || status.state == .running, + NSWorkspace.shared.isVoiceOverEnabled, + let (project, service) = self.service(status.serviceID, on: .machine(machineID)), + let shown = self.shownMachine(machineID) + else { continue } + let isSelected = self.selectedPlace == .machine(machineID) && project.id == self.selectedProjectID + let subject = isSelected ? service.name : "\(shown.machine.name), \(project.name), \(service.name)" + var announcement = AttributedString("\(subject), \(status.spokenSummary)") + announcement.accessibilitySpeechAnnouncementPriority = status.state == .failed ? .high : .default + AccessibilityNotification.Announcement(announcement).post() + } + } + } + + /// Connects to the Machine first, and adds it only once that works. + @discardableResult + func addMachine(destination: String, name: String) async throws(LocalhostError) -> Machine { + guard let machines else { throw machinesError ?? .MachineNotFound(UUID()) } + let name = name.trimmingCharacters(in: .whitespaces) + return try await machines.add(destination: destination, name: name.isEmpty ? nil : name) + } + + func renameMachine(_ machineID: UUID, to name: String) { + guard let machines else { return } + Task { await report { () throws(LocalhostError) in try await machines.rename(machineID, to: name) } } + } + + /// Disconnects and forgets the Machine; nothing changes on it. + func removeMachine(_ machineID: UUID) { + guard let machines else { return } + if selectedPlace == .machine(machineID) { sidebarSelection = nil } + Task { await report { () throws(LocalhostError) in try await machines.remove(machineID) } } + } + + /// Connects again now: at once if it was waiting, else anew. + func reconnectMachine(_ machineID: UUID) { + guard let machines else { return } + do throws(LocalhostError) { + if shownMachine(machineID)?.isConnected == true { + try machines.reconnect(machineID) + } else { + try machines.connect(machineID) + } + } catch { + actionError = error + } + } + + /// "Connected · localhost 0.5.0", "Connecting…", or why it isn't. + func stateDescription(of shown: MachinesStore.Shown) -> String { + switch shown.state { + case .notConnected: "Not connected" + case .connecting: "Connecting…" + case .connected(let version): "Connected · localhost \(version)" + case .unavailable(let problem, let isRetrying): + problem.message(machine: shown.machine.name) + (isRetrying ? " Trying again…" : "") + } + } + + /// Whether the Machine is waiting for the user to connect it again. + func needsReconnect(_ shown: MachinesStore.Shown) -> Bool { + switch shown.state { + case .notConnected: true + case .unavailable(_, let isRetrying): !isRetrying + case .connecting, .connected: false + } + } +} diff --git a/apps/macos/App/AppModel.swift b/apps/macos/App/AppModel.swift index 3410f4e..3ca63d2 100644 --- a/apps/macos/App/AppModel.swift +++ b/apps/macos/App/AppModel.swift @@ -37,6 +37,15 @@ final class AppModel { enum SidebarItem: Hashable { case ports case project(UUID) + /// A Project of another Machine: identified by both, since each + /// Machine names its own Projects. + case machineProject(UUID, machine: UUID) + } + + /// Where a Project and its Services are: this Mac, or another Machine. + enum Place: Hashable { + case thisMac + case machine(UUID) } /// A start that failed because its port is taken, waiting for the user @@ -60,6 +69,11 @@ final class AppModel { let core: Core? /// Why the core could not start, e.g. an unreadable Project file. let startupError: LocalhostError? + /// The other Machines, kept connected while the app runs. + let machines: Machines? + let machineStore: MachinesStore? + /// Why the list of Machines couldn't be read. + let machinesError: LocalhostError? private(set) var projects: [Project] = [] var sidebarSelection: SidebarItem? { @@ -69,10 +83,21 @@ final class AppModel { saveSelection() } } + /// The selected Project's id, on this Mac or another Machine. Set, it + /// selects a Project of this Mac. var selectedProjectID: UUID? { - get { if case .project(let id) = sidebarSelection { id } else { nil } } + get { + switch sidebarSelection { + case .project(let id), .machineProject(let id, _): id + case .ports, nil: nil + } + } set { sidebarSelection = newValue.map(SidebarItem.project) } } + /// Where the selected Project is. + var selectedPlace: Place { + if case .machineProject(_, let machineID) = sidebarSelection { .machine(machineID) } else { .thisMac } + } var selectedServiceID: UUID? { didSet { if selectedServiceID != oldValue { saveSelection() } } } @@ -80,6 +105,11 @@ final class AppModel { var projectSearch = "" /// The Projects whose name or Key contains `projectSearch`. var filteredProjects: [Project] { + filtered(projects) + } + + /// `projects` whose name or Key contains `projectSearch`. + func filtered(_ projects: [Project]) -> [Project] { let query = projectSearch.trimmingCharacters(in: .whitespaces) guard !query.isEmpty else { return projects } return projects.filter { @@ -181,16 +211,35 @@ final class AppModel { core = nil startupError = error } + do { + let machines = try Machines(storageRoot: root) + self.machines = machines + machineStore = MachinesStore(machines: machines) + machinesError = nil + } catch { + machines = nil + machineStore = nil + machinesError = error + } observeProjects() + connectToMachines() announceStates() + announceMachineStates() connectToSharedCore() updates.model = self } // MARK: Reading + /// The selected Project, on this Mac or another Machine. var selectedProject: Project? { - projects.first { $0.id == selectedProjectID } + guard let id = selectedProjectID else { return nil } + return project(id, on: selectedPlace) + } + + /// The selected Project when it is on this Mac: what can be edited. + var selectedLocalProject: Project? { + selectedPlace == .thisMac ? selectedProject : nil } /// The `selectedServiceID` of the "All services" row, which shows every @@ -205,24 +254,44 @@ final class AppModel { selectedProject?.services.first { $0.id == selectedServiceID } } - func project(_ id: UUID) -> Project? { - projects.first { $0.id == id } + func project(_ id: UUID, on place: Place = .thisMac) -> Project? { + switch place { + case .thisMac: projects.first { $0.id == id } + case .machine(let machineID): machineStore?.machine(machineID)?.project(id) + } } - func service(_ id: UUID) -> (project: Project, service: Service)? { - for project in projects { - if let service = project.services.first(where: { $0.id == id }) { return (project, service) } + func service(_ id: UUID, on place: Place = .thisMac) -> (project: Project, service: Service)? { + switch place { + case .thisMac: + for project in projects { + if let service = project.services.first(where: { $0.id == id }) { return (project, service) } + } + return nil + case .machine(let machineID): + return machineStore?.machine(machineID)?.service(id) } - return nil } - func status(of service: Service) -> ServiceStatus { - core?.statuses.status(of: service.id) ?? .neverRun(service.id) + func status(of service: Service, on place: Place = .thisMac) -> ServiceStatus { + switch place { + case .thisMac: core?.statuses.status(of: service.id) ?? .neverRun(service.id) + case .machine(let machineID): machineStore?.machine(machineID)?.status(of: service.id) ?? .neverRun(service.id) + } } /// The most significant state among a Project's Services, or `nil` if it has none. - func aggregateState(of project: Project) -> ServiceState? { - .mostSignificant(of: project.services.map { status(of: $0).state }) + func aggregateState(of project: Project, on place: Place = .thisMac) -> ServiceState? { + .mostSignificant(of: project.services.map { status(of: $0, on: place).state }) + } + + /// Whether its Services can be controlled now: this Mac always, another + /// Machine while connected. + func isReachable(_ place: Place) -> Bool { + switch place { + case .thisMac: core != nil + case .machine(let machineID): machineStore?.machine(machineID)?.isConnected ?? false + } } var activeServiceCount: Int { @@ -250,8 +319,14 @@ final class AppModel { // MARK: Lifecycle - /// Starts the Service; if its port is taken, asks what to do (`blockedStart`). - func start(_ serviceID: UUID, onPort port: Int? = nil, origin: Origin = .mainWindow) { + /// Starts the Service; if its port is taken on this Mac, asks what to do + /// (`blockedStart`). On another Machine, its row says why it Failed. + func start(_ serviceID: UUID, onPort port: Int? = nil, on place: Place = .thisMac, origin: Origin = .mainWindow) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { await report(origin: origin) { () throws(LocalhostError) in try await machines.start(serviceID, on: machineID, onPort: port) } } + return + } guard let services = core?.services else { return } Task { await report(origin: origin) { () throws(LocalhostError) in @@ -261,12 +336,22 @@ final class AppModel { } } - func stop(_ serviceID: UUID, origin: Origin = .mainWindow) { + func stop(_ serviceID: UUID, on place: Place = .thisMac, origin: Origin = .mainWindow) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { await report(origin: origin) { () throws(LocalhostError) in try await machines.stop(serviceID, on: machineID) } } + return + } guard let services = core?.services else { return } Task { await report(origin: origin) { () throws(LocalhostError) in try await services.stop(serviceID, origin: origin) } } } - func restart(_ serviceID: UUID, origin: Origin = .mainWindow) { + func restart(_ serviceID: UUID, on place: Place = .thisMac, origin: Origin = .mainWindow) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { await report(origin: origin) { () throws(LocalhostError) in try await machines.restart(serviceID, on: machineID) } } + return + } guard let services = core?.services else { return } Task { await report(origin: origin) { () throws(LocalhostError) in @@ -276,26 +361,56 @@ final class AppModel { } } - func startAll(inProject projectID: UUID, origin: Origin = .mainWindow) { + func startAll(inProject projectID: UUID, on place: Place = .thisMac, origin: Origin = .mainWindow) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { await report(origin: origin) { () throws(LocalhostError) in try await machines.startAll(inProject: projectID, on: machineID) } } + return + } guard let services = core?.services else { return } Task { await report(origin: origin) { () throws(LocalhostError) in try await services.startAll(inProject: projectID, origin: origin) } } } - func stopAll(inProject projectID: UUID, origin: Origin = .mainWindow) { + func stopAll(inProject projectID: UUID, on place: Place = .thisMac, origin: Origin = .mainWindow) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { await report(origin: origin) { () throws(LocalhostError) in try await machines.stopAll(inProject: projectID, on: machineID) } } + return + } guard let services = core?.services else { return } Task { await report(origin: origin) { () throws(LocalhostError) in try await services.stopAll(inProject: projectID, origin: origin) } } } /// The URL "Open" uses for a Service, or `nil` if there is none to open. - func urlToOpen(_ service: Service) -> URL? { - service.urlToOpen(for: status(of: service).run) - } - - func openURL(of serviceID: UUID) { + /// On another Machine, its Port is forwarded to the same port here. + func urlToOpen(_ service: Service, on place: Place = .thisMac) -> URL? { + service.urlToOpen(for: status(of: service, on: place).run) + } + + func openURL(of serviceID: UUID, on place: Place = .thisMac) { + if case .machine(let machineID) = place { + guard let machines else { return } + Task { + await report { () throws(LocalhostError) in + if let url = try await machines.openURL(ofService: serviceID, on: machineID) { + NSWorkspace.shared.open(url) + } + } + } + return + } guard let (_, service) = service(serviceID), let url = urlToOpen(service) else { return } NSWorkspace.shared.open(url) } + /// Where the selected Project's logs are read. + func logs(on place: Place) -> (any LogSource)? { + switch place { + case .thisMac: return core?.logs + case .machine(let machineID): return machines?.logs(on: machineID) + } + } + /// Shows Connect Your Agents, unless another sheet is open: replacing /// it would lose its edits. func showConnectAgents() { @@ -326,8 +441,13 @@ final class AppModel { columnFocusRequest = ColumnFocusRequest(column: column) } - /// Whether a log is shown that ⌃L can clear. + /// Whether a log is shown that ⌃L can clear: another Machine's can't be. var canClearLogs: Bool { + selectedPlace == .thisMac && (selectedService != nil || isAllServicesSelected) + } + + /// Whether a log is shown that ⌘F can filter. + var canFindInLog: Bool { selectedService != nil || isAllServicesSelected } @@ -780,12 +900,13 @@ final class AppModel { switch sidebarSelection { case .ports: defaults.set("ports", forKey: Self.sidebarSelectionKey) case .project(let id): defaults.set(id.uuidString, forKey: Self.sidebarSelectionKey) - case nil: defaults.removeObject(forKey: Self.sidebarSelectionKey) + // Another Machine's Projects arrive only once it connects. + case .machineProject, nil: defaults.removeObject(forKey: Self.sidebarSelectionKey) } defaults.set(selectedServiceID?.uuidString, forKey: Self.serviceSelectionKey) } - private func report(origin: Origin = .mainWindow, _ action: () async throws(LocalhostError) -> Void) async { + func report(origin: Origin = .mainWindow, _ action: () async throws(LocalhostError) -> Void) async { do { try await action() } catch { diff --git a/apps/macos/App/CommandPalette.swift b/apps/macos/App/CommandPalette.swift index 785e65a..55238e3 100644 --- a/apps/macos/App/CommandPalette.swift +++ b/apps/macos/App/CommandPalette.swift @@ -31,7 +31,8 @@ extension AppModel { /// Project. The rest (ids starting `other:`) are reachable by typing. func paletteItems() -> [PaletteItem] { var items: [PaletteItem] = [] - let selected = selectedProject + // Another Machine's Projects are controlled from the main window for now. + let selected = selectedLocalProject if let service = selectedService, let selected { items += serviceActions(service, in: selected, isSelected: true) } diff --git a/apps/macos/App/ExternalActions.swift b/apps/macos/App/ExternalActions.swift index 3db63e5..1635b15 100644 --- a/apps/macos/App/ExternalActions.swift +++ b/apps/macos/App/ExternalActions.swift @@ -17,8 +17,9 @@ extension AppModel { /// The folder actions on the main window's selection use: the selected /// Service's working directory, else the selected Project's folder. + /// Another Machine's folders aren't on this Mac. var selectedFolder: URL? { - guard let project = selectedProject else { return nil } + guard let project = selectedLocalProject else { return nil } if let service = selectedService { return project.workingDirectoryURL(for: service) } return Self.folder(of: project) } diff --git a/apps/macos/App/LocalhostApp.swift b/apps/macos/App/LocalhostApp.swift index 8455314..b20cd3f 100644 --- a/apps/macos/App/LocalhostApp.swift +++ b/apps/macos/App/LocalhostApp.swift @@ -55,14 +55,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let origin = model.quitOrigin model.quitOrigin = .mainWindow - let count = model.activeServiceCount + let count = model.activeServiceCount + model.activeMachineServiceCount guard count > 0 else { return .terminateNow } // Without a main window the app may be in the background (no Dock icon). NSApp.activate() let alert = NSAlert() alert.messageText = count == 1 ? "Stop 1 service and quit?" : "Stop \(count) services and quit?" - alert.informativeText = "Running services are stopped before localhost quits." + alert.informativeText = model.activeMachineServiceCount == 0 + ? "Running services are stopped before localhost quits." + : "Running services are stopped before localhost quits. Those started from here on other machines stop too, unless localhost is open on them." alert.addButton(withTitle: "Stop and Quit") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return .terminateCancel } @@ -112,14 +114,14 @@ struct AppCommands: Commands { .keyboardShortcut("n") .disabled(model.core == nil) Button("New Service…") { - if let project = model.selectedProject { model.presentedSheet = .addService(projectID: project.id) } + if let project = model.selectedLocalProject { model.presentedSheet = .addService(projectID: project.id) } } .keyboardShortcut("n", modifiers: [.command, .shift]) - .disabled(model.selectedProject == nil) + .disabled(model.selectedLocalProject == nil) Button("Detect Services…") { - if let project = model.selectedProject { model.presentedSheet = .detectServices(projectID: project.id) } + if let project = model.selectedLocalProject { model.presentedSheet = .detectServices(projectID: project.id) } } - .disabled(model.selectedProject == nil) + .disabled(model.selectedLocalProject == nil) Divider() Button("Import Project…") { openWindow(id: "main") @@ -128,53 +130,59 @@ struct AppCommands: Commands { .disabled(model.core == nil) Button("Export Project…") { openWindow(id: "main") - if let project = model.selectedProject { model.exportProject(project.id) } + if let project = model.selectedLocalProject { model.exportProject(project.id) } } - .disabled(model.selectedProject == nil) + .disabled(model.selectedLocalProject == nil) } CommandGroup(replacing: .textEditing) { Button("Find in Log") { model.findInLog() } .keyboardShortcut("f") - .disabled(!model.canClearLogs) + .disabled(!model.canFindInLog) Button("Clear Log") { model.clearLogs() } .keyboardShortcut("l", modifiers: .control) .disabled(!model.canClearLogs) } CommandMenu("Service") { - let status = model.selectedService.map { model.status(of: $0) } + let place = model.selectedPlace + let status = model.selectedService.map { model.status(of: $0, on: place) } let isLive = status?.state == .starting || status?.state == .running Button(isLive ? "Restart" : "Start") { guard let id = model.selectedServiceID else { return } - isLive ? model.restart(id) : model.start(id) + isLive ? model.restart(id, on: place) : model.start(id, on: place) } .keyboardShortcut("r") - .disabled(status == nil || status?.state == .stopping) + .disabled(status == nil || status?.state == .stopping || !model.isReachable(place)) // In Ports, Stop applies to the selected Listener. let listener = model.sidebarSelection == .ports ? model.selectedListenerID.flatMap(model.listener) : nil Button(listener.map { "Stop \($0.name)" + (model.service(of: $0) == nil ? "…" : "") } ?? "Stop") { - if let listener { model.requestStop(listener) } else { model.selectedServiceID.map { model.stop($0) } } + if let listener { model.requestStop(listener) } else { model.selectedServiceID.map { model.stop($0, on: place) } } } .keyboardShortcut(".") - .disabled(listener.map { model.reasonNotStoppable($0) != nil } ?? !isLive) + .disabled(listener.map { model.reasonNotStoppable($0) != nil } ?? (!isLive || !model.isReachable(place))) Divider() - Button("Open URL") { model.selectedServiceID.map { model.openURL(of: $0) } } + Button("Open URL") { model.selectedServiceID.map { model.openURL(of: $0, on: place) } } .keyboardShortcut("o", modifiers: [.command, .shift]) - .disabled(!isLive || model.selectedService.flatMap(model.urlToOpen) == nil) + .disabled(!isLive || model.selectedService.flatMap { model.urlToOpen($0, on: place) } == nil || !model.isReachable(place)) Divider() - Button("Edit Service…") { model.selectedService.map { model.presentedSheet = .editService($0.id) } } - .disabled(model.selectedService == nil) - Button("Remove Service…") { model.selectedService.map { model.pendingRemoval = .service($0.id) } } - .disabled(model.selectedService == nil) + // Another Machine's Services are edited there. + let localService = place == .thisMac ? model.selectedService : nil + Button("Edit Service…") { localService.map { model.presentedSheet = .editService($0.id) } } + .disabled(localService == nil) + Button("Remove Service…") { localService.map { model.pendingRemoval = .service($0.id) } } + .disabled(localService == nil) } CommandMenu("Project") { - let project = model.selectedProject - Button("Start All") { project.map { model.startAll(inProject: $0.id) } } + let place = model.selectedPlace + let shown = model.selectedProject + Button("Start All") { shown.map { model.startAll(inProject: $0.id, on: place) } } .keyboardShortcut("r", modifiers: [.command, .shift]) - .disabled(project?.services.isEmpty ?? true) - Button("Stop All") { project.map { model.stopAll(inProject: $0.id) } } + .disabled((shown?.services.isEmpty ?? true) || !model.isReachable(place)) + Button("Stop All") { shown.map { model.stopAll(inProject: $0.id, on: place) } } .keyboardShortcut(".", modifiers: [.command, .shift]) - .disabled(!(project.map { $0.services.contains { model.status(of: $0).state.isActive } } ?? false)) + .disabled(!(shown.map { $0.services.contains { model.status(of: $0, on: place).state.isActive } } ?? false) + || !model.isReachable(place)) Divider() + let project = model.selectedLocalProject Button("Edit Project…") { project.map { model.presentedSheet = .editProject($0.id) } } .disabled(project == nil) Button("Edit Environment…") { project.map { model.presentedSheet = .projectEnvironment($0.id) } } diff --git a/apps/macos/App/Views/Accessibility.swift b/apps/macos/App/Views/Accessibility.swift index 0195579..5743479 100644 --- a/apps/macos/App/Views/Accessibility.swift +++ b/apps/macos/App/Views/Accessibility.swift @@ -19,9 +19,9 @@ extension String { extension AppModel { /// A Project's Services for VoiceOver, e.g. "2 of 3 running, 1 failed". - func accessibilityState(of project: Project) -> String { + func accessibilityState(of project: Project, on place: Place = .thisMac) -> String { guard !project.services.isEmpty else { return "No services" } - let states = project.services.map { status(of: $0).state } + let states = project.services.map { status(of: $0, on: place).state } let failed = states.filter { $0 == .failed }.count return "\(states.filter(\.isActive).count) of \(states.count) running" + (failed > 0 ? ", \(failed) failed" : "") } diff --git a/apps/macos/App/Views/Controls.swift b/apps/macos/App/Views/Controls.swift index 856f136..94127c5 100644 --- a/apps/macos/App/Views/Controls.swift +++ b/apps/macos/App/Views/Controls.swift @@ -133,9 +133,9 @@ struct MoreMenu: View { extension AppModel { /// Each Service's colour in its Project with whether it is active, for `ServiceDots`. - func serviceDots(of project: Project) -> [(color: NSColor, isActive: Bool)] { + func serviceDots(of project: Project, on place: Place = .thisMac) -> [(color: NSColor, isActive: Bool)] { project.services.enumerated().map { index, service in - (Theme.serviceColor(at: index), status(of: service).state.isActive) + (Theme.serviceColor(at: index), status(of: service, on: place).state.isActive) } } @@ -146,8 +146,8 @@ extension AppModel { } /// The Service's colour: stable for its position in its Project. - func color(of service: Service) -> NSColor { - guard let (project, _) = self.service(service.id), + func color(of service: Service, on place: Place = .thisMac) -> NSColor { + guard let (project, _) = self.service(service.id, on: place), let index = project.services.firstIndex(where: { $0.id == service.id }) else { return Theme.serviceColor(at: 0) } return Theme.serviceColor(at: index) } diff --git a/apps/macos/App/Views/LogPane.swift b/apps/macos/App/Views/LogPane.swift index 8b545c8..ab09148 100644 --- a/apps/macos/App/Views/LogPane.swift +++ b/apps/macos/App/Views/LogPane.swift @@ -3,7 +3,8 @@ import LocalhostCore import Synchronization import SwiftUI -/// The selected Service's output: its latest Run, live. +/// The selected Service's output: its latest Run, live, on this Mac or +/// another Machine. struct LogPane: View { @Environment(AppModel.self) private var model let service: Service @@ -11,12 +12,14 @@ struct LogPane: View { @State private var dismissedBannerRunID: UUID? @State private var revealCount = 0 + private var place: AppModel.Place { model.selectedPlace } + var body: some View { - let status = model.status(of: service) + let status = model.status(of: service, on: place) VStack(spacing: 0) { LogHeader { RoundedRectangle(cornerRadius: 2) - .fill(Color(nsColor: model.color(of: service))) + .fill(Color(nsColor: model.color(of: service, on: place))) .frame(width: 8, height: 8) Text(service.name).fontWeight(.semibold) .lineLimit(1) @@ -36,12 +39,13 @@ struct LogPane: View { url(status) Color.clear.frame(width: 0, height: 0) } - if status.state == .starting || status.state == .running, status.run?.endedAt == nil { + // Stats are sampled on this Mac only. + if place == .thisMac, status.state == .starting || status.state == .running, status.run?.endedAt == nil { ServiceStatsButton(service: service) } } - if let logs = model.core?.logs { + if let logs = model.logs(on: place) { if status.state == .failed, let run = status.run, run.id != dismissedBannerRunID { FailedBanner(service: service, run: run, logs: logs) { dismissedBannerRunID = run.id @@ -50,13 +54,15 @@ struct LogPane: View { } LogView(source: .run(status.run?.id), logs: logs, filter: model.logFilter, clearCount: model.logClearCount, revealCount: revealCount) + // A new view for each place: its subscription is made where the log is. + .id(place) .overlay { if status.run == nil { VStack(spacing: 10) { Text("No output yet.") .foregroundStyle(.secondary) - if status.state == .stopped { - Button("Start \(service.name)", systemImage: "play.fill") { model.start(service.id) } + if status.state == .stopped, model.isReachable(place) { + Button("Start \(service.name)", systemImage: "play.fill") { model.start(service.id, on: place) } .buttonStyle(AccentButtonStyle()) .help("Start (⌘R)") KeyTip(before: "or press", keys: "⌘R") @@ -76,8 +82,8 @@ struct LogPane: View { @ViewBuilder private func url(_ status: ServiceStatus) -> some View { - if status.state.isActive, let url = model.urlToOpen(service) { - Button { model.openURL(of: service.id) } label: { + if status.state.isActive, let url = model.urlToOpen(service, on: place) { + Button { model.openURL(of: service.id, on: place) } label: { HStack(spacing: 2) { Text(url.absoluteString.replacingOccurrences(of: "http://", with: "")) .font(.callout.monospaced()) @@ -100,21 +106,24 @@ struct CombinedLogPane: View { @Environment(AppModel.self) private var model let project: Project + private var place: AppModel.Place { model.selectedPlace } + var body: some View { - let running = project.services.filter { model.status(of: $0).state.isActive }.count + let running = project.services.filter { model.status(of: $0, on: place).state.isActive }.count VStack(spacing: 0) { LogHeader { - ServiceDots(services: model.serviceDots(of: project)) + ServiceDots(services: model.serviceDots(of: project, on: place)) Text("All services").fontWeight(.semibold) Text("\(running) of \(project.services.count) running") .foregroundStyle(.secondary) .lineLimit(1) } - if let logs = model.core?.logs { + if let logs = model.logs(on: place) { LogView(source: .services(Self.prefixes(of: project)), logs: logs, filter: model.logFilter, clearCount: model.logClearCount, revealCount: 0) + .id(place) .overlay { - if !project.services.isEmpty, project.services.allSatisfy({ model.status(of: $0).run == nil }) { + if !project.services.isEmpty, project.services.allSatisfy({ model.status(of: $0, on: place).run == nil }) { VStack(spacing: 10) { Text("No output yet.") .foregroundStyle(.secondary) @@ -122,9 +131,10 @@ struct CombinedLogPane: View { .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - Button("Start All", systemImage: "play.fill") { model.startAll(inProject: project.id) } + Button("Start All", systemImage: "play.fill") { model.startAll(inProject: project.id, on: place) } .buttonStyle(AccentButtonStyle()) .help("Start All (⇧⌘R)") + .disabled(!model.isReachable(place)) KeyTip(before: "or press", keys: "⇧⌘R") } .padding() @@ -233,7 +243,7 @@ private struct FailedBanner: View { @Environment(AppModel.self) private var model let service: Service let run: Run - let logs: Targets + let logs: any LogSource let dismiss: () -> Void @State private var excerpt: [OutputLine] = [] @@ -257,11 +267,14 @@ private struct FailedBanner: View { .lineLimit(1) } Spacer(minLength: 8) - Button("Start") { model.start(service.id) } + Button("Start") { model.start(service.id, on: model.selectedPlace) } .buttonStyle(AccentButtonStyle(isCompact: true)) .help("Start (⌘R)") - Button("Edit Service…") { model.presentedSheet = .editService(service.id) } - .buttonStyle(PillButtonStyle(isCompact: true)) + .disabled(!model.isReachable(model.selectedPlace)) + if model.selectedPlace == .thisMac { + Button("Edit Service…") { model.presentedSheet = .editService(service.id) } + .buttonStyle(PillButtonStyle(isCompact: true)) + } Button("Hide", systemImage: "xmark") { dismiss() } .labelStyle(.iconOnly) .buttonStyle(.borderless) @@ -355,7 +368,7 @@ struct LogView: NSViewRepresentable { } let source: Source - let logs: Targets + let logs: any LogSource @AppStorage(Preferences.logFontSizeKey) private var fontSize = Preferences.defaultLogFontSize /// Only lines containing this text are shown, with matches highlighted. let filter: String @@ -396,7 +409,7 @@ final class LogViewController { let scrollView = NSScrollView() private let textView = LogTextView(frame: .zero) - private let logs: Targets + private let logs: any LogSource private var source: LogView.Source? private var clearCount = 0 private var filter = "" @@ -409,7 +422,7 @@ final class LogViewController { /// UTF-16 length of each displayed line, oldest first, for trimming. private var displayedLengths: [Int] = [] - init(logs: Targets) { + init(logs: any LogSource) { self.logs = logs textView.isEditable = false textView.isSelectable = true diff --git a/apps/macos/App/Views/MachinesSettings.swift b/apps/macos/App/Views/MachinesSettings.swift new file mode 100644 index 0000000..2423d27 --- /dev/null +++ b/apps/macos/App/Views/MachinesSettings.swift @@ -0,0 +1,203 @@ +import LocalhostCore +import SwiftUI + +/// Settings › Machines: the other computers whose Projects this Mac shows, +/// each with where `ssh` reaches it and how it is, and Add Machine. +struct MachinesSettings: View { + @Environment(AppModel.self) private var model + @State private var isAdding = false + @State private var renaming: Machine? + @State private var newName = "" + @State private var removing: Machine? + + static let title = "Machines" + static let subtitle = "Other computers whose projects you run from here, over SSH. Their projects, secrets and services stay on them." + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SectionHeader(title: Self.title, subtitle: Self.subtitle) + if let error = model.machinesError { + Label(error.message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(Color(nsColor: Theme.failed)) + .padding(.bottom, 12) + } + ForEach(model.shownMachines) { shown in + MachineSettingsRow(shown: shown) { + newName = shown.machine.name + renaming = shown.machine + } remove: { + removing = shown.machine + } + } + if model.shownMachines.isEmpty, model.machinesError == nil { + Text("No machines yet. Add one you can reach with ssh, like a dev box on your network or over Tailscale.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 11) + .overlay(alignment: .top) { Divider() } + } + HStack { + Spacer() + Button("Add Machine…", systemImage: "plus") { isAdding = true } + .buttonStyle(PillButtonStyle()) + .disabled(model.machines == nil) + } + .padding(.top, 12) + .overlay(alignment: .top) { Divider() } + } + .sheet(isPresented: $isAdding) { + AddMachineForm() + .environment(model) + } + .alert("Rename Machine", isPresented: Binding { renaming != nil } set: { if !$0 { renaming = nil } }, + presenting: renaming) { machine in + TextField("Name", text: $newName) + Button("Rename") { model.renameMachine(machine.id, to: newName) } + Button("Cancel", role: .cancel) {} + } message: { machine in + Text("What localhost calls \(machine.destination). Leave it empty to use that.") + } + .confirmationDialog( + "Remove “\(removing?.name ?? "machine")”?", + isPresented: Binding { removing != nil } set: { if !$0 { removing = nil } }, + presenting: removing + ) { machine in + Button("Remove", role: .destructive) { model.removeMachine(machine.id) } + } message: { machine in + Text("localhost stops showing it here and closes its forwarded ports. Nothing changes on \(machine.name): its projects and services stay as they are.") + } + } +} + +/// A Machine: its name, the destination `ssh` reaches it at, how it is, and +/// a `⋯` menu with Reconnect, Rename and Remove. +private struct MachineSettingsRow: View { + @Environment(AppModel.self) private var model + let shown: MachinesStore.Shown + let rename: () -> Void + let remove: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(shown.machine.name) + if shown.machine.name != shown.machine.destination { + Text(shown.machine.destination) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + } + } + HStack(alignment: .firstTextBaseline, spacing: 5) { + if case .connecting = shown.state { + ProgressView().controlSize(.mini) + } else if case .unavailable = shown.state { + Image(systemName: "exclamationmark.triangle.fill") + .accessibilityHidden(true) + } + Text(model.stateDescription(of: shown)) + .fixedSize(horizontal: false, vertical: true) + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if model.needsReconnect(shown) { + Button("Reconnect") { model.reconnectMachine(shown.id) } + .buttonStyle(PillButtonStyle(isCompact: true)) + .accessibilityLabel("Reconnect \(shown.machine.name)") + } + MoreMenu(help: "More actions for \(shown.machine.name)") { + Button("Reconnect") { model.reconnectMachine(shown.id) } + Button("Rename…", action: rename) + Divider() + Button("Remove…", role: .destructive, action: remove) + } + .accessibilityLabel("More actions for \(shown.machine.name)") + } + .padding(.vertical, 11) + .overlay(alignment: .top) { Divider() } + .accessibilityElement(children: .contain) + .accessibilityLabel(shown.machine.name) + } +} + +/// Add Machine: where `ssh` reaches it (a Host of `~/.ssh/config`, offered +/// from a menu, or `user@host:port`) and what to call it. Add connects +/// first, and only once that works is the Machine kept; otherwise it says +/// why, and what to do next. +struct AddMachineForm: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var destination = "" + @State private var name = "" + @State private var hosts: [String] = [] + @State private var problem: String? + @State private var isConnecting = false + + private var trimmedDestination: String { + destination.trimmingCharacters(in: .whitespaces) + } + + var body: some View { + FormSheet( + systemImage: "desktopcomputer", + title: "Add Machine", + subtitle: Text("Another computer you reach with ssh."), + hint: isConnecting ? "Connecting to \(trimmedDestination)…" : "↩ to connect and add", + primaryTitle: isConnecting ? "Connecting…" : "Add", + isPrimaryDisabled: trimmedDestination.isEmpty || isConnecting, + width: 480, + cancel: { dismiss() }, + primary: { Task { await add() } } + ) { + Field(title: "SSH host", + hint: "A host from ~/.ssh/config, or user@host:port. localhost connects as ssh does, with your keys and settings.") { + HStack(spacing: 6) { + BoxedTextField(label: "SSH host", text: $destination, prompt: hosts.first ?? "devbox", isMonospaced: true) + if !hosts.isEmpty { + Menu { + ForEach(hosts, id: \.self) { host in + Button(host) { destination = host } + } + } label: { + Label("Hosts in ~/.ssh/config", systemImage: "chevron.down") + .labelStyle(.iconOnly) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Hosts in ~/.ssh/config") + } + } + } + Field(title: "Name", note: "optional") { + BoxedTextField(label: "Name", text: $name, prompt: trimmedDestination.isEmpty ? "Dev box" : trimmedDestination) + } + // Often longer than the footer holds, and says what to type in a terminal. + if let problem { + Label(problem, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(Color(nsColor: Theme.failed)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + .disabled(isConnecting) + .onChange(of: destination) { problem = nil } + .task { hosts = await Machines.sshConfigHosts() } + } + + private func add() async { + isConnecting = true + defer { isConnecting = false } + do { + try await model.addMachine(destination: trimmedDestination, name: name) + dismiss() + } catch { + problem = error.message + } + } +} diff --git a/apps/macos/App/Views/ProjectDetailView.swift b/apps/macos/App/Views/ProjectDetailView.swift index 3b7f544..7b3afe8 100644 --- a/apps/macos/App/Views/ProjectDetailView.swift +++ b/apps/macos/App/Views/ProjectDetailView.swift @@ -1,11 +1,15 @@ import LocalhostCore import SwiftUI -/// A Project's Services with their state and Start / Stop / Restart. +/// A Project's Services with their state and Start / Stop / Restart. Those +/// of another Machine look and act the same, but are edited there. struct ProjectDetailView: View { @Environment(AppModel.self) private var model let project: Project + /// This view shows the selected Project, wherever it is. + private var place: AppModel.Place { model.selectedPlace } + var body: some View { @Bindable var model = model List(selection: $model.selectedServiceID) { @@ -15,7 +19,7 @@ struct ProjectDetailView: View { .tag(AppModel.allServicesID) } ForEach(Array(project.services.enumerated()), id: \.element.id) { index, service in - ServiceRow(service: service, status: model.status(of: service), color: Theme.serviceColor(at: index), + ServiceRow(service: service, status: model.status(of: service, on: place), color: Theme.serviceColor(at: index), isSelected: model.selectedServiceID == service.id) .shortcutBadge(index + 1, shownIn: .services) .listRowSeparator(.hidden) @@ -25,20 +29,28 @@ struct ProjectDetailView: View { .listStyle(.inset) .scrollContentBackground(.hidden) .contextMenu(forSelectionType: UUID.self) { ids in - if let id = ids.first, id != AppModel.allServicesID, let (_, service) = model.service(id) { + if let id = ids.first, id != AppModel.allServicesID, let (_, service) = model.service(id, on: place) { serviceStateButtons(service) - Divider() - Button("Edit Service…") { model.presentedSheet = .editService(id) } - Divider() - if let folder = model.folder(ofService: id) { FolderActionButtons(folder: folder) } - Divider() - Button("Remove Service…", role: .destructive) { model.pendingRemoval = .service(id) } + if place == .thisMac { + Divider() + Button("Edit Service…") { model.presentedSheet = .editService(id) } + Divider() + if let folder = model.folder(ofService: id) { FolderActionButtons(folder: folder) } + Divider() + Button("Remove Service…", role: .destructive) { model.pendingRemoval = .service(id) } + } } } primaryAction: { ids in - if let id = ids.first, id != AppModel.allServicesID { model.presentedSheet = .editService(id) } + if place == .thisMac, let id = ids.first, id != AppModel.allServicesID { model.presentedSheet = .editService(id) } } .overlay { - if project.services.isEmpty { + if project.services.isEmpty, case .machine(let machineID) = place { + ContentUnavailableView { + Label("No Services", systemImage: "terminal") + } description: { + Text("Add services to this project on \(model.shownMachine(machineID)?.machine.name ?? "its machine").") + } + } else if project.services.isEmpty { ContentUnavailableView { Label("No Services", systemImage: "terminal") } description: { @@ -52,27 +64,36 @@ struct ProjectDetailView: View { } } .safeAreaInset(edge: .bottom, spacing: 0) { - if !project.services.isEmpty { addServiceRow } + if !project.services.isEmpty, place == .thisMac { addServiceRow } } .columnGlass() .navigationTitle(project.name) - .navigationSubtitle((project.path as NSString).abbreviatingWithTildeInPath) + .navigationSubtitle(subtitle) + } + + /// The folder, and on another Machine its name: a path there isn't one here. + private var subtitle: String { + guard case .machine(let machineID) = place else { return (project.path as NSString).abbreviatingWithTildeInPath } + return "\(model.shownMachine(machineID)?.machine.name ?? "Machine") · \(project.path)" } @ViewBuilder private func serviceStateButtons(_ service: Service) -> some View { - switch model.status(of: service).state { - case .starting, .running: - if model.urlToOpen(service) != nil { - Button("Open in Browser") { model.openURL(of: service.id) } + Group { + switch model.status(of: service, on: place).state { + case .starting, .running: + if model.urlToOpen(service, on: place) != nil { + Button("Open in Browser") { model.openURL(of: service.id, on: place) } + } + Button("Restart") { model.restart(service.id, on: place) } + Button("Stop") { model.stop(service.id, on: place) } + case .stopping: + EmptyView() + case .stopped, .exited, .failed: + Button("Start") { model.start(service.id, on: place) } } - Button("Restart") { model.restart(service.id) } - Button("Stop") { model.stop(service.id) } - case .stopping: - EmptyView() - case .stopped, .exited, .failed: - Button("Start") { model.start(service.id) } } + .disabled(!model.isReachable(place)) } private var addServiceRow: some View { @@ -101,19 +122,30 @@ struct ProjectToolbar: ToolbarContent { let model: AppModel let project: Project + private var place: AppModel.Place { model.selectedPlace } + var body: some ToolbarContent { ToolbarItemGroup(placement: .primaryAction) { - if !project.profiles.isEmpty { + // Another Machine's Profiles, configuration and folder are changed there. + if !project.profiles.isEmpty, place == .thisMac { profileMenu } - Button("Start All", systemImage: "play.fill") { model.startAll(inProject: project.id) } + Button("Start All", systemImage: "play.fill") { model.startAll(inProject: project.id, on: place) } .buttonStyle(AccentButtonStyle()) .help("Start every service except Manual ones (⇧⌘R)") - .disabled(project.services.isEmpty) - Button("Stop All", systemImage: "stop.fill") { model.stopAll(inProject: project.id) } + .disabled(project.services.isEmpty || !model.isReachable(place)) + Button("Stop All", systemImage: "stop.fill") { model.stopAll(inProject: project.id, on: place) } .buttonStyle(PillButtonStyle()) .help("Stop All (⇧⌘.)") - .disabled(!project.services.contains { model.status(of: $0).state.isActive }) + .disabled(!project.services.contains { model.status(of: $0, on: place).state.isActive } || !model.isReachable(place)) + if place == .thisMac { + moreMenu + } + } + } + + private var moreMenu: some View { + Group { MoreMenu(help: "More actions for this project") { Button("Stats…") { model.statsProjectID = project.id } .disabled(!project.services.contains { model.status(of: $0).state.isActive }) @@ -168,10 +200,10 @@ struct AllServicesRow: View { let project: Project var body: some View { - let states = project.services.map { model.status(of: $0).state } + let states = project.services.map { model.status(of: $0, on: model.selectedPlace).state } let failed = states.filter { $0 == .failed }.count HStack(spacing: 9) { - ServiceDots(services: model.serviceDots(of: project)) + ServiceDots(services: model.serviceDots(of: project, on: model.selectedPlace)) Text("All services") .fontWeight(.semibold) .lineLimit(1) @@ -273,7 +305,7 @@ struct ServiceRow: View { /// The status summary, naming the Dependencies a Starting Service waits for. private var summary: String { - let awaited = status.awaitedDependencies.compactMap { id in model.service(id)?.service.name } + let awaited = status.awaitedDependencies.compactMap { id in model.service(id, on: model.selectedPlace)?.service.name } if isManual { return "Manual" } if status.isRestartNeeded { return "\(status.summary) · restart to apply" } guard status.state == .starting, !awaited.isEmpty else { return status.summary } @@ -303,19 +335,24 @@ struct ServiceAccessibilityActions: View { let status: ServiceStatus var body: some View { - switch status.state { - case .starting, .running: - if model.urlToOpen(service) != nil { - Button("Open in Browser") { model.openURL(of: service.id) } + let place = model.selectedPlace + if model.isReachable(place) { + switch status.state { + case .starting, .running: + if model.urlToOpen(service, on: place) != nil { + Button("Open in Browser") { model.openURL(of: service.id, on: place) } + } + Button("Restart") { model.restart(service.id, on: place) } + Button("Stop") { model.stop(service.id, on: place) } + case .stopping: + EmptyView() + case .stopped, .exited, .failed: + Button("Start") { model.start(service.id, on: place) } } - Button("Restart") { model.restart(service.id) } - Button("Stop") { model.stop(service.id) } - case .stopping: - EmptyView() - case .stopped, .exited, .failed: - Button("Start") { model.start(service.id) } } - Button("Edit Service") { model.presentedSheet = .editService(service.id) } + if place == .thisMac { + Button("Edit Service") { model.presentedSheet = .editService(service.id) } + } } } @@ -326,13 +363,15 @@ struct ServiceActions: View { let service: Service let status: ServiceStatus + private var place: AppModel.Place { model.selectedPlace } + var body: some View { HStack(spacing: 6) { switch status.state { case .starting, .running: - let url = model.urlToOpen(service) + let url = model.urlToOpen(service, on: place) if let url { - Button { model.openURL(of: service.id) } label: { + Button { model.openURL(of: service.id, on: place) } label: { HStack(spacing: 3) { Text("Open") Image(systemName: "arrow.up.right").font(.system(size: 8.5, weight: .bold)) @@ -342,24 +381,27 @@ struct ServiceActions: View { .help("Open \(url.absoluteString) (⇧⌘O)") .accessibilityLabel("Open in Browser") } - Button("Restart") { model.restart(service.id) } + Button("Restart") { model.restart(service.id, on: place) } .buttonStyle(PillButtonStyle(isProminent: url == nil, isCompact: true)) .help("Restart (⌘R)") - Button("Stop") { model.stop(service.id) } + Button("Stop") { model.stop(service.id, on: place) } .buttonStyle(PillButtonStyle(isCompact: true)) .help("Stop (⌘.)") case .stopping: ProgressView().controlSize(.mini) Text("Stopping…").font(.caption).foregroundStyle(.secondary) case .stopped, .exited, .failed: - Button("Start") { model.start(service.id) } + Button("Start") { model.start(service.id, on: place) } .buttonStyle(PillButtonStyle(isProminent: true, isCompact: true)) .help("Start (⌘R)") - Button("Edit…") { model.presentedSheet = .editService(service.id) } - .buttonStyle(PillButtonStyle(isCompact: true)) - .help("Edit Service") + if place == .thisMac { + Button("Edit…") { model.presentedSheet = .editService(service.id) } + .buttonStyle(PillButtonStyle(isCompact: true)) + .help("Edit Service") + } } } + .disabled(!model.isReachable(place)) .frame(height: 22) } } diff --git a/apps/macos/App/Views/ProjectSidebar.swift b/apps/macos/App/Views/ProjectSidebar.swift index e7f4c5d..2d689d4 100644 --- a/apps/macos/App/Views/ProjectSidebar.swift +++ b/apps/macos/App/Views/ProjectSidebar.swift @@ -2,8 +2,8 @@ import LocalhostCore import SwiftUI /// The app's name, project search with Add Project, Ports and the Projects, -/// a newer version when there is one, and a footer with Settings, Keyboard -/// Shortcuts and how many Services are running. +/// then each other Machine's, a newer version when there is one, and a +/// footer with Settings, Keyboard Shortcuts and how many Services are running. struct ProjectSidebar: View { @Environment(AppModel.self) private var model @@ -15,22 +15,9 @@ struct ProjectSidebar: View { .tag(AppModel.SidebarItem.ports) Section("Projects") { ForEach(projects) { project in - HStack(spacing: 8) { - StateIndicator(state: model.aggregateState(of: project)) - Text(project.name) - .lineLimit(1) - Spacer(minLength: 0) - if project.isFavorite { - Image(systemName: "star.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .shortcutBadge(model.projects.firstIndex { $0.id == project.id }.map { $0 + 1 }, shownIn: .projects) - .accessibilityElement(children: .ignore) - .accessibilityLabel(project.name + (project.isFavorite ? ", favourite" : "")) - .accessibilityValue(model.accessibilityState(of: project)) - .tag(AppModel.SidebarItem.project(project.id)) + ProjectRow(project: project, place: .thisMac) + .shortcutBadge(model.projects.firstIndex { $0.id == project.id }.map { $0 + 1 }, shownIn: .projects) + .tag(AppModel.SidebarItem.project(project.id)) } if projects.isEmpty, !model.projectSearch.isEmpty { Text("No projects match “\(model.projectSearch)”.") @@ -39,6 +26,19 @@ struct ProjectSidebar: View { .selectionDisabled() } } + ForEach(model.shownMachines) { shown in + Section { + MachineStateRow(shown: shown) + ForEach(model.filtered(shown.projects)) { project in + ProjectRow(project: project, place: .machine(shown.id)) + .opacity(shown.isConnected ? 1 : 0.6) + .tag(AppModel.SidebarItem.machineProject(project.id, machine: shown.id)) + } + } header: { + Text(shown.machine.name) + .accessibilityLabel("Machine \(shown.machine.name)") + } + } } .listStyle(.sidebar) .modifier(SolidSidebar()) @@ -59,7 +59,88 @@ struct ProjectSidebar: View { FolderActionButtons(folder: AppModel.folder(of: project)) Divider() Button("Remove Project…", role: .destructive) { model.pendingRemoval = .project(id) } + } else if case .machineProject(let id, let machineID) = items.first, + let project = model.project(id, on: .machine(machineID)) { + let place = AppModel.Place.machine(machineID) + Button("Start All") { model.startAll(inProject: id, on: place) } + .disabled(project.services.isEmpty || !model.isReachable(place)) + Button("Stop All") { model.stopAll(inProject: id, on: place) } + .disabled(!project.services.contains { model.status(of: $0, on: place).state.isActive } || !model.isReachable(place)) + Divider() + Button("Reconnect") { model.reconnectMachine(machineID) } + } + } + } +} + +/// A Project in the sidebar, on this Mac or another Machine: the state of +/// its Services, its name, and whether it is a favourite. +private struct ProjectRow: View { + @Environment(AppModel.self) private var model + let project: Project + let place: AppModel.Place + + var body: some View { + HStack(spacing: 8) { + StateIndicator(state: model.aggregateState(of: project, on: place)) + Text(project.name) + .lineLimit(1) + Spacer(minLength: 0) + if project.isFavorite { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(project.name + (project.isFavorite ? ", favourite" : "")) + .accessibilityValue(model.accessibilityState(of: project, on: place)) + } +} + +/// How another Machine is, under its name, while that isn't plainly +/// connected: connecting, why it can't be reached (with Reconnect when it +/// waits for the user), or that it has no Projects. +private struct MachineStateRow: View { + @Environment(AppModel.self) private var model + let shown: MachinesStore.Shown + + /// Nothing while connected with Projects, or reconnecting to them. + private var text: String? { + switch shown.state { + case .connected: + return shown.projects.isEmpty ? "No projects on \(shown.machine.name)." : nil + case .connecting where !shown.projects.isEmpty: + return nil + default: + return model.stateDescription(of: shown) + } + } + + var body: some View { + if let text { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if case .connecting = shown.state { + ProgressView().controlSize(.mini) + } else if case .unavailable = shown.state { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + Text(text) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if model.needsReconnect(shown) { + Button("Reconnect") { model.reconnectMachine(shown.id) } + .buttonStyle(PillButtonStyle(isCompact: true)) + .accessibilityLabel("Reconnect \(shown.machine.name)") + } } + .padding(.vertical, 2) + .selectionDisabled() } } } diff --git a/apps/macos/App/Views/SettingsView.swift b/apps/macos/App/Views/SettingsView.swift index a9cd44b..da43cbc 100644 --- a/apps/macos/App/Views/SettingsView.swift +++ b/apps/macos/App/Views/SettingsView.swift @@ -5,7 +5,7 @@ import SwiftUI /// the sidebar's glass and the selected section beside it. struct SettingsView: View { enum Section: String, CaseIterable, Identifiable { - case general, appearance, ports, agents + case general, appearance, ports, machines, agents var id: Self { self } @@ -14,6 +14,7 @@ struct SettingsView: View { case .general: "General" case .appearance: "Appearance" case .ports: "Ports" + case .machines: "Machines" case .agents: "Agents" } } @@ -23,6 +24,7 @@ struct SettingsView: View { case .general: "gearshape" case .appearance: "circle.lefthalf.filled" case .ports: "network" + case .machines: "desktopcomputer" case .agents: "link" } } @@ -40,6 +42,7 @@ struct SettingsView: View { case .general: GeneralSettings() case .appearance: AppearanceSettings() case .ports: PortsSettings() + case .machines: MachinesSettings() case .agents: AgentsSettings() } } @@ -95,7 +98,7 @@ private struct RailLabelStyle: LabelStyle { // MARK: Sections -private struct SectionHeader: View { +struct SectionHeader: View { let title: String let subtitle: String From a9086a6496c4f3022ae6eb8723bafbe42440e5bf Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:53:47 -0300 Subject: [PATCH 13/27] Word a Machine's state once, in the core, and say when it waits for the user --- core/localhost-core/src/ffi/functions.rs | 12 +++++++ core/localhost-core/src/machine/machines.rs | 35 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/core/localhost-core/src/ffi/functions.rs b/core/localhost-core/src/ffi/functions.rs index 426a03a..1ade311 100644 --- a/core/localhost-core/src/ffi/functions.rs +++ b/core/localhost-core/src/ffi/functions.rs @@ -180,6 +180,18 @@ pub async fn scan_project(project: Project) -> Vec { // MARK: Machines +/// "Connected · localhost 0.5.0", "Connecting…", or why `machine` isn't. +#[uniffi::export] +pub fn machine_state_summary(state: &crate::machine::MachineState, machine: &str) -> String { + state.summary(machine) +} + +/// Whether the Machine waits for the user to connect it again. +#[uniffi::export] +pub fn machine_state_needs_user(state: &crate::machine::MachineState) -> bool { + state.needs_user() +} + /// The hosts named in `~/.ssh/config`, to suggest as a Machine's destination. #[uniffi::export] pub async fn ssh_config_hosts() -> Vec { diff --git a/core/localhost-core/src/machine/machines.rs b/core/localhost-core/src/machine/machines.rs index 8ee8c7e..3256571 100644 --- a/core/localhost-core/src/machine/machines.rs +++ b/core/localhost-core/src/machine/machines.rs @@ -57,6 +57,27 @@ pub enum MachineState { }, } +impl MachineState { + /// How it is, as the apps say it under its name: "Connected · localhost + /// 0.5.0", "Connecting…", or why it isn't and what to do next. + pub fn summary(&self, machine: &str) -> String { + match self { + MachineState::NotConnected => "Not connected".into(), + MachineState::Connecting => "Connecting…".into(), + MachineState::Connected { version } => format!("Connected · localhost {version}"), + MachineState::Unavailable { problem, is_retrying: true } => { + format!("{} Trying again…", problem.message(machine)) + } + MachineState::Unavailable { problem, is_retrying: false } => problem.message(machine), + } + } + + /// Whether it waits for the user to connect it again. + pub fn needs_user(&self) -> bool { + matches!(self, MachineState::NotConnected | MachineState::Unavailable { is_retrying: false, .. }) + } +} + /// One Machine as an app shows it. Its Projects and statuses are those of /// its last connection, kept while it reconnects. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -835,6 +856,20 @@ mod tests { assert!(matches!(machines.rename(Id::new(), "x"), Err(LocalhostError::MachineNotFound(_)))); } + #[test] + fn says_how_a_machine_is() { + let lost = MachineState::Unavailable { problem: MachineProblem::Lost, is_retrying: true }; + assert_eq!(lost.summary("devbox"), "The connection to devbox was lost. Trying again…"); + assert!(!lost.needs_user()); + let untrusted = MachineState::Unavailable { problem: MachineProblem::HostKeyNotTrusted, is_retrying: false }; + assert!(untrusted.needs_user()); + assert_eq!( + MachineState::Connected { version: "0.5.0".into() }.summary("devbox"), + "Connected · localhost 0.5.0" + ); + assert!(MachineState::NotConnected.needs_user()); + } + #[test] fn forwards_only_urls_local_to_the_machine() { assert_eq!(local_port("http://localhost:3000/app"), Some(3000)); From a6fc5bdac337c23b52a13386c46e0c2592adf524 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 22:53:47 -0300 Subject: [PATCH 14/27] Take a Machine's state wording from the core in the macOS app --- apps/macos/App/AppModel+Machines.swift | 14 ++------------ .../Sources/LocalhostCore/Machines.swift | 11 +++++++++++ .../Tests/LocalhostCoreTests/LayerTests.swift | 2 ++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/macos/App/AppModel+Machines.swift b/apps/macos/App/AppModel+Machines.swift index 8d3bc80..c0580fe 100644 --- a/apps/macos/App/AppModel+Machines.swift +++ b/apps/macos/App/AppModel+Machines.swift @@ -101,21 +101,11 @@ extension AppModel { /// "Connected · localhost 0.5.0", "Connecting…", or why it isn't. func stateDescription(of shown: MachinesStore.Shown) -> String { - switch shown.state { - case .notConnected: "Not connected" - case .connecting: "Connecting…" - case .connected(let version): "Connected · localhost \(version)" - case .unavailable(let problem, let isRetrying): - problem.message(machine: shown.machine.name) + (isRetrying ? " Trying again…" : "") - } + shown.state.summary(machine: shown.machine.name) } /// Whether the Machine is waiting for the user to connect it again. func needsReconnect(_ shown: MachinesStore.Shown) -> Bool { - switch shown.state { - case .notConnected: true - case .unavailable(_, let isRetrying): !isRetrying - case .connecting, .connected: false - } + shown.state.needsUser } } diff --git a/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift index b9f356a..2010147 100644 --- a/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift +++ b/apps/macos/LocalhostCoreFFI/Sources/LocalhostCore/Machines.swift @@ -118,6 +118,17 @@ extension MachineSnapshot: Identifiable { public var id: UUID { machine.id } } +extension MachineState { + /// "Connected · localhost 0.5.0", "Connecting…", or why `machine` isn't + /// connected and what to do next. + public func summary(machine: String) -> String { + machineStateSummary(state: self, machine: machine) + } + + /// Whether the Machine waits for the user to connect it again. + public var needsUser: Bool { machineStateNeedsUser(state: self) } +} + extension MachineProblem { /// Why `machine` can't be reached, and what to do next. public func message(machine: String) -> String { diff --git a/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift b/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift index da03c88..d5d710b 100644 --- a/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift +++ b/apps/macos/LocalhostCoreFFI/Tests/LocalhostCoreTests/LayerTests.swift @@ -260,5 +260,7 @@ private final class TemporaryRoot { #expect(MachineProblem.hostKeyNotTrusted.message(machine: "devbox").contains("ssh devbox")) let error = LocalhostError.MachineUnavailable(machine: "devbox", problem: .notInstalled) #expect(error.message.contains("isn’t installed on devbox")) + #expect(MachineState.connected(version: "0.5.0").summary(machine: "devbox") == "Connected · localhost 0.5.0") + #expect(MachineState.unavailable(problem: .hostKeyChanged, isRetrying: false).needsUser) } } From 41d43081aba1030479a1516d88aafbf513ea13c2 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 23:04:00 -0300 Subject: [PATCH 15/27] Name a Machine's state and event fields in camelCase per variant, as specta reads them --- core/localhost-core/src/machine/machines.rs | 32 ++++++++------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/core/localhost-core/src/machine/machines.rs b/core/localhost-core/src/machine/machines.rs index 3256571..9b885bb 100644 --- a/core/localhost-core/src/machine/machines.rs +++ b/core/localhost-core/src/machine/machines.rs @@ -51,6 +51,7 @@ pub enum MachineState { }, /// Not connected, for this reason. `is_retrying`: another attempt comes /// on its own. + #[serde(rename_all = "camelCase")] Unavailable { problem: MachineProblem, is_retrying: bool, @@ -93,34 +94,25 @@ pub struct MachineSnapshot { } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +// Per variant, not `rename_all_fields`, which specta doesn't read. +#[serde(tag = "kind", rename_all = "camelCase")] #[cfg_attr(feature = "ffi", derive(uniffi::Enum))] #[cfg_attr(feature = "specta", derive(specta::Type))] // Not boxed: UniFFI carries no `Box`, and events go one at a time. #[allow(clippy::large_enum_variant)] pub enum MachinesEvent { /// The list changed: added, removed or renamed. - MachinesChanged { - machines: Vec, - }, - StateChanged { - machine_id: Id, - state: MachineState, - }, + MachinesChanged { machines: Vec }, + #[serde(rename_all = "camelCase")] + StateChanged { machine_id: Id, state: MachineState }, /// Its Projects, after it connected or they changed there. - ProjectsChanged { - machine_id: Id, - projects: Vec, - }, + #[serde(rename_all = "camelCase")] + ProjectsChanged { machine_id: Id, projects: Vec }, /// Every status there, read afresh after it connected. - StatusesChanged { - machine_id: Id, - statuses: Vec, - }, - ServiceChanged { - machine_id: Id, - event: ServiceEvent, - }, + #[serde(rename_all = "camelCase")] + StatusesChanged { machine_id: Id, statuses: Vec }, + #[serde(rename_all = "camelCase")] + ServiceChanged { machine_id: Id, event: ServiceEvent }, } #[derive(Serialize, Deserialize)] From 286af548abddfd050273c0e20c9b8aeb475d5af3 Mon Sep 17 00:00:00 2001 From: Marcel Losso Date: Wed, 4 Nov 2026 23:04:00 -0300 Subject: [PATCH 16/27] =?UTF-8?q?Show=20other=20Machines=20in=20the=20Linu?= =?UTF-8?q?x=20and=20Windows=20app:=20Settings=20=E2=80=BA=20Machines,=20a?= =?UTF-8?q?=20sidebar=20section=20each,=20their=20logs,=20and=20Open=20thr?= =?UTF-8?q?ough=20a=20Forward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src-tauri/src/bridge.rs | 51 ++- apps/desktop/src-tauri/src/lib.rs | 23 ++ apps/desktop/src-tauri/src/lifecycle.rs | 10 +- apps/desktop/src-tauri/src/logs.rs | 15 +- apps/desktop/src-tauri/src/machines.rs | 334 ++++++++++++++++++ apps/desktop/src/App.tsx | 55 ++- apps/desktop/src/actions.ts | 33 +- apps/desktop/src/bindings.ts | 242 ++++++++++++- apps/desktop/src/components/Announcer.tsx | 38 +- apps/desktop/src/components/FailedBanner.tsx | 10 +- apps/desktop/src/components/Icons.tsx | 8 + apps/desktop/src/components/LogPane.tsx | 45 ++- .../components/MachinesSettings.module.css | 58 +++ .../src/components/MachinesSettings.tsx | 239 +++++++++++++ .../desktop/src/components/ServicesColumn.tsx | 143 +++++--- .../desktop/src/components/SettingsDialog.tsx | 7 +- .../desktop/src/components/Sidebar.module.css | 13 + apps/desktop/src/components/Sidebar.test.tsx | 29 +- apps/desktop/src/components/Sidebar.tsx | 109 +++++- apps/desktop/src/log/useLog.ts | 9 +- apps/desktop/src/machines.test.ts | 42 +++ apps/desktop/src/machines.ts | 102 ++++++ apps/desktop/src/selection.ts | 10 +- apps/desktop/src/shortcuts.test.tsx | 25 +- apps/desktop/src/shortcuts.ts | 33 +- apps/desktop/src/test/fixtures.ts | 22 +- 26 files changed, 1554 insertions(+), 151 deletions(-) create mode 100644 apps/desktop/src-tauri/src/machines.rs create mode 100644 apps/desktop/src/components/MachinesSettings.module.css create mode 100644 apps/desktop/src/components/MachinesSettings.tsx create mode 100644 apps/desktop/src/machines.test.ts create mode 100644 apps/desktop/src/machines.ts diff --git a/apps/desktop/src-tauri/src/bridge.rs b/apps/desktop/src-tauri/src/bridge.rs index b5fae77..6f2dfa0 100644 --- a/apps/desktop/src-tauri/src/bridge.rs +++ b/apps/desktop/src-tauri/src/bridge.rs @@ -32,9 +32,9 @@ pub struct Snapshot { #[serde(rename_all = "camelCase")] pub struct StatusView { #[serde(flatten)] - status: ServiceStatus, + pub(crate) status: ServiceStatus, failure_message: Option, - url_to_open: Option, + pub(crate) url_to_open: Option, } impl StatusView { @@ -51,7 +51,7 @@ impl StatusView { #[serde(rename_all = "camelCase")] pub struct ProjectState { project_id: Id, - state: Option, + pub(crate) state: Option, } /// A command's failure: the core's typed error, and its message for the user. @@ -93,6 +93,10 @@ pub enum CoreEvent { pub struct Subscriptions(Mutex>>); impl Subscriptions { + pub fn push(&self, task: JoinHandle<()>) { + self.0.lock().unwrap().push(task); + } + pub fn close_all(&self) { for task in self.0.lock().unwrap().drain(..) { task.abort(); @@ -107,13 +111,11 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel) let core = state.core()?; let (projects, mut library_events) = core.library.subscribe(); let (statuses, mut service_events) = core.targets.subscribe(); - let mut view = View { projects, statuses, states: HashMap::new() }; - let project_states: Vec = view.projects.iter().map(|project| view.project_state(project)).collect(); - view.states = project_states.iter().map(|state| (state.project_id, state.state)).collect(); + let mut view = View::new(projects, statuses); let snapshot = Snapshot { projects: view.projects.clone(), - statuses: view.statuses.values().map(|status| view.status_view(status)).collect(), - project_states, + statuses: view.status_views(), + project_states: view.project_states(), }; let task = tauri::async_runtime::spawn(async move { @@ -142,18 +144,37 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel) } } }); - state.subscriptions.0.lock().unwrap().push(task); + state.subscriptions.push(task); Ok(snapshot) } -/// One subscriber's copy of the state, for the derived Project states. -struct View { - projects: Vec, +/// One subscriber's copy of the state, for the derived Project states: of +/// this computer's Projects, or another Machine's (`machines`). +pub(crate) struct View { + pub(crate) projects: Vec, statuses: HashMap, states: HashMap>, } impl View { + pub(crate) fn new(projects: Vec, statuses: HashMap) -> View { + let mut view = View { projects, statuses, states: HashMap::new() }; + view.states = view.project_states().into_iter().map(|state| (state.project_id, state.state)).collect(); + view + } + + pub(crate) fn statuses(&self) -> &HashMap { + &self.statuses + } + + pub(crate) fn status_views(&self) -> Vec { + self.statuses.values().map(|status| self.status_view(status)).collect() + } + + pub(crate) fn project_states(&self) -> Vec { + self.projects.iter().map(|project| self.project_state(project)).collect() + } + /// Applies the event; returns the Project whose state may have changed. fn apply_library(&mut self, event: &ProjectLibraryEvent) -> Option { match event { @@ -175,7 +196,7 @@ impl View { } } - fn status_view(&self, status: &ServiceStatus) -> StatusView { + pub(crate) fn status_view(&self, status: &ServiceStatus) -> StatusView { let service = self.projects.iter().find_map(|project| project.service(status.service_id)); StatusView::new(status.clone(), service) } @@ -191,13 +212,13 @@ impl View { .collect() } - fn apply_status(&mut self, status: &ServiceStatus) -> Option { + pub(crate) fn apply_status(&mut self, status: &ServiceStatus) -> Option { self.statuses.insert(status.service_id, status.clone()); self.projects.iter().find(|project| project.service(status.service_id).is_some()).map(|project| project.id) } /// The Project's state if it differs from the last one sent. - fn refresh_state(&mut self, project_id: Id) -> Option { + pub(crate) fn refresh_state(&mut self, project_id: Id) -> Option { let project = self.projects.iter().find(|project| project.id == project_id)?; let state = self.project_state(project); (self.states.insert(project_id, state.state) != Some(state.state)).then_some(state) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2f85a2c..1ab0845 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod launch; mod lifecycle; mod listeners; mod logs; +mod machines; mod palette; mod perf; mod services; @@ -29,6 +30,7 @@ pub use targets::DAEMON_ARGUMENT; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use localhost_core::machine::Machines; use localhost_core::{ Launcher, LocalhostError, Origin, ProjectLibrary, SecretStore, ServiceManagerOptions, SystemSecretStore, Targets, default_storage_root, @@ -69,6 +71,8 @@ impl Core { /// update on offer and the app's lifetime. pub struct AppState { core: Result, + /// The other Machines, or why their list couldn't be read. + machines: Result, logs: logs::LogStreams, subscriptions: bridge::Subscriptions, lifecycle: lifecycle::Lifecycle, @@ -165,6 +169,18 @@ fn specta_builder() -> tauri_specta::Builder { agents::command_line_status, agents::add_command_to_path, agents::remove_command_from_path, + machines::subscribe_machines, + machines::add_machine, + machines::rename_machine, + machines::remove_machine, + machines::reconnect_machine, + machines::ssh_hosts, + machines::machine_start_service, + machines::machine_stop_service, + machines::machine_restart_service, + machines::machine_start_all, + machines::machine_stop_all, + machines::machine_open_service, ]) .events(collect_events![lifecycle::Reveal, launch::OrphansFound, updates::UpdateState]) } @@ -200,8 +216,15 @@ pub fn run() { if let Ok(core) = &core { core.targets.set_automatic_port_move(settings.get().automatic_port_move); } + let machines = Machines::open(&storage_root(), Path::new("ssh")); + if let (Ok(machines), false) = (&machines, is_measuring) { + // Connecting spawns the tasks that keep each one connected. + let _runtime = tauri::async_runtime::handle().inner().enter(); + machines::connect_all(machines); + } app.manage(AppState { core, + machines, logs: logs::LogStreams::default(), subscriptions: bridge::Subscriptions::default(), lifecycle: lifecycle::Lifecycle::default(), diff --git a/apps/desktop/src-tauri/src/lifecycle.rs b/apps/desktop/src-tauri/src/lifecycle.rs index 8b90351..cdae85c 100644 --- a/apps/desktop/src-tauri/src/lifecycle.rs +++ b/apps/desktop/src-tauri/src/lifecycle.rs @@ -144,15 +144,23 @@ pub fn quit(app: &AppHandle, origin: Origin) { let services = projects.iter().flat_map(|project| &project.services); services.filter(|service| core.targets.status(service.id).state.is_active()).count() }); + let on_machines = state.machines().map_or(0, crate::machines::active_count); + let count = count + on_machines; if count == 0 { exit(app, origin); return; } let title = if count == 1 { "Stop 1 service and quit?".to_string() } else { format!("Stop {count} services and quit?") }; + let message = if on_machines == 0 { + "Running services are stopped before localhost quits." + } else { + "Running services are stopped before localhost quits. Those started from here on other machines stop too, \ + unless localhost is open on them." + }; let mut dialog = app .dialog() - .message("Running services are stopped before localhost quits.") + .message(message) .title(title) .kind(MessageDialogKind::Warning) .buttons(MessageDialogButtons::OkCancelCustom("Stop and Quit".into(), "Cancel".into())); diff --git a/apps/desktop/src-tauri/src/logs.rs b/apps/desktop/src-tauri/src/logs.rs index bb0453a..6b8f6a6 100644 --- a/apps/desktop/src-tauri/src/logs.rs +++ b/apps/desktop/src-tauri/src/logs.rs @@ -96,18 +96,22 @@ pub struct LogStreams { } /// Streams `source` over `batches`: what is buffered first, then new -/// lines. Returns the id to close it with. +/// lines; on another Machine (`machine_id`), through its connection. +/// Returns the id to close it with. #[tauri::command] #[specta::specta] pub async fn open_log( state: tauri::State<'_, AppState>, source: LogSource, + machine_id: Option, batches: Channel, ) -> CommandResult { - let targets = &state.core()?.targets; let (buffered, receiver) = match &source { LogSource::Run { run_id } => { - let (lines, receiver) = targets.subscribe_to_run(*run_id).await?; + let (lines, receiver) = match machine_id { + Some(machine_id) => state.machines()?.subscribe_to_run(machine_id, *run_id).await?, + None => state.core()?.targets.subscribe_to_run(*run_id).await?, + }; let lines: Vec = lines.iter().map(|line| LogLine::new(line, None)).collect(); let (sender, combined) = mpsc::unbounded_channel(); forward(receiver, sender, |line| LogLine::new(&line, None)); @@ -115,7 +119,10 @@ pub async fn open_log( } LogSource::Services { service_ids } => { let ids: HashSet = service_ids.iter().copied().collect(); - let (lines, receiver) = targets.subscribe_to_services(ids).await?; + let (lines, receiver) = match machine_id { + Some(machine_id) => state.machines()?.subscribe_to_services(machine_id, ids).await?, + None => state.core()?.targets.subscribe_to_services(ids).await?, + }; let lines = lines.iter().map(|line| LogLine::new(&line.line, Some(line.service_id))).collect(); let (sender, combined) = mpsc::unbounded_channel(); forward(receiver, sender, |line| LogLine::new(&line.line, Some(line.service_id))); diff --git a/apps/desktop/src-tauri/src/machines.rs b/apps/desktop/src-tauri/src/machines.rs new file mode 100644 index 0000000..0bec693 --- /dev/null +++ b/apps/desktop/src-tauri/src/machines.rs @@ -0,0 +1,334 @@ +//! Other Machines: computers whose localhost this app shows, reached with +//! the system's `ssh`. The core keeps each one connected and mirrors its +//! Projects and statuses; this sends them to the UI as this computer's are +//! (`bridge`), each Machine with a view of its own, and turns its buttons +//! into calls on the Machine. Changes there are made as `Origin::Machine`, +//! which the other Machine decides. + +use std::collections::HashMap; + +use localhost_core::machine::{MachineSnapshot, MachineState, Machines, MachinesEvent, ssh_config_hosts}; +use localhost_core::{Id, LocalhostError, Machine, ServiceEvent}; +use serde::Serialize; +use specta::Type; +use tauri::ipc::Channel; + +use crate::AppState; +use crate::bridge::{CommandResult, ProjectState, StatusView, View}; + +/// One Machine as the UI shows it. Its Projects and statuses are those of +/// its last connection, kept while it reconnects. +#[derive(Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MachineView { + machine: Machine, + state: MachineState, + /// How it is, in the core's words: "Connected · localhost 0.5.0", or why not. + summary: String, + is_connected: bool, + /// It waits for the user to connect it again. + needs_user: bool, + projects: Vec, + statuses: Vec, + project_states: Vec, +} + +#[derive(Clone, Serialize, Type)] +// Per variant, not `rename_all_fields`, which specta doesn't read. +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MachineEvent { + /// Every Machine afresh: one was added, removed or renamed, or its + /// state, Projects or statuses changed as a whole. + Machines { machines: Vec }, + /// A Service's state changed there. + #[serde(rename_all = "camelCase")] + Status { machine_id: Id, status: Box }, + /// Sent after the status that changed it. + #[serde(rename_all = "camelCase")] + ProjectState { machine_id: Id, project_state: ProjectState }, +} + +/// The UI's copy of one Machine. +struct Shown { + machine: Machine, + state: MachineState, + view: View, +} + +impl Shown { + fn new(snapshot: MachineSnapshot) -> Shown { + let statuses = snapshot.statuses.into_iter().map(|status| (status.service_id, status)).collect(); + Shown { machine: snapshot.machine, state: snapshot.state, view: View::new(snapshot.projects, statuses) } + } + + fn view(&self) -> MachineView { + MachineView { + summary: self.state.summary(&self.machine.name), + is_connected: matches!(self.state, MachineState::Connected { .. }), + needs_user: self.state.needs_user(), + machine: self.machine.clone(), + state: self.state.clone(), + projects: self.view.projects.clone(), + statuses: self.view.status_views(), + project_states: self.view.project_states(), + } + } +} + +/// The Machines as they are, then every change over `events` until the +/// window goes away. +#[tauri::command] +#[specta::specta] +pub fn subscribe_machines( + state: tauri::State<'_, AppState>, + events: Channel, +) -> CommandResult> { + let machines = state.machines()?; + let (snapshots, mut changes) = machines.subscribe(); + let mut shown: Vec = snapshots.into_iter().map(Shown::new).collect(); + let snapshot = shown.iter().map(Shown::view).collect(); + + let task = tauri::async_runtime::spawn(async move { + while let Some(change) = changes.recv().await { + let sent = apply(&mut shown, change); + for event in sent { + // A send fails once the window is closed or reloaded. + if events.send(event).is_err() { + return; + } + } + } + }); + state.subscriptions.push(task); + Ok(snapshot) +} + +/// Applies the change; returns what the UI is told. +fn apply(shown: &mut Vec, change: MachinesEvent) -> Vec { + let every = |shown: &Vec| vec![MachineEvent::Machines { machines: shown.iter().map(Shown::view).collect() }]; + match change { + MachinesEvent::MachinesChanged { machines } => { + let mut previous: HashMap = shown.drain(..).map(|one| (one.machine.id, one)).collect(); + *shown = machines + .into_iter() + .map(|machine| match previous.remove(&machine.id) { + Some(one) => Shown { machine, ..one }, + None => Shown { + machine, + state: MachineState::NotConnected, + view: View::new(Vec::new(), HashMap::new()), + }, + }) + .collect(); + every(shown) + } + MachinesEvent::StateChanged { machine_id, state } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + one.state = state; + every(shown) + } + MachinesEvent::ProjectsChanged { machine_id, projects } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + one.view = View::new(projects, one.view.statuses().clone()); + every(shown) + } + MachinesEvent::StatusesChanged { machine_id, statuses } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + let statuses = statuses.into_iter().map(|status| (status.service_id, status)).collect(); + one.view = View::new(std::mem::take(&mut one.view.projects), statuses); + every(shown) + } + MachinesEvent::ServiceChanged { machine_id, event: ServiceEvent::ServiceStateChanged(status) } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + let project_id = one.view.apply_status(&status); + let mut sent = vec![MachineEvent::Status { machine_id, status: Box::new(one.view.status_view(&status)) }]; + sent.extend( + project_id + .and_then(|id| one.view.refresh_state(id)) + .map(|project_state| MachineEvent::ProjectState { machine_id, project_state }), + ); + sent + } + // Runs starting and ending show up in the status changes too. + MachinesEvent::ServiceChanged { .. } => Vec::new(), + } +} + +/// Connects to the Machine `ssh` reaches as `destination`, and only once +/// that works keeps it. Fails with why it can't be reached. +#[tauri::command] +#[specta::specta] +pub async fn add_machine( + state: tauri::State<'_, AppState>, + destination: String, + name: Option, +) -> CommandResult { + Ok(state.machines()?.add(name, &destination).await?) +} + +#[tauri::command] +#[specta::specta] +pub fn rename_machine(state: tauri::State<'_, AppState>, machine_id: Id, name: String) -> CommandResult { + Ok(state.machines()?.rename(machine_id, &name)?) +} + +/// Disconnects and forgets the Machine. Nothing changes on it. +#[tauri::command] +#[specta::specta] +pub async fn remove_machine(state: tauri::State<'_, AppState>, machine_id: Id) -> CommandResult { + Ok(state.machines()?.remove(machine_id)?) +} + +/// Connects again now: at once if it was waiting, else anew. +#[tauri::command] +#[specta::specta] +pub async fn reconnect_machine(state: tauri::State<'_, AppState>, machine_id: Id) -> CommandResult { + let machines = state.machines()?; + match machines.state(machine_id) { + MachineState::Connected { .. } => machines.reconnect(machine_id)?, + _ => machines.connect(machine_id)?, + } + Ok(()) +} + +/// The hosts named in `~/.ssh/config`, to suggest as a destination. Only read. +#[tauri::command] +#[specta::specta] +pub async fn ssh_hosts() -> Vec { + tauri::async_runtime::spawn_blocking(ssh_config_hosts).await.unwrap_or_default() +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_start_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + state.machines()?.start(machine_id, service_id, None).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_stop_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + state.machines()?.stop(machine_id, service_id).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_restart_service( + state: tauri::State<'_, AppState>, + machine_id: Id, + service_id: Id, +) -> CommandResult { + state.machines()?.restart(machine_id, service_id).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_start_all(state: tauri::State<'_, AppState>, machine_id: Id, project_id: Id) -> CommandResult { + Ok(state.machines()?.start_all(machine_id, project_id).await?) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_stop_all(state: tauri::State<'_, AppState>, machine_id: Id, project_id: Id) -> CommandResult { + Ok(state.machines()?.stop_all(machine_id, project_id).await?) +} + +/// Open in Browser: forwards the Service's Port to the same port here, +/// then opens its URL. Fails with `PortConflict` when that port is taken here. +#[tauri::command] +#[specta::specta] +pub async fn machine_open_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + match state.machines()?.open_service(machine_id, service_id).await? { + Some(url) => crate::desktop::open_url(url), + None => Ok(()), + } +} + +/// Connects to every Machine as the app starts; each then keeps reconnecting. +pub fn connect_all(machines: &Machines) { + for machine in machines.machines() { + let _ = machines.connect(machine.id); + } +} + +/// Active Services on connected Machines started from a Machine: those this +/// computer may hold, which stop there once it disconnects, unless +/// localhost is open on that Machine. +pub fn active_count(machines: &Machines) -> usize { + let (snapshots, _) = machines.subscribe(); + snapshots + .iter() + .filter(|snapshot| matches!(snapshot.state, MachineState::Connected { .. })) + .flat_map(|snapshot| &snapshot.statuses) + .filter(|status| { + status.state.is_active() + && status.run.as_ref().is_some_and(|run| run.origin == localhost_core::Origin::Machine) + }) + .count() +} + +impl AppState { + pub fn machines(&self) -> Result<&Machines, LocalhostError> { + self.machines.as_ref().map_err(Clone::clone) + } +} + +#[cfg(test)] +mod tests { + use localhost_core::{Key, NewService, Project, ServiceState, ServiceStatus}; + + use super::*; + + fn project() -> Project { + let root = std::env::temp_dir().join(format!("localhost-machines-{}", Id::new())); + let library = + localhost_core::ProjectLibrary::open(&root, std::sync::Arc::new(localhost_core::MemorySecretStore::new())) + .unwrap(); + let web = NewService { port: Some(3000), ..NewService::new("Web", Key::new("web").unwrap(), "pnpm dev") }; + let path = root.to_string_lossy().into_owned(); + let project = library + .add_project("Shop", Key::new("shop").unwrap(), &path, vec![web], localhost_core::Origin::Cli) + .unwrap(); + let _ = std::fs::remove_dir_all(&root); + project + } + + #[test] + fn shows_each_machine_as_this_computer_is_shown() { + let devbox = Machine { id: Id::new(), name: "devbox".into(), destination: "devbox".into() }; + let mut shown = Vec::new(); + let sent = apply(&mut shown, MachinesEvent::MachinesChanged { machines: vec![devbox.clone()] }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert_eq!((machines[0].summary.as_str(), machines[0].needs_user), ("Not connected", true)); + + let project = project(); + let web = project.services[0].clone(); + apply(&mut shown, MachinesEvent::ProjectsChanged { machine_id: devbox.id, projects: vec![project.clone()] }); + let state = MachineState::Connected { version: "0.5.0".into() }; + let sent = apply(&mut shown, MachinesEvent::StateChanged { machine_id: devbox.id, state }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert!(machines[0].is_connected); + assert_eq!(machines[0].project_states[0].state, Some(ServiceState::Stopped)); + + let mut status = ServiceStatus::never_run(web.id); + status.state = ServiceState::Failed; + let event = ServiceEvent::ServiceStateChanged(status); + let sent = apply(&mut shown, MachinesEvent::ServiceChanged { machine_id: devbox.id, event }); + assert!( + matches!(&sent[0], MachineEvent::Status { status, .. } if status.url_to_open.as_deref() == Some("http://localhost:3000")) + ); + assert!(matches!( + &sent[1], + MachineEvent::ProjectState { project_state, .. } if project_state.state == Some(ServiceState::Failed) + )); + + // Renamed: its Projects and statuses stay. + let renamed = Machine { name: "Dev box".into(), ..devbox.clone() }; + let sent = apply(&mut shown, MachinesEvent::MachinesChanged { machines: vec![renamed] }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert_eq!((machines[0].machine.name.as_str(), machines[0].projects.len()), ("Dev box", 1)); + assert_eq!(machines[0].statuses[0].status.state, ServiceState::Failed); + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0b9c93c..dad275d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react"; import { askAboutFoundOrphans, runLaunchChecks } from "./actions"; import { commands, events, type Reveal } from "./bindings"; import { Alerts } from "./components/Alerts"; -import { Announcer } from "./components/Announcer"; +import { Announcer, MachinesAnnouncer } from "./components/Announcer"; import { CommandPalette } from "./components/CommandPalette"; import { EmptyState, KeyTip } from "./components/EmptyState"; import { Sheets } from "./components/Sheets"; import { FolderIcon, LinesIcon } from "./components/Icons"; import { LogPane } from "./components/LogPane"; import { PortsView } from "./components/PortsView"; -import { ProjectActions, ServicesColumn } from "./components/ServicesColumn"; +import { type Place, ProjectActions, ServicesColumn, thisComputer } from "./components/ServicesColumn"; import { Sidebar } from "./components/Sidebar"; import { Toolbar } from "./components/Toolbar"; import { importProject } from "./editors"; import { SHORTCUTS, serviceShortcut, shortcutLabel } from "./keyboard"; +import { startMachines, useMachines } from "./machines"; import { loadHome, tildePath } from "./paths"; import { onPerfMeasurement, runPerfMeasurement } from "./perf"; import { allServices, noService, type ServiceSelection, type SidebarSelection } from "./selection"; @@ -32,7 +33,7 @@ let started: Promise | null = null; * A WSL distro's Orphans are asked about when it connects, which may be later. */ function start(): Promise { - started ??= Promise.all([loadSettings(), loadHome(), startCore(), startUpdates()]).then(async () => { + started ??= Promise.all([loadSettings(), loadHome(), startCore(), startMachines(), startUpdates()]).then(async () => { void events.orphansFound.listen(() => void askAboutFoundOrphans()); await runLaunchChecks(); await runPerfMeasurement(); @@ -67,9 +68,19 @@ function MainWindow({ view }: { view: CoreView }) { // Under a sheet, an alert or the palette, the window behind can't be reached, by screen readers either. const isBehindModal = isPaletteOpen || sheet !== null || alerts.length > 0; + const machines = useMachines(); const isPorts = sidebarSelection?.kind === "ports"; const selectedId = sidebarSelection?.kind === "project" ? sidebarSelection.projectId : null; - const selected = isPorts ? undefined : (view.projects.find((project) => project.id === selectedId) ?? view.projects[0]); + // A Project of another Machine, while it still has it. + const onMachine = sidebarSelection?.kind === "machineProject" ? sidebarSelection : null; + const machine = onMachine ? machines.find((shown) => shown.machine.id === onMachine.machineId) : undefined; + const machineProject = machine?.view.projects.find((project) => project.id === onMachine?.projectId); + const place: Place = + machine && machineProject ? { machineId: machine.machine.id, isReachable: machine.isConnected } : thisComputer; + const selectedView = machine && machineProject ? machine.view : view; + const selected = isPorts + ? undefined + : (machineProject ?? view.projects.find((project) => project.id === selectedId) ?? view.projects[0]); const select = (next: SidebarSelection) => { setSidebarSelection(next); setSelection(noService); @@ -91,7 +102,7 @@ function MainWindow({ view }: { view: CoreView }) { }), [], ); - useWindowShortcuts({ view, selected, selection, setSelection, navigator }); + useWindowShortcuts({ view, selected, selection, setSelection, navigator, place, selectedView }); useReveal(navigator); useEffect(() => onPerfMeasurement(() => setSelection(allServices)), []); @@ -100,7 +111,16 @@ function MainWindow({ view }: { view: CoreView }) {
{isPorts ? ( @@ -112,10 +132,17 @@ function MainWindow({ view }: { view: CoreView }) { <> } + // A path on another Machine isn't one here. + subtitle={machine && place.machineId ? `${machine.machine.name} · ${selected.path}` : tildePath(selected.path)} + actions={} + /> + - {selection.kind === "none" ? (
) : ( - + )} ) : ( @@ -171,9 +198,13 @@ function MainWindow({ view }: { view: CoreView }) { )}
- {isPaletteOpen && } + {isPaletteOpen && ( + // Another Machine's Projects are controlled from the window for now. + + )} - + + ); } diff --git a/apps/desktop/src/actions.ts b/apps/desktop/src/actions.ts index d2b920c..1e3b35b 100644 --- a/apps/desktop/src/actions.ts +++ b/apps/desktop/src/actions.ts @@ -11,27 +11,31 @@ import { import { listenerKey } from "./listeners"; import { currentUi, setProgress, setStoppingListener, showAlert, showSheet } from "./ui"; -// What the main window's buttons do. Failures become an alert; a start -// blocked by a taken port becomes the question about it. +// What the main window's buttons do, on this computer or, given its id, +// another Machine. Failures become an alert; a start blocked by a taken +// port here becomes the question about it. On another Machine, its row +// says why it Failed. -export async function start(serviceId: Id, port: number | null = null) { - askIfBlocked(await commands.startService(serviceId, port)); +export async function start(serviceId: Id, port: number | null = null, machineId: Id | null = null) { + if (machineId) report(await commands.machineStartService(machineId, serviceId)); + else askIfBlocked(await commands.startService(serviceId, port)); } -export async function restart(serviceId: Id) { - askIfBlocked(await commands.restartService(serviceId)); +export async function restart(serviceId: Id, machineId: Id | null = null) { + if (machineId) report(await commands.machineRestartService(machineId, serviceId)); + else askIfBlocked(await commands.restartService(serviceId)); } -export async function stop(serviceId: Id) { - report(await commands.stopService(serviceId)); +export async function stop(serviceId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStopService(machineId, serviceId) : commands.stopService(serviceId))); } -export async function startAll(projectId: Id) { - report(await commands.startAll(projectId)); +export async function startAll(projectId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStartAll(machineId, projectId) : commands.startAll(projectId))); } -export async function stopAll(projectId: Id) { - report(await commands.stopAll(projectId)); +export async function stopAll(projectId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStopAll(machineId, projectId) : commands.stopAll(projectId))); } /** "Stop it and start": stops whatever holds the blocked start's port, then starts the Service. */ @@ -40,8 +44,9 @@ export async function stopHolderAndStart(blocked: BlockedStart) { askIfBlocked(await commands.stopHolderAndStart(blocked.serviceId, blocked.holder.listener)); } -export async function openUrl(serviceId: Id) { - report(await commands.openServiceUrl(serviceId)); +/** Open in Browser; another Machine's Port is forwarded to the same port here first. */ +export async function openUrl(serviceId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineOpenService(machineId, serviceId) : commands.openServiceUrl(serviceId))); } /** Opens the folder in the file manager, a terminal or the editor, asking for the editor first if none is set. */ diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 2cc953f..5883825 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -457,11 +457,12 @@ async saveSettings(settings: Settings) : Promise> { }, /** * Streams `source` over `batches`: what is buffered first, then new - * lines. Returns the id to close it with. + * lines; on another Machine (`machine_id`), through its connection. + * Returns the id to close it with. */ -async openLog(source: LogSource, batches: TAURI_CHANNEL) : Promise> { +async openLog(source: LogSource, machineId: Id | null, batches: TAURI_CHANNEL) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("open_log", { source, batches }) }; + return { status: "ok", data: await TAURI_INVOKE("open_log", { source, machineId, batches }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -603,6 +604,118 @@ async removeCommandFromPath() : Promise> if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +/** + * The Machines as they are, then every change over `events` until the + * window goes away. + */ +async subscribeMachines(events: TAURI_CHANNEL) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("subscribe_machines", { events }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Connects to the Machine `ssh` reaches as `destination`, and only once + * that works keeps it. Fails with why it can't be reached. + */ +async addMachine(destination: string, name: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("add_machine", { destination, name }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async renameMachine(machineId: Id, name: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("rename_machine", { machineId, name }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Disconnects and forgets the Machine. Nothing changes on it. + */ +async removeMachine(machineId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("remove_machine", { machineId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Connects again now: at once if it was waiting, else anew. + */ +async reconnectMachine(machineId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("reconnect_machine", { machineId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * The hosts named in `~/.ssh/config`, to suggest as a destination. Only read. + */ +async sshHosts() : Promise { + return await TAURI_INVOKE("ssh_hosts"); +}, +async machineStartService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_start_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStopService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_stop_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineRestartService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_restart_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStartAll(machineId: Id, projectId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_start_all", { machineId, projectId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStopAll(machineId: Id, projectId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_stop_all", { machineId, projectId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Open in Browser: forwards the Service's Port to the same port here, + * then opens its URL. Fails with `PortConflict` when that port is taken here. + */ +async machineOpenService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_open_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -1035,7 +1148,28 @@ export type LocalhostError = { kind: "invalidKey"; detail: string } | { kind: "i /** * An agent's own command (`claude mcp …`) failed: what it printed. */ -{ kind: "agentCommandFailed"; detail: { command: string; output: string } } +{ kind: "agentCommandFailed"; detail: { command: string; output: string } } | +/** + * Not something `ssh` can connect to: empty, with spaces, or like an option. + */ +{ kind: "invalidMachineDestination"; detail: string } | +/** + * Another Machine is already reached at this destination. + */ +{ kind: "duplicateMachine"; detail: string } | { kind: "machineNotFound"; detail: Id } | +/** + * Another Machine (by name) can't be reached over SSH, or the + * connection to it was lost. + */ +{ kind: "machineUnavailable"; detail: { machine: string; problem: MachineProblem } } | +/** + * The Port can't be forwarded from another Machine: SSH's reason. + */ +{ kind: "forwardFailed"; detail: { port: number; reason: string } } | +/** + * The other end runs an older localhost, which can't do this. + */ +{ kind: "unsupported" } export type LogBatch = { lines: LogLine[]; /** * Lines that arrived but were left out because the batch kept only its @@ -1075,6 +1209,100 @@ export type LogSource = * The latest Runs of these Services, and any they start later, in one stream. */ { kind: "services"; serviceIds: Id[] } +/** + * Another Machine, as this app remembers it. + */ +export type Machine = { id: Id; +/** + * What the app calls it: by default its destination. + */ +name: string; +/** + * What `ssh` connects to: a `Host` of `~/.ssh/config`, or `user@host`. + */ +destination: string } +export type MachineEvent = +/** + * Every Machine afresh: one was added, removed or renamed, or its + * state, Projects or statuses changed as a whole. + */ +{ kind: "machines"; machines: MachineView[] } | +/** + * A Service's state changed there. + */ +{ kind: "status"; machineId: Id; status: StatusView } | +/** + * Sent after the status that changed it. + */ +{ kind: "projectState"; machineId: Id; projectState: ProjectState } +/** + * Why another Machine can't be reached, as the app explains it. + */ +export type MachineProblem = +/** + * There is no `ssh` command on this computer. + */ +{ kind: "sshNotInstalled" } | +/** + * Its host key isn't in `known_hosts` yet. localhost never trusts one + * on the user's behalf. + */ +{ kind: "hostKeyNotTrusted" } | +/** + * Its host key differs from the one in `known_hosts`. + */ +{ kind: "hostKeyChanged" } | +/** + * SSH would have to ask for a password or passphrase, and can't. + */ +{ kind: "signInNeedsPrompt" } | { kind: "hostNotFound" } | +/** + * The network refused or timed out: SSH's words. + */ +{ kind: "unreachable"; detail: string } | +/** + * No `localhost` command there, or not on the `PATH` SSH gives. + */ +{ kind: "notInstalled" } | +/** + * Its localhost has no `localhost remote` (the version it reported, if any). + */ +{ kind: "tooOld"; detail: string | null } | +/** + * It was connected and the connection ended. + */ +{ kind: "lost" } | +/** + * Anything else SSH said, e.g. a mistake in `~/.ssh/config`. Not + * tried again on its own. + */ +{ kind: "other"; detail: string } +/** + * Where the app is with one Machine. + */ +export type MachineState = { kind: "notConnected" } | { kind: "connecting" } | +/** + * Connected to the localhost of this version there. + */ +{ kind: "connected"; detail: { version: string } } | +/** + * Not connected, for this reason. `is_retrying`: another attempt comes + * on its own. + */ +{ kind: "unavailable"; detail: { problem: MachineProblem; isRetrying: boolean } } +/** + * One Machine as the UI shows it. Its Projects and statuses are those of + * its last connection, kept while it reconnects. + */ +export type MachineView = { machine: Machine; state: MachineState; +/** + * How it is, in the core's words: "Connected · localhost 0.5.0", or why not. + */ +summary: string; isConnected: boolean; +/** + * It waits for the user to connect it again. + */ +needsUser: boolean; projects: Project[]; statuses: StatusView[]; projectStates: ProjectState[] } /** * Who or what requested a state-changing action. * @@ -1085,7 +1313,11 @@ export type Origin = "mainWindow" | "menuBar" | "cli" | /** * A coding agent, through `localhost mcp`. */ -"agent" +"agent" | +/** + * The localhost app of another Machine, through `localhost remote`. + */ +"machine" /** * A target (this machine's shared core, a WSL distro) had Orphans as it * connected: the window asks about them. diff --git a/apps/desktop/src/components/Announcer.tsx b/apps/desktop/src/components/Announcer.tsx index 03e5898..6d79a5a 100644 --- a/apps/desktop/src/components/Announcer.tsx +++ b/apps/desktop/src/components/Announcer.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import type { Id } from "../bindings"; +import type { Id, Project, ServiceState } from "../bindings"; import type { CoreView } from "../core"; -import { type Announcement, currentStates, onAnnouncement, settledAnnouncements } from "../announce"; +import { type Announcement, announce, currentStates, onAnnouncement, settledAnnouncements } from "../announce"; +import type { ShownMachine } from "../machines"; /** How long a message stays in its live region: long enough to be read, then out of the way. */ const MESSAGE_LIFETIME = 10_000; @@ -52,3 +53,36 @@ export function Announcer({ view, selectedProjectId }: { view: CoreView; selecte ); } + +/** + * Says when a Service of another Machine settles, through `Announcer`: with + * the Machine's name unless it is the selected one. What a Machine reads + * as it connects (its Projects afresh) is not announced. + */ +export function MachinesAnnouncer({ + machines, + selected, +}: { + machines: ShownMachine[]; + selected: { machineId: Id; projectId: Id | null } | null; +}) { + const seen = useRef(new Map }>()); + + useEffect(() => { + for (const shown of machines) { + const id = shown.machine.id; + const last = seen.current.get(id); + if (!last || last.projects !== shown.view.projects) { + seen.current.set(id, { projects: shown.view.projects, states: currentStates(shown.view) }); + continue; + } + const isSelected = selected?.machineId === id; + for (const { text, isUrgent } of settledAnnouncements(last.states, shown.view, isSelected ? selected.projectId : null)) { + announce(isSelected ? text : `${shown.machine.name}, ${text}`, isUrgent); + } + } + // Only new statuses are announced; the selection is read as they arrive. + }, [machines]); + + return null; +} diff --git a/apps/desktop/src/components/FailedBanner.tsx b/apps/desktop/src/components/FailedBanner.tsx index ec29243..292b741 100644 --- a/apps/desktop/src/components/FailedBanner.tsx +++ b/apps/desktop/src/components/FailedBanner.tsx @@ -1,5 +1,6 @@ import { start } from "../actions"; import type { Run, Service } from "../bindings"; +import { type Place, thisComputer } from "./ServicesColumn"; import type { LogBuffer } from "../log/buffer"; import { failureExcerpt, failureTitle, runDuration } from "../status"; import { ClearIcon } from "./Icons"; @@ -14,13 +15,14 @@ type Props = { buffer: LogBuffer; /** Puts the banner away for this Run. */ onHide: () => void; + place?: Place; }; /** * Why a Service Failed: what ended it and when, the error lines from its * output when the log below doesn't already show them all, and Start. */ -export function FailedBanner({ service, run, failureMessage, buffer, onHide }: Props) { +export function FailedBanner({ service, run, failureMessage, buffer, onHide, place = thisComputer }: Props) { const excerpt = failureExcerpt(buffer.lines); const duration = runDuration(run); return ( @@ -30,7 +32,11 @@ export function FailedBanner({ service, run, failureMessage, buffer, onHide }: P {failureTitle(service, run, failureMessage)} {duration && {duration}} - )} - {(status.state === "starting" || status.state === "running") && status.run && !status.run.endedAt && ( + {!machineId && (status.state === "starting" || status.state === "running") && status.run && !status.run.endedAt && ( )} @@ -114,7 +125,7 @@ export function LogPane({ project, view, selection }: { project: Project; view: onFilter={setFilter} filterField={filterField} onCopy={() => void copyLog(buffer, filter, linePrefixes)} - onClear={() => void clearLogs()} + onClear={machineId ? undefined : () => void clearLogs()} > {header} @@ -126,6 +137,7 @@ export function LogPane({ project, view, selection }: { project: Project; view: failureMessage={status.failureMessage} buffer={buffer} onHide={() => setHiddenBannerRunId(status.run!.id)} + place={place} /> )}
@@ -139,9 +151,9 @@ export function LogPane({ project, view, selection }: { project: Project; view: {(service ? !status?.run : buffer.lines.length === 0) && (

No output yet.

- {service && status?.state === "stopped" && ( + {service && status?.state === "stopped" && place.isReachable && ( <> - @@ -151,7 +163,12 @@ export function LogPane({ project, view, selection }: { project: Project; view: {!service && project.services.length > 0 && !isAnyActive && ( <>

Every service’s output shows here, each line marked with its service.

- @@ -172,7 +189,8 @@ type HeaderProps = { onFilter: (filter: string) => void; filterField: React.RefObject; onCopy: () => void; - onClear: () => void; + /** None for a log that can't be cleared here: another Machine's. */ + onClear?: () => void; }; /** The bar above a log: what it shows, then the filter and a `⋯` menu. */ @@ -208,10 +226,7 @@ function LogHeader({ children, filter, onFilter, filterField, onCopy, onClear }: ); diff --git a/apps/desktop/src/components/MachinesSettings.module.css b/apps/desktop/src/components/MachinesSettings.module.css new file mode 100644 index 0000000..f69601e --- /dev/null +++ b/apps/desktop/src/components/MachinesSettings.module.css @@ -0,0 +1,58 @@ +.empty { + margin: 0; +} + +.destination { + margin-left: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.grow { + flex: 1; + width: auto; +} + +.add { + display: grid; + gap: 8px; + padding: 11px 0; + border-top: 1px solid var(--separator); +} + +.label { + display: flex; + align-items: center; + gap: 12px; + font-size: 12px; + font-weight: 600; +} + +.label > input { + font-weight: normal; +} + +.add > p { + margin: 0; +} + +/* Why the Machine can't be reached: often long, and says what to type in a terminal. */ +.problem { + color: var(--state-failed); + font-size: 12px; + user-select: text; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--separator); +} + +.add .footer { + border-top: none; + padding-top: 4px; +} diff --git a/apps/desktop/src/components/MachinesSettings.tsx b/apps/desktop/src/components/MachinesSettings.tsx new file mode 100644 index 0000000..09aba3a --- /dev/null +++ b/apps/desktop/src/components/MachinesSettings.tsx @@ -0,0 +1,239 @@ +import { useEffect, useId, useState } from "react"; +import { commands } from "../bindings"; +import { report } from "../actions"; +import { type ShownMachine, useMachines } from "../machines"; +import { Menu } from "./Menu"; +import settings from "./SettingsDialog.module.css"; +import styles from "./MachinesSettings.module.css"; + +export const MACHINES_TITLE = "Machines"; +export const MACHINES_SUBTITLE = + "Other computers whose projects you run from here, over SSH. Their projects, secrets and services stay on them."; + +/** + * Settings › Machines: each other computer with where `ssh` reaches it and + * how it is, and Add Machine. Everything happens here, in the window: + * renaming and removing ask in the row itself. + */ +export function MachinesSettings() { + const machines = useMachines(); + const [isAdding, setIsAdding] = useState(false); + return ( + <> +
+

{MACHINES_TITLE}

+

{MACHINES_SUBTITLE}

+
+ {machines.map((shown) => ( + + ))} + {machines.length === 0 && !isAdding && ( +

+ No machines yet. Add one you can reach with ssh, like a dev box on your network or over Tailscale. +

+ )} + {isAdding ? ( + setIsAdding(false)} /> + ) : ( +
+ +
+ )} + + ); +} + +/** A Machine: its name, destination and state, and Reconnect, Rename and Remove. */ +function MachineRow({ shown }: { shown: ShownMachine }) { + const { machine } = shown; + const [mode, setMode] = useState<"show" | "rename" | "remove">("show"); + const [name, setName] = useState(machine.name); + const reconnect = async () => report(await commands.reconnectMachine(machine.id)); + const rename = async () => { + setMode("show"); + if (name.trim() !== machine.name) report(await commands.renameMachine(machine.id, name)); + }; + const remove = async () => report(await commands.removeMachine(machine.id)); + + if (mode === "rename") { + return ( +
+ setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void rename(); + if (event.key === "Escape") { + event.stopPropagation(); + setMode("show"); + } + }} + /> + + +
+ ); + } + if (mode === "remove") { + return ( +
+
+ Remove “{machine.name}”? + + localhost stops showing it here and closes its forwarded ports. Nothing changes on {machine.name}: its projects + and services stay as they are. + +
+ + +
+ ); + } + return ( +
+
+ + {machine.name} + {machine.name !== machine.destination && {machine.destination}} + + {shown.summary} +
+ {shown.needsUser && ( + + )} + void reconnect() }, + { + label: "Rename…", + onSelect: () => { + setName(machine.name); + setMode("rename"); + }, + }, + "separator", + { label: "Remove…", isDestructive: true, onSelect: () => setMode("remove") }, + ]} + /> +
+ ); +} + +/** + * Where `ssh` reaches the Machine (a host of `~/.ssh/config`, suggested, or + * `user@host:port`) and what to call it. Add connects first, and only once + * that works is the Machine kept; otherwise it says why, and what to do next. + */ +function AddMachine({ onDone }: { onDone: () => void }) { + const [destination, setDestination] = useState(""); + const [name, setName] = useState(""); + const [hosts, setHosts] = useState([]); + const [problem, setProblem] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const hostsId = useId(); + const hintId = useId(); + + useEffect(() => { + void commands.sshHosts().then(setHosts); + }, []); + + const add = async () => { + if (!destination.trim() || isConnecting) return; + setIsConnecting(true); + const result = await commands.addMachine(destination.trim(), name.trim() || null); + setIsConnecting(false); + if (result.status === "ok") onDone(); + else setProblem(result.error.message); + }; + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") void add(); + if (event.key === "Escape" && !isConnecting) { + event.stopPropagation(); + onDone(); + } + }; + + return ( +
{ + event.preventDefault(); + void add(); + }} + > + +

+ A host from ~/.ssh/config, or user@host:port. localhost connects as ssh does, with your keys and settings. +

+ + {problem && ( +

+ {problem} +

+ )} +
+ {isConnecting && Connecting to {destination.trim()}…} + + +
+
+ ); +} diff --git a/apps/desktop/src/components/ServicesColumn.tsx b/apps/desktop/src/components/ServicesColumn.tsx index c4c6434..5761c78 100644 --- a/apps/desktop/src/components/ServicesColumn.tsx +++ b/apps/desktop/src/components/ServicesColumn.tsx @@ -1,6 +1,6 @@ import { type CSSProperties, useId, useState } from "react"; import { openFolder, openUrl, restart, start, startAll, stop, stopAll } from "../actions"; -import type { Folder, Project, Service, StatusView } from "../bindings"; +import type { Folder, Id, Project, Service, StatusView } from "../bindings"; import { exportProject, requestRemoveService, setActiveProfile, setFavorite } from "../editors"; import { type CoreView, serviceStatus } from "../core"; import type { ServiceSelection } from "../selection"; @@ -16,15 +16,35 @@ import { StateGlyph } from "./StateGlyph"; import { ProjectStats } from "./Stats"; import styles from "./ServicesColumn.module.css"; +/** + * Where a Project is: this computer (`machineId` null), or another Machine, + * whose Services are controlled while it is connected and edited there. + */ +export type Place = { machineId: Id | null; isReachable: boolean }; + +export const thisComputer: Place = { machineId: null, isReachable: true }; + type Props = { project: Project; view: CoreView; selection: ServiceSelection; onSelect: (selection: ServiceSelection) => void; + place?: Place; }; /** The selected Project's Services with their state and actions, the combined stream on top. */ -export function ServicesColumn({ project, view, selection, onSelect }: Props) { +export function ServicesColumn({ project, view, selection, onSelect, place = thisComputer }: Props) { + if (project.services.length === 0 && place.machineId) { + return ( +
+ } + title="No Services" + description="Add services to this project on its machine." + /> +
+ ); + } if (project.services.length === 0) { return (
@@ -92,41 +112,54 @@ export function ServicesColumn({ project, view, selection, onSelect }: Props) { id={optionId(service.id)} isSelected={selection.kind === "service" && selection.serviceId === service.id} onSelect={() => onSelect({ kind: "service", serviceId: service.id })} + place={place} /> ))} - + {!place.machineId && ( + + )}
); } -/** The selected Project's toolbar actions: its Environment Profile once it has one, Start All, Stop All, and the rest in `⋯`. */ -export function ProjectActions({ project, view }: { project: Project; view: CoreView }) { +/** + * The selected Project's toolbar actions: its Environment Profile once it + * has one, Start All, Stop All, and the rest in `⋯`. Another Machine's + * Projects have only Start All and Stop All: the rest is changed there. + */ +export function ProjectActions({ project, view, place = thisComputer }: { project: Project; view: CoreView; place?: Place }) { const anyActive = project.services.some((service) => isActive(serviceStatus(view, service.id).state)); const [showsStats, setShowsStats] = useState(false); return ( <> - {(project.profiles ?? []).length > 0 && } + {(project.profiles ?? []).length > 0 && !place.machineId && } - + {!place.machineId && ( {showsStats && setShowsStats(false)} />} + )} ); } @@ -195,25 +229,32 @@ type RowProps = { id: string; isSelected: boolean; onSelect: () => void; + place: Place; }; /** A Service: a rail in its colour, its state, name and Port, its command, and, while selected, its actions. */ -function ServiceRow({ project, service, status, index, id, isSelected, onSelect }: RowProps) { +function ServiceRow({ project, service, status, index, id, isSelected, onSelect, place }: RowProps) { const state = status.state; // The active Run's port, which may have moved, else the Service's Port. const port = isActive(state) ? (status.run?.port ?? service.port) : service.port; const summary = rowSummary(project, service, status); const summaryClass = state === "failed" ? styles.failed : isWarning(status) ? styles.warning : "secondary"; - const edit = () => showSheet({ kind: "editService", serviceId: service.id }); - const context = useContextMenu(`${service.name} actions`, () => [ - ...stateMenuItems(service, status), - "separator", - { label: "Edit Service…", onSelect: edit }, - "separator", - ...folderMenuItems({ kind: "service", serviceId: service.id }), - "separator", - { label: "Remove Service…", isDestructive: true, onSelect: () => void requestRemoveService(service.id) }, - ]); + const edit = () => { + if (!place.machineId) showSheet({ kind: "editService", serviceId: service.id }); + }; + const context = useContextMenu(`${service.name} actions`, () => + place.machineId + ? stateMenuItems(service, status, place) + : [ + ...stateMenuItems(service, status, place), + "separator", + { label: "Edit Service…", onSelect: edit }, + "separator", + ...folderMenuItems({ kind: "service", serviceId: service.id }), + "separator", + { label: "Remove Service…", isDestructive: true, onSelect: () => void requestRemoveService(service.id) }, + ], + ); return (
  • {service.commandLine}
    - {isSelected && } + {isSelected && } {context.menu} @@ -243,7 +284,9 @@ function ServiceRow({ project, service, status, index, id, isSelected, onSelect } /** Open (or Restart) and Stop while live, Start otherwise; the main one tinted. */ -function ServiceActions({ service, status }: { service: Service; status: StatusView }) { +function ServiceActions({ service, status, place }: { service: Service; status: StatusView; place: Place }) { + const machineId = place.machineId; + const disabled = !place.isReachable; const act = (action: () => Promise) => (event: React.MouseEvent) => { event.stopPropagation(); void action(); @@ -257,7 +300,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact prominent" title={`Open ${url} (${shortcutLabel(SHORTCUTS.openUrl)})`} aria-label={`Open ${service.name} in Browser`} - onClick={act(() => openUrl(service.id))} + disabled={disabled} + onClick={act(() => openUrl(service.id, machineId))} > Open @@ -266,7 +310,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className={`button compact ${url ? "" : "prominent"}`} title={`Restart (${shortcutLabel(SHORTCUTS.startOrRestart)})`} aria-label={`Restart ${service.name}`} - onClick={act(() => restart(service.id))} + disabled={disabled} + onClick={act(() => restart(service.id, machineId))} > Restart @@ -274,7 +319,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact" title={`Stop (${shortcutLabel(SHORTCUTS.stop)})`} aria-label={`Stop ${service.name}`} - onClick={act(() => stop(service.id))} + disabled={disabled} + onClick={act(() => stop(service.id, machineId))} > Stop @@ -294,36 +340,41 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact prominent" title={`Start (${shortcutLabel(SHORTCUTS.startOrRestart)})`} aria-label={`Start ${service.name}`} - onClick={act(() => start(service.id))} + disabled={disabled} + onClick={act(() => start(service.id, null, machineId))} > Start - + {!machineId && ( + + )}
    ); } /** What a Service's context menu offers for its state, like its buttons. */ -function stateMenuItems(service: Service, status: StatusView): MenuItem[] { +function stateMenuItems(service: Service, status: StatusView, place: Place): MenuItem[] { + const machineId = place.machineId; + if (!place.isReachable) return []; switch (status.state) { case "starting": case "running": return [ - ...(status.urlToOpen ? [{ label: "Open in Browser", onSelect: () => void openUrl(service.id) }] : []), - { label: "Restart", onSelect: () => void restart(service.id) }, - { label: "Stop", onSelect: () => void stop(service.id) }, + ...(status.urlToOpen ? [{ label: "Open in Browser", onSelect: () => void openUrl(service.id, machineId) }] : []), + { label: "Restart", onSelect: () => void restart(service.id, machineId) }, + { label: "Stop", onSelect: () => void stop(service.id, machineId) }, ]; case "stopping": return []; default: - return [{ label: "Start", onSelect: () => void start(service.id) }]; + return [{ label: "Start", onSelect: () => void start(service.id, null, machineId) }]; } } diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index 435a5d2..37aca18 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -4,15 +4,17 @@ import { saveSettings, useSettings } from "../settings"; import { checkForUpdates } from "../updates"; import { CONNECT_AGENTS_SUBTITLE, CONNECT_AGENTS_TITLE, ConnectAgents } from "./ConnectAgents"; import { Dialog, useModal } from "./Dialog"; -import { CircleHalfIcon, GearIcon, GlobeIcon, LinkIcon } from "./Icons"; +import { CircleHalfIcon, DesktopIcon, GearIcon, GlobeIcon, LinkIcon } from "./Icons"; +import { MachinesSettings } from "./MachinesSettings"; import styles from "./SettingsDialog.module.css"; -type Section = "general" | "appearance" | "ports" | "agents"; +type Section = "general" | "appearance" | "ports" | "machines" | "agents"; const sections: { id: Section; title: string; icon: ReactNode }[] = [ { id: "general", title: "General", icon: }, { id: "appearance", title: "Appearance", icon: }, { id: "ports", title: "Ports", icon: }, + { id: "machines", title: "Machines", icon: }, { id: "agents", title: "Agents", icon: }, ]; @@ -61,6 +63,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { {section === "general" && } {section === "appearance" && } {section === "ports" && } + {section === "machines" && } {section === "agents" && ( <> diff --git a/apps/desktop/src/components/Sidebar.module.css b/apps/desktop/src/components/Sidebar.module.css index 1967a41..18dd650 100644 --- a/apps/desktop/src/components/Sidebar.module.css +++ b/apps/desktop/src/components/Sidebar.module.css @@ -97,6 +97,19 @@ font-size: 12px; } +/* How another Machine is, and Reconnect when it waits for the user. */ +.machineNote { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +/* Another Machine's Projects while it isn't connected: shown as they were. */ +.dimmed { + opacity: 0.6; +} + .row { position: relative; display: flex; diff --git a/apps/desktop/src/components/Sidebar.test.tsx b/apps/desktop/src/components/Sidebar.test.tsx index ad4a402..cd9a389 100644 --- a/apps/desktop/src/components/Sidebar.test.tsx +++ b/apps/desktop/src/components/Sidebar.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { viewFromSnapshot } from "../core"; -import { project, service, status } from "../test/fixtures"; +import { machineView, project, service, status } from "../test/fixtures"; +import { shownMachine } from "../machines"; import { Sidebar } from "./Sidebar"; describe("Sidebar", () => { @@ -52,4 +53,30 @@ describe("Sidebar", () => { fireEvent.change(search, { target: { value: "zzz" } }); expect(screen.getByText("No projects match “zzz”.")).toBeTruthy(); }); + + it("shows each other Machine's Projects below this computer's, and why one isn't connected", () => { + const web = service("Web"); + const shop = project("Shop", 0, [web]); + const devbox = shownMachine( + machineView("devbox", [shop], [status(web.id, "running")], { projectStates: [{ projectId: shop.id, state: "running" }] }), + ); + const lab = shownMachine( + machineView("lab", [], [], { + state: { kind: "unavailable", detail: { problem: { kind: "hostKeyNotTrusted" }, isRetrying: false } }, + summary: "lab’s host key isn’t trusted yet. Connect once from a terminal with `ssh lab`, then try again.", + isConnected: false, + needsUser: true, + }), + ); + const onSelect = vi.fn(); + const empty = viewFromSnapshot({ projects: [], statuses: [], projectStates: [] }); + render(); + + const rows = screen.getAllByRole("option"); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual(["Ports", "Shop, 1 of 1 running"]); + fireEvent.click(rows[1]); + expect(onSelect).toHaveBeenCalledWith({ kind: "machineProject", machineId: devbox.machine.id, projectId: shop.id }); + expect(screen.getByText(/lab’s host key isn’t trusted yet/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reconnect lab" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 3ef95de..bb5663d 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -1,14 +1,16 @@ import { useId, useState } from "react"; -import type { Project } from "../bindings"; +import { report, startAll, stopAll } from "../actions"; +import { commands, type Project } from "../bindings"; import { type CoreView, serviceState } from "../core"; import type { SidebarSelection } from "../selection"; import { importProject } from "../editors"; import { SHORTCUTS, shortcutLabel } from "../keyboard"; +import type { ShownMachine } from "../machines"; import { isActive, projectServicesSummary } from "../status"; import { showAlert, showSheet } from "../ui"; import { useUpdate } from "../updates"; import { ClearIcon, GearIcon, GlobeIcon, KeyboardIcon, PlusIcon, SearchIcon } from "./Icons"; -import { Menu, useContextMenu } from "./Menu"; +import { Menu, type MenuItem, useContextMenu } from "./Menu"; import { projectMenuItems } from "./ServicesColumn"; import { ShortcutBadge } from "./ShortcutBadge"; import { StateGlyph } from "./StateGlyph"; @@ -17,31 +19,37 @@ import styles from "./Sidebar.module.css"; type Props = { view: CoreView; + /** Other Machines, each shown below this computer's Projects. */ + machines?: ShownMachine[]; selection: SidebarSelection | null; onSelect: (selection: SidebarSelection) => void; }; /** - * The wordmark and project search, Ports and the Projects, a newer version - * when there is one, and a footer with Settings and how many Services run. + * The wordmark and project search, Ports and the Projects, then each other + * Machine's, a newer version when there is one, and a footer with Settings + * and how many Services run. */ -export function Sidebar({ view, selection, onSelect }: Props) { +export function Sidebar({ view, machines = [], selection, onSelect }: Props) { const [search, setSearch] = useState(""); const update = useUpdate(); const query = search.trim().toLowerCase(); - const projects = query - ? view.projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) - : view.projects; + const matching = (projects: Project[]) => + query ? projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) : projects; + const projects = matching(view.projects); const running = [...view.statuses.values()].filter((status) => isActive(status.state)).length; // Orca follows the selected row, as focus stays on the list. const listId = useId(); const optionId = (key: string) => `${listId}-${key}`; + const machineKey = (machineId: string, projectId: string) => `${machineId}:${projectId}`; const selectedKey = selection?.kind === "ports" ? "ports" : selection?.kind === "project" && projects.some((project) => project.id === selection.projectId) ? selection.projectId - : null; + : selection?.kind === "machineProject" + ? machineKey(selection.machineId, selection.projectId) + : null; return (