diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 51556207..d82a550a 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -56,7 +56,9 @@ herdr machine enable herdr machine remove ``` -Saved machines appear beside Local in the machine sidebar the next time Herdr starts. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. +`machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. + +Changes apply automatically to open local Herdr clients, normally within a second. Added or enabled machines connect in the background; renaming does not reconnect. Removing or disabling disconnects only that machine and leaves its remote sessions running. Removing the machine you are viewing returns to Local, or shows Local as unavailable until it reconnects. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox --session agents --handoff`, to complete that setup in the foreground, then restart the client. diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index e10a1c73..e5cec116 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -45,7 +45,9 @@ herdr machine add ssh://you@server:2222 --label "Production" --remote-session op herdr machine list ``` -Restart Herdr after changing the saved machine catalog. Enabled SSH machines then appear with Local in the machine sidebar. Select a machine or one of its workspaces to switch to it. Herdr keeps metadata, agent state, and notifications from connected machines available, but streams pane surfaces, input, geometry, and graphics only for the selected machine. Workspace, tab, pane, agent, and graphics identities stay scoped to their machine even when two servers happen to use the same internal ID. +`machine add` prepares Herdr on the remote machine and starts the requested session's background server before saving the profile. It checks the running server separately from the installed binary and keeps compatible servers, even when their release versions differ from your client. If the running server needs an upgrade, setup uses live handoff when supported to preserve pane processes; otherwise it asks before stopping the server. Missing or incompatible installations use the same approval-based setup as `--remote`; run the command in an interactive terminal when approval is needed. Cancelling setup leaves the profile unsaved. The remote server keeps running after setup exits. + +Saved-machine changes apply to open local Herdr clients automatically, normally within a second. An in-progress machine switch finishes before the change is applied. Added or enabled SSH machines appear with Local in the machine sidebar and connect in the background without changing your selection. Select a machine or one of its workspaces to switch to it. Herdr keeps metadata, agent state, and notifications from connected machines available, but streams pane surfaces, input, geometry, and graphics only for the selected machine. Workspace, tab, pane, agent, and graphics identities stay scoped to their machine even when two servers happen to use the same internal ID. Local opens directly on startup, without waiting for saved SSH machines or showing a connection badge. With enabled saved machines, the client can also open if Local actually fails, and stopping or restarting Local does not close healthy SSH connections. Local reconnects when its server returns, without taking selection away from the machine you are using. A stalled machine cannot block another machine's input or connection handling. @@ -55,7 +57,7 @@ Herdr checks connected SSH machines for application-level activity and uses a li herdr --remote workbox --session agents --handoff ``` -Then restart the client. Rename, disable, or remove profiles by their opaque ID: +If the machine shows Attention, restart the client after setup to retry its connection. Rename, disable, or remove profiles by their opaque ID: ```bash herdr machine rename --label "New name" @@ -64,6 +66,8 @@ herdr machine enable herdr machine remove ``` +Renaming updates the label without reconnecting. Removing or disabling a machine disconnects only that machine from the client; its remote sessions keep running, even if the machine is unreachable. If you remove or disable the machine you are viewing, the client returns to Local. If Local is unavailable, it shows that and reconnects rather than selecting another remote. An unreadable or invalid saved-machine file leaves current connections unchanged and shows a notice; the client retries reading it automatically. + Saved profiles contain only the opaque ID, label, SSH target, explicit remote session, and enabled state. Passwords, private keys, agent tickets, and SSH control sockets are never stored in the machine catalog. The client uses local keybindings by default. Custom commands advertised by the selected machine still execute on that machine. Herdr does not copy local command plugins, configuration, executables, or secrets to SSH hosts; a missing remote command or plugin fails visibly. diff --git a/src/cli/machine.rs b/src/cli/machine.rs index af0cdf53..a55e4611 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -10,6 +10,10 @@ const HELP: &str = "Usage: herdr machine enable herdr machine disable +Add prepares the remote Herdr installation and starts its server before saving. +Missing or incompatible installations require approval in an interactive terminal. +Changes apply automatically to open local Herdr clients. +Removing or disabling a machine leaves its remote sessions running. Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state. SSH credentials and key material remain owned by OpenSSH."; @@ -130,8 +134,25 @@ fn add(args: &[String]) -> std::io::Result { return Ok(2); }; let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); - let bootstrap = crate::remote::saved_ssh_bootstrap_command(target, &session); let mut catalog = load_catalog()?; + match catalog.add_ssh(label.clone(), target, session.clone()) { + Ok(_) => {} + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_remote_error_hint(&error, target); + return Ok(1); + } + // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile. + let mut catalog = load_catalog().map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; let id = match catalog.add_ssh(label, target, session) { Ok(id) => id, Err(error) => { @@ -139,9 +160,13 @@ fn add(args: &[String]) -> std::io::Result { return Ok(2); } }; - store_catalog(&catalog)?; - println!("Saved SSH machine {id}."); - println!("Run Herdr to connect, or use `{bootstrap}` for interactive setup."); + store_catalog(&catalog).map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + println!("Saved SSH machine {id}. Remote server is ready."); + println!("Open Herdr clients connect automatically."); Ok(0) } diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs index 33dfb09f..188e27b9 100644 --- a/src/cli/spec/machine.rs +++ b/src/cli/spec/machine.rs @@ -12,7 +12,7 @@ pub(super) fn command() -> Command { ) .subcommand( Command::new("add") - .about("Save an SSH machine") + .about("Prepare the remote Herdr server and save an SSH machine") .arg( Arg::new("ssh-target") .value_name("SSH_TARGET") diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs new file mode 100644 index 00000000..696b962e --- /dev/null +++ b/src/client/catalog_reload.rs @@ -0,0 +1,276 @@ +use super::*; + +pub(super) fn watch_profiles( + event_tx: tokio::sync::mpsc::Sender, + should_quit: Arc, +) { + // One bounded read per second per client, independent of rendering and pane count. + std::thread::spawn(move || { + let mut previous = None; + while !should_quit.load(Ordering::Acquire) { + let current = endpoint::EndpointCatalog::load_profiles(); + if previous.as_ref() != Some(¤t) { + previous = Some(current.clone()); + if event_tx + .blocking_send(ClientLoopEvent::EndpointCatalog(current)) + .is_err() + { + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + } + }); +} + +// Only called between surface handoffs: removing a source must not invalidate an in-flight +// rollback. Connection attempts are independent and fenced by supervisor generations. +pub(super) fn apply_profiles( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + catalog: &mut endpoint::EndpointCatalog, + profiles: Vec, + now: std::time::Instant, +) -> bool { + if catalog.ssh == profiles { + return false; + } + let previous_size = state + .shell + .as_ref() + .map(|shell| shell.surface_size(state.reported_size.0, state.reported_size.1)); + let retired = supervisors.reconcile_profiles(&profiles, now); + let active_removed = retired.contains(endpoints.active_id()); + for endpoint_id in retired { + endpoints.disconnect(&endpoint_id); + let cancelled = commands.disconnect(&endpoint_id); + #[cfg(unix)] + state.retire_endpoint_graphics(&endpoint_id); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.discard_endpoint_result(&request_id); + } + shell.retire_endpoint(&endpoint_id); + } + } + catalog.ssh = profiles; + if active_removed { + endpoints.select_unavailable_local(); + catalog.select_local(); + state.freeze_presentation(); + if let Some(shell) = state.shell.as_mut() { + shell.select_unavailable_local(); + } + } else if catalog.selected_profile.as_ref().is_some_and(|selected| { + !catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + catalog.select_local(); + } + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_catalog(&catalog.ssh); + if endpoints.active_surface_available() + && previous_size + != Some(shell.surface_size(state.reported_size.0, state.reported_size.1)) + { + shell.invalidate_pane_surface(); + endpoints.send(&client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + )); + } + } + active_removed +} + +#[cfg(test)] +mod tests { + use super::*; + use endpoint::{ClientEndpointId, EndpointCatalog, EndpointRegistry, EndpointSupervisors}; + use std::sync::atomic::AtomicUsize; + use std::time::Instant; + + struct Transport(Arc); + + impl endpoint::EndpointTransport for Transport { + fn send(&mut self, _: &ClientMessage) -> io::Result<()> { + Ok(()) + } + + fn disconnect(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + fn state() -> ClientState { + ClientState { + blit_encoder: render_ansi::BlitEncoder::new(), + mouse_capture_active: false, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), + direct_mouse_capture_preference: false, + shell_mouse_capture_preference: false, + direct_keyboard_protocol: Default::default(), + pane_keyboard_report_all: false, + keyboard_report_all_active: false, + reported_size: (100, 30), + reported_cell_size: (0, 0), + sound_config: Default::default(), + kitty_graphics_enabled: false, + pixel_geometry_enabled: false, + pixel_geometry_exact: false, + #[cfg(unix)] + direct_graphics_response: Default::default(), + #[cfg(unix)] + retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), + attach_escape: None, + #[cfg(unix)] + mouse_scroll_lines: 3, + remote_image_paste_key: None, + redraw_on_focus_gained: false, + repaint_pending: false, + presentation_frozen: false, + draw_host_cursor: false, + detached_process_children: Vec::new(), + shell: Some(shell::ClientShellState::new( + shell::ClientShellConfig::from_config(&crate::config::Config::default()), + )), + } + } + + #[test] + fn live_catalog_add_and_rename_keep_local_connection_and_selection() { + let now = Instant::now(); + let mut state = state(); + let disconnected = Arc::new(AtomicUsize::new(0)); + let mut endpoints = + EndpointRegistry::new(Transport(disconnected.clone()), 1, Default::default()); + let mut supervisors = EndpointSupervisors::new(&[], now); + let mut commands = endpoint_commands::EndpointCommands::default(); + let mut catalog = EndpointCatalog::default(); + let mut profile = endpoint::SavedSshEndpoint::new("Build", "build", "main").unwrap(); + for label in ["Build", "Renamed"] { + profile.label = label.into(); + assert!(!apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + vec![profile.clone()], + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(endpoints.active_surface_available()); + assert_eq!( + endpoints + .connection(&ClientEndpointId::Local) + .unwrap() + .generation, + 1 + ); + assert_eq!(catalog.selected_profile, None); + assert_eq!( + state + .shell + .as_ref() + .unwrap() + .endpoint_label(&ClientEndpointId::Ssh(profile.id.clone())), + label + ); + } + assert_eq!(disconnected.load(Ordering::Relaxed), 0); + } + + #[test] + fn live_catalog_remove_or_disable_active_machine_selects_local_without_input() { + for local_online in [false, true] { + for disable in [false, true] { + let now = Instant::now(); + let mut state = state(); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "main").unwrap(); + let remote = ClientEndpointId::Ssh(id.clone()); + catalog.select_ssh(&id); + state + .shell + .as_mut() + .unwrap() + .set_endpoint_catalog(&catalog.ssh); + let mut supervisors = EndpointSupervisors::new(&catalog.ssh, now); + let mut endpoints = EndpointRegistry::empty(); + let local_disconnects = Arc::new(AtomicUsize::new(0)); + if local_online { + endpoints.insert( + ClientEndpointId::Local, + Transport(local_disconnects.clone()), + 1, + Default::default(), + false, + ); + } + let remote_disconnects = Arc::new(AtomicUsize::new(0)); + endpoints.insert( + remote.clone(), + Transport(remote_disconnects.clone()), + 2, + Default::default(), + true, + ); + endpoints.set_active(&remote); + endpoints.unfreeze_input(); + let mut commands = endpoint_commands::EndpointCommands::default(); + let profiles = if disable { + let mut profiles = catalog.ssh.clone(); + profiles[0].enabled = false; + profiles + } else { + Vec::new() + }; + assert!(apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + profiles, + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert!(endpoints.connection(&remote).is_none()); + assert_eq!( + endpoints.connection(&ClientEndpointId::Local).is_some(), + local_online + ); + assert!(state + .shell + .as_ref() + .unwrap() + .endpoint_is_active(&ClientEndpointId::Local)); + assert!(!state.shell.as_ref().unwrap().has_presented_surface()); + assert!(state.presentation_frozen); + assert_eq!(catalog.selected_profile, None); + assert_eq!(remote_disconnects.load(Ordering::Relaxed), 1); + assert_eq!(local_disconnects.load(Ordering::Relaxed), 0); + assert!(!supervisors.record_status( + &remote, + 2, + endpoint::ClientEndpointStatus::Online, + now + )); + } + } + } +} diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs index b3cc362f..23f26387 100644 --- a/src/client/endpoint/catalog.rs +++ b/src/client/endpoint/catalog.rs @@ -103,6 +103,11 @@ impl EndpointCatalog { Self::load_from_paths(&catalog_path(), &selection_path()) } + pub(crate) fn load_profiles() -> Result, String> { + // Live clients keep their own selection, independent of other attached clients. + Self::load_from_path(&catalog_path()).map(|catalog| catalog.ssh) + } + fn load_from_paths(catalog_path: &Path, selection_path: &Path) -> Result { let mut catalog = Self::load_from_path(catalog_path)?; match load_selection_from_path(selection_path) { diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs index d96e374a..437f39f3 100644 --- a/src/client/endpoint/registry.rs +++ b/src/client/endpoint/registry.rs @@ -127,6 +127,11 @@ impl EndpointRegistry { .is_some_and(|connection| connection.surface_active) } + pub(crate) fn select_unavailable_local(&mut self) { + self.active = ClientEndpointId::Local; + self.freeze_input(); + } + pub(crate) fn freeze_input(&mut self) { self.input_enabled = false; } @@ -284,6 +289,8 @@ impl EndpointRegistry { } pub(crate) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) { + self.failures + .retain(|failure| &failure.endpoint_id != endpoint_id); if let Some(mut connection) = self.connections.remove(endpoint_id) { connection.transport.disconnect(); } diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs index 1f8671fd..458ce927 100644 --- a/src/client/endpoint/supervisor.rs +++ b/src/client/endpoint/supervisor.rs @@ -99,6 +99,37 @@ impl EndpointSupervisors { self.endpoints.insert(ClientEndpointId::Local, state); } + pub(crate) fn reconcile_profiles( + &mut self, + profiles: &[super::SavedSshEndpoint], + now: Instant, + ) -> Vec { + let mut retired = Vec::new(); + self.endpoints.retain(|endpoint_id, state| { + let ConnectTarget::Ssh(previous) = &state.target else { + return true; + }; + let keep = profiles.iter().any(|profile| { + profile.id == previous.id + && profile.enabled + && profile.target == previous.target + && profile.session == previous.session + }); + if !keep { + retired.push(endpoint_id.clone()); + } + keep + }); + for profile in profiles.iter().filter(|profile| profile.enabled) { + let state = self + .endpoints + .entry(ClientEndpointId::Ssh(profile.id.clone())) + .or_insert_with(|| ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now)); + state.target = ConnectTarget::Ssh(profile.clone()); + } + retired + } + pub(crate) fn spawn_due( &mut self, now: Instant, @@ -324,6 +355,76 @@ mod tests { } } + #[test] + fn live_catalog_preserves_renamed_connections_and_local_recovery() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[profile.clone()], now); + supervisors.add_local(PathBuf::from("local"), Some(1), now); + let state = supervisors.endpoints.get_mut(&id).unwrap(); + state.generation = Some(7); + state.attempts = 3; + state.next_attempt = Some(now + Duration::from_secs(4)); + profile.label = "Renamed".into(); + assert!(supervisors.reconcile_profiles(&[profile], now).is_empty()); + let state = &supervisors.endpoints[&id]; + assert_eq!(state.generation, Some(7)); + assert_eq!(state.attempts, 3); + assert_eq!(state.next_attempt, Some(now + Duration::from_secs(4))); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].generation, + Some(1) + ); + } + + #[test] + fn live_catalog_add_disable_enable_and_remove_fence_late_connections() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[], now); + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert_eq!(supervisors.endpoints[&id].next_attempt, Some(now)); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(7); + supervisors.next_generation = 8; + profile.enabled = false; + assert_eq!( + supervisors.reconcile_profiles(&[profile.clone()], now), + vec![id.clone()] + ); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + profile.enabled = true; + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + assert_eq!(supervisors.next_generation, 8); + assert_eq!(supervisors.reconcile_profiles(&[], now), vec![id.clone()]); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + } + + #[test] + fn live_catalog_destination_change_retires_only_that_machine() { + let now = Instant::now(); + let mut changed = profile(); + let other = super::super::SavedSshEndpoint::new("Other", "other", "main").unwrap(); + let id = ClientEndpointId::Ssh(changed.id.clone()); + let other_id = ClientEndpointId::Ssh(other.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[changed.clone(), other.clone()], now); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2); + supervisors.endpoints.get_mut(&other_id).unwrap().generation = Some(3); + changed.session = "another-session".into(); + assert_eq!( + supervisors.reconcile_profiles(&[changed, other], now), + vec![id.clone()] + ); + assert_eq!(supervisors.endpoints[&id].generation, None); + assert_eq!(supervisors.endpoints[&other_id].generation, Some(3)); + } + #[test] fn retry_backoff_is_bounded() { assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY); diff --git a/src/client/events.rs b/src/client/events.rs index 01f1a61a..43f7b8ad 100644 --- a/src/client/events.rs +++ b/src/client/events.rs @@ -22,6 +22,7 @@ pub(super) enum ClientLoopEvent { generation: u64, }, EndpointSupervisor(endpoint::EndpointSupervisorEvent), + EndpointCatalog(Result, String>), ActivateEndpoint { endpoint_id: endpoint::ClientEndpointId, target: Option, diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 0c4f5be6..937f92ac 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -124,6 +124,28 @@ pub(super) struct HandshakeResult { pub(super) endpoint_capabilities: Option>, } +pub(crate) fn probe_endpoint_negotiation( + stream: &mut LocalStream, +) -> io::Result { + let handshake = do_handshake( + stream, + 80, + 24, + 0, + 0, + false, + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }), + false, + false, + false, + ) + .map_err(io::Error::other)?; + Ok(super::endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + )) +} + /// Performs the client→server handshake. /// /// Direct terminal clients retain the same-install private protocol. Client-owned diff --git a/src/client/mod.rs b/src/client/mod.rs index 5e6f3ff6..bad8ceed 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -13,6 +13,7 @@ //! - Displays sound/toast notifications forwarded from server mod attach; +mod catalog_reload; mod clipboard_forwarding; mod clipboard_images; mod config_reload; @@ -101,6 +102,7 @@ use frame_output::{ contains_kitty_graphics_bytes, record_received_kitty_graphics, write_encoded_frame_with_graphics, }; +pub(crate) use handshake::probe_endpoint_negotiation; use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; #[cfg(test)] use handshake::{ @@ -405,7 +407,7 @@ async fn run_client_loop( detached_process_children: Vec::new(), shell: config.shell_config.map(shell::ClientShellState::new), }; - let federated = endpoint_catalog.has_enabled_ssh(); + let mut federated = endpoint_catalog.has_enabled_ssh(); if let Some(shell) = state.shell.as_mut() { shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); shell.set_endpoint_catalog(&endpoint_catalog.ssh); @@ -554,6 +556,10 @@ async fn run_client_loop( let mut next_surface_serial = 1_u64; let mut pending_activation: Option = None; let mut scheduled_activation = None; + let mut pending_catalog: Option, String>> = None; + if state.shell.is_some() && !is_remote_client && state.attach_escape.is_none() { + catalog_reload::watch_profiles(event_tx.clone(), should_quit.clone()); + } // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). @@ -564,6 +570,95 @@ async fn run_client_loop( #[cfg(windows)] let mut stdin_open = true; while !should_quit.load(Ordering::Acquire) { + if pending_activation.is_none() { + if let Some(reload) = pending_catalog.take() { + match reload { + Ok(profiles) => { + let now = std::time::Instant::now(); + if !federated && profiles.iter().any(|profile| profile.enabled) { + // Keep Local recovery once enabled, even after removing the last SSH profile. + federated = true; + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + now, + ); + if write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some_and(|connection| { + !connection.negotiation.supports_surface_interest() + }) + { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable( + "Update the Local server before switching between machines" + .into(), + ); + } + } + } + let active_removed = catalog_reload::apply_profiles( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut endpoint_catalog, + profiles, + now, + ); + if active_removed { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + scheduled_activation = None; + if state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_projection_available( + &endpoint::ClientEndpointId::Local, + ) + }) && write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some() + { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId::Local, + target: None, + force: true, + }); + } else { + present_handoff_unavailable( + &mut state, + "Local is unavailable; reconnecting".into(), + ); + } + } + } + Err(error) => { + warn!(%error, "saved machines could not be reloaded; keeping current connections"); + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "Saved machines could not be reloaded; keeping current connections: {error}" + )); + } + } + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(shell) = state.shell.as_mut() { + let cleanup = shell.take_pending_graphics_cleanup(); + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + let frozen = state.presentation_frozen; + state.presentation_frozen = false; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + state.presentation_frozen = frozen; + } + } + } if let Some(shell) = state.shell.as_ref() { supervisors.spawn_due( std::time::Instant::now(), @@ -622,6 +717,7 @@ async fn run_client_loop( } match event { + ClientLoopEvent::EndpointCatalog(reload) => pending_catalog = Some(reload), #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { let image_bridge_active = endpoint_accepts_local_images( @@ -1025,6 +1121,9 @@ async fn run_client_loop( if !supervisors.record_status(&endpoint_id, generation, status, now) { continue; } + if status == endpoint::ClientEndpointStatus::Attention { + warn!(endpoint = %endpoint_id.storage_key(), generation, error = %message, "endpoint needs attention"); + } let unavailable = state.shell.as_mut().and_then(|shell| { shell.set_endpoint_status(&endpoint_id, status); (status == endpoint::ClientEndpointStatus::Attention @@ -1087,10 +1186,11 @@ async fn run_client_loop( target, force, } => { - if endpoint_catalog.select_endpoint(&endpoint_id) { - if let Err(error) = endpoint_catalog.store_selection() { - warn!(%error, "failed to persist desired endpoint selection"); - } + if !endpoint_catalog.select_endpoint(&endpoint_id) { + continue; + } + if let Err(error) = endpoint_catalog.store_selection() { + warn!(%error, "failed to persist desired endpoint selection"); } begin_endpoint_activation( &mut state, diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index f1631c8d..0611061d 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -40,7 +40,10 @@ impl ClientShellState { let previous = self .endpoints .iter() - .find(|endpoint| endpoint.endpoint_id == endpoint_id); + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .filter(|endpoint| { + profile.enabled && endpoint.status != ClientEndpointStatus::Disabled + }); next.push(ClientShellEndpoint { endpoint_id, label: profile.label.clone(), @@ -68,12 +71,7 @@ impl ClientShellState { .iter() .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) { - self.active_endpoint_id = ClientEndpointId::Local; - self.pane_surface = None; - self.pending_pane_surface = None; - if let Some(snapshot) = next[0].snapshot.clone() { - self.apply_active_snapshot(snapshot); - } + self.select_unavailable_local(); } self.collapsed_endpoints.retain(|endpoint_id| { next.iter() @@ -82,6 +80,31 @@ impl ClientShellState { self.endpoints = next; } + pub(crate) fn select_unavailable_local(&mut self) { + self.reset_endpoint_projection(); + self.active_endpoint_id = ClientEndpointId::Local; + self.mode = ClientShellMode::Terminal; + self.snapshot = None; + self.graphics.set_scope("local:unavailable"); + self.reconcile_input_source(); + } + + pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + self.retire_endpoint_notifications(endpoint_id); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = ClientEndpointStatus::Disabled; + endpoint.snapshot = None; + endpoint.snapshot_generation = None; + endpoint.methods = None; + endpoint.agent_recency.clear(); + endpoint.agent_presentation = Default::default(); + } + } + pub(crate) fn set_endpoint_status( &mut self, endpoint_id: &ClientEndpointId, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index b15a9be4..67bdff91 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -1192,6 +1192,61 @@ impl ClientShellState { } } + pub(super) fn reset_endpoint_projection(&mut self) { + self.hits = ShellHitMap::default(); + self.pane_surface = None; + self.pending_pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_focused_workspace = true; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.endpoint_notice_seen.clear(); + self.visible_endpoint_notice = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } + pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box) { snapshot .commands @@ -1263,54 +1318,7 @@ impl ClientShellState { self.hits = ShellHitMap::default(); } if boot_changed { - self.pane_surface = None; - self.pending_pane_surface = None; - self.input_leases = ClientInputLeases::default(); - self.popup_terminal_id = None; - self.chrome_drag = None; - self.workspace_press = None; - self.tab_press = None; - self.workspace_scroll = 0; - self.agent_scroll = 0; - self.tab_scroll = 0; - self.mobile_switcher_scroll = 0; - self.reveal_focused_workspace = true; - self.reveal_mobile_workspace = false; - self.mobile_switcher_suspended = false; - self.reveal_focused_tab = true; - self.last_tab_bar_width = None; - self.last_composed_size = None; - self.pending_requests.clear(); - self.pane_scroll_in_flight.clear(); - self.pane_scroll_queued.clear(); - self.pane_scroll_targets.clear(); - self.popup_pending = false; - self.popup_pending_deadline = None; - self.pending_integration_installs = 0; - self.endpoint_notice_seen.clear(); - self.visible_endpoint_notice = None; - self.endpoint_error = None; - self.navigate_workspace_id = None; - self.overlay = self - .config - .startup_onboarding - .then_some(ClientShellOverlay::Onboarding); - self.previous_pane_id = None; - self.pane_mouse_gesture = None; - self.url_click_consumes_until_up = false; - self.replaying_url_click = false; - self.selection = None; - self.last_pane_click = None; - self.selection_autoscroll = None; - self.selection_autoscroll_deadline = None; - self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; - self.copy_mode = None; - self.reset_copy_pipeline(); - self.copy_feedback = None; - self.copy_feedback_deadline = None; - self.host_mouse_pixels = None; - self.dismissed_product_announcement = None; + self.reset_endpoint_projection(); } else if let Some(previous) = self .snapshot .as_deref() diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index e9c3d622..52c71923 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -52,6 +52,91 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +#[test] +fn switching_machines_from_copy_mode_restores_terminal_input() { + let (mut state, remote) = state_with_remote(); + let mut local_surface = surface(); + local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(local_surface); + state.compose(100, 28).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + assert_eq!(state.mode, ClientShellMode::Copy); + + assert!(state.activate_endpoint_projection(&remote)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.compose(100, 28).unwrap(); + + assert!(state.copy_mode.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))]); + assert!(matches!( + input.requests.as_slice(), + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" && events.len() == 1 + )); +} + +#[test] +fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() { + let (mut state, remote) = state_with_remote(); + let mut profile = remote_profile(); + profile.label = "Renamed".into(); + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!(state.endpoint_label(&remote), "Renamed"); + assert!(state.endpoint_is_online(&remote)); + assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot")); + profile.enabled = false; + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Disabled) + ); + assert!(!state.endpoint_has_snapshot(&remote)); + profile.enabled = true; + state.set_endpoint_catalog(&[profile]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Connecting) + ); + assert!(!state.endpoint_has_snapshot(&remote)); +} + +#[test] +fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() { + let (mut state, remote) = state_with_remote(); + assert!(state.activate_endpoint_projection(&remote)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Prefix; + state.overlay = Some(ClientShellOverlay::Onboarding); + state.select_unavailable_local(); + state.retire_endpoint(&remote); + state.set_endpoint_catalog(&[]); + assert!(state.endpoint_is_active(&ClientEndpointId::Local)); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + assert!(state.pending_pane_surface.is_none()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local)); + let frame = state.compose(100, 30).unwrap(); + let buffer = frame.to_ratatui_buffer().unwrap(); + let text = buffer + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!text.contains("remote-workspace")); +} + #[test] fn machine_navigation_does_not_require_a_local_snapshot_or_surface() { for (cols, rows) in [(100, 28), (36, 18)] { diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 7ad4f6ae..ce0afedb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -28,6 +28,39 @@ pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::size() } +pub(crate) fn replace_file( + source: &std::path::Path, + destination: &std::path::Path, +) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + pub(crate) fn set_default_plugin_pane_pwd( _env: &mut Vec<(String, String)>, _cwd: &std::path::Path, diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 2c2cda4b..81810411 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -50,7 +50,11 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let require_surface_interest = crate::client::endpoint::EndpointCatalog::load() .map(|catalog| catalog.contains_enabled_target_session(&remote.target, &session_name)) .unwrap_or(false); - let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config); + let remote_ssh = RemoteSsh::new( + remote.target.clone(), + manage_ssh_config, + session_name.clone(), + ); let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff, require_surface_interest)?; ensure_remote_server_ready( @@ -73,6 +77,62 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { run_client_process(&local_socket, &reattach_command, remote.keybindings) } +pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> { + super::validate_remote_target(target) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + crate::session::validate_name(session_name) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let manage_ssh_config = crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let ssh = RemoteSsh::new( + target.to_owned(), + manage_ssh_config, + session_name.to_owned(), + ); + let prepared = prepare_remote_herdr(&ssh, true, true)?; + ensure_remote_server_ready( + &ssh, + &prepared.remote_herdr, + prepared.stop_after_install_approved, + true, + true, + )?; + + // The bridge already owns daemon startup. EOF closes only this temporary attachment, + // leaving the named server running even when no local TUI is open yet. + let output = ssh.sh_output(&format!( + "{} + { + Ok(()) + } + _ => Err(io::Error::other( + "remote server is not ready for saved machines", + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -293,12 +353,13 @@ impl Drop for ManagedSshConfig { pub(super) struct RemoteSsh { target: String, + session_name: String, managed_config: Option, noninteractive: bool, } impl RemoteSsh { - fn new(target: String, manage_ssh_config: bool) -> Self { + fn new(target: String, manage_ssh_config: bool, session_name: String) -> Self { let managed_config = if manage_ssh_config { write_managed_ssh_config() .inspect_err(|err| { @@ -311,6 +372,7 @@ impl RemoteSsh { Self { target, + session_name, managed_config, noninteractive: false, } @@ -319,6 +381,7 @@ impl RemoteSsh { pub(super) fn new_noninteractive(target: String) -> Self { Self { target, + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: None, noninteractive: true, } @@ -328,6 +391,10 @@ impl RemoteSsh { &self.target } + fn destination(&self) -> String { + format!("{} (session {})", self.target, self.session_name) + } + pub(super) fn options(&self) -> Option<&ManagedSshOptions> { self.managed_config.as_ref().map(|config| &config.options) } @@ -607,7 +674,7 @@ pub(super) fn prepare_remote_herdr( )?; } confirm_remote_install( - ssh.target(), + &ssh.destination(), &remote_herdr, &install_source_description(&remote_herdr.platform, override_binary.as_deref()), )?; @@ -830,21 +897,8 @@ fn remote_binary_supports_endpoint_requirement( remote_herdr: &RemoteHerdr, require_surface_interest: bool, ) -> io::Result { - Ok( - remote_client_status(ssh, remote_herdr)?.is_some_and(|status| { - status.endpoint_protocol_generation - == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) - && (!require_surface_interest - || (status.endpoint_capabilities.iter().any(|capability| { - capability == crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY - }) && status.endpoint_capabilities.iter().any(|capability| { - capability - == crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY - }) && status.endpoint_capabilities.iter().any(|capability| { - capability == crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY - }))) - }), - ) + Ok(remote_client_status(ssh, remote_herdr)? + .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { @@ -956,6 +1010,24 @@ enum RemoteServerStatus { NotRunning, } +impl RemoteServerStatus { + fn with_endpoint_negotiation( + mut self, + negotiation: &crate::client::endpoint::EndpointNegotiation, + ) -> Self { + if let Self::Running { + surface_interest, + health_check, + .. + } = &mut self + { + *surface_interest = negotiation.supports_surface_interest(); + *health_check = negotiation.supports_health_check(); + } + self + } +} + fn ensure_remote_server_ready( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, @@ -963,7 +1035,7 @@ fn ensure_remote_server_ready( live_handoff_enabled: bool, require_surface_interest: bool, ) -> io::Result<()> { - let status = remote_server_status(ssh, remote_herdr)?; + let status = remote_server_status(ssh, remote_herdr, require_surface_interest)?; let RemoteServerStatus::Running { version, endpoint_protocol_generation, @@ -1001,7 +1073,7 @@ fn ensure_remote_server_ready( return Ok(()); } - if confirm_remote_server_stop(ssh.target(), version.as_deref(), reason)? { + if confirm_remote_server_stop(&ssh.destination(), version.as_deref(), reason)? { stop_remote_server(ssh, remote_herdr)?; } Ok(()) @@ -1013,8 +1085,8 @@ fn confirm_remote_install_with_running_server( live_handoff_enabled: bool, require_surface_interest: bool, ) -> io::Result { - let target = ssh.target(); - let status = match remote_server_status(ssh, remote_herdr) { + let target = ssh.destination(); + let status = match remote_server_status(ssh, remote_herdr, require_surface_interest) { Ok(status) => status, Err(err) => { if !io::stdin().is_terminal() { @@ -1125,15 +1197,54 @@ fn confirm_remote_install_with_running_server( fn remote_server_status( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, + require_surface_interest: bool, ) -> io::Result { - let command = format!("{} status server --json", remote_herdr.shell_path); + let command = remote_session_command(remote_herdr, &ssh.session_name, "status server --json"); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server status failed", &output)); } let stdout = String::from_utf8_lossy(&output.stdout); - parse_remote_server_status_json(stdout.trim()) + let status = parse_remote_server_status_json(stdout.trim())?; + if require_surface_interest + && matches!( + status, + RemoteServerStatus::Running { + endpoint_protocol_generation: Some( + crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + ), + surface_interest: true, + health_check: true, + .. + } + ) + { + // Older status helpers omit newer capabilities. Ask the live endpoint rather than + // assuming that the installed binary and the running daemon support the same features. + let negotiation = probe_remote_endpoint(ssh, remote_herdr)?; + return Ok(status.with_endpoint_negotiation(&negotiation)); + } + Ok(status) +} + +fn probe_remote_endpoint( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, +) -> io::Result { + let path = local_forward_socket_path(ssh.target(), &ssh.session_name); + let _bridge = SshStdioBridge::start( + ssh.target.clone(), + remote_herdr.clone(), + path.clone(), + ssh.session_name.clone(), + None, + true, + )?; + let mut stream = crate::ipc::connect_local_stream(&path)?; + // Use the saved client's noninteractive path. This metadata-only attachment never + // acquires a surface or sends pane input. + crate::client::probe_endpoint_negotiation(&mut stream) } #[derive(Debug, Deserialize)] @@ -1148,6 +1259,25 @@ struct RemoteClientStatusJson { endpoint_capabilities: Vec, } +impl RemoteClientStatusJson { + fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool { + self.endpoint_protocol_generation + == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!require_surface_interest + || [ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, + ] + .iter() + .all(|required| { + self.endpoint_capabilities + .iter() + .any(|capability| capability == required) + })) + } +} + #[derive(Debug, Deserialize)] struct RemoteServerStatusJson { running: bool, @@ -1274,13 +1404,7 @@ fn confirm_remote_server_stop( eprint!("{prompt}"); io::stderr().flush()?; - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "y" || answer == "yes" { - return Ok(true); - } - if answer.is_empty() && required_upgrade { + if read_remote_confirmation(&mut io::stdin().lock(), required_upgrade)? { return Ok(true); } if required_upgrade { @@ -1293,10 +1417,19 @@ fn confirm_remote_server_stop( Ok(false) } -fn remote_live_handoff_command(remote_herdr: &RemoteHerdr, protocol: u32, version: &str) -> String { - format!( - "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", - remote_herdr.shell_path, remote_herdr.shell_path, protocol, version +fn remote_live_handoff_command( + remote_herdr: &RemoteHerdr, + session_name: &str, + protocol: u32, + version: &str, +) -> String { + remote_session_command( + remote_herdr, + session_name, + &format!( + "server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", + remote_herdr.shell_path, protocol, version + ), ) } @@ -1311,7 +1444,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io .version .filter(|version| !version.is_empty()) .ok_or_else(|| io::Error::other("prepared remote herdr did not report its version"))?; - let command = remote_live_handoff_command(remote_herdr, protocol, &version); + let command = remote_live_handoff_command(remote_herdr, &ssh.session_name, protocol, &version); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server live handoff failed", &output)); @@ -1325,7 +1458,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io } fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!("{} server stop", remote_herdr.shell_path); + let command = remote_session_command(remote_herdr, &ssh.session_name, "server stop"); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server stop failed", &output)); @@ -1342,7 +1475,7 @@ fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; loop { - if remote_server_status(ssh, remote_herdr)? == RemoteServerStatus::NotRunning { + if remote_server_status(ssh, remote_herdr, false)? == RemoteServerStatus::NotRunning { return Ok(()); } if Instant::now() >= deadline { @@ -1535,6 +1668,25 @@ fn private_download_dir(asset_key: &str) -> io::Result { )) } +fn read_remote_confirmation(reader: &mut impl io::BufRead, default: bool) -> io::Result { + let mut answer = String::new(); + if reader.read_line(&mut answer)? == 0 { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote setup cancelled", + )); + } + match answer.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + "" => Ok(default), + _ => Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote setup cancelled: expected yes or no", + )), + } +} + fn confirm_remote_install( target: &str, remote_herdr: &RemoteHerdr, @@ -1559,10 +1711,7 @@ fn confirm_remote_install( ); io::stderr().flush()?; - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "n" || answer == "no" { + if !read_remote_confirmation(&mut io::stdin().lock(), true)? { return Err(io::Error::new( io::ErrorKind::Interrupted, "remote herdr installation cancelled", @@ -1572,16 +1721,24 @@ fn confirm_remote_install( Ok(()) } -fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { - let mut command = format!("exec {}", remote_herdr.shell_path); +fn remote_session_command(remote_herdr: &RemoteHerdr, session_name: &str, args: &str) -> String { + let mut command = remote_herdr.shell_path.clone(); if session_name != crate::session::DEFAULT_SESSION_NAME { command.push_str(" --session "); command.push_str(&shell_quote(session_name)); } - command.push_str(" remote-client-bridge"); + command.push(' '); + command.push_str(args); command } +fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { + format!( + "exec {}", + remote_session_command(remote_herdr, session_name, "remote-client-bridge") + ) +} + fn reattach_command( program: &str, target: &str, @@ -2261,6 +2418,7 @@ mod tests { .expect("Unix managed config has a control path"); let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: Some(managed_config), noninteractive: false, }; @@ -2300,6 +2458,7 @@ mod tests { let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: Some(managed_config), noninteractive: false, }; @@ -2351,10 +2510,82 @@ mod tests { assert!(ssh.options().is_none()); } + #[test] + fn remote_setup_approval_requires_input_and_rejects_unrecognized_answers() { + for default in [false, true] { + for input in ["", "maybe\n"] { + assert_eq!( + read_remote_confirmation(&mut input.as_bytes(), default) + .unwrap_err() + .kind(), + io::ErrorKind::Interrupted + ); + } + assert_eq!( + read_remote_confirmation(&mut "\n".as_bytes(), default).unwrap(), + default + ); + assert!(read_remote_confirmation(&mut "YES\n".as_bytes(), default).unwrap()); + assert!(!read_remote_confirmation(&mut "no\n".as_bytes(), default).unwrap()); + } + } + + #[test] + fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() { + let mut status = RemoteClientStatusJson { + version: Some("0.1.0".into()), + protocol: Some(1), + endpoint_protocol_generation: Some( + crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, + ), + endpoint_capabilities: vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), + ], + }; + assert!(status.supports_endpoint_requirement(true)); + for index in 0..status.endpoint_capabilities.len() { + let removed = status.endpoint_capabilities.remove(index); + assert!(!status.supports_endpoint_requirement(true)); + assert!(status.supports_endpoint_requirement(false)); + status.endpoint_capabilities.insert(index, removed); + } + status.endpoint_protocol_generation = None; + assert!(!status.supports_endpoint_requirement(true)); + } + + #[test] + fn saved_machine_server_commands_are_scoped_to_the_explicit_session() { + let herdr = + RemoteHerdr::for_platform(RemotePlatform::from_uname("Linux", "x86_64").unwrap()); + for command in [ + "status server --json", + "server stop", + "remote-client-bridge", + ] { + assert_eq!( + remote_session_command(&herdr, "agents", command), + format!("{} --session agents {command}", herdr.shell_path) + ); + assert_eq!( + remote_session_command(&herdr, crate::session::DEFAULT_SESSION_NAME, command), + format!("{} {command}", herdr.shell_path) + ); + } + assert!( + remote_live_handoff_command(&herdr, "agents", 19, "0.7.9").starts_with(&format!( + "{} --session agents server live-handoff", + herdr.shell_path + )) + ); + } + #[test] fn remote_ssh_command_is_plain_without_managed_config() { let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: None, noninteractive: false, }; @@ -2838,6 +3069,64 @@ mod tests { ); } + #[test] + fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() { + // Captured from Rohan after installing a new binary while the old daemon stayed alive. + let installed = parse_client_status_json( + r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, + ) + .unwrap(); + let running_binary = parse_client_status_json( + r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, + ) + .unwrap(); + assert!(installed.supports_endpoint_requirement(true)); + assert!(!running_binary.supports_endpoint_requirement(true)); + for (live_capabilities, expected) in [ + ( + running_binary.endpoint_capabilities, + RemoteInstallRunningServerPlan::LiveHandoff, + ), + ( + installed.endpoint_capabilities, + RemoteInstallRunningServerPlan::KeepRunning, + ), + ] { + let live_negotiation = crate::client::endpoint::EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + live_capabilities, + ); + let RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + live_handoff, + detached_server_daemon, + .. + } = parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.8.2","protocol":22,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#, + ) + .unwrap() + .with_endpoint_negotiation(&live_negotiation) else { + panic!("captured server must be running"); + }; + + assert_eq!( + remote_install_running_server_plan( + endpoint_protocol_generation, + detached_server_daemon, + surface_interest, + health_check, + live_handoff, + true, + true, + ), + expected, + "setup must follow the running server's negotiated capabilities", + ); + } + } + #[test] fn parse_remote_server_status_json_reads_running_server() { assert_eq!( @@ -3047,7 +3336,12 @@ mod tests { os: "linux", arch: "x86_64", }); - let command = remote_live_handoff_command(&remote_herdr, 19, "0.7.9"); + let command = remote_live_handoff_command( + &remote_herdr, + crate::session::DEFAULT_SESSION_NAME, + 19, + "0.7.9", + ); assert!(command.contains("--expected-protocol 19")); assert!(command.contains("--expected-version 0.7.9")); assert!(!command.contains(&format!( diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 01c15c68..3e933c95 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -119,6 +119,7 @@ fn spawn_server_with_path( let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("server"); + cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); @@ -157,6 +158,7 @@ fn spawn_client_process( let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); + cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path);