diff options
Diffstat (limited to 'src')
114 files changed, 17372 insertions, 2674 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs index 074cd4ed..ba9f0c3a 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -69,6 +69,8 @@ pub enum Method { ClientWindowTitleSet(ClientWindowTitleSetParams), #[serde(rename = "client.window_title.clear")] ClientWindowTitleClear(EmptyParams), + #[serde(rename = "client_shell.surface.set")] + ClientShellSurfaceSet(ClientShellSurfaceSetParams), #[serde(rename = "session.snapshot")] SessionSnapshot(EmptyParams), #[serde(rename = "workspace.create")] diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 15f56e6c..bf0d7caf 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -61,6 +61,12 @@ pub struct ClientWindowTitleSetParams { pub title: String, } +/// Updates whether the requesting client shell receives and controls pane presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ClientShellSurfaceSetParams { + pub active: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SplitDirection { diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 180d6645..e6d4de4d 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -295,6 +295,12 @@ pub enum ResponseResult { status: crate::config::ConfigReloadStatus, diagnostics: Vec<String>, }, + /// Acknowledgement for the client-shell surface interest lease. This method is new on the + /// endpoint protocol, so its revision-bearing result can establish an activation floor. + ClientShellSurfaceSet { + active: bool, + projection_revision: u64, + }, Ok {}, } diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs index 96e51564..b274bc95 100644 --- a/src/api/schema/server.rs +++ b/src/api/schema/server.rs @@ -21,4 +21,10 @@ pub struct ServerCapabilities { /// Stable client-owned endpoint generation supported by this server. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint_protocol_generation: Option<u32>, + /// Whether this server supports explicit client-shell surface interest. + #[serde(default)] + pub surface_interest: bool, + /// Whether this server supports endpoint health probes. + #[serde(default)] + pub health_check: bool, } diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 693f0e85..b566e8f6 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -725,6 +725,8 @@ fn success_response_round_trips() { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: Some(1), + surface_interest: true, + health_check: true, }), }, }; diff --git a/src/api/server.rs b/src/api/server.rs index 655d89c5..b8ef7c38 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -69,6 +69,8 @@ fn default_capabilities() -> Option<ServerCapabilities> { live_handoff: crate::platform::capabilities().live_handoff, detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(), endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + surface_interest: true, + health_check: true, }) } @@ -352,6 +354,14 @@ fn handle_request( }); } + if matches!(&request.method, Method::ClientShellSurfaceSet(_)) { + return error_response_json( + request.id, + "connection_local_only", + "client_shell.surface.set is only available through a client shell endpoint".into(), + ); + } + if matches!(&request.method, Method::ServerStop(_)) { if let Some(server_stop) = server_stop { server_stop.store(true, Ordering::Release); @@ -386,6 +396,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::CommandInvoke(_) => "command.invoke", Method::ClientWindowTitleSet(_) => "client.window_title.set", Method::ClientWindowTitleClear(_) => "client.window_title.clear", + Method::ClientShellSurfaceSet(_) => "client_shell.surface.set", Method::SessionSnapshot(_) => "session.snapshot", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", @@ -1136,6 +1147,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), None, None, @@ -26,6 +26,7 @@ mod agent; mod api; mod completion; mod integration; +mod machine; mod notification; mod pane; mod plugin; @@ -113,6 +114,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "completion" | "completions" => completion::run_completion_command(&args[2..])?, "config" => run_config_command(&args[2..])?, "channel" => run_channel_command(&args[2..])?, + "machine" => machine::run_machine_command(&args[2..])?, "workspace" => workspace::run_workspace_command(&args[2..])?, "worktree" => worktree::run_worktree_command(&args[2..])?, "tab" => tab::run_tab_command(&args[2..])?, diff --git a/src/cli/machine.rs b/src/cli/machine.rs new file mode 100644 index 00000000..a55e4611 --- /dev/null +++ b/src/cli/machine.rs @@ -0,0 +1,295 @@ +use serde::Serialize; + +use crate::client::endpoint::{EndpointCatalog, ProfileId}; + +const HELP: &str = "Usage: + herdr machine list [--json] + herdr machine add <ssh-target> --label <label> [--remote-session <name>] + herdr machine rename <profile-id> --label <label> + herdr machine remove <profile-id> + herdr machine enable <profile-id> + herdr machine disable <profile-id> + +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."; + +#[derive(Serialize)] +struct MachineListRow<'a> { + id: &'a str, + label: &'a str, + target: &'a str, + session: &'a str, + enabled: bool, + selected: bool, +} + +pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> { + match args.first().map(String::as_str) { + Some("list") => list(&args[1..]), + Some("add") => add(&args[1..]), + Some("rename") => rename(&args[1..]), + Some("remove") => remove(&args[1..]), + Some("enable") => set_enabled(&args[1..], true), + Some("disable") => set_enabled(&args[1..], false), + Some("help" | "--help" | "-h") => { + println!("{HELP}"); + Ok(0) + } + _ => { + eprintln!("{HELP}"); + Ok(2) + } + } +} + +fn list(args: &[String]) -> std::io::Result<i32> { + let json = match args { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("usage: herdr machine list [--json]"); + return Ok(2); + } + }; + let catalog = load_catalog()?; + let rows = catalog + .ssh + .iter() + .map(|profile| MachineListRow { + id: profile.id.as_str(), + label: &profile.label, + target: &profile.target, + session: &profile.session, + enabled: profile.enabled, + selected: catalog.selected_profile.as_ref() == Some(&profile.id), + }) + .collect::<Vec<_>>(); + if json { + println!( + "{}", + serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)? + ); + return Ok(0); + } + if rows.is_empty() { + println!("No saved SSH machines."); + return Ok(0); + } + for row in rows { + let state = if row.enabled { "enabled" } else { "disabled" }; + println!( + "{}\t{}\t{}\t{}\t{}", + row.id, row.label, row.target, row.session, state + ); + } + Ok(0) +} + +fn add(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label", "--remote-session"]); + let Some(target) = args.first().filter(|value| !value.starts_with('-')) else { + eprintln!( + "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]" + ); + return Ok(2); + }; + let mut label = None; + let mut session = None; + let mut index = 1; + while index < args.len() { + let (name, value) = match args[index].as_str() { + "--label" | "--remote-session" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {}", args[index]); + return Ok(2); + }; + index += 2; + (args[index - 2].as_str(), value.clone()) + } + unknown => { + eprintln!("unknown machine add option: {unknown}"); + return Ok(2); + } + }; + match name { + "--label" if label.is_none() => label = Some(value), + "--remote-session" if session.is_none() => session = Some(value), + "--remote-session" => { + eprintln!("--remote-session can only be specified once"); + return Ok(2); + } + "--label" => { + eprintln!("--label can only be specified once"); + return Ok(2); + } + _ => unreachable!("validated machine add option"), + } + } + let Some(label) = label else { + eprintln!("--label is required"); + return Ok(2); + }; + let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); + 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) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + 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) +} + +fn rename(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label"]); + let [raw_id, flag, label] = args.as_slice() else { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + }; + if flag != "--label" { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + } + let id = match ProfileId::parse(raw_id.clone()) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + let mut catalog = load_catalog()?; + match catalog.rename_ssh(&id, label) { + Ok(true) => {} + Ok(false) => { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + store_catalog(&catalog)?; + println!("Renamed SSH machine {id}."); + Ok(0) +} + +fn remove(args: &[String]) -> std::io::Result<i32> { + let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.remove_ssh(&id) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!("Removed SSH machine {id}."); + Ok(0) +} + +fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> { + let action = if enabled { "enable" } else { "disable" }; + let usage = format!("usage: herdr machine {action} <profile-id>"); + let Some(id) = one_profile_id(args, &usage)? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.set_enabled(&id, enabled) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!( + "{} SSH machine {id}.", + if enabled { "Enabled" } else { "Disabled" } + ); + Ok(0) +} + +fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> { + let [raw] = args else { + eprintln!("{usage}"); + return Ok(None); + }; + match ProfileId::parse(raw.clone()) { + Ok(id) => Ok(Some(id)), + Err(error) => { + eprintln!("error: {error}"); + Ok(None) + } + } +} + +fn load_catalog() -> std::io::Result<EndpointCatalog> { + EndpointCatalog::load().map_err(std::io::Error::other) +} + +fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> { + catalog.store_profiles().map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_id_parser_rejects_target_text() { + assert!(one_profile_id(&["build.example".into()], "usage") + .unwrap() + .is_none()); + } + + #[test] + fn list_rows_do_not_have_credential_fields() { + let encoded = serde_json::to_string(&MachineListRow { + id: "0123456789abcdef0123456789abcdef", + label: "Build", + target: "dev@build", + session: "agents", + enabled: true, + selected: false, + }) + .unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("key")); + } +} diff --git a/src/cli/spec.rs b/src/cli/spec.rs index cf23e320..2da0a25c 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -2,6 +2,9 @@ use std::io::Write; use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; +mod completion; +mod machine; + pub(super) fn command() -> Command { let command = Command::new("herdr") .about("terminal workspace manager for AI coding agents") @@ -25,11 +28,12 @@ pub(super) fn command() -> Command { .action(ArgAction::SetTrue) .help("Print version and exit"), ) - .subcommand(completion_command()) + .subcommand(completion::command()) .subcommand(update_command()) .subcommand(status_command()) .subcommand(config_command()) .subcommand(channel_command()) + .subcommand(machine::command()) .subcommand(server_command()) .subcommand(api_command()) .subcommand(workspace_command()) @@ -108,19 +112,6 @@ fn write_requested_help( Ok(true) } -fn completion_command() -> Command { - Command::new("completion") - .visible_alias("completions") - .about("Generate shell completion scripts") - .arg( - Arg::new("shell") - .value_name("SHELL") - .required(true) - .value_parser(super::completion::SUPPORTED_SHELLS) - .help("Shell to generate completions for"), - ) -} - fn update_command() -> Command { Command::new("update") .about("Download and install the latest version") diff --git a/src/cli/spec/completion.rs b/src/cli/spec/completion.rs new file mode 100644 index 00000000..8cc27d7b --- /dev/null +++ b/src/cli/spec/completion.rs @@ -0,0 +1,14 @@ +use clap::{Arg, Command}; + +pub(super) fn command() -> Command { + Command::new("completion") + .visible_alias("completions") + .about("Generate shell completion scripts") + .arg( + Arg::new("shell") + .value_name("SHELL") + .required(true) + .value_parser(super::super::completion::SUPPORTED_SHELLS) + .help("Shell to generate completions for"), + ) +} diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs new file mode 100644 index 00000000..188e27b9 --- /dev/null +++ b/src/cli/spec/machine.rs @@ -0,0 +1,49 @@ +use clap::{Arg, Command}; + +use super::{json_flag, option}; + +pub(super) fn command() -> Command { + Command::new("machine") + .about("Manage saved SSH machines") + .subcommand( + Command::new("list") + .about("List saved SSH machines") + .arg(json_flag()), + ) + .subcommand( + Command::new("add") + .about("Prepare the remote Herdr server and save an SSH machine") + .arg( + Arg::new("ssh-target") + .value_name("SSH_TARGET") + .required(true), + ) + .arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ) + .arg( + option("remote-session", "NAME") + .help("Set the explicit Herdr session on the remote machine"), + ), + ) + .subcommand( + profile_command("rename", "Rename a saved SSH machine").arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ), + ) + .subcommand(profile_command("remove", "Remove a saved SSH machine")) + .subcommand(profile_command("enable", "Enable a saved SSH machine")) + .subcommand(profile_command("disable", "Disable a saved SSH machine")) +} + +fn profile_command(name: &'static str, about: &'static str) -> Command { + Command::new(name).about(about).arg( + Arg::new("profile-id") + .value_name("PROFILE_ID") + .required(true), + ) +} diff --git a/src/cli/status.rs b/src/cli/status.rs index 4dba81b7..c6dda462 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -255,6 +255,7 @@ struct ClientStatusJson { channel: &'static str, protocol: u32, endpoint_protocol_generation: u32, + endpoint_capabilities: Vec<&'static str>, binary: String, session: Option<String>, } @@ -279,6 +280,8 @@ struct ServerCapabilitiesJson { live_handoff: bool, detached_server_daemon: bool, endpoint_protocol_generation: Option<u32>, + surface_interest: bool, + health_check: bool, } #[derive(Serialize)] @@ -293,6 +296,11 @@ fn client_status_json() -> ClientStatusJson { channel: crate::config::Config::load().config.update.channel.as_str(), protocol: crate::protocol::PROTOCOL_VERSION, endpoint_protocol_generation: crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, + endpoint_capabilities: vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, + ], binary: current_exe_label(), session: crate::session::active_name(), } @@ -315,6 +323,8 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { live_handoff: capabilities.live_handoff, detached_server_daemon: capabilities.detached_server_daemon, endpoint_protocol_generation: capabilities.endpoint_protocol_generation, + surface_interest: capabilities.surface_interest, + health_check: capabilities.health_check, }), compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION), endpoint_compatible: capabilities.as_ref().and_then(|capabilities| { @@ -404,6 +414,8 @@ mod tests { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: endpoint_generation, + surface_interest: true, + health_check: true, }), } } diff --git a/src/client/attach.rs b/src/client/attach.rs index e385775c..2cabe5bb 100644 --- a/src/client/attach.rs +++ b/src/client/attach.rs @@ -9,7 +9,6 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; #[cfg(unix)] use super::write_to_server; #[cfg(unix)] -use crate::ipc::LocalStream; #[cfg(unix)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; @@ -260,7 +259,7 @@ fn attach_scroll_action( #[cfg(unix)] pub(super) fn write_attach_semantic_action( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, action: AttachSemanticAction, ) -> io::Result<()> { let message = match action { diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs new file mode 100644 index 00000000..21f33bea --- /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<ClientLoopEvent>, + should_quit: Arc<AtomicBool>, +) { + // 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<endpoint::SavedSshEndpoint>, + 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.cancel_endpoint_request(&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<AtomicUsize>); + + 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/clipboard_forwarding.rs b/src/client/clipboard_forwarding.rs new file mode 100644 index 00000000..2de5f49f --- /dev/null +++ b/src/client/clipboard_forwarding.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { + use base64::Engine; + + base64::engine::general_purpose::STANDARD.decode(data).ok() +} + +pub(super) fn forward_clipboard(data: &str) -> bool { + let Some(bytes) = decode_clipboard_payload(data) else { + warn!("received invalid clipboard payload from server"); + return false; + }; + crate::selection::write_osc52_bytes(&bytes); + true +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs index c1c49cbc..1faebf39 100644 --- a/src/client/clipboard_images.rs +++ b/src/client/clipboard_images.rs @@ -2,16 +2,15 @@ use std::path::PathBuf; use tracing::{info, warn}; -use crate::ipc::LocalStream; #[cfg(windows)] use crate::protocol::ClientInputEvent; use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; -use super::{is_remote_client_process, write_to_server, ClientError}; +use super::{write_to_server, ClientError}; pub(super) fn write_remote_image_to_server( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, target: ClientClipboardImageTarget, image: crate::platform::ClipboardImage, source: &'static str, @@ -46,10 +45,6 @@ pub(super) fn write_remote_image_to_server( pub(super) fn client_remote_image_paste_key( config: &crate::config::Config, ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - match config.remote_image_paste_key() { Ok(key) => key, Err(diagnostic) => { @@ -59,14 +54,25 @@ pub(super) fn client_remote_image_paste_key( } } +pub(super) fn endpoint_accepts_local_images( + remote_client_process: bool, + endpoint_id: &super::endpoint::ClientEndpointId, + active_surface_available: bool, +) -> bool { + active_surface_available && (remote_client_process || !endpoint_id.is_local()) +} + #[cfg(unix)] pub(super) fn should_bridge_clipboard_image_paste( data: &[u8], is_remote_client: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, ) -> bool { + if !is_remote_client { + return false; + } if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; + return true; } let Some(remote_image_paste_key) = remote_image_paste_key else { diff --git a/src/client/config_reload.rs b/src/client/config_reload.rs new file mode 100644 index 00000000..c20de760 --- /dev/null +++ b/src/client/config_reload.rs @@ -0,0 +1,129 @@ +use super::*; + +pub(super) fn init_logging() { + crate::logging::init_file_logging("herdr-client.log"); +} + +pub(super) fn apply_reload( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; + reload_local_client_config( + &mut state.sound_config, + &mut state.redraw_on_focus_gained, + &mut state.draw_host_cursor, + &mut state.remote_image_paste_key, + &mut mouse_capture, + ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + endpoints, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels).map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + 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, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + if let Some(resize) = resize { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + write_to_server(endpoints, &resize).map_err(ClientError::ConnectionLost)?; + } + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(()) +} + +pub(super) fn reload_local_client_config( + sound_config: &mut crate::config::SoundConfig, + redraw_on_focus_gained: &mut bool, + draw_host_cursor: &mut bool, + remote_image_paste_key: &mut Option<( + crossterm::event::KeyCode, + crossterm::event::KeyModifiers, + )>, + mouse_capture: &mut bool, +) { + match crate::config::load_live_config() { + Ok(loaded) => { + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); + } + debug!("reloaded local client config"); + } + Err(diagnostics) => { + warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); + } + } +} diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs new file mode 100644 index 00000000..77aaae3b --- /dev/null +++ b/src/client/endpoint.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +mod activation; +mod catalog; +mod control; +mod health; +mod message_policy; +mod registry; +mod supervisor; +mod writer; + +pub(crate) use activation::*; +pub(crate) use catalog::*; +pub(crate) use control::*; +pub(crate) use message_policy::*; +pub(crate) use registry::*; +pub(crate) use supervisor::*; +pub(crate) use writer::NativeEndpointTransport; + +const PROFILE_ID_BYTES: usize = 16; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct ProfileId(String); + +impl ProfileId { + pub(crate) fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() != PROFILE_ID_BYTES * 2 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("endpoint profile id must be 32 lowercase hexadecimal characters".into()); + } + Ok(Self(value)) + } + + pub(crate) fn generate() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let digest = Sha256::digest(format!("{}:{now}:{sequence}", std::process::id()).as_bytes()); + Self( + digest[..PROFILE_ID_BYTES] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ClientEndpointId { + Local, + Ssh(ProfileId), +} + +impl ClientEndpointId { + pub(crate) fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + + pub(crate) fn storage_key(&self) -> String { + match self { + Self::Local => "local".into(), + Self::Ssh(profile_id) => format!("ssh:{profile_id}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointStatus { + Connecting, + Online, + Reconnecting, + Attention, + Disabled, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_ids_are_opaque_and_stable_when_parsed() { + let first = ProfileId::generate(); + let second = ProfileId::generate(); + assert_ne!(first, second); + assert_eq!(ProfileId::parse(first.to_string()).unwrap(), first); + assert_eq!(first.as_str().len(), 32); + } + + #[test] + fn endpoint_storage_keys_do_not_contain_ssh_targets() { + let profile = ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(); + assert_eq!( + ClientEndpointId::Ssh(profile).storage_key(), + "ssh:0123456789abcdef0123456789abcdef" + ); + } +} diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs new file mode 100644 index 00000000..a5b63344 --- /dev/null +++ b/src/client/endpoint/activation.rs @@ -0,0 +1,1152 @@ +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointRegistry, EndpointSendOutcome}; + +mod model; +mod protocol; +pub(crate) use model::{ + ActivationBeginError, ActivationCompletion, ActivationRollback, EndpointActivationIntent, + PendingEndpointActivation, SurfaceActivationProgress, +}; +use model::{ActivationEvidence, ActivationPhase, EndpointLease}; +use protocol::*; + +const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5); + +impl PendingEndpointActivation { + #[allow(clippy::too_many_arguments)] + pub(crate) fn begin( + shell: &crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + target: ClientEndpointId, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + resize: crate::protocol::ClientMessage, + serial: u64, + now: Instant, + ) -> Result<Self, ActivationBeginError> { + resize_geometry(&resize).ok_or_else(|| { + ActivationBeginError::Preflight( + "endpoint activation did not include a surface resize".to_owned(), + ) + })?; + let source_id = endpoints.active_id().clone(); + let source_has_live_surface = endpoints + .connection(&source_id) + .is_some_and(|connection| connection.surface_active); + let (source, source_available) = if source_has_live_surface { + ( + endpoint_lease(shell, endpoints, &source_id) + .map_err(ActivationBeginError::Preflight)?, + true, + ) + } else { + (disconnected_endpoint_lease(shell, &source_id), false) + }; + let target_lease = + endpoint_lease(shell, endpoints, &target).map_err(ActivationBeginError::Preflight)?; + let source_compatible = !source_available + || endpoints + .connection(&source_id) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + let target_compatible = endpoints + .connection(&target) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + if !source_compatible || !target_compatible { + return Err(ActivationBeginError::Preflight( + "endpoint must be updated before it can join the selected surface".into(), + )); + } + + let source_is_target = source.endpoint_id == target_lease.endpoint_id; + // Validate every typed lifecycle and optional focus envelope before the first transport + // write. Any error above this line is guaranteed not to have changed either endpoint. + let source_release_request = (source_available && !source_is_target) + .then(|| { + surface_interest_request( + &source.boot_id, + format!("client-shell-surface:{serial}:off"), + false, + ) + }) + .transpose() + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + surface_interest_request( + &target_lease.boot_id, + format!("client-shell-surface:{serial}:on"), + true, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + if let Some(target) = focus.as_ref() { + focus_request( + &target_lease.boot_id, + format!("client-shell-focus:{serial}:1"), + target, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + } + + endpoints.freeze_input(); + let mut activation = Self { + source, + source_available, + target: target_lease, + focus, + host_focused: shell.host_focus_baseline(), + resize: resize.clone(), + phase: ActivationPhase::ReleasingSource { + request_id: format!("client-shell-surface:{serial}:off"), + }, + deadline: now + ACTIVATION_TIMEOUT, + epoch: serial, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + + // Reconnecting the selected endpoint has no live source surface to release. All normal + // handoffs must make the source locally inactive before a target request is even sent. + if source_is_target || !source_available { + if let Err(error) = activation.start_target(endpoints, resize) { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error, + }); + } + } else { + // `surface.set(false)` removes the viewer, but old servers only emit the PTY focus + // loss while the viewer is still active. Revoke it explicitly before source-off. + if endpoints.send_to( + &activation.source.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused: false }, + ) != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint focus revoke could not be sent".into(), + }); + } + let request = source_release_request.expect("validated source release request"); + if endpoints.send_to(&activation.source.endpoint_id, &request) + != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint release could not be sent".into(), + }); + } + // This is deliberately before the acknowledgement: the source is no longer viewed + // locally while its release is in flight, and pane input is consequently blocked. + endpoints.set_surface_active(&activation.source.endpoint_id, false); + } + Ok(activation) + } + + pub(crate) fn target(&self) -> &ClientEndpointId { + &self.target.endpoint_id + } + + fn geometry(&self) -> crate::protocol::ClientSurfaceSize { + resize_geometry(&self.resize).expect("activation resize was validated before construction") + } + + pub(crate) fn presentation_sync_endpoint(&self) -> Option<&ClientEndpointId> { + match self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + _ => None, + } + } + + /// The complete source command lane cannot safely cross source-off into a later presentation + /// epoch. Other endpoint lanes are not part of this retirement. + pub(crate) fn source_command_lane(&self) -> Option<&ClientEndpointId> { + (self.source_available && self.source.endpoint_id != self.target.endpoint_id) + .then_some(&self.source.endpoint_id) + } + + pub(crate) fn can_retarget(&self, endpoint_id: &ClientEndpointId) -> bool { + self.target.endpoint_id == *endpoint_id + && self.successor.is_none() + && matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } | ActivationPhase::ActivatingTarget { .. } + ) + } + + /// Replace an in-flight handoff with the latest endpoint-qualified intent. The current + /// transaction is still reversed through target-off/source-on; the replacement is launched + /// by the caller only after the source's coherent restoration commits. + pub(crate) fn supersede( + &mut self, + endpoint_id: ClientEndpointId, + target: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> ActivationRollback { + self.successor = Some(EndpointActivationIntent { + endpoint_id, + target, + }); + // Source-on is already ordered and must finish before any replacement is allowed to + // begin. Later rapid selections only replace the retained intent; they never turn a + // safe restoration into an unavailable state. + let source_restoration_in_flight = matches!( + self.phase, + ActivationPhase::RestoringSource { .. } + ) || matches!( + &self.phase, + ActivationPhase::SynchronizingPresentation { completion, .. } + if matches!(completion.as_ref(), ActivationCompletion::RestoredSource { .. }) + ); + if source_restoration_in_flight { + return ActivationRollback::Pending; + } + self.rollback( + endpoints, + "endpoint handoff superseded by a newer selection".into(), + false, + ) + } + + pub(crate) fn accepts_endpoint(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + (self.source_available + && self.source.endpoint_id == *endpoint_id + && self.source.generation == generation) + || (self.target.endpoint_id == *endpoint_id && self.target.generation == generation) + } + + pub(crate) fn involves_endpoint(&self, endpoint_id: &ClientEndpointId) -> bool { + self.source.endpoint_id == *endpoint_id || self.target.endpoint_id == *endpoint_id + } + + pub(crate) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + ) -> bool { + match &self.phase { + ActivationPhase::ReleasingSource { + request_id: expected, + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::ActivatingTarget { + request_id: expected, + focus_request_id, + .. + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && (expected == request_id || focus_request_id.as_deref() == Some(request_id)) + } + ActivationPhase::ReleasingTargetForRollback { + request_id: expected, + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::RestoringSource { + request_id: expected, + .. + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::SynchronizingPresentation { + lease, + request_id: expected, + .. + } => { + endpoint_matches(lease, endpoint_id, generation, boot_id) && expected == request_id + } + ActivationPhase::AwaitingPresentationEffects { .. } => false, + } + } + + pub(crate) fn expired(&self, now: Instant) -> bool { + now >= self.deadline + } + + #[cfg(test)] + pub(crate) fn receive_response( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + let boot_id = if self.source.endpoint_id == *endpoint_id { + self.source.boot_id.clone() + } else { + self.target.boot_id.clone() + }; + self.receive_response_for_boot( + endpoint_id, + generation, + &boot_id, + request_id, + data, + endpoints, + ) + } + + pub(crate) fn receive_response_for_boot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + if !self.accepts_response(endpoint_id, generation, boot_id, request_id) { + return SurfaceActivationProgress::Stale; + } + let result = match decode_endpoint_response(request_id, data) { + Ok(result) => result, + Err(error) => { + return SurfaceActivationProgress::Rejected { + source_release_rejected: matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } + ) && error.code.is_some(), + message: error.message, + }; + } + }; + match &mut self.phase { + ActivationPhase::ReleasingSource { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + if let Err(message) = self.start_target(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::ActivatingTarget { + request_id: surface_request_id, + acknowledged_revision, + .. + } if surface_request_id == request_id => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } if focus_request_id.as_deref() == Some(request_id) => { + let requested = focus_request_target.clone(); + let Some(requested) = requested else { + return SurfaceActivationProgress::Stale; + }; + if !focus_result_matches(Some(&requested), &result) { + return SurfaceActivationProgress::Rejected { + message: "endpoint focus returned an unexpected result".into(), + source_release_rejected: false, + }; + } + *focus_request_id = None; + *focus_request_target = None; + if self.focus != Some(requested) { + *focus_acknowledged = false; + if let Err(message) = self.send_latest_focus(endpoints) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + return self.progress(); + } + *focus_acknowledged = true; + self.progress() + } + ActivationPhase::ReleasingTargetForRollback { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + endpoints.set_surface_active(&self.target.endpoint_id, false); + if !self.source_available { + return SurfaceActivationProgress::Rejected { + message: self.rollback_error.clone().unwrap_or_else(|| { + "the previous endpoint is no longer connected".into() + }), + source_release_rejected: false, + }; + } + if let Err(message) = self.start_source_restore(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::RestoringSource { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + _ => SurfaceActivationProgress::Stale, + } + } + + pub(crate) fn receive_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: &crate::protocol::ClientShellSnapshot, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) + || snapshot.revision < lease.minimum_revision + { + return SurfaceActivationProgress::Stale; + } + let evidence = match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + if endpoint_matches(&self.target, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::RestoringSource { evidence, .. } + if endpoint_matches(&self.source, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::SynchronizingPresentation { + lease, evidence, .. + } if endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) => evidence, + _ => return SurfaceActivationProgress::Stale, + }; + evidence.record_snapshot(snapshot); + self.progress() + } + + pub(crate) fn receive_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + surface: crate::protocol::PaneSurfaceFrame, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &surface.boot_id) { + return SurfaceActivationProgress::Stale; + } + if !surface_matches_geometry(&surface, self.geometry()) { + return SurfaceActivationProgress::Pending; + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.record_surface(surface) + } + _ => unreachable!("checked activation phase"), + } + self.progress() + } + + pub(crate) fn receive_presentation_effects_ready( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + token: &str, + ) -> SurfaceActivationProgress { + let ActivationPhase::AwaitingPresentationEffects { + lease, + token: expected, + ready, + .. + } = &mut self.phase + else { + return SurfaceActivationProgress::Stale; + }; + if lease.endpoint_id != *endpoint_id || lease.generation != generation || expected != token + { + return SurfaceActivationProgress::Stale; + } + *ready = true; + SurfaceActivationProgress::Ready + } + + /// A same-endpoint navigation request replaces the desired target but never joins the + /// in-flight focus RPC. Once that request resolves, `send_latest_focus` sends only the most + /// recent desired target. + pub(crate) fn retarget( + &mut self, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.focus = focus; + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = self.focus.is_none() && focus_request_id.is_none(); + } else { + // The latest target is retained and will be sent immediately after source release. + return Ok(()); + } + self.send_latest_focus(endpoints) + } + + pub(crate) fn update_resize( + &mut self, + resize: crate::protocol::ClientMessage, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + resize_geometry(&resize) + .ok_or_else(|| "endpoint activation did not include a surface resize".to_owned())?; + self.resize = resize.clone(); + let restart_effects_fence = match &self.phase { + ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + }; + if let Some((lease, completion)) = restart_effects_fence { + if endpoints.send_to(&lease.endpoint_id, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + return self.start_presentation_sync(endpoints, lease, completion); + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.invalidate_surface() + } + _ => {} + } + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } => Some(&lease.endpoint_id), + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to(destination, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + } + Ok(()) + } + + pub(crate) fn update_host_focus( + &mut self, + focused: bool, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.host_focused = focused; + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to( + destination, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("pending endpoint focus baseline could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + pub(crate) fn update_host_theme( + &mut self, + update: crate::protocol::ClientHostThemeUpdate, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + let message = crate::protocol::ClientMessage::ClientShellHostTheme { update }; + if endpoints.send_to(destination, &message) != EndpointSendOutcome::Sent { + return Err("pending endpoint host theme could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + fn presentation_restart(&self) -> Option<(EndpointLease, ActivationCompletion)> { + match &self.phase { + ActivationPhase::SynchronizingPresentation { + lease, completion, .. + } + | ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + } + } + + fn invalidate_current_evidence(&mut self) { + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } => evidence.invalidate_surface(), + _ => {} + } + } + + /// Losing the source revokes its surface and removes the rollback destination; it must not + /// cancel a healthy target. Losing the target restores the source when it is still available. + pub(crate) fn endpoint_disconnected( + &mut self, + endpoints: &mut EndpointRegistry, + endpoint_id: &ClientEndpointId, + error: String, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if self.target.endpoint_id == *endpoint_id && self.source.endpoint_id != *endpoint_id { + return match self.phase { + ActivationPhase::RestoringSource { .. } => ActivationRollback::Pending, + _ => match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {restore_error}" + )), + }, + }; + } + if self.source.endpoint_id != *endpoint_id { + return ActivationRollback::Unavailable(error); + } + self.source_available = false; + if self.source.endpoint_id != self.target.endpoint_id { + match &self.phase { + ActivationPhase::ReleasingSource { .. } => { + return match self.start_target(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(message) => ActivationRollback::Unavailable(message), + }; + } + ActivationPhase::ActivatingTarget { .. } => return ActivationRollback::Pending, + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } + if lease.endpoint_id == self.target.endpoint_id => + { + return ActivationRollback::Pending + } + _ => {} + } + } + match self.phase { + ActivationPhase::ReleasingSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::ActivatingTarget { .. } => { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } + ActivationPhase::ReleasingTargetForRollback { .. } => ActivationRollback::Pending, + ActivationPhase::RestoringSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } else { + ActivationRollback::Unavailable(error) + } + } + } + } + + pub(crate) fn rollback( + &mut self, + endpoints: &mut EndpointRegistry, + error: String, + source_release_rejected: bool, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if matches!(self.phase, ActivationPhase::ReleasingSource { .. }) && source_release_rejected + { + // Rejection proves source-off did not commit, but cached source metadata may have + // advanced while the frame was frozen. Restore through the same coherent on/sync + // path rather than immediately exposing a stale source projection. + return match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not resume: {restore_error}" + )), + }; + } + let result = match self.phase { + ActivationPhase::ReleasingSource { .. } => { + if self.source_available { + self.start_source_restore(endpoints, self.resize.clone()) + } else { + Err("the previous endpoint is no longer connected".into()) + } + } + ActivationPhase::ActivatingTarget { .. } => self.start_target_release(endpoints), + ActivationPhase::ReleasingTargetForRollback { .. } => { + // The target may have observed target-on or target-off. Closing this transport + // is the only safe local revocation when target-off is not acknowledged. + endpoints.fail( + &self.target.endpoint_id, + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "endpoint did not acknowledge surface revocation", + ), + ); + if !self.source_available { + return ActivationRollback::Unavailable(format!( + "{error}; the target connection was closed because no presentation owner could be proven" + )); + } + self.start_source_restore(endpoints, self.resize.clone()) + } + ActivationPhase::RestoringSource { .. } => { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored" + )); + } + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + self.start_target_release(endpoints) + } else { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint presentation could not be synchronized" + )); + } + } + }; + match result { + Ok(()) => ActivationRollback::Pending, + Err(rollback_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {rollback_error}" + )), + } + } + + pub(crate) fn complete( + &mut self, + shell: &mut crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + ) -> Result<ActivationCompletion, String> { + if let ActivationPhase::SynchronizingPresentation { + lease, + acknowledged_revision, + evidence, + completion, + .. + } = &self.phase + { + let lease = lease.clone(); + let completion = (**completion).clone(); + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + *acknowledged_revision, + self.geometry(), + )?; + if endpoints.active_id() != &lease.endpoint_id + || !shell.endpoint_projection_available(&lease.endpoint_id) + || !shell.activate_endpoint_projection(&lease.endpoint_id) + { + return Err( + "endpoint became unavailable during presentation synchronization".into(), + ); + } + shell.set_pane_surface(surface); + self.start_presentation_effects_fence(endpoints, lease, completion)?; + return Ok(ActivationCompletion::AwaitingPresentationEffects); + } + if let ActivationPhase::AwaitingPresentationEffects { + ready: true, + completion, + .. + } = &self.phase + { + return Ok((**completion).clone()); + } + + let (lease, evidence, acknowledgement_revision, completion) = match &self.phase { + ActivationPhase::ActivatingTarget { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::Activated; + ( + self.target.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + ActivationPhase::RestoringSource { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::RestoredSource { + error: self + .rollback_error + .clone() + .unwrap_or_else(|| "endpoint handoff was rolled back".into()), + successor: self.successor.clone(), + }; + ( + self.source.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + _ => return Err("endpoint activation completed in an invalid phase".into()), + }; + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + acknowledgement_revision, + self.geometry(), + )?; + endpoints.set_surface_active(&lease.endpoint_id, true); + shell.set_endpoint_status(&lease.endpoint_id, ClientEndpointStatus::Online); + if !shell.endpoint_projection_available(&lease.endpoint_id) + || !endpoints.set_active(&lease.endpoint_id) + { + return Err("endpoint became unavailable during activation".into()); + } + let activated = shell.activate_endpoint_projection(&lease.endpoint_id); + debug_assert!(activated, "preflighted endpoint projection must activate"); + debug_assert!(shell.endpoint_is_active(endpoints.active_id())); + shell.set_pane_surface(surface); + self.start_presentation_sync(endpoints, lease.clone(), completion)?; + Ok(ActivationCompletion::AwaitingPresentationSync { + previous: self.source.endpoint_id.clone(), + endpoint: lease.endpoint_id, + }) + } + + fn start_target( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:on", self.epoch); + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + // A transport may fail after writing any baseline or surface message. Enter the target + // phase first so every uncertain target write is reversed through target-off before + // source restoration is considered. + self.phase = ActivationPhase::ActivatingTarget { + request_id: request_id.clone(), + acknowledged_revision: None, + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: self.focus.is_none(), + evidence: ActivationEvidence::default(), + }; + send_surface_activation( + endpoints, + &self.target, + request_id, + &resize, + self.host_focused, + )?; + + // From this point the target may have processed surface.set(true). Optional navigation + // is serialized through one coalescing focus lane. + self.send_latest_focus(endpoints) + } + + fn start_presentation_sync( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:presentation-sync", self.epoch); + let request = surface_interest_request(&lease.boot_id, request_id.clone(), true) + .map_err(|error| error.to_string())?; + self.phase = ActivationPhase::SynchronizingPresentation { + lease: lease.clone(), + request_id, + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&lease.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint presentation synchronization could not be sent".into()); + } + Ok(()) + } + + fn start_presentation_effects_fence( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let token = format!("{}:{}:{}", self.epoch, lease.generation, lease.boot_id); + self.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease.clone(), + token: token.clone(), + ready: false, + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + let message = crate::protocol::ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND.into(), + data: token, + }; + if endpoints.send_to(&lease.endpoint_id, &message) != EndpointSendOutcome::Sent { + return Err("endpoint presentation effects fence could not be sent".into()); + } + Ok(()) + } + + fn start_target_release(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-target-off", self.epoch); + let request = surface_interest_request(&self.target.boot_id, request_id.clone(), false) + .map_err(|error| error.to_string())?; + // Set the rollback phase before the potentially observed target-off write. + self.phase = ActivationPhase::ReleasingTargetForRollback { request_id }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("target endpoint release could not be sent".into()); + } + Ok(()) + } + + fn start_source_restore( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-source-on", self.epoch); + // Source baseline writes can also be observed before their send reports an error. + self.phase = ActivationPhase::RestoringSource { + request_id: request_id.clone(), + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + send_surface_activation( + endpoints, + &self.source, + request_id, + &resize, + self.host_focused, + ) + } + + fn send_latest_focus(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let desired = self.focus.clone(); + let Some(desired) = desired else { + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + if focus_request_id.is_none() { + *focus_acknowledged = true; + } + } + return Ok(()); + }; + if !matches!( + &self.phase, + ActivationPhase::ActivatingTarget { + focus_request_id: None, + .. + } + ) { + return Ok(()); + } + let request_id = self + .next_focus_request_id() + .expect("desired focus creates a request id"); + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = false; + *focus_request_id = Some(request_id.clone()); + *focus_request_target = Some(desired.clone()); + } + let request = focus_request(&self.target.boot_id, request_id, &desired) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint focus could not be sent".into()); + } + Ok(()) + } + + fn next_focus_request_id(&mut self) -> Option<String> { + self.focus.as_ref()?; + self.next_focus_serial = self.next_focus_serial.saturating_add(1); + Some(format!( + "client-shell-focus:{}:{}", + self.epoch, self.next_focus_serial + )) + } + + fn progress(&self) -> SurfaceActivationProgress { + match &self.phase { + ActivationPhase::ActivatingTarget { + acknowledged_revision, + focus_acknowledged, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + *focus_acknowledged + && evidence + .coherent_surface(revision, self.geometry()) + .is_some_and(|surface| self.target_matches(surface)) + }) => + { + SurfaceActivationProgress::Ready + } + ActivationPhase::RestoringSource { + acknowledged_revision, + evidence, + .. + } + | ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + evidence + .coherent_surface(revision, self.geometry()) + .is_some() + }) => + { + SurfaceActivationProgress::Ready + } + _ => SurfaceActivationProgress::Pending, + } + } + + fn target_matches(&self, surface: &crate::protocol::PaneSurfaceFrame) -> bool { + let evidence = match &self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } => evidence, + _ => return false, + }; + match &self.focus { + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => { + evidence.focused_pane_id.as_deref() == Some(pane_id) + && surface + .panes + .iter() + .any(|pane| pane.focused && &pane.pane_id == pane_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id)) => { + evidence.focused_tab_id.as_deref() == Some(tab_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id)) => { + evidence.focused_workspace_id.as_deref() == Some(workspace_id) + } + None => true, + } + } +} + +#[cfg(test)] +#[path = "activation_tests.rs"] +mod tests; diff --git a/src/client/endpoint/activation/model.rs b/src/client/endpoint/activation/model.rs new file mode 100644 index 00000000..c94029e9 --- /dev/null +++ b/src/client/endpoint/activation/model.rs @@ -0,0 +1,174 @@ +use std::time::Instant; + +use super::super::ClientEndpointId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct EndpointLease { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, + pub(super) boot_id: String, + /// The endpoint cache may already be newer than the first activation event. Never let an + /// activation prove coherence with a revision that the monotonic cache has discarded. + pub(super) minimum_revision: u64, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct ActivationEvidence { + pub(super) snapshot_revision: Option<u64>, + pub(super) focused_workspace_id: Option<String>, + pub(super) focused_tab_id: Option<String>, + pub(super) focused_pane_id: Option<String>, + pub(super) surface: Option<crate::protocol::PaneSurfaceFrame>, +} + +impl ActivationEvidence { + pub(super) fn record_snapshot(&mut self, snapshot: &crate::protocol::ClientShellSnapshot) { + if self + .snapshot_revision + .is_none_or(|current| snapshot.revision >= current) + { + self.snapshot_revision = Some(snapshot.revision); + self.focused_workspace_id = snapshot.focused_workspace_id.clone(); + self.focused_tab_id = snapshot.focused_tab_id.clone(); + self.focused_pane_id = snapshot.focused_pane_id.clone(); + } + } + + pub(super) fn record_surface(&mut self, surface: crate::protocol::PaneSurfaceFrame) { + let replace = self.surface.as_ref().is_none_or(|current| { + surface.projection_revision > current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision >= current.surface_revision) + }); + if replace { + self.surface = Some(surface); + } + } + + pub(super) fn invalidate_surface(&mut self) { + self.surface = None; + } + + pub(super) fn coherent_surface( + &self, + minimum_revision: u64, + geometry: crate::protocol::ClientSurfaceSize, + ) -> Option<&crate::protocol::PaneSurfaceFrame> { + self.surface.as_ref().filter(|surface| { + self.snapshot_revision == Some(surface.projection_revision) + && surface.projection_revision >= minimum_revision + && super::surface_matches_geometry(surface, geometry) + }) + } +} + +#[derive(Clone, Debug)] +pub(super) enum ActivationPhase { + ReleasingSource { + request_id: String, + }, + ActivatingTarget { + request_id: String, + acknowledged_revision: Option<u64>, + /// At most one focus request may be in flight. Retargets only replace `focus` until + /// this response arrives, at which point the latest target is sent. + focus_request_id: Option<String>, + focus_request_target: Option<crate::client::shell::ClientEndpointFocusTarget>, + focus_acknowledged: bool, + evidence: ActivationEvidence, + }, + ReleasingTargetForRollback { + request_id: String, + }, + RestoringSource { + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + }, + SynchronizingPresentation { + lease: EndpointLease, + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + completion: Box<ActivationCompletion>, + }, + AwaitingPresentationEffects { + lease: EndpointLease, + token: String, + ready: bool, + completion: Box<ActivationCompletion>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SurfaceActivationProgress { + Pending, + Ready, + Rejected { + message: String, + source_release_rejected: bool, + }, + Stale, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationRollback { + /// A correlated rollback request is in flight; retain the frozen frame. + Pending, + /// Neither endpoint can be made safe to present. Keep pane input frozen while presenting + /// client chrome and this error. + Unavailable(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationCompletion { + AwaitingPresentationSync { + previous: ClientEndpointId, + endpoint: ClientEndpointId, + }, + AwaitingPresentationEffects, + Activated, + RestoredSource { + error: String, + /// A newer endpoint-qualified selection arrived while this handoff was frozen. It is + /// started only after the original source has been coherently restored. + successor: Option<EndpointActivationIntent>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointActivationIntent { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) target: Option<crate::client::shell::ClientEndpointFocusTarget>, +} + +/// Begin failures are separated by whether the transport may already have observed a lifecycle +/// write. A partial transaction must be kept and rolled back under a frozen presentation. +#[derive(Debug)] +pub(crate) enum ActivationBeginError { + Preflight(String), + Partial { + activation: Box<PendingEndpointActivation>, + error: String, + }, +} + +/// The only owner of an endpoint handoff. The registry's active endpoint remains the committed +/// endpoint until the target commits, while this object owns the uncommitted lifecycle lane. +#[derive(Debug)] +pub(crate) struct PendingEndpointActivation { + pub(super) source: EndpointLease, + pub(super) source_available: bool, + pub(super) target: EndpointLease, + pub(super) focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + pub(super) host_focused: bool, + pub(super) resize: crate::protocol::ClientMessage, + pub(super) phase: ActivationPhase, + pub(super) deadline: Instant, + pub(super) epoch: u64, + pub(super) next_focus_serial: u64, + pub(super) rollback_error: Option<String>, + /// A different endpoint was selected while this source-off-first transaction was in flight. + /// Keep only the latest intent until source restoration commits. + pub(super) successor: Option<EndpointActivationIntent>, +} diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs new file mode 100644 index 00000000..569d53f3 --- /dev/null +++ b/src/client/endpoint/activation/protocol.rs @@ -0,0 +1,219 @@ +use super::super::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; +use super::model::{ActivationEvidence, EndpointLease}; + +pub(super) fn focus_result_matches( + focus: Option<&crate::client::shell::ClientEndpointFocusTarget>, + result: &crate::api::schema::ResponseResult, +) -> bool { + match (focus, result) { + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)), + crate::api::schema::ResponseResult::PaneInfo { pane }, + ) => pane.focused && &pane.pane_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)), + crate::api::schema::ResponseResult::WorkspaceInfo { workspace }, + ) => workspace.focused && &workspace.workspace_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(expected)), + crate::api::schema::ResponseResult::TabInfo { tab }, + ) => tab.focused && &tab.tab_id == expected, + _ => false, + } +} + +pub(super) fn endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoints: &EndpointRegistry, + endpoint_id: &ClientEndpointId, +) -> Result<EndpointLease, String> { + let connection = endpoints + .connection(endpoint_id) + .ok_or_else(|| "endpoint connection is unavailable".to_owned())?; + let (boot_id, minimum_revision) = shell + .endpoint_snapshot_identity(endpoint_id, connection.generation) + .ok_or_else(|| "endpoint metadata is not ready for this connection".to_owned())?; + Ok(EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: connection.generation, + boot_id: boot_id.to_owned(), + minimum_revision, + }) +} + +pub(super) fn disconnected_endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoint_id: &ClientEndpointId, +) -> EndpointLease { + EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: 0, + boot_id: shell + .endpoint_boot_id(endpoint_id) + .unwrap_or_default() + .to_owned(), + minimum_revision: 0, + } +} + +pub(super) fn endpoint_matches( + lease: &EndpointLease, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, +) -> bool { + lease.endpoint_id == *endpoint_id && lease.generation == generation && lease.boot_id == boot_id +} + +pub(super) fn coherent_completion_surface( + shell: &crate::client::shell::ClientShellState, + lease: &EndpointLease, + evidence: &ActivationEvidence, + acknowledgement_revision: Option<u64>, + geometry: crate::protocol::ClientSurfaceSize, +) -> Result<crate::protocol::PaneSurfaceFrame, String> { + let acknowledgement_revision = acknowledgement_revision.ok_or_else(|| { + "endpoint activation completed without a surface acknowledgement".to_owned() + })?; + let surface = evidence + .surface + .clone() + .ok_or_else(|| "endpoint activation completed without a surface".to_owned())?; + if surface.projection_revision < acknowledgement_revision + || !surface_matches_geometry(&surface, geometry) + { + return Err("endpoint activation lost its acknowledged surface evidence".into()); + } + if !shell.endpoint_snapshot_matches( + &lease.endpoint_id, + lease.generation, + &lease.boot_id, + surface.projection_revision, + ) { + return Err("endpoint activation lost its coherent snapshot/surface pair".into()); + } + Ok(surface) +} + +pub(super) fn resize_geometry( + message: &crate::protocol::ClientMessage, +) -> Option<crate::protocol::ClientSurfaceSize> { + match message { + crate::protocol::ClientMessage::ClientShellResize { surface_size, .. } => { + Some(*surface_size) + } + _ => None, + } +} + +pub(super) fn surface_matches_geometry( + surface: &crate::protocol::PaneSurfaceFrame, + geometry: crate::protocol::ClientSurfaceSize, +) -> bool { + surface.frame.width == geometry.cols && surface.frame.height == geometry.rows +} + +pub(super) fn send_surface_activation( + endpoints: &mut EndpointRegistry, + target: &EndpointLease, + request_id: String, + resize: &crate::protocol::ClientMessage, + focused: bool, +) -> Result<(), String> { + if endpoints.send_to(&target.endpoint_id, resize) != EndpointSendOutcome::Sent { + return Err("endpoint resize could not be sent".into()); + } + let request = surface_interest_request(&target.boot_id, request_id, true) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint activation could not be sent".into()); + } + // Inactive endpoints reject focus events. Activate first, then establish the host baseline + // on the same ordered transport before navigation or presentation can commit. + if endpoints.send_to( + &target.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("endpoint focus baseline could not be sent".into()); + } + Ok(()) +} + +pub(super) fn decode_endpoint_response( + request_id: &str, + data: &[u8], +) -> Result<crate::api::schema::ResponseResult, crate::client::shell::ClientShellEndpointError> { + crate::client::endpoint_commands::parse_response(request_id, data) +} + +pub(super) fn surface_set_revision( + result: &crate::api::schema::ResponseResult, + expected_active: bool, +) -> Result<u64, String> { + match result { + crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + } if *active == expected_active => Ok(*projection_revision), + _ => Err("surface activation returned an invalid acknowledgement".into()), + } +} + +pub(super) fn focus_request( + boot_id: &str, + request_id: String, + focus: &crate::client::shell::ClientEndpointFocusTarget, +) -> std::io::Result<crate::protocol::ClientMessage> { + let method = match focus { + crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: workspace_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: tab_id.clone(), + }) + } + }; + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method, + }, + ) +} + +pub(super) fn surface_interest_request( + boot_id: &str, + request_id: String, + active: bool, +) -> std::io::Result<crate::protocol::ClientMessage> { + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method: crate::api::schema::Method::ClientShellSurfaceSet( + crate::api::schema::ClientShellSurfaceSetParams { active }, + ), + }, + ) +} + +fn endpoint_request( + boot_id: &str, + request: crate::api::schema::Request, +) -> std::io::Result<crate::protocol::ClientMessage> { + Ok(crate::protocol::ClientMessage::ClientShellEndpointRequest { + boot_id: boot_id.to_owned(), + request: serde_json::to_string(&request) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + }) +} diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs new file mode 100644 index 00000000..b66cb98b --- /dev/null +++ b/src/client/endpoint/activation_tests.rs @@ -0,0 +1,1305 @@ +use super::*; + +fn endpoint() -> ClientEndpointId { + ClientEndpointId::Ssh( + super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ) +} + +fn lease(id: ClientEndpointId, generation: u64, boot: &str) -> EndpointLease { + EndpointLease { + endpoint_id: id, + generation, + boot_id: boot.into(), + minimum_revision: 0, + } +} + +#[derive(Clone)] +struct FakeTransport { + sent: std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>, + fail_after_write: bool, +} + +impl super::super::EndpointTransport for FakeTransport { + fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + if self.fail_after_write { + Err(std::io::Error::other("simulated observed write failure")) + } else { + Ok(()) + } + } +} + +fn negotiation() -> super::super::EndpointNegotiation { + super::super::EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + ], + ) +} + +fn test_snapshot(boot_id: &str, revision: u64) -> crate::protocol::ClientShellSnapshot { + crate::protocol::ClientShellSnapshot { + boot_id: boot_id.into(), + revision, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + } +} + +type SentMessages = std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>; +type TestFixture = ( + crate::client::ClientShellState, + EndpointRegistry, + SentMessages, + SentMessages, +); + +fn shell_and_registry() -> TestFixture { + shell_and_registry_with_source_failure(false) +} + +fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> TestFixture { + let mut shell = crate::client::ClientShellState::new( + crate::client::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + let profile = super::super::SavedSshEndpoint { + id: super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Remote".into(), + target: "dev@example.com".into(), + session: "main".into(), + enabled: true, + }; + let target = ClientEndpointId::Ssh(profile.id.clone()); + shell.set_endpoint_catalog(&[profile]); + shell.set_snapshot(Box::new(test_snapshot("local-boot", 1))); + shell.set_endpoint_status(&target, ClientEndpointStatus::Online); + shell.set_endpoint_snapshot(&target, Box::new(test_snapshot("remote-boot", 1))); + + let local_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let remote_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + fail_after_write: source_fail_after_write, + }, + 1, + negotiation(), + ); + endpoints.insert( + target, + FakeTransport { + sent: remote_sent.clone(), + fail_after_write: false, + }, + 7, + negotiation(), + false, + ); + (shell, endpoints, local_sent, remote_sent) +} + +fn surface_success(id: &str, active: bool, projection_revision: u64) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + }, + }) + .unwrap() +} + +fn workspace_focus_success(id: &str, workspace_id: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::WorkspaceInfo { + workspace: crate::api::schema::WorkspaceInfo { + workspace_id: workspace_id.into(), + number: 1, + label: workspace_id.into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "tab".into(), + agent_status: crate::api::schema::AgentStatus::Unknown, + tokens: Default::default(), + worktree: None, + }, + }, + }) + .unwrap() +} + +fn failure(id: &str, message: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::ErrorResponse { + id: id.into(), + error: crate::api::schema::ErrorBody { + code: "surface_rejected".into(), + message: message.into(), + }, + }) + .unwrap() +} + +fn surface_set_active(message: &crate::protocol::ClientMessage) -> Option<bool> { + let crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } = message else { + return None; + }; + let request: crate::api::schema::Request = serde_json::from_str(request).ok()?; + match request.method { + crate::api::schema::Method::ClientShellSurfaceSet(params) => Some(params.active), + _ => None, + } +} + +fn resize() -> crate::protocol::ClientMessage { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: false, + } +} + +fn surface(boot_id: &str, revision: u64, pane: &str) -> crate::protocol::PaneSurfaceFrame { + crate::protocol::PaneSurfaceFrame { + boot_id: boot_id.into(), + projection_revision: revision, + surface_revision: revision, + frame: crate::protocol::FrameData { + cells: Vec::new(), + width: 80, + height: 24, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![crate::protocol::PaneSurfacePane { + pane_id: pane.into(), + content_revision: revision, + rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + inner_rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } +} + +fn machine() -> PendingEndpointActivation { + PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ActivatingTarget { + request_id: "client-shell-surface:3:on".into(), + acknowledged_revision: Some(1), + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: true, + evidence: ActivationEvidence::default(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 3, + next_focus_serial: 0, + rollback_error: None, + successor: None, + } +} + +#[test] +fn source_off_request_is_distinct_and_precedes_target_on_phase() { + let activation = PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ReleasingSource { + request_id: "client-shell-surface:9:off".into(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 9, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:9:off" + )); + assert!(!activation.accepts_response( + &endpoint(), + 7, + "remote-boot", + "client-shell-surface:9:on" + )); +} + +#[test] +fn active_source_requires_metadata_from_its_current_connection_generation() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 99, + Box::new(test_snapshot("stale-local-boot", 1)), + ); + + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 26, + Instant::now(), + ); + + assert!( + matches!(result, Err(ActivationBeginError::Preflight(message)) if message.contains("this connection")) + ); + assert!(local_sent.lock().unwrap().is_empty()); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(endpoints.active_surface_available()); +} + +#[test] +fn observed_begin_write_failure_returns_recoverable_partial_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = + shell_and_registry_with_source_failure(true); + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 10, + Instant::now(), + ); + + let ActivationBeginError::Partial { + mut activation, + error, + } = (match result { + Err(error) => error, + Ok(_) => panic!("an observed source write must return partial lifecycle state"), + }) + else { + panic!("an observed source write must return partial lifecycle state"); + }; + assert!(error.contains("focus revoke")); + assert_eq!( + local_sent.lock().unwrap().first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(matches!( + activation.rollback(&mut endpoints, error, false), + ActivationRollback::Unavailable(_) + )); +} + +#[test] +fn source_release_is_sent_and_acknowledged_before_target_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 11, + Instant::now(), + ) + .unwrap(); + let local = local_sent.lock().unwrap(); + assert_eq!( + local.first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }), + "source focus is revoked before source-off" + ); + assert_eq!( + local + .as_slice() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false], + "the source is released first" + ); + drop(local); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!( + !endpoints.active_surface_available(), + "pane input is blocked while frozen" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:11:off", + &surface_success("client-shell-surface:11:off", false, 1), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let remote = remote_sent.lock().unwrap(); + assert!(matches!( + remote[0], + crate::protocol::ClientMessage::ClientShellResize { .. } + )); + assert_eq!( + remote.get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); + assert_eq!(remote.get(1).and_then(surface_set_active), Some(true)); +} + +#[test] +fn activation_requires_an_exact_snapshot_surface_revision_pair() { + let mut activation = machine(); + let target = endpoint(); + let snapshot = crate::protocol::ClientShellSnapshot { + boot_id: "remote-boot".into(), + revision: 2, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + }; + assert_eq!( + activation.receive_snapshot(&target, 7, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn typed_target_ack_sets_a_floor_for_same_boot_activation_evidence() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 16, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:16:off", + &surface_success("client-shell-surface:16:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.receive_response( + &target, + 7, + "client-shell-surface:16:on", + &surface_success("client-shell-surface:16:on", true, 4), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 3)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 3, "pane")), + SurfaceActivationProgress::Pending, + "a delayed same-boot surface below the acknowledgement floor is not evidence" + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 4)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 4, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn stale_generation_and_boot_are_not_activation_evidence() { + let mut activation = machine(); + assert_eq!( + activation.receive_surface(&endpoint(), 6, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("old-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn stale_response_boot_is_not_consumed() { + let mut activation = machine(); + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + assert_eq!( + activation.receive_response_for_boot( + &endpoint(), + 7, + "old-boot", + "client-shell-surface:3:on", + &surface_success("client-shell-surface:3:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn same_target_retarget_is_latest_wins() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "old".into(), + )), + resize(), + 12, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:12:off", + &surface_success("client-shell-surface:12:off", false, 1), + &mut endpoints, + ); + let old_focus = remote_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if surface_set_active(message).is_none() => + { + Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ) + } + _ => None, + }) + .unwrap(); + let sent_before_retarget = remote_sent.lock().unwrap().len(); + activation + .retarget( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "new".into(), + )), + &mut endpoints, + ) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().len(), sent_before_retarget); + assert!(activation.accepts_response(&target, 7, "remote-boot", &old_focus)); + assert_eq!( + activation.receive_response( + &target, + 7, + &old_focus, + &workspace_focus_success(&old_focus, "old"), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let latest_focus = remote_sent + .lock() + .unwrap() + .last() + .and_then(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } => Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ), + _ => None, + }) + .unwrap(); + assert_ne!(latest_focus, old_focus); + assert!(activation.accepts_response(&target, 7, "remote-boot", &latest_focus)); +} + +#[test] +fn latest_host_focus_is_replayed_to_the_eventual_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 25, + Instant::now(), + ) + .unwrap(); + activation.update_host_focus(false, &mut endpoints).unwrap(); + assert!(remote_sent.lock().unwrap().is_empty()); + + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:25:off", + &surface_success("client-shell-surface:25:off", false, 1), + &mut endpoints, + ); + assert_eq!( + remote_sent.lock().unwrap().get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + + activation.update_host_focus(true, &mut endpoints).unwrap(); + assert_eq!( + remote_sent.lock().unwrap().last(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); +} + +#[test] +fn host_focus_change_restarts_an_issued_presentation_effects_fence() { + let (_shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = machine(); + activation.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease(endpoint(), 7, "remote-boot"), + token: "old-token".into(), + ready: false, + completion: Box::new(ActivationCompletion::Activated), + }; + + activation.update_host_focus(false, &mut endpoints).unwrap(); + + assert!(matches!( + activation.phase, + ActivationPhase::SynchronizingPresentation { .. } + )); + assert_eq!( + activation.receive_presentation_effects_ready(&endpoint(), 7, "old-token"), + SurfaceActivationProgress::Stale + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); +} + +#[test] +fn source_release_rejection_restores_the_source_coherently() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 13, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:13:off", + &failure("client-shell-surface:13:off", "source rejected release"), + &mut endpoints, + ), + SurfaceActivationProgress::Rejected { + message: "source rejected release".into(), + source_release_rejected: true, + } + ); + assert_eq!( + activation.rollback(&mut endpoints, "source rejected release".into(), true), + ActivationRollback::Pending + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn source_release_timeout_starts_an_acknowledged_source_restore() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 14, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.rollback(&mut endpoints, "source release timed out".into(), false), + ActivationRollback::Pending + ); + let sent = local_sent.lock().unwrap(); + assert_eq!( + sent.iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:14:rollback-source-on" + )); +} + +#[test] +fn resize_invalidates_already_recorded_surface_evidence() { + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let mut activation = machine(); + assert_eq!( + activation.receive_snapshot(&endpoint(), 7, &test_snapshot("remote-boot", 1)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Ready + ); + let resize = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation.update_resize(resize, &mut endpoints).unwrap(); + assert_eq!(activation.progress(), SurfaceActivationProgress::Pending); +} + +#[test] +fn resize_during_activation_reaches_the_pending_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 15, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:15:off", + &surface_success("client-shell-surface:15:off", false, 1), + &mut endpoints, + ); + let resized = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation + .update_resize(resized.clone(), &mut endpoints) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().last(), Some(&resized)); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending, + "a surface for the prior geometry cannot commit" + ); +} + +#[test] +fn rapid_a_to_b_to_a_restores_source_before_a_fresh_latest_epoch() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 20, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:off", + &surface_success("client-shell-surface:20:off", false, 1), + &mut endpoints, + ); + + // The latest A-qualified target replaces B while B may already have accepted target-on. + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true, false], + "B is released before A can be restored" + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Stale, + "delayed B activation evidence cannot satisfy A restoration" + ); + let _ = activation.receive_response( + &endpoint(), + 7, + "client-shell-surface:20:rollback-target-off", + &surface_success("client-shell-surface:20:rollback-target-off", false, 1), + &mut endpoints, + ); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true], + "source restoration is acknowledged rather than racing target ownership" + ); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:rollback-source-on", + &surface_success("client-shell-surface:20:rollback-source-on", true, 2), + &mut endpoints, + ); + let local_snapshot = test_snapshot("local-boot", 2); + shell.set_snapshot(Box::new(local_snapshot.clone())); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &local_snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + endpoint: ClientEndpointId::Local, + .. + }) + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:presentation-sync", + &surface_success("client-shell-surface:20:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "20:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::RestoredSource { + successor: Some(EndpointActivationIntent { + endpoint_id: ClientEndpointId::Local, + .. + }), + .. + }) + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + + // Runtime queues this successor with force=true, so even source==target receives a new + // activation epoch only after restoration committed. + let _fresh_epoch = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + resize(), + 21, + Instant::now(), + ) + .unwrap(); + assert!(local_sent.lock().unwrap().iter().any(|message| { + matches!( + message, + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if serde_json::from_str::<crate::api::schema::Request>(request) + .is_ok_and(|request| request.id == "client-shell-surface:21:on") + ) + })); +} + +#[test] +fn disconnected_committed_source_does_not_block_switching_to_a_live_endpoint() { + let (mut shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let disconnected = endpoint(); + endpoints.set_surface_active(&ClientEndpointId::Local, false); + endpoints.set_surface_active(&disconnected, true); + assert!(endpoints.set_active(&disconnected)); + assert!(shell.activate_endpoint_projection(&disconnected)); + endpoints.disconnect(&disconnected); + + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + resize(), + 22, + Instant::now(), + ) + .unwrap(); + assert_eq!(activation.source_command_lane(), None); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true], + "there is no unavailable source release to await" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:on", + &surface_success("client-shell-surface:22:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let snapshot = test_snapshot("local-boot", 2); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(snapshot.clone()), + ); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + previous, + endpoint: ClientEndpointId::Local, + }) if previous == disconnected + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:presentation-sync", + &surface_success("client-shell-surface:22:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "22:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::Activated) + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert_ne!(endpoints.active_id(), &disconnected); +} + +#[test] +fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 23, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:23:off", + &surface_success("client-shell-surface:23:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert!(!activation.can_retarget(&target)); + assert_eq!( + activation.supersede( + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + activation.successor, + Some(EndpointActivationIntent { + endpoint_id: target, + target: Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + }) + ); +} + +#[test] +fn unacknowledged_target_release_closes_target_before_restoring_source() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 24, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:24:off", + &surface_success("client-shell-surface:24:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.rollback(&mut endpoints, "target activation timed out".into(), false), + ActivationRollback::Pending + ); + assert_eq!( + activation.rollback(&mut endpoints, "target release timed out".into(), false), + ActivationRollback::Pending + ); + assert!(endpoints.connection(&target).is_none()); + let failures = endpoints.take_failures(); + assert_eq!( + failures.len(), + 1, + "rollback revocation must reach the reconnect owner" + ); + assert_eq!(failures[0].endpoint_id, target); + assert_eq!(failures[0].generation, 7); + assert_eq!(failures[0].kind, std::io::ErrorKind::TimedOut); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn target_loss_at_activation_deadline_restores_source_before_timeout() { + let (shell, mut endpoints, local_sent, _) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 30, + Instant::now(), + ) + .unwrap(); + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:30:off", + &surface_success("client-shell-surface:30:off", false, 1), + &mut endpoints, + ); + let now = Instant::now(); + activation.deadline = now; + assert!(activation.expired(now)); + endpoints.fail(&target, std::io::ErrorKind::UnexpectedEof.into()); + // Match the client timer: apply transport failures before checking phase expiry. + for failure in endpoints.take_failures() { + assert_eq!( + activation.endpoint_disconnected(&mut endpoints, &failure.endpoint_id, failure.message), + ActivationRollback::Pending + ); + } + assert!(matches!( + activation.phase, + ActivationPhase::RestoringSource { .. } + )); + assert!(!activation.expired(now)); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn losing_local_during_handoff_does_not_revoke_the_healthy_target() { + for source_released in [false, true] { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 29, + Instant::now(), + ) + .unwrap(); + if source_released { + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:29:off", + &surface_success("client-shell-surface:29:off", false, 1), + &mut endpoints, + ); + } + if !source_released { + activation.deadline = Instant::now() - Duration::from_millis(1); + } + endpoints.fail( + &ClientEndpointId::Local, + std::io::ErrorKind::BrokenPipe.into(), + ); + assert_eq!( + activation.endpoint_disconnected( + &mut endpoints, + &ClientEndpointId::Local, + "Local stopped".into() + ), + ActivationRollback::Pending + ); + assert!(!activation.source_available); + assert!( + !activation.expired(Instant::now()), + "starting the healthy target must get a fresh deadline" + ); + assert!(matches!( + activation.phase, + ActivationPhase::ActivatingTarget { .. } + )); + assert!(endpoints.connection(&target).is_some()); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); + } +} + +#[test] +fn resize_message_preserves_the_latest_surface_dimensions() { + assert_eq!( + resize_geometry(&resize()), + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }) + ); +} diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs new file mode 100644 index 00000000..23f26387 --- /dev/null +++ b/src/client/endpoint/catalog.rs @@ -0,0 +1,578 @@ +use std::collections::HashSet; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use super::ProfileId; + +const CATALOG_VERSION: u32 = 1; +const SELECTION_VERSION: u32 = 1; +const MAX_CATALOG_BYTES: u64 = 64 * 1024; +const MAX_PROFILES: usize = 64; +const MAX_LABEL_BYTES: usize = 128; +const MAX_TARGET_BYTES: usize = 1024; +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SavedSshEndpoint { + pub(crate) id: ProfileId, + pub(crate) label: String, + pub(crate) target: String, + pub(crate) session: String, + pub(crate) enabled: bool, +} + +impl SavedSshEndpoint { + pub(crate) fn new( + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<Self, String> { + let profile = Self { + id: ProfileId::generate(), + label: label.into(), + target: target.into(), + session: session.into(), + enabled: true, + }; + profile.validate()?; + Ok(profile) + } + + fn validate(&self) -> Result<(), String> { + ProfileId::parse(self.id.to_string())?; + let label = self.label.trim(); + if label.is_empty() { + return Err("SSH endpoint label cannot be empty".into()); + } + if label.len() > MAX_LABEL_BYTES || label.chars().any(char::is_control) { + return Err(format!( + "SSH endpoint label must be at most {MAX_LABEL_BYTES} bytes and contain no control characters" + )); + } + if self.target.len() > MAX_TARGET_BYTES || self.target.chars().any(char::is_control) { + return Err(format!( + "SSH target must be at most {MAX_TARGET_BYTES} bytes and contain no control characters" + )); + } + crate::remote::validate_remote_target(&self.target).map(|_| ())?; + let authority = self.target.strip_prefix("ssh://").unwrap_or(&self.target); + if authority + .rsplit_once('@') + .is_some_and(|(userinfo, _)| userinfo.contains(':')) + { + return Err("SSH target must not contain a password".into()); + } + crate::session::validate_name(&self.session)?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EndpointCatalog { + version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) selected_profile: Option<ProfileId>, + #[serde(default)] + pub(crate) ssh: Vec<SavedSshEndpoint>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EndpointSelection { + version: u32, + selected_profile: Option<ProfileId>, +} + +impl Default for EndpointCatalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + selected_profile: None, + ssh: Vec::new(), + } + } +} + +impl EndpointCatalog { + pub(crate) fn load() -> Result<Self, String> { + Self::load_from_paths(&catalog_path(), &selection_path()) + } + + pub(crate) fn load_profiles() -> Result<Vec<SavedSshEndpoint>, 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<Self, String> { + let mut catalog = Self::load_from_path(catalog_path)?; + match load_selection_from_path(selection_path) { + Ok(Some(selection)) => { + let valid = selection.selected_profile.as_ref().is_none_or(|selected| { + catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }); + if valid { + catalog.selected_profile = selection.selected_profile; + } else { + tracing::warn!( + path = %selection_path.display(), + "saved endpoint selection is absent or disabled; using Local" + ); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + %error, + path = %selection_path.display(), + "saved endpoint selection is unavailable; using Local" + ); + } + } + Ok(catalog) + } + + pub(crate) fn store_profiles(&self) -> Result<(), String> { + self.store_to_path(&catalog_path()) + } + + pub(crate) fn store_selection(&self) -> Result<(), String> { + self.store_selection_to_path(&selection_path()) + } + + fn store_selection_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: self.selected_profile.clone(), + }) + .map_err(|error| format!("failed to encode endpoint selection: {error}"))?; + store_private_json(path, &content, "endpoint selection") + } + + pub(crate) fn add_ssh( + &mut self, + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<ProfileId, String> { + if self.ssh.len() >= MAX_PROFILES { + return Err(format!("at most {MAX_PROFILES} SSH endpoints can be saved")); + } + let profile = SavedSshEndpoint::new(label, target, session)?; + let id = profile.id.clone(); + self.ssh.push(profile); + Ok(id) + } + + pub(crate) fn rename_ssh( + &mut self, + id: &ProfileId, + label: impl Into<String>, + ) -> Result<bool, String> { + let Some(index) = self.ssh.iter().position(|profile| &profile.id == id) else { + return Ok(false); + }; + let mut renamed = self.ssh[index].clone(); + renamed.label = label.into(); + renamed.validate()?; + self.ssh[index] = renamed; + Ok(true) + } + + pub(crate) fn remove_ssh(&mut self, id: &ProfileId) -> bool { + let previous_len = self.ssh.len(); + self.ssh.retain(|profile| &profile.id != id); + if self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + self.ssh.len() != previous_len + } + + pub(crate) fn select_local(&mut self) { + self.selected_profile = None; + } + + pub(crate) fn select_endpoint(&mut self, endpoint_id: &super::ClientEndpointId) -> bool { + match endpoint_id { + super::ClientEndpointId::Local => { + self.select_local(); + true + } + super::ClientEndpointId::Ssh(profile_id) => self.select_ssh(profile_id), + } + } + + pub(crate) fn select_ssh(&mut self, id: &ProfileId) -> bool { + if !self + .ssh + .iter() + .any(|profile| &profile.id == id && profile.enabled) + { + return false; + } + self.selected_profile = Some(id.clone()); + true + } + + pub(crate) fn has_enabled_ssh(&self) -> bool { + self.ssh.iter().any(|profile| profile.enabled) + } + + pub(crate) fn contains_enabled_target_session(&self, target: &str, session: &str) -> bool { + self.ssh.iter().any(|profile| { + profile.enabled && profile.target == target && profile.session == session + }) + } + + pub(crate) fn set_enabled(&mut self, id: &ProfileId, enabled: bool) -> bool { + let Some(profile) = self.ssh.iter_mut().find(|profile| &profile.id == id) else { + return false; + }; + profile.enabled = enabled; + if !enabled && self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + true + } + + fn validate(&self) -> Result<(), String> { + if self.version != CATALOG_VERSION { + return Err(format!( + "unsupported endpoint catalog version {}; expected {CATALOG_VERSION}", + self.version + )); + } + if self.ssh.len() > MAX_PROFILES { + return Err(format!( + "endpoint catalog contains more than {MAX_PROFILES} SSH profiles" + )); + } + let mut ids = HashSet::new(); + for profile in &self.ssh { + profile.validate()?; + if !ids.insert(profile.id.clone()) { + return Err(format!("duplicate endpoint profile id {}", profile.id)); + } + } + if self.selected_profile.as_ref().is_some_and(|selected| { + !self + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + return Err("selected SSH endpoint is absent or disabled in the catalog".into()); + } + Ok(()) + } + + fn load_from_path(path: &Path) -> Result<Self, String> { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(error) => { + return Err(format!( + "failed to open endpoint catalog {}: {error}", + path.display() + )) + } + }; + let metadata = file + .metadata() + .map_err(|error| format!("failed to inspect endpoint catalog: {error}"))?; + if metadata.len() > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let mut content = String::new(); + file.take(MAX_CATALOG_BYTES + 1) + .read_to_string(&mut content) + .map_err(|error| format!("failed to read endpoint catalog: {error}"))?; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let catalog: Self = serde_json::from_str(&content) + .map_err(|error| format!("stored endpoint catalog is invalid: {error}"))?; + catalog.validate()?; + Ok(catalog) + } + + fn store_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(self) + .map_err(|error| format!("failed to encode endpoint catalog: {error}"))?; + store_private_json(path, &content, "endpoint catalog") + } +} + +fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, String> { + let content = match std::fs::read(path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("failed to read endpoint selection: {error}")), + }; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint selection exceeds the storage limit".into()); + } + let selection: EndpointSelection = serde_json::from_slice(&content) + .map_err(|error| format!("stored endpoint selection is invalid: {error}"))?; + if selection.version != SELECTION_VERSION { + return Err(format!( + "unsupported endpoint selection version {}; expected {SELECTION_VERSION}", + selection.version + )); + } + Ok(Some(selection)) +} + +fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> { + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err(format!("{description} exceeds the storage limit")); + } + let parent = path + .parent() + .ok_or_else(|| format!("invalid {description} path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {description} directory: {error}"))?; + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "refusing to replace {description} through a non-file path" + )); + } + } + + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!(".endpoints-{}-{sequence}.tmp", std::process::id())); + let mut temp = crate::platform::create_private_state_file(&temp_path) + .map_err(|error| format!("failed to create {description}: {error}"))?; + if let Err(error) = temp.write_all(content).and_then(|()| temp.sync_all()) { + drop(temp); + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to write {description}: {error}")); + } + drop(temp); + if let Err(error) = crate::platform::replace_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to activate {description}: {error}")); + } + crate::platform::sync_parent_directory(parent) + .map_err(|error| format!("failed to persist {description} directory: {error}")) +} + +pub(crate) fn catalog_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoints.json") +} + +fn selection_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoint-selection.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(name: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "herdr-endpoint-catalog-{}-{name}", + std::process::id() + )) + .join("endpoints.json") + } + + #[test] + fn catalog_roundtrip_persists_profiles_without_secret_fields() { + let path = path("roundtrip"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog + .add_ssh("Build", "ssh://dev@build.example:2222", "agents") + .unwrap(); + assert!(catalog.select_ssh(&id)); + catalog.store_to_path(&path).unwrap(); + + let encoded = std::fs::read_to_string(&path).unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("private_key")); + assert!(!encoded.contains("control_socket")); + let loaded = EndpointCatalog::load_from_path(&path).unwrap(); + assert_eq!(loaded, catalog); + assert_eq!(loaded.ssh[0].id, id); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn duplicate_target_and_session_profiles_keep_distinct_opaque_ids() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "build", "default").unwrap(); + let second = catalog.add_ssh("Two", "build", "default").unwrap(); + assert_ne!(first, second); + } + + #[test] + fn catalog_rejects_passwords_embedded_in_ssh_targets() { + let mut catalog = EndpointCatalog::default(); + assert!(catalog + .add_ssh("Build", "ssh://dev:secret@build.example", "default") + .unwrap_err() + .contains("must not contain a password")); + assert!(catalog + .add_ssh("Build", "dev:secret@build.example", "default") + .is_err()); + assert!(catalog + .add_ssh("Build", "ssh://dev@[::1]:2222", "default") + .is_ok()); + } + + #[test] + fn interactive_bootstrap_matches_only_enabled_target_and_session() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + assert!(catalog.contains_enabled_target_session("build", "agents")); + assert!(!catalog.contains_enabled_target_session("build", "default")); + assert!(catalog.set_enabled(&id, false)); + assert!(!catalog.contains_enabled_target_session("build", "agents")); + } + + #[test] + fn rename_changes_only_the_machine_label() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Old", "build", "agents").unwrap(); + let original = catalog.ssh[0].clone(); + + assert!(catalog.rename_ssh(&id, "New").unwrap()); + assert_eq!(catalog.ssh[0].label, "New"); + assert_eq!(catalog.ssh[0].id, original.id); + assert_eq!(catalog.ssh[0].target, original.target); + assert_eq!(catalog.ssh[0].session, original.session); + assert!(catalog.rename_ssh(&id, "\n").is_err()); + assert_eq!(catalog.ssh[0].label, "New"); + } + + #[test] + fn removal_and_disable_return_selection_to_local() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "one", "default").unwrap(); + assert!(catalog.select_ssh(&first)); + assert!(catalog.set_enabled(&first, false)); + assert_eq!(catalog.selected_profile, None); + + assert!(catalog.set_enabled(&first, true)); + assert!(catalog.select_ssh(&first)); + assert!(catalog.remove_ssh(&first)); + assert_eq!(catalog.selected_profile, None); + } + + #[test] + fn catalog_rejects_unknown_fields_instead_of_retaining_possible_secrets() { + let path = path("unknown-field"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{ + "version": 1, + "ssh": [{ + "id": "0123456789abcdef0123456789abcdef", + "label": "Build", + "target": "build", + "session": "default", + "enabled": true, + "password": "must-not-be-accepted" + }] + }"#, + ) + .unwrap(); + assert!(EndpointCatalog::load_from_path(&path) + .unwrap_err() + .contains("unknown field")); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn storing_selection_does_not_rewrite_profile_membership() { + let catalog_path = path("separate-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let profiles_before = std::fs::read(&catalog_path).unwrap(); + + assert!(catalog.select_ssh(&id)); + catalog.store_selection_to_path(&selection_path).unwrap(); + + assert_eq!(std::fs::read(&catalog_path).unwrap(), profiles_before); + assert_eq!( + load_selection_from_path(&selection_path) + .unwrap() + .unwrap() + .selected_profile, + Some(id) + ); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn malformed_selection_does_not_discard_saved_profiles() { + let catalog_path = path("malformed-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + std::fs::write(&selection_path, b"not json").unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh.len(), 1); + assert_eq!(loaded.ssh[0].id, id); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn absent_selected_profile_falls_back_without_discarding_catalog() { + let catalog_path = path("absent-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let saved = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let missing = ProfileId::parse("fedcba9876543210fedcba9876543210").unwrap(); + store_private_json( + &selection_path, + &serde_json::to_vec(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: Some(missing), + }) + .unwrap(), + "endpoint selection", + ) + .unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh[0].id, saved); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn invalid_or_missing_selected_profile_is_rejected() { + let catalog = EndpointCatalog { + selected_profile: Some(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()), + ..EndpointCatalog::default() + }; + assert!(catalog.validate().is_err()); + } +} diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs new file mode 100644 index 00000000..7cf1159f --- /dev/null +++ b/src/client/endpoint/control.rs @@ -0,0 +1,63 @@ +use super::ClientEndpointId; + +pub(crate) enum EndpointControlMessage { + HealthPong, + Snapshot(Box<crate::protocol::ClientShellSnapshot>), + Ignored, +} + +pub(crate) fn decode_endpoint_control( + kind: &str, + data: &str, +) -> Result<EndpointControlMessage, String> { + if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { + return Ok(EndpointControlMessage::HealthPong); + } + if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { + let snapshot = serde_json::from_str(data) + .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; + return Ok(EndpointControlMessage::Snapshot(Box::new(snapshot))); + } + if kind.starts_with("shell.snapshot.") { + return Err(format!( + "unsupported mandatory endpoint snapshot codec {kind:?}" + )); + } + Ok(EndpointControlMessage::Ignored) +} + +pub(crate) fn protocol_failure_is_fatal(endpoint_id: &ClientEndpointId) -> bool { + endpoint_id.is_local() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + #[test] + fn unknown_optional_controls_are_ignored() { + assert!(matches!( + decode_endpoint_control("future.optional", "not json").unwrap(), + EndpointControlMessage::Ignored + )); + } + + #[test] + fn unknown_snapshot_codecs_are_rejected() { + assert_eq!( + decode_endpoint_control("shell.snapshot.v2", "{}") + .err() + .as_deref(), + Some("unsupported mandatory endpoint snapshot codec \"shell.snapshot.v2\"") + ); + } + + #[test] + fn only_local_protocol_failures_end_the_client() { + let remote = + ClientEndpointId::Ssh(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()); + assert!(protocol_failure_is_fatal(&ClientEndpointId::Local)); + assert!(!protocol_failure_is_fatal(&remote)); + } +} diff --git a/src/client/endpoint/health.rs b/src/client/endpoint/health.rs new file mode 100644 index 00000000..87c08952 --- /dev/null +++ b/src/client/endpoint/health.rs @@ -0,0 +1,99 @@ +use std::time::{Duration, Instant}; + +pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +pub(super) const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HealthAction { + None, + Ping, + Expired, +} + +pub(super) struct EndpointHealth { + connected_at: Instant, + last_received: Instant, + ping_sent_at: Option<Instant>, + ready: bool, +} + +impl EndpointHealth { + pub(super) fn new(now: Instant) -> Self { + Self { + connected_at: now, + last_received: now, + ping_sent_at: None, + ready: false, + } + } + + pub(super) fn received(&mut self, now: Instant) { + self.last_received = now; + self.ping_sent_at = None; + } + + pub(super) fn ready(&mut self) { + self.ready = true; + } + + pub(super) fn action(&self, now: Instant) -> HealthAction { + let initial_snapshot_expired = + !self.ready && now.saturating_duration_since(self.connected_at) >= HEARTBEAT_TIMEOUT; + let probe_expired = self + .ping_sent_at + .is_some_and(|sent_at| now.saturating_duration_since(sent_at) >= HEARTBEAT_TIMEOUT); + if initial_snapshot_expired || probe_expired { + HealthAction::Expired + } else if self.ping_sent_at.is_none() + && now.saturating_duration_since(self.last_received) >= HEARTBEAT_INTERVAL + { + HealthAction::Ping + } else { + HealthAction::None + } + } + + pub(super) fn ping_sent(&mut self, now: Instant) { + self.ping_sent_at = Some(now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quiet_connection_is_probed_then_expires_without_a_reply() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + assert_eq!(health.action(now), HealthAction::None); + assert_eq!(health.action(now + HEARTBEAT_INTERVAL), HealthAction::Ping); + health.ping_sent(now + HEARTBEAT_INTERVAL); + assert_eq!( + health.action(now + HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } + + #[test] + fn any_incoming_message_satisfies_an_outstanding_probe() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ready(); + health.ping_sent(now); + health.received(now + HEARTBEAT_TIMEOUT - Duration::from_millis(1)); + assert_eq!(health.action(now + HEARTBEAT_TIMEOUT), HealthAction::None); + } + + #[test] + fn heartbeats_do_not_hide_a_missing_initial_snapshot() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ping_sent(now + HEARTBEAT_INTERVAL); + health.received(now + HEARTBEAT_INTERVAL + Duration::from_secs(1)); + assert_eq!( + health.action(now + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } +} diff --git a/src/client/endpoint/message_policy.rs b/src/client/endpoint/message_policy.rs new file mode 100644 index 00000000..5e84325a --- /dev/null +++ b/src/client/endpoint/message_policy.rs @@ -0,0 +1,124 @@ +use crate::protocol::ServerMessage; + +/// Effects that are meaningful only for the endpoint currently holding the host presentation +/// lease. During a frozen handoff they are intentionally dropped and the committed target asks +/// for one bounded replay, rather than allowing target graphics or modes onto the source frame. +pub(crate) fn is_presentation_effect(message: &ServerMessage) -> bool { + matches!( + message, + ServerMessage::MouseCapture { .. } + | ServerMessage::ClientShellKeyboardReportAll { .. } + | ServerMessage::WindowTitle { .. } + | ServerMessage::Graphics { .. } + | ServerMessage::GraphicsFile { .. } + | ServerMessage::GraphicsTransmissionRetired { .. } + | ServerMessage::TerminalBell { .. } + ) +} + +pub(crate) fn accepts_endpoint_message( + endpoint_active: bool, + activation_message: bool, + command_response: bool, + message: &ServerMessage, +) -> bool { + endpoint_active + || matches!( + message, + ServerMessage::EndpointControl { .. } + | ServerMessage::SemanticNotification(_) + | ServerMessage::ServerShutdown { .. } + | ServerMessage::ClientShellSnapshot(_) + | ServerMessage::Welcome { .. } + ) + || (activation_message + && matches!( + message, + ServerMessage::PaneSurface(_) + | ServerMessage::ClientShellEndpointResponseChunk { .. } + )) + || (command_response + && matches!( + message, + ServerMessage::ClientShellEndpointResponseChunk { .. } + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_endpoints_deliver_metadata_but_not_presentation_effects() { + assert!(accepts_endpoint_message( + false, + false, + false, + &ServerMessage::EndpointControl { + kind: "snapshot".into(), + data: "{}".into(), + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::Clipboard { + data: "text".into() + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::WindowTitle { + title: Some("remote".into()) + } + )); + } + + #[test] + fn frozen_handoffs_drop_target_effects_until_the_post_commit_resync() { + assert!(is_presentation_effect(&ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + })); + assert!(is_presentation_effect(&ServerMessage::GraphicsFile { + path: "asset".into(), + expected_len: 1, + image_id: 1, + transfer_id: 1, + leading: Vec::new(), + control: "a=t".into(), + surface_asset: None, + })); + assert!(!is_presentation_effect(&ServerMessage::EndpointControl { + kind: "shell.snapshot.v1".into(), + data: "{}".into(), + })); + } + + #[test] + fn pending_activation_accepts_only_its_surface_lane() { + assert!(accepts_endpoint_message( + false, + true, + false, + &ServerMessage::ClientShellEndpointResponseChunk { + boot_id: "boot".into(), + request_id: "surface".into(), + final_chunk: true, + data: Vec::new(), + } + )); + assert!(!accepts_endpoint_message( + false, + true, + false, + &ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + } + )); + } +} diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs new file mode 100644 index 00000000..437f39f3 --- /dev/null +++ b/src/client/endpoint/registry.rs @@ -0,0 +1,629 @@ +use std::collections::{HashMap, HashSet}; +use std::io; +use std::time::Instant; + +use super::health::{EndpointHealth, HealthAction}; +use super::ClientEndpointId; +use crate::protocol::ClientMessage; + +pub(crate) trait EndpointTransport: Send { + fn send(&mut self, message: &ClientMessage) -> io::Result<()>; + + fn disconnect(&mut self) {} + + fn flush(&mut self, _deadline: Instant) -> io::Result<()> { + Ok(()) + } + + fn take_error(&mut self) -> Option<io::Error> { + None + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EndpointNegotiation { + methods: HashSet<String>, + capabilities: HashSet<String>, +} + +impl EndpointNegotiation { + pub(crate) fn new(methods: Vec<String>, capabilities: Vec<String>) -> Self { + Self { + methods: methods.into_iter().collect(), + capabilities: capabilities.into_iter().collect(), + } + } + + pub(crate) fn methods(&self) -> Vec<String> { + self.methods.iter().cloned().collect() + } + + pub(crate) fn supports_method(&self, method: &str) -> bool { + self.methods.contains(method) + } + + pub(crate) fn supports_capability(&self, capability: &str) -> bool { + self.capabilities.contains(capability) + } + + pub(crate) fn supports_surface_interest(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY) + && self.supports_capability( + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + ) + && self.supports_method("client_shell.surface.set") + } + + pub(crate) fn supports_health_check(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY) + } +} + +pub(crate) struct EndpointConnection { + transport: Box<dyn EndpointTransport>, + pub(crate) generation: u64, + pub(crate) surface_active: bool, + pub(crate) negotiation: EndpointNegotiation, + health: Option<EndpointHealth>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointTransportFailure { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) generation: u64, + pub(crate) kind: io::ErrorKind, + pub(crate) message: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EndpointSendOutcome { + Sent, + NotSent, +} + +pub(crate) struct EndpointRegistry { + active: ClientEndpointId, + input_enabled: bool, + connections: HashMap<ClientEndpointId, EndpointConnection>, + failures: Vec<EndpointTransportFailure>, +} + +impl EndpointRegistry { + pub(crate) fn empty() -> Self { + Self { + active: ClientEndpointId::Local, + input_enabled: false, + connections: HashMap::new(), + failures: Vec::new(), + } + } + + pub(crate) fn new( + local: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + ) -> Self { + let mut registry = Self::empty(); + registry.input_enabled = true; + registry.insert( + ClientEndpointId::Local, + local, + generation, + negotiation, + true, + ); + registry + } + + pub(crate) fn active_id(&self) -> &ClientEndpointId { + &self.active + } + + pub(crate) fn active_surface_available(&self) -> bool { + self.input_enabled + && self + .connections + .get(&self.active) + .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; + } + + pub(crate) fn unfreeze_input(&mut self) { + self.input_enabled = true; + } + + pub(crate) fn connection(&self, endpoint_id: &ClientEndpointId) -> Option<&EndpointConnection> { + self.connections.get(endpoint_id) + } + + pub(crate) fn insert( + &mut self, + endpoint_id: ClientEndpointId, + transport: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + surface_active: bool, + ) { + let health = (!endpoint_id.is_local() && negotiation.supports_health_check()) + .then(|| EndpointHealth::new(Instant::now())); + if let Some(mut previous) = self.connections.insert( + endpoint_id, + EndpointConnection { + transport: Box::new(transport), + generation, + surface_active, + negotiation, + health, + }, + ) { + previous.transport.disconnect(); + } + } + + pub(crate) fn accepts(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + self.connections + .get(endpoint_id) + .is_some_and(|connection| connection.generation == generation) + } + + pub(crate) fn received( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.received(now); + } + } + + pub(crate) fn mark_ready(&mut self, endpoint_id: &ClientEndpointId, generation: u64) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.ready(); + } + } + + pub(crate) fn tick_health(&mut self, now: Instant) { + let actions = self + .connections + .iter() + .filter_map(|(endpoint_id, connection)| { + connection + .health + .as_ref() + .map(|health| (endpoint_id.clone(), health.action(now))) + }) + .filter(|(_, action)| *action != HealthAction::None) + .collect::<Vec<_>>(); + for (endpoint_id, action) in actions { + match action { + HealthAction::None => {} + HealthAction::Ping => { + let ping = ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::HEALTH_PING_KIND.into(), + data: String::new(), + }; + if self.send_to(&endpoint_id, &ping) == EndpointSendOutcome::Sent { + if let Some(health) = self + .connections + .get_mut(&endpoint_id) + .and_then(|connection| connection.health.as_mut()) + { + health.ping_sent(now); + } + } + } + HealthAction::Expired => self.record_failure( + endpoint_id, + io::Error::new(io::ErrorKind::TimedOut, "endpoint health check timed out"), + ), + } + } + } + + pub(crate) fn set_active(&mut self, endpoint_id: &ClientEndpointId) -> bool { + if !self + .connections + .get(endpoint_id) + .is_some_and(|connection| connection.surface_active) + { + return false; + } + self.active = endpoint_id.clone(); + true + } + + pub(crate) fn set_surface_active( + &mut self, + endpoint_id: &ClientEndpointId, + active: bool, + ) -> bool { + let Some(connection) = self.connections.get_mut(endpoint_id) else { + return false; + }; + let changed = connection.surface_active != active; + connection.surface_active = active; + changed + } + + pub(crate) fn send(&mut self, message: &ClientMessage) -> EndpointSendOutcome { + let endpoint_id = self.active.clone(); + self.send_to(&endpoint_id, message) + } + + pub(crate) fn send_to( + &mut self, + endpoint_id: &ClientEndpointId, + message: &ClientMessage, + ) -> EndpointSendOutcome { + let result = self + .connections + .get_mut(endpoint_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "endpoint is unavailable")) + .and_then(|connection| connection.transport.send(message)); + match result { + Ok(()) => EndpointSendOutcome::Sent, + Err(error) => { + self.record_failure(endpoint_id.clone(), error); + EndpointSendOutcome::NotSent + } + } + } + + 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(); + } + } + + pub(crate) fn fail(&mut self, endpoint_id: &ClientEndpointId, error: io::Error) { + self.record_failure(endpoint_id.clone(), error); + } + + pub(crate) fn take_failures(&mut self) -> Vec<EndpointTransportFailure> { + let errors = self + .connections + .iter_mut() + .filter_map(|(id, connection)| { + connection + .transport + .take_error() + .map(|error| (id.clone(), error)) + }) + .collect::<Vec<_>>(); + for (endpoint_id, error) in errors { + self.record_failure(endpoint_id, error); + } + std::mem::take(&mut self.failures) + } + + fn record_failure(&mut self, endpoint_id: ClientEndpointId, error: io::Error) { + let Some(generation) = self + .connections + .get(&endpoint_id) + .map(|connection| connection.generation) + else { + return; + }; + let failure = EndpointTransportFailure { + endpoint_id: endpoint_id.clone(), + generation, + kind: error.kind(), + message: error.to_string(), + }; + if let Some(mut connection) = self.connections.remove(&endpoint_id) { + connection.transport.disconnect(); + } + if let Some(existing) = self + .failures + .iter_mut() + .find(|existing| existing.endpoint_id == endpoint_id) + { + *existing = failure; + } else { + self.failures.push(failure); + } + } +} + +impl Drop for EndpointRegistry { + fn drop(&mut self) { + let deadline = Instant::now() + std::time::Duration::from_millis(250); + for connection in self.connections.values_mut() { + let _ = connection.transport.send(&ClientMessage::Detach); + } + for connection in self.connections.values_mut() { + let _ = connection.transport.flush(deadline); + connection.transport.disconnect(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + struct FakeTransport { + sent: Arc<Mutex<Vec<ClientMessage>>>, + error: Option<io::ErrorKind>, + } + + impl EndpointTransport for FakeTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if let Some(kind) = self.error { + return Err(io::Error::new(kind, "fake transport failure")); + } + self.sent.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + fn negotiation() -> EndpointNegotiation { + EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), + ], + ) + } + + fn profile() -> crate::client::endpoint::ProfileId { + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + fn endpoint_failures_do_not_remove_other_connections() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: Some(io::ErrorKind::BrokenPipe), + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::NotSent + ); + assert!(registry.connection(&ssh_id).is_none()); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert_eq!(registry.take_failures()[0].endpoint_id, ssh_id); + + assert!(registry.set_active(&ClientEndpointId::Local)); + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::Sent + ); + assert_eq!(local_sent.lock().unwrap().len(), 1); + } + + #[test] + fn reconnecting_active_identity_does_not_count_as_an_active_surface() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + assert!(registry.active_surface_available()); + registry.disconnect(&ssh_id); + registry.insert( + ssh_id, + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 3, + negotiation(), + false, + ); + assert!(!registry.active_surface_available()); + } + + #[test] + fn recovered_local_uses_transport_failure_not_remote_health_probes() { + let mut registry = EndpointRegistry::empty(); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ClientEndpointId::Local, + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + registry.tick_health(Instant::now() + std::time::Duration::from_secs(300)); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert!(sent.lock().unwrap().is_empty()); + assert!(registry.take_failures().is_empty()); + } + + #[test] + fn negotiated_remote_health_probe_expires_the_connection() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.tick_health(now + super::super::health::HEARTBEAT_INTERVAL); + assert!(matches!( + sent.lock().unwrap().as_slice(), + [ClientMessage::EndpointControl { kind, .. }] + if kind == crate::protocol::endpoint::HEALTH_PING_KIND + )); + + registry.tick_health( + now + super::super::health::HEARTBEAT_INTERVAL + + super::super::health::HEARTBEAT_TIMEOUT, + ); + assert!(registry.connection(&ssh_id).is_none()); + assert_eq!(registry.take_failures()[0].kind, io::ErrorKind::TimedOut); + } + + #[test] + fn a_ready_endpoint_can_stay_connected_after_the_initial_deadline() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.mark_ready(&ssh_id, 2); + registry.received(&ssh_id, 2, now + super::super::health::HEARTBEAT_INTERVAL); + registry.tick_health(now + super::super::health::HEARTBEAT_TIMEOUT); + assert!(registry.connection(&ssh_id).is_some()); + } + + #[test] + fn negotiated_surface_interest_requires_capability_and_method() { + assert!(negotiation().supports_surface_interest()); + assert!(negotiation().supports_health_check()); + assert!( + !EndpointNegotiation::new(vec!["client_shell.surface.set".into()], Vec::new()) + .supports_surface_interest() + ); + assert!(!EndpointNegotiation::new( + Vec::new(), + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + assert!(!EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + } + + #[test] + fn dropping_registry_detaches_every_connected_endpoint() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let remote_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + registry.insert( + ClientEndpointId::Ssh(profile()), + FakeTransport { + sent: remote_sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + + drop(registry); + + assert!(matches!( + local_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + assert!(matches!( + remote_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + } + + #[test] + fn stale_generations_are_rejected() { + let registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 7, + negotiation(), + ); + assert!(registry.accepts(&ClientEndpointId::Local, 7)); + assert!(!registry.accepts(&ClientEndpointId::Local, 6)); + } +} diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs new file mode 100644 index 00000000..458ce927 --- /dev/null +++ b/src/client/endpoint/supervisor.rs @@ -0,0 +1,492 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, NativeEndpointTransport}; +use crate::protocol::{ClientSurfaceSize, RenderEncoding}; +use interprocess::TryClone as _; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +pub(crate) struct EndpointConnectOptions { + pub(crate) cols: u16, + pub(crate) rows: u16, + pub(crate) cell_width_px: u32, + pub(crate) cell_height_px: u32, + pub(crate) pixel_geometry_exact: bool, + pub(crate) surface_size: ClientSurfaceSize, + pub(crate) endpoint_keybindings: bool, + pub(crate) mouse_capture: bool, +} + +pub(crate) enum EndpointSupervisorEvent { + Status { + endpoint_id: ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + message: String, + }, + Connected { + endpoint_id: ClientEndpointId, + generation: u64, + reader: crate::ipc::LocalStream, + writer: NativeEndpointTransport, + negotiation: EndpointNegotiation, + }, +} + +#[derive(Clone)] +enum ConnectTarget { + Local(PathBuf), + Ssh(super::SavedSshEndpoint), +} + +struct ReconnectState { + target: ConnectTarget, + attempts: u32, + next_attempt: Option<Instant>, + in_flight: bool, + generation: Option<u64>, +} + +impl ReconnectState { + fn new(target: ConnectTarget, now: Instant) -> Self { + Self { + target, + attempts: 0, + next_attempt: Some(now), + in_flight: false, + generation: None, + } + } +} + +pub(crate) struct EndpointSupervisors { + endpoints: HashMap<ClientEndpointId, ReconnectState>, + next_generation: u64, + shutdown: Arc<AtomicBool>, +} + +impl EndpointSupervisors { + pub(crate) fn new(profiles: &[super::SavedSshEndpoint], now: Instant) -> Self { + let endpoints = profiles + .iter() + .filter(|profile| profile.enabled) + .map(|profile| { + ( + ClientEndpointId::Ssh(profile.id.clone()), + ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now), + ) + }) + .collect(); + Self { + endpoints, + next_generation: 2, + shutdown: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn add_local(&mut self, path: PathBuf, generation: Option<u64>, now: Instant) { + let mut state = ReconnectState::new(ConnectTarget::Local(path), now); + state.generation = generation; + if generation.is_some() { + state.next_attempt = None; + } + self.endpoints.insert(ClientEndpointId::Local, state); + } + + pub(crate) fn reconcile_profiles( + &mut self, + profiles: &[super::SavedSshEndpoint], + now: Instant, + ) -> Vec<ClientEndpointId> { + 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, + options: EndpointConnectOptions, + event_tx: &tokio::sync::mpsc::Sender<EndpointSupervisorEvent>, + ) { + for (endpoint_id, state) in &mut self.endpoints { + if state.in_flight || state.next_attempt.is_none_or(|deadline| deadline > now) { + continue; + } + state.in_flight = true; + state.next_attempt = None; + let generation = self.next_generation; + state.generation = Some(generation); + self.next_generation = self.next_generation.saturating_add(1); + let endpoint_id = endpoint_id.clone(); + let target = state.target.clone(); + let event_tx = event_tx.clone(); + let shutdown = self.shutdown.clone(); + tokio::spawn(async move { + if shutdown.load(Ordering::Acquire) { + return; + } + let task_endpoint_id = endpoint_id.clone(); + let result = tokio::task::spawn_blocking(move || { + connect_once(&target, options, endpoint_id, generation) + }) + .await; + let event = match result { + Ok(Ok(event)) => event, + Ok(Err(error)) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: if failure_needs_attention(&error) { + ClientEndpointStatus::Attention + } else { + ClientEndpointStatus::Reconnecting + }, + message: error.to_string(), + }, + Err(error) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: ClientEndpointStatus::Reconnecting, + message: format!("endpoint connection task stopped unexpectedly: {error}"), + }, + }; + if !shutdown.load(Ordering::Acquire) { + let _ = event_tx.send(event).await; + } + }); + } + } + + pub(crate) fn record_status( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + now: Instant, + ) -> bool { + let Some(state) = self.endpoints.get_mut(endpoint_id) else { + return false; + }; + if state.generation != Some(generation) { + return false; + } + state.in_flight = false; + match status { + ClientEndpointStatus::Online => { + state.attempts = 0; + state.next_attempt = None; + } + ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => { + state.next_attempt = None + } + ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => { + state.attempts = state.attempts.saturating_add(1); + state.next_attempt = Some(now + retry_delay(state.attempts)); + } + } + true + } + + pub(crate) fn disconnected( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) -> bool { + self.record_status( + endpoint_id, + generation, + ClientEndpointStatus::Reconnecting, + now, + ) + } +} + +impl Drop for EndpointSupervisors { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + } +} + +fn connect_once( + target: &ConnectTarget, + options: EndpointConnectOptions, + endpoint_id: ClientEndpointId, + generation: u64, +) -> Result<EndpointSupervisorEvent, std::io::Error> { + let (mut stream, lifetime): (_, Box<dyn Send>) = match target { + ConnectTarget::Local(path) => { + let stream = crate::ipc::connect_local_stream(path).map_err(|error| { + // An absent Local socket is transient, unlike a missing SSH install. + if error.kind() == std::io::ErrorKind::NotFound { + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "Local is unavailable; start its server to reconnect", + ) + } else { + error + } + })?; + (stream, Box::new(())) + } + ConnectTarget::Ssh(profile) => { + let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| { + if failure_needs_attention(&error) { + std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session))) + } else { error } + })?; + (connected.stream, Box::new(connected.bridge)) + } + }; + let handshake = super::super::do_handshake( + &mut stream, + options.cols, + options.rows, + options.cell_width_px, + options.cell_height_px, + options.pixel_geometry_exact, + Some(options.surface_size), + options.endpoint_keybindings, + options.mouse_capture, + false, + ) + .map_err(handshake_error)?; + if handshake.encoding != RenderEncoding::SemanticFrame { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "endpoint did not negotiate the semantic client shell", + )); + } + let negotiation = EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + ); + if !negotiation.supports_surface_interest() + || (!endpoint_id.is_local() && !negotiation.supports_health_check()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "this machine needs a server update before it can participate in multi-machine viewing", + )); + } + let reader = stream.try_clone()?; + let writer = NativeEndpointTransport::with_lifetime(stream, lifetime)?; + Ok(EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + }) +} + +fn failure_needs_attention(error: &std::io::Error) -> bool { + crate::remote::saved_ssh_failure_needs_attention(error) +} + +fn handshake_error(error: crate::client::ClientError) -> std::io::Error { + use crate::client::ClientError; + use crate::protocol::FramingError; + match error { + ClientError::ConnectionFailed(error) | ClientError::ConnectionLost(error) => error, + ClientError::HandshakeRejected { error, .. } => { + std::io::Error::new(std::io::ErrorKind::Unsupported, error) + } + ClientError::Protocol(FramingError::Io(error)) => error, + ClientError::Protocol(error) => { + std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()) + } + ClientError::ServerShutdown { reason } => std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server shut down during handshake".into()), + ), + } +} + +fn retry_delay(attempt: u32) -> Duration { + INITIAL_RETRY_DELAY + .saturating_mul( + 1_u32 + .checked_shl(attempt.saturating_sub(1).min(6)) + .unwrap_or(u32::MAX), + ) + .min(MAX_RETRY_DELAY) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + fn profile() -> super::super::SavedSshEndpoint { + super::super::SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + } + } + + #[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); + assert_eq!(retry_delay(100), MAX_RETRY_DELAY); + } + + #[test] + fn handshake_network_failures_retry_but_incompatibility_needs_attention() { + let timeout = handshake_error(crate::client::ClientError::ConnectionLost( + std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"), + )); + assert!(!failure_needs_attention(&timeout)); + let rejected = handshake_error(crate::client::ClientError::HandshakeRejected { + version: 1, + error: "surface capability missing".into(), + }); + assert_eq!(rejected.kind(), std::io::ErrorKind::Unsupported); + assert!(failure_needs_attention(&rejected)); + } + + #[test] + fn healthy_local_only_retries_after_its_connection_fails() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + supervisors.add_local(PathBuf::from("local.sock"), Some(1), now); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(!supervisors.disconnected(&ClientEndpointId::Local, 0, now)); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(supervisors.disconnected(&ClientEndpointId::Local, 1, now)); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Ssh(profile().id)].next_attempt, + Some(now) + ); + } + + #[test] + fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + let endpoint_id = ClientEndpointId::Ssh(profile().id); + supervisors + .endpoints + .get_mut(&endpoint_id) + .unwrap() + .generation = Some(4); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Online, now)); + assert!(!supervisors.disconnected(&endpoint_id, 3, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + assert!(supervisors.disconnected(&endpoint_id, 4, now)); + assert_eq!( + supervisors.endpoints[&endpoint_id].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + } +} diff --git a/src/client/endpoint/writer.rs b/src/client/endpoint/writer.rs new file mode 100644 index 00000000..973ff234 --- /dev/null +++ b/src/client/endpoint/writer.rs @@ -0,0 +1,400 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::traits::Stream as _; + +use super::EndpointTransport; +use crate::ipc::LocalStream; +use crate::protocol::ClientMessage; + +const MAX_QUEUED_MESSAGES: usize = 256; +const MAX_QUEUED_BYTES: usize = 2 * crate::protocol::MAX_GRAPHICS_FRAME_SIZE; +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const IO_POLL_INTERVAL: Duration = Duration::from_millis(2); + +enum WriterCommand { + Frame(Vec<u8>), + Flush(mpsc::Sender<()>), +} + +/// The UI only enqueues complete frames. A worker owns partial writes, cancellation, and the +/// bridge lifetime, so neither socket backpressure nor bridge teardown can block other endpoints. +pub(crate) struct NativeEndpointTransport { + sender: mpsc::SyncSender<WriterCommand>, + queued_bytes: Arc<AtomicUsize>, + stopped: Arc<AtomicBool>, + error: Arc<Mutex<Option<io::Error>>>, +} + +impl NativeEndpointTransport { + pub(crate) fn with_lifetime( + mut stream: LocalStream, + lifetime: impl Send + 'static, + ) -> io::Result<Self> { + stream.set_nonblocking(true)?; + let (sender, receiver) = mpsc::sync_channel::<WriterCommand>(MAX_QUEUED_MESSAGES); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicBool::new(false)); + let error = Arc::new(Mutex::new(None)); + let worker_bytes = queued_bytes.clone(); + let worker_stop = stopped.clone(); + let worker_error = error.clone(); + std::thread::Builder::new() + .name("endpoint-writer".into()) + .spawn(move || { + let _lifetime = lifetime; + while let Ok(command) = receiver.recv() { + if worker_stop.load(Ordering::Acquire) { + break; + } + let frame = match command { + WriterCommand::Frame(frame) => frame, + WriterCommand::Flush(done) => { + let _ = done.send(()); + continue; + } + }; + let result = write_frame(&mut stream, &frame, &worker_stop); + worker_bytes.fetch_sub(frame.len(), Ordering::AcqRel); + if let Err(error) = result { + if let Ok(mut slot) = worker_error.lock() { + *slot = Some(error); + } + worker_stop.store(true, Ordering::Release); + break; + } + } + })?; + Ok(Self { + sender, + queued_bytes, + stopped, + error, + }) + } + + pub(crate) fn stop_handle(&self) -> Arc<AtomicBool> { + self.stopped.clone() + } +} + +impl EndpointTransport for NativeEndpointTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if self.stopped.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "endpoint writer stopped", + )); + } + let mut frame = Vec::new(); + crate::protocol::write_message(&mut frame, message) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + let len = frame.len(); + if self + .queued_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bytes| { + bytes + .checked_add(len) + .filter(|total| *total <= MAX_QUEUED_BYTES) + }) + .is_err() + { + return Err(queue_full()); + } + self.sender + .try_send(WriterCommand::Frame(frame)) + .map_err(|error| { + self.queued_bytes.fetch_sub(len, Ordering::AcqRel); + match error { + mpsc::TrySendError::Full(_) => queue_full(), + mpsc::TrySendError::Disconnected(_) => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + } + }) + } + + fn disconnect(&mut self) { + self.stopped.store(true, Ordering::Release); + } + + fn flush(&mut self, deadline: Instant) -> io::Result<()> { + let (done, completion) = mpsc::channel(); + self.sender + .try_send(WriterCommand::Flush(done)) + .map_err(|_| queue_full())?; + completion + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => { + io::Error::new(io::ErrorKind::TimedOut, "endpoint flush timed out") + } + mpsc::RecvTimeoutError::Disconnected => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + }) + } + + fn take_error(&mut self) -> Option<io::Error> { + self.error.lock().ok()?.take() + } +} + +impl Drop for NativeEndpointTransport { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + } +} + +fn queue_full() -> io::Error { + // A message may already be partially written. Retrying or dropping only this message would + // lose input ordering; revoke the connection and recover through the normal lifecycle. + io::Error::new( + io::ErrorKind::ConnectionAborted, + "endpoint output queue is full", + ) +} + +fn write_frame( + writer: &mut impl io::Write, + mut frame: &[u8], + stopped: &AtomicBool, +) -> io::Result<()> { + let deadline = Instant::now() + WRITE_TIMEOUT; + while !frame.is_empty() && !stopped.load(Ordering::Acquire) { + match writer.write(frame) { + Ok(0) => {} + Ok(written) => { + frame = &frame[written..]; + continue; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "endpoint write timed out", + )); + } + std::thread::sleep(IO_POLL_INTERVAL); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn streams() -> (LocalStream, LocalStream, std::path::PathBuf) { + use interprocess::local_socket::traits::Listener as _; + let path = std::env::temp_dir().join(format!( + "herdr-writer-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = crate::ipc::bind_private_local_listener(&path).unwrap(); + let accepting = std::thread::spawn(move || listener.accept().unwrap()); + let client = crate::ipc::connect_local_stream(&path).unwrap(); + (client, accepting.join().unwrap(), path) + } + + #[test] + fn native_endpoint_writer_delivers_ordered_protocol_frames() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + transport + .send(&ClientMessage::ClientShellFocus { focused: true }) + .unwrap(); + transport + .send(&ClientMessage::ClientShellFocus { focused: false }) + .unwrap(); + let messages = received.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!( + messages, + ( + ClientMessage::ClientShellFocus { focused: true }, + ClientMessage::ClientShellFocus { focused: false } + ) + ); + reader.join().unwrap(); + drop(transport); + let _ = std::fs::remove_file(path); + } + + #[test] + fn registry_exit_flushes_queued_input_and_a_complete_detach() { + let (stream, mut peer, path) = streams(); + let transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let result = (|| { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + Ok::<_, crate::protocol::FramingError>((first, second)) + })(); + done.send(result).unwrap(); + }); + let mut registry = super::super::EndpointRegistry::new( + transport, + 1, + super::super::EndpointNegotiation::default(), + ); + let input = ClientMessage::Input { + data: b"queued input".to_vec(), + }; + assert_eq!( + registry.send(&input), + super::super::EndpointSendOutcome::Sent + ); + drop(registry); + let (first, second) = received + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .expect("clean exit must flush complete frames before closing"); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_flush_drains_large_frames_before_detach() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + let input = ClientMessage::Input { + data: vec![b'x'; 1024 * 1024], + }; + transport.send(&input).unwrap(); + transport.send(&ClientMessage::Detach).unwrap(); + // Large-frame correctness must not depend on the registry's short exit grace period. + transport + .flush(Instant::now() + Duration::from_secs(10)) + .unwrap(); + drop(transport); + let (first, second) = received.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_teardown_does_not_wait_for_a_stalled_peer() { + struct Lifetime(mpsc::Sender<()>); + impl Drop for Lifetime { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let (stream, peer, path) = streams(); + let (done, dropped) = mpsc::channel(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, Lifetime(done)).unwrap(); + transport + .send(&ClientMessage::Input { + data: vec![b'x'; 2 * 1024 * 1024], + }) + .unwrap(); + drop(transport); + dropped + .recv_timeout(Duration::from_secs(3)) + .expect("worker lifetime is released without peer reads"); + drop(peer); + let _ = std::fs::remove_file(path); + } + + #[test] + fn partial_writes_preserve_frame_bytes() { + struct PartialWriter(Vec<u8>); + impl io::Write for PartialWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { + let count = bytes.len().min(3); + self.0.extend_from_slice(&bytes[..count]); + Ok(count) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = PartialWriter(Vec::new()); + write_frame(&mut writer, b"first frame", &AtomicBool::new(false)).unwrap(); + write_frame(&mut writer, b"second frame", &AtomicBool::new(false)).unwrap(); + assert_eq!(writer.0, b"first framesecond frame"); + } + + #[test] + fn a_stalled_write_is_cancellable_without_peer_progress() { + struct StalledWriter(mpsc::Sender<()>); + impl io::Write for StalledWriter { + fn write(&mut self, _: &[u8]) -> io::Result<usize> { + let _ = self.0.send(()); + Err(io::ErrorKind::WouldBlock.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let (attempted, attempts) = mpsc::channel(); + let (done, completion) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = write_frame(&mut StalledWriter(attempted), b"input", &worker_stop); + done.send(result).unwrap(); + }); + attempts.recv_timeout(Duration::from_secs(2)).unwrap(); + stop.store(true, Ordering::Release); + completion + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + worker.join().unwrap(); + } + + #[test] + fn a_full_queue_is_a_connection_failure_not_silent_input_loss() { + let (sender, _receiver) = mpsc::sync_channel(1); + let mut transport = NativeEndpointTransport { + sender, + queued_bytes: Arc::new(AtomicUsize::new(0)), + stopped: Arc::new(AtomicBool::new(false)), + error: Arc::new(Mutex::new(None)), + }; + transport.send(&ClientMessage::Detach).unwrap(); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + transport + .queued_bytes + .store(MAX_QUEUED_BYTES, Ordering::Release); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + } +} diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs index d07b3d42..8e77b796 100644 --- a/src/client/endpoint_commands.rs +++ b/src/client/endpoint_commands.rs @@ -1,127 +1,269 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::io; use std::time::{Duration, Instant}; use crate::api::client::ApiClientError; use crate::api::schema::{Request, ResponseResult}; -use crate::ipc::LocalStream; use crate::protocol::ClientMessage; +use super::endpoint::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; use super::shell::ClientShellEndpointError; const ENDPOINT_COMMAND_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_RETIRED_REQUESTS_PER_ENDPOINT: usize = 128; + +struct QueuedCommand { + generation: u64, + boot_id: String, + request: Box<Request>, +} struct InFlightCommand { + generation: u64, boot_id: String, request_id: String, response: Vec<u8>, sent_at: Instant, - timed_out: bool, } pub(super) struct EndpointCommandResult { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, pub(super) boot_id: String, pub(super) request_id: String, pub(super) result: Result<ResponseResult, ClientShellEndpointError>, } #[derive(Default)] -pub(super) struct EndpointCommands { - queued: VecDeque<(String, Box<Request>)>, +struct EndpointCommandLane { + queued: VecDeque<QueuedCommand>, in_flight: Option<InFlightCommand>, + retired: VecDeque<(u64, String, String)>, +} + +impl EndpointCommandLane { + fn retire(&mut self, request: (u64, String, String)) { + if self.retired.contains(&request) { + return; + } + if self.retired.len() == MAX_RETIRED_REQUESTS_PER_ENDPOINT { + self.retired.pop_front(); + } + self.retired.push_back(request); + } + + fn consume_retired(&mut self, request: &(u64, String, String), final_chunk: bool) -> bool { + let Some(index) = self.retired.iter().position(|retired| retired == request) else { + return false; + }; + if final_chunk { + self.retired.remove(index); + } + true + } +} + +#[derive(Default)] +pub(super) struct EndpointCommands { + lanes: HashMap<ClientEndpointId, EndpointCommandLane>, } impl EndpointCommands { - pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) { - self.queued.push_back((boot_id, request)); + pub(super) fn enqueue( + &mut self, + endpoint_id: ClientEndpointId, + generation: u64, + boot_id: String, + request: Box<Request>, + ) { + self.lanes + .entry(endpoint_id) + .or_default() + .queued + .push_back(QueuedCommand { + generation, + boot_id, + request, + }); } - pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> { - if self.in_flight.is_some() { - return Ok(()); + pub(super) fn send_next( + &mut self, + endpoint_id: &ClientEndpointId, + endpoints: &mut EndpointRegistry, + ) -> Vec<String> { + let lane = self.lanes.entry(endpoint_id.clone()).or_default(); + let mut cancelled = Vec::new(); + if lane.in_flight.is_some() { + return cancelled; } - let Some((boot_id, request)) = self.queued.pop_front() else { - return Ok(()); - }; - let request_id = request.id.clone(); - let request = serde_json::to_string(&request) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - super::write_to_server( - stream, - &ClientMessage::ClientShellEndpointRequest { - boot_id: boot_id.clone(), + while let Some(queued) = lane.queued.pop_front() { + let request_id = queued.request.id.clone(); + if !endpoints.accepts(endpoint_id, queued.generation) { + cancelled.push(request_id); + continue; + } + let request = match serde_json::to_string(&queued.request) { + Ok(request) => request, + Err(error) => { + tracing::warn!(%error, %request_id, "could not encode endpoint request"); + cancelled.push(request_id); + continue; + } + }; + let message = ClientMessage::ClientShellEndpointRequest { + boot_id: queued.boot_id.clone(), request, - }, - )?; - self.in_flight = Some(InFlightCommand { - boot_id, - request_id, - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }); - Ok(()) + }; + if endpoints.send_to(endpoint_id, &message) != EndpointSendOutcome::Sent { + cancelled.push(request_id); + continue; + } + lane.in_flight = Some(InFlightCommand { + generation: queued.generation, + boot_id: queued.boot_id, + request_id, + response: Vec::new(), + sent_at: Instant::now(), + }); + break; + } + cancelled } - pub(super) fn expire(&mut self, now: Instant) -> Option<EndpointCommandResult> { - let command = self.in_flight.as_mut()?; - if command.timed_out - || now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT - { - return None; + pub(super) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + response_generation: u64, + response_boot_id: &str, + response_request_id: &str, + ) -> bool { + self.lanes + .get(endpoint_id) + .and_then(|lane| lane.in_flight.as_ref()) + .is_some_and(|command| { + command.generation == response_generation + && command.boot_id == response_boot_id + && command.request_id == response_request_id + }) + } + + /// Retire the complete source lane at source-off. The in-flight request is tombstoned for a + /// late endpoint-local response; every queued request is cancelled before it can run in a + /// later presentation epoch. Other endpoint lanes are deliberately untouched. + pub(super) fn retire_lane(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = Vec::new(); + if let Some(command) = lane.in_flight.take() { + lane.retire(( + command.generation, + command.boot_id, + command.request_id.clone(), + )); + request_ids.push(command.request_id); } - command.timed_out = true; - Some(EndpointCommandResult { - boot_id: command.boot_id.clone(), - request_id: command.request_id.clone(), - result: Err(ClientShellEndpointError { - code: Some("endpoint_timeout".into()), - message: "this server did not respond to the action".into(), - }), - }) + request_ids.extend(lane.queued.drain(..).map(|command| command.request.id)); + request_ids + } + + pub(super) fn expire(&mut self, now: Instant) -> Vec<EndpointCommandResult> { + self.lanes + .iter_mut() + .filter_map(|(endpoint_id, lane)| { + let command = lane.in_flight.as_ref()?; + if now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT { + return None; + } + let command = lane.in_flight.take().expect("checked in-flight command"); + lane.retire(( + command.generation, + command.boot_id.clone(), + command.request_id.clone(), + )); + Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: command.generation, + boot_id: command.boot_id, + request_id: command.request_id, + result: Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "this server did not respond to the action".into(), + }), + }) + }) + .collect() } pub(super) fn receive_chunk( &mut self, + endpoint_id: &ClientEndpointId, + response_generation: u64, response_boot_id: &str, response_request_id: &str, final_chunk: bool, data: Vec<u8>, ) -> io::Result<Option<EndpointCommandResult>> { - let Some(in_flight) = self.in_flight.as_mut() else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response arrived without an in-flight command", - )); + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Ok(None); }; - if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response correlation did not match the in-flight command", - )); + let retired = ( + response_generation, + response_boot_id.to_owned(), + response_request_id.to_owned(), + ); + if lane.consume_retired(&retired, final_chunk) { + return Ok(None); + } + let Some(in_flight) = lane.in_flight.as_mut() else { + return Ok(None); + }; + if response_generation != in_flight.generation + || response_boot_id != in_flight.boot_id + || response_request_id != in_flight.request_id + { + return Ok(None); } in_flight.response.extend(data); if !final_chunk { return Ok(None); } - let in_flight = self.in_flight.take().expect("checked in-flight command"); - let response = String::from_utf8(in_flight.response) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let result = parse_response(&in_flight.request_id, &response); + let in_flight = lane.in_flight.take().expect("checked in-flight command"); + let result = parse_response(&in_flight.request_id, &in_flight.response); Ok(Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: in_flight.generation, boot_id: in_flight.boot_id, request_id: in_flight.request_id, result, })) } + + /// Disconnecting an endpoint also cancels its shell-pending requests. Connection generation + /// rejection handles any late wire response after the lane itself is removed. + pub(super) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.remove(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = lane + .queued + .into_iter() + .map(|command| command.request.id) + .collect::<Vec<_>>(); + if let Some(command) = lane.in_flight { + request_ids.push(command.request_id); + } + request_ids + } } -fn parse_response( +pub(super) fn parse_response( expected_id: &str, - response: &str, + response: &[u8], ) -> Result<ResponseResult, ClientShellEndpointError> { - let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError { + let value = serde_json::from_slice(response).map_err(|error| ClientShellEndpointError { code: None, message: format!("invalid endpoint response: {error}"), })?; @@ -159,19 +301,35 @@ mod tests { use super::*; use crate::api::schema::{ResponseResult, SuccessResponse}; + fn endpoint() -> ClientEndpointId { + ClientEndpointId::Local + } + fn commands_with_in_flight() -> EndpointCommands { EndpointCommands { - in_flight: Some(InFlightCommand { - boot_id: "boot-a".into(), - request_id: "request-a".into(), - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }), - ..EndpointCommands::default() + lanes: HashMap::from([( + endpoint(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 1, + boot_id: "boot-a".into(), + request_id: "request-a".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + )]), } } + fn has_in_flight(commands: &EndpointCommands) -> bool { + commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.in_flight.is_some()) + } + #[test] fn chunked_response_completion_is_correlated_and_clears_the_lane() { let mut commands = commands_with_in_flight(); @@ -184,6 +342,8 @@ mod tests { assert!(commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", false, @@ -193,6 +353,8 @@ mod tests { .is_none()); let completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", true, @@ -201,10 +363,12 @@ mod tests { .unwrap() .unwrap(); + assert_eq!(completed.endpoint_id, endpoint()); + assert_eq!(completed.generation, 1); assert_eq!(completed.boot_id, "boot-a"); assert_eq!(completed.request_id, "request-a"); assert!(matches!(completed.result, Ok(ResponseResult::Ok {}))); - assert!(commands.in_flight.is_none()); + assert!(!has_in_flight(&commands)); } #[test] @@ -224,6 +388,8 @@ mod tests { for (index, chunk) in response.chunks(128 * 1024).enumerate() { completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", index + 1 == chunk_count, @@ -243,8 +409,10 @@ mod tests { let mut commands = commands_with_in_flight(); let expired = commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .pop() .expect("expired endpoint command"); + assert_eq!(expired.endpoint_id, endpoint()); assert_eq!(expired.boot_id, "boot-a"); assert_eq!(expired.request_id, "request-a"); assert!(matches!( @@ -254,30 +422,184 @@ mod tests { .. }) if code == "endpoint_timeout" )); - assert!(commands.in_flight.is_some()); + assert!(!has_in_flight(&commands)); assert!(commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .is_empty()); + let late_response = serde_json::to_vec(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .expect("late retired response is ignored") .is_none()); + assert!(!has_in_flight(&commands)); + } + + #[test] + fn endpoint_lanes_complete_independently() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 2, + boot_id: "boot-b".into(), + request_id: "request-b".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + ); + let response = serde_json::to_vec(&SuccessResponse { + id: "request-b".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + + let completed = commands + .receive_chunk(&remote, 2, "boot-b", "request-b", true, response) + .unwrap() + .expect("remote response"); + + assert_eq!(completed.endpoint_id, remote); + assert!(has_in_flight(&commands)); + assert!(commands + .lanes + .get(&completed.endpoint_id) + .is_some_and(|lane| lane.in_flight.is_none())); + } + + #[test] + fn retiring_complete_source_lane_cancels_queued_ids_and_keeps_other_lanes() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-source".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + queued: VecDeque::from([QueuedCommand { + generation: 2, + boot_id: "boot-b".into(), + request: Box::new(Request { + id: "request-b".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }]), + ..EndpointCommandLane::default() + }, + ); + + assert_eq!( + commands.retire_lane(&endpoint()), + vec!["request-a", "queued-source"] + ); + assert!(!has_in_flight(&commands)); + assert!(commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.queued.is_empty())); + assert!(commands + .lanes + .get(&remote) + .is_some_and(|lane| !lane.queued.is_empty())); + let late_response = serde_json::to_vec(&SuccessResponse { id: "request-a".into(), result: ResponseResult::Ok {}, }) .unwrap(); assert!(commands - .receive_chunk("boot-a", "request-a", true, late_response) - .expect("late response releases the synchronized lane") - .is_some()); - assert!(commands.in_flight.is_none()); + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .unwrap() + .is_none()); } #[test] - fn response_from_another_boot_is_rejected() { + fn disconnect_returns_every_request_that_must_be_discarded() { let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-a".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + assert_eq!( + commands.disconnect(&endpoint()), + vec!["queued-a", "request-a"] + ); + assert!(!commands.lanes.contains_key(&endpoint())); + } - let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else { - panic!("mismatched boot should fail"); - }; + #[test] + fn retired_request_tombstones_are_bounded() { + let mut lane = EndpointCommandLane::default(); + for serial in 0..MAX_RETIRED_REQUESTS_PER_ENDPOINT + 10 { + lane.retire((1, "boot".into(), format!("request-{serial}"))); + } + assert_eq!(lane.retired.len(), MAX_RETIRED_REQUESTS_PER_ENDPOINT); + assert!(!lane + .retired + .contains(&(1, "boot".into(), "request-0".into()))); + assert!(lane.retired.contains(&( + 1, + "boot".into(), + format!("request-{}", MAX_RETIRED_REQUESTS_PER_ENDPOINT + 9) + ))); + } + + #[test] + fn stale_or_unknown_responses_do_not_damage_the_live_lane() { + let mut commands = commands_with_in_flight(); + let unknown = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(commands + .receive_chunk(&endpoint(), 2, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-b", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&unknown, 1, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(has_in_flight(&commands)); } } diff --git a/src/client/events.rs b/src/client/events.rs new file mode 100644 index 00000000..43f7b8ad --- /dev/null +++ b/src/client/events.rs @@ -0,0 +1,34 @@ +use super::*; + +/// Internal events for the client event loop. +pub(super) enum ClientLoopEvent { + #[cfg(unix)] + StdinInput(Vec<u8>), + #[cfg(unix)] + PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), + #[cfg(unix)] + DirectGraphicsResponse(direct_graphics::Response), + #[cfg(windows)] + StdinEvents(Vec<crate::protocol::ClientInputEvent>), + Resize(u16, u16, u32, u32, bool), + TerminalUnavailable(io::Error), + ServerMessage { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + message: Box<ServerMessage>, + }, + ServerDisconnected { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + }, + EndpointSupervisor(endpoint::EndpointSupervisorEvent), + EndpointCatalog(Result<Vec<endpoint::SavedSshEndpoint>, String>), + ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + /// A superseded handoff deliberately starts a fresh target-on epoch even when source and + /// latest target have the same identity after restoration. + force: bool, + }, + Timer, +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 84dd6643..937f92ac 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -121,6 +121,29 @@ fn set_handshake_recv_timeout( pub(super) struct HandshakeResult { pub(super) encoding: RenderEncoding, pub(super) endpoint_methods: Option<Vec<String>>, + pub(super) endpoint_capabilities: Option<Vec<String>>, +} + +pub(crate) fn probe_endpoint_negotiation( + stream: &mut LocalStream, +) -> io::Result<super::endpoint::EndpointNegotiation> { + 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. @@ -138,6 +161,7 @@ pub(super) fn do_handshake( shell_surface_size: Option<crate::protocol::ClientSurfaceSize>, endpoint_keybindings: bool, mouse_capture: bool, + surface_active: bool, ) -> Result<HandshakeResult, ClientError> { stream .set_nonblocking(false) @@ -157,6 +181,7 @@ pub(super) fn do_handshake( && direct_graphics_profile_allowed(), endpoint_keybindings, mouse_capture, + surface_active, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -181,9 +206,14 @@ pub(super) fn do_handshake( protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + let read_timeout = if endpoint_shell && !surface_active { + REMOTE_HANDSHAKE_READ_TIMEOUT + } else { + handshake_read_timeout() + }; set_handshake_recv_timeout( stream, - Some(handshake_read_timeout()), + Some(read_timeout), "client handshake read timeout unavailable", )?; let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; @@ -238,6 +268,7 @@ pub(super) fn do_handshake( return Ok(HandshakeResult { encoding: RenderEncoding::SemanticFrame, endpoint_methods: Some(welcome.methods), + endpoint_capabilities: Some(welcome.capabilities), }); } @@ -254,6 +285,7 @@ pub(super) fn do_handshake( Ok(HandshakeResult { encoding, endpoint_methods: None, + endpoint_capabilities: None, }) } _ => Err(ClientError::Protocol(protocol::FramingError::Io( diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs new file mode 100644 index 00000000..53ef9f38 --- /dev/null +++ b/src/client/loop_config.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) struct ClientLoopConfig { + pub(super) sound_config: crate::config::SoundConfig, + pub(super) mouse_scroll_lines: usize, + pub(super) redraw_on_focus_gained: bool, + pub(super) host_cursor: crate::config::HostCursorModeConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_fallback: bool, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_keybindings: bool, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) shell_config: Option<shell::ClientShellConfig>, +} diff --git a/src/client/mod.rs b/src/client/mod.rs index ea789431..b815a057 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -13,23 +13,46 @@ //! - Displays sound/toast notifications forwarded from server mod attach; +mod catalog_reload; +mod clipboard_forwarding; mod clipboard_images; +mod config_reload; #[cfg(unix)] mod direct_graphics; +pub(crate) mod endpoint; mod endpoint_commands; mod errors; +mod events; mod frame_output; mod handshake; mod input; +mod loop_config; mod notifications; mod shell; +mod shell_runtime; +mod startup; +mod state; mod terminal_geometry; mod terminal_sessions; mod terminal_setup; mod timer; +mod transport; + +#[cfg(test)] +use clipboard_forwarding::decode_clipboard_payload; +use clipboard_forwarding::forward_clipboard; +#[cfg(test)] +use config_reload::reload_local_client_config; +use config_reload::{apply_reload, init_logging}; +use events::ClientLoopEvent; +use loop_config::ClientLoopConfig; +use shell_runtime::*; +use state::ClientState; +use transport::*; #[cfg(test)] pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use startup::{run_client, run_terminal_attach}; pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; #[cfg(not(windows))] @@ -65,7 +88,9 @@ use attach::direct_attach_pixel_mouse; use attach::AttachEscapeState; #[cfg(unix)] use attach::{write_attach_semantic_action, AttachInputAction}; -use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +use clipboard_images::{ + client_remote_image_paste_key, endpoint_accepts_local_images, write_remote_image_to_server, +}; #[cfg(windows)] use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; #[cfg(unix)] @@ -77,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::{ @@ -103,243 +129,11 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::{ - self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, -}; +use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; #[cfg(test)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -// --------------------------------------------------------------------------- -// Client state -// --------------------------------------------------------------------------- - -struct ClientLoopConfig { - sound_config: crate::config::SoundConfig, - mouse_scroll_lines: usize, - redraw_on_focus_gained: bool, - host_cursor: crate::config::HostCursorModeConfig, - kitty_graphics_enabled: bool, - pixel_geometry_enabled: bool, - pixel_geometry_fallback: bool, - mouse_capture_active: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - shell_config: Option<shell::ClientShellConfig>, -} - -/// State tracking for the thin client. -struct ClientState { - /// Stateful semantic-frame encoder used when the server sends FrameData. - blit_encoder: render_ansi::BlitEncoder, - /// Whether host mouse capture is currently active. - mouse_capture_active: bool, - /// Last mouse-capture demand published by the endpoint. - endpoint_mouse_capture_requested: bool, - /// Last exact-pixel mouse demand published by the endpoint. - endpoint_sgr_pixels_requested: bool, - /// Client-local direct-attach preference, combined with child mouse demand. - direct_mouse_capture_preference: bool, - /// Client-owned shell mouse-capture preference. - shell_mouse_capture_preference: bool, - /// Host keyboard protocol state currently owned for a direct terminal attach. - direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, - /// Focused endpoint pane/popup demand for Kitty report-all input. - pane_keyboard_report_all: bool, - /// Whether the client-owned shell currently enabled host report-all input. - keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last handshake/Resize. - reported_size: (u16, u16), - /// Last exact host cell size used by client-rendered surfaces. - reported_cell_size: (u32, u32), - /// Client-local sound playback config, refreshed on server request. - sound_config: crate::config::SoundConfig, - /// Whether this client may write Kitty graphics bytes to its host terminal. - kitty_graphics_enabled: bool, - /// Whether resize reports inspect host pixel geometry. - pixel_geometry_enabled: bool, - /// Whether the latest host pixel geometry is exact enough for pixel mouse input. - pixel_geometry_exact: bool, - /// One bounded matcher, inactive unless a direct transmission is armed. - #[cfg(unix)] - direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, - /// One server-retired direct transfer to suppress if it was still queued. - #[cfg(unix)] - retired_direct_graphics: Option<(u64, u32)>, - /// ClientShell assets waiting for the host terminal's direct-upload response. - #[cfg(unix)] - pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>, - /// Direct attach prefix escape state. None for ClientShell connections. - attach_escape: Option<AttachEscapeState>, - /// Rows scrolled for one direct-attach wheel notch. - #[cfg(unix)] - mouse_scroll_lines: usize, - /// Local-client shortcut that sends a clipboard image to a remote Herdr session. - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - /// Whether outer focus gain should force a full host-terminal redraw. - redraw_on_focus_gained: bool, - /// Whether the next semantic frame must repaint every cell without clearing the surface. - repaint_pending: bool, - /// Whether this client draws the cursor into frame cells instead of using the host cursor. - draw_host_cursor: bool, - /// Browser opener processes launched by client-owned link activation. - detached_process_children: Vec<std::process::Child>, - /// Experimental client-owned shell state. - shell: Option<shell::ClientShellState>, -} - -impl Drop for ClientState { - fn drop(&mut self) { - if self.attach_escape.is_some() { - let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut self.direct_keyboard_protocol, - 0, - 0, - ); - } - } -} - -impl ClientState { - fn request_repaint(&mut self) { - self.repaint_pending = true; - } - - fn present_graphics(&mut self, graphics: &[u8]) { - if graphics.is_empty() || !self.kitty_graphics_enabled { - return; - } - let mut stdout = io::stdout(); - let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); - let _ = stdout.flush(); - } - - fn present_surface_patch( - &mut self, - patch: shell::ClientComposedSurfacePatch, - ) -> io::Result<bool> { - if self.repaint_pending { - crate::render_prof::event("client_surface_patch.fallback.repaint"); - return Ok(false); - } - let rows = if self.draw_host_cursor { - let Some(rows) = self - .blit_encoder - .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) - else { - crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); - return Ok(false); - }; - rows - } else { - patch.rows - }; - let encode_started = crate::render_prof::timer(); - let Some(encoded) = - self.blit_encoder - .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) - else { - crate::render_prof::event("client_surface_patch.fallback.encode"); - return Ok(false); - }; - crate::render_prof::duration_since("client_surface_patch.encode", encode_started); - let write_started = crate::render_prof::timer(); - let mut stdout = io::stdout(); - stdout.write_all(&encoded.bytes)?; - stdout.flush()?; - crate::render_prof::duration_since("client_surface_patch.write", write_started); - let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); - crate::render_prof::event(if committed { - "client_surface_patch.success" - } else { - "client_surface_patch.fallback.commit" - }); - Ok(committed) - } - - fn present_frame(&mut self, frame_data: FrameData) { - let frame_data = if self.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if self.draw_host_cursor { - self.blit_encoder - .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) - } else { - self.blit_encoder.encode(&frame_data, self.repaint_pending) - }; - let mut stdout = io::stdout(); - let graphics = if self.kitty_graphics_enabled { - frame_data.graphics.as_slice() - } else { - &[] - }; - let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - self.blit_encoder.commit(frame_data, encoded); - self.repaint_pending = false; - } -} - -// --------------------------------------------------------------------------- -// Client event loop -// --------------------------------------------------------------------------- - -/// Internal events for the client event loop. -enum ClientLoopEvent { - /// Raw input bytes from stdin. - #[cfg(unix)] - StdinInput(Vec<u8>), - /// One confirmed SGR pixel report with geometry captured by the reader. - #[cfg(unix)] - PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), - #[cfg(unix)] - DirectGraphicsResponse(direct_graphics::Response), - /// Structured input events from platforms without Unix-style stdin bytes. - #[cfg(windows)] - StdinEvents(Vec<crate::protocol::ClientInputEvent>), - /// Terminal resize detected, including current exact-pixel eligibility. - Resize(u16, u16, u32, u32, bool), - /// The client's host terminal can no longer report a valid grid. - TerminalUnavailable(io::Error), - /// Server message received. - ServerMessage(Box<ServerMessage>), - /// Server reader thread exited (connection lost). - ServerDisconnected, - /// Timer tick. - Timer, -} - -/// Runs the thin client: connects to the server, performs the handshake, -/// and enters the main event loop. -/// -/// This is the entry point called from `main.rs` when running in client mode. -pub fn run_client() -> io::Result<()> { - run_client_with_mode(None, None, "connecting to server") -} - -/// Runs a direct terminal attach client. -#[cfg(unix)] -pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { - run_client_with_mode( - Some((terminal_id, takeover)), - Some(AttachEscapeState::default()), - "attaching to terminal", - ) -} - -/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path. -#[cfg(windows)] -pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { - debug_assert!(!crate::platform::capabilities().direct_terminal_attach); - Err(io::Error::new( - io::ErrorKind::Unsupported, - "direct terminal attach is not supported on Windows yet", - )) -} - fn run_client_with_mode( attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, @@ -373,6 +167,9 @@ fn run_client_with_mode( let kitty_graphics_enabled = loaded_config.config.kitty_graphics_enabled() && client_rendered_shell; let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); + let endpoint_keybindings = shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, @@ -382,6 +179,7 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + endpoint_keybindings, remote_image_paste_key, shell_config, }; @@ -389,14 +187,26 @@ fn run_client_with_mode( crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); - // Try to connect to the server. - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(s) => s, - Err(err) => { - // Server unreachable — show clear error and exit. - let client_err = ClientError::ConnectionFailed(err); - eprintln!("herdr: {client_err}"); - std::process::exit(1); + let endpoint_catalog = if client_rendered_shell && !is_remote_client_process() { + endpoint::EndpointCatalog::load().unwrap_or_else(|error| { + warn!(%error, "saved SSH endpoint catalog is unavailable"); + endpoint::EndpointCatalog::default() + }) + } else { + endpoint::EndpointCatalog::default() + }; + let federated = endpoint_catalog.has_enabled_ssh(); + + let initial_stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => Some(stream), + Err(error) if federated => { + warn!(%error, "Local is unavailable; keeping saved machines available"); + None + } + Err(error) => { + return Err(io::Error::other( + ClientError::ConnectionFailed(error).to_string(), + )); } }; @@ -408,43 +218,56 @@ fn run_client_with_mode( .shell_config .as_ref() .map(|shell| shell.initial_surface_size(cols, rows)); - let endpoint_keybindings = loop_config - .shell_config - .as_ref() - .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - - // Perform handshake while the stream is still in blocking mode. - let handshake = match do_handshake( - &mut stream, - cols, - rows, - cell_width_px, - cell_height_px, - exact_cell_size, - shell_surface_size, - endpoint_keybindings, - loop_config.mouse_capture_active, - ) { - Ok(encoding) => encoding, - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); + // Healthy Local attaches directly; only an actual failure enters background recovery. + let initial = initial_stream + .map(|mut stream| { + let handshake = do_handshake( + &mut stream, + cols, + rows, + cell_width_px, + cell_height_px, + exact_cell_size, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, + true, + ) + .map_err(|error| io::Error::other(error.to_string()))?; + if federated + && !endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.clone().unwrap_or_default(), + handshake.endpoint_capabilities.clone().unwrap_or_default(), + ) + .supports_surface_interest() + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Local needs a server update before it can participate in multi-machine viewing", + )); + } + if let Some((terminal_id, takeover)) = attach_request { + write_to_server( + &mut stream, + &ClientMessage::AttachTerminal { + terminal_id, + takeover, + }, + )?; + } + Ok((stream, handshake)) + }) + .transpose(); + let initial = match initial { + Ok(initial) => initial, + Err(error) if federated => { + warn!(%error, "Local handshake failed; keeping saved machines available"); + None } + Err(error) => return Err(error), }; - if let Some((terminal_id, takeover)) = attach_request { - let attach = ClientMessage::AttachTerminal { - terminal_id, - takeover, - }; - if let Err(err) = write_to_server(&mut stream, &attach) { - eprintln!("herdr: failed to request terminal attach: {err}"); - std::process::exit(1); - } - } - - // Now set up the terminal. This must happen AFTER the handshake succeeds, - // so we don't leave the terminal in raw mode if the server rejects us. + // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); let terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) @@ -483,7 +306,8 @@ fn run_client_with_mode( let result = rt.block_on(async { run_client_loop( - stream, + initial, + endpoint_catalog, cols, rows, cell_width_px, @@ -491,8 +315,6 @@ fn run_client_with_mode( exact_cell_size, should_quit, loop_config, - handshake.encoding, - handshake.endpoint_methods, attach_escape, ) .await @@ -526,187 +348,6 @@ fn run_client_with_mode( Ok(()) } -fn dispatch_client_shell_actions( - actions: Vec<shell::ClientShellAction>, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - write_stream: &mut LocalStream, - detached_process_children: &mut Vec<std::process::Child>, -) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> { - let mut replay_mouse = Vec::new(); - for action in actions { - match action { - shell::ClientShellAction::Endpoint { boot_id, request } => { - endpoint_commands.enqueue(boot_id, request); - } - shell::ClientShellAction::ClipboardWrite(bytes) => { - crate::selection::write_osc52_bytes(&bytes); - } - shell::ClientShellAction::Request(request) => { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - shell::ClientShellAction::OpenSafeWebUrl(url) => { - if crate::app::actions::safe_web_url(&url).is_some() { - match crate::platform::open_url(&url) { - Ok(Some(child)) => detached_process_children.push(child), - Ok(None) => {} - Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), - } - } - } - shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), - shell::ClientShellAction::Keybind(action) => { - debug!( - ?action, - "client shell action awaits its presentation family" - ); - } - } - } - endpoint_commands - .send_next(write_stream) - .map_err(ClientError::ConnectionLost)?; - Ok(replay_mouse) -} - -fn client_shell_resize_message( - shell: &shell::ClientShellState, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - pixel_mouse: bool, -) -> ClientMessage { - ClientMessage::ClientShellResize { - cell_width_px, - cell_height_px, - surface_size: shell.surface_size(cols, rows), - pixel_mouse, - } -} - -fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { - let Some(shell) = state.shell.as_ref() else { - return Ok(()); - }; - let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); - if desired == state.keyboard_report_all_active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) - .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = desired; - Ok(()) -} - -fn apply_client_shell_input_source_changes( - state: &mut ClientState, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) { - let changes = state - .shell - .as_mut() - .map(shell::ClientShellState::take_input_source_changes) - .unwrap_or_default(); - for active in changes { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); - } - } -} - -fn install_client_shell_snapshot( - state: &mut ClientState, - snapshot: Box<crate::protocol::ClientShellSnapshot>, - write_stream: &mut LocalStream, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<(), ClientError> { - let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { - let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.set_snapshot(snapshot); - let graphics_cleanup = shell.take_pending_graphics_cleanup(); - let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - (previous_size != next_size).then(|| { - 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, - ) - }), - graphics_cleanup, - ) - } else { - (None, None, Vec::new()) - }; - apply_client_shell_input_source_changes(state, prefix_input_source); - state.present_graphics(&graphics_cleanup); - if let Some(resize) = resize { - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = composed { - state.present_frame(frame); - } - Ok(()) -} - -fn finish_client_shell_input( - state: &mut ClientState, - outcome: shell::ClientShellInput, - frame: Option<FrameData>, - write_stream: &mut LocalStream, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<bool, ClientError> { - apply_client_shell_input_source_changes(state, prefix_input_source); - if outcome.detach { - let _ = write_to_server(write_stream, &ClientMessage::Detach); - return Ok(true); - } - if outcome.resize { - let shell = state.shell.as_ref().expect("shell mode remains active"); - let resize = 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, - ); - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - #[cfg(not(windows))] - if outcome.query_host_appearance { - query_host_terminal_appearance(); - } - if outcome.query_host_theme { - query_host_terminal_theme(); - } - sync_client_shell_keyboard_report_all(state)?; - let replay = dispatch_client_shell_actions( - outcome.actions, - endpoint_commands, - write_stream, - &mut state.detached_process_children, - )?; - debug_assert!( - replay.is_empty(), - "mouse replay only follows endpoint results" - ); - for request in outcome.requests { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - Ok(false) -} - /// The main client event loop. /// /// Uses a threaded architecture: @@ -715,7 +356,8 @@ fn finish_client_shell_input( /// - server reader thread → reads ServerMessages and sends to main loop /// - main loop: coordinates input, output, and server communication async fn run_client_loop( - stream: LocalStream, + initial: Option<(LocalStream, handshake::HandshakeResult)>, + mut endpoint_catalog: endpoint::EndpointCatalog, cols: u16, rows: u16, initial_cell_width_px: u32, @@ -723,20 +365,20 @@ async fn run_client_loop( initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, - negotiated_encoding: RenderEncoding, - endpoint_methods: Option<Vec<String>>, attach_escape: Option<AttachEscapeState>, ) -> Result<(), ClientError> { #[cfg(windows)] let _ = config.mouse_scroll_lines; let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor); let is_remote_client = is_remote_client_process(); + let local_unavailable = initial.is_none(); let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, endpoint_mouse_capture_requested: false, endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, shell_mouse_capture_preference: config.mouse_capture_active, direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), @@ -760,15 +402,28 @@ async fn run_client_loop( remote_image_paste_key: config.remote_image_paste_key, redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, + presentation_frozen: false, draw_host_cursor, detached_process_children: Vec::new(), shell: config.shell_config.map(shell::ClientShellState::new), }; + 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_methods(endpoint_methods); + shell.set_endpoint_catalog(&endpoint_catalog.ssh); + shell.set_endpoint_methods_for( + &endpoint::ClientEndpointId::Local, + initial + .as_ref() + .and_then(|(_, handshake)| handshake.endpoint_methods.clone()), + ); + if local_unavailable { + shell.set_endpoint_status( + &endpoint::ClientEndpointId::Local, + endpoint::ClientEndpointStatus::Connecting, + ); + } } - debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. // Zero means the host has not reported one. @@ -777,6 +432,8 @@ async fn run_client_loop( // Channel for events from the resize and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); + let (supervisor_tx, mut supervisor_rx) = + tokio::sync::mpsc::channel::<endpoint::EndpointSupervisorEvent>(64); // Keep Windows console draining independent of server-frame backpressure. #[cfg(windows)] let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -833,7 +490,6 @@ async fn run_client_loop( let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); - let kitty_graphics_enabled = state.kitty_graphics_enabled; let pixel_geometry_enabled = state.pixel_geometry_enabled; let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { @@ -851,31 +507,59 @@ async fn run_client_loop( ); }); - // Spawn the server reader thread (blocking reads from the socket). - // Clone the stream's file descriptor so we can read from a blocking stream. - let server_read_quit = should_quit.clone(); - let server_read_tx = event_tx.clone(); - let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?; - std::thread::spawn(move || { - let max_frame_size = if kitty_graphics_enabled { + let mut write_stream = if let Some((stream, handshake)) = initial { + let max_frame_size = if state.kitty_graphics_enabled { MAX_GRAPHICS_FRAME_SIZE } else { - MAX_FRAME_SIZE + crate::protocol::MAX_FRAME_SIZE }; - server_reader_thread( - read_stream, - server_read_tx, - &server_read_quit, + let transport = start_endpoint_transport( + stream, + (), + &event_tx, + endpoint::ClientEndpointId::Local, + 1, max_frame_size, + )?; + let negotiation = endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), ); - }); - - // Use the original stream for writing (blocking is fine since we write - // from the async loop). - let mut write_stream = stream; - write_stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; + let mut registry = endpoint::EndpointRegistry::new(transport, 1, negotiation); + if state.shell.is_some() { + registry.send(&ClientMessage::ClientShellFocus { focused: true }); + } + registry + } else { + endpoint::EndpointRegistry::empty() + }; + let mut supervisors = + endpoint::EndpointSupervisors::new(&endpoint_catalog.ssh, std::time::Instant::now()); + if federated { + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + std::time::Instant::now(), + ); + } + if local_unavailable { + if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(cols, rows)) + { + state.present_frame(frame); + } + } + let mut next_surface_serial = 1_u64; + let mut pending_activation: Option<endpoint::PendingEndpointActivation> = None; + let mut scheduled_activation = None; + let mut pending_catalog: Option<Result<Vec<endpoint::SavedSshEndpoint>, 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). @@ -886,6 +570,111 @@ 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(), + endpoint::EndpointConnectOptions { + cols: state.reported_size.0, + rows: state.reported_size.1, + cell_width_px: state.reported_cell_size.0, + cell_height_px: state.reported_cell_size.1, + pixel_geometry_exact: state.pixel_geometry_exact, + surface_size: shell.surface_size(state.reported_size.0, state.reported_size.1), + endpoint_keybindings: config.endpoint_keybindings, + mouse_capture: state.shell_mouse_capture_preference, + }, + &supervisor_tx, + ); + } let timer_delay = state .shell .as_ref() @@ -893,23 +682,34 @@ async fn run_client_loop( shell.timer_delay(std::time::Instant::now()) }); let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); + let immediate_event = scheduled_activation.take(); #[cfg(windows)] - let event = tokio::select! { - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = stdin_rx.recv(), if stdin_open => match ev { - Some(event) => event, - None => { - stdin_open = false; - ClientLoopEvent::Timer - } - }, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = stdin_rx.recv(), if stdin_open => match ev { + Some(event) => event, + None => { + stdin_open = false; + ClientLoopEvent::Timer + } + }, + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + } }; #[cfg(unix)] - let event = tokio::select! { - biased; - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + } }; let now = std::time::Instant::now(); if let Some(shell) = state.shell.as_mut() { @@ -917,8 +717,14 @@ 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( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { if will_query_host_cell_size { let events = crate::raw_input::parse_raw_input_bytes_sync(&data); @@ -934,7 +740,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -951,7 +757,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_terminal_drop(&data, is_remote_client) + read_image_file_from_terminal_drop(&data, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -976,8 +782,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1048,7 +856,7 @@ async fn run_client_loop( }; if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1064,7 +872,8 @@ async fn run_client_loop( "clipboard image paste trigger received, but local clipboard has no image" ); } - if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { + if let Some(image) = read_image_file_from_terminal_drop(&data, image_bridge_active) + { write_remote_image_to_server( &mut write_stream, crate::protocol::ClientClipboardImageTarget::DirectTerminal, @@ -1080,11 +889,28 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { - let composed = state + let pending_key = state .pending_surface_graphics - .remove(&response.transfer_id) + .keys() + .find(|(_, transfer_id, image_id)| { + *transfer_id == response.transfer_id && *image_id == response.image_id + }) + .cloned(); + let owner = pending_key + .as_ref() + .map(|(endpoint_id, _, _)| endpoint_id.clone()); + let composed = pending_key + .and_then(|key| { + state + .pending_surface_graphics + .remove(&key) + .map(|asset| (key, asset)) + }) .filter(|_| response.success) - .and_then(|asset| { + .and_then(|((endpoint_id, _, _), asset)| { + if write_stream.active_id() != &endpoint_id { + return None; + } let shell = state.shell.as_mut()?; shell .trust_direct_graphics_asset(&asset, response.image_id) @@ -1096,8 +922,8 @@ async fn run_client_loop( image_id: response.image_id, success: response.success, }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if let Some(owner) = owner { + write_stream.send_to(&owner, &message); } if let Some(frame) = composed { state.present_frame(frame); @@ -1120,8 +946,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1159,6 +987,11 @@ async fn run_client_loop( } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { + let image_bridge_active = endpoint_accepts_local_images( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { let image_target = state .shell @@ -1167,7 +1000,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_events( &events, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1184,7 +1017,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_client_events(&events, is_remote_client) + read_image_file_from_client_events(&events, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -1209,8 +1042,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1262,181 +1097,291 @@ async fn run_client_loop( pixel_mouse: pixel_geometry_exact, } }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(msg, &mut write_stream) { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + error, + false, + ); + } + } else if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::ServerMessage(msg) => match *msg { - ServerMessage::ClientShellSnapshot(_) => { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - "server sent an unnegotiated binary endpoint snapshot", - ), - ))); + ClientLoopEvent::EndpointSupervisor(event) => match event { + endpoint::EndpointSupervisorEvent::Status { + endpoint_id, + generation, + status, + message, + } => { + 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 + && shell.endpoint_is_active(&endpoint_id)) + .then(|| format!("{}: {message}", shell.endpoint_label(&endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(&mut state, message); + } else if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } } - ServerMessage::PaneSurface(surface) => { - let composed = if let Some(shell) = &mut state.shell { - shell.set_pane_surface(surface); + endpoint::EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + } => { + if !supervisors.record_status( + &endpoint_id, + generation, + endpoint::ClientEndpointStatus::Online, + now, + ) { + continue; + } + let frame = state.shell.as_mut().and_then(|shell| { + shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods())); shell.compose(state.reported_size.0, state.reported_size.1) - } else { - None - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(frame) = composed { + }); + let reader_quit = writer.stop_handle(); + write_stream.insert( + endpoint_id.clone(), + writer, + generation, + negotiation, + false, + ); + if let Some(frame) = frame { state.present_frame(frame); } + let reader_tx = event_tx.clone(); + std::thread::spawn(move || { + server_reader_thread( + reader, + reader_tx, + &reader_quit, + MAX_GRAPHICS_FRAME_SIZE, + endpoint_id, + generation, + ); + }); } - ServerMessage::PaneSurfacePatch(patch) => { - let patch_started = crate::render_prof::timer(); - let apply_started = crate::render_prof::timer(); - let outcome = state - .shell - .as_mut() - .map(|shell| shell.apply_pane_surface_patch(patch)); - crate::render_prof::duration_since("client_surface_patch.apply", apply_started); - let compose_fallback = match outcome { - Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { - match state.present_surface_patch(patch) { - Ok(presented) => !presented, - Err(error) => { - warn!(%error, "failed to present retained pane surface patch"); - state.request_repaint(); - false + }, + ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force, + } => { + 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, + &mut write_stream, + &mut endpoint_commands, + &mut pending_activation, + &mut next_surface_serial, + endpoint_id, + target, + force, + now, + &event_tx, + )?; + } + ClientLoopEvent::ServerMessage { + endpoint_id, + generation, + message, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; + } + write_stream.received(&endpoint_id, generation, now); + let endpoint_active = write_stream.active_id() == &endpoint_id + && write_stream + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + let activation_message = pending_activation + .as_ref() + .is_some_and(|pending| pending.accepts_endpoint(&endpoint_id, generation)); + let command_response = match message.as_ref() { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + .. + } => endpoint_commands.accepts_response( + &endpoint_id, + generation, + boot_id, + request_id, + ), + _ => false, + }; + if !endpoint::accepts_endpoint_message( + endpoint_active, + activation_message, + command_response, + message.as_ref(), + ) { + continue; + } + // Target presentation effects may arrive as soon as surface.set(true) is + // acknowledged. They cannot be applied while the source frame is frozen; the + // target receives one explicit replay after the coherent commit instead. + if state.presentation_frozen + && activation_message + && endpoint::is_presentation_effect(message.as_ref()) + { + continue; + } + match *message { + ServerMessage::ClientShellSnapshot(_) => { + let message = "server sent an unnegotiated binary endpoint snapshot"; + if federated || !endpoint_id.is_local() { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message.into(), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + ServerMessage::PaneSurface(surface) => { + if activation_message { + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_surface(&endpoint_id, generation, surface) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); } } + continue; } - Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, - Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if compose_fallback { - let composed = state.shell.as_mut().and_then(|shell| { + if !endpoint_active { + continue; + } + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); shell.compose(state.reported_size.0, state.reported_size.1) - }); + } else { + None + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); if let Some(frame) = composed { state.present_frame(frame); } } - crate::render_prof::duration_since("client_surface_patch.total", patch_started); - crate::render_prof::flush_if_due(); - } - ServerMessage::Terminal(frame) => { - if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { - record_received_kitty_graphics(&frame.bytes); + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since( + "client_surface_patch.apply", + apply_started, + ); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since( + "client_surface_patch.total", + patch_started, + ); + crate::render_prof::flush_if_due(); } - let mut stdout = io::stdout(); - let _ = stdout.write_all(&frame.bytes); - let _ = stdout.flush(); - } - ServerMessage::Graphics { bytes } => { - if state.kitty_graphics_enabled { - record_received_kitty_graphics(&bytes); + ServerMessage::Terminal(frame) => { + if state.kitty_graphics_enabled + && contains_kitty_graphics_bytes(&frame.bytes) + { + record_received_kitty_graphics(&frame.bytes); + } let mut stdout = io::stdout(); - let _ = stdout.write_all(&bytes); + let _ = stdout.write_all(&frame.bytes); let _ = stdout.flush(); } - } - ServerMessage::TerminalBell { count } => { - if let Err(err) = - crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) - { - warn!(err = %err, "failed to emit terminal bell"); - } - } - ServerMessage::GraphicsFile { - path, - expected_len, - image_id, - transfer_id, - leading, - control, - surface_asset, - } => { - #[cfg(unix)] - { - if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { - continue; - } - let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { - (Some(shell), Some(asset)) => { - crate::kitty_graphics::surface::host_image_id( - shell.graphics_scope(), - asset, - ) == image_id - } - (None, None) => true, - _ => false, - }; - let valid = state.kitty_graphics_enabled - && surface_asset_valid - && usize::try_from(expected_len).ok().is_some_and(|len| { - crate::pane_graphics_files::validate_direct_source( - std::path::Path::new(&path), - len, - ) - .is_ok() - && direct_graphics::valid_control(&control, image_id, len) - }) - && state - .direct_graphics_response - .lock() - .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); - let sent = if valid { - let mut command = Vec::new(); - crate::kitty_graphics::encode_kitty_regular_file( - &mut command, - &leading, - &control, - &path, - ); + ServerMessage::Graphics { bytes } => { + if state.kitty_graphics_enabled { + record_received_kitty_graphics(&bytes); let mut stdout = io::stdout(); - let written = stdout - .write_all(&command) - .and_then(|()| stdout.flush()) - .is_ok(); - if written { - record_received_kitty_graphics(&command); - } - written - } else { - false - }; - if sent { - if let Some(asset) = surface_asset { - state.pending_surface_graphics.insert(transfer_id, asset); - } - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.start(transfer_id); - } - let started = ClientMessage::GraphicsTransmissionStarted { - transfer_id, - image_id, - }; - if let Err(err) = write_to_server(&mut write_stream, &started) { - return Err(ClientError::ConnectionLost(err)); - } - } else { - state.pending_surface_graphics.remove(&transfer_id); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - if valid { - matcher.retire(transfer_id); - } else { - matcher.cancel(transfer_id); - } - } - let result = ClientMessage::GraphicsTransmissionResult { - transfer_id, - image_id, - success: false, - }; - if let Err(err) = write_to_server(&mut write_stream, &result) { - return Err(ClientError::ConnectionLost(err)); - } + let _ = stdout.write_all(&bytes); + let _ = stdout.flush(); + } + } + ServerMessage::TerminalBell { count } => { + if let Err(err) = + crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) + { + warn!(err = %err, "failed to emit terminal bell"); } } - #[cfg(not(unix))] - let _ = ( + ServerMessage::GraphicsFile { path, expected_len, image_id, @@ -1444,319 +1389,546 @@ async fn run_client_loop( leading, control, surface_asset, - ); - } - ServerMessage::GraphicsTransmissionRetired { - transfer_id, - image_id, - } => { - #[cfg(unix)] - { - state.retired_direct_graphics = Some((transfer_id, image_id)); - state.pending_surface_graphics.remove(&transfer_id); - let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { - shell.retire_direct_graphics_image(image_id); - shell - .compose(state.reported_size.0, state.reported_size.1) - .map(|frame| frame.graphics) - .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) - }); - state.present_graphics(&cleanup); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.retire(transfer_id); + } => { + #[cfg(unix)] + { + if state.retired_direct_graphics.take() + == Some((endpoint_id.clone(), transfer_id, image_id)) + { + continue; + } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; + let valid = state.kitty_graphics_enabled + && surface_asset_valid + && usize::try_from(expected_len).ok().is_some_and(|len| { + crate::pane_graphics_files::validate_direct_source( + std::path::Path::new(&path), + len, + ) + .is_ok() + && direct_graphics::valid_control(&control, image_id, len) + }) + && state + .direct_graphics_response + .lock() + .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); + let sent = if valid { + let mut command = Vec::new(); + crate::kitty_graphics::encode_kitty_regular_file( + &mut command, + &leading, + &control, + &path, + ); + let mut stdout = io::stdout(); + let written = stdout + .write_all(&command) + .and_then(|()| stdout.flush()) + .is_ok(); + if written { + record_received_kitty_graphics(&command); + } + written + } else { + false + }; + if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert( + (endpoint_id.clone(), transfer_id, image_id), + asset, + ); + } + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.start(transfer_id); + } + let started = ClientMessage::GraphicsTransmissionStarted { + transfer_id, + image_id, + }; + if let Err(err) = write_to_server(&mut write_stream, &started) { + return Err(ClientError::ConnectionLost(err)); + } + } else { + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + if valid { + matcher.retire(transfer_id); + } else { + matcher.cancel(transfer_id); + } + } + let result = ClientMessage::GraphicsTransmissionResult { + transfer_id, + image_id, + success: false, + }; + if let Err(err) = write_to_server(&mut write_stream, &result) { + return Err(ClientError::ConnectionLost(err)); + } + } } + #[cfg(not(unix))] + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } - #[cfg(not(unix))] - let _ = (transfer_id, image_id); - } - ServerMessage::ServerShutdown { reason } => { - return Err(ClientError::ServerShutdown { reason }); - } - ServerMessage::Notify { - kind, - message, - body, - } => { - if state.shell.is_none() { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + } => { + #[cfg(unix)] + { + state.retired_direct_graphics = + Some((endpoint_id.clone(), transfer_id, image_id)); + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.retire(transfer_id); + } + } + #[cfg(not(unix))] + let _ = (transfer_id, image_id); } - } - ServerMessage::SemanticNotification(event) => { - if state.shell.is_some() { - let (effects, frame) = { - let shell = state.shell.as_mut().expect("checked shell mode"); - let (effects, repaint) = - shell.receive_notification(event, std::time::Instant::now()); - let frame = repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (effects, frame) - }; - handle_shell_notification_effects(effects, &state.sound_config); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::ServerShutdown { reason } => { + if !federated && endpoint_id.is_local() { + return Err(ClientError::ServerShutdown { reason }); } + write_stream.fail( + &endpoint_id, + io::Error::new( + io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server stopped".into()), + ), + ); } - } - ServerMessage::ClientShellError { message } => { - if let Some(shell) = state.shell.as_mut() { - if shell.receive_endpoint_error(message) { - let frame = shell.compose(state.reported_size.0, state.reported_size.1); + ServerMessage::Notify { + kind, + message, + body, + } => { + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = shell.receive_notification( + &endpoint_id, + event, + std::time::Instant::now(), + ); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); if let Some(frame) = frame { state.present_frame(frame); } } } - } - ServerMessage::ClientShellEndpointResponseChunk { - boot_id, - request_id, - final_chunk, - data, - } => { - let completed = endpoint_commands - .receive_chunk(&boot_id, &request_id, final_chunk, data) - .map_err(ClientError::ConnectionLost)?; - let Some(completed) = completed else { - continue; - }; - let (repaint, actions) = state.shell.as_mut().map_or_else( - || (false, Vec::new()), - |shell| { - shell.handle_endpoint_result( - &completed.boot_id, - &completed.request_id, - completed.result, - ) - }, - ); - if let Some(shell) = state.shell.as_mut() { - shell.reconcile_input_source(); + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = + shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } } - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - let replay_mouse = dispatch_client_shell_actions( - actions, - &mut endpoint_commands, - &mut write_stream, - &mut state.detached_process_children, - )?; - if replay_mouse.is_empty() { - if repaint { - if let Some(frame) = state.shell.as_mut().and_then(|shell| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) { - state.present_frame(frame); + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + if pending_activation.as_ref().is_some_and(|pending| { + pending.accepts_response( + &endpoint_id, + generation, + &boot_id, + &request_id, + ) + }) { + if !final_chunk { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + "endpoint returned a chunked activation acknowledgement".into(), + false, + ); + continue; + } + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_response_for_boot( + &endpoint_id, + generation, + &boot_id, + &request_id, + &data, + &mut write_stream, + ) + }); + match progress { + Some(endpoint::SurfaceActivationProgress::Ready) => { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + Some(endpoint::SurfaceActivationProgress::Rejected { + message, + source_release_rejected, + }) => { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + message, + source_release_rejected, + ); + } + _ => {} } + continue; } - } else { - let (outcome, frame) = { - let shell = state.shell.as_mut().expect("shell endpoint response"); - let mut outcome = shell.replay_mouse_events(replay_mouse); - outcome.repaint |= repaint; - let frame = outcome - .repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (outcome, frame) + if request_id.starts_with("client-shell-surface:") { + continue; + } + let completed = endpoint_commands + .receive_chunk( + &endpoint_id, + generation, + &boot_id, + &request_id, + final_chunk, + data, + ) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; }; - if finish_client_shell_input( + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + if completed.generation == generation + && shell.endpoint_is_active(&completed.endpoint_id) + { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + } else { + ( + shell.cancel_endpoint_request(&completed.request_id), + Vec::new(), + ) + } + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes( &mut state, - outcome, - frame, - &mut write_stream, - &mut endpoint_commands, &mut prefix_input_source, - )? { - return Ok(()); + ); + let (replay_mouse, dispatch_repaint) = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + state.shell.as_mut(), + &mut state.detached_process_children, + &event_tx, + )?; + let repaint = repaint || dispatch_repaint; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &event_tx, + )? { + return Ok(()); + } } } - } - ServerMessage::Clipboard { data } => { - if forward_clipboard(&data) { - let (width, height) = state.reported_size; - let frame = state.shell.as_mut().and_then(|shell| { - shell - .show_copy_feedback(std::time::Instant::now()) - .then(|| shell.compose(width, height)) - .flatten() - }); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::Clipboard { data } => { + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } } + let _ = io::stdout().flush(); } - let _ = io::stdout().flush(); - } - ServerMessage::WindowTitle { title } => { - let _ = crate::terminal_effects::write_window_title( - &mut io::stdout(), - title.as_deref(), - ); - } - ServerMessage::ReloadSoundConfig => { - let previous_mouse_capture = state.shell_mouse_capture_preference; - let mut mouse_capture = previous_mouse_capture; - reload_local_client_config( - &mut state.sound_config, - &mut state.redraw_on_focus_gained, - &mut state.draw_host_cursor, - &mut state.remote_image_paste_key, - &mut mouse_capture, - ); - state.shell_mouse_capture_preference = mouse_capture; - state.direct_mouse_capture_preference = - state.attach_escape.is_some() && mouse_capture; - if state.shell.is_some() && previous_mouse_capture != mouse_capture { - write_to_server( - &mut write_stream, - &ClientMessage::ClientShellMouseCapture { - enabled: mouse_capture, - }, - ) - .map_err(ClientError::ConnectionLost)?; - } - if state.attach_escape.is_some() { - let enabled = effective_mouse_capture( - state.endpoint_mouse_capture_requested, - state.direct_mouse_capture_preference, + ServerMessage::WindowTitle { title } => { + let _ = crate::terminal_effects::write_window_title( + &mut io::stdout(), + title.as_deref(), ); - let sgr_pixels = effective_sgr_pixel_mouse( + } + ServerMessage::ReloadSoundConfig => apply_reload( + &mut state, + &mut write_stream, + &mut pending_activation, + &host_mouse_capture_active, + &host_sgr_pixels_active, + &mut prefix_input_source, + )?, + ServerMessage::MouseCapture { + enabled, + sgr_pixels, + } => { + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = effective_sgr_pixel_mouse( enabled, - state.endpoint_sgr_pixels_requested, + sgr_pixels, state.pixel_geometry_exact, ); - if enabled != state.mouse_capture_active - || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) - { - set_mouse_capture(enabled, sgr_pixels) + let mouse_mode_changed = enabled != state.mouse_capture_active + || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); + if mouse_mode_changed { + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } + set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } } state.mouse_capture_active = enabled; host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - let (frame, resize) = if let Some(shell) = state.shell.as_mut() { - let previous_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.reload_client_config(); - let next_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - let resize = (previous_size != next_size).then(|| { - shell.invalidate_pane_surface(); - 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, + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) - }); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - resize, - ) - } else { - (None, None) - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(resize) = resize { - write_to_server(&mut write_stream, &resize) - .map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - } - ServerMessage::MouseCapture { - enabled, - sgr_pixels, - } => { - state.endpoint_mouse_capture_requested = enabled; - state.endpoint_sgr_pixels_requested = sgr_pixels; - let enabled = - effective_mouse_capture(enabled, state.direct_mouse_capture_preference); - let next_sgr_pixels = - effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); - let mouse_mode_changed = enabled != state.mouse_capture_active - || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); - if mouse_mode_changed { - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); - } - set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); } } - state.mouse_capture_active = enabled; - host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); - } - ServerMessage::DirectTerminalKeyboardProtocol { - flags, - modify_other_keys_level, - } => { - if state.attach_escape.is_some() { - crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut state.direct_keyboard_protocol, - flags, - modify_other_keys_level, - ) - .map_err(ClientError::ConnectionFailed)?; - } - } - ServerMessage::ClientShellKeyboardReportAll { enabled } => { - if state.shell.is_some() { - state.pane_keyboard_report_all = enabled; - sync_client_shell_keyboard_report_all(&mut state)?; + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; + } } - } - ServerMessage::EndpointControl { kind, data } => { - if kind != crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { - if kind.starts_with("shell.snapshot.") { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "unsupported mandatory endpoint snapshot codec {kind:?}" - ), - ), - ))); + ServerMessage::EndpointControl { kind, data } => { + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND { + let progress = pending_activation.as_mut().map(|activation| { + activation.receive_presentation_effects_ready( + &endpoint_id, + generation, + &data, + ) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + continue; + } + let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { + Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::Ignored) => { + debug!(%kind, "ignoring unknown endpoint control message"); + continue; + } + Ok(endpoint::EndpointControlMessage::Snapshot(snapshot)) => snapshot, + Err(message) + if federated + || !endpoint::protocol_failure_is_fatal(&endpoint_id) => + { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message, + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + Err(message) => { + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + }; + let projection_pending = activation_message; + let activation_progress = activation_message + .then(|| { + pending_activation.as_mut().map(|pending| { + pending.receive_snapshot(&endpoint_id, generation, &snapshot) + }) + }) + .flatten(); + install_client_shell_snapshot( + &mut state, + &endpoint_id, + snapshot, + projection_pending, + &mut write_stream, + &mut prefix_input_source, + )?; + if matches!( + activation_progress, + Some(endpoint::SurfaceActivationProgress::Ready) + ) { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + write_stream.mark_ready(&endpoint_id, generation); + let selected_endpoint = endpoint_catalog + .selected_profile + .as_ref() + .map_or(endpoint::ClientEndpointId::Local, |profile_id| { + endpoint::ClientEndpointId::Ssh(profile_id.clone()) + }); + let activation_ready = state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_has_snapshot(&selected_endpoint) + && (!write_stream + .connection(write_stream.active_id()) + .is_some_and(|connection| connection.surface_active) + || shell.endpoint_boot_id(write_stream.active_id()).is_some()) + }); + let needs_surface = write_stream + .connection(&selected_endpoint) + .is_some_and(|connection| !connection.surface_active); + if activation_ready && needs_surface && pending_activation.is_none() { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: selected_endpoint, + target: None, + force: false, + }); } - debug!(%kind, "ignoring unknown endpoint control message"); - continue; } - let snapshot: crate::protocol::ClientShellSnapshot = - serde_json::from_str(&data).map_err(|error| { - ClientError::Protocol(protocol::FramingError::Io(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid endpoint snapshot: {error}"), - ))) - })?; - install_client_shell_snapshot( - &mut state, - Box::new(snapshot), - &mut write_stream, - &mut prefix_input_source, - )?; + ServerMessage::Welcome { .. } => { + debug!("received unexpected Welcome in main loop"); + } } - ServerMessage::Welcome { .. } => { - debug!("received unexpected Welcome in main loop"); + } + ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; } - }, - ClientLoopEvent::ServerDisconnected => { - return Err(ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - ))); + write_stream.fail( + &endpoint_id, + io::Error::new(io::ErrorKind::UnexpectedEof, "connection was lost"), + ); } ClientLoopEvent::Timer => { client_timer.fired(); @@ -1767,12 +1939,80 @@ async fn run_client_loop( state .detached_process_children .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + write_stream.tick_health(now); + for failure in write_stream.take_failures() { + if write_stream.connection(&failure.endpoint_id).is_some() + && !write_stream.accepts(&failure.endpoint_id, failure.generation) + { + continue; + } + warn!( + endpoint = %failure.endpoint_id.storage_key(), + error = %failure.message, + "endpoint transport failed" + ); + if !federated && failure.endpoint_id.is_local() { + return Err(ClientError::ConnectionLost(io::Error::new( + failure.kind, + failure.message, + ))); + } + if handle_endpoint_disconnect( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &failure.endpoint_id, + failure.generation, + now, + &format!("{}; reconnecting", failure.message), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + } + // A revoked transport changes the safe rollback destination. Handle those + // failures before applying a timeout to the remaining activation phase. + if pending_activation + .as_ref() + .is_some_and(|activation| activation.expired(now)) + { + let endpoint_id = pending_activation + .as_ref() + .map(|activation| activation.target().clone()) + .expect("checked pending activation"); + let label = state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| "Endpoint".into()); + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + format!("{label} did not produce a coherent surface in time"), + false, + ); + } if state.shell.is_some() { - let expired_endpoint = endpoint_commands.expire(now); + let expired_endpoints = endpoint_commands + .expire(now) + .into_iter() + .filter(|expired| { + write_stream.accepts(&expired.endpoint_id, expired.generation) + }) + .collect::<Vec<_>>(); let (effects, outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); let mut outcome = shell.tick_selection_autoscroll(now); - if let Some(expired) = expired_endpoint { + for expired in expired_endpoints { + if !shell.endpoint_is_active(&expired.endpoint_id) { + continue; + } let (repaint, actions) = shell.handle_endpoint_result( &expired.boot_id, &expired.request_id, @@ -1795,8 +2035,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1813,141 +2055,5 @@ async fn run_client_loop( Ok(()) } -// --------------------------------------------------------------------------- -// Server reader thread -// --------------------------------------------------------------------------- - -/// Blocking thread that reads ServerMessages from the server and sends them -/// to the main event loop. -fn server_reader_thread( - mut stream: LocalStream, - event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, - should_quit: &Arc<AtomicBool>, - max_frame_size: usize, -) { - // Ensure the read stream is in blocking mode to avoid WouldBlock errors - // from read_exact inside read_message. The stream should already be - // blocking after handshake, but we enforce it here as a safety measure. - if stream.set_nonblocking(false).is_err() { - // If we can't set blocking mode, the stream is likely broken. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - return; - } - - loop { - if should_quit.load(Ordering::Acquire) { - break; - } - - match protocol::read_message(&mut stream, max_frame_size) { - Ok(msg) => { - if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) - .is_err() - { - break; // Main loop gone. - } - } - Err(protocol::FramingError::UnexpectedEof) => { - // Server closed connection. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { - // Should not happen with blocking mode, but handle gracefully - // in case the stream was set nonblocking by another clone. - std::thread::sleep(Duration::from_millis(1)); - continue; - } - Err(err) => { - warn!(err = %err, "server read error"); - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - } - } -} - -// --------------------------------------------------------------------------- -// Write helper -// --------------------------------------------------------------------------- - -/// Writes a message to the server stream (blocking). -fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> { - protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) -} - -// --------------------------------------------------------------------------- -// Notifications -// --------------------------------------------------------------------------- - -fn reload_local_client_config( - sound_config: &mut crate::config::SoundConfig, - redraw_on_focus_gained: &mut bool, - draw_host_cursor: &mut bool, - remote_image_paste_key: &mut Option<( - crossterm::event::KeyCode, - crossterm::event::KeyModifiers, - )>, - mouse_capture: &mut bool, -) { - match crate::config::load_live_config() { - Ok(loaded) => { - let invalid_section = |section: &str| { - loaded - .invalid_sections - .iter() - .any(|invalid| invalid == section) - }; - if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() - { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); - } - *sound_config = loaded.config.ui.sound.clone(); - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *mouse_capture = loaded.config.ui.mouse_capture; - } - if !invalid_section("keys") { - *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - } - debug!("reloaded local client config"); - } - Err(diagnostics) => { - warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); - } - } -} - -// --------------------------------------------------------------------------- -// Clipboard forwarding -// --------------------------------------------------------------------------- - -/// Decode a clipboard payload forwarded by the server. -fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(data).ok() -} - -/// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) -> bool { - let Some(bytes) = decode_clipboard_payload(data) else { - warn!("received invalid clipboard payload from server"); - return false; - }; - - crate::selection::write_osc52_bytes(&bytes); - true -} - -fn init_logging() { - crate::logging::init_file_logging("herdr-client.log"); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests; diff --git a/src/client/shell.rs b/src/client/shell.rs index 9a877744..14f4f660 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -2,15 +2,25 @@ use std::collections::{HashMap, HashSet, VecDeque}; mod actions; mod agent_sidebar; +mod aggregate_navigation; mod composition; mod config; mod context_menu; mod copy_mode; +mod endpoint_agent_state; +mod endpoint_agents; +mod endpoint_navigation; +mod endpoint_notices; +mod endpoint_sidebar; +mod endpoints; +pub(super) use endpoints::*; mod global_menu; mod graphics; mod input; +mod input_source; mod mobile; mod mouse; +mod notification_policy; mod notifications; mod overlay_input; mod preferences; @@ -21,6 +31,7 @@ mod state; mod surface_patch; mod worktrees; +pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; #[cfg(test)] pub(super) use surface_patch::apply_composed_surface_patch; @@ -34,6 +45,7 @@ use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; +use super::endpoint::{ClientEndpointId, ClientEndpointStatus, SavedSshEndpoint}; use crate::app::state::Palette; use crate::config::{ Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 1ebd4e29..f99d8f3a 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -147,6 +147,9 @@ impl ClientShellState { } return; } + if self.handle_endpoint_navigation(action, outcome) { + return; + } if let Some(method) = self.endpoint_method_for_action(action) { self.push_endpoint_method(method, outcome); return; @@ -408,7 +411,9 @@ impl ClientShellState { kind: PendingEndpointKind, outcome: &mut ClientShellInput, ) -> bool { - if self.snapshot.is_none() { + if !self.endpoint_is_online(&self.active_endpoint_id) { + let label = self.active_endpoint_label().to_owned(); + outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready")); return false; } let method_name = crate::api::api_method_name(&method).to_owned(); @@ -423,7 +428,9 @@ impl ClientShellState { ); return false; } - let snapshot = self.snapshot.as_deref().expect("checked snapshot"); + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; let confirmation_workspace_id = match &method { crate::api::schema::Method::TabClose(target) => snapshot .tabs @@ -450,6 +457,7 @@ impl ClientShellState { }, ); outcome.actions.push(ClientShellAction::Endpoint { + endpoint_id: self.active_endpoint_id.clone(), boot_id: snapshot.boot_id.clone(), request: Box::new(crate::api::schema::Request { id: request_id, @@ -468,6 +476,58 @@ impl ClientShellState { ) } + pub(crate) fn receive_endpoint_unavailable(&mut self, message: String) -> bool { + self.push_endpoint_notice( + ClientEndpointNoticeKind::Unavailable, + message.clone(), + "Endpoint unavailable", + message, + ) + } + + pub(crate) fn focus_endpoint_target( + &mut self, + target: ClientEndpointFocusTarget, + ) -> Vec<ClientShellAction> { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + let mut outcome = ClientShellInput::default(); + self.push_endpoint_method(method, &mut outcome); + outcome.actions + } + + pub(crate) fn cancel_endpoint_request(&mut self, request_id: &str) -> bool { + let Some(pending) = self.pending_requests.get(request_id) else { + return false; + }; + let boot_id = pending.boot_id.clone(); + let (repaint, actions) = self.handle_endpoint_result( + &boot_id, + request_id, + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "This server action was interrupted. Check its state before retrying." + .into(), + }), + ); + debug_assert!( + actions.is_empty(), + "cancellation must not start another action" + ); + repaint + } + pub(crate) fn handle_endpoint_result( &mut self, boot_id: &str, @@ -506,6 +566,12 @@ impl ClientShellState { "Server timed out", format!("This server did not respond to {}.", pending.method_name), ), + "endpoint_cancelled" => ( + ClientEndpointNoticeKind::Unavailable, + "cancelled".to_owned(), + "Action interrupted", + error.message.clone(), + ), "server_unavailable" => ( ClientEndpointNoticeKind::Unavailable, "server".to_owned(), @@ -613,6 +679,9 @@ impl ClientShellState { Some("endpoint returned an unexpected selection result".to_owned()); (true, fallback()) } + Err(error) if error.code.as_deref() == Some("endpoint_cancelled") => { + (true, Vec::new()) + } Err(_) => (true, fallback()), }; } @@ -724,7 +793,7 @@ impl ClientShellState { Err(error) if matches!( error.code.as_deref(), - Some("stale_content" | "stale_target") + Some("stale_content" | "stale_target" | "endpoint_cancelled") ) => { self.url_click_consumes_until_up = completed_before_release; diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index 8f87b6ad..d6fe3352 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -10,11 +10,11 @@ use ratatui::{ use super::*; -struct AgentRow { - pane_id: String, - status: crate::api::schema::AgentStatus, - focused: bool, - rows: Vec<Vec<crate::ui::ResolvedToken>>, +pub(super) struct AgentRow { + pub(super) pane_id: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) focused: bool, + pub(super) rows: Vec<Vec<crate::ui::ResolvedToken>>, } pub(super) fn ordered_agent_pane_ids( @@ -57,9 +57,46 @@ pub(super) fn render_agent_panel( agent_scroll: &mut usize, hits: &mut ShellHitMap, ) { - if area.height == 0 { + if !render_agent_panel_header( + buffer, + area, + snapshot.agent_view_label.as_deref(), + config, + hits, + ) { return; } + + let rows = agent_rows(snapshot, config, None); + render_agent_list( + buffer, + area, + &rows, + snapshot + .agent_view_label + .as_ref() + .map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.rows.len(), + |buffer, rect, row, hits| { + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + }, + ); +} + +pub(super) fn render_agent_panel_header( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) -> bool { + if area.height == 0 { + return false; + } put_text( buffer, area.x, @@ -69,7 +106,7 @@ pub(super) fn render_agent_panel( Style::default().fg(config.palette.surface_dim), ); if area.height < 2 { - return; + return false; } put_text( buffer, @@ -81,14 +118,10 @@ pub(super) fn render_agent_panel( .fg(config.palette.overlay0) .add_modifier(Modifier::BOLD), ); - let sort_label = - snapshot - .agent_view_label - .as_deref() - .unwrap_or(match config.agent_panel_sort { - crate::config::AgentPanelSortConfig::Spaces => "grouped", - crate::config::AgentPanelSortConfig::Priority => "priority", - }); + let sort_label = agent_view_label.unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); let sort_width = display_width(sort_label).min(area.width as usize) as u16; let sort_rect = Rect::new( area.right().saturating_sub(sort_width), @@ -96,7 +129,7 @@ pub(super) fn render_agent_panel( sort_width, 1, ); - hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + hits.agent_sort_toggle = if config.mouse_capture && agent_view_label.is_none() { sort_rect } else { Rect::default() @@ -108,15 +141,27 @@ pub(super) fn render_agent_panel( sort_rect.width, sort_label, Style::default() - .fg(if snapshot.agent_view_label.is_some() { + .fg(if agent_view_label.is_some() { config.palette.accent } else { config.palette.overlay0 }) .add_modifier(Modifier::BOLD), ); + true +} - let rows = agent_rows(snapshot, config); +pub(super) fn render_agent_list<T>( + buffer: &mut Buffer, + area: Rect, + rows: &[T], + empty_message: Option<&str>, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, + row_lines: impl Fn(&T) -> usize, + mut render_row: impl FnMut(&mut Buffer, Rect, &T, &mut ShellHitMap), +) { let body = Rect::new( area.x, area.y.saturating_add(3), @@ -126,13 +171,13 @@ pub(super) fn render_agent_panel( hits.agent_body = body; if body.is_empty() || rows.is_empty() { *agent_scroll = 0; - if !body.is_empty() && snapshot.agent_view_label.is_some() { + if let Some(message) = empty_message.filter(|_| !body.is_empty()) { put_text( buffer, body.x, body.y, body.width, - " no matching agents", + message, Style::default() .fg(config.palette.overlay0) .add_modifier(Modifier::DIM), @@ -143,7 +188,7 @@ pub(super) fn render_agent_panel( let row_heights = rows .iter() - .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .map(|row| row_lines(row).max(1).min(u16::MAX as usize) as u16) .collect::<Vec<_>>(); let gaps = rows .iter() @@ -167,13 +212,12 @@ pub(super) fn render_agent_panel( let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); let mut y = body.y; for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { - let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + let height = row_heights[index].min(body.height); if y.saturating_add(height) > body.bottom() { break; } let rect = Rect::new(body.x, y, content_width, height); - hits.agents.push((rect, row.pane_id.clone())); - render_agent_row(buffer, rect, row, config); + render_row(buffer, rect, row, hits); y = y .saturating_add(height) .saturating_add(if index + 1 < rows.len() { @@ -190,7 +234,11 @@ pub(super) fn render_agent_panel( } } -fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { +pub(super) fn agent_rows( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + machine: Option<&str>, +) -> Vec<AgentRow> { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() .filter_map(|pane_id| { @@ -238,6 +286,7 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec let rows = crate::ui::sidebar_agent_rows( &config.agents, crate::ui::AgentTokenContext { + machine, workspace: &workspace.label, tab: tab_label, pane: agent @@ -262,7 +311,12 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec .collect() } -fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { +pub(super) fn render_agent_row( + buffer: &mut Buffer, + rect: Rect, + row: &AgentRow, + config: &ClientShellConfig, +) { let palette = &config.palette; let row_style = if row.focused { Style::default().bg(palette.active_row_bg) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs new file mode 100644 index 00000000..5c826bf3 --- /dev/null +++ b/src/client/shell/aggregate_navigation.rs @@ -0,0 +1,262 @@ +//! Endpoint-qualified rows shared by aggregate navigation surfaces. + +use super::*; +use crate::protocol::ClientShellAgent; + +#[derive(Clone, Copy)] +pub(super) struct CachedEndpointSnapshot<'a> { + pub(super) endpoint_id: &'a ClientEndpointId, + pub(super) label: &'a str, + pub(super) status: ClientEndpointStatus, + pub(super) snapshot: &'a ClientShellSnapshot, + pub(super) agent_recency: &'a HashMap<String, u64>, +} + +impl CachedEndpointSnapshot<'_> { + pub(super) fn stale(self) -> bool { + self.status != ClientEndpointStatus::Online + } +} + +pub(super) fn cached_endpoint_snapshots( + endpoints: &[ClientShellEndpoint], +) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> { + endpoints.iter().filter_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| CachedEndpointSnapshot { + endpoint_id: &endpoint.endpoint_id, + label: &endpoint.label, + status: endpoint.status, + snapshot, + agent_recency: &endpoint.agent_recency, + }) + }) +} + +pub(super) struct AggregateAgentRow<'a> { + pub(super) endpoint: CachedEndpointSnapshot<'a>, + pub(super) agent: &'a ClientShellAgent, + pub(super) recency: u64, +} + +pub(super) struct AggregateAgentTarget { + pub(super) endpoint_id: ClientEndpointId, + pub(super) pane_id: String, +} + +pub(super) fn aggregate_agent_rows( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentRow<'_>> { + let mut rows = cached_endpoint_snapshots(endpoints) + .flat_map(|endpoint| { + super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort) + .into_iter() + .filter_map(move |pane_id| { + let agent = endpoint + .snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + Some(AggregateAgentRow { + recency: endpoint + .agent_recency + .get(&pane_id) + .copied() + .unwrap_or_default(), + endpoint, + agent, + }) + }) + }) + .collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + rows.sort_by_key(|row| { + ( + row.endpoint.stale(), + std::cmp::Reverse(status_priority(row.agent.agent_status)), + std::cmp::Reverse(row.recency), + ) + }); + } + rows +} + +pub(super) fn online_agent_targets( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentTarget> { + aggregate_agent_rows(endpoints, sort) + .into_iter() + .filter(|row| !row.endpoint.stale()) + .map(|row| AggregateAgentTarget { + endpoint_id: row.endpoint.endpoint_id.clone(), + pane_id: row.agent.pane_id.clone(), + }) + .collect() +} + +pub(super) fn navigator_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + navigator: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let query = navigator.query.trim().to_lowercase(); + let filter = |status| match navigator.filter { + Some(ClientNavigatorFilter::Blocked) => status == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => status == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => status == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query); + let filtering = navigator.filter.is_some() || !query.is_empty(); + let federated = endpoints.len() > 1; + let depth_offset = u8::from(federated); + let mut rows = Vec::new(); + + for endpoint in endpoints { + let stale = endpoint.status != ClientEndpointStatus::Online; + let endpoint_query_matches = !query.is_empty() && text(&endpoint.label); + let mut endpoint_rows = Vec::new(); + if let Some(snapshot) = endpoint.snapshot.as_deref() { + for workspace in &snapshot.workspaces { + let workspace_meta = workspace.branch.clone().unwrap_or_default(); + let mut children = Vec::new(); + for tab in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + { + let mut panes = Vec::new(); + for (index, pane) in snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .enumerate() + { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id); + let status = agent + .map_or(crate::api::schema::AgentStatus::Unknown, |agent| { + agent.agent_status + }); + let label = pane + .label + .clone() + .or_else(|| agent.and_then(|agent| agent.name.clone())) + .or_else(|| agent.and_then(|agent| agent.display_agent.clone())) + .or_else(|| agent.and_then(|agent| agent.title.clone())) + .unwrap_or_else(|| format!("pane {}", index + 1)); + let meta = pane + .foreground_cwd + .clone() + .or_else(|| pane.cwd.clone()) + .unwrap_or_default(); + if !filtering + || filter(status) + && (endpoint_query_matches || text(&label) || text(&meta)) + { + panes.push(ClientNavigatorRow { + depth: 2 + depth_offset, + label, + meta, + status: Some(status), + stale, + current: endpoint.endpoint_id == *active_endpoint_id + && snapshot.focused_pane_id.as_deref() == Some(&pane.pane_id), + target: ClientNavigatorTarget::Pane { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: pane.pane_id.clone(), + }, + }); + } + } + if !filtering + || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label)) + || !panes.is_empty() + { + children.push(ClientNavigatorRow { + depth: 1 + depth_offset, + label: tab.label.clone(), + meta: format!( + "{} panes", + snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .count() + ), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Tab { + endpoint_id: endpoint.endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }, + }); + children.extend(panes); + } + } + let workspace_matches = filter(workspace.agent_status) + && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta)); + if !filtering || workspace_matches || !children.is_empty() { + let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()); + endpoint_rows.push(ClientNavigatorRow { + depth: depth_offset, + label: workspace.label.clone(), + meta: workspace_meta, + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }, + }); + if navigator.expanded_workspaces.contains(&key) || filtering { + endpoint_rows.extend(children); + } + } + } + } + if !filtering || endpoint_query_matches || !endpoint_rows.is_empty() { + if federated { + rows.push(ClientNavigatorRow { + depth: 0, + label: endpoint.label.to_owned(), + meta: String::new(), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Machine { + endpoint_id: endpoint.endpoint_id.clone(), + }, + }); + } + rows.extend(endpoint_rows); + } + } + rows +} + +pub(super) fn navigator_selected_index( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<usize> { + match navigator.selected.as_ref() { + Some(target) => rows.iter().position(|row| row.target == *target), + None => (!rows.is_empty()).then_some(0), + } +} + +pub(super) fn selected_navigator_target( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<ClientNavigatorTarget> { + navigator_selected_index(rows, navigator).map(|index| rows[index].target.clone()) +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index bcbf92fa..a80ebb3f 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -20,9 +20,95 @@ fn restore_mode_bar( } impl ClientShellState { + fn compose_unavailable(&mut self, cols: u16, rows: u16) -> FrameData { + let layout = self.layout(cols, rows); + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + buffer.set_style( + buffer.area, + Style::default() + .fg(self.config.palette.text) + .bg(self.config.palette.panel_bg), + ); + self.hits = ShellHitMap::default(); + let sidebar = if layout.sidebar.width > 0 { + layout.sidebar + } else { + Rect::new(0, 1, cols, rows.saturating_sub(2)) + }; + super::endpoint_sidebar::render_expanded( + &mut buffer, + sidebar, + self.snapshot.as_deref(), + &self.config, + &mut render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_workspace: &mut self.reveal_focused_workspace, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: false, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index: None, + selected_workspace_id: self.navigate_workspace_id.as_deref(), + dragged_workspace_id: None, + workspace_drop_indicator_row: None, + }, + &mut self.hits, + ); + if !self.config.mouse_capture { + self.hits = ShellHitMap::default(); + } + let message = self.endpoint_error.clone().unwrap_or_else(|| { + let status = self + .endpoint_status(&self.active_endpoint_id) + .unwrap_or(ClientEndpointStatus::Connecting); + let (_, label, _) = endpoint_status_presentation(status, &self.config.palette); + format!( + "{}: {label}. Select a connected machine.", + self.active_endpoint_label() + ) + }); + let message_area = if layout.sidebar.width > 0 { + layout.pane_surface + } else { + Rect::new(0, 0, cols, 1) + }; + render::put_text( + &mut buffer, + message_area.x, + message_area.y, + message_area.width, + &message, + Style::default().fg(self.config.palette.overlay0), + ); + render::render_mode_bar( + &mut buffer, + Rect::new(0, 0, cols, rows), + self.mode, + None, + self.endpoint_error.as_deref(), + false, + &self.config.keybinds, + &self.config.palette, + ); + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]) + } + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { self.last_composed_size = Some((cols, rows)); + if self.snapshot.is_none() || self.pane_surface.is_none() { + return Some(self.compose_unavailable(cols, rows)); + } let snapshot = self.snapshot.as_deref()?; + // A one-step successor is retained separately until its exact snapshot arrives; do not + // keep composing the now-superseded current pair while it is pending. + if self.pending_pane_surface.is_some() { + return None; + } let surface = self.pane_surface.as_ref()?; if snapshot.revision != surface.projection_revision { return None; @@ -53,6 +139,9 @@ impl ClientShellState { snapshot, &self.config, render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, collapsed_groups: &self.collapsed_groups, workspace_scroll: &mut self.workspace_scroll, agent_scroll: &mut self.agent_scroll, @@ -270,7 +359,14 @@ impl ClientShellState { restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); self.hits.notification_toast = Rect::default(); let has_config_diagnostic = self.config_diagnostic.is_some(); + let active_lifecycle = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .filter(|endpoint| endpoint.status != ClientEndpointStatus::Online) + .map(|endpoint| (endpoint.label.clone(), endpoint.status)); if has_config_diagnostic + || active_lifecycle.is_some() || self.visible_endpoint_notice.is_some() || self.visible_notification.is_some() { @@ -289,21 +385,32 @@ impl ClientShellState { &self.config.palette, ); } + let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + u16::from(has_config_diagnostic) + layout.mobile_header.height, + &self.config.palette, + ); + 1 + }); if let Some(notice) = self.visible_endpoint_notice.as_ref() { self.hits.notification_toast = if layout.mobile_header.is_empty() { - notifications::render_endpoint_notice( + endpoint_notices::render_notice( &mut composed, Rect::new(0, 0, cols, rows), notice, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { - notifications::render_mobile_endpoint_notice_banner( + endpoint_notices::render_mobile_banner( &mut composed, Rect::new(0, 0, cols, rows), notice, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -314,7 +421,7 @@ impl ClientShellState { Rect::new(0, 0, cols, rows), notification, self.config.toast_position, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { @@ -322,7 +429,7 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), notification, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -399,12 +506,33 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config, self.navigate_workspace_id.as_deref(), &mut self.mobile_switcher_scroll, &mut self.reveal_mobile_workspace, &mut self.hits, ); + if let Some((label, status)) = active_lifecycle.as_ref() { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + 2, + &self.config.palette, + ); + } + if let Some(notice) = self.visible_endpoint_notice.as_ref() { + self.hits.notification_toast = endpoint_notices::render_mobile_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notice, + active_lifecycle.is_some(), + &self.config.palette, + ); + } frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); self.hits.panes.clear(); self.hits.pane_splits.clear(); @@ -431,6 +559,8 @@ impl ClientShellState { &mut composed, overlay, snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config.keybinds, &self.config.palette, )?; @@ -474,6 +604,12 @@ impl ClientShellState { .scroll .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); } + if self.endpoint_status(&self.active_endpoint_id) != Some(ClientEndpointStatus::Online) { + frame.cursor = None; + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } self.compose_graphics(&mut frame, layout); Some(frame) } diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs new file mode 100644 index 00000000..fdcd7478 --- /dev/null +++ b/src/client/shell/endpoint_agent_state.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use crate::api::schema::AgentStatus; +use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; + +#[derive(Clone, Debug, Default)] +pub(super) struct EndpointAgentPresentation { + boot_id: Option<String>, + acknowledged: HashMap<String, u64>, +} + +impl EndpointAgentPresentation { + pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) { + if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) { + self.boot_id = Some(snapshot.boot_id.clone()); + self.acknowledged.clear(); + self.acknowledged.extend( + snapshot + .agents + .iter() + .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)), + ); + } + self.acknowledged.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + + pub(super) fn acknowledge_surface( + &mut self, + snapshot: &mut ClientShellSnapshot, + surface: &PaneSurfaceFrame, + outer_focused: Option<bool>, + ) -> bool { + if outer_focused == Some(false) + || self.boot_id.as_deref() != Some(surface.boot_id.as_str()) + || snapshot.boot_id != surface.boot_id + || snapshot.revision != surface.projection_revision + { + return false; + } + + let mut changed = false; + for pane in &surface.panes { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id) + else { + continue; + }; + let acknowledged = self.acknowledged.entry(agent.pane_id.clone()).or_default(); + if *acknowledged < agent.state_change_seq { + *acknowledged = agent.state_change_seq; + changed = true; + } + } + if changed { + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + changed + } + + fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { + match agent.agent_status { + AgentStatus::Idle | AgentStatus::Done => { + if self + .acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| *sequence >= agent.state_change_seq) + { + AgentStatus::Idle + } else { + AgentStatus::Done + } + } + status => status, + } + } +} + +fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) { + for tab in &mut snapshot.tabs { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.tab_id == tab.tab_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + tab.agent_status = status; + } + } + for workspace in &mut snapshot.workspaces { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.workspace_id == workspace.workspace_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + workspace.agent_status = status; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect}; + + fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent { + ClientShellAgent { + pane_id: "agent-pane".into(), + workspace_id: "workspace".into(), + tab_id: "tab".into(), + name: None, + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq: sequence, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } + } + + fn snapshot(status: AgentStatus, sequence: u64, revision: u64) -> ClientShellSnapshot { + let mut snapshot = crate::client::shell::tests::snapshot(); + snapshot.boot_id = "endpoint-boot".into(); + snapshot.revision = revision; + snapshot.agents = vec![agent(status, sequence)]; + snapshot + } + + fn surface(revision: u64) -> PaneSurfaceFrame { + let rect = SurfaceRect { + x: 0, + y: 0, + width: 1, + height: 1, + }; + PaneSurfaceFrame { + boot_id: "endpoint-boot".into(), + projection_revision: revision, + surface_revision: 1, + frame: FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![PaneSurfacePane { + pane_id: "agent-pane".into(), + content_revision: 1, + rect, + inner_rect: rect, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } + } + + #[test] + fn first_snapshot_establishes_an_idle_baseline_without_server_seen_authority() { + let mut presentation = EndpointAgentPresentation::default(); + let mut snapshot = snapshot(AgentStatus::Done, 4, 1); + + presentation.project_snapshot(&mut snapshot); + + assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn unpresented_working_completion_projects_done() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + + presentation.project_snapshot(&mut completed); + + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } + + #[test] + fn coherent_presented_surface_acknowledges_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn clients_acknowledge_the_same_endpoint_completion_independently() { + let mut viewing_client = EndpointAgentPresentation::default(); + let mut background_client = EndpointAgentPresentation::default(); + let mut initial_for_viewer = snapshot(AgentStatus::Working, 4, 1); + let mut initial_for_background = initial_for_viewer.clone(); + viewing_client.project_snapshot(&mut initial_for_viewer); + background_client.project_snapshot(&mut initial_for_background); + let mut completed_for_viewer = snapshot(AgentStatus::Idle, 5, 2); + let mut completed_for_background = completed_for_viewer.clone(); + viewing_client.project_snapshot(&mut completed_for_viewer); + background_client.project_snapshot(&mut completed_for_background); + + assert!(viewing_client.acknowledge_surface( + &mut completed_for_viewer, + &surface(2), + Some(true) + )); + + assert_eq!( + completed_for_viewer.agents[0].agent_status, + AgentStatus::Idle + ); + assert_eq!( + completed_for_background.agents[0].agent_status, + AgentStatus::Done + ); + } + + #[test] + fn stale_or_unfocused_surface_does_not_acknowledge_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(!presentation.acknowledge_surface(&mut completed, &surface(1), Some(true))); + assert!(!presentation.acknowledge_surface(&mut completed, &surface(2), Some(false))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } +} diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs new file mode 100644 index 00000000..a49fccf9 --- /dev/null +++ b/src/client/shell/endpoint_agents.rs @@ -0,0 +1,129 @@ +use super::render::put_text; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + let rows = agent_rows(endpoints, active_endpoint_id, config); + for (index, row) in rows.into_iter().take(area.height as usize).enumerate() { + let rect = Rect::new(area.x, area.y + index as u16, area.width, 1); + if row.agent.focused { + buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg)); + } + let initial = row.machine_label.chars().next().unwrap_or('?'); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!( + "{initial}{}", + status_icon(row.agent.status, config.status_indicators) + ), + Style::default() + .fg(if row.stale { + config.palette.overlay0 + } else { + status_color(row.agent.status, &config.palette) + }) + .add_modifier(if row.stale { + Modifier::DIM + } else { + Modifier::empty() + }), + ); + hits.endpoint_agents + .push((rect, row.endpoint_id, row.agent.pane_id)); + } +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if !super::agent_sidebar::render_agent_panel_header( + buffer, + area, + agent_view_label, + config, + hits, + ) { + return; + } + let rows = agent_rows(endpoints, active_endpoint_id, config); + super::agent_sidebar::render_agent_list( + buffer, + area, + &rows, + agent_view_label.map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.agent.rows.len(), + |buffer, rect, row, hits| { + super::agent_sidebar::render_agent_row(buffer, rect, &row.agent, config); + if row.stale { + buffer.set_style( + rect, + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.endpoint_agents + .push((rect, row.endpoint_id.clone(), row.agent.pane_id.clone())); + }, + ); +} + +struct EndpointAgentRow { + endpoint_id: ClientEndpointId, + machine_label: String, + stale: bool, + agent: super::agent_sidebar::AgentRow, +} + +fn agent_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, +) -> Vec<EndpointAgentRow> { + let mut rendered_rows = endpoints + .iter() + .filter_map(|endpoint| { + endpoint.snapshot.as_deref().map(|snapshot| { + super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label)) + .into_iter() + .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent)) + .collect::<Vec<_>>() + }) + }) + .flatten() + .collect::<HashMap<_, _>>(); + + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort) + .into_iter() + .filter_map(|row| { + let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); + let mut agent = rendered_rows.remove(&key)?; + agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; + Some(EndpointAgentRow { + endpoint_id: row.endpoint.endpoint_id.clone(), + machine_label: row.endpoint.label.to_owned(), + stale: row.endpoint.stale(), + agent, + }) + }) + .collect() +} diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs new file mode 100644 index 00000000..adb29c93 --- /dev/null +++ b/src/client/shell/endpoint_navigation.rs @@ -0,0 +1,290 @@ +use super::*; + +impl ClientShellState { + pub(super) fn active_endpoint_workspace_at(&self, point: (u16, u16)) -> Option<String> { + self.hits + .workspaces + .iter() + .find(|hit| { + hit.endpoint_id == self.active_endpoint_id && super::contains(hit.rect, point) + }) + .map(|hit| hit.workspace_id.clone()) + } + + pub(super) fn endpoint_workspace_is_draggable(&self, press: &ClientWorkspacePress) -> bool { + press.endpoint_id == self.active_endpoint_id + && self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == press.workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + } + + pub(super) fn finish_endpoint_workspace_press( + &mut self, + press: ClientWorkspacePress, + outcome: &mut ClientShellInput, + ) { + if press.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }), + outcome, + ); + } else if self.endpoint_is_online(&press.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: press.endpoint_id, + target: Some(ClientEndpointFocusTarget::Workspace(press.workspace_id)), + }); + } else { + let label = self.endpoint_label(&press.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + } + + pub(super) fn handle_endpoint_machine_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some(endpoint_id) = self + .hits + .machines + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.endpoint_id.clone()) + else { + return false; + }; + if endpoint_id == self.active_endpoint_id { + if !self.collapsed_endpoints.remove(&endpoint_id) { + self.collapsed_endpoints.insert(endpoint_id); + } + outcome.repaint = true; + } else if self.endpoint_is_online(&endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + true + } + + pub(super) fn handle_endpoint_agent_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some((endpoint_id, pane_id)) = self + .hits + .endpoint_agents + .iter() + .find(|(rect, _, _)| super::contains(*rect, point)) + .map(|(_, endpoint_id, pane_id)| (endpoint_id.clone(), pane_id.clone())) + else { + return false; + }; + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is reconnecting")); + outcome.repaint = true; + } else if endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + true + } + + pub(super) fn handle_endpoint_navigation( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) -> bool { + use crate::input::KeybindAction; + if !self.multi_endpoint_active() { + return false; + } + if matches!( + action, + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace + ) { + let workspaces = self + .endpoints + .iter() + .filter(|endpoint| endpoint.status == ClientEndpointStatus::Online) + .flat_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map_or_else(Vec::new, |snapshot| { + render::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .filter_map(|entry| { + snapshot.workspaces.get(entry.index).map(|workspace| { + ( + endpoint.endpoint_id.clone(), + workspace.workspace_id.clone(), + ) + }) + }) + .collect() + }) + }) + .collect::<Vec<_>>(); + if workspaces.is_empty() { + return true; + } + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()); + let current = workspaces.iter().position(|(endpoint_id, workspace_id)| { + endpoint_id == &self.active_endpoint_id && Some(workspace_id.as_str()) == focused + }); + let next = match (current, action) { + (Some(index), KeybindAction::PreviousWorkspace) => { + (index + workspaces.len() - 1) % workspaces.len() + } + (Some(index), KeybindAction::NextWorkspace) => (index + 1) % workspaces.len(), + (None, KeybindAction::PreviousWorkspace) => workspaces.len() - 1, + (None, KeybindAction::NextWorkspace) => 0, + _ => unreachable!("endpoint workspace navigation"), + }; + let (endpoint_id, workspace_id) = workspaces[next].clone(); + self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ); + return true; + } + if matches!( + action, + KeybindAction::PreviousAgent | KeybindAction::NextAgent | KeybindAction::FocusAgent(_) + ) { + let agents = super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return true; + } + let next = match action { + KeybindAction::FocusAgent(index) => { + if index >= agents.len() { + return true; + } + index + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.as_deref()); + let current = agents.iter().position(|target| { + target.endpoint_id == self.active_endpoint_id + && Some(target.pane_id.as_str()) == focused + }); + match (current, action) { + (Some(index), KeybindAction::PreviousAgent) => { + (index + agents.len() - 1) % agents.len() + } + (Some(index), KeybindAction::NextAgent) => (index + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + _ => 0, + } + } + _ => unreachable!("endpoint agent navigation"), + }; + let target = &agents[next]; + self.focus_or_activate( + target.endpoint_id.clone(), + ClientEndpointFocusTarget::Pane(target.pane_id.clone()), + outcome, + ); + return true; + } + false + } + + pub(super) fn activate_endpoint( + &mut self, + endpoint_id: ClientEndpointId, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id != self.active_endpoint_id { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } + true + } + + pub(super) fn focus_or_activate( + &mut self, + endpoint_id: ClientEndpointId, + target: ClientEndpointFocusTarget, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id == self.active_endpoint_id { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + }; + self.push_endpoint_method(method, outcome); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(target), + }); + } + true + } +} diff --git a/src/client/shell/endpoint_notices.rs b/src/client/shell/endpoint_notices.rs new file mode 100644 index 00000000..e796d2c5 --- /dev/null +++ b/src/client/shell/endpoint_notices.rs @@ -0,0 +1,80 @@ +use super::*; +use ratatui::widgets::{Clear, Widget}; + +pub(super) fn render_mobile_banner( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + super::notifications::render_mobile_notice_banner( + buffer, + area, + ¬ice.title, + Some(¬ice.body), + palette.red, + offset_for_warning, + palette, + ) +} + +pub(super) fn render_lifecycle_banner( + buffer: &mut Buffer, + area: Rect, + label: &str, + status: ClientEndpointStatus, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() || status == ClientEndpointStatus::Online { + return Rect::default(); + } + let (symbol, state, color) = endpoint_status_presentation(status, palette); + let text = format!("{symbol} {label} · {state}"); + let width = u16::try_from(unicode_width::UnicodeWidthStr::width(text.as_str()) + 2) + .unwrap_or(u16::MAX) + .min(area.width); + let y = area + .y + .saturating_add(top_offset) + .min(area.bottom().saturating_sub(1)); + let rect = Rect::new(area.right().saturating_sub(width), y, width, 1); + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(palette.surface0)); + super::render::put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(2), + &text, + Style::default().fg(color).bg(palette.surface0), + ); + rect +} + +pub(super) fn render_notice( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + top_offset: u16, + palette: &Palette, +) -> Rect { + super::notifications::render_notification_card( + buffer, + area, + ¬ice.title, + ¬ice.body, + crate::config::ToastHerdrPosition::TopRight, + top_offset, + match notice.key.kind { + ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { + palette.red + } + ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { + palette.yellow + } + }, + palette, + ) +} diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs new file mode 100644 index 00000000..51c940cd --- /dev/null +++ b/src/client/shell/endpoint_sidebar.rs @@ -0,0 +1,452 @@ +use super::render::{display_width, put_right_text, put_text, ShellRenderState}; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area); + let mut y = workspace_area.y; + for (index, endpoint) in state.endpoints.iter().enumerate() { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let active = &endpoint.endpoint_id == state.active_endpoint_id; + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + if active && collapsed { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let label = if endpoint.endpoint_id.is_local() { + "L".to_owned() + } else { + (index + 1).to_string() + }; + let marker = if collapsed { "▸" } else { "▾" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(1), + &format!("{marker}{label}"), + Style::default().fg(if endpoint.status == ClientEndpointStatus::Online { + palette.text + } else { + palette.overlay0 + }), + ); + if !endpoint.endpoint_id.is_local() { + let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette); + put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color)); + } + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + if collapsed { + continue; + } + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + for workspace in &snapshot.workspaces { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let focused = active && workspace.focused; + if focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let stale = endpoint.status != ClientEndpointStatus::Online; + let number = format!(" {}", workspace.number); + let number_width = super::render::display_width(&number).min(rect.width); + let dim = if stale { + Modifier::DIM + } else { + Modifier::empty() + }; + put_text( + buffer, + rect.x, + rect.y, + number_width, + &number, + Style::default() + .fg(if focused && !stale { + palette.text + } else { + palette.overlay0 + }) + .add_modifier(dim), + ); + put_text( + buffer, + rect.x.saturating_add(number_width), + rect.y, + rect.width.saturating_sub(number_width), + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(if stale { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }) + .add_modifier(dim), + ); + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + y = y.saturating_add(1); + } + } + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + super::endpoint_agents::render_collapsed( + buffer, + detail_area, + state.endpoints, + state.active_endpoint_id, + config, + hits, + ); + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + Style::default().fg(palette.overlay0), + ); +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + active_snapshot: Option<&ClientShellSnapshot>, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " machines", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + enum Row { + Endpoint(usize), + Workspace { + endpoint: usize, + entry: WorkspaceEntry, + }, + } + let mut rows = Vec::new(); + for (endpoint_index, endpoint) in state.endpoints.iter().enumerate() { + rows.push(Row::Endpoint(endpoint_index)); + if state.collapsed_endpoints.contains(&endpoint.endpoint_id) { + continue; + } + if let Some(snapshot) = endpoint.snapshot.as_deref() { + rows.extend( + super::sidebar::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .map(|entry| Row::Workspace { + endpoint: endpoint_index, + entry, + }), + ); + } + } + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = rows + .iter() + .map(|row| match row { + Row::Endpoint(_) => 1, + Row::Workspace { endpoint, entry } => state.endpoints[*endpoint] + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.workspaces.get(entry.index)) + .map(|workspace| { + super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1), + }) + .collect::<Vec<_>>(); + let gaps = vec![0; rows.len()]; + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for row in rows.iter().skip(*state.workspace_scroll) { + match row { + Row::Endpoint(index) => { + if y >= body.bottom() { + break; + } + let endpoint = &state.endpoints[*index]; + let rect = Rect::new(body.x, y, content_width, 1); + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + let marker = if collapsed { "▸" } else { "▾" }; + render_endpoint_row( + buffer, + rect, + marker, + endpoint, + collapsed && &endpoint.endpoint_id == state.active_endpoint_id, + palette, + ); + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + } + Row::Workspace { endpoint, entry } => { + let endpoint = &state.endpoints[*endpoint]; + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let tokens = super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ); + let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + let nested = Rect::new( + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + rect.height, + ); + let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id; + super::sidebar::render_workspace_rows( + buffer, + nested, + workspace, + workspace.agent_status, + config.status_indicators, + entry, + tokens, + endpoint_active, + false, + false, + palette, + ); + if endpoint.status != ClientEndpointStatus::Online { + buffer.set_style( + rect, + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle: None, + }); + y = y.saturating_add(height); + } + } + } + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + let label = format!(" new · {}", active_endpoint_label(state)); + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + display_width(&label).min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + &label, + Style::default().fg(palette.overlay0), + ); + let attention = active_snapshot.is_some_and(super::global_menu::global_menu_attention); + let width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(width), + footer_y, + width, + 1, + ); + put_right_text( + buffer, + workspace_area, + footer_y, + if attention { "● menu" } else { "menu" }, + Style::default().fg(if attention { + palette.accent + } else { + palette.overlay0 + }), + ); + } + super::endpoint_agents::render_expanded( + buffer, + detail_area, + active_snapshot.and_then(|snapshot| snapshot.agent_view_label.as_deref()), + state.endpoints, + state.active_endpoint_id, + config, + state.agent_scroll, + hits, + ); + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +fn active_endpoint_label<'a>(state: &'a ShellRenderState<'_>) -> &'a str { + state + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == state.active_endpoint_id) + .map_or("Local", |endpoint| endpoint.label.as_str()) +} + +fn render_endpoint_row( + buffer: &mut Buffer, + rect: Rect, + marker: &str, + endpoint: &ClientShellEndpoint, + highlighted: bool, + palette: &Palette, +) { + if highlighted { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let (glyph, state, color) = endpoint_status_presentation(endpoint.status, palette); + let state = if endpoint.status == ClientEndpointStatus::Online { + "" + } else { + state + }; + let signal = if endpoint.endpoint_id.is_local() { + String::new() + } else if state.is_empty() { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_width = display_width(&signal).min(rect.width); + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(signal_width.saturating_add(1)), + &format!(" {marker} {}", endpoint.label), + Style::default() + .fg( + if matches!(endpoint.status, ClientEndpointStatus::Disabled) { + palette.overlay0 + } else { + palette.text + }, + ) + .add_modifier(Modifier::BOLD), + ); + put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color)); +} diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs new file mode 100644 index 00000000..2f5a8e46 --- /dev/null +++ b/src/client/shell/endpoints.rs @@ -0,0 +1,506 @@ +use super::*; + +#[derive(Clone, Debug)] +pub(crate) struct ClientShellEndpoint { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) label: String, + pub(crate) status: ClientEndpointStatus, + pub(crate) snapshot: Option<Box<ClientShellSnapshot>>, + /// Connection generation that produced `snapshot`. `None` is reserved for local tests. + pub(crate) snapshot_generation: Option<u64>, + pub(crate) agent_recency: HashMap<String, u64>, + pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) methods: Option<HashSet<String>>, +} + +pub(super) struct MachineHit { + pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointFocusTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +impl ClientShellState { + pub(crate) fn set_endpoint_catalog(&mut self, profiles: &[SavedSshEndpoint]) { + let mut next = Vec::with_capacity(profiles.len().saturating_add(1)); + let local = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .cloned() + .unwrap_or_else(local_endpoint); + next.push(local); + for profile in profiles { + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + let previous = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .filter(|endpoint| { + profile.enabled && endpoint.status != ClientEndpointStatus::Disabled + }); + next.push(ClientShellEndpoint { + endpoint_id, + label: profile.label.clone(), + status: previous.map_or( + if profile.enabled { + ClientEndpointStatus::Connecting + } else { + ClientEndpointStatus::Disabled + }, + |endpoint| endpoint.status, + ), + snapshot: previous.and_then(|endpoint| endpoint.snapshot.clone()), + snapshot_generation: previous.and_then(|endpoint| endpoint.snapshot_generation), + agent_recency: previous + .map(|endpoint| endpoint.agent_recency.clone()) + .unwrap_or_default(), + agent_presentation: previous + .map(|endpoint| endpoint.agent_presentation.clone()) + .unwrap_or_default(), + methods: previous.and_then(|endpoint| endpoint.methods.clone()), + }); + } + + if !next + .iter() + .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + { + self.select_unavailable_local(); + } + self.collapsed_endpoints.retain(|endpoint_id| { + next.iter() + .any(|endpoint| &endpoint.endpoint_id == endpoint_id) + }); + 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, + status: ClientEndpointStatus, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = status; + } + } + + pub(crate) fn mark_endpoint_disconnected(&mut self, endpoint_id: &ClientEndpointId) { + self.set_endpoint_status(endpoint_id, ClientEndpointStatus::Reconnecting); + if endpoint_id == &self.active_endpoint_id { + let pending = self.pending_requests.keys().cloned().collect::<Vec<_>>(); + for request_id in pending { + self.cancel_endpoint_request(&request_id); + } + self.pending_integration_installs = 0; + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + } + } + + pub(crate) fn set_endpoint_methods_for( + &mut self, + endpoint_id: &ClientEndpointId, + methods: Option<Vec<String>>, + ) { + let methods = methods.map(|methods| methods.into_iter().collect::<HashSet<_>>()); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.methods = methods; + } + } + + pub(crate) fn endpoint_projection_available(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints.iter().any(|endpoint| { + &endpoint.endpoint_id == endpoint_id + && endpoint.status == ClientEndpointStatus::Online + && endpoint.snapshot.is_some() + }) + } + + pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool { + let Some(endpoint) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return false; + }; + if endpoint.status != ClientEndpointStatus::Online { + return false; + } + let Some(snapshot) = endpoint.snapshot.clone() else { + return false; + }; + if endpoint_id != &self.active_endpoint_id { + self.active_endpoint_id = endpoint_id.clone(); + self.pane_surface = None; + self.pending_pane_surface = None; + } + self.apply_active_snapshot(snapshot); + true + } + + pub(crate) fn endpoint_status( + &self, + endpoint_id: &ClientEndpointId, + ) -> Option<ClientEndpointStatus> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status) + } + + pub(crate) fn endpoint_has_snapshot(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| endpoint.snapshot.is_some()) + } + + pub(crate) fn endpoint_is_online(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoint_status(endpoint_id) == Some(ClientEndpointStatus::Online) + && self.endpoint_has_snapshot(endpoint_id) + } + + pub(crate) fn endpoint_boot_id(&self, endpoint_id: &ClientEndpointId) -> Option<&str> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)? + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + } + + pub(crate) fn endpoint_snapshot_matches( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + revision: u64, + ) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| { + endpoint + .snapshot_generation + .is_none_or(|snapshot_generation| snapshot_generation == generation) + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.boot_id == boot_id && snapshot.revision == revision + }) + }) + } + + pub(crate) fn endpoint_snapshot_identity( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + ) -> Option<(&str, u64)> { + let endpoint = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?; + if endpoint + .snapshot_generation + .is_some_and(|snapshot_generation| snapshot_generation != generation) + { + return None; + } + endpoint + .snapshot + .as_deref() + .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) + } + + /// A terminal normally starts focused. `None` means this host cannot report focus events, + /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline. + pub(crate) fn host_focus_baseline(&self) -> bool { + self.outer_focused.unwrap_or(true) + } + + pub(crate) fn endpoint_label(&self, endpoint_id: &ClientEndpointId) -> &str { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map_or("Unknown endpoint", |endpoint| endpoint.label.as_str()) + } + + pub(crate) fn active_endpoint_label(&self) -> &str { + self.endpoint_label(&self.active_endpoint_id) + } + + pub(crate) fn endpoint_is_active(&self, endpoint_id: &ClientEndpointId) -> bool { + &self.active_endpoint_id == endpoint_id + } + + pub(crate) fn multi_endpoint_active(&self) -> bool { + self.endpoints.len() > 1 + } + + #[cfg(test)] + pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_snapshot(&endpoint_id, snapshot); + } + + #[cfg(test)] + pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_methods_for(&endpoint_id, methods); + } + + pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { + self.endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) + } + + pub(super) fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + #[cfg(test)] + pub(crate) fn cache_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, None, snapshot, true); + } + + pub(crate) fn cache_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, true); + } + + /// Metadata delivered while an endpoint surface is inactive must never advance this + /// aggregate client's viewed watermark, even if the frozen source frame still exists. + pub(crate) fn cache_endpoint_snapshot_inactive_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, false); + } + + fn cache_endpoint_snapshot_with_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: Option<u64>, + mut snapshot: Box<ClientShellSnapshot>, + acknowledge_surface: bool, + ) { + snapshot + .commands + .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return; + }; + if self.endpoints[index].snapshot_generation == generation + && self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| { + previous.boot_id == snapshot.boot_id && previous.revision > snapshot.revision + }) + { + return; + } + let boot_changed = self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| previous.boot_id != snapshot.boot_id); + if boot_changed { + self.retire_endpoint_notifications(endpoint_id); + } + self.endpoints[index] + .agent_presentation + .project_snapshot(&mut snapshot); + let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id { + self.pane_surface.as_ref() + } else { + None + }; + if let Some(surface) = presented_surface { + self.endpoints[index] + .agent_presentation + .acknowledge_surface(&mut snapshot, surface, self.outer_focused); + } + let previous = self.endpoints[index].snapshot.as_deref(); + let mut next_recency = self + .endpoints + .iter() + .flat_map(|endpoint| endpoint.agent_recency.values()) + .copied() + .max() + .unwrap_or_default(); + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + agents.sort_by_key(|agent| agent.state_change_seq); + let mut recency = self.endpoints[index].agent_recency.clone(); + for agent in agents { + let changed = previous + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|previous| previous.pane_id == agent.pane_id) + }) + .is_none_or(|previous| previous.state_change_seq != agent.state_change_seq); + if changed { + next_recency = next_recency.saturating_add(1); + recency.insert(agent.pane_id.clone(), next_recency); + } + } + recency.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + let endpoint = &mut self.endpoints[index]; + endpoint.agent_recency = recency; + endpoint.snapshot_generation = generation; + endpoint.snapshot = Some(snapshot); + } + + pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool { + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + else { + return false; + }; + let changed = { + let endpoint = &mut self.endpoints[index]; + let Some(snapshot) = endpoint.snapshot.as_deref_mut() else { + return false; + }; + endpoint + .agent_presentation + .acknowledge_surface(snapshot, surface, self.outer_focused) + }; + if changed { + self.snapshot = self.endpoints[index].snapshot.clone(); + } + changed + } + + #[cfg(test)] + pub(crate) fn set_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot(endpoint_id, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + pub(crate) fn set_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + fn apply_cached_endpoint_snapshot(&mut self, endpoint_id: &ClientEndpointId) { + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.clone()) + else { + return; + }; + if endpoint_id == &self.active_endpoint_id { + self.apply_active_snapshot(snapshot); + } + } +} + +pub(super) fn endpoint_status_presentation( + status: ClientEndpointStatus, + palette: &Palette, +) -> (&'static str, &'static str, ratatui::style::Color) { + match status { + ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow), + ClientEndpointStatus::Online => ("●", "online", palette.green), + ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow), + ClientEndpointStatus::Attention => ("!", "attention", palette.red), + ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0), + } +} + +pub(super) fn local_endpoint() -> ClientShellEndpoint { + ClientShellEndpoint { + endpoint_id: ClientEndpointId::Local, + label: "Local".into(), + status: ClientEndpointStatus::Online, + snapshot: None, + snapshot_generation: None, + agent_recency: HashMap::new(), + agent_presentation: Default::default(), + methods: None, + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index 8a512d3a..31061c9b 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -3,10 +3,7 @@ use super::*; impl ClientShellState { #[cfg(unix)] pub(crate) fn graphics_scope(&self) -> &str { - self.snapshot - .as_deref() - .map(|snapshot| snapshot.boot_id.as_str()) - .unwrap_or_default() + self.graphics.scope() } #[cfg(unix)] diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index d2ca3a82..f8826136 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -236,6 +236,9 @@ impl ClientShellState { self.outer_focused = Some(true); outcome.query_host_appearance = true; outcome.repaint |= self.config.redraw_on_focus_gained; + if let Some(surface) = self.pane_surface.clone() { + outcome.repaint |= self.acknowledge_active_surface_agents(&surface); + } outcome .requests .push(ClientMessage::ClientShellFocus { focused: true }); @@ -798,7 +801,36 @@ impl ClientShellState { ) { use crate::input::{KeybindAction, KeybindMatch}; - let indexed_target_exists = match &binding { + if !self.indexed_navigation_target_exists(&binding) { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + pub(super) fn indexed_navigation_target_exists( + &self, + binding: &crate::input::KeybindMatch, + ) -> bool { + use crate::input::{KeybindAction, KeybindMatch}; + + match binding { KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { self.snapshot.as_deref().is_some_and(|snapshot| { self.navigation_workspace_entries(snapshot) @@ -819,38 +851,15 @@ impl ClientShellState { }) .is_some(), KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { - self.snapshot.as_deref().is_some_and(|snapshot| { - super::agent_sidebar::ordered_agent_pane_ids( - snapshot, - self.config.agent_panel_sort, - ) - .get(*index) - .is_some() - }) + super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() } _ => true, - }; - if !indexed_target_exists { - return; } - - if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { - self.cycle_pane(false, outcome); - } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { - self.cycle_pane(true, outcome); - } else { - if !preserve_navigate { - self.mode = ClientShellMode::Terminal; - } - self.record_binding(binding, outcome); - } - if !preserve_navigate { - if self.mode == ClientShellMode::Navigate { - self.mode = self.copy_or_terminal_mode(); - } - self.navigate_workspace_id = None; - } - outcome.repaint = true; } fn move_navigate_workspace(&mut self, delta: isize) { diff --git a/src/client/shell/input_source.rs b/src/client/shell/input_source.rs new file mode 100644 index 00000000..7997c72f --- /dev/null +++ b/src/client/shell/input_source.rs @@ -0,0 +1,41 @@ +use super::*; + +impl ClientShellState { + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 014f51a0..abe50590 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -202,6 +202,16 @@ fn render_header_button( } } +fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str { + match status { + ClientEndpointStatus::Connecting => "connecting", + ClientEndpointStatus::Online => "online", + ClientEndpointStatus::Reconnecting => "reconnecting", + ClientEndpointStatus::Attention => "attention", + ClientEndpointStatus::Disabled => "disabled", + } +} + fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { let tabs = snapshot .tabs @@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher( buffer: &mut Buffer, area: Rect, snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, scroll: &mut usize, @@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher( let items = mobile_items( snapshot, + endpoints, + active_endpoint_id, config, selected_workspace_id, viewport.width.saturating_sub(1), @@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher( let end = start.saturating_add(item.lines.len()); if matches!( item.target.as_ref(), - Some(ClientMobileTarget::Workspace(workspace_id)) - if workspace_id == selected_workspace_id + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id ) { if start < *scroll { *scroll = start; @@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { fn mobile_items( snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, content_width: u16, ) -> Vec<MobileItem> { let palette = &config.palette; let mut items = Vec::new(); - let ordered_agents = - super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); - if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { - let title = snapshot - .agent_view_label - .as_deref() + if endpoints.len() > 1 { + items.push(MobileItem::section("machines", palette)); + for endpoint in endpoints { + let background = palette.panel_bg; + let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette); + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled(symbol, Style::default().fg(color).bg(background)), + Span::styled( + format!(" {}", endpoint.label), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + format!(" {state}"), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())), + }); + } + } + let agents = + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agent_view_label = snapshot.agent_view_label.as_deref(); + if !agents.is_empty() || agent_view_label.is_some() { + let title = agent_view_label .map(|label| format!("agents · {label}")) .unwrap_or_else(|| "agents".to_owned()); items.push(MobileItem::section(title, palette)); - if ordered_agents.is_empty() { + if agents.is_empty() { items.push(MobileItem { lines: vec![Line::from(Span::styled( " no matching agents", @@ -586,19 +631,19 @@ fn mobile_items( target: None, }); } - for pane_id in ordered_agents { - let Some(agent) = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - else { - continue; - }; - let workspace = snapshot + for row in agents { + let endpoint = row.endpoint; + let agent = row.agent; + let workspace = endpoint + .snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == agent.workspace_id); - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let tab = endpoint + .snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == agent.tab_id); let agent_label = agent .display_agent .as_deref() @@ -609,7 +654,8 @@ fn mobile_items( .map(|workspace| workspace.label.as_str()) .unwrap_or(agent_label); let mut detail = Vec::new(); - let workspace_tab_count = snapshot + let workspace_tab_count = endpoint + .snapshot .tabs .iter() .filter(|candidate| candidate.workspace_id == agent.workspace_id) @@ -633,11 +679,24 @@ fn mobile_items( }), ); detail.push(agent_label.to_owned()); - let background = if agent.focused { + if endpoint.stale() { + detail.push(mobile_endpoint_state(endpoint.status).to_owned()); + } + let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused { palette.surface_dim } else { palette.panel_bg }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; items.push(MobileItem { lines: vec![ Line::from(vec![ @@ -645,19 +704,24 @@ fn mobile_items( Span::styled( status_icon(agent.agent_status, config.status_indicators), Style::default() - .fg(status_color(agent.agent_status, palette)) - .bg(background), + .fg(if endpoint.stale() { + palette.overlay0 + } else { + status_color(agent.agent_status, palette) + }) + .bg(background) + .add_modifier(dim), ), Span::styled(" ", Style::default().bg(background)), Span::styled( crate::ui::truncate_end( - primary, + &format!("{} · {primary}", endpoint.label), usize::from(content_width.saturating_sub(5)), ), Style::default() - .fg(palette.text) + .fg(foreground) .bg(background) - .add_modifier(Modifier::BOLD), + .add_modifier(Modifier::BOLD | dim), ), ]), Line::from(Span::styled( @@ -665,11 +729,17 @@ fn mobile_items( &format!(" {}", detail.join(" · ")), usize::from(content_width), ), - Style::default().fg(palette.overlay0).bg(background), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), )), ], background, - target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + target: Some(ClientMobileTarget::Agent { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: agent.pane_id.clone(), + }), }); } } @@ -680,91 +750,119 @@ fn mobile_items( ClientMobileTarget::NewWorkspace, palette, )); - for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { - let Some(workspace) = snapshot.workspaces.get(entry.index) else { - continue; - }; - let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let background = if selected { - palette.surface0 - } else if workspace.focused { - palette.surface_dim - } else { - palette.panel_bg - }; - let connector = if entry.indented { - if entry.last_child { - "└─ " + for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) { + for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) { + let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = endpoint.endpoint_id == active_endpoint_id + && selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { + palette.surface_dim } else { - "├─ " - } - } else { - "" - }; - let name = if entry.indented && !workspace.custom_label { - workspace - .branch - .as_deref() - .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) - .unwrap_or(&workspace.label) - } else { - &workspace.label - }; - let branch = workspace.branch.as_deref().unwrap_or("shell"); - let detail_prefix = if entry.indented { - if entry.last_child { - " " + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } } else { - " │ " - } - } else { - " " - }; - items.push(MobileItem { - lines: vec![ - Line::from(vec![ - Span::styled( - format!(" {connector}"), - Style::default().fg(palette.overlay0).bg(background), - ), - Span::styled( - status_icon(workspace.agent_status, config.status_indicators), - Style::default() - .fg(status_color(workspace.agent_status, palette)) - .bg(background), - ), - Span::styled(" ", Style::default().bg(background)), - Span::styled( + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let status = if endpoint.stale() { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }; + let stale_detail = if endpoint.stale() { + format!(" · {}", mobile_endpoint_state(endpoint.status)) + } else { + String::new() + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default().fg(status).bg(background).add_modifier(dim), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + &format!("{} · {name}", endpoint.label), + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(foreground) + .bg(background) + .add_modifier(Modifier::BOLD | dim), + ), + ]), + Line::from(Span::styled( crate::ui::truncate_end( - name, - usize::from(content_width.saturating_sub(if entry.indented { - 8 - } else { - 5 - })), + &format!( + "{detail_prefix}{branch} · {}{stale_detail}", + compact_tab_status(endpoint.snapshot, workspace) + ), + usize::from(content_width), ), Style::default() - .fg(palette.text) + .fg(palette.overlay0) .bg(background) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(Span::styled( - crate::ui::truncate_end( - &format!( - "{detail_prefix}{branch} · {}", - compact_tab_status(snapshot, workspace) - ), - usize::from(content_width), - ), - Style::default().fg(palette.overlay0).bg(background), - )), - ], - background, - target: Some(ClientMobileTarget::Workspace( - workspace.workspace_id.clone(), - )), - }); + .add_modifier(dim), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }), + }); + } } if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { @@ -803,7 +901,10 @@ fn mobile_items( .add_modifier(Modifier::BOLD), ))], background, - target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + target: Some(ClientMobileTarget::Tab { + endpoint_id: active_endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }), }); } } @@ -890,6 +991,22 @@ impl ClientShellState { .find(|(rect, _)| super::contains(*rect, point)) .map(|(_, target)| target.clone()); match target { + Some(ClientMobileTarget::Machine(endpoint_id)) => { + if endpoint_id == self.active_endpoint_id { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } else if self.endpoint_is_online(&endpoint_id) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + } + } Some(ClientMobileTarget::NewWorkspace) => { self.mobile_switcher_suspended = true; self.record_binding( @@ -897,34 +1014,44 @@ impl ClientShellState { outcome, ); } - Some( - target @ (ClientMobileTarget::Workspace(_) - | ClientMobileTarget::Tab(_) - | ClientMobileTarget::Agent(_)), - ) => { - let method = match target { - ClientMobileTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { workspace_id }, - ) - } - ClientMobileTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { - tab_id, - }) - } - ClientMobileTarget::Agent(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { - pane_id, - }) - } - ClientMobileTarget::NewWorkspace - | ClientMobileTarget::NewTab - | ClientMobileTarget::Menu(_) => return true, - }; - self.mode = ClientShellMode::Terminal; - self.navigate_workspace_id = None; - self.push_endpoint_method(method, outcome); + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Agent { + endpoint_id, + pane_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Tab { + endpoint_id, + tab_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Tab(tab_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } } Some(ClientMobileTarget::NewTab) => { self.mobile_switcher_suspended = true; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 612319e2..48646f28 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -466,6 +466,9 @@ impl ClientShellState { if self.hits.workspace_body.height == 0 || point.1 < self.hits.workspace_body.y.saturating_sub(1) || point.1 >= self.hits.new_workspace.y + || self.hits.workspaces.iter().any(|hit| { + hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point) + }) { return None; } @@ -473,12 +476,17 @@ impl ClientShellState { .hits .workspaces .iter() - .filter(|hit| !hit.indented) + .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented) .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) .collect::<Vec<_>>(); let snapshot = self.snapshot.as_deref()?; let entries = render::workspace_entries(snapshot, &self.collapsed_groups); - let last_hit = self.hits.workspaces.last()?; + let last_hit = self + .hits + .workspaces + .iter() + .rev() + .find(|hit| hit.endpoint_id == self.active_endpoint_id)?; let last_position = entries.iter().position(|entry| { snapshot .workspaces @@ -1109,21 +1117,7 @@ impl ClientShellState { .max(mouse.row.abs_diff(press.start_row)); if delta >= 1 { let source_workspace_id = press.workspace_id.clone(); - let draggable = self - .snapshot - .as_deref() - .and_then(|snapshot| { - snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == source_workspace_id) - }) - .is_some_and(|workspace| { - !workspace - .worktree - .as_ref() - .is_some_and(|worktree| worktree.is_linked_worktree) - }); + let draggable = self.endpoint_workspace_is_draggable(press); if draggable { if let Some(target) = self.workspace_drop_target_at(point) { self.chrome_drag = Some(ClientChromeDrag::Workspace { @@ -1268,14 +1262,7 @@ impl ClientShellState { return; } if let Some(press) = self.workspace_press.take() { - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { - workspace_id: press.workspace_id, - }, - ), - outcome, - ); + self.finish_endpoint_workspace_press(press, outcome); return; } if let Some(press) = self.tab_press.take() { @@ -1552,14 +1539,14 @@ impl ClientShellState { .navigator_rows .iter() .find(|(rect, _)| super::contains(*rect, point)) - .copied(); + .cloned(); match mouse.kind { MouseEventKind::Moved => { - if let Some((_, index)) = row_hit { + if let Some((_, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target); } outcome.repaint = true; } @@ -1573,30 +1560,13 @@ impl ClientShellState { navigator.filter = None; } outcome.repaint = true; - } else if let Some((rect, index)) = row_hit { + } else if let Some((rect, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target.clone()); } - let workspace = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - render::client_navigator_rows(snapshot, navigator) - .get(index) - .map(|row| { - matches!( - row.target, - ClientNavigatorTarget::Workspace(_) - ) - }) - } - _ => None, - }) - .unwrap_or(false); + let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. }); if workspace && mouse.column <= rect.x.saturating_add(3) { self.toggle_selected_navigator_workspace(); outcome.repaint = true; @@ -1749,13 +1719,7 @@ impl ClientShellState { return; } let workspace_id = (!self.sidebar_collapsed) - .then(|| { - self.hits - .workspaces - .iter() - .find(|hit| super::contains(hit.rect, point)) - .map(|hit| hit.workspace_id.clone()) - }) + .then(|| self.active_endpoint_workspace_at(point)) .flatten(); if let Some(workspace_id) = workspace_id { self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); @@ -1949,6 +1913,9 @@ impl ClientShellState { outcome.repaint = true; return; } + if self.handle_endpoint_machine_click(point, outcome) { + return; + } if super::contains(self.hits.global_launcher, point) { self.toggle_global_menu(); outcome.repaint = true; @@ -2023,6 +1990,7 @@ impl ClientShellState { .iter() .find(|hit| super::contains(hit.rect, point)) .map(|hit| ClientWorkspacePress { + endpoint_id: hit.endpoint_id.clone(), workspace_id: hit.workspace_id.clone(), start_column: mouse.column, start_row: mouse.row, @@ -2059,6 +2027,9 @@ impl ClientShellState { self.tab_press = Some(tab_press); return; } + if self.handle_endpoint_agent_click(point, outcome) { + return; + } let agent_pane_id = self .hits .agents diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs new file mode 100644 index 00000000..f3eeeb3c --- /dev/null +++ b/src/client/shell/notification_policy.rs @@ -0,0 +1,319 @@ +use super::*; + +const MAX_QUEUED_NOTIFICATIONS: usize = 8; +const COMPLETION_EVIDENCE_GRACE: std::time::Duration = std::time::Duration::from_secs(1); +pub(super) const COMPLETION_RECHECK_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(50); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotificationValidation { + Current, + AwaitingSnapshot, + Stale, +} + +fn notification_duration(kind: SemanticNotificationKind) -> std::time::Duration { + std::time::Duration::from_secs(match kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }) +} + +impl ClientShellState { + pub(super) fn retire_endpoint_notifications(&mut self, endpoint_id: &ClientEndpointId) { + self.pending_notifications + .retain(|pending| &pending.endpoint_id != endpoint_id); + self.queued_notifications + .retain(|queued| &queued.endpoint_id != endpoint_id); + if self + .visible_notification + .as_ref() + .is_some_and(|visible| &visible.endpoint_id == endpoint_id) + { + self.visible_notification = None; + self.promote_queued_notification(std::time::Instant::now()); + } + } + + fn queue_visible_notification( + &mut self, + mut notification: ClientVisibleNotification, + now: std::time::Instant, + ) { + if self.visible_notification.is_none() { + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + return; + } + if self.queued_notifications.len() == MAX_QUEUED_NOTIFICATIONS { + self.queued_notifications.pop_front(); + } + self.queued_notifications.push_back(notification); + } + + fn promote_queued_notification(&mut self, now: std::time::Instant) -> bool { + let Some(mut notification) = self.queued_notifications.pop_front() else { + return false; + }; + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + true + } + + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.as_ref() else { + return; + }; + if notification.event.pane_id.is_some() + && !self.endpoint_is_online(¬ification.endpoint_id) + { + let label = self.endpoint_label(¬ification.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is unavailable")); + outcome.repaint = true; + return; + } + let notification = self + .visible_notification + .take() + .expect("checked visible notification"); + self.promote_queued_notification(std::time::Instant::now()); + outcome.repaint = true; + let Some(pane_id) = notification.event.pane_id else { + return; + }; + if notification.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else if self.endpoint_is_online(¬ification.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: notification.endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + } + + pub(crate) fn receive_notification( + &mut self, + endpoint_id: &ClientEndpointId, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification.as_ref().is_some_and(|visible| { + visible.endpoint_id == *endpoint_id + && visible.event.pane_id.as_deref() == Some(pane_id) + }) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications.retain(|pending| { + pending.endpoint_id != *endpoint_id + || pending.event.pane_id.as_deref() != Some(pane_id) + }); + self.queued_notifications.retain(|queued| { + queued.endpoint_id != *endpoint_id + || queued.event.pane_id.as_deref() != Some(pane_id) + }); + if cleared_visible { + self.visible_notification = None; + self.promote_queued_notification(now); + } + } + // Completion evidence is advisory. A Finished effect is valid only while the + // client-projected pane remains Done, even when delivery is immediate. + let validate_state = delay > 0 || event.kind == SemanticNotificationKind::Finished; + self.pending_notifications.push(ClientPendingNotification { + endpoint_id: endpoint_id.clone(), + event, + deadline, + expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now), + validate_state, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + self.promote_queued_notification(now); + repaint = true; + } + if self + .visible_endpoint_notice + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_endpoint_notice = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state { + match self.notification_validation(&pending.endpoint_id, &pending.event) { + NotificationValidation::Current => {} + NotificationValidation::AwaitingSnapshot if now < pending.expires_at => { + let mut pending = pending; + pending.deadline = now + .checked_add(COMPLETION_RECHECK_INTERVAL) + .unwrap_or(now) + .min(pending.expires_at); + self.pending_notifications.push(pending); + continue; + } + NotificationValidation::AwaitingSnapshot | NotificationValidation::Stale => { + continue; + } + } + } + let target_active = + self.notification_target_is_active(&pending.endpoint_id, &pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + self.queue_visible_notification( + ClientVisibleNotification { + endpoint_id: pending.endpoint_id, + event: pending.event, + deadline: now, + }, + now, + ); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> bool { + if endpoint_id != &self.active_endpoint_id { + return false; + } + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_validation( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> NotificationValidation { + let Some(pane_id) = event.pane_id.as_deref() else { + // Finished notifications carry no independently trustworthy completion state. Without + // a projected pane to verify as Done, do not emit completion chrome or sound. + return if event.kind == SemanticNotificationKind::Finished { + NotificationValidation::Stale + } else { + NotificationValidation::Current + }; + }; + let Some(agent) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) + else { + return NotificationValidation::AwaitingSnapshot; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention + if agent.agent_status == crate::api::schema::AgentStatus::Blocked => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Done => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Working => + { + NotificationValidation::AwaitingSnapshot + } + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + NotificationValidation::Current + } + SemanticNotificationKind::NeedsAttention | SemanticNotificationKind::Finished => { + NotificationValidation::Stale + } + } + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs index 43fe566a..b8437ea7 100644 --- a/src/client/shell/notifications.rs +++ b/src/client/shell/notifications.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use super::notification_policy::COMPLETION_RECHECK_INTERVAL; use super::*; use ratatui::{ style::Color, @@ -5,7 +7,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -fn render_mobile_notice_banner( +pub(super) fn render_mobile_notice_banner( buffer: &mut Buffer, area: Rect, title: &str, @@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner( ) } -pub(super) fn render_mobile_endpoint_notice_banner( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - offset_for_warning: bool, - palette: &Palette, -) -> Rect { - render_mobile_notice_banner( - buffer, - area, - ¬ice.title, - Some(¬ice.body), - palette.red, - offset_for_warning, - palette, - ) -} - -fn render_notification_card( +pub(super) fn render_notification_card( buffer: &mut Buffer, area: Rect, title: &str, @@ -221,205 +205,13 @@ pub(super) fn render_visible_notification( ) } -pub(super) fn render_endpoint_notice( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - top_offset: u16, - palette: &Palette, -) -> Rect { - render_notification_card( - buffer, - area, - ¬ice.title, - ¬ice.body, - crate::config::ToastHerdrPosition::TopRight, - top_offset, - match notice.key.kind { - ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { - palette.red - } - ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { - palette.yellow - } - }, - palette, - ) -} - -impl ClientShellState { - pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { - let Some(notification) = self.visible_notification.take() else { - return; - }; - outcome.repaint = true; - if let Some(pane_id) = notification.event.pane_id { - self.push_endpoint_method( - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), - outcome, - ); - } - } - - pub(crate) fn receive_notification( - &mut self, - event: SemanticNotification, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let delay = if event.kind == SemanticNotificationKind::Custom { - 0 - } else { - self.config.toast_delay_seconds - }; - let deadline = now - .checked_add(std::time::Duration::from_secs(delay)) - .unwrap_or(now); - let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { - self.visible_notification - .as_ref() - .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) - }); - if let Some(pane_id) = event.pane_id.as_deref() { - self.pending_notifications - .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); - if cleared_visible { - self.visible_notification = None; - } - } - self.pending_notifications.push(ClientPendingNotification { - event, - deadline, - validate_state: delay > 0, - }); - let (effects, repaint) = self.tick_notifications(now); - (effects, repaint || cleared_visible) - } - - pub(crate) fn tick_notifications( - &mut self, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let mut repaint = false; - if self - .visible_notification - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_notification = None; - repaint = true; - } - if self - .visible_endpoint_notice - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_endpoint_notice = None; - repaint = true; - } - - let pending = std::mem::take(&mut self.pending_notifications); - let mut effects = Vec::new(); - for pending in pending { - if pending.deadline > now { - self.pending_notifications.push(pending); - continue; - } - if pending.validate_state && !self.notification_still_current(&pending.event) { - continue; - } - let target_active = self.notification_target_is_active(&pending.event); - let suppress_external = target_active && self.outer_focused != Some(false); - if let Some(sound) = pending.event.sound { - let suppress_sound = - pending.event.kind == SemanticNotificationKind::Finished && suppress_external; - if !suppress_sound { - effects.push(ClientShellNotificationEffect::Sound { - sound: match sound { - SemanticNotificationSound::Done => crate::sound::Sound::Done, - SemanticNotificationSound::Request => crate::sound::Sound::Request, - }, - agent: pending.event.agent.clone(), - }); - } - } - - match self.config.toast_delivery { - crate::config::ToastDelivery::Off => {} - crate::config::ToastDelivery::Herdr if !target_active => { - let duration = match pending.event.kind { - SemanticNotificationKind::NeedsAttention => 8, - SemanticNotificationKind::Finished => 5, - SemanticNotificationKind::UpdateInstalled => 3, - SemanticNotificationKind::Custom => 5, - }; - self.visible_notification = Some(ClientVisibleNotification { - event: pending.event, - deadline: now + std::time::Duration::from_secs(duration), - }); - repaint = true; - } - crate::config::ToastDelivery::Herdr => {} - crate::config::ToastDelivery::Terminal if !suppress_external => { - effects.push(ClientShellNotificationEffect::Terminal { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::System if !suppress_external => { - effects.push(ClientShellNotificationEffect::System { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} - } - } - (effects, repaint) - } - - fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { - let Some(snapshot) = self.snapshot.as_deref() else { - return false; - }; - if let Some(tab_id) = event.tab_id.as_deref() { - return snapshot.focused_tab_id.as_deref() == Some(tab_id); - } - event.workspace_id.as_deref().is_some_and(|workspace_id| { - snapshot.focused_workspace_id.as_deref() == Some(workspace_id) - }) - } - - fn notification_still_current(&self, event: &SemanticNotification) -> bool { - let Some(pane_id) = event.pane_id.as_deref() else { - return true; - }; - let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { - snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - }) else { - return false; - }; - match event.kind { - SemanticNotificationKind::NeedsAttention => { - agent.agent_status == crate::api::schema::AgentStatus::Blocked - } - SemanticNotificationKind::Finished => matches!( - agent.agent_status, - crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done - ), - SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, - } - } -} - #[cfg(test)] mod tests { use super::*; fn notification() -> ClientVisibleNotification { ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notice".into(), @@ -463,6 +255,205 @@ mod tests { } #[test] + fn unavailable_notification_target_stays_visible_and_reports_the_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, + event: SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "agent needs attention".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("workspace".into()), + tab_id: Some("tab".into()), + pane_id: Some("pane".into()), + position: None, + }, + deadline: std::time::Instant::now() + std::time::Duration::from_secs(5), + }); + let mut outcome = ClientShellInput::default(); + + state.focus_visible_notification(&mut outcome); + + assert!(state.visible_notification.is_some()); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.body.contains("Local is unavailable"))); + assert!(outcome.repaint); + } + + #[test] + fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + state.set_snapshot(Box::new(snapshot)); + + let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: Some(SemanticNotificationSound::Done), + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("tab_1".into()), + pane_id: Some("pane_1".into()), + position: None, + }, + std::time::Instant::now(), + ); + + assert!(effects.is_empty()); + assert!(!repaint); + assert!(state.visible_notification.is_none()); + } + + #[test] + fn finished_hint_waits_for_the_projected_completion_snapshot() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Terminal; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(snapshot.clone())); + let now = std::time::Instant::now(); + let event = SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("background-tab".into()), + pane_id: Some("pane_1".into()), + position: None, + }; + + let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now); + assert!(effects.is_empty()); + assert_eq!(state.pending_notifications.len(), 1); + + snapshot.revision = snapshot.revision.saturating_add(1); + snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle; + snapshot.agents[0].state_change_seq = 2; + state.set_snapshot(Box::new(snapshot)); + let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished" + )); + assert!(state.pending_notifications.is_empty()); + } + + #[test] + fn retiring_one_endpoint_preserves_other_endpoint_notifications() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut local = notification(); + local.event.title = "local".into(); + let remote_id = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut remote = notification(); + remote.endpoint_id = remote_id.clone(); + remote.event.title = "remote".into(); + state.visible_notification = Some(local); + state.queued_notifications.push_back(remote); + + state.retire_endpoint_notifications(&ClientEndpointId::Local); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| (¬ification.endpoint_id, notification.event.title.as_str())), + Some((&remote_id, "remote")) + ); + } + + #[test] + fn concurrent_endpoint_notifications_are_queued_in_arrival_order() { + let mut config = Config::default(); + config.ui.toast.delivery = crate::config::ToastDelivery::Herdr; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let now = std::time::Instant::now(); + for title in ["first", "second"] { + state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: title.into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: Some(title.into()), + position: None, + }, + now, + ); + } + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("first") + ); + assert_eq!(state.queued_notifications.len(), 1); + + state.tick_notifications(now + std::time::Duration::from_secs(5)); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("second") + ); + assert!(state.queued_notifications.is_empty()); + } + + #[test] fn notification_rect_stays_inside_short_nonzero_area() { let palette = crate::app::client_palette_from_config(&Config::default()); for height in [1, 2] { diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 154b00b6..db089f4e 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -191,98 +191,123 @@ impl ClientShellState { } pub(super) fn open_navigator_overlay(&mut self) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let expanded_workspaces = snapshot - .workspaces - .iter() - .map(|workspace| workspace.workspace_id.clone()) - .collect(); + let expanded_workspaces = + super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints) + .flat_map(|endpoint| { + endpoint.snapshot.workspaces.iter().map(move |workspace| { + (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()) + }) + }) + .collect(); let mut navigator = ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces, }; - let rows = render::client_navigator_rows(snapshot, &navigator); - navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator); + navigator.selected = rows + .iter() + .find(|row| row.current) + .map(|row| row.target.clone()); self.overlay = Some(ClientShellOverlay::Navigator(navigator)); } pub(super) fn move_navigator_selection(&mut self, delta: isize) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { return; }; - let rows = render::client_navigator_rows(snapshot, navigator); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); if rows.is_empty() { - navigator.selected = 0; + navigator.selected = None; return; } - navigator.selected = (navigator.selected as isize + delta) - .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + let selected = + super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0); + let next = + (selected as isize + delta).clamp(0, rows.len().saturating_sub(1) as isize) as usize; + navigator.selected = Some(rows[next].target.clone()); } pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { - let target = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .map(|row| row.target.clone()) - }; + let target = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator) + } + _ => None, + }); let Some(target) = target else { return; }; - self.overlay = None; - let method = match target { - ClientNavigatorTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { - workspace_id, - }) - } - ClientNavigatorTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + let activated = match target { + ClientNavigatorTarget::Machine { endpoint_id } => { + self.activate_endpoint(endpoint_id, outcome) } - ClientNavigatorTarget::Pane(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ), + ClientNavigatorTarget::Tab { + endpoint_id, + tab_id, + } => { + self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome) } + ClientNavigatorTarget::Pane { + endpoint_id, + pane_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ), }; - self.push_endpoint_method(method, outcome); + if activated { + self.overlay = None; + } outcome.repaint = true; } pub(super) fn toggle_selected_navigator_workspace(&mut self) { - let workspace_id = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .and_then(|row| match &row.target { - ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), - _ => None, - }) - }; - if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = - (workspace_id, self.overlay.as_mut()) + let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then( + |target| match target { + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => Some((endpoint_id, workspace_id)), + _ => None, + }, + ) + } + _ => None, + }); + if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_key, self.overlay.as_mut()) { - if !navigator.expanded_workspaces.remove(&workspace_id) { - navigator.expanded_workspaces.insert(workspace_id); + if !navigator.expanded_workspaces.remove(&workspace_key) { + navigator.expanded_workspaces.insert(workspace_key); } - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } } @@ -433,7 +458,7 @@ impl ClientShellState { Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { navigator.query.push_str(text); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; true } _ => false, @@ -641,11 +666,11 @@ impl ClientShellState { if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if code == KeyCode::Backspace { navigator.query.pop(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if let KeyCode::Char(character) = code { if modifiers.difference(KeyModifiers::SHIFT).is_empty() { navigator.filter = None; @@ -654,7 +679,7 @@ impl ClientShellState { } else { navigator.query.push(character); } - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -664,7 +689,7 @@ impl ClientShellState { if code == KeyCode::Backspace && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { if navigator.filter.take().is_some() { - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -672,25 +697,23 @@ impl ClientShellState { } if code == KeyCode::Home && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } outcome.repaint = true; return; } if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { - let last = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - Some(render::client_navigator_rows(snapshot, navigator).len()) - } - _ => None, - }) - .unwrap_or(0) - .saturating_sub(1); + let last = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ) + .last() + .map(|row| row.target.clone()), + _ => None, + }); if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.selected = last; } @@ -735,7 +758,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = Some(filter); - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; @@ -744,7 +767,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 30ac78b9..faf252a8 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -10,7 +10,7 @@ pub(crate) struct OverlayRender { pub(crate) cancel: Rect, pub(crate) navigator_popup: Rect, pub(crate) navigator_search: Rect, - pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(crate) worktree_search: Rect, pub(crate) worktree_rows: Vec<(Rect, usize)>, pub(crate) help_popup: Rect, @@ -33,6 +33,8 @@ pub(crate) fn render_client_overlay( b: &mut Buffer, o: &ClientShellOverlay, s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, k: &LiveKeybindConfig, p: &Palette, ) -> Option<OverlayRender> { @@ -58,7 +60,9 @@ pub(crate) fn render_client_overlay( ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), - ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Navigator(v) => { + render_navigator_overlay(b, v, endpoints, active_endpoint_id, p) + } ClientShellOverlay::Settings(v) => { settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) } @@ -669,91 +673,11 @@ fn render_rename_overlay( }) } -pub(crate) fn client_navigator_rows( - s: &ClientShellSnapshot, - n: &ClientNavigatorOverlay, -) -> Vec<ClientNavigatorRow> { - let q = n.query.trim().to_lowercase(); - let filter = |st| match n.filter { - Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, - Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, - Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, - Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, - None => true, - }; - let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); - let filtering = n.filter.is_some() || !q.is_empty(); - let mut out = Vec::new(); - for w in &s.workspaces { - let mut children = Vec::new(); - for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { - let mut panes = Vec::new(); - for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { - let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); - let st = a - .map(|a| a.agent_status) - .unwrap_or(crate::api::schema::AgentStatus::Unknown); - let label = p - .label - .clone() - .or_else(|| a.and_then(|a| a.name.clone())) - .or_else(|| a.and_then(|a| a.display_agent.clone())) - .or_else(|| a.and_then(|a| a.title.clone())) - .unwrap_or_else(|| format!("pane {}", ix + 1)); - let meta = p - .foreground_cwd - .clone() - .or_else(|| p.cwd.clone()) - .unwrap_or_default(); - if !filtering || filter(st) && (text(&label) || text(&meta)) { - panes.push(ClientNavigatorRow { - depth: 2, - label, - meta, - status: st, - current: s.focused_pane_id.as_deref() == Some(&p.pane_id), - target: ClientNavigatorTarget::Pane(p.pane_id.clone()), - }) - } - } - if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { - children.push(ClientNavigatorRow { - depth: 1, - label: t.label.clone(), - meta: format!( - "{} panes", - s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() - ), - status: t.agent_status, - current: s.focused_tab_id.as_deref() == Some(&t.tab_id), - target: ClientNavigatorTarget::Tab(t.tab_id.clone()), - }); - children.extend(panes) - } - } - let wm = - filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); - if !filtering || wm || !children.is_empty() { - out.push(ClientNavigatorRow { - depth: 0, - label: w.label.clone(), - meta: w.branch.clone().unwrap_or_default(), - status: w.agent_status, - current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), - target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), - }); - if n.expanded_workspaces.contains(&w.workspace_id) || filtering { - out.extend(children) - } - } - } - out -} - fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, - s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, p: &Palette, ) -> Option<OverlayRender> { let a = b.area; @@ -766,7 +690,7 @@ fn render_navigator_overlay( a.height.saturating_sub(my * 2).max(4), ); let i = panel(b, q, p.accent, p.panel_bg)?; - let rows = client_navigator_rows(s, n); + let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { format!(" / {}", n.query) } else if let Some(f) = n.filter { @@ -798,7 +722,12 @@ fn render_navigator_overlay( b, i, i.y, - &format!("{} panes", s.panes.len()), + &format!( + "{} panes", + rows.iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count() + ), Style::default().fg(p.overlay0).bg(p.panel_bg), ); put_text( @@ -810,14 +739,12 @@ fn render_navigator_overlay( Style::default().fg(p.surface1).bg(p.panel_bg), ); let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0); let max = rows.len().saturating_sub(body.height as usize); let scroll = n .scroll - .max( - n.selected - .saturating_sub(body.height.saturating_sub(1) as usize), - ) - .min(n.selected) + .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) + .min(selected) .min(max); let mut row_hits = Vec::new(); for (vis, (ix, r)) in rows @@ -828,8 +755,17 @@ fn render_navigator_overlay( .enumerate() { let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); - row_hits.push((rect, ix)); - let st = if ix == n.selected { + row_hits.push((rect, r.target.clone())); + let st = if r.stale { + Style::default() + .fg(p.overlay0) + .bg(if ix == selected { + p.surface0 + } else { + p.panel_bg + }) + .add_modifier(Modifier::DIM) + } else if ix == selected { Style::default() .fg(contrast(p)) .bg(p.accent) @@ -840,23 +776,92 @@ fn render_navigator_overlay( .bg(p.panel_bg) }; b.set_style(rect, st); - let tree = match r.depth { - 0 => match &r.target { - ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", - _ => "▸", - }, - 1 => "└──", - _ => " └──", + let tree = match &r.target { + ClientNavigatorTarget::Machine { .. } => "▾", + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } if n + .expanded_workspaces + .contains(&(endpoint_id.clone(), workspace_id.clone())) => + { + if r.depth == 0 { + "▾" + } else { + " ▾" + } + } + ClientNavigatorTarget::Workspace { .. } => { + if r.depth == 0 { + "▸" + } else { + " ▸" + } + } + ClientNavigatorTarget::Tab { .. } => { + if r.depth == 1 { + "└──" + } else { + " └──" + } + } + ClientNavigatorTarget::Pane { .. } => { + if r.depth == 2 { + " └──" + } else { + " └──" + } + } }; - let label = format!( - " {} {} {} {}", - if r.current { "◆" } else { " " }, - tree, - status_dot(r.status), - r.label - ); + let current = if r.current { "◆ " } else { "" }; + let status = r.status.map(status_dot).unwrap_or_default(); + let status_separator = if status.is_empty() { "" } else { " " }; + let label = format!(" {tree} {current}{status}{status_separator}{}", r.label); put_text(b, rect.x, rect.y, rect.width, &label, st); - if !r.meta.is_empty() { + if let Some(status) = r.status { + let prefix = format!(" {tree} {current}"); + let status_style = if r.stale || ix == selected { + st + } else { + Style::default().fg(status_color(status, p)).bg(p.panel_bg) + }; + put_text( + b, + rect.x.saturating_add(display_width(&prefix)), + rect.y, + display_width(status_dot(status)), + status_dot(status), + status_style, + ); + } + let machine_status = match &r.target { + ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status), + _ => None, + }; + if let Some(status) = machine_status { + let (glyph, state, color) = endpoint_status_presentation(status, p); + let signal = if status == ClientEndpointStatus::Online { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_style = if ix == selected { + st + } else { + Style::default() + .fg(color) + .bg(p.panel_bg) + .add_modifier(if r.stale { + Modifier::DIM + } else { + Modifier::empty() + }) + }; + put_right_text(b, rect, rect.y, &signal, signal_style); + } else if !r.meta.is_empty() { let label_width = display_width(&label).min(rect.width); let meta = Rect::new( rect.x.saturating_add(label_width).saturating_add(1), @@ -868,7 +873,7 @@ fn render_navigator_overlay( } } let dy = i.bottom() - 2; - if let Some(r) = rows.get(n.selected) { + if let Some(r) = rows.get(selected) { put_text( b, i.x, diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 387695ec..ad9ac8b1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -3,16 +3,31 @@ use super::*; #[path = "../shell/overlays.rs"] mod overlays; #[path = "../shell/sidebar.rs"] -mod sidebar; +pub(in crate::client::shell) mod sidebar; #[path = "../shell/tabs.rs"] mod tabs; pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; -pub(super) use overlays::{ - client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, -}; +pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows; +pub(super) use overlays::{render_client_overlay, render_context_menu, render_global_menu}; pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; + +pub(in crate::client::shell) fn render_sidebar_background( + buffer: &mut Buffer, + area: Rect, + palette: &Palette, +) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + pub(super) fn render_mode_bar( buffer: &mut Buffer, pane_area: Rect, @@ -196,6 +211,9 @@ pub(super) fn render_mode_bar( } pub(super) struct ShellRenderState<'a> { + pub(super) endpoints: &'a [ClientShellEndpoint], + pub(super) active_endpoint_id: &'a ClientEndpointId, + pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>, pub(super) collapsed_groups: &'a HashSet<String>, pub(super) workspace_scroll: &'a mut usize, pub(super) agent_scroll: &'a mut usize, @@ -228,7 +246,26 @@ pub(super) fn render_shell( ); } if layout.sidebar.width > 0 { - if state.sidebar_collapsed { + if state.endpoints.len() > 1 { + if state.sidebar_collapsed { + super::endpoint_sidebar::render_collapsed( + buffer, + layout.sidebar, + config, + &mut state, + &mut hits, + ); + } else { + super::endpoint_sidebar::render_expanded( + buffer, + layout.sidebar, + Some(snapshot), + config, + &mut state, + &mut hits, + ); + } + } else if state.sidebar_collapsed { render_collapsed_sidebar( buffer, layout.sidebar, @@ -267,8 +304,10 @@ pub(super) fn render_shell( hits.agent_scrollbar = Rect::default(); hits.agent_sort_toggle = Rect::default(); hits.new_workspace = Rect::default(); + hits.machines.clear(); hits.workspaces.clear(); hits.agents.clear(); + hits.endpoint_agents.clear(); hits.tab_scroll_left = Rect::default(); hits.tab_scroll_right = Rect::default(); hits.new_tab = Rect::default(); @@ -277,7 +316,7 @@ pub(super) fn render_shell( hits } -fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { +pub(super) fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { let width = display_width(text).min(area.width); put_text( buffer, diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs index d90d371f..200201c5 100644 --- a/src/client/shell/settings.rs +++ b/src/client/shell/settings.rs @@ -313,6 +313,9 @@ impl ClientShellState { (true, Vec::new()) } PendingEndpointKind::IntegrationInstall => { + let cancelled = result + .as_ref() + .is_err_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); self.pending_integration_installs = self.pending_integration_installs.saturating_sub(1); if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { @@ -328,7 +331,8 @@ impl ClientShellState { } settings.installing_integrations = self.pending_integration_installs > 0; } - let actions = if self.pending_integration_installs == 0 + let actions = if !cancelled + && self.pending_integration_installs == 0 && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { let mut deferred = ClientShellInput::default(); diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 2a71e297..0aaa0d51 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -4,7 +4,9 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; -fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { +pub(in crate::client::shell) fn collapsed_sidebar_sections( + area: Rect, +) -> (Rect, Option<u16>, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); if content.is_empty() { return (Rect::default(), None, Rect::default()); @@ -85,6 +87,7 @@ pub(crate) fn render_collapsed_sidebar( ); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: false, group_toggle: None, @@ -308,6 +311,7 @@ pub(crate) fn render_sidebar( config.status_indicators, entry, rows, + true, selected, dragged, palette, @@ -330,6 +334,7 @@ pub(crate) fn render_sidebar( }); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: entry.indented, group_toggle, @@ -440,17 +445,6 @@ pub(crate) fn render_sidebar( ); } -pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { - buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); - let separator_x = area.right().saturating_sub(1); - for y in area.y..area.bottom() { - if let Some(cell) = buffer.cell_mut((separator_x, y)) { - cell.set_symbol("│"); - cell.set_style(Style::default().fg(palette.surface_dim)); - } - } -} - pub(crate) fn workspace_entries( snapshot: &ClientShellSnapshot, collapsed_groups: &HashSet<String>, @@ -540,7 +534,7 @@ pub(crate) fn workspace_entries( entries } -fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { +pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { let workspace = snapshot.workspaces.get(index)?; let worktree = workspace.worktree.as_ref()?; if worktree.is_linked_worktree { @@ -560,7 +554,7 @@ fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<Stri .then(|| worktree.key.clone()) } -fn displayed_workspace_status( +pub(super) fn displayed_workspace_status( snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace, collapsed_groups: &HashSet<String>, @@ -589,7 +583,7 @@ fn displayed_workspace_status( .unwrap_or(workspace.agent_status) } -fn workspace_rows( +pub(in crate::client::shell) fn workspace_rows( workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, indented: bool, @@ -618,7 +612,7 @@ fn workspace_rows( ) } -fn render_workspace_rows( +pub(in crate::client::shell) fn render_workspace_rows( buffer: &mut Buffer, area: Rect, workspace: &ClientShellWorkspace, @@ -626,6 +620,7 @@ fn render_workspace_rows( indicators: crate::config::StatusIndicatorStyle, entry: &WorkspaceEntry, rows: Vec<Vec<crate::ui::ResolvedToken>>, + endpoint_active: bool, selected: bool, dragged: bool, palette: &Palette, @@ -661,7 +656,7 @@ fn render_workspace_rows( } else { x = x.saturating_add(3); } - let highlighted = workspace.focused || dragged; + let highlighted = endpoint_active && workspace.focused || dragged; let workspace_style = Style::default() .fg(if highlighted { palette.text @@ -673,7 +668,7 @@ fn render_workspace_rows( } else { Modifier::empty() }); - let secondary_style = Style::default().fg(if workspace.focused { + let secondary_style = Style::default().fg(if endpoint_active && workspace.focused { palette.mauve } else { palette.overlay0 @@ -703,7 +698,7 @@ fn render_workspace_rows( Some(palette.selection_bg) } else if dragged { Some(palette.surface1) - } else if workspace.focused { + } else if endpoint_active && workspace.focused { Some(palette.active_row_bg) } else { None diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 60307f66..67bdff91 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum ClientMobileTarget { + Machine(ClientEndpointId), NewWorkspace, - Workspace(String), + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, NewTab, - Tab(String), - Agent(String), + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Agent { + endpoint_id: ClientEndpointId, + pane_id: String, + }, Menu(usize), } #[derive(Default)] pub(super) struct ShellHitMap { + pub(super) machines: Vec<MachineHit>, pub(super) workspaces: Vec<WorkspaceHit>, pub(super) workspace_body: Rect, pub(super) workspace_scrollbar: Rect, @@ -139,6 +150,7 @@ pub(super) struct ShellHitMap { pub(super) popup: Option<PaneHit>, pub(super) pane_splits: Vec<PaneSplitHit>, pub(super) agents: Vec<(Rect, String)>, + pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>, pub(super) agent_body: Rect, pub(super) agent_scrollbar: Rect, pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>, @@ -164,7 +176,7 @@ pub(super) struct ShellHitMap { pub(super) overlay_cancel: Rect, pub(super) navigator_popup: Rect, pub(super) navigator_search: Rect, - pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(super) worktree_search: Rect, pub(super) worktree_rows: Vec<(Rect, usize)>, pub(super) help_popup: Rect, @@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture { } pub(super) struct ClientWorkspacePress { + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) start_column: u16, pub(super) start_row: u16, @@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag { pub(super) struct WorkspaceHit { pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) indented: bool, pub(super) group_toggle: Option<(Rect, String)>, @@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit { #[derive(Debug)] pub(crate) enum ClientShellAction { Endpoint { + endpoint_id: ClientEndpointId, boot_id: String, request: Box<crate::api::schema::Request>, }, ClipboardWrite(Vec<u8>), Request(ClientMessage), OpenSafeWebUrl(String), + ActivateEndpoint { + endpoint_id: ClientEndpointId, + target: Option<ClientEndpointFocusTarget>, + }, ReplayMouse(Vec<crossterm::event::MouseEvent>), Keybind(crate::input::KeybindAction), } @@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter { Done, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum ClientNavigatorTarget { - Workspace(String), - Tab(String), - Pane(String), + Machine { + endpoint_id: ClientEndpointId, + }, + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Pane { + endpoint_id: ClientEndpointId, + pane_id: String, + }, } #[derive(Clone, Debug)] @@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow { pub(super) depth: u8, pub(super) label: String, pub(super) meta: String, - pub(super) status: crate::api::schema::AgentStatus, + pub(super) status: Option<crate::api::schema::AgentStatus>, + pub(super) stale: bool, pub(super) current: bool, pub(super) target: ClientNavigatorTarget, } @@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow { pub(super) struct ClientNavigatorOverlay { pub(super) query: String, pub(super) search_focused: bool, - pub(super) selected: usize, + pub(super) selected: Option<ClientNavigatorTarget>, pub(super) scroll: usize, pub(super) filter: Option<ClientNavigatorFilter>, - pub(super) expanded_workspaces: HashSet<String>, + pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>, } #[derive(Debug)] @@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect { } pub(super) struct ClientPendingNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, + pub(super) expires_at: std::time::Instant, pub(super) validate_state: bool, } pub(super) struct ClientVisibleNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, } @@ -861,6 +896,9 @@ pub(crate) struct ClientShellState { pub(super) config: ClientShellConfig, pub(super) snapshot: Option<Box<ClientShellSnapshot>>, pub(super) pane_surface: Option<PaneSurfaceFrame>, + /// A future projection surface waits here until its matching snapshot arrives. The visible + /// pane surface always remains an exact snapshot pair. + pub(super) pending_pane_surface: Option<PaneSurfaceFrame>, pub(super) graphics: crate::kitty_graphics::surface::ClientState, pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, pub(super) popup_terminal_id: Option<String>, @@ -887,6 +925,9 @@ pub(crate) struct ClientShellState { pub(super) last_tab_bar_width: Option<u16>, pub(super) last_composed_size: Option<(u16, u16)>, pub(super) hits: ShellHitMap, + pub(super) endpoints: Vec<ClientShellEndpoint>, + pub(super) active_endpoint_id: ClientEndpointId, + pub(super) collapsed_endpoints: HashSet<ClientEndpointId>, pub(super) mode: ClientShellMode, pub(super) navigate_workspace_id: Option<String>, pub(super) overlay: Option<ClientShellOverlay>, @@ -917,14 +958,11 @@ pub(crate) struct ClientShellState { pub(super) popup_pending: bool, pub(super) popup_pending_deadline: Option<std::time::Instant>, pub(super) next_request_id: u64, - /// Methods advertised by this endpoint. `None` is used only by local tests - /// and legacy construction paths; negotiated endpoint connections always - /// install an explicit set. - pub(super) endpoint_methods: Option<HashSet<String>>, pub(super) pending_requests: HashMap<String, PendingEndpointRequest>, pub(super) pending_integration_installs: usize, pub(super) pending_notifications: Vec<ClientPendingNotification>, pub(super) visible_notification: Option<ClientVisibleNotification>, + pub(super) queued_notifications: VecDeque<ClientVisibleNotification>, pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>, pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>, pub(super) outer_focused: Option<bool>, @@ -1000,6 +1038,7 @@ impl ClientShellState { config, snapshot: None, pane_surface: None, + pending_pane_surface: None, graphics: crate::kitty_graphics::surface::ClientState::default(), graphics_cell_size: crate::kitty_graphics::HostCellSize { width_px: 1, @@ -1029,6 +1068,9 @@ impl ClientShellState { last_tab_bar_width: None, last_composed_size: None, hits: ShellHitMap::default(), + endpoints: vec![local_endpoint()], + active_endpoint_id: ClientEndpointId::Local, + collapsed_endpoints: HashSet::new(), mode: ClientShellMode::Terminal, navigate_workspace_id: None, overlay, @@ -1059,11 +1101,11 @@ impl ClientShellState { popup_pending: false, popup_pending_deadline: None, next_request_id: 1, - endpoint_methods: None, pending_requests: HashMap::new(), pending_integration_installs: 0, pending_notifications: Vec::new(), visible_notification: None, + queued_notifications: VecDeque::new(), endpoint_notice_seen: HashSet::new(), visible_endpoint_notice: None, outer_focused: None, @@ -1078,29 +1120,6 @@ impl ClientShellState { } } - pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { - self.endpoint_methods = methods.map(|methods| methods.into_iter().collect()); - } - - pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { - self.endpoint_methods - .as_ref() - .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) - } - - fn focused_tab_count(&self) -> usize { - let Some(snapshot) = self.snapshot.as_deref() else { - return 0; - }; - snapshot - .tabs - .iter() - .filter(|tab| { - Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() - }) - .count() - } - pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { if !self.mobile_switcher_suspended || self.overlay.is_some() { return false; @@ -1173,16 +1192,80 @@ impl ClientShellState { } } - pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) { + 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<ClientShellSnapshot>) { snapshot .commands .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); - if self.snapshot.as_ref().is_some_and(|current| { - current.boot_id == snapshot.boot_id && snapshot.revision < current.revision - }) { + let graphics_scope = format!( + "{}:{}", + self.active_endpoint_id.storage_key(), + snapshot.boot_id + ); + let endpoint_boot_changed = + self.snapshot.is_some() && self.graphics.scope() != graphics_scope; + if !endpoint_boot_changed + && self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) + { return; } - self.graphics.set_scope(&snapshot.boot_id); + self.graphics.set_scope(&graphics_scope); let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { current.commands.len() != snapshot.commands.len() || current @@ -1221,10 +1304,11 @@ impl ClientShellState { self.local_config_diagnostic.as_deref(), snapshot.config_diagnostic.as_deref(), ); - let boot_changed = self - .snapshot - .as_ref() - .is_some_and(|current| current.boot_id != snapshot.boot_id); + let boot_changed = endpoint_boot_changed + || self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); if boot_changed || self .pane_surface @@ -1234,55 +1318,7 @@ impl ClientShellState { self.hits = ShellHitMap::default(); } if boot_changed { - self.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.pending_notifications.clear(); - self.visible_notification = None; - 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() @@ -1474,16 +1510,64 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + let pending_surface = self.pending_pane_surface.take(); + if let Some(surface) = pending_surface { + let matching = self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }); + if matching { + self.install_pane_surface(surface, false); + } else if self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision > snapshot.revision + }) { + self.pending_pane_surface = Some(surface); + } + } self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } - pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + pub(crate) fn has_presented_surface(&self) -> bool { + self.pane_surface.is_some() + } + + pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision { + return; + } + if self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) { + return; + } + if surface.projection_revision == snapshot.revision.saturating_add(1) { + // The next expected surface waits separately for its exact snapshot. Keeping the + // current pair avoids treating this speculative successor as presentation evidence. + self.pending_pane_surface = Some(surface); + self.hits = ShellHitMap::default(); + return; + } + // A surface that skips one or more revisions supersedes any retained pair, but is still + // not rendered until its matching snapshot arrives. Retain it monotonically so delayed + // intermediate surfaces cannot replace it. + self.install_pane_surface(surface, true); + } + + fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) { let Some(snapshot) = self.snapshot.as_ref() else { return; }; if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision + || (!retain_future && surface.projection_revision != snapshot.revision) || self.pane_surface.as_ref().is_some_and(|current| { current.boot_id == surface.boot_id && (surface.projection_revision < current.projection_revision @@ -1493,13 +1577,12 @@ impl ClientShellState { { return; } - if self - .snapshot - .as_ref() - .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) - { + // A retained future surface is not presentable yet. Clear hit targets immediately; the + // exact-pair compose guard prevents it from replacing the visible frame. + if surface.projection_revision != snapshot.revision { self.hits = ShellHitMap::default(); } + self.acknowledge_active_surface_agents(&surface); let previous_popup = self.popup_terminal_id.clone(); let next_popup = surface .popup @@ -1696,45 +1779,8 @@ impl ClientShellState { pub(crate) fn invalidate_pane_surface(&mut self) { self.pane_surface = None; + self.pending_pane_surface = None; self.hits = ShellHitMap::default(); self.host_mouse_pixels = None; } - - fn wants_ascii_input(&self) -> bool { - if let Some(overlay) = self.overlay.as_ref() { - return matches!( - overlay, - ClientShellOverlay::ConfirmClose(_) - | ClientShellOverlay::Help(_) - | ClientShellOverlay::Navigator(_) - | ClientShellOverlay::WorktreeRemove(_) - | ClientShellOverlay::ContextMenu(_) - | ClientShellOverlay::GlobalMenu(_) - ); - } - matches!( - self.mode, - ClientShellMode::Prefix - | ClientShellMode::Navigate - | ClientShellMode::Resize - | ClientShellMode::Copy - ) - } - - pub(crate) fn reconcile_input_source(&mut self) { - // Keep the platform restore token while another window has focus. Restoring - // through a global key injection is only safe after this client regains focus. - if self.outer_focused == Some(false) { - return; - } - let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); - if desired != self.ascii_input_source_active { - self.ascii_input_source_active = desired; - self.pending_input_source_changes.push(desired); - } - } - - pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { - std::mem::take(&mut self.pending_input_source_changes) - } } diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 2ccfe77b..10f97118 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() { &state.overlay, Some(ClientShellOverlay::WorktreeCreate(create)) if create.checkout_path - == std::path::Path::new("/tmp/herdr-worktrees") - .join("repo") - .join("feature-client-shell").to_string_lossy() + == "/tmp/herdr-worktrees/repo/feature-client-shell" )); let submit = state.handle_input_bytes(b"\r"); let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { @@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { state.set_pane_surface(surface()); let now = std::time::Instant::now(); let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "first".into(), @@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_some()); state.config.toast_delay_seconds = 1; let (_, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "replacement".into(), diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index e8555db6..7d71a981 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { state.handle_input_bytes(b"\x1b"); state.handle_input_bytes(b"a"); state.compose(106, 30).expect("navigator rows"); - let pane_index = { - let snapshot = state.snapshot.as_deref().expect("snapshot"); + let pane_target = { let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") else { panic!("expected navigator"); }; - render::client_navigator_rows(snapshot, navigator) + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) .iter() - .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .map(|row| row.target.clone()) .expect("pane row") }; let pane_rect = state .hits .navigator_rows .iter() - .find(|(_, index)| *index == pane_index) + .find(|(_, target)| *target == pane_target) .map(|(rect, _)| *rect) .expect("visible pane row"); let select = diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs new file mode 100644 index 00000000..8149b530 --- /dev/null +++ b/src/client/shell/tests/endpoint_requests.rs @@ -0,0 +1,298 @@ +use super::*; +use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus}; + +fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + assert!(state.popup_pending); + (state, outcome.actions) +} + +fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected worktree preparation"); + }; + state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None))); + state.handle_input_bytes(b"feature/reconnect"); + let outcome = state.handle_input_bytes(b"\r"); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating + )); + (state, outcome.actions) +} + +fn request_id(actions: &[ClientShellAction]) -> &str { + let [ClientShellAction::Endpoint { request, .. }] = actions else { + panic!("expected one endpoint request"); + }; + &request.id +} + +#[test] +fn cancelling_popup_request_unblocks_input_and_ignores_late_success() { + let (mut state, actions) = pending_popup(); + let id = request_id(&actions); + assert!(state.cancel_endpoint_request(id)); + assert!(!state.popup_pending); + assert!(state.pending_requests.is_empty()); + assert!(state + .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {})) + .1 + .is_empty()); + assert!(!state.popup_pending); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +#[test] +fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() { + let (mut state, _) = pending_worktree(); + state.mark_endpoint_disconnected(&ClientEndpointId::Local); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.set_pane_surface(surface()); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))]); + assert!(state.overlay.is_none()); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +struct TestTransport { + fail: bool, +} + +impl crate::client::endpoint::EndpointTransport for TestTransport { + fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> { + if self.fail { + Err(std::io::ErrorKind::BrokenPipe.into()) + } else { + Ok(()) + } + } +} + +#[test] +fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + for fail_send in [false, true] { + let (mut state, actions) = pending_worktree(); + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: fail_send }, + 1, + EndpointNegotiation::default(), + ); + endpoints.set_surface_active(&ClientEndpointId::Local, fail_send); + let mut commands = EndpointCommands::default(); + let (tx, _rx) = tokio::sync::mpsc::channel(16); + let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions( + actions, + &mut commands, + &mut endpoints, + Some(&mut state), + &mut Vec::new(), + &tx, + ) + .unwrap(); + assert!(repaint); + assert!(replay.is_empty()); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| { notice.title == "Action interrupted" })); + assert!(commands.disconnect(&ClientEndpointId::Local).is_empty()); + } +} + +#[test] +fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + let (mut state, actions) = pending_popup(); + let stale_id = request_id(&actions).to_owned(); + let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into())); + let current_id = request_id(¤t).to_owned(); + let mut commands = EndpointCommands::default(); + for (generation, actions) in [(1, actions), (2, current)] { + for action in actions { + let ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } = action + else { + panic!("expected endpoint request"); + }; + commands.enqueue(endpoint_id, generation, boot_id, request); + } + } + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: false }, + 2, + EndpointNegotiation::default(), + ); + let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints); + assert_eq!(cancelled, vec![stale_id.clone()]); + state.cancel_endpoint_request(&stale_id); + assert!(!state.popup_pending); + assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id)); + assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", ¤t_id)); + assert!(state.pending_requests.contains_key(¤t_id)); +} + +#[test] +fn cancelled_integration_install_does_not_queue_a_refresh() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.open_settings_overlay(); + let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else { + panic!("settings overlay"); + }; + settings.installing_integrations = true; + state.pending_integration_installs = 1; + let mut outcome = ClientShellInput::default(); + assert!(state.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Pi, + } + ), + PendingEndpointKind::IntegrationInstall, + &mut outcome, + )); + assert!(state.cancel_endpoint_request(request_id(&outcome.actions))); + assert!(state.pending_requests.is_empty()); + assert_eq!(state.pending_integration_installs, 0); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::Settings(settings)) + if !settings.installing_integrations && !settings.loading_integrations + )); +} + +#[test] +fn cancelled_selection_does_not_replay_its_fallback_key() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.selection = Some(crate::selection::Selection::absolute_range( + "pane_1".into(), + (0, 0), + (0, 2), + )); + let mut outcome = ClientShellInput::default(); + state.request_selection_copy_with_fallback( + &mut outcome, + Some(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )), + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.pending_requests.is_empty()); +} + +#[test] +fn cancelled_link_activation_does_not_replay_mouse_input() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + let pane_id = state.hits.panes[0].pane_id.clone(); + let inner_rect = state.hits.panes[0].inner_rect; + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events: vec![MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: inner_rect.x, + row: inner_rect.y, + modifiers: KeyModifiers::NONE, + }], + }, + &mut outcome, + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn another_machine_disconnect_does_not_cancel_active_popup() { + let (mut state, _) = pending_popup(); + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + state.mark_endpoint_disconnected(&remote); + assert!(state.popup_pending); + assert_eq!(state.pending_requests.len(), 1); +} diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs new file mode 100644 index 00000000..52c71923 --- /dev/null +++ b/src/client/shell/tests/endpoints.rs @@ -0,0 +1,1205 @@ +use super::*; +use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, +}; +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; + +fn remote_profile() -> SavedSshEndpoint { + SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "dev@build.example".into(), + session: "agents".into(), + enabled: true, + } +} + +fn agent( + name: &str, + status: crate::api::schema::AgentStatus, + state_change_seq: u64, +) -> ClientShellAgent { + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(name.into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } +} + +fn state_with_remote() -> (ClientShellState, ClientEndpointId) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.workspaces[0].label = "remote-workspace".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + (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::<String>(); + 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)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let remote = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting); + state.set_endpoint_status(&remote, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote, Box::new(snapshot())); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + let frame = state + .compose(cols, rows) + .expect("connection chrome without Local"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .unwrap() + .rect; + let buffer = frame.to_ratatui_buffer().unwrap(); + let local_row = (local.x..local.right()) + .map(|x| buffer[(x, local.y)].symbol()) + .collect::<String>(); + assert!(!local_row.contains("reconnecting")); + assert!( + !local_row.contains('◐'), + "Local never gets a connection badge" + ); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == remote) + .unwrap() + .rect; + let mut outcome = ClientShellInput::default(); + state.handle_mouse( + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x + 5, + row: hit.y, + modifiers: KeyModifiers::NONE, + }, + &mut outcome, + ); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote) + ); + assert!( + state.snapshot.is_none(), + "selection is committed only by coherent activation" + ); + } +} + +#[test] +fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() { + let (mut state, _) = state_with_remote(); + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("Local")); + assert!(text.contains("Build")); + assert!(text.contains("remote-workspace")); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); + assert_eq!( + buffer[(remote.right() - 1, remote.y)].fg, + state.config.palette.green + ); + + state.sidebar_collapsed = true; + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("collapsed local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("collapsed remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); +} + +#[test] +fn active_workspace_is_the_only_highlight_when_machine_is_expanded() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); + assert_eq!( + buffer[(workspace.x + 2, workspace.y)].bg, + state.config.palette.active_row_bg + ); + + state.collapsed_endpoints.insert(endpoint_id.clone()); + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_eq!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); +} + +#[test] +fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let mut config = Config::default(); + config.ui.status_indicators = StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("○ Local · local agent"), "frame: {text}"); + assert!(text.contains("× Build · remote agent"), "frame: {text}"); + assert!(text.contains("grouped"), "frame: {text}"); + let toggle = state.hits.agent_sort_toggle; + assert!(!toggle.is_empty()); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + + let buffer = frame + .to_ratatui_buffer() + .expect("aggregate frame should reconstruct"); + assert!(buffer + .content() + .iter() + .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red)); +} + +#[test] +fn aggregate_priority_uses_client_observed_recency_across_machines() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 2)]; + state.set_snapshot(Box::new(local)); + let frame_text = |state: &mut ClientShellState| { + let frame = state.compose(100, 28).expect("combined endpoint frame"); + frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + }; + let text = frame_text(&mut state); + assert!( + text.find("Local · local agent").expect("local agent") + < text.find("Build · remote agent").expect("remote agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + let mut outcome = ClientShellInput::default(); + assert!( + state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,) + ); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); +} + +#[test] +fn unselected_endpoint_completion_projects_done_client_side() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("background agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + remote.revision = 2; + remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)]; + + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let status = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.agents.first()) + .map(|agent| agent.agent_status); + assert_eq!(status, Some(AgentStatus::Done)); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote endpoint hit") + .rect; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert_eq!( + state + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()), + Some("boot-1") + ); +} + +#[test] +fn context_menu_lookup_ignores_inactive_endpoint_workspaces() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + + assert_eq!( + state.active_endpoint_workspace_at((remote.x, remote.y)), + None + ); + assert_eq!( + state.active_endpoint_workspace_at((local.x, local.y)), + Some("ws_1".into()) + ); +} + +#[test] +fn future_surface_waits_for_its_exact_snapshot_revision() { + let (mut state, _) = state_with_remote(); + let mut future = surface(); + future.projection_revision = 2; + future.surface_revision = 2; + state.set_pane_surface(future); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(1) + ); + assert_eq!( + state + .pending_pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + + let mut next = snapshot(); + next.revision = 2; + state.set_snapshot(Box::new(next)); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + assert!(state.pending_pane_surface.is_none()); +} + +#[test] +fn inactive_endpoint_snapshot_cache_never_regresses_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut newest = snapshot(); + newest.boot_id = "remote-boot".into(); + newest.revision = 3; + newest.workspaces[0].label = "newest".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(newest)); + let mut delayed = snapshot(); + delayed.boot_id = "remote-boot".into(); + delayed.revision = 2; + delayed.workspaces[0].label = "delayed".into(); + + state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed)); + + let label = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.workspaces.first()) + .map(|workspace| workspace.label.as_str()); + assert_eq!(label, Some("newest")); +} + +#[test] +fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut previous = snapshot(); + previous.boot_id = "shared-server-boot".into(); + previous.revision = 9; + previous.workspaces[0].label = "old connection".into(); + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous)); + let mut reconnected = snapshot(); + reconnected.boot_id = "shared-server-boot".into(); + reconnected.revision = 1; + reconnected.workspaces[0].label = "new connection".into(); + + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected)); + + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + assert_eq!(endpoint.snapshot_generation, Some(5)); + assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1); + assert_eq!( + endpoint.snapshot.as_ref().unwrap().workspaces[0].label, + "new connection" + ); +} + +#[test] +fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.mark_endpoint_disconnected(&endpoint_id); + let mut replacement = snapshot(); + replacement.boot_id = "replacement-boot".into(); + state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!( + state.snapshot.as_deref().unwrap().boot_id, + "replacement-boot" + ); +} + +#[test] +fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let (mut state, endpoint_id) = state_with_remote(); + state.config.status_indicators = StatusIndicatorStyle::Symbols; + state.config.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + endpoint.snapshot.as_mut().expect("remote snapshot").agents = + vec![agent("remote agent", AgentStatus::Blocked, 1)]; + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.pending_integration_installs = 2; + + state.mark_endpoint_disconnected(&endpoint_id); + let frame = state.compose(100, 28).expect("frozen endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + + assert_eq!(state.pending_integration_installs, 0); + assert_eq!( + state.endpoint_status(&endpoint_id), + Some(ClientEndpointStatus::Reconnecting) + ); + assert!(text.contains("◐ reconnecting"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); + assert!( + text.contains("LIVE"), + "frozen surface should remain: {text}" + ); + assert!(state.hits.panes.is_empty()); + assert!(frame.cursor.is_none()); + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + let stale_icon = buffer + .content() + .iter() + .find(|cell| cell.symbol() == "×") + .expect("stale blocked icon"); + assert_eq!(stale_icon.fg, state.config.palette.overlay0); +} + +#[cfg(unix)] +#[test] +fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + let local_scope = state.graphics_scope().to_owned(); + let mut remote = snapshot(); + remote.boot_id = "boot-1".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let remote_scope = state.graphics_scope(); + assert_ne!(local_scope, remote_scope); + assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:")); +} + +#[test] +fn navigator_uses_machine_parents_only_for_federated_clients() { + let (mut state, _) = state_with_remote(); + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machines = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. })) + .collect::<Vec<_>>(); + assert_eq!( + machines + .iter() + .map(|row| row.label.as_str()) + .collect::<Vec<_>>(), + vec!["Local", "Build"] + ); + assert!(rows.iter().all(|row| { + matches!(row.target, ClientNavigatorTarget::Machine { .. }) + || (!row.label.contains("Local ·") && !row.label.contains("Build ·")) + })); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(), + ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(), + ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(), + ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + })); + assert_eq!(rows.iter().filter(|row| row.current).count(), 1); + + let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + local.set_snapshot(Box::new(snapshot())); + local.set_pane_surface(surface()); + let frame = local.compose(100, 28).expect("local-only sidebar"); + assert!(local.hits.machines.is_empty()); + assert!(!frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + .contains(" machines")); + local.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator); + assert!(rows + .iter() + .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. }))); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Workspace { .. } => row.depth == 0, + ClientNavigatorTarget::Tab { .. } => row.depth == 1, + ClientNavigatorTarget::Pane { .. } => row.depth == 2, + ClientNavigatorTarget::Machine { .. } => false, + })); +} + +#[test] +fn navigator_keeps_saved_machine_visible_before_metadata_arrives() { + let (mut state, endpoint_id) = state_with_remote(); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("saved remote endpoint"); + endpoint.snapshot = None; + endpoint.status = ClientEndpointStatus::Connecting; + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + + assert!(rows.iter().any(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) && row.label == "Build" + && row.stale + })); + assert!(!rows.iter().any(|row| match &row.target { + ClientNavigatorTarget::Machine { .. } => false, + ClientNavigatorTarget::Workspace { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Tab { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Pane { + endpoint_id: target, + .. + } => target == &endpoint_id, + })); +} + +#[test] +fn navigator_machine_selection_opens_its_remembered_view() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .into_iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .map(|row| row.target) + .expect("remote machine row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn navigator_foreign_pane_selection_activates_its_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote pane row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut local = snapshot(); + let mut inserted = local.workspaces[0].clone(); + inserted.workspace_id = "ws_2".into(); + inserted.focused = false; + local.workspaces.push(inserted); + state.set_snapshot(Box::new(local)); + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_agent = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Agent { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + .then_some(*rect) + }) + .expect("remote agent target"); + let click = |rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let outcome = state.handle_raw_events(vec![click(remote_agent)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("remote workspace target"); + let outcome = state.handle_raw_events(vec![click(remote_workspace)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)), + }] if activated == &endpoint_id && workspace_id == "ws_1" + )); +} + +#[test] +fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)]; + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machine = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .expect("cached remote machine row"); + assert!(machine.stale); + let row = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .expect("cached remote pane row"); + assert!(row.stale); + assert!(!row.meta.contains("reconnecting")); + row.target.clone() + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut navigator_outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut navigator_outcome); + assert!(navigator_outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(_)) + )); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let frame = state.compose(44, 30).expect("offline mobile switcher"); + let mobile_target = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("cached remote workspace target"); + let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer"); + assert_eq!( + buffer[(mobile_target.x, mobile_target.y)].fg, + state.config.palette.overlay0 + ); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: mobile_target.x, + row: mobile_target.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn focus_agent_index_uses_online_aggregate_rows() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + let focus_agent = + |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index)); + + assert!(state.indexed_navigation_target_exists(&focus_agent(0))); + assert!(!state.indexed_navigation_target_exists(&focus_agent(1))); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + assert!(!state.indexed_navigation_target_exists(&focus_agent(0))); +} + +#[test] +fn workspace_drag_rejects_foreign_endpoint_slots() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("aggregate sidebar"); + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let mouse = |kind, rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind, + column: rect.x.saturating_add(1), + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]); + state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]); + + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn collapsed_aggregate_workspace_status_uses_its_status_color() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .workspaces[0] + .agent_status = AgentStatus::Blocked; + state.sidebar_collapsed = true; + + let frame = state.compose(100, 28).expect("collapsed aggregate sidebar"); + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame buffer"); + assert_eq!( + buffer[(workspace.x.saturating_add(2), workspace.y)].fg, + state.config.palette.red + ); +} + +#[test] +fn navigator_foreign_tab_selection_keeps_the_tab_target() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Tab { + endpoint_id: target_endpoint, + tab_id, + .. + } if target_endpoint == &endpoint_id && tab_id == "tab_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote tab row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Tab(tab_id)), + }] if activated == &endpoint_id && tab_id == "tab_1" + )); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index d20485b3..b8f17e57 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() { assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); let create = state.handle_input_bytes(b"c"); - let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + let [ClientShellAction::Endpoint { + boot_id, request, .. + }] = &create.actions[..] + else { panic!("expected one endpoint action: {:?}", create.actions); }; assert_eq!(boot_id, "boot-1"); diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs index 094b141f..b7cc2a47 100644 --- a/src/client/shell/tests/mobile.rs +++ b/src/client/shell/tests/mobile.rs @@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() { state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "bottom notification".into(), @@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() { } #[test] +fn mobile_switcher_can_activate_an_online_saved_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = crate::client::endpoint::SavedSshEndpoint { + id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + }; + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + state.mode = ClientShellMode::Navigate; + + let frame = state.compose(44, 30).expect("mobile machine switcher"); + let text = frame + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("machines")); + assert!(text.contains("Build")); + let machine = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect) + }) + .expect("saved machine target"); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: machine.x, + row: machine.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.mark_endpoint_disconnected(&endpoint_id); + state.receive_endpoint_unavailable("network lost".into()); + state.mode = ClientShellMode::Navigate; + let stale = state.compose(44, 30).expect("stale mobile switcher"); + let text = stale + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("reconnecting"), "frame: {text}"); + assert!(text.contains("network lost"), "frame: {text}"); +} + +#[test] fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { let mut config = ClientShellConfig::from_config(&Config::default()); config.mouse_capture = false; @@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1" + ) + .then_some(*rect) }) .expect("workspace hit"); let focused = state.handle_raw_events(vec![click(workspace_hit)]); @@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1" + ) + .then_some(*rect) }) .expect("agent hit"); let focused = state.handle_raw_events(vec![click(agent_hit)]); @@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Tab { + endpoint_id, + tab_id, + } if endpoint_id.is_local() && tab_id == "tab_1" + ) + .then_some(*rect) }) .expect("tab hit"); let focused = state.handle_raw_events(vec![click(tab_hit)]); @@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); assert!(state.mobile_switcher_scroll > 2); assert!(state.hits.mobile_targets.iter().any(|(_, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8" + ) })); let close = state.hits.mobile_close; state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 4cf97152..497ce2c6 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -6,7 +6,7 @@ use crate::protocol::{ }; use crossterm::event::MouseEvent; -fn snapshot() -> ClientShellSnapshot { +pub(super) fn snapshot() -> ClientShellSnapshot { ClientShellSnapshot { boot_id: "boot-1".into(), revision: 1, @@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; mod copy; +mod endpoint_requests; +mod endpoints; #[path = "input.rs"] mod input_domain; mod keybindings_settings; diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 6ccdcf6e..044ef145 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces: HashSet::new(), diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 2e2901e8..7107aa71 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { state.set_snapshot(Box::new(endpoint_snapshot)); state.set_pane_surface(surface()); state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notification".into(), diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index bcadf1c8..ea2e8f24 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -1,10 +1,24 @@ use super::*; +fn checkout_path_preview(root: &str, repo: &str, branch: &str) -> String { + // This is an endpoint path, not a path on the machine drawing the dialog. + let separator = if !root.starts_with('/') && root.contains('\\') { + '\\' + } else { + '/' + }; + format!( + "{}{separator}{repo}{separator}{}", + root.trim_end_matches(separator), + crate::worktree::branch_to_path_slug(branch) + ) +} + impl ClientShellState { - fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> { + fn endpoint_worktree_directory(&self) -> Option<String> { self.snapshot .as_deref() - .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + .map(|snapshot| snapshot.worktree_directory.clone()) } pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { @@ -241,13 +255,8 @@ impl ClientShellState { let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { return; }; - create.checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &create.repo_name, - &create.branch, - ) - .display() - .to_string(); + create.checkout_path = + checkout_path_preview(&worktree_directory, &create.repo_name, &create.branch); create.error = None; } @@ -270,9 +279,7 @@ impl ClientShellState { create.branch = branch.clone(); create.replace_on_type = false; create.checkout_path = - crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) - .display() - .to_string(); + checkout_path_preview(&worktree_directory, &create.repo_name, &branch); create.creating = true; create.error = None; let workspace_id = create.source_workspace_id.clone(); @@ -399,13 +406,8 @@ impl ClientShellState { let Some(worktree_directory) = self.endpoint_worktree_directory() else { return false; }; - let checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &source.repo_name, - &branch, - ) - .display() - .to_string(); + let checkout_path = + checkout_path_preview(&worktree_directory, &source.repo_name, &branch); self.overlay = Some(ClientShellOverlay::WorktreeCreate( ClientWorktreeCreateOverlay { source_workspace_id: workspace_id, @@ -549,3 +551,24 @@ impl ClientShellState { } } } + +#[cfg(test)] +mod path_tests { + use super::checkout_path_preview; + + #[test] + fn endpoint_path_style_is_independent_of_the_client_os() { + assert_eq!( + checkout_path_preview("/worktrees/", "repo", "feature/a"), + "/worktrees/repo/feature-a" + ); + assert_eq!( + checkout_path_preview(r"C:\worktrees\", "repo", "feature/a"), + r"C:\worktrees\repo\feature-a" + ); + assert_eq!( + checkout_path_preview(r"\\server\share", "repo", "feature/a"), + r"\\server\share\repo\feature-a" + ); + } +} diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs new file mode 100644 index 00000000..8a4af0b7 --- /dev/null +++ b/src/client/shell_runtime.rs @@ -0,0 +1,739 @@ +use super::*; + +pub(super) fn dispatch_client_shell_actions( + actions: Vec<shell::ClientShellAction>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + endpoints: &mut endpoint::EndpointRegistry, + mut shell: Option<&mut shell::ClientShellState>, + detached_process_children: &mut Vec<std::process::Child>, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(Vec<crossterm::event::MouseEvent>, bool), ClientError> { + let mut replay_mouse = Vec::new(); + let mut repaint = false; + for action in actions { + match action { + shell::ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } => { + if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| { + endpoints.active_id() == &endpoint_id && endpoints.active_surface_available() + }) { + endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request); + } else if let Some(shell) = shell.as_deref_mut() { + repaint |= shell.cancel_endpoint_request(&request.id); + } + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + if endpoints.active_surface_available() { + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + } + shell::ClientShellAction::ActivateEndpoint { + endpoint_id, + target, + } => { + let _ = event_tx.try_send(ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force: false, + }); + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + // A source-off-first handoff leaves the registry's committed identity pointing at a + // deliberately surface-inactive source. Do not drain its retained queue into a server that + // must reject it; completion below resumes the committed owner's lane. + if endpoints.active_surface_available() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = shell { + for request_id in cancelled { + repaint |= shell.cancel_endpoint_request(&request_id); + } + } + } + Ok((replay_mouse, repaint)) +} + +pub(super) fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +pub(super) fn sync_client_shell_keyboard_report_all( + state: &mut ClientState, +) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +pub(super) fn clear_endpoint_host_effects( + state: &mut ClientState, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, +) { + state.endpoint_mouse_capture_requested = false; + state.endpoint_sgr_pixels_requested = false; + let enabled = if state.shell.is_some() { + state.shell_mouse_capture_preference + } else { + state.direct_mouse_capture_preference + }; + let sgr_pixels = super::effective_sgr_pixel_mouse(enabled, false, state.pixel_geometry_exact); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(std::sync::atomic::Ordering::Acquire) + { + let _ = super::set_mouse_capture(enabled, sgr_pixels); + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, std::sync::atomic::Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, std::sync::atomic::Ordering::Release); + + state.pane_keyboard_report_all = false; + let _ = sync_client_shell_keyboard_report_all(state); + let _ = crate::terminal_effects::write_window_title(&mut std::io::stdout(), None); +} + +pub(super) fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn install_pending_activation( + state: &mut ClientState, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + activation: endpoint::PendingEndpointActivation, +) { + let retired = activation + .source_command_lane() + .map(|source| endpoint_commands.retire_lane(source)) + .unwrap_or_default(); + if let Some(shell) = state.shell.as_mut() { + for request_id in retired { + shell.cancel_endpoint_request(&request_id); + } + } + *next_surface_serial = next_surface_serial.saturating_add(1); + state.freeze_presentation(); + *pending = Some(activation); +} + +pub(super) fn begin_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + force: bool, + now: std::time::Instant, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(), ClientError> { + if let Some(activation) = pending.as_mut() { + if activation.can_retarget(&endpoint_id) { + let retarget_error = activation.retarget(target, endpoints).err(); + if let Some(error) = retarget_error { + rollback_endpoint_activation(state, endpoints, pending, error, false); + } + } else { + // Once rollback starts, even a request for the original target is a new intent. It + // replaces the retained successor instead of mutating the transaction being retired. + let outcome = activation.supersede(endpoint_id, target, endpoints); + if let endpoint::ActivationRollback::Unavailable(message) = outcome { + *pending = None; + present_handoff_unavailable(state, message); + } + } + return Ok(()); + } + let already_active = !force + && endpoints.active_id() == &endpoint_id + && endpoints + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + if already_active { + if let (Some(shell), Some(target)) = (state.shell.as_mut(), target) { + let actions = shell.focus_endpoint_target(target); + let (_, repaint) = dispatch_client_shell_actions( + actions, + endpoint_commands, + endpoints, + Some(shell), + &mut state.detached_process_children, + event_tx, + )?; + if repaint { + if let Some(frame) = shell.compose(state.reported_size.0, state.reported_size.1) { + state.present_frame(frame); + } + } + } + return Ok(()); + } + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let resize = 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, + ); + match endpoint::PendingEndpointActivation::begin( + shell, + endpoints, + endpoint_id.clone(), + target, + resize, + *next_surface_serial, + now, + ) { + Ok(activation) => install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + activation, + ), + Err(endpoint::ActivationBeginError::Preflight(error)) => { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "{}: {error}", + shell.endpoint_label(&endpoint_id) + )); + } + } + Err(endpoint::ActivationBeginError::Partial { activation, error }) => { + // A send error is not evidence that its peer did not observe the write. Freeze and + // retain the lifecycle object before rollback so no source or target output can be + // projected until one ownership path has been proved again. + install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + *activation, + ); + rollback_endpoint_activation( + state, + endpoints, + pending, + format!( + "{}: {error}", + state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| format!("{endpoint_id:?}")) + ), + false, + ); + } + } + Ok(()) +} + +pub(super) fn complete_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, +) -> Result<Option<ClientLoopEvent>, ClientError> { + let sync_endpoint = pending + .as_ref() + .and_then(endpoint::PendingEndpointActivation::presentation_sync_endpoint) + .cloned(); + if let Some(endpoint_id) = sync_endpoint.as_ref() { + state.replay_host_theme(endpoints, endpoint_id); + } + let completion = { + let Some(activation) = pending.as_mut() else { + return Ok(None); + }; + let Some(shell) = state.shell.as_mut() else { + return Ok(None); + }; + match activation.complete(shell, endpoints) { + Ok(completion) => completion, + Err(error) => { + shell.receive_endpoint_unavailable(error); + return Ok(None); + } + } + }; + + if matches!( + completion, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + ) { + #[cfg(unix)] + if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } = + &completion + { + if previous != endpoint { + state.retire_endpoint_graphics(previous); + } + } + // The coherent target frame can replace the frozen source now, but the registry keeps + // pane input disabled until a second projection epoch has replayed host modes/effects. + state.unfreeze_presentation(); + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + return Ok(None); + } + if completion == endpoint::ActivationCompletion::AwaitingPresentationEffects { + return Ok(None); + } + + let _ = pending.take(); + endpoints.unfreeze_input(); + let successor = match completion { + endpoint::ActivationCompletion::RestoredSource { + error, + successor: next, + .. + } => { + if next.is_none() { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(error); + } + } + next + } + endpoint::ActivationCompletion::Activated => None, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + | endpoint::ActivationCompletion::AwaitingPresentationEffects => unreachable!(), + }; + state.unfreeze_presentation(); + if successor.is_none() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + } + } + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + if let Some(intent) = successor { + return Ok(Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: intent.endpoint_id, + target: intent.target, + force: true, + })); + } + Ok(None) +} + +pub(super) fn present_handoff_unavailable(state: &mut ClientState, message: String) { + // An unavailable committed endpoint has no presentation lease. Keep all pane input and late + // source output blocked, while allowing this client-owned chrome frame through the freeze. + state.freeze_presentation(); + let frame = state.shell.as_mut().and_then(|shell| { + shell.receive_endpoint_unavailable(message); + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = frame { + state.present_frozen_chrome(frame); + } +} + +pub(super) fn rollback_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + error: String, + source_release_rejected: bool, +) { + let Some(activation) = pending.as_mut() else { + return; + }; + match activation.rollback(endpoints, error.clone(), source_release_rejected) { + endpoint::ActivationRollback::Pending => state.freeze_presentation(), + endpoint::ActivationRollback::Unavailable(message) => { + *pending = None; + // No endpoint has been proven safe to present. Keep pane input frozen, but render + // the client-owned unavailable chrome rather than silently swallowing the error. + present_handoff_unavailable(state, message); + } + } +} + +pub(super) fn handle_endpoint_disconnect( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + notice: &str, +) -> bool { + supervisors.disconnected(endpoint_id, generation, now); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + format!("endpoint connection was lost while activating {notice}"), + ); + match outcome { + endpoint::ActivationRollback::Pending => {} + endpoint::ActivationRollback::Unavailable(error) => { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.mark_endpoint_disconnected(endpoint_id); + endpoint_was_active.then(|| format!("{} {notice}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn handle_endpoint_attention( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + message: String, +) -> bool { + endpoints.disconnect(endpoint_id); + supervisors.record_status( + endpoint_id, + generation, + endpoint::ClientEndpointStatus::Attention, + now, + ); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + "endpoint reported attention while activating".into(), + ); + if let endpoint::ActivationRollback::Unavailable(error) = outcome { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Attention); + endpoint_was_active.then(|| format!("{}: {message}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn install_client_shell_snapshot( + state: &mut ClientState, + endpoint_id: &endpoint::ClientEndpointId, + snapshot: Box<crate::protocol::ClientShellSnapshot>, + projection_pending: bool, + endpoints: &mut endpoint::EndpointRegistry, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let Some(connection) = endpoints.connection(endpoint_id) else { + return Ok(()); + }; + let generation = connection.generation; + let project_snapshot = + !projection_pending && endpoints.active_id() == endpoint_id && connection.surface_active; + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { + let waits_for_selected_surface = projection_pending + || (endpoints.active_id() == endpoint_id + && !project_snapshot + && shell.has_presented_surface()); + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + if !waits_for_selected_surface { + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Online); + } + if project_snapshot { + shell.set_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + } else { + shell.cache_endpoint_snapshot_inactive_for_generation( + endpoint_id, + generation, + snapshot, + ); + } + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + 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, + ) + }), + graphics_cleanup, + ) + } else { + (None, None, Vec::new()) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + endpoints.send_to(endpoint_id, &resize); + } + if let Some(frame) = composed { + if projection_pending { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(()) +} + +pub(super) fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option<FrameData>, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<bool, ClientError> { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(endpoints, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = 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, + ); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + let _ = write_to_server(endpoints, &resize); + } + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let (replay, dispatch_repaint) = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + endpoints, + state.shell.as_mut(), + &mut state.detached_process_children, + event_tx, + )?; + let frame = if dispatch_repaint { + state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + } else { + frame + }; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + let active_endpoint_online = state + .shell + .as_ref() + .is_none_or(|shell| shell.endpoint_is_online(endpoints.active_id())) + && endpoints.active_surface_available(); + for request in outcome.requests { + if let ClientMessage::ClientShellHostTheme { update } = &request { + state.record_host_theme_update(update); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_theme(update.clone(), endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + } + // Host focus belongs to a pending target even when the source has gone offline or has + // already had its surface revoked. Route it before the ordinary source-online gate. + if let ClientMessage::ClientShellFocus { focused } = request { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_focus(focused, endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + if active_endpoint_online { + write_to_server(endpoints, &ClientMessage::ClientShellFocus { focused }) + .map_err(ClientError::ConnectionLost)?; + } + continue; + } + if !active_endpoint_online { + continue; + } + if pending_activation.is_some() { + // Pane input and non-focus host effects do not cross the frozen handoff boundary. + continue; + } + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + if pending_activation.is_some() { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(false) +} diff --git a/src/client/startup.rs b/src/client/startup.rs new file mode 100644 index 00000000..40ee2cc8 --- /dev/null +++ b/src/client/startup.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Runs the thin client and enters the main event loop. +pub fn run_client() -> io::Result<()> { + run_client_with_mode(None, None, "connecting to server") +} + +#[cfg(unix)] +pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { + run_client_with_mode( + Some((terminal_id, takeover)), + Some(AttachEscapeState::default()), + "attaching to terminal", + ) +} + +#[cfg(windows)] +pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { + debug_assert!(!crate::platform::capabilities().direct_terminal_attach); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "direct terminal attach is not supported on Windows yet", + )) +} diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 00000000..c26ca165 --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,231 @@ +use super::*; + +/// State tracking for the thin client. +pub(super) struct ClientState { + /// Stateful semantic-frame encoder used when the server sends FrameData. + pub(super) blit_encoder: render_ansi::BlitEncoder, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_mouse_capture_requested: bool, + pub(super) endpoint_sgr_pixels_requested: bool, + /// Latest physical host theme observations, retained so an endpoint selected after the + /// observation receives the same client-owned baseline. + pub(super) host_theme_updates: Vec<crate::protocol::ClientHostThemeUpdate>, + pub(super) direct_mouse_capture_preference: bool, + pub(super) shell_mouse_capture_preference: bool, + pub(super) direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + pub(super) pane_keyboard_report_all: bool, + pub(super) keyboard_report_all_active: bool, + pub(super) reported_size: (u16, u16), + pub(super) reported_cell_size: (u32, u32), + pub(super) sound_config: crate::config::SoundConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_exact: bool, + #[cfg(unix)] + pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, + #[cfg(unix)] + pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>, + #[cfg(unix)] + pub(super) pending_surface_graphics: + HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>, + pub(super) attach_escape: Option<AttachEscapeState>, + #[cfg(unix)] + pub(super) mouse_scroll_lines: usize, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) redraw_on_focus_gained: bool, + pub(super) repaint_pending: bool, + /// During a source-off-first handoff the currently blitted frame remains authoritative until + /// an acknowledged target snapshot/surface pair commits. + pub(super) presentation_frozen: bool, + pub(super) draw_host_cursor: bool, + pub(super) detached_process_children: Vec<std::process::Child>, + pub(super) shell: Option<shell::ClientShellState>, +} + +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); + } + } +} + +impl ClientState { + pub(super) fn request_repaint(&mut self) { + self.repaint_pending = true; + } + + pub(super) fn freeze_presentation(&mut self) { + self.presentation_frozen = true; + } + + pub(super) fn record_host_theme_update( + &mut self, + update: &crate::protocol::ClientHostThemeUpdate, + ) { + use crate::protocol::ClientHostThemeUpdate; + + match update { + ClientHostThemeUpdate::DefaultColor { kind, .. } => { + self.host_theme_updates.retain(|current| { + !matches!( + current, + ClientHostThemeUpdate::DefaultColor { + kind: current_kind, + .. + } if current_kind == kind + ) + }); + } + ClientHostThemeUpdate::PaletteColors(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::PaletteColors(_))), + ClientHostThemeUpdate::Appearance(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::Appearance(_))), + } + self.host_theme_updates.push(update.clone()); + } + + /// Replay the retained physical-host baseline only after an endpoint owns the committed + /// presentation. The endpoint transport preserves this order ahead of the resync control. + pub(super) fn replay_host_theme( + &self, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_id: &endpoint::ClientEndpointId, + ) { + for update in &self.host_theme_updates { + let _ = endpoints.send_to( + endpoint_id, + &crate::protocol::ClientMessage::ClientShellHostTheme { + update: update.clone(), + }, + ); + } + } + + pub(super) fn unfreeze_presentation(&mut self) { + self.presentation_frozen = false; + // A resize or metadata event may have happened while frozen. Force a full frame rather + // than attempting to patch the old source frame. + self.request_repaint(); + } + + /// Present a composed error/chrome frame while retaining the handoff input freeze. The pane + /// cells are still the last coherent surface; only client chrome (including the error) moves. + pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) { + let frozen = self.presentation_frozen; + self.presentation_frozen = false; + self.present_frame(frame_data); + self.presentation_frozen = frozen; + } + + pub(super) fn present_graphics(&mut self, graphics: &[u8]) { + if self.presentation_frozen || graphics.is_empty() || !self.kitty_graphics_enabled { + return; + } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); + } + + pub(super) fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result<bool> { + if self.presentation_frozen || self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); + } + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows + } else { + patch.rows + }; + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); + }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) + } + + #[cfg(unix)] + pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) { + let transfer_ids = self + .pending_surface_graphics + .keys() + .filter(|(owner, _, _)| owner == endpoint_id) + .map(|(_, transfer_id, _)| *transfer_id) + .collect::<Vec<_>>(); + self.pending_surface_graphics + .retain(|(owner, _, _), _| owner != endpoint_id); + if self + .retired_direct_graphics + .as_ref() + .is_some_and(|(owner, _, _)| owner == endpoint_id) + { + self.retired_direct_graphics = None; + } + if let Ok(mut matcher) = self.direct_graphics_response.lock() { + for transfer_id in transfer_ids { + matcher.retire(transfer_id); + } + } + } + + pub(super) fn present_frame(&mut self, frame_data: FrameData) { + if self.presentation_frozen { + return; + } + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; + } +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index a46af2b1..c7328efd 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -89,7 +89,18 @@ fn connect_terminal_session_stream( } }; - match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + match do_handshake( + &mut stream, + cols, + rows, + 0, + 0, + false, + None, + false, + false, + true, + ) { Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {} Ok(handshake) => { eprintln!( diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 5d789444..cca73232 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -156,6 +156,26 @@ fn host_cursor_policy_native_and_drawn_override_auto_detection() { )); } +#[test] +fn image_bridge_follows_the_selected_remote_endpoint() { + let remote = crate::client::endpoint::ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + + assert!(endpoint_accepts_local_images(false, &remote, true)); + assert!(endpoint_accepts_local_images( + true, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images( + false, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images(false, &remote, false)); +} + #[cfg(unix)] #[test] fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { @@ -181,6 +201,11 @@ fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { Some(ctrl_v) )); assert!(!should_bridge_clipboard_image_paste( + &[0x16], + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( b"\x1b[200~text\x1b[201~", true, Some(ctrl_v) diff --git a/src/client/transport.rs b/src/client/transport.rs new file mode 100644 index 00000000..4807c31d --- /dev/null +++ b/src/client/transport.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(super) fn start_endpoint_transport( + stream: LocalStream, + lifetime: impl Send + 'static, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + max_frame_size: usize, +) -> Result<endpoint::NativeEndpointTransport, ClientError> { + let reader = stream.try_clone().map_err(ClientError::ConnectionFailed)?; + let transport = endpoint::NativeEndpointTransport::with_lifetime(stream, lifetime) + .map_err(ClientError::ConnectionFailed)?; + let stopped = transport.stop_handle(); + let event_tx = event_tx.clone(); + std::thread::Builder::new() + .name("endpoint-reader".into()) + .spawn(move || { + server_reader_thread( + reader, + event_tx, + &stopped, + max_frame_size, + endpoint_id, + generation, + ); + }) + .map_err(ClientError::ConnectionFailed)?; + Ok(transport) +} + +/// Reads complete frames while retaining partial-read progress across nonblocking polls. +pub(super) fn server_reader_thread( + mut stream: LocalStream, + event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + should_quit: &Arc<AtomicBool>, + max_frame_size: usize, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, +) { + if stream.set_nonblocking(true).is_err() { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + }); + return; + } + + let mut stream = EndpointReader { + stream: &mut stream, + stopped: should_quit, + }; + loop { + if should_quit.load(Ordering::Acquire) { + break; + } + + match protocol::read_message(&mut stream, max_frame_size) { + Ok(msg) => { + if event_tx + .blocking_send(ClientLoopEvent::ServerMessage { + endpoint_id: endpoint_id.clone(), + generation, + message: Box::new(msg), + }) + .is_err() + { + break; + } + } + Err(protocol::FramingError::UnexpectedEof) => { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(err) => { + warn!(err = %err, "server read error"); + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + } + } +} + +struct EndpointReader<'a> { + stream: &'a mut LocalStream, + stopped: &'a AtomicBool, +} + +impl io::Read for EndpointReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> { + loop { + if self.stopped.load(Ordering::Acquire) { + return Ok(0); + } + match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? { + crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count), + crate::ipc::LocalStreamReadCount::Closed => return Ok(0), + crate::ipc::LocalStreamReadCount::Pending => { + crate::platform::wait_client_stream_readable(self.stream)?; + } + } + } + } +} + +pub(in crate::client) fn write_to_local_server( + stream: &mut LocalStream, + msg: &ClientMessage, +) -> io::Result<()> { + protocol::write_message(stream, msg).map_err(|error| io::Error::other(error.to_string())) +} + +pub(super) trait ClientMessageSink { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()>; +} + +impl ClientMessageSink for LocalStream { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + write_to_local_server(self, message) + } +} + +impl ClientMessageSink for endpoint::EndpointRegistry { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + // The lifecycle loop consumes failures for every endpoint, including Local. A send + // failure must not bypass that transition or tear down unrelated connections. + self.send(message); + Ok(()) + } +} + +pub(super) fn write_to_server( + stream: &mut impl ClientMessageSink, + msg: &ClientMessage, +) -> io::Result<()> { + stream.send_client_message(msg) +} diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index f5715889..32838c21 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -104,6 +104,7 @@ pub struct SidebarTokenStyle { pub enum AgentSidebarToken { StateIcon, StateText, + Machine, Workspace, Tab, Pane, @@ -234,6 +235,7 @@ fn agent_token_name(token: &AgentSidebarToken) -> String { match token { AgentSidebarToken::StateIcon => "state_icon".into(), AgentSidebarToken::StateText => "state_text".into(), + AgentSidebarToken::Machine => "machine".into(), AgentSidebarToken::Workspace => "workspace".into(), AgentSidebarToken::Tab => "tab".into(), AgentSidebarToken::Pane => "pane".into(), @@ -288,6 +290,7 @@ impl<'de> Deserialize<'de> for AgentSidebarToken { &[ ("state_icon", Self::StateIcon), ("state_text", Self::StateText), + ("machine", Self::Machine), ("workspace", Self::Workspace), ("tab", Self::Tab), ("pane", Self::Pane), @@ -393,6 +396,7 @@ impl Default for AgentsSidebarConfig { rows: vec![ vec![ AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], @@ -443,6 +447,7 @@ mod tests { vec![ vec![ AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 77ac36da..cd774c6b 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1557,6 +1557,11 @@ impl Terminal { ) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> { + let generation = self.kitty_graphics_generation()?; + Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation)) + } + pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -3448,13 +3453,16 @@ mod tests { terminal.resize(10, 5, 8, 16).unwrap(); assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"); let transmitted = terminal.kitty_graphics_generation().unwrap(); assert_ne!(transmitted, 0); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted)); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); terminal.write(b"plain text"); assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted); @@ -3463,6 +3471,7 @@ mod tests { terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"); let placed = terminal.kitty_graphics_generation().unwrap(); assert_ne!(placed, transmitted); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); terminal.resize(10, 5, 12, 24).unwrap(); @@ -3473,14 +3482,17 @@ mod tests { assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_ne!(terminal.kitty_empty_generation.get(), Some(placed)); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); terminal.scroll_viewport_row(0); assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); terminal.write(b"\x1b_Ga=d,d=A\x1b\\"); let deleted = terminal.kitty_graphics_generation().unwrap(); assert_ne!(deleted, placed); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted)); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); } #[test] @@ -131,13 +131,6 @@ pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool) } } -#[cfg(unix)] -pub(crate) fn shutdown_local_stream_write(stream: &LocalStream) -> io::Result<()> { - match stream { - LocalStream::UdSocket(stream) => stream.inner().shutdown(std::net::Shutdown::Write), - } -} - /// Binds a listener for private terminal traffic. Unix callers restrict the /// socket file after binding; Windows must set the named-pipe DACL at creation. pub(crate) fn bind_private_local_listener(path: &Path) -> io::Result<LocalListener> { diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index bf2482b6..e5f00ac2 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -50,6 +50,10 @@ pub(crate) struct ClientState { } impl ClientState { + pub(crate) fn scope(&self) -> &str { + &self.scope + } + pub(crate) fn set_scope(&mut self, scope: &str) { if self.scope == scope { return; diff --git a/src/main.rs b/src/main.rs index ee38e84b..4d7e2f88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -330,18 +330,18 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # distinct static glyphs for blocked, working, done, idle, and unknown states. # status_indicators = "dots" -# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent, -# terminal_title, and terminal_title_stripped. +# Expanded agent rows. Built-ins are state_icon, state_text, machine, workspace, tab, +# pane, agent, terminal_title, and terminal_title_stripped. # Custom values reported through pane metadata use a $name token. # A token occurrence may be styled with { token = "workspace", fg = "#89b4fa", bold = true, dim = false }. # Omitted style fields preserve the contextual default. # [ui.sidebar.agents] # Blank rows between agent entries. Set to 1 to restore the previous spacing. # row_gap = 0 -# rows = [["state_icon", "workspace", "tab"], ["agent"]] +# rows = [["state_icon", "machine", "workspace", "tab"], ["agent"]] # Optional canonical agent IDs replace the default rows for matching agents. # [ui.sidebar.agents.rows_by_agent] -# claude = [["state_icon", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]] +# claude = [["state_icon", "machine", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]] # Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status. # Custom values reported through workspace metadata use a $name token, for example $jj_status. @@ -587,6 +587,7 @@ fn main() -> io::Result<()> { println!(" herdr completion zsh"); println!(" herdr update [--handoff]"); println!(" herdr channel set <stable|preview>"); + println!(" herdr machine <subcommand> ..."); println!(" herdr server stop"); println!(" herdr server reload-config"); println!(" herdr api <subcommand> ..."); @@ -631,6 +632,7 @@ fn main() -> io::Result<()> { "herdr channel <subcommand>", "Manage the stable or preview update channel", ), + ("herdr machine <subcommand>", "Manage saved SSH machines"), ( "herdr api <subcommand>", "Inspect socket API metadata and live runtime state", @@ -737,6 +739,7 @@ fn main() -> io::Result<()> { "status", "config", "channel", + "machine", "workspace", "worktree", "pane", @@ -764,7 +767,9 @@ fn main() -> io::Result<()> { let loaded_config = config::Config::load(); exit_if_nested_disabled(&loaded_config.config); - if let Err(err) = server::autodetect::auto_detect_launch() { + let saved_federation = + client::endpoint::EndpointCatalog::load().is_ok_and(|catalog| catalog.has_enabled_ssh()); + if let Err(err) = server::autodetect::auto_detect_launch(saved_federation) { eprintln!("herdr: {err}"); std::process::exit(1); } diff --git a/src/pane.rs b/src/pane.rs index 4f1ea0ad..83bad900 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3086,6 +3086,10 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.terminal.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index a804a7fb..1dc5a88d 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -538,6 +538,10 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.ghostty.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, @@ -2190,6 +2194,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.core + .lock() + .ok() + .and_then(|core| core.terminal.kitty_graphics_may_have_placements().ok()) + .unwrap_or(true) + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/platform/client_state.rs b/src/platform/client_state.rs new file mode 100644 index 00000000..d339bae8 --- /dev/null +++ b/src/platform/client_state.rs @@ -0,0 +1,32 @@ +use std::path::Path; + +#[cfg(not(windows))] +pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> { + super::create_remote_ssh_config_file(path) +} + +#[cfg(windows)] +pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> { + super::windows::create_remote_ssh_config_file(path) +} + +#[cfg(not(windows))] +pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::rename(source, destination) +} + +#[cfg(windows)] +pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + super::windows::replace_file(source, destination) +} + +#[cfg(not(windows))] +pub(crate) fn sync_parent_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +#[cfg(windows)] +pub(crate) fn sync_parent_directory(_path: &Path) -> std::io::Result<()> { + // replace_file uses MOVEFILE_WRITE_THROUGH on Windows. + Ok(()) +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 8efe7357..6c09667a 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - StatusCommandGuard, + wait_client_stream_readable, StatusCommandGuard, }; const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 948468ac..8840c7ed 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - StatusCommandGuard, + wait_client_stream_readable, StatusCommandGuard, }; const PROC_PGRP_ONLY: u32 = 2; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 4b49b9f7..86a431aa 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -236,6 +236,9 @@ mod unix_common; #[cfg(unix)] pub(crate) use unix_common::{begin_cli_output, end_cli_output}; +mod client_state; +pub(crate) use client_state::{create_private_state_file, replace_file, sync_parent_directory}; + #[cfg(not(unix))] pub(crate) fn begin_cli_output() {} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index a7926c22..dff67357 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -1,5 +1,24 @@ use std::path::{Path, PathBuf}; +pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { + use std::os::fd::{AsFd as _, AsRawFd as _}; + let crate::ipc::LocalStream::UdSocket(stream) = stream; + let mut descriptor = libc::pollfd { + fd: stream.as_fd().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // Bound cancellation latency without polling idle connections hundreds of times per second. + let result = unsafe { libc::poll(&mut descriptor, 1, 100) }; + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } + Ok(()) +} + pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::window_size().map(|size| (size.columns, size.rows)) } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 1c5c4afb..ce0afedb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,10 +15,52 @@ use std::{ mod clipboard_image; +pub(crate) fn wait_client_stream_readable( + _stream: &crate::ipc::LocalStream, +) -> std::io::Result<()> { + // Sync named pipes have no read timeout. The caller peeks before each read and checks its + // cancellation flag between polls, including when a frame arrives in several fragments. + std::thread::sleep(Duration::from_millis(2)); + Ok(()) +} + 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::<Vec<_>>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::<Vec<_>>(); + 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/protocol/endpoint.rs b/src/protocol/endpoint.rs index b2955ec8..a70ef568 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -20,6 +20,17 @@ pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1; pub const SURFACE_CODEC_V1: &str = "shell.surface.v1"; pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1"; pub const BLOB_CODEC_V1: &str = "shell.blob.v1"; +pub const SURFACE_INTEREST_CAPABILITY: &str = "surface_interest"; +pub const PRESENTATION_EFFECTS_FENCE_CAPABILITY: &str = "presentation_effects_fence"; +pub const PRESENTATION_EFFECTS_SYNC_KIND: &str = "endpoint.presentation.sync.v1"; +pub const PRESENTATION_EFFECTS_READY_KIND: &str = "endpoint.presentation.ready.v1"; +pub const HEALTH_CHECK_CAPABILITY: &str = "health_check"; +pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1"; +pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1"; + +fn default_true() -> bool { + true +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointClientHello { @@ -31,6 +42,8 @@ pub struct EndpointClientHello { pub direct_graphics: bool, pub endpoint_keybindings: bool, pub mouse_capture: bool, + #[serde(default = "default_true")] + pub surface_active: bool, #[serde(default)] pub snapshot_codecs: Vec<String>, #[serde(default)] @@ -57,6 +70,8 @@ pub struct EndpointServerWelcome { pub blob_codec: String, #[serde(default)] pub methods: Vec<String>, + #[serde(default)] + pub capabilities: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option<EndpointHandshakeError>, } @@ -95,6 +110,11 @@ impl EndpointServerWelcome { input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods, + capabilities: vec![ + SURFACE_INTEREST_CAPABILITY.into(), + PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + HEALTH_CHECK_CAPABILITY.into(), + ], error: None, } } @@ -108,6 +128,7 @@ impl EndpointServerWelcome { input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods: Vec::new(), + capabilities: Vec::new(), error: Some(EndpointHandshakeError { code: code.into(), message: message.into(), @@ -130,6 +151,7 @@ mod tests { direct_graphics: false, endpoint_keybindings: false, mouse_capture: true, + surface_active: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -192,6 +214,7 @@ mod tests { assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1); assert_eq!(welcome.input_codec, INPUT_CODEC_V1); assert_eq!(welcome.blob_codec, BLOB_CODEC_V1); + assert!(welcome.capabilities.is_empty()); } #[test] @@ -245,6 +268,27 @@ mod tests { } #[test] + fn legacy_hello_defaults_to_an_active_surface() { + let mut value = serde_json::to_value(hello()).unwrap(); + value.as_object_mut().unwrap().remove("surface_active"); + let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); + assert!(decoded.surface_active); + } + + #[test] + fn compatible_server_advertises_endpoint_lifecycle_capabilities() { + let welcome = EndpointServerWelcome::compatible(Vec::new()); + assert_eq!( + welcome.capabilities, + vec![ + SURFACE_INTEREST_CAPABILITY.to_string(), + PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), + HEALTH_CHECK_CAPABILITY.to_string(), + ] + ); + } + + #[test] fn required_codecs_are_explicit() { let mut value = hello(); assert!(value.supports_required_codecs()); diff --git a/src/remote.rs b/src/remote.rs index 19904ef2..ba935ec4 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,10 +1,16 @@ +mod args; mod attach; #[cfg(unix)] mod host_unix; +mod process; +mod restart_policy; +mod saved; +pub(crate) use args::*; pub(crate) use attach::*; #[cfg(unix)] pub(crate) use host_unix::run_remote_client_bridge; +pub(crate) use saved::*; #[cfg(windows)] pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> { diff --git a/src/remote/args.rs b/src/remote/args.rs new file mode 100644 index 00000000..476acf70 --- /dev/null +++ b/src/remote/args.rs @@ -0,0 +1,126 @@ +pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; +pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteKeybindings { + Local, + Server, +} + +impl RemoteKeybindings { + pub(super) fn parse(value: &str) -> Result<Self, String> { + match value { + "local" => Ok(Self::Local), + "server" => Ok(Self::Server), + _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), + } + } + + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Server => "server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteLaunch { + pub(crate) target: String, + pub(crate) keybindings: RemoteKeybindings, + pub(crate) live_handoff: bool, +} + +pub(crate) fn extract_remote_args( + args: &[String], +) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut remote_target = None; + let mut keybindings = RemoteKeybindings::Local; + let mut keybindings_seen = false; + let mut live_handoff = false; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--" { + cleaned.extend_from_slice(&args[index..]); + break; + } + if arg == "--handoff" { + live_handoff = true; + index += 1; + continue; + } + if arg == "--remote" { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote".to_string()); + }; + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote=") { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 1; + continue; + } + if arg == "--remote-keybindings" { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote-keybindings".to_string()); + }; + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote-keybindings=") { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + let remote = remote_target.map(|target| RemoteLaunch { + target, + keybindings, + live_handoff, + }); + if remote.is_none() && keybindings_seen { + return Err("--remote-keybindings requires --remote".to_string()); + } + if remote.is_none() && live_handoff { + cleaned.push("--handoff".to_string()); + } + + Ok((cleaned, remote)) +} + +pub(crate) fn validate_remote_target(target: &str) -> Result<&str, String> { + if target.is_empty() { + return Err("missing value for --remote".to_string()); + } + if target.starts_with('-') { + return Err("--remote target must not start with '-'".to_string()); + } + Ok(target) +} diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 641aad00..81810411 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -1,15 +1,13 @@ //! Remote thin-client launcher over SSH command stdio. -use super::shell_quote; +use super::{args::*, process::wait_with_output_timeout, restart_policy::*, shell_quote}; use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{self, IsTerminal, Write as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; -use interprocess::local_socket::traits::Listener as _; -#[cfg(windows)] -use interprocess::local_socket::traits::Stream as _; +use interprocess::local_socket::traits::{Listener as _, Stream as _}; use interprocess::local_socket::ListenerNonblockingMode; use interprocess::TryClone as _; use serde::Deserialize; @@ -21,144 +19,16 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); -#[cfg(windows)] const BRIDGE_IO_POLL: Duration = Duration::from_millis(1); const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); +const NONINTERACTIVE_SSH_COMMAND_TIMEOUT: Duration = Duration::from_secs(15); const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; -pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; - -pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RemoteKeybindings { - Local, - Server, -} - -impl RemoteKeybindings { - fn parse(value: &str) -> Result<Self, String> { - match value { - "local" => Ok(Self::Local), - "server" => Ok(Self::Server), - _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Server => "server", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RemoteLaunch { - pub(crate) target: String, - pub(crate) keybindings: RemoteKeybindings, - pub(crate) live_handoff: bool, -} - -pub(crate) fn extract_remote_args( - args: &[String], -) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { - let mut cleaned = Vec::with_capacity(args.len()); - if let Some(program) = args.first() { - cleaned.push(program.clone()); - } - - let mut remote_target = None; - let mut keybindings = RemoteKeybindings::Local; - let mut keybindings_seen = false; - let mut live_handoff = false; - let mut index = 1; - while index < args.len() { - let arg = &args[index]; - if arg == "--" { - cleaned.extend_from_slice(&args[index..]); - break; - } - if arg == "--handoff" { - live_handoff = true; - index += 1; - continue; - } - if arg == "--remote" { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote".to_string()); - }; - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote=") { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 1; - continue; - } - if arg == "--remote-keybindings" { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote-keybindings".to_string()); - }; - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote-keybindings=") { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 1; - continue; - } - - cleaned.push(arg.clone()); - index += 1; - } - - let remote = remote_target.map(|target| RemoteLaunch { - target, - keybindings, - live_handoff, - }); - if remote.is_none() && keybindings_seen { - return Err("--remote-keybindings requires --remote".to_string()); - } - if remote.is_none() && live_handoff { - cleaned.push("--handoff".to_string()); - } - - Ok((cleaned, remote)) -} - -fn validate_remote_target(target: &str) -> Result<&str, String> { - if target.is_empty() { - return Err("missing value for --remote".to_string()); - } - if target.starts_with('-') { - return Err("--remote target must not start with '-'".to_string()); - } - Ok(target) -} - pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let session_name = crate::session::active_name() .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); @@ -177,13 +47,22 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { .config .remote .manage_ssh_config; - let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config); - let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff)?; + 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, + session_name.clone(), + ); + let prepared_remote = + prepare_remote_herdr(&remote_ssh, remote.live_handoff, require_surface_interest)?; ensure_remote_server_ready( &remote_ssh, &prepared_remote.remote_herdr, prepared_remote.stop_after_install_approved, remote.live_handoff, + require_surface_interest, )?; let _bridge = SshStdioBridge::start( @@ -192,11 +71,68 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { local_socket.clone(), session_name, remote_ssh.options(), + false, )?; 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!( + "{} </dev/null", + remote_bridge_command(&prepared.remote_herdr, session_name) + ))?; + if !output.status.success() { + return Err(command_failed("remote server startup failed", &output)); + } + match remote_server_status(&ssh, &prepared.remote_herdr, true)? { + RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + detached_server_daemon, + .. + } if remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + true, + surface_interest, + health_check, + ) + .is_none() => + { + Ok(()) + } + _ => Err(io::Error::other( + "remote server is not ready for saved machines", + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -244,7 +180,7 @@ impl RemotePlatform { } #[derive(Debug, Clone)] -struct RemoteHerdr { +pub(super) struct RemoteHerdr { install_suffix: String, shell_path: String, platform: RemotePlatform, @@ -392,13 +328,13 @@ struct RemoteReleaseAsset { sha256: Option<String>, } -struct PreparedRemoteHerdr { - remote_herdr: RemoteHerdr, +pub(super) struct PreparedRemoteHerdr { + pub(super) remote_herdr: RemoteHerdr, stop_after_install_approved: bool, } #[derive(Clone)] -struct ManagedSshOptions { +pub(super) struct ManagedSshOptions { config_path: PathBuf, control_path: Option<PathBuf>, } @@ -415,13 +351,15 @@ impl Drop for ManagedSshConfig { } } -struct RemoteSsh { +pub(super) struct RemoteSsh { target: String, + session_name: String, managed_config: Option<ManagedSshConfig>, + 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| { @@ -434,7 +372,18 @@ impl RemoteSsh { Self { target, + session_name, managed_config, + noninteractive: false, + } + } + + pub(super) fn new_noninteractive(target: String) -> Self { + Self { + target, + session_name: crate::session::DEFAULT_SESSION_NAME.into(), + managed_config: None, + noninteractive: true, } } @@ -442,12 +391,19 @@ impl RemoteSsh { &self.target } - fn options(&self) -> Option<&ManagedSshOptions> { + 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) } fn command(&self) -> Command { let mut command = self.base_command(); + if self.noninteractive { + apply_noninteractive_ssh_options(&mut command); + } command.arg("-T").arg(&self.target); command } @@ -475,13 +431,26 @@ impl RemoteSsh { "ssh bootstrap stdin missing", )) }; - let output = child.wait_with_output()?; + let output = if self.noninteractive { + wait_with_output_timeout(child, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)? + } else { + child.wait_with_output()? + }; write_result?; Ok(output) } - fn user_shell_output(&self, command: &str) -> io::Result<Output> { - self.command().arg(command).output() + fn user_shell_output(&self, remote_command: &str) -> io::Result<Output> { + let mut command = self.command(); + command + .arg(remote_command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if self.noninteractive { + wait_with_output_timeout(command.spawn()?, NONINTERACTIVE_SSH_COMMAND_TIMEOUT) + } else { + command.output() + } } fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> { @@ -601,6 +570,24 @@ impl Drop for RemoteSsh { } } +fn apply_noninteractive_ssh_options(command: &mut Command) { + command + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("NumberOfPasswordPrompts=0") + .arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg("-o") + .arg("ConnectionAttempts=1") + .arg("-o") + .arg("ServerAliveInterval=15") + .arg("-o") + .arg("ServerAliveCountMax=4"); +} + fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) { let Some(options) = options else { return; @@ -640,9 +627,10 @@ impl InstallSource { } } -fn prepare_remote_herdr( +pub(super) fn prepare_remote_herdr( ssh: &RemoteSsh, live_handoff_enabled: bool, + require_surface_interest: bool, ) -> io::Result<PreparedRemoteHerdr> { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); @@ -651,14 +639,20 @@ fn prepare_remote_herdr( if override_binary.is_none() { for candidate in &remote_binary_candidates { - if remote_binary_supports_endpoint(ssh, candidate).unwrap_or(false) { + if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest) + .unwrap_or(false) + { return Ok(PreparedRemoteHerdr { remote_herdr: candidate.clone(), stop_after_install_approved: false, }); } } - if remote_binary_supports_endpoint(ssh, &remote_herdr)? { + if remote_binary_supports_endpoint_requirement( + ssh, + &remote_herdr, + require_surface_interest, + )? { return Ok(PreparedRemoteHerdr { remote_herdr, stop_after_install_approved: false, @@ -676,10 +670,11 @@ fn prepare_remote_herdr( ssh, status_probe_herdr, live_handoff_enabled, + require_surface_interest, )?; } confirm_remote_install( - ssh.target(), + &ssh.destination(), &remote_herdr, &install_source_description(&remote_herdr.platform, override_binary.as_deref()), )?; @@ -688,11 +683,10 @@ fn prepare_remote_herdr( source.cleanup(); install_result?; - if !remote_binary_supports_endpoint(ssh, &remote_herdr)? { + if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { return Err(io::Error::other(format!( - "installed remote herdr at {}, but it does not support endpoint generation {}", - remote_herdr.shell_path, - crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + "installed remote herdr at {}, but it does not support saved SSH endpoint federation", + remote_herdr.shell_path ))); } warn_if_remote_bin_not_on_path(ssh)?; @@ -703,6 +697,25 @@ fn prepare_remote_herdr( }) } +pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> { + let platform = detect_remote_platform(ssh)?; + let remote_herdr = RemoteHerdr::for_platform(platform); + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; + for candidate in candidates { + if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? { + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "matching Herdr is not ready on {}; run `herdr --remote {}` interactively to install or update it", + ssh.target(), + ssh.target() + ), + )) +} + fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result<RemotePlatform> { let output = ssh.sh_output("uname -s\nuname -m\n")?; if !output.status.success() { @@ -869,6 +882,9 @@ fn remote_client_status( ); let output = ssh.sh_output(&command)?; if !output.status.success() { + if output.status.code() == Some(255) { + return Err(command_failed("remote SSH connection failed", &output)); + } return Ok(None); } Ok(parse_client_status_json(&String::from_utf8_lossy( @@ -876,13 +892,13 @@ fn remote_client_status( ))) } -fn remote_binary_supports_endpoint( +fn remote_binary_supports_endpoint_requirement( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, + require_surface_interest: bool, ) -> io::Result<bool> { Ok(remote_client_status(ssh, remote_herdr)? - .and_then(|status| status.endpoint_protocol_generation) - == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)) + .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> { @@ -986,23 +1002,30 @@ enum RemoteServerStatus { Running { version: Option<String>, endpoint_protocol_generation: Option<u32>, + surface_interest: bool, + health_check: bool, live_handoff: bool, detached_server_daemon: bool, }, NotRunning, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteServerRestartReason { - EndpointProtocolMissing, - DaemonDetachMissing, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteInstallRunningServerPlan { - KeepRunning, - LiveHandoff, - StopRequired(RemoteServerRestartReason), +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( @@ -1010,11 +1033,14 @@ fn ensure_remote_server_ready( remote_herdr: &RemoteHerdr, stop_after_install_approved: bool, 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, + surface_interest, + health_check, live_handoff, detached_server_daemon, } = status @@ -1022,9 +1048,13 @@ fn ensure_remote_server_ready( return Ok(()); }; - let Some(reason) = - remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon) - else { + let Some(reason) = remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + require_surface_interest, + surface_interest, + health_check, + ) else { return Ok(()); }; @@ -1043,33 +1073,20 @@ 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(()) } -fn remote_server_restart_reason( - endpoint_protocol_generation: Option<u32>, - detached_server_daemon: bool, -) -> Option<RemoteServerRestartReason> { - if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) - { - return Some(RemoteServerRestartReason::EndpointProtocolMissing); - } - if !detached_server_daemon { - return Some(RemoteServerRestartReason::DaemonDetachMissing); - } - None -} - fn confirm_remote_install_with_running_server( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, live_handoff_enabled: bool, + require_surface_interest: bool, ) -> io::Result<bool> { - 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() { @@ -1098,6 +1115,8 @@ fn confirm_remote_install_with_running_server( let RemoteServerStatus::Running { version, endpoint_protocol_generation, + surface_interest, + health_check, live_handoff, detached_server_daemon, } = &status @@ -1107,8 +1126,11 @@ fn confirm_remote_install_with_running_server( let plan = remote_install_running_server_plan( *endpoint_protocol_generation, *detached_server_daemon, + *surface_interest, + *health_check, *live_handoff, live_handoff_enabled, + require_surface_interest, ); if plan == RemoteInstallRunningServerPlan::KeepRunning { @@ -1172,37 +1194,57 @@ fn confirm_remote_install_with_running_server( Ok(true) } -fn remote_install_running_server_plan( - endpoint_protocol_generation: Option<u32>, - detached_server_daemon: bool, - live_handoff: bool, - live_handoff_enabled: bool, -) -> RemoteInstallRunningServerPlan { - let Some(reason) = - remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon) - else { - return RemoteInstallRunningServerPlan::KeepRunning; - }; - - if live_handoff_enabled && live_handoff { - return RemoteInstallRunningServerPlan::LiveHandoff; - } - - RemoteInstallRunningServerPlan::StopRequired(reason) -} - fn remote_server_status( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, + require_surface_interest: bool, ) -> io::Result<RemoteServerStatus> { - 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<crate::client::endpoint::EndpointNegotiation> { + 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)] @@ -1213,6 +1255,27 @@ struct RemoteClientStatusJson { protocol: Option<u32>, #[serde(default)] endpoint_protocol_generation: Option<u32>, + #[serde(default)] + endpoint_capabilities: Vec<String>, +} + +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)] @@ -1229,6 +1292,10 @@ struct RemoteServerCapabilitiesJson { detached_server_daemon: bool, #[serde(default)] endpoint_protocol_generation: Option<u32>, + #[serde(default)] + surface_interest: bool, + #[serde(default)] + health_check: bool, } fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { @@ -1241,6 +1308,7 @@ fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { status.version.is_some() || status.protocol.is_some() || status.endpoint_protocol_generation.is_some() + || !status.endpoint_capabilities.is_empty() }) } @@ -1261,6 +1329,12 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu endpoint_protocol_generation: capabilities .as_ref() .and_then(|capabilities| capabilities.endpoint_protocol_generation), + surface_interest: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.surface_interest), + health_check: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.health_check), live_handoff: capabilities .as_ref() .is_some_and(|capabilities| capabilities.live_handoff), @@ -1275,8 +1349,14 @@ fn confirm_remote_server_stop( version: Option<&str>, reason: RemoteServerRestartReason, ) -> io::Result<bool> { + let required_upgrade = matches!( + reason, + RemoteServerRestartReason::EndpointProtocol + | RemoteServerRestartReason::SurfaceInterest + | RemoteServerRestartReason::HealthCheck + ); if !io::stdin().is_terminal() { - if reason == RemoteServerRestartReason::EndpointProtocolMissing { + if required_upgrade { return Err(io::Error::other(format!( "remote herdr server on {target} needs one final update before this client can attach; run from an interactive terminal to approve updating it" ))); @@ -1296,19 +1376,27 @@ fn confirm_remote_server_stop( eprintln!(); match reason { - RemoteServerRestartReason::EndpointProtocolMissing => { + RemoteServerRestartReason::EndpointProtocol => { eprintln!( "the remote server predates Herdr's stable endpoint protocol and must update before this client can attach." ); } - RemoteServerRestartReason::DaemonDetachMissing => { + RemoteServerRestartReason::SurfaceInterest => { + eprintln!( + "the remote server must restart before it can join saved SSH endpoint federation." + ); + } + RemoteServerRestartReason::HealthCheck => { + eprintln!("the remote server must restart to enable saved SSH endpoint health checks."); + } + RemoteServerRestartReason::DaemonDetach => { eprintln!( "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client." ); } } - let prompt = if reason == RemoteServerRestartReason::EndpointProtocolMissing { + let prompt = if required_upgrade { "update the remote server and continue attaching? [Y/n] " } else { "restart the remote server now? [y/N] " @@ -1316,16 +1404,10 @@ 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" { + if read_remote_confirmation(&mut io::stdin().lock(), required_upgrade)? { return Ok(true); } - if answer.is_empty() && reason == RemoteServerRestartReason::EndpointProtocolMissing { - return Ok(true); - } - if reason == RemoteServerRestartReason::EndpointProtocolMissing { + if required_upgrade { return Err(io::Error::new( io::ErrorKind::Interrupted, "remote herdr server stop cancelled", @@ -1335,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 + ), ) } @@ -1353,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)); @@ -1367,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)); @@ -1384,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 { @@ -1577,6 +1668,25 @@ fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> { )) } +fn read_remote_confirmation(reader: &mut impl io::BufRead, default: bool) -> io::Result<bool> { + 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, @@ -1601,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", @@ -1614,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, @@ -1658,7 +1773,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error { } } -struct SshStdioBridge { +pub(super) struct SshStdioBridge { local_socket: PathBuf, socket_identity: crate::ipc::SocketFileIdentity, should_stop: Arc<AtomicBool>, @@ -1666,12 +1781,13 @@ struct SshStdioBridge { } impl SshStdioBridge { - fn start( + pub(super) fn start( target: String, remote_herdr: RemoteHerdr, local_socket: PathBuf, session_name: String, ssh_options: Option<&ManagedSshOptions>, + noninteractive: bool, ) -> io::Result<Self> { crate::ipc::prepare_socket_path(&local_socket, |path| { format!("remote bridge is already listening at {}", path.display()) @@ -1712,16 +1828,25 @@ impl SshStdioBridge { &remote_herdr, &session_name, thread_ssh_options.as_ref(), + noninteractive, &thread_stop, ) { - eprintln!("herdr: remote bridge failed: {err}"); + if noninteractive { + tracing::warn!(error = %err, "saved SSH endpoint bridge failed"); + } else { + eprintln!("herdr: remote bridge failed: {err}"); + } } } Err(err) if err.kind() == io::ErrorKind::WouldBlock => { thread::sleep(BRIDGE_ACCEPT_POLL); } Err(err) => { - eprintln!("herdr: remote bridge listener failed: {err}"); + if noninteractive { + tracing::warn!(error = %err, "saved SSH endpoint listener failed"); + } else { + eprintln!("herdr: remote bridge listener failed: {err}"); + } break; } } @@ -1813,79 +1938,31 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { }) } -#[cfg(unix)] -fn bridge_connection( - stream: crate::ipc::LocalStream, - target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, - ssh_options: Option<&ManagedSshOptions>, - _bridge_stop: &Arc<AtomicBool>, -) -> io::Result<()> { - let mut command = Command::new("ssh"); - apply_managed_ssh_options(&mut command, ssh_options); - command - .arg("-T") - .arg(target) - .arg(remote_bridge_command(remote_herdr, session_name)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - - let mut child = command - .spawn() - .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; - let mut child_stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; - let mut child_stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; - let mut stream_to_child = stream.try_clone()?; - let mut child_to_stream = stream; - - let upload = thread::spawn(move || { - let _ = copy_flush(&mut stream_to_child, &mut child_stdin); - }); - let download = thread::spawn(move || { - let _ = copy_flush(&mut child_stdout, &mut child_to_stream); - let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream); - }); - - let status = child.wait()?; - let _ = upload.join(); - let _ = download.join(); - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - format!("ssh bridge exited with {status}"), - )) - } -} - -#[cfg(windows)] fn bridge_connection( stream: crate::ipc::LocalStream, target: &str, remote_herdr: &RemoteHerdr, session_name: &str, ssh_options: Option<&ManagedSshOptions>, + noninteractive: bool, bridge_stop: &Arc<AtomicBool>, ) -> io::Result<()> { let mut command = Command::new("ssh"); apply_managed_ssh_options(&mut command, ssh_options); + if noninteractive { + apply_noninteractive_ssh_options(&mut command); + } command .arg("-T") .arg(target) .arg(remote_bridge_command(remote_herdr, session_name)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(if noninteractive { + Stdio::null() + } else { + Stdio::inherit() + }); let mut child = command .spawn() @@ -2018,33 +2095,12 @@ fn bridge_connection( } } -#[cfg(unix)] -fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { - let mut buffer = [0_u8; 16 * 1024]; - let mut total = 0; - - loop { - let bytes_read = match reader.read(&mut buffer) { - Ok(0) => return Ok(total), - Ok(bytes_read) => bytes_read, - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err), - }; - - writer.write_all(&buffer[..bytes_read])?; - writer.flush()?; - total += bytes_read as u64; - } -} - -#[cfg(windows)] fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> { let _ = child.kill(); let _ = child.wait(); Err(io::Error::new(io::ErrorKind::BrokenPipe, message)) } -#[cfg(windows)] fn copy_reader_to_local_stream<R: io::Read>( reader: &mut R, stream: &mut crate::ipc::LocalStream, @@ -2082,7 +2138,6 @@ fn copy_reader_to_local_stream<R: io::Read>( } } -#[cfg(windows)] fn copy_local_stream_to_writer<W: io::Write>( mut stream: crate::ipc::LocalStream, writer: &mut W, @@ -2207,6 +2262,7 @@ mod tests { socket.clone(), "default".to_string(), None, + false, ) .expect("start bridge listener"); @@ -2253,9 +2309,8 @@ mod tests { let _ = std::fs::remove_file(socket); } - #[cfg(windows)] #[test] - fn windows_bridge_drop_while_waiting_for_client_is_bounded() { + fn bridge_drop_while_waiting_for_client_is_bounded() { let socket = local_forward_socket_path("drop-test", "default"); let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { os: "linux", @@ -2267,6 +2322,7 @@ mod tests { socket.clone(), "default".to_string(), None, + false, ) .expect("start bridge listener"); let started = Instant::now(); @@ -2362,7 +2418,9 @@ 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, }; let command = ssh.command(); @@ -2400,7 +2458,9 @@ mod tests { let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: Some(managed_config), + noninteractive: false, }; let args = ssh .command() @@ -2428,10 +2488,106 @@ mod tests { } #[test] + fn noninteractive_ssh_command_cannot_prompt_or_accept_unknown_hosts() { + let ssh = RemoteSsh::new_noninteractive("example".into()); + let args = ssh + .command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(); + for required in [ + "BatchMode=yes", + "NumberOfPasswordPrompts=0", + "StrictHostKeyChecking=yes", + "ConnectTimeout=10", + "ConnectionAttempts=1", + "ServerAliveInterval=15", + "ServerAliveCountMax=4", + ] { + assert!(args.iter().any(|arg| arg == required), "missing {required}"); + } + assert!(!args.iter().any(|arg| arg == "-F")); + 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, }; let command = ssh.command(); @@ -2898,27 +3054,91 @@ mod tests { #[test] fn parse_client_status_json_reads_last_json_record() { let status = parse_client_status_json( - "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1}\n{\"wrapper\":true}\n", + "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1,\"endpoint_capabilities\":[\"surface_interest\",\"health_check\"]}\n{\"wrapper\":true}\n", ) .unwrap(); assert_eq!(status.version.as_deref(), Some("0.8.0")); assert_eq!(status.protocol, Some(20)); assert_eq!(status.endpoint_protocol_generation, Some(1)); + assert_eq!( + status.endpoint_capabilities, + vec!["surface_interest", "health_check"] + ); assert!( parse_client_status_json(r#"{"endpoint_protocol_generation":"unknown"}"#).is_none() ); } #[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!( parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1}}"# + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"# ) .unwrap(), RemoteServerStatus::Running { version: Some("0.6.0".into()), endpoint_protocol_generation: Some(1), + surface_interest: true, + health_check: true, live_handoff: true, detached_server_daemon: true } @@ -2935,6 +3155,8 @@ mod tests { RemoteServerStatus::Running { version: Some("0.6.0".into()), endpoint_protocol_generation: None, + surface_interest: false, + health_check: false, live_handoff: false, detached_server_daemon: false } @@ -3109,88 +3331,17 @@ mod tests { } #[test] - fn remote_server_restart_reason_requires_one_update_for_pre_floor_server() { - assert_eq!( - remote_server_restart_reason(None, true), - Some(RemoteServerRestartReason::EndpointProtocolMissing) - ); - } - - #[test] - fn remote_server_restart_reason_allows_compatible_server() { - assert_eq!( - remote_server_restart_reason( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - true - ), - None - ); - } - - #[test] - fn remote_server_restart_reason_requires_restart_for_old_daemon() { - assert_eq!( - remote_server_restart_reason( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - false - ), - Some(RemoteServerRestartReason::DaemonDetachMissing) - ); - } - - #[test] - fn remote_install_plan_keeps_compatible_running_server() { - assert_eq!( - remote_install_running_server_plan( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - true, - false, - false - ), - RemoteInstallRunningServerPlan::KeepRunning - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_old_daemon() { - assert_eq!( - remote_install_running_server_plan( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - false, - false, - false - ), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::DaemonDetachMissing - ) - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_pre_floor_server() { - assert_eq!( - remote_install_running_server_plan(None, true, false, false), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::EndpointProtocolMissing - ) - ); - } - - #[test] - fn remote_install_plan_uses_live_handoff_for_pre_floor_server() { - assert_eq!( - remote_install_running_server_plan(None, true, true, true), - RemoteInstallRunningServerPlan::LiveHandoff - ); - } - - #[test] fn remote_live_handoff_uses_prepared_binary_identity() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { 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/src/remote/process.rs b/src/remote/process.rs new file mode 100644 index 00000000..d3db5c9f --- /dev/null +++ b/src/remote/process.rs @@ -0,0 +1,87 @@ +use std::io::{self, Read as _}; +use std::process::Output; +use std::thread; +use std::time::{Duration, Instant}; + +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +pub(super) fn wait_with_output_timeout( + mut child: std::process::Child, + timeout: Duration, +) -> io::Result<Output> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("SSH command stdout was not captured"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("SSH command stderr was not captured"))?; + let stdout = thread::spawn(move || { + let mut stdout = stdout; + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let stderr = thread::spawn(move || { + let mut stderr = stderr; + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(error); + } + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "noninteractive SSH command timed out", + )); + } + thread::sleep(POLL_INTERVAL); + }; + let stdout = stdout + .join() + .map_err(|_| io::Error::other("SSH stdout reader panicked"))??; + let stderr = stderr + .join() + .map_err(|_| io::Error::other("SSH stderr reader panicked"))??; + Ok(Output { + status, + stdout, + stderr, + }) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + + #[test] + fn timeout_kills_the_child() { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("exec sleep 10") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let started = Instant::now(); + let error = wait_with_output_timeout(command.spawn().unwrap(), Duration::from_millis(25)) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} diff --git a/src/remote/restart_policy.rs b/src/remote/restart_policy.rs new file mode 100644 index 00000000..757efcc5 --- /dev/null +++ b/src/remote/restart_policy.rs @@ -0,0 +1,161 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RemoteServerRestartReason { + EndpointProtocol, + SurfaceInterest, + HealthCheck, + DaemonDetach, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RemoteInstallRunningServerPlan { + KeepRunning, + LiveHandoff, + StopRequired(RemoteServerRestartReason), +} + +pub(super) fn remote_server_restart_reason( + endpoint_protocol_generation: Option<u32>, + detached_server_daemon: bool, + require_surface_interest: bool, + surface_interest: bool, + health_check: bool, +) -> Option<RemoteServerRestartReason> { + if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + { + return Some(RemoteServerRestartReason::EndpointProtocol); + } + if require_surface_interest && !surface_interest { + return Some(RemoteServerRestartReason::SurfaceInterest); + } + if require_surface_interest && !health_check { + return Some(RemoteServerRestartReason::HealthCheck); + } + if !detached_server_daemon { + return Some(RemoteServerRestartReason::DaemonDetach); + } + None +} + +pub(super) fn remote_install_running_server_plan( + endpoint_protocol_generation: Option<u32>, + detached_server_daemon: bool, + surface_interest: bool, + health_check: bool, + live_handoff: bool, + live_handoff_enabled: bool, + require_surface_interest: bool, +) -> RemoteInstallRunningServerPlan { + let Some(reason) = remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + require_surface_interest, + surface_interest, + health_check, + ) else { + return RemoteInstallRunningServerPlan::KeepRunning; + }; + + if live_handoff_enabled && live_handoff { + return RemoteInstallRunningServerPlan::LiveHandoff; + } + RemoteInstallRunningServerPlan::StopRequired(reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pre_floor_server_requires_update() { + assert_eq!( + remote_server_restart_reason(None, true, false, false, false), + Some(RemoteServerRestartReason::EndpointProtocol) + ); + } + + #[test] + fn compatible_server_can_keep_running() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + false, + false, + false + ), + None + ); + } + + #[test] + fn saved_endpoint_requires_surface_interest() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + false, + false + ), + Some(RemoteServerRestartReason::SurfaceInterest) + ); + } + + #[test] + fn saved_endpoint_requires_health_checks() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + true, + false + ), + Some(RemoteServerRestartReason::HealthCheck) + ); + } + + #[test] + fn saved_endpoint_accepts_complete_lifecycle_support() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + true, + true + ), + None + ); + } + + #[test] + fn old_daemon_requires_restart() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + false, + false, + false, + false + ), + Some(RemoteServerRestartReason::DaemonDetach) + ); + } + + #[test] + fn saved_endpoint_can_explicitly_live_handoff_for_surface_support() { + assert_eq!( + remote_install_running_server_plan( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + false, + false, + true, + true, + true + ), + RemoteInstallRunningServerPlan::LiveHandoff + ); + } +} diff --git a/src/remote/saved.rs b/src/remote/saved.rs new file mode 100644 index 00000000..47684758 --- /dev/null +++ b/src/remote/saved.rs @@ -0,0 +1,136 @@ +use std::io; +use std::path::PathBuf; + +use super::attach::{find_installed_remote_herdr, RemoteSsh, SshStdioBridge}; + +pub(crate) struct SavedSshBridge { + _bridge: SshStdioBridge, +} + +pub(crate) struct SavedSshStream { + pub(crate) stream: crate::ipc::LocalStream, + pub(crate) bridge: SavedSshBridge, +} + +pub(crate) fn connect_saved_ssh( + profile_id: &str, + target: &str, + session: &str, +) -> io::Result<SavedSshStream> { + validate_profile_path_id(profile_id)?; + crate::session::validate_name(session) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let ssh = RemoteSsh::new_noninteractive(target.to_owned()); + let remote_herdr = find_installed_remote_herdr(&ssh)?; + let path = saved_bridge_path(profile_id); + let bridge = SshStdioBridge::start( + target.to_owned(), + remote_herdr, + path.clone(), + session.to_owned(), + ssh.options(), + true, + )?; + let stream = crate::ipc::connect_local_stream(&path)?; + Ok(SavedSshStream { + stream, + bridge: SavedSshBridge { _bridge: bridge }, + }) +} + +pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String { + format!( + "herdr --remote {} --session {} --handoff", + super::shell_quote(target), + super::shell_quote(session) + ) +} + +pub(crate) fn saved_ssh_failure_needs_attention(error: &io::Error) -> bool { + if matches!( + error.kind(), + io::ErrorKind::InvalidInput + | io::ErrorKind::InvalidData + | io::ErrorKind::NotFound + | io::ErrorKind::PermissionDenied + | io::ErrorKind::Unsupported + ) { + return true; + } + let message = error.to_string().to_ascii_lowercase(); + [ + "permission denied", + "host key verification failed", + "remote host identification has changed", + "no matching host key", + "unsupported remote platform", + "not ready", + "install or update", + "protocol", + "handshake", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +fn saved_bridge_path(profile_id: &str) -> PathBuf { + let pid = std::process::id(); + let readable = format!("herdr-ssh-{pid}-{profile_id}.sock"); + let short = format!("herdr-s-{pid}-{}.sock", &profile_id[..16]); + crate::platform::remote_bridge_endpoint_path(&readable, &short) +} + +fn validate_profile_path_id(profile_id: &str) -> io::Result<()> { + if profile_id.len() == 32 + && profile_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid SSH endpoint profile id", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_paths_use_profile_identity_not_target_or_session() { + let first = saved_bridge_path("0123456789abcdef0123456789abcdef"); + let second = saved_bridge_path("fedcba9876543210fedcba9876543210"); + assert_ne!(first, second); + assert!(!first.to_string_lossy().contains("example.com")); + assert!(!first.to_string_lossy().contains("default")); + } + + #[test] + fn bootstrap_command_preserves_the_explicit_remote_session() { + assert_eq!( + saved_ssh_bootstrap_command("build host", "agent work"), + "herdr --remote 'build host' --session 'agent work' --handoff" + ); + } + + #[test] + fn prompt_and_compatibility_failures_require_attention() { + for message in [ + "Permission denied (publickey)", + "Host key verification failed", + "matching Herdr is not ready; install or update", + "handshake rejected", + ] { + assert!(saved_ssh_failure_needs_attention(&io::Error::other( + message + ))); + } + assert!(!saved_ssh_failure_needs_attention(&io::Error::new( + io::ErrorKind::TimedOut, + "network timed out" + ))); + } +} diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index bd364a70..e6fdaaa4 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -142,7 +142,7 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> { } } -fn validate_running_server_compatibility() -> io::Result<()> { +fn validate_running_server_compatibility(saved_federation: bool) -> io::Result<()> { let Some(status) = read_server_status()? else { return Err(io::Error::other(format!( "a herdr server is listening, but its status API is unavailable.\n\n{}\nIf that fails, stop the old server process manually.", @@ -150,16 +150,23 @@ fn validate_running_server_compatibility() -> io::Result<()> { ))); }; - let endpoint_generation = status - .capabilities - .as_ref() - .and_then(|capabilities| capabilities.endpoint_protocol_generation); - if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) { + let capabilities = status.capabilities.as_ref(); + let endpoint_generation = + capabilities.and_then(|capabilities| capabilities.endpoint_protocol_generation); + let surface_interest = capabilities.is_some_and(|capabilities| capabilities.surface_interest); + if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!saved_federation || surface_interest) + { return Ok(()); } + let requirement = if saved_federation && !surface_interest { + "saved SSH machines require surface lifecycle support" + } else { + "the stable endpoint generation is incompatible" + }; Err(io::Error::other(format!( - "This session predates Herdr's stable endpoint protocol and needs one final server update.\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}", + "This session needs one final server update before Herdr can attach ({requirement}).\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}", status.version.as_deref().unwrap_or("unknown"), endpoint_generation .map(|value| value.to_string()) @@ -285,18 +292,27 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu /// 1. Check if a server is listening on the client socket /// 2. If no server → spawn server daemon → wait for socket readiness /// 3. Run the thin client (which connects to the server) -pub fn auto_detect_launch() -> io::Result<()> { +pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> { let socket_path = client_socket_path(); info!(path = %socket_path.display(), "auto-detect launch starting"); - if is_server_listening_at(&socket_path) { - validate_running_server_compatibility()?; + let startup = if is_server_listening_at(&socket_path) { info!("server already running, attaching as client"); + if saved_federation { + Ok(()) + } else { + validate_running_server_compatibility(false) + } } else { info!("no server running, spawning server daemon"); - spawn_server_daemon()?; - wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?; - info!("server ready, attaching as client"); + spawn_server_daemon() + .and_then(|_| wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)) + }; + if let Err(error) = startup { + if !saved_federation { + return Err(error); + } + tracing::warn!(%error, "Local startup failed; keeping saved machines available"); } // Now attach as a thin client. @@ -522,7 +538,7 @@ test "$sid" = "$$" let path = dir.join("api.sock"); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path); - let err = validate_running_server_compatibility().unwrap_err(); + let err = validate_running_server_compatibility(false).unwrap_err(); assert!( err.to_string().contains("status API is unavailable"), @@ -558,7 +574,7 @@ test "$sid" = "$$" stream.flush().unwrap(); }); - let err = validate_running_server_compatibility().unwrap_err(); + let err = validate_running_server_compatibility(false).unwrap_err(); let message = err.to_string(); let _ = handle.join(); diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 34be0519..0af1fad1 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -13,6 +13,7 @@ pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; const CLIENT_SHELL_METHODS: &[&str] = &[ + "client_shell.surface.set", "command.invoke", "integration.install", "integration.list", @@ -77,6 +78,30 @@ pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) }) } +pub(crate) fn success_message_with_result( + boot_id: String, + request_id: String, + result: crate::api::schema::ResponseResult, +) -> crate::protocol::ServerMessage { + let response = serde_json::to_string(&crate::api::schema::SuccessResponse { + id: request_id.clone(), + result, + }) + .unwrap_or_else(|_| { + error_response( + request_id.clone(), + "serialization_error", + "failed to serialize endpoint response", + ) + }); + crate::protocol::ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk: true, + data: response.into_bytes(), + } +} + pub(crate) fn error_message( boot_id: String, request_id: String, @@ -312,6 +337,11 @@ mod tests { #[test] fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { + assert!(supports_client_shell_method( + &Method::ClientShellSurfaceSet(crate::api::schema::ClientShellSurfaceSetParams { + active: false, + }) + )); assert!(supports_client_shell_method(&Method::ServerReloadConfig( crate::api::schema::EmptyParams::default(), ))); diff --git a/src/server/client_endpoint_control.rs b/src/server/client_endpoint_control.rs new file mode 100644 index 00000000..09970004 --- /dev/null +++ b/src/server/client_endpoint_control.rs @@ -0,0 +1,24 @@ +pub(crate) fn response(kind: &str, data: String) -> Option<crate::protocol::ServerMessage> { + (kind == crate::protocol::endpoint::HEALTH_PING_KIND).then(|| { + crate::protocol::ServerMessage::EndpointControl { + kind: crate::protocol::endpoint::HEALTH_PONG_KIND.into(), + data, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_ping_echoes_a_pong_without_entering_server_state() { + let pong = response(crate::protocol::endpoint::HEALTH_PING_KIND, "probe".into()).unwrap(); + assert!(matches!( + pong, + crate::protocol::ServerMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::HEALTH_PONG_KIND && data == "probe" + )); + assert!(response("future.control", String::new()).is_none()); + } +} diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs index 578350b2..00355230 100644 --- a/src/server/client_shell_graphics.rs +++ b/src/server/client_shell_graphics.rs @@ -1,6 +1,48 @@ use crate::kitty_graphics::surface::DeliveryCache; use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; +pub(crate) fn collect_retained( + app: &crate::app::App, + surface: &crate::protocol::PaneSurfaceFrame, + target: crate::ui::TabSurfaceTarget, + cell_size: crate::kitty_graphics::HostCellSize, + delivered: &DeliveryCache, + client_id: u64, +) -> Option<(SurfaceGraphicsScene, DeliveryCache)> { + let rect = |rect: crate::protocol::SurfaceRect| { + ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height) + }; + let pane_infos = surface + .panes + .iter() + .map(|pane| { + let (workspace_index, id) = app.parse_pane_id(&pane.pane_id)?; + if workspace_index != target.workspace_index { + return None; + } + Some(crate::layout::PaneInfo { + id, + rect: rect(pane.rect), + inner_rect: rect(pane.inner_rect), + scrollbar_rect: pane.scrollbar_rect.map(rect), + borders: ratatui::widgets::Borders::NONE, + is_focused: pane.focused, + }) + }) + .collect::<Option<Vec<_>>>()?; + // Image clipping uses the retained content rectangles, not borders or split handles. + Some(collect( + app, + &pane_infos, + &[], + None, + Some(target), + cell_size, + delivered, + client_id, + )) +} + pub(crate) fn collect( app: &crate::app::App, pane_infos: &[crate::layout::PaneInfo], diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 25a963e7..c57d0f86 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -124,6 +124,11 @@ pub(crate) struct ClientWriter { } impl ClientWriter { + /// Drops render-lane work that has not yet been claimed by the writer. + pub(crate) fn discard_pending_render(&self) { + self.render.queue.discard_pending_render(); + } + #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec<u8>) { self.render.try_send(data).unwrap(); @@ -304,6 +309,13 @@ impl ClientWriterQueue { Ok(()) } + fn discard_pending_render(&self) { + let mut state = self.lock_state(); + state.render = None; + state.ordered.clear(); + self.ready.notify_all(); + } + fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> { let mut state = self.lock_state(); if !state.writer_alive { @@ -382,6 +394,7 @@ pub(crate) enum ServerEvent { direct_graphics: bool, endpoint_keybindings: bool, mouse_capture: bool, + surface_active: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -483,6 +496,8 @@ pub(crate) enum ServerEvent { ClientShellFocus { client_id: u64, focused: bool }, /// A client-owned shell updated its local mouse-capture preference. ClientShellMouseCapture { client_id: u64, enabled: bool }, + /// The committed shell asks the server to replay presentation effects before input resumes. + ClientShellPresentationSync { client_id: u64, token: String }, /// A client-owned shell invoked one endpoint operation through this connection. ClientShellEndpointRequest { client_id: u64, @@ -744,6 +759,7 @@ pub(crate) fn handle_client_handshake( hello.direct_graphics, hello.endpoint_keybindings, hello.mouse_capture, + hello.surface_active, )), ) } @@ -831,33 +847,39 @@ pub(crate) fn handle_client_handshake( } // Notify the main loop about the new client. - let connected = - if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) = - shell_options - { - ServerEvent::ClientShellConnected { - client_id, - surface_cols: client_cols, - surface_rows: client_rows, - cell_width_px, - cell_height_px, - pixel_mouse, - direct_graphics, - endpoint_keybindings, - mouse_capture, - writer, - } - } else { - ServerEvent::ClientConnected { - client_id, - cols: client_cols, - rows: client_rows, - cell_width_px, - cell_height_px, - pixel_mouse: terminal_pixel_mouse, - writer, - } - }; + let endpoint_control_writer = shell_options.as_ref().map(|_| writer.control.clone()); + let connected = if let Some(( + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + surface_active, + )) = shell_options + { + ServerEvent::ClientShellConnected { + client_id, + surface_cols: client_cols, + surface_rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + surface_active, + writer, + } + } else { + ServerEvent::ClientConnected { + client_id, + cols: client_cols, + rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse: terminal_pixel_mouse, + writer, + } + }; if let Err(err) = server_event_tx.blocking_send(connected) { match err.0 { ServerEvent::ClientConnected { writer, .. } @@ -869,7 +891,13 @@ pub(crate) fn handle_client_handshake( } // Enter read loop — read client messages and forward to main loop. - client_read_loop(stream, client_id, server_event_tx, should_quit) + client_read_loop_with_endpoint_controls( + stream, + client_id, + server_event_tx, + should_quit, + endpoint_control_writer.as_ref(), + ) } fn send_shutdown_to_unregistered_client(writer: &ClientWriter) { @@ -924,11 +952,22 @@ fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool { } /// The client read loop — reads messages from the client and forwards to the server event channel. +#[cfg(test)] fn client_read_loop( + stream: LocalStream, + client_id: u64, + server_event_tx: &mpsc::Sender<ServerEvent>, + should_quit: &Arc<AtomicBool>, +) -> io::Result<()> { + client_read_loop_with_endpoint_controls(stream, client_id, server_event_tx, should_quit, None) +} + +fn client_read_loop_with_endpoint_controls( mut stream: LocalStream, client_id: u64, server_event_tx: &mpsc::Sender<ServerEvent>, should_quit: &Arc<AtomicBool>, + endpoint_control_writer: Option<&ClientControlWriter>, ) -> io::Result<()> { while !should_quit.load(Ordering::Acquire) { let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) @@ -1242,8 +1281,29 @@ fn client_read_loop( }, } } - ClientMessage::EndpointControl { kind, .. } => { - debug!(client_id, %kind, "ignoring unknown endpoint control message"); + ClientMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND => + { + ServerEvent::ClientShellPresentationSync { + client_id, + token: data, + } + } + ClientMessage::EndpointControl { kind, data } => { + let Some(response) = crate::server::client_endpoint_control::response(&kind, data) + else { + debug!(client_id, %kind, "ignoring unknown endpoint control message"); + continue; + }; + let Some(writer) = endpoint_control_writer else { + continue; + }; + let mut framed = Vec::new(); + if protocol::write_message(&mut framed, &response).is_err() + || writer.send(framed).is_err() + { + break; + } continue; } ClientMessage::Detach => { @@ -1356,6 +1416,7 @@ mod tests { direct_graphics: true, endpoint_keybindings: true, mouse_capture: true, + surface_active: true, snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()], input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()], @@ -1767,6 +1828,7 @@ mod tests { direct_graphics, endpoint_keybindings, mouse_capture, + surface_active, writer, } => { assert_eq!(client_id, 43); @@ -1776,6 +1838,7 @@ mod tests { assert!(direct_graphics); assert!(endpoint_keybindings); assert!(mouse_capture); + assert!(surface_active); drop(writer); } other => panic!("expected ClientShellConnected, got {other:?}"), diff --git a/src/server/clients.rs b/src/server/clients.rs index 4f1baadb..972342ac 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -159,6 +159,8 @@ pub(crate) struct ClientConnection { pub(crate) host_keyboard_report_all_active: Option<bool>, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this connection receives pane surfaces and may affect presentation state. + pub(crate) shell_surface_active: bool, /// Whether this shell wants host mouse capture without pane demand. pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. @@ -179,6 +181,9 @@ pub(crate) struct ClientConnection { pub(crate) shell_projection_revision: u64, /// Whether this shell is waiting for one ordered endpoint command response. pub(crate) shell_endpoint_command_in_flight: bool, + /// Surface projection epoch that owned the in-flight command. Deferred navigation may run + /// only if this exact presentation lease is still active when its response arrives. + pub(crate) shell_endpoint_command_surface_revision: Option<u64>, /// Request id and buffered response for a deferred worktree-created navigation. pub(crate) shell_deferred_navigation_request_id: Option<String>, pub(crate) shell_deferred_navigation_response: Option<Vec<u8>>, @@ -230,6 +235,7 @@ impl ClientConnection { outer_terminal_focus: None, host_keyboard_report_all_active: None, render_pending: false, + shell_surface_active: true, shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, @@ -240,6 +246,7 @@ impl ClientConnection { shell_snapshot: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, + shell_endpoint_command_surface_revision: None, shell_deferred_navigation_request_id: None, shell_deferred_navigation_response: None, shell_uses_endpoint_keybindings: false, @@ -451,12 +458,16 @@ impl ClientConnection { pub(crate) fn is_shell_client(&self) -> bool { matches!(self.mode, ClientConnectionMode::ClientShell) } + + pub(crate) fn is_active_shell_client(&self) -> bool { + self.is_shell_client() && self.shell_surface_active + } } pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { clients .iter() - .filter(|(_, client)| client.is_shell_client()) + .filter(|(_, client)| client.is_active_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } diff --git a/src/server/headless.rs b/src/server/headless.rs index 04a1b17b..abaca0aa 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -73,11 +73,13 @@ use crate::server::terminal_attach::paste_payload_for_runtime; mod bootstrap; mod client_views; +mod endpoint_requests; mod lifecycle; mod notifications; mod pane_graphics; mod render; mod retained_surface; +mod surface_interest; pub use bootstrap::run_server; use lifecycle::wait_for_live_handoff_response_write; @@ -910,6 +912,9 @@ impl HeadlessServer { let Some(client) = self.clients.get_mut(&client_id) else { return false; }; + if !client.shell_surface_active { + return false; + } client.last_activity = stamp; let changed = self.foreground_client_id != Some(client_id); @@ -929,13 +934,13 @@ impl HeadlessServer { fn app_client_count(&self) -> usize { self.clients .values() - .filter(|client| client.is_shell_client() && client.writer.is_some()) + .filter(|client| client.is_active_shell_client() && client.writer.is_some()) .count() } fn client_supports_direct_graphics(&self, client_id: u64) -> bool { self.clients.get(&client_id).is_some_and(|client| { - client.is_shell_client() + client.is_active_shell_client() && client.writer.is_some() && client.direct_graphics && client.pixel_mouse @@ -992,12 +997,14 @@ impl HeadlessServer { let disconnected_focus = self .clients .get(&client_id) - .filter(|client| client.is_shell_client() && client.outer_terminal_focus == Some(true)) + .filter(|client| { + client.is_active_shell_client() && client.outer_terminal_focus == Some(true) + }) .and_then(|_| self.shell_focus_target(client_id)); let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| { !self.clients.iter().any(|(&other_id, client)| { other_id != client_id - && client.is_shell_client() + && client.is_active_shell_client() && client.outer_terminal_focus == Some(true) && self.shell_tab_id_for_client(other_id).as_deref() == Some(target.tab_id.as_str()) @@ -1008,7 +1015,8 @@ impl HeadlessServer { self.tab_geometry_controllers .retain(|_, controller_id| *controller_id != client_id); if let Some(mut removed) = removed { - self.release_client_shell_inputs(client_id, &mut removed); + let held_inputs = removed.drain_shell_held_inputs(); + self.release_client_shell_inputs(client_id, held_inputs); crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); @@ -1033,8 +1041,12 @@ impl HeadlessServer { } } - fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) { - for held in client.drain_shell_held_inputs() { + fn release_client_shell_inputs( + &mut self, + client_id: u64, + held_inputs: Vec<crate::server::clients::ClientShellHeldInput>, + ) { + for held in held_inputs { let result = match held.target { ClientShellInputTarget::Pane(pane_id) => { let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) @@ -1181,16 +1193,18 @@ impl HeadlessServer { protocol::ClientClipboardImageTarget::Pane(pane_id) => { !self.handoff_in_progress && self.app.state.popup_pane.is_none() - && self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + && self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) && self.app.parse_pane_id(pane_id).is_some() } protocol::ClientClipboardImageTarget::Popup(terminal_id) => { !self.handoff_in_progress - && self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + && self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) && self .app .state @@ -1237,9 +1251,10 @@ impl HeadlessServer { } protocol::ClientClipboardImageTarget::Pane(pane_id) => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -1273,9 +1288,10 @@ impl HeadlessServer { } protocol::ClientClipboardImageTarget::Popup(terminal_id) => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -1929,6 +1945,7 @@ impl HeadlessServer { direct_graphics, endpoint_keybindings, mouse_capture, + surface_active, writer, } => { if self.handoff_in_progress { @@ -1950,6 +1967,7 @@ impl HeadlessServer { rows = surface_rows, cell_width_px, cell_height_px, + surface_active, render_encoding = ?protocol::RenderEncoding::SemanticFrame, "client connected" ); @@ -1972,6 +1990,7 @@ impl HeadlessServer { connection.direct_graphics = direct_graphics; connection.shell_uses_endpoint_keybindings = endpoint_keybindings; connection.shell_mouse_capture = mouse_capture; + connection.shell_surface_active = surface_active; connection.shell_projection_revision = 1; let config_diagnostic = if endpoint_keybindings { self.server_config_diagnostic.as_deref() @@ -2002,7 +2021,9 @@ impl HeadlessServer { self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id); } self.send_to_client(client_id, snapshot_message); - self.foreground_client_id = Some(client_id); + if surface_active { + self.foreground_client_id = Some(client_id); + } if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } @@ -2224,6 +2245,9 @@ impl HeadlessServer { client.cell_size = observed; } client.pixel_mouse = pixel_mouse && observed.is_known(); + if !client.shell_surface_active { + return false; + } client.request_repaint(); self.promote_client_to_foreground(client_id); self.resize_shell_tab_if_controller(client_id, true); @@ -2239,7 +2263,7 @@ impl HeadlessServer { if !client.update_host_theme(&update) { return false; } - if self.foreground_client_id != Some(client_id) { + if !client.shell_surface_active || self.foreground_client_id != Some(client_id) { return false; } let mut changed = self.app.set_host_terminal_appearance_state( @@ -2256,15 +2280,14 @@ impl HeadlessServer { let Some(client) = self.clients.get(&client_id) else { return false; }; - if !matches!(client.mode, ClientConnectionMode::ClientShell) - || client.outer_terminal_focus == Some(focused) + if !client.is_active_shell_client() || client.outer_terminal_focus == Some(focused) { return false; } let tab_id = self.shell_tab_id_for_client(client_id); let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| { other_id != client_id - && client.is_shell_client() + && client.is_active_shell_client() && client.outer_terminal_focus == Some(true) && self.shell_tab_id_for_client(other_id) == tab_id }); @@ -2308,15 +2331,38 @@ impl HeadlessServer { client.host_mouse_capture_active = None; true } + ServerEvent::ClientShellPresentationSync { client_id, token } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !client.is_active_shell_client() { + return false; + } + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + client.host_keyboard_report_all_active = None; + self.sent_window_title = None; + self.stream_host_mouse_capture_mode(); + self.stream_direct_terminal_keyboard_mode(); + self.sync_window_title(); + self.send_to_client( + client_id, + ServerMessage::EndpointControl { + kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND.into(), + data: token, + }, + ) + } ServerEvent::ClientShellPaneInput { client_id, pane_id, events, } => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -2400,9 +2446,10 @@ impl HeadlessServer { events, } => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -2494,96 +2541,8 @@ impl HeadlessServer { ServerEvent::ClientShellEndpointRequest { client_id, boot_id, - mut request, - } => { - let Some(client) = self.clients.get(&client_id) else { - return false; - }; - if !matches!(client.mode, ClientConnectionMode::ClientShell) { - self.remove_client_and_resize_if_needed(client_id); - return true; - } - let request_id = request.id.clone(); - if !crate::server::client_commands::supports_client_shell_method(&request.method) { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "unsupported_endpoint_command", - "this method is not available through the client shell command lane", - ); - self.send_to_client(client_id, message); - return false; - } - if boot_id != self.client_shell_boot_id { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "stale_boot", - "endpoint command targeted an earlier server boot", - ); - self.send_to_client(client_id, message); - return false; - } - if client.shell_endpoint_command_in_flight { - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some("client sent concurrent endpoint commands".into()), - }, - ); - self.remove_client_and_resize_if_needed(client_id); - return true; - } - - let api_request_id = format!( - "endpoint:{}:{client_id}:{request_id}", - self.client_shell_boot_id - ); - request.id = api_request_id.clone(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if let Err(err) = crate::server::client_commands::spawn_response_waiter( - client_id, - boot_id.clone(), - request_id.clone(), - response_rx, - self.server_event_tx.clone(), - ) { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "server_unavailable", - format!("failed to start endpoint response bridge: {err}"), - ); - self.send_to_client(client_id, message); - return false; - } - if let Some(client) = self.clients.get_mut(&client_id) { - client.shell_endpoint_command_in_flight = true; - let deferred_worktree = matches!( - &request.method, - api::schema::Method::WorktreeCreate(_) - | api::schema::Method::WorktreeRemove(_) - ); - let deferred_navigation = matches!( - &request.method, - api::schema::Method::WorktreeCreate(params) if params.focus - ); - client.shell_deferred_navigation_request_id = - deferred_worktree.then(|| api_request_id.clone()); - client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); - } - let foreground_changed = self.promote_client_to_foreground(client_id); - foreground_changed - | self.handle_client_shell_api_request( - client_id, - api::ApiRequestMessage { - request: *request, - respond_to, - response_write_complete: None, - stream_active: None, - }, - ) - } + request, + } => self.handle_client_shell_endpoint_request(client_id, boot_id, request), ServerEvent::ClientShellEndpointResponseChunkReady { client_id, boot_id, @@ -2591,14 +2550,16 @@ impl HeadlessServer { final_chunk, data, } => { - let valid = self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) + let command_surface_revision = self.clients.get(&client_id).and_then(|client| { + (matches!(client.mode, ClientConnectionMode::ClientShell) && client.shell_endpoint_command_in_flight - && boot_id == self.client_shell_boot_id + && boot_id == self.client_shell_boot_id) + .then_some(client.shell_endpoint_command_surface_revision) + .flatten() }); - if !valid { + let Some(command_surface_revision) = command_surface_revision else { return false; - } + }; let completed_deferred_response = self.clients.get_mut(&client_id).and_then(|client| { let response = client.shell_deferred_navigation_response.as_mut()?; @@ -2610,6 +2571,7 @@ impl HeadlessServer { if final_chunk { if let Some(client) = self.clients.get_mut(&client_id) { client.shell_endpoint_command_in_flight = false; + client.shell_endpoint_command_surface_revision = None; client.shell_deferred_navigation_request_id = None; } } @@ -2618,7 +2580,10 @@ impl HeadlessServer { .and_then(Self::deferred_endpoint_navigation_tab_id); let focus_before = self.shell_focus_target(client_id); let focused_tabs_before = self.focused_shell_tabs(); - let navigation_changed = deferred_tab_id + let navigation_changed = self.clients.get(&client_id).is_some_and(|client| { + client.is_active_shell_client() + && client.shell_projection_revision == command_surface_revision + }) && deferred_tab_id .as_deref() .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id)); let geometry_changed = diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 2d49a50a..2257a05e 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -388,7 +388,7 @@ impl HeadlessServer { self.clients .iter() .filter(|(_, client)| { - client.is_shell_client() && client.outer_terminal_focus == Some(true) + client.is_active_shell_client() && client.outer_terminal_focus == Some(true) }) .filter_map(|(&client_id, _)| self.shell_tab_id_for_client(client_id)) .collect() @@ -397,7 +397,7 @@ impl HeadlessServer { pub(super) fn shell_focus_targets(&self) -> Vec<(u64, Option<ShellFocusTarget>)> { self.clients .iter() - .filter(|(_, client)| client.is_shell_client()) + .filter(|(_, client)| client.is_active_shell_client()) .map(|(&client_id, _)| (client_id, self.shell_focus_target(client_id))) .collect() } @@ -608,11 +608,16 @@ impl HeadlessServer { &mut self, start_pending_agent_resumes: bool, ) -> bool { - if self.app_client_count() != 1 { + let active_shell_count = self + .clients + .values() + .filter(|client| client.is_active_shell_client() && client.writer.is_some()) + .count(); + if active_shell_count != 1 { return false; } let Some(client_id) = self.clients.iter().find_map(|(&client_id, client)| { - (client.is_shell_client() && client.writer.is_some()).then_some(client_id) + (client.is_active_shell_client() && client.writer.is_some()).then_some(client_id) }) else { return false; }; @@ -625,7 +630,7 @@ impl HeadlessServer { ) -> bool { let mut viewed_tabs = HashMap::<String, Vec<u64>>::new(); for (&client_id, client) in &self.clients { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { continue; } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { @@ -684,6 +689,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; @@ -698,6 +710,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; @@ -713,6 +732,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs new file mode 100644 index 00000000..4f02b676 --- /dev/null +++ b/src/server/headless/endpoint_requests.rs @@ -0,0 +1,130 @@ +use super::*; + +impl HeadlessServer { + pub(super) fn handle_client_shell_endpoint_request( + &mut self, + client_id: u64, + boot_id: String, + mut request: Box<api::schema::Request>, + ) -> bool { + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + self.remove_client_and_resize_if_needed(client_id); + return true; + } + let request_id = request.id.clone(); + if !crate::server::client_commands::supports_client_shell_method(&request.method) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "unsupported_endpoint_command", + "this method is not available through the client shell command lane", + ); + self.send_to_client(client_id, message); + return false; + } + if boot_id != self.client_shell_boot_id { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "stale_boot", + "endpoint command targeted an earlier server boot", + ); + self.send_to_client(client_id, message); + return false; + } + let surface_active = client.shell_surface_active; + if let api::schema::Method::ClientShellSurfaceSet(params) = &request.method { + let Some((changed, projection_revision)) = + self.set_client_shell_surface_active(client_id, params.active) + else { + return false; + }; + self.send_to_client( + client_id, + crate::server::client_commands::success_message_with_result( + boot_id, + request_id, + api::schema::ResponseResult::ClientShellSurfaceSet { + active: params.active, + projection_revision, + }, + ), + ); + return changed; + } + if client.shell_endpoint_command_in_flight { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "endpoint_busy", + "this endpoint is still processing another command", + ); + self.send_to_client(client_id, message); + return false; + } + if !surface_active { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "surface_inactive", + "this method requires an active client shell surface", + ); + self.send_to_client(client_id, message); + return false; + } + + let api_request_id = format!( + "endpoint:{}:{client_id}:{request_id}", + self.client_shell_boot_id + ); + request.id = api_request_id.clone(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = crate::server::client_commands::spawn_response_waiter( + client_id, + boot_id.clone(), + request_id.clone(), + response_rx, + self.server_event_tx.clone(), + ) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "server_unavailable", + format!("failed to start endpoint response bridge: {err}"), + ); + self.send_to_client(client_id, message); + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = true; + // A later source restore has a new projection revision. Keep this request's lease + // so a delayed worktree response cannot focus a pane after endpoint switching. + client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision); + let deferred_worktree = matches!( + &request.method, + api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) + ); + let deferred_navigation = matches!( + &request.method, + api::schema::Method::WorktreeCreate(params) if params.focus + ); + client.shell_deferred_navigation_request_id = + deferred_worktree.then(|| api_request_id.clone()); + client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); + } + let foreground_changed = self.promote_client_to_foreground(client_id); + foreground_changed + | self.handle_client_shell_api_request( + client_id, + api::ApiRequestMessage { + request: *request, + respond_to, + response_write_complete: None, + stream_active: None, + }, + ) + } +} diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 991d1861..5e36be01 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -39,7 +39,10 @@ impl HeadlessServer { .iter() .filter_map(|(&client_id, client)| match &client.mode { ClientConnectionMode::ClientShell => { - let focused = self.shell_focused_runtime(client_id); + let focused = client + .shell_surface_active + .then(|| self.shell_focused_runtime(client_id)) + .flatten(); let child_requests_mouse = focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled()); let sgr_pixels = client.pixel_mouse @@ -49,8 +52,9 @@ impl HeadlessServer { }); Some(( client_id, - client.shell_mouse_capture || child_requests_mouse, - sgr_pixels, + client.shell_surface_active + && (client.shell_mouse_capture || child_requests_mouse), + client.shell_surface_active && sgr_pixels, )) } ClientConnectionMode::TerminalAttach { terminal_id } => { @@ -113,9 +117,10 @@ impl HeadlessServer { .clients .iter() .filter(|(_, client)| client.is_shell_client()) - .map(|(&client_id, _)| { - let report_all = - self.shell_focused_runtime(client_id) + .map(|(&client_id, client)| { + let report_all = client.shell_surface_active + && self + .shell_focused_runtime(client_id) .is_some_and(|(runtime, _)| { let protocol = runtime.keyboard_protocol(); protocol.reports_all_keys() @@ -224,7 +229,7 @@ impl HeadlessServer { let mut pane_ids = HashSet::new(); if has_app_target { for (&client_id, client) in &self.clients { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { continue; } let Some(target) = self.shell_target_for_client(client_id) else { @@ -279,7 +284,10 @@ impl HeadlessServer { .filter(|client| client.writer.is_some()) { match &client.mode { - ClientConnectionMode::ClientShell => has_app_target = true, + ClientConnectionMode::ClientShell if client.shell_surface_active => { + has_app_target = true; + } + ClientConnectionMode::ClientShell => {} ClientConnectionMode::TerminalAttach { terminal_id } | ClientConnectionMode::TerminalObserve { terminal_id } => { direct_terminal_targets.insert(terminal_id.as_str()); @@ -339,7 +347,7 @@ impl HeadlessServer { fn any_shell_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { self.clients.iter().any(|(&client_id, client)| { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { return false; } if self @@ -456,6 +464,10 @@ impl HeadlessServer { client.shell_snapshot = Some(candidate); } shell_projection_revision = client.shell_projection_revision; + if !client.shell_surface_active { + client.clear_deferred_render(); + continue; + } } let shell_graphics_delivery = self .clients diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index 230704a6..a62a43f6 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -199,12 +199,14 @@ struct CollectedPanePatch { mouse_reporting: bool, sgr_pixel_mouse: bool, alternate_screen_active: bool, + graphics_may_have_placements: bool, } struct RetainedRecipientUpdate { client_id: u64, surface: protocol::PaneSurfaceFrame, patch: protocol::PaneSurfacePatch, + graphics_delivery: Option<crate::kitty_graphics::surface::DeliveryCache>, } impl HeadlessServer { @@ -239,11 +241,20 @@ impl HeadlessServer { { fallback!("unsafe_state"); } - let targets = render_targets(&self.clients, self.foreground_client_id); - if targets.is_empty() - || targets - .iter() - .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) + let mut targets = render_targets(&self.clients, self.foreground_client_id); + targets.retain(|(client_id, _, _, _, mode)| { + !matches!(mode, ClientConnectionMode::ClientShell) + || self + .clients + .get(client_id) + .is_some_and(|client| client.shell_surface_active) + }); + if targets.is_empty() { + success!("no_active_surface"); + } + if targets + .iter() + .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) { fallback!("non_shell_target"); } @@ -266,8 +277,6 @@ impl HeadlessServer { || surface.frame.height != *rows || surface.popup.is_some() || !surface.graphics.assets.is_empty() - || !surface.graphics.placements.is_empty() - || !surface.graphics.retained_assets.is_empty() || !surface.frame.graphics.is_empty() { fallback!("baseline_mismatch"); @@ -325,6 +334,8 @@ impl HeadlessServer { fallback!("terminal_patch"); } }; + let graphics_may_have_placements = + crate::kitty_graphics::is_enabled() && runtime.kitty_graphics_may_have_placements(); let revision = runtime.content_seq(); if revision != revision_before || !revision.is_multiple_of(2) { fallback!("content_changed"); @@ -337,6 +348,7 @@ impl HeadlessServer { mouse_reporting: runtime.mouse_reporting_enabled(), sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), alternate_screen_active: runtime.alternate_screen_active(), + graphics_may_have_placements, }); } @@ -349,6 +361,8 @@ impl HeadlessServer { let mut changed_panes = Vec::with_capacity(collected.len()); let mut patch_rows = Vec::new(); let mut metadata_changed = false; + let mut refresh_graphics = !surface.graphics.placements.is_empty() + || !surface.graphics.retained_assets.is_empty(); for collected_pane in &collected { let Some(pane) = surface .panes @@ -370,6 +384,7 @@ impl HeadlessServer { ) { fallback!("hyperlink"); } + refresh_graphics |= collected_pane.graphics_may_have_placements; let previous_pane = pane.clone(); let Some(rows) = apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch) @@ -405,7 +420,31 @@ impl HeadlessServer { let cursor = retained_cursor(&self.app, &surface.panes); let cursor_changed = cursor != surface.frame.cursor; surface.frame.cursor = cursor.clone(); - if patch_rows.is_empty() && !cursor_changed && !metadata_changed { + let mut graphics_changed = false; + let graphics_delivery = if refresh_graphics { + let Some(target) = self.shell_target_for_client(client_id) else { + fallback!("graphics_target"); + }; + let client = &self.clients[&client_id]; + let Some((graphics, delivery)) = + crate::server::client_shell_graphics::collect_retained( + &self.app, + &surface, + target, + client.cell_size, + &client.shell_graphics_delivery, + client_id, + ) + else { + fallback!("graphics_geometry"); + }; + graphics_changed = graphics != surface.graphics; + surface.graphics = graphics; + Some(delivery) + } else { + None + }; + if patch_rows.is_empty() && !cursor_changed && !metadata_changed && !graphics_changed { continue; } let patch = protocol::PaneSurfacePatch { @@ -421,6 +460,7 @@ impl HeadlessServer { client_id, surface, patch, + graphics_delivery, }); } if updates.is_empty() { @@ -435,6 +475,7 @@ impl HeadlessServer { client_id, surface, patch, + graphics_delivery, } = update; let Some(client) = self.clients.get_mut(&client_id) else { continue; @@ -444,31 +485,53 @@ impl HeadlessServer { deferred += 1; continue; }; - let Some(prepared) = client - .render_state - .prepare_pane_surface_patch(patch, surface) - else { + // The published row patch cannot carry images. Reuse the retained text/layout + // in a graphics-capable surface message rather than invoking the full renderer. + let prepared = if graphics_delivery.is_some() { + client.render_state.prepare_pane_surface(surface) + } else { + client + .render_state + .prepare_pane_surface_patch(patch, surface) + }; + let Some(prepared) = prepared else { client.defer_full_render(); deferred += 1; continue; }; - let serialized = match Self::frame_server_message(prepared.message()) { - Ok(serialized) => serialized, - Err(error) => { - warn!( - client_id, - %error, - "failed to serialize retained pane surface patch" - ); - client.defer_full_render(); - deferred += 1; - continue; - } + let max_frame_size = if graphics_delivery.is_some() { + MAX_GRAPHICS_FRAME_SIZE + } else { + protocol::MAX_FRAME_SIZE }; + let serialized = + match Self::frame_server_message_with_max(prepared.message(), max_frame_size) { + Ok(serialized) => serialized, + Err(error) => { + warn!( + client_id, + %error, + "failed to serialize retained pane surface patch" + ); + client.defer_full_render(); + deferred += 1; + continue; + } + }; crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64); match writer.render.try_send(serialized) { Ok(()) => { - client.clear_deferred_render(); + let graphics_pending = graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + if let Some(delivery) = graphics_delivery { + client.shell_graphics_delivery = delivery; + } + if graphics_pending { + client.defer_full_render(); + } else { + client.clear_deferred_render(); + } client.render_state.commit_sent_frame(prepared); sent += 1; } diff --git a/src/server/headless/surface_interest.rs b/src/server/headless/surface_interest.rs new file mode 100644 index 00000000..7270be0d --- /dev/null +++ b/src/server/headless/surface_interest.rs @@ -0,0 +1,86 @@ +use super::*; + +impl HeadlessServer { + /// Apply a client-shell surface lease and return the resulting projection floor. + /// + /// Every `active: true` request advances the floor, even if the server already considered + /// the connection active. That makes a new client activation epoch distinguishable from a + /// delayed same-boot PaneSurface that was prepared for an earlier epoch. + pub(super) fn set_client_shell_surface_active( + &mut self, + client_id: u64, + active: bool, + ) -> Option<(bool, u64)> { + let focus_before = self.shell_focus_targets(); + let focused_tabs_before = self.focused_shell_tabs(); + let (changed, projection_revision) = { + let client = self.clients.get_mut(&client_id)?; + if !client.is_shell_client() { + return None; + } + let changed = client.shell_surface_active != active; + if active { + client.shell_projection_revision = + client.shell_projection_revision.saturating_add(1); + // Force the next control snapshot to carry this new floor instead of reusing a + // same-boot cached snapshot from the prior surface epoch. + client.shell_snapshot = None; + } + if !changed && !active { + return Some((false, client.shell_projection_revision)); + } + client.shell_surface_active = active; + client.request_repaint(); + client.shell_graphics_delivery = Default::default(); + // The client drops target effects while its old source frame is frozen. Reset the + // dedupe state whenever a viewer is (re)activated so the post-commit replay can + // produce mouse/keyboard modes and graphics even when runtime demand is unchanged. + if active { + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + client.host_keyboard_report_all_active = None; + } + client.clear_deferred_render(); + if !active { + if let Some(writer) = &client.writer { + writer.discard_pending_render(); + } + } + (changed, client.shell_projection_revision) + }; + let held_inputs = (!active && changed).then(|| { + self.clients + .get_mut(&client_id) + .expect("checked client") + .drain_shell_held_inputs() + }); + + if let Some(held_inputs) = held_inputs { + self.release_client_shell_inputs(client_id, held_inputs); + self.retire_direct_graphics_for_client(client_id); + } + if changed { + self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); + } + + if active { + self.promote_client_to_foreground(client_id); + // Do not emit/cache a title during a frozen target activation. A committed client + // explicitly requests the bounded replay after its coherent frame is visible. + self.sent_window_title = None; + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + self.claim_shell_tab_geometry(client_id, true); + } else { + self.tab_geometry_controllers + .retain(|_, controller_id| *controller_id != client_id); + if self.foreground_client_id == Some(client_id) { + self.promote_latest_remaining_client(); + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + } else { + self.sync_foreground_client_state(); + } + self.reapply_controlled_shell_tab_geometry(true); + } + Some((changed || active, projection_revision)) + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index e7e73e8b..5e0e5d01 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -2,6 +2,8 @@ use super::*; #[path = "pane_graphics.rs"] mod pane_graphics_tests; +#[path = "surface_interest.rs"] +mod surface_interest_tests; fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> { let ServerMessage::EndpointControl { kind, data } = message else { @@ -607,6 +609,7 @@ async fn client_shell_attach_seeds_workspace() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -636,6 +639,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -654,6 +658,26 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { ); assert!(server.clients[&client_id].shell_endpoint_command_in_flight); + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "client-shell:busy".into(), + method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default()), + }), + }) + ); + assert!(server.clients.contains_key(&client_id)); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("busy endpoint response")) + else { + panic!("expected busy endpoint response"); + }; + let response = + serde_json::from_slice::<api::schema::ErrorResponse>(&data).expect("typed busy response"); + assert_eq!(response.error.code, "endpoint_busy"); + let response_ready = server .server_event_rx .recv() @@ -751,6 +775,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -894,6 +919,7 @@ fn connect_test_shell( direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -1219,6 +1245,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer: local_writer, }) ); @@ -1242,6 +1269,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { direct_graphics: false, endpoint_keybindings: true, mouse_capture: false, + surface_active: true, writer: endpoint_writer, }) ); @@ -1351,6 +1379,12 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .get_mut(&51) .unwrap() .shell_endpoint_command_in_flight = true; + let source_surface_revision = server.clients[&51].shell_projection_revision; + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); server .clients .get_mut(&51) @@ -1385,6 +1419,11 @@ async fn deferred_worktree_response_moves_only_its_source_client() { ); assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); + // A source command begun in the old presentation epoch may finish after source-off and + // source-on rollback. Its response remains endpoint-local, but it must not apply deferred + // client navigation to the restored source. + assert!(server.set_client_shell_surface_active(51, false).is_some()); + assert!(server.set_client_shell_surface_active(51, true).is_some()); server .clients .get_mut(&51) @@ -1394,7 +1433,17 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .clients .get_mut(&51) .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); + server + .clients + .get_mut(&51) + .unwrap() .shell_deferred_navigation_request_id = Some("background-worktree".into()); + server + .clients + .get_mut(&51) + .unwrap() + .shell_deferred_navigation_response = Some(Vec::new()); assert!( !server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { client_id: 51, @@ -1919,6 +1968,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -2168,6 +2218,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -2664,6 +2715,19 @@ fn retained_test_server( std::sync::mpsc::Receiver<Vec<u8>>, crate::layout::PaneId, ) { + let (server, _control_rx, render_rx, pane_id) = + retained_test_server_with_control(initial_screen); + (server, render_rx, pane_id) +} + +fn retained_test_server_with_control( + initial_screen: &[u8], +) -> ( + HeadlessServer, + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, + crate::layout::PaneId, +) { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("test"); let pane_id = workspace.focused_pane_id().expect("focused pane"); @@ -2676,7 +2740,7 @@ fn retained_test_server( server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + let (client_tx, client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( @@ -2691,7 +2755,7 @@ fn retained_test_server( server.sync_foreground_client_state(); assert!(server.claim_unowned_shell_tab_geometry(1, true)); - (server, client_rx, pane_id) + (server, client_control_rx, client_rx, pane_id) } #[test] diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index d310003c..f8637e8f 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -6,7 +6,8 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati #[tokio::test] async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -38,6 +39,19 @@ async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once }; assert_eq!(second.graphics.placements, first.graphics.placements); assert!(second.graphics.assets.is_empty()); + + // A post-commit typed surface.set(true) resets only this viewer's delivery cache, so the + // already selected target receives the asset again without relying on graphics that may have + // arrived during the frozen source frame. + assert!(server.set_client_shell_surface_active(1, true).is_some()); + server.render_and_stream(); + let replay = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(replay) = replay else { + panic!("expected post-commit graphics replay"); + }; + assert_eq!(replay.graphics.placements, first.graphics.placements); + assert_eq!(replay.graphics.assets.len(), 1); + assert_eq!(replay.graphics.assets[0].data, vec![1, 2, 3, 4]); } #[tokio::test] @@ -77,6 +91,237 @@ async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { } #[tokio::test] +async fn first_kitty_image_updates_retained_surface_without_full_redraw() { + let (mut server, client_rx, pane_id) = retained_test_server(b"text before image"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + server.render_and_stream(); + let ServerMessage::PaneSurface(initial) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected text-only baseline"); + }; + assert!(initial.graphics.placements.is_empty()); + + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(repainted) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected image update without a full redraw"); + }; + assert_eq!(repainted.graphics.placements.len(), 1); + assert_eq!(repainted.graphics.assets[0].data, [255, 0, 0, 255]); + assert_eq!(repainted.panes[0].inner_rect, initial.panes[0].inner_rect); + + // Text changes while an image is visible must reuse its uploaded pixels. + write_shared_test_pane(&mut server, pane_id, b"\rupdated text"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(text_update) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected retained text and image scene"); + }; + assert_eq!( + text_update.graphics.placements, + repainted.graphics.placements + ); + assert!(text_update.graphics.assets.is_empty()); + assert!(frame_text(&text_update.frame).contains("updated text")); + + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=A\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(deleted) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected image removal"); + }; + assert!(deleted.graphics.placements.is_empty()); + assert!(deleted.graphics.assets.is_empty()); + + write_shared_test_pane(&mut server, pane_id, b"\rtext only again"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(matches!( + read_server_message(receive_render(&client_rx, Duration::from_millis(100))), + ServerMessage::PaneSurfacePatch(_) + )); + + // A full output queue must not mark unsent pixels as delivered. + fill_render_lane(&server); + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(server.clients[&1] + .render_state + .last_pane_surface() + .unwrap() + .graphics + .placements + .is_empty()); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + server.render_and_stream(); + let ServerMessage::PaneSurface(recovered) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected deferred graphics recovery"); + }; + assert_eq!(recovered.graphics.assets[0].data, [255, 0, 0, 255]); +} + +#[tokio::test] +async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { + let (mut server, client_rx, pane_id) = retained_test_server(b"\x1b[?1049h"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + let sources = HashSet::from([pane_id]); + // Yazi-style virtual placement: uploading the image and drawing its Unicode cell + // can happen in separate PTY reads, with no text dirty rows when upload completes. + for bytes in [ + b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;/wAA/w".as_slice(), + b"==\x1b\\", + b"\x1b_Ga=p,U=1,i=1193046,c=1,r=1,q=2\x1b\\", + ] { + write_shared_test_pane(&mut server, pane_id, bytes); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + for frame in client_rx.try_iter() { + if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { + assert!(surface.graphics.placements.is_empty()); + } + } + } + write_shared_test_pane( + &mut server, + pane_id, + "\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes(), + ); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(surface) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("virtual image must arrive without a tab switch"); + }; + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]); + // Replacing pixels under the same image ID must invalidate the delivered asset. + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;AP8A/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(replaced) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("updated image pixels must arrive"); + }; + assert_eq!(replaced.graphics.assets[0].data, [0, 255, 0, 255]); + assert_ne!( + surface.graphics.assets[0].key, + replaced.graphics.assets[0].key + ); +} + +#[tokio::test] +#[ignore = "manual retained text/image scaling profile"] +async fn render_scale_profile_retained_graphics() { + use ratatui::layout::Direction; + for retained in [false, true] { + for with_image in [false, true] { + for count in [1, 15] { + let (mut server, client_rx, root) = retained_test_server(b"populated terminal\r\n"); + let mut pane_ids = vec![root]; + for index in 1..count { + let workspace = &mut server.app.state.workspaces[0]; + workspace.tabs[0] + .layout + .focus_pane(pane_ids[(index - 1) / 2]); + let id = workspace.test_split(if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }); + workspace.insert_test_runtime( + id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"populated terminal\r\n", + ), + ); + pane_ids.push(id); + } + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + if with_image { + write_shared_test_pane( + &mut server, + root, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + } + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + let sources = pane_ids.iter().copied().collect(); + let mut samples = Vec::new(); + for sample in 0..110 { + for id in &pane_ids { + write_shared_test_pane( + &mut server, + *id, + format!("\x1b[H{sample:03}").as_bytes(), + ); + } + let started = Instant::now(); + if retained { + assert!(server.render_retained_pane_surface_and_stream(&sources)); + } else { + server.render_and_stream(); + } + let elapsed = started.elapsed(); + for frame in client_rx.try_iter() { + if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { + assert!(surface.graphics.assets.is_empty()); + } + } + if sample >= 10 { + samples.push(elapsed); + } + } + samples.sort_unstable(); + println!( + "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}", + samples[50].as_micros(), + samples[94].as_micros() + ); + } + } + } +} + +#[tokio::test] async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { let (mut server, client_rx, _pane_id) = retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs new file mode 100644 index 00000000..591f6a8f --- /dev/null +++ b/src/server/headless/tests/surface_interest.rs @@ -0,0 +1,725 @@ +use super::*; + +use std::sync::{Arc, Mutex}; + +use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, EndpointRegistry, + EndpointTransport, ProfileId, SavedSshEndpoint, +}; + +#[derive(Clone)] +struct CapturingEndpointTransport(Arc<Mutex<Vec<crate::protocol::ClientMessage>>>); + +impl EndpointTransport for CapturingEndpointTransport { + fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } +} + +fn lifecycle_negotiation() -> EndpointNegotiation { + EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + ], + ) +} + +fn lifecycle_resize() -> crate::protocol::ClientMessage { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: false, + } +} + +#[tokio::test] +async fn metadata_only_shell_is_isolated_until_surface_activation() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b""); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + let workspace_id = server.app.session_snapshot().focused_workspace_id.unwrap(); + let original_size = server.effective_size; + let (writer, control_rx, render_rx) = test_client_writer(); + let client_id = 52; + + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 101, + surface_rows: 37, + cell_width_px: 9, + cell_height_px: 18, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: false, + writer, + }) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("metadata snapshot")), + ServerMessage::EndpointControl { .. } + )); + assert_eq!(server.foreground_client_id, None); + assert_eq!(server.effective_size, original_size); + + server.render_and_stream(); + assert!(render_rx.try_recv().is_err()); + assert!(server.clients[&client_id] + .render_state + .last_pane_surface() + .is_none()); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Paste( + "blocked".into() + )], + }) + ); + assert!(input_rx.try_recv().is_err()); + + let boot_id = server.client_shell_boot_id.clone(); + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "inactive-mutation".into(), + method: api::schema::Method::WorkspaceFocus(api::schema::WorkspaceTarget { + workspace_id, + }), + }), + }) + ); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("inactive mutation response")) + else { + panic!("expected endpoint response"); + }; + let error = serde_json::from_slice::<api::schema::ErrorResponse>(&data).unwrap(); + assert_eq!(error.error.code, "surface_inactive"); + + assert!( + server.send_to_client_shells(ServerMessage::SemanticNotification( + crate::protocol::SemanticNotification { + kind: crate::protocol::SemanticNotificationKind::Custom, + title: "metadata event".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + )) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("metadata notification")), + ServerMessage::SemanticNotification(_) + )); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "activate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("surface activation response")) + else { + panic!("expected typed surface activation response"); + }; + let activation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap(); + let api::schema::ResponseResult::ClientShellSurfaceSet { + active: true, + projection_revision: activation_floor, + } = activation_ack.result + else { + panic!("expected typed surface activation result"); + }; + assert_eq!(server.foreground_client_id, Some(client_id)); + assert_eq!(server.effective_size, (101, 37)); + + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("activated surface")) + else { + panic!("expected pane surface"); + }; + assert_eq!((surface.frame.width, surface.frame.height), (101, 37)); + assert!(surface.projection_revision >= activation_floor); + assert_eq!(surface.surface_revision, 1); + server + .clients + .get_mut(&client_id) + .expect("surface client") + .shell_endpoint_command_in_flight = true; + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "deactivate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: false }, + ), + }), + }) + ); + let _ = control_rx.recv().expect("surface deactivation response"); + assert!(server.clients.contains_key(&client_id)); + let (_, runtime_pane_id) = server.app.parse_pane_id(&surface.panes[0].pane_id).unwrap(); + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, runtime_pane_id) + .unwrap() + .test_process_pty_bytes(b"REACTIVATED"); + assert!( + server.render_retained_pane_surface_and_stream(&std::collections::HashSet::from([ + runtime_pane_id + ])) + ); + assert!(render_rx.try_recv().is_err()); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(api::schema::Request { + id: "reactivate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let data = loop { + let message = + read_server_message(control_rx.recv().expect("surface reactivation response")); + match message { + ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } if request_id == "reactivate-surface" => break data, + ServerMessage::EndpointControl { .. } + | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue, + other => panic!("unexpected surface reactivation message: {other:?}"), + } + }; + let reactivation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap(); + let api::schema::ResponseResult::ClientShellSurfaceSet { + active: true, + projection_revision: reactivation_floor, + } = reactivation_ack.result + else { + panic!("expected typed surface reactivation result"); + }; + assert!(reactivation_floor > activation_floor); + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("reactivated surface")) + else { + panic!("expected replacement pane surface"); + }; + assert!(frame_text(&surface.frame).contains("REACTIVATED")); + assert!(surface.projection_revision >= reactivation_floor); + assert_eq!(surface.surface_revision, 2); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn presentation_sync_epoch_replays_modes_and_title() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client_id = 63; + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: true, + writer, + }) + ); + let _ = control_rx.recv().expect("initial snapshot"); + server.api_window_title = Some("target title".into()); + { + let client = server.clients.get_mut(&client_id).unwrap(); + client.host_mouse_capture_active = Some(false); + client.host_sgr_pixels_active = Some(false); + client.host_keyboard_report_all_active = Some(false); + } + + let boot_id = server.client_shell_boot_id.clone(); + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(api::schema::Request { + id: "post-commit-reassert".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let _ = control_rx + .recv() + .expect("typed surface reassertion acknowledgement"); + server.stream_host_mouse_capture_mode(); + server.stream_direct_terminal_keyboard_mode(); + server.sync_window_title(); + assert_eq!( + server.clients[&client_id].host_mouse_capture_active, + Some(true), + "the target mode is sent after, not during, the frozen handoff" + ); + assert_eq!( + server.clients[&client_id].host_keyboard_report_all_active, + Some(false) + ); + let messages = (0..3) + .map(|_| read_server_message(control_rx.recv().expect("reassertion effect"))) + .collect::<Vec<_>>(); + assert!(messages + .iter() + .any(|message| matches!(message, ServerMessage::MouseCapture { enabled: true, .. }))); + assert!(messages.iter().any(|message| matches!( + message, + ServerMessage::ClientShellKeyboardReportAll { enabled: false } + ))); + assert!(messages.iter().any(|message| matches!( + message, + ServerMessage::WindowTitle { title: Some(title) } if title == "target title" + ))); + shutdown_test_runtimes(&mut server); +} + +fn dispatch_lifecycle_messages( + server: &mut HeadlessServer, + client_id: u64, + messages: Vec<crate::protocol::ClientMessage>, +) { + for message in messages { + let event = match message { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + } => ServerEvent::ClientShellResize { + client_id, + cell_width_px, + cell_height_px, + surface_cols: surface_size.cols, + surface_rows: surface_size.rows, + pixel_mouse, + }, + crate::protocol::ClientMessage::ClientShellFocus { focused } => { + ServerEvent::ClientShellFocus { client_id, focused } + } + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(serde_json::from_str(&request).unwrap()), + } + } + other => panic!("unhandled lifecycle message: {other:?}"), + }; + server.handle_server_event(event); + } +} + +/// A real two-server/client lifecycle harness. Both source-off and target-on traverse the +/// production HeadlessServer endpoint request path; the client test only routes its emitted wire +/// messages and never authors an acknowledgement, snapshot, or surface response. +#[tokio::test] +async fn two_headless_servers_drive_atomic_endpoint_handoff() { + let mut source_server = test_headless_server(); + let _source_input = install_focused_test_runtime(&mut source_server, b"local source"); + let (source_writer, source_control, _source_render) = test_client_writer(); + let source_client_id = 78; + assert!( + source_server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: source_client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: true, + writer: source_writer, + }) + ); + let source_snapshot = client_shell_snapshot(read_server_message( + source_control + .recv() + .expect("real source metadata snapshot"), + )); + + let mut target_server = test_headless_server(); + let _target_input = install_focused_test_runtime(&mut target_server, b"remote target"); + let (target_writer, target_control, target_render) = test_client_writer(); + let target_client_id = 79; + assert!( + target_server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: target_client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: false, + writer: target_writer, + }) + ); + let remote_snapshot = client_shell_snapshot(read_server_message( + target_control + .recv() + .expect("real target metadata snapshot"), + )); + + let profile = SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Remote".into(), + target: "dev@example.com".into(), + session: "main".into(), + enabled: true, + }; + let target_id = ClientEndpointId::Ssh(profile.id.clone()); + let mut shell = crate::client::ClientShellState::new( + crate::client::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + shell.set_endpoint_catalog(&[profile]); + shell.set_snapshot(source_snapshot); + shell.set_endpoint_status(&target_id, ClientEndpointStatus::Online); + shell.set_endpoint_snapshot(&target_id, remote_snapshot); + + let source_sent = Arc::new(Mutex::new(Vec::new())); + let target_sent = Arc::new(Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::new( + CapturingEndpointTransport(source_sent.clone()), + 1, + lifecycle_negotiation(), + ); + endpoints.insert( + target_id.clone(), + CapturingEndpointTransport(target_sent.clone()), + 7, + lifecycle_negotiation(), + false, + ); + let mut activation = crate::client::endpoint::PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target_id.clone(), + None, + lifecycle_resize(), + 41, + std::time::Instant::now(), + ) + .unwrap(); + + // Route the source-off-first client messages through a second real HeadlessServer. Its + // typed response is the only source acknowledgement supplied to the activation state. + let mut source_release_request_id = None; + for message in std::mem::take(&mut *source_sent.lock().unwrap()) { + match message { + crate::protocol::ClientMessage::ClientShellFocus { focused } => { + assert!( + source_server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: source_client_id, + focused, + }) + ); + } + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + let request = serde_json::from_str::<api::schema::Request>(&request).unwrap(); + if matches!( + request.method, + api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: false } + ) + ) { + source_release_request_id = Some(request.id.clone()); + } + assert!(source_server.handle_server_event( + ServerEvent::ClientShellEndpointRequest { + client_id: source_client_id, + boot_id, + request: Box::new(request), + } + )); + } + other => panic!("unexpected source lifecycle message: {other:?}"), + } + } + let source_release_request_id = source_release_request_id.expect("client source-off request"); + let source_release_data = loop { + let message = read_server_message(source_control.recv().expect("source typed release ack")); + match message { + ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } if request_id == source_release_request_id => break data, + ServerMessage::EndpointControl { .. } + | ServerMessage::MouseCapture { .. } + | ServerMessage::ClientShellKeyboardReportAll { .. } + | ServerMessage::WindowTitle { .. } + | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue, + other => panic!("unexpected source release message: {other:?}"), + } + }; + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + &source_release_request_id, + &source_release_data, + &mut endpoints, + ), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + + dispatch_lifecycle_messages( + &mut target_server, + target_client_id, + std::mem::take(&mut *target_sent.lock().unwrap()), + ); + assert_eq!( + target_server.clients[&target_client_id].outer_terminal_focus, + Some(true) + ); + assert_eq!( + source_server.clients[&source_client_id].outer_terminal_focus, + Some(false) + ); + let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = read_server_message(target_control.recv().expect("target typed activation ack")) + else { + panic!("expected target activation acknowledgement"); + }; + assert_eq!( + activation.receive_response(&target_id, 7, &request_id, &data, &mut endpoints), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + + target_server.render_and_stream(); + let coherent_snapshot = client_shell_snapshot(read_server_message( + target_control.recv().expect("target replacement snapshot"), + )); + let snapshot_progress = activation.receive_snapshot(&target_id, 7, &coherent_snapshot); + shell.set_endpoint_snapshot(&target_id, coherent_snapshot); + assert_eq!( + snapshot_progress, + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + let ServerMessage::PaneSurface(coherent_surface) = + read_server_message(target_render.recv().expect("target replacement surface")) + else { + panic!("expected target pane surface"); + }; + assert_eq!( + activation.receive_surface(&target_id, 7, coherent_surface), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationSync { + endpoint, + .. + }) if endpoint == target_id + )); + assert!( + !endpoints.active_surface_available(), + "target input remains fenced while presentation effects resynchronize" + ); + + let sync_request = target_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + let request = serde_json::from_str::<api::schema::Request>(request).ok()?; + request + .id + .ends_with(":presentation-sync") + .then(|| (boot_id.clone(), Box::new(request))) + } + _ => None, + }) + .expect("client presentation synchronization request"); + assert!( + target_server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id: target_client_id, + boot_id: sync_request.0, + request: sync_request.1, + }) + ); + let (sync_request_id, sync_data) = loop { + let message = read_server_message(target_control.recv().expect("presentation sync ack")); + if let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = message + { + if request_id.ends_with(":presentation-sync") { + break (request_id, data); + } + } + }; + assert_eq!( + activation.receive_response(&target_id, 7, &sync_request_id, &sync_data, &mut endpoints,), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + target_server.render_and_stream(); + let sync_snapshot = loop { + let message = + read_server_message(target_control.recv().expect("presentation sync snapshot")); + if let ServerMessage::EndpointControl { kind, data } = message { + if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { + break serde_json::from_str::<crate::protocol::ClientShellSnapshot>(&data).unwrap(); + } + } + }; + let sync_progress = activation.receive_snapshot(&target_id, 7, &sync_snapshot); + shell.set_endpoint_snapshot_for_generation(&target_id, 7, Box::new(sync_snapshot)); + assert_eq!( + sync_progress, + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + let ServerMessage::PaneSurface(sync_surface) = + read_server_message(target_render.recv().expect("presentation sync surface")) + else { + panic!("expected synchronized target surface"); + }; + assert_eq!( + activation.receive_surface(&target_id, 7, sync_surface), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationEffects) + ); + let effects_token = target_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND => + { + Some(data.clone()) + } + _ => None, + }) + .expect("client presentation effects fence"); + assert!( + target_server.handle_server_event(ServerEvent::ClientShellPresentationSync { + client_id: target_client_id, + token: effects_token.clone(), + }) + ); + let mut replayed_mouse = false; + let mut replayed_keyboard = false; + loop { + match read_server_message(target_control.recv().expect("presentation effect or fence")) { + ServerMessage::MouseCapture { .. } => replayed_mouse = true, + ServerMessage::ClientShellKeyboardReportAll { .. } => replayed_keyboard = true, + ServerMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND => + { + assert_eq!(data, effects_token); + assert_eq!( + activation.receive_presentation_effects_ready(&target_id, 7, &data), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + break; + } + ServerMessage::WindowTitle { .. } => {} + other => panic!("unexpected presentation fence message: {other:?}"), + } + } + assert!(replayed_mouse); + assert!(replayed_keyboard); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::Activated) + ); + endpoints.unfreeze_input(); + assert_eq!(endpoints.active_id(), &target_id); + assert!(endpoints.active_surface_available()); + assert!(shell.endpoint_is_active(&target_id)); + + target_sent.lock().unwrap().clear(); + let mut returning = crate::client::endpoint::PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + lifecycle_resize(), + 42, + std::time::Instant::now(), + ) + .unwrap(); + dispatch_lifecycle_messages( + &mut target_server, + target_client_id, + std::mem::take(&mut *target_sent.lock().unwrap()), + ); + loop { + if let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = read_server_message(target_control.recv().unwrap()) + { + returning.receive_response(&target_id, 7, &request_id, &data, &mut endpoints); + break; + } + } + dispatch_lifecycle_messages( + &mut source_server, + source_client_id, + std::mem::take(&mut *source_sent.lock().unwrap()), + ); + assert_eq!( + source_server.clients[&source_client_id].outer_terminal_focus, + Some(true), + "returning to Local must restore focus without a host focus event" + ); + assert_eq!( + target_server.clients[&target_client_id].outer_terminal_focus, + Some(false) + ); + shutdown_test_runtimes(&mut source_server); + shutdown_test_runtimes(&mut target_server); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 8b155df1..5d249587 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3,6 +3,7 @@ pub mod autodetect; #[cfg(unix)] pub(crate) mod client_accept; pub(crate) mod client_commands; +mod client_endpoint_control; pub(crate) mod client_shell; pub(crate) mod client_shell_graphics; pub(crate) mod client_transport; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 4fa80326..57d45b92 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -423,6 +423,10 @@ impl TerminalRuntime { self.0.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.0.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 3da1e9f9..178bdb1f 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -129,6 +129,7 @@ pub(crate) fn resolved_token_spans( .iter() .map(|token| match &token.kind { ResolvedTokenKind::StateText(text) + | ResolvedTokenKind::Machine(text) | ResolvedTokenKind::Workspace(text) | ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) @@ -236,7 +237,8 @@ pub(crate) fn resolved_token_spans( truncate_end(text, budgets[index]), apply_token_style(workspace_style, token.style), )), - ResolvedTokenKind::Tab(text) + ResolvedTokenKind::Machine(text) + | ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) | ResolvedTokenKind::Agent(text) | ResolvedTokenKind::Branch(text) => spans.push(Span::styled( diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index eaec154b..911c87f9 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -13,6 +13,7 @@ pub(crate) struct ResolvedToken { pub(crate) enum ResolvedTokenKind { StateIcon, StateText(String), + Machine(String), Workspace(String), Tab(String), Pane(String), @@ -35,6 +36,7 @@ impl ResolvedToken { } pub(crate) struct AgentTokenContext<'a> { + pub(crate) machine: Option<&'a str>, pub(crate) workspace: &'a str, pub(crate) tab: Option<&'a str>, pub(crate) pane: Option<&'a str>, @@ -63,6 +65,9 @@ pub(crate) fn agent_rows( AgentSidebarToken::StateText => { Some(ResolvedTokenKind::StateText(state_text.to_string())) } + AgentSidebarToken::Machine => context + .machine + .map(|value| ResolvedTokenKind::Machine(value.to_string())), AgentSidebarToken::Workspace => { Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } @@ -190,6 +195,7 @@ mod tests { fn context(entry: &Entry) -> AgentTokenContext<'_> { AgentTokenContext { + machine: None, workspace: &entry.workspace, tab: entry.tab.as_deref(), pane: entry.pane.as_deref(), @@ -232,6 +238,35 @@ mod tests { } #[test] + fn machine_token_only_resolves_for_multi_machine_rows() { + let entry = entry(); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::Machine, + AgentSidebarToken::Workspace, + ]], + ..Default::default() + }; + + assert_eq!( + agent_rows(&config, context(&entry), "working"), + vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( + "repo".into() + ))]] + ); + + let mut remote_context = context(&entry); + remote_context.machine = Some("Build"); + assert_eq!( + agent_rows(&config, remote_context, "working"), + vec![vec![ + ResolvedToken::unstyled(ResolvedTokenKind::Machine("Build".into())), + ResolvedToken::unstyled(ResolvedTokenKind::Workspace("repo".into())), + ]] + ); + } + + #[test] fn state_text_and_arbitrary_values_are_independent_tokens() { let mut entry = entry(); entry diff --git a/src/update.rs b/src/update.rs index ae307bc7..f836fb80 100644 --- a/src/update.rs +++ b/src/update.rs @@ -2852,6 +2852,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }; let missing_baseline = crate::api::RuntimeStatus { @@ -2924,6 +2926,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }, }; @@ -3180,6 +3184,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }, }; |
