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