diff --git a/crates/tty7-core/src/client/pane.rs b/crates/tty7-core/src/client/pane.rs index 8939208c..daa46479 100644 --- a/crates/tty7-core/src/client/pane.rs +++ b/crates/tty7-core/src/client/pane.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use std::time::Duration; use crate::daemon::protocol::{ - ClientMsg, DaemonMsg, DaemonVersion, PaneInfo, PaneProcs, ShellSpec, WinSize, is_error_kind, - peek_frame_kind, take_frame, + ClientMsg, DaemonMsg, DaemonVersion, LeaseRequest, PaneInfo, PaneProcs, ShellSpec, WinSize, + is_error_kind, peek_frame_kind, take_frame, }; use crate::daemon::router::{RouteAction, RouteChannel, RouteHeader, RouteTarget, negotiate}; use crate::daemon::transport; @@ -292,6 +292,11 @@ impl PaneSession { self.input.resize(size) } + /// See [`PaneInput::lease`]. + pub fn lease(&mut self, request: LeaseRequest) -> io::Result<()> { + self.input.lease(request) + } + pub fn detach(self) -> io::Result<()> { self.input.detach() } @@ -332,6 +337,13 @@ impl PaneInput { ClientMsg::Resize(size).encode(&mut self.writer) } + /// Only to a daemon that advertised + /// [`FEATURE_SIZE_LEASE`](crate::daemon::protocol::FEATURE_SIZE_LEASE): + /// an older one cannot decode it and drops the connection. + pub fn lease(&mut self, request: LeaseRequest) -> io::Result<()> { + ClientMsg::Lease(request).encode(&mut self.writer) + } + pub fn detach(mut self) -> io::Result<()> { ClientMsg::Detach.encode(&mut self.writer) } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 6492ca1f..dbde976d 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -17,8 +17,8 @@ use crate::core::kitty_graphics::{GraphicsSniffer, Segment, Sniffed}; use crate::core::osc::OscTokenizer; use crate::core::term_modes::TerminalModes; use crate::daemon::protocol::{ - AuthResponse, DaemonMsg, MAX_FRAME, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, - ShellSpec, WinSize, + AuthResponse, DaemonMsg, LeaseRequest, MAX_FRAME, NativeSshSpec, PaneInfo, RemoteContext, + RemoteKind, ShellSpec, WinSize, }; use crate::daemon::shell_integration; @@ -673,6 +673,19 @@ struct Observer { gate: Arc, } +/// An observer showing the pane at its own size — a phone, typically, while +/// its user is away from the desk. +/// +/// The pty runs at the observer's size and the observers see it; the +/// controller keeps its own grid, is told who holds the pane, and is the only +/// one that can end it early ([`LeaseRequest::TakeBack`]). What the controller +/// asks for meanwhile is kept, not applied, and is what the pane goes back to. +struct SizeLease { + observer: u64, + by: String, + desk: WinSize, +} + struct PaneState { id: u64, ring: ReplayRing, @@ -689,6 +702,11 @@ struct PaneState { clipboard_write_from_spec: Option, observers: Vec, observer_seq: u64, + lease: Option, + /// The controller asked to hear about leases: it can say + /// [`DaemonMsg::Lease`], which a client from before them cannot decode. + /// Reset with every new controller. + lease_watch: bool, cwd: Option, /// The last title the pane reported over OSC 0/2, for the machine tree to /// record. See [`crate::core::machine::PaneRecord::osc_title`]. @@ -764,6 +782,91 @@ fn resize_state(st: &mut PaneState, size: WinSize) { .retain(|obs| obs.tx.send(DaemonMsg::Size(size)).is_ok()); } +fn tell_observer(st: &mut PaneState, observer: u64, msg: DaemonMsg) { + if let Some(obs) = st.observers.iter().find(|obs| obs.id == observer) { + let _ = obs.tx.send(msg); + } +} + +fn tell_controller_lease(st: &PaneState) { + if !st.lease_watch { + return; + } + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::Lease(st.lease.as_ref().map(|l| l.by.clone()))); + } +} + +/// An observer takes the pane at `size`, or changes the size it holds it at. +/// Returns the size to put the pty at, once the state lock is let go. +/// +/// A second observer taking it over displaces the first, who is told, and the +/// size the desk asked for carries over, so ending the lease still restores +/// the desk rather than the first observer's size. +fn lease_take(st: &mut PaneState, observer: u64, size: WinSize, by: String) -> Option { + if !st.observers.iter().any(|obs| obs.id == observer) { + return None; + } + let desk = match st.lease.take() { + Some(prev) => { + if prev.observer != observer { + tell_observer(st, prev.observer, DaemonMsg::Lease(None)); + } + prev.desk + } + None => st.ring.size(), + }; + st.lease = Some(SizeLease { + observer, + by: by.clone(), + desk, + }); + tell_controller_lease(st); + // The observers see the pane at the leased size, sealed into the ring so + // a replay does too. The controller keeps its grid and gets no Size: a + // client that is told its pane changed size resizes back to fit its + // window, and the two would take turns. + st.ring.resize(size); + st.observers + .retain(|obs| obs.tx.send(DaemonMsg::Size(size)).is_ok()); + tell_observer(st, observer, DaemonMsg::Lease(Some(by))); + Some(size) +} + +/// Ends the lease, whoever holds it, and puts the pane back at the size the +/// desk last asked for. Returns that size for the pty, or `None` if there was +/// no lease. +fn lease_end(st: &mut PaneState) -> Option { + let lease = st.lease.take()?; + tell_observer(st, lease.observer, DaemonMsg::Lease(None)); + tell_controller_lease(st); + resize_state(st, lease.desk); + Some(lease.desk) +} + +/// Ends the lease if `observer` holds it: it let go, or it went away. +fn lease_release(st: &mut PaneState, observer: u64) -> Option { + if st.lease.as_ref()?.observer != observer { + return None; + } + lease_end(st) +} + +/// The controller's resize. Under a lease it is only remembered, for when the +/// lease ends, and echoed to the controller alone so it reflows its own grid. +/// Returns the size for the pty if it is to change now. +fn controller_resize(st: &mut PaneState, size: WinSize) -> Option { + if let Some(lease) = &mut st.lease { + lease.desk = size; + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::Size(size)); + } + return None; + } + resize_state(st, size); + Some(size) +} + /// One gated message to the controller and to every observer. /// /// A send error just means that client is gone; ignore it and let the next @@ -1584,6 +1687,8 @@ impl DaemonPane { clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, + lease: None, + lease_watch: false, cwd: spawn.initial_cwd, osc_title: restored_title, shell: ShellState::default(), @@ -1804,6 +1909,8 @@ impl DaemonPane { clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, + lease: None, + lease_watch: false, cwd: carried.cwd, osc_title: carried.osc_title, shell_spec: carried.shell_spec, @@ -1867,6 +1974,8 @@ impl DaemonPane { clipboard_write_from_spec: Some(allow_remote_clipboard_write), observers: Vec::new(), observer_seq: 0, + lease: None, + lease_watch: false, // A native ssh pane is not running a shell of this machine's; what // it is, `ssh_spec` already says. shell_spec: None, @@ -2239,8 +2348,51 @@ impl DaemonPane { } pub fn unobserve(&self, observer_id: u64) { - let mut st = self.state.lock().unwrap(); - st.observers.retain(|obs| obs.id != observer_id); + let restore = { + let mut st = self.state.lock().unwrap(); + let restore = lease_release(&mut st, observer_id); + st.observers.retain(|obs| obs.id != observer_id); + restore + }; + if let Some(size) = restore { + self.resize_pty(size); + } + } + + /// An observer's [`LeaseRequest`]: take the pane at its size, or let go. + pub fn observer_lease(&self, observer_id: u64, request: LeaseRequest) { + let apply = { + let mut st = self.state.lock().unwrap(); + match request { + LeaseRequest::Take { size, by } => lease_take(&mut st, observer_id, size, by), + LeaseRequest::Release => lease_release(&mut st, observer_id), + LeaseRequest::Watch | LeaseRequest::TakeBack => None, + } + }; + if let Some(size) = apply { + self.resize_pty(size); + } + } + + /// The controller's [`LeaseRequest`]: hear about leases, or end one. + pub fn controller_lease(&self, request: LeaseRequest) { + let apply = { + let mut st = self.state.lock().unwrap(); + match request { + LeaseRequest::Watch => { + st.lease_watch = true; + if st.lease.is_some() { + tell_controller_lease(&st); + } + None + } + LeaseRequest::TakeBack => lease_end(&mut st), + LeaseRequest::Take { .. } | LeaseRequest::Release => None, + } + }; + if let Some(size) = apply { + self.resize_pty(size); + } } pub fn controls(&self, epoch: u64) -> bool { @@ -2305,8 +2457,15 @@ impl DaemonPane { } } + /// The controller's resize; see [`controller_resize`] for what a lease + /// makes of it. pub fn resize(&self, size: WinSize) { - resize_state(&mut self.state.lock().unwrap(), size); + if let Some(size) = controller_resize(&mut self.state.lock().unwrap(), size) { + self.resize_pty(size); + } + } + + fn resize_pty(&self, size: WinSize) { match &self.backend { PaneBackend::Pty(p) => { if let Ok(master) = p.master.lock() { @@ -2707,6 +2866,11 @@ impl ReplayRing { self.segments.back_mut().expect("ring always has a tail") } + /// The size the pane is at now: the newest segment's. + fn size(&self) -> WinSize { + self.segments.back().expect("ring always has a tail").size + } + fn resize(&mut self, size: WinSize) { let tail = self.tail(); if tail.size == size { @@ -2928,6 +3092,7 @@ fn attach_subscriber_with_permissions( foreground_command: bool, ) -> u64 { st.subscriber_epoch += 1; + st.lease_watch = false; set_clipboard_permission(st, allow_remote_clipboard_write); replay_state(st, &subscriber, foreground_command); st.subscriber = Some(subscriber); @@ -5160,6 +5325,8 @@ mod tests { clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, + lease: None, + lease_watch: false, shell_spec: None, cwd: None, osc_title: None, @@ -5809,6 +5976,139 @@ mod tests { got } + /// A controller that watches leases, and an observer: what each hears. + fn leased_rig() -> ( + PaneState, + mpsc::Receiver, + u64, + mpsc::Receiver, + ) { + let mut st = test_state(true); + let (controller_tx, controller_rx) = mpsc::channel(); + attach_subscriber(&mut st, controller_tx); + st.lease_watch = true; + let (observer_tx, observer_rx) = mpsc::channel(); + let phone = observe_subscriber(&mut st, observer_tx, Arc::new(OutputGate::new()), false); + drain(&controller_rx); + drain(&observer_rx); + (st, controller_rx, phone, observer_rx) + } + + #[test] + fn a_lease_resizes_for_observers_and_only_names_the_holder_to_the_controller() { + let (mut st, controller_rx, phone, observer_rx) = leased_rig(); + + let pty = lease_take(&mut st, phone, ws(40, 30), "phone".into()); + assert_eq!(pty, Some(ws(40, 30))); + assert_eq!(st.ring.size(), ws(40, 30), "a replay shows the leased size"); + assert_eq!( + drain(&observer_rx), + vec![ + DaemonMsg::Size(ws(40, 30)), + DaemonMsg::Lease(Some("phone".into())) + ] + ); + assert_eq!( + drain(&controller_rx), + vec![DaemonMsg::Lease(Some("phone".into()))], + "the controller keeps its grid: no Size, or it would resize back" + ); + } + + #[test] + fn the_desk_resizing_under_a_lease_is_kept_for_later() { + let (mut st, controller_rx, phone, observer_rx) = leased_rig(); + lease_take(&mut st, phone, ws(40, 30), "phone".into()); + drain(&controller_rx); + drain(&observer_rx); + + assert_eq!( + controller_resize(&mut st, ws(120, 40)), + None, + "the pty stays put" + ); + assert_eq!(st.ring.size(), ws(40, 30)); + assert_eq!(drain(&controller_rx), vec![DaemonMsg::Size(ws(120, 40))]); + assert!(drain(&observer_rx).is_empty()); + + // Letting go restores what the desk asked for last, not what it had. + assert_eq!(lease_release(&mut st, phone), Some(ws(120, 40))); + assert_eq!(st.ring.size(), ws(120, 40)); + assert_eq!( + drain(&observer_rx), + vec![DaemonMsg::Lease(None), DaemonMsg::Size(ws(120, 40))] + ); + assert_eq!( + drain(&controller_rx), + vec![DaemonMsg::Lease(None), DaemonMsg::Size(ws(120, 40))] + ); + assert!(st.lease.is_none()); + } + + #[test] + fn taking_back_ends_the_lease_and_restores_the_desk() { + let (mut st, controller_rx, phone, observer_rx) = leased_rig(); + lease_take(&mut st, phone, ws(40, 30), "phone".into()); + drain(&controller_rx); + drain(&observer_rx); + + assert_eq!(lease_end(&mut st), Some(ws(80, 24))); + assert_eq!(st.ring.size(), ws(80, 24)); + assert_eq!( + drain(&observer_rx), + vec![DaemonMsg::Lease(None), DaemonMsg::Size(ws(80, 24))] + ); + assert_eq!(lease_end(&mut st), None, "nothing left to end"); + // The observer that lost it cannot end a lease it no longer holds. + assert_eq!(lease_release(&mut st, phone), None); + } + + #[test] + fn a_second_observer_takes_over_and_the_desk_size_carries() { + let (mut st, controller_rx, phone, observer_rx) = leased_rig(); + let (tablet_tx, tablet_rx) = mpsc::channel(); + let tablet = observe_subscriber(&mut st, tablet_tx, Arc::new(OutputGate::new()), false); + drain(&tablet_rx); + + lease_take(&mut st, phone, ws(40, 30), "phone".into()); + lease_take(&mut st, tablet, ws(100, 50), "tablet".into()); + drain(&controller_rx); + let heard = drain(&observer_rx); + assert_eq!(heard.last(), Some(&DaemonMsg::Size(ws(100, 50)))); + assert!( + heard.contains(&DaemonMsg::Lease(None)), + "the phone is told it lost it" + ); + + assert_eq!(lease_release(&mut st, phone), None); + assert_eq!(lease_release(&mut st, tablet), Some(ws(80, 24))); + } + + #[test] + fn a_controller_that_did_not_ask_hears_no_lease() { + let (mut st, controller_rx, phone, _observer_rx) = leased_rig(); + st.lease_watch = false; + lease_take(&mut st, phone, ws(40, 30), "phone".into()); + assert!(drain(&controller_rx).is_empty()); + lease_end(&mut st); + assert_eq!(drain(&controller_rx), vec![DaemonMsg::Size(ws(80, 24))]); + } + + #[test] + fn a_new_controller_must_ask_again() { + let (mut st, _controller_rx, _phone, _observer_rx) = leased_rig(); + let (tx, _rx) = mpsc::channel(); + attach_subscriber(&mut st, tx); + assert!(!st.lease_watch); + } + + #[test] + fn an_unknown_observer_cannot_take_a_lease() { + let (mut st, _c, _phone, _o) = leased_rig(); + assert_eq!(lease_take(&mut st, 999, ws(40, 30), "ghost".into()), None); + assert!(st.lease.is_none()); + } + #[test] fn observe_replays_state_without_displacing_the_controller() { let mut st = test_state(true); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index fad170f6..7fede32e 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -39,6 +39,29 @@ pub const FEATURE_HANDOFF: &str = "handoff"; /// and drops the route unanswered, so the client checks first and says why. pub const FEATURE_UPDATE_SERVER: &str = "update-server"; +/// The daemon understands [`ClientMsg::Lease`]: an observer can show a pane at +/// its own size until it lets go or the controller takes it back. A client +/// must see this before sending one; an older daemon cannot decode the frame +/// and drops the connection. +pub const FEATURE_SIZE_LEASE: &str = "size-lease"; + +/// What a [`ClientMsg::Lease`] asks. The first two are an observer's, the last +/// two the controller's; each side's are ignored from the other. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LeaseRequest { + /// Put the pty at `size` while this observer is connected. `by` names + /// it to the controller ("Thomas's iPhone"). Sent again to change size. + Take { size: WinSize, by: String }, + /// Put the pane back at the size the controller asked for. + Release, + /// The controller can hear [`DaemonMsg::Lease`]; tell it now and on + /// every change. + Watch, + /// The controller ends the lease, whoever holds it. + TakeBack, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonVersion { pub protocol: u32, @@ -57,6 +80,7 @@ impl DaemonVersion { FEATURE_RESIZE_ECHO.to_string(), FEATURE_RESTORE_SCROLLBACK.to_string(), FEATURE_UPDATE_SERVER.to_string(), + FEATURE_SIZE_LEASE.to_string(), ]; if cfg!(unix) { features.push(FEATURE_HANDOFF.to_string()); @@ -932,6 +956,8 @@ pub enum ClientMsg { }, OnWorkspace(Box), Version, + /// Only after the daemon advertised [`FEATURE_SIZE_LEASE`]. + Lease(LeaseRequest), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -992,6 +1018,11 @@ pub enum DaemonMsg { ForwardList(Vec), Procs(PaneProcs), Version(DaemonVersion), + /// Who holds the pane at their own size, or `None` once nobody does. Sent + /// to the holding observer and to a controller that asked with + /// [`LeaseRequest::Watch`], never to anyone else: older clients cannot + /// decode it. + Lease(Option), Error(String), } @@ -1027,6 +1058,7 @@ mod kind { pub const OBSERVE: u8 = 54; pub const SEND_INPUT: u8 = 55; pub const HANDOFF: u8 = 56; + pub const LEASE: u8 = 57; pub const SPAWNED: u8 = 1; pub const SNAPSHOT: u8 = 2; @@ -1061,6 +1093,7 @@ mod kind { /// (issue #213). Compact binary selector, like `IMAGE`. pub const DELETE_IMAGE: u8 = 61; pub const CLIPBOARD_WRITE: u8 = 62; + pub const LEASE_HOLDER: u8 = 63; } pub fn write_frame(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { @@ -1256,6 +1289,7 @@ impl ClientMsg { ClientMsg::List => write_frame(w, kind::LIST, &[]), ClientMsg::Shutdown => write_frame(w, kind::SHUTDOWN, &[]), ClientMsg::Handoff { exe } => write_frame(w, kind::HANDOFF, &to_json(exe)?), + ClientMsg::Lease(request) => write_frame(w, kind::LEASE, &to_json(request)?), ClientMsg::EnsureLoopbackForward(req) => { write_frame(w, kind::ENSURE_LOOPBACK_FORWARD, &to_json(req)?) } @@ -1383,6 +1417,7 @@ impl ClientMsg { }, kind::LIST => ClientMsg::List, kind::SHUTDOWN => ClientMsg::Shutdown, + kind::LEASE => ClientMsg::Lease(from_json(&payload)?), kind::HANDOFF => ClientMsg::Handoff { exe: from_json(&payload)?, }, @@ -1470,6 +1505,7 @@ impl DaemonMsg { DaemonMsg::Image(frame) => write_frame(w, kind::IMAGE, frame), DaemonMsg::DeleteImage(sel) => write_frame(w, kind::DELETE_IMAGE, sel), DaemonMsg::ClipboardWrite(frame) => write_frame(w, kind::CLIPBOARD_WRITE, frame), + DaemonMsg::Lease(by) => write_frame(w, kind::LEASE_HOLDER, &to_json(by)?), DaemonMsg::Cwd(path) => write_frame(w, kind::CWD, &to_json(path)?), DaemonMsg::Prompt { active, @@ -1527,6 +1563,7 @@ impl DaemonMsg { kind::IMAGE => DaemonMsg::Image(payload), kind::DELETE_IMAGE => DaemonMsg::DeleteImage(payload), kind::CLIPBOARD_WRITE => DaemonMsg::ClipboardWrite(payload), + kind::LEASE_HOLDER => DaemonMsg::Lease(from_json(&payload)?), kind::CWD => DaemonMsg::Cwd(from_json(&payload)?), kind::PROMPT => { let (active, at_prompt, last_exit) = from_json(&payload)?; @@ -1659,6 +1696,13 @@ mod tests { #[test] fn client_roundtrip() { let msgs = vec![ + ClientMsg::Lease(LeaseRequest::Take { + size: SIZE, + by: "phone".into(), + }), + ClientMsg::Lease(LeaseRequest::Release), + ClientMsg::Lease(LeaseRequest::Watch), + ClientMsg::Lease(LeaseRequest::TakeBack), ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), size: SIZE, @@ -1849,6 +1893,8 @@ mod tests { fn daemon_roundtrip() { let msgs = vec![ DaemonMsg::Spawned { pane_id: 1 }, + DaemonMsg::Lease(Some("Thomas's iPhone".into())), + DaemonMsg::Lease(None), DaemonMsg::Size(SIZE), DaemonMsg::Snapshot(vec![1, 2, 3, 0, 255]), DaemonMsg::Output((0u8..=255).collect()), diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 8028dffd..fa02eeff 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -5,7 +5,7 @@ use std::sync::mpsc::{self, Receiver}; use std::sync::{Arc, Mutex}; use crate::daemon::pane::DaemonPane; -use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, RemoteKind}; +use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, LeaseRequest, RemoteKind}; use crate::daemon::ssh::SshConnection; use crate::daemon::transport::{self, Stream}; @@ -1180,7 +1180,9 @@ fn stream_observer( let observer_id = pane.observe(tx, gate.clone()); let writer = spawn_writer(rx, write_stream, gate); - observe_loop(&mut read_stream, &refusals); + observe_loop(&mut read_stream, &refusals, |request| { + pane.observer_lease(observer_id, request) + }); pane.unobserve(observer_id); drop(refusals); @@ -1188,9 +1190,14 @@ fn stream_observer( Ok(()) } -fn observe_loop(read_stream: &mut R, refusals: &mpsc::Sender) { +fn observe_loop( + read_stream: &mut R, + refusals: &mpsc::Sender, + mut lease: impl FnMut(LeaseRequest), +) { loop { match ClientMsg::read(read_stream) { + Ok(ClientMsg::Lease(request)) => lease(request), Ok(ClientMsg::Input(_)) | Ok(ClientMsg::Resize(_)) => { let refused = refusals.send(DaemonMsg::Error( "this connection is a read-only observer; attach to write".to_string(), @@ -1248,6 +1255,12 @@ fn run_stream( } pane.resize(size); } + ClientMsg::Lease(request) => { + if !pane.controls(epoch) { + break 'conn; + } + pane.controller_lease(request); + } ClientMsg::AuthResponse { request_id, response, @@ -1432,7 +1445,7 @@ mod tests { .unwrap(); let (tx, rx) = std::sync::mpsc::channel(); - observe_loop(&mut std::io::Cursor::new(wire), &tx); + observe_loop(&mut std::io::Cursor::new(wire), &tx, |_| {}); drop(tx); assert!( @@ -1452,7 +1465,7 @@ mod tests { #[test] fn the_observer_loop_ends_at_stream_eof() { let (tx, rx) = std::sync::mpsc::channel(); - observe_loop(&mut std::io::Cursor::new(Vec::::new()), &tx); + observe_loop(&mut std::io::Cursor::new(Vec::::new()), &tx, |_| {}); drop(tx); assert!(rx.try_recv().is_err()); } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 9673e5d2..58b8c412 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -107,6 +107,7 @@ struct ReaderSignals { images: crate::terminal::images::ImageStore, clipboard_writes: Arc>>, clipboard_write_busy: Arc, + lease: Arc>>, /// Whether this pane's pty is one a conhost renders into, and so whether /// the reader puts back the cursor a repaint parked. Decided per pane from /// its [`PtySource`], and shared rather than copied because the reader can @@ -166,6 +167,10 @@ pub struct PaneWorkspace { /// `false` whenever the answer is unknown (link down, server too old to /// advertise), which keeps the safe reflow-at-request behavior. pub resize_echo: bool, + /// Whether that daemon knows size leases — a phone running the pane at + /// its own size — so this client can ask to hear about them. Read off the + /// same hello, `false` when unknown, like `resize_echo`. + pub size_lease: bool, } impl PaneWorkspace { @@ -222,6 +227,8 @@ pub enum PaneRoute { /// answer everywhere a route is assigned — spawn, attach, relink — /// and a relink builds its route fresh, so a reconnect re-answers it. resize_echo: bool, + /// As [`PaneWorkspace::size_lease`]. + size_lease: bool, }, Unroutable(String), } @@ -234,6 +241,7 @@ impl PaneRoute { Ok(header) => PaneRoute::Remote { header: Box::new(header), resize_echo: ws.resize_echo, + size_lease: ws.size_lease, }, Err(e) => PaneRoute::Unroutable(e.to_string()), }, @@ -615,6 +623,9 @@ pub struct RemoteTerminal { images: crate::terminal::images::ImageStore, clipboard_writes: Arc>>, clipboard_write_busy: Arc, + /// Who runs this pane at their own size — a phone, by the name it paired + /// under — while the daemon says so. See [`Self::take_back`]. + lease: Arc>>, route: PaneRoute, proxy: EventProxy, reader_thread: Option>, @@ -788,6 +799,7 @@ impl RemoteTerminal { Self::from_stream_with(stream, size, Vec::new(), PtySource::for_route(route))?; term.route = route.clone(); term.seed_cwd(spawned_in); + term.watch_leases(); Ok((term, pane_id)) } @@ -858,6 +870,7 @@ impl RemoteTerminal { let mut term = Self::from_stream_parts(stream, size, buffered, PtySource::for_route(route), true)?; term.route = route.clone(); + term.watch_leases(); Ok(term) } @@ -953,6 +966,7 @@ impl RemoteTerminal { images: self.images.clone(), clipboard_writes: self.clipboard_writes.clone(), clipboard_write_busy: self.clipboard_write_busy.clone(), + lease: self.lease.clone(), // Deliberately the pane's existing answer rather than one // rebuilt from `route`: the pty on the far side is the same pty // it was before the link dropped, and only this value still @@ -967,6 +981,12 @@ impl RemoteTerminal { // `LinkWriter` through its `Drop`, which is what closes the old socket. self.link = LinkWriter::new(stream, self.input_loss())?; self.route = route.clone(); + // The new link has told us nothing yet; the daemon repeats a lease + // that still stands when we ask again. + if let Ok(mut guard) = self.lease.lock() { + *guard = None; + } + self.watch_leases(); self.synced_size = false; self.resize(size, cell_w, cell_h); Ok(()) @@ -1049,6 +1069,7 @@ impl RemoteTerminal { let images = crate::terminal::images::ImageStore::new(); let clipboard_writes = Arc::new(Mutex::new(VecDeque::new())); let clipboard_write_busy = Arc::new(AtomicBool::new(false)); + let lease = Arc::new(Mutex::new(None)); let reader_quit = Arc::new(AtomicBool::new(false)); let local_conpty = Arc::new(AtomicBool::new(pty == PtySource::LocalConpty)); @@ -1075,6 +1096,7 @@ impl RemoteTerminal { images: images.clone(), clipboard_writes: clipboard_writes.clone(), clipboard_write_busy: clipboard_write_busy.clone(), + lease: lease.clone(), local_conpty: local_conpty.clone(), }, ); @@ -1113,6 +1135,7 @@ impl RemoteTerminal { images, clipboard_writes, clipboard_write_busy, + lease, route: PaneRoute::Local, proxy, reader_thread: Some(reader_thread), @@ -1188,6 +1211,7 @@ impl RemoteTerminal { images, clipboard_writes, clipboard_write_busy, + lease, local_conpty, } = signals; let mut awaiting_replay = awaiting_replay; @@ -1689,6 +1713,12 @@ impl RemoteTerminal { } proxy.send_event(AlacEvent::Wakeup); } + DaemonMsg::Lease(by) => { + if let Ok(mut guard) = lease.lock() { + *guard = by; + } + proxy.send_event(AlacEvent::Wakeup); + } DaemonMsg::Exited { .. } => { flush_batch!(); child_exited.store(true, Ordering::SeqCst); @@ -1824,6 +1854,41 @@ impl RemoteTerminal { } } + /// Whether the daemon behind this pane knows size leases, asked the way + /// [`Self::resize_echoed`] asks: never over the network from here. + fn leases_supported(&self) -> bool { + match &self.route { + PaneRoute::Local => crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_SIZE_LEASE, + ), + PaneRoute::Remote { size_lease, .. } => *size_lease, + PaneRoute::Unroutable(_) => false, + } + } + + /// Asks to be told when a phone runs this pane at its own size. Only + /// where the daemon knows leases: an older one drops a link that sends it. + fn watch_leases(&self) { + if self.leases_supported() { + self.link.send(ClientMsg::Lease( + crate::daemon::protocol::LeaseRequest::Watch, + )); + } + } + + /// Who runs this pane at their own size right now, if anyone. + pub fn leased_by(&self) -> Option { + self.lease.lock().ok().and_then(|guard| guard.clone()) + } + + /// Ends a phone's lease: the pane goes back to this window's size. The + /// daemon confirms with the lease ending, which clears [`Self::leased_by`]. + pub fn take_back(&self) { + self.link.send(ClientMsg::Lease( + crate::daemon::protocol::LeaseRequest::TakeBack, + )); + } + pub fn resize(&mut self, size: TermSize, cell_w: u16, cell_h: u16) { let echoed = self.resize_echoed(); // The cell size has to be part of the early-out, not just cols/rows: it @@ -4002,6 +4067,7 @@ mod replay_tests { PaneRoute::Remote { header: Box::new(crate::daemon::router::RouteHeader::wsl("Ubuntu-22.04")), resize_echo: true, + size_lease: false, } } @@ -4435,6 +4501,7 @@ mod route_header_tests { spec: None, label: label.map(str::to_string), resize_echo: false, + size_lease: false, } } @@ -4559,6 +4626,7 @@ mod parked_cursor_tests { let route = PaneRoute::Remote { header: Box::new(header), resize_echo: false, + size_lease: false, }; assert_eq!(PtySource::for_route(&route), PtySource::Raw); } @@ -4798,6 +4866,7 @@ mod tests { )), label: None, resize_echo: false, + size_lease: false, } } @@ -4827,6 +4896,7 @@ mod tests { PaneRoute::for_workspace(Some(&ws)), PaneRoute::Remote { resize_echo: true, + size_lease: false, .. } ), @@ -4844,6 +4914,7 @@ mod tests { spec: None, label: None, resize_echo: false, + size_lease: false, }; let route = PaneRoute::for_workspace(Some(&ws)); let header = route.header().expect("WSL is routed"); @@ -4862,6 +4933,7 @@ mod tests { spec: None, label: None, resize_echo: false, + size_lease: false, }; let route = PaneRoute::for_workspace(Some(&ws)); let header = route.header().expect("a local child is routable"); @@ -4885,6 +4957,7 @@ mod tests { spec: None, label: None, resize_echo: false, + size_lease: false, }; let route = PaneRoute::for_workspace(Some(&ws)); assert!(matches!(route, PaneRoute::Unroutable(_))); @@ -6042,6 +6115,7 @@ mod tests { term.route = PaneRoute::Remote { header: Box::new(crate::daemon::router::RouteHeader::wsl("Ubuntu-22.04")), resize_echo: true, + size_lease: false, }; term.resize(TermSize::new(120, 30), 8, 17); @@ -6106,6 +6180,7 @@ mod tests { term.route = PaneRoute::Remote { header: Box::new(crate::daemon::router::RouteHeader::wsl("Ubuntu-22.04")), resize_echo: true, + size_lease: false, }; daemon_side .set_read_timeout(Some(std::time::Duration::from_millis(150))) @@ -6277,6 +6352,7 @@ mod tests { term.route = PaneRoute::Remote { header: Box::new(crate::daemon::router::RouteHeader::wsl("Ubuntu-22.04")), resize_echo: false, + size_lease: false, }; term.resize(TermSize::new(120, 30), 8, 17); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index bccbda6a..816e4821 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -8552,6 +8552,7 @@ mod tests { }), label: None, resize_echo: false, + size_lease: false, } } @@ -10200,6 +10201,7 @@ mod tests { spec: None, label: None, resize_echo: false, + size_lease: false, }; let remote = ws.target.host_id(); @@ -10217,6 +10219,7 @@ mod tests { spec: None, label: None, resize_echo: false, + size_lease: false, }; assert_eq!(sibling.target.host_id(), remote); } @@ -14448,6 +14451,7 @@ mod gpui_tests { )), label: None, resize_echo: false, + size_lease: false, })); id } @@ -14701,6 +14705,7 @@ mod gpui_tests { spec: None, label: Some("hummingbot".into()), resize_echo: false, + size_lease: false, })); assert_eq!(view.title, "hummingbot", "an untitled tab shows the name"); view.handle_event(AlacEvent::Exit, cx); diff --git a/src/ui/app.rs b/src/ui/app.rs index df47ad6c..2fc44a2a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -8281,6 +8281,51 @@ impl Tty7App { ) } + /// One pill per pane in the active tab that a phone is running at its own + /// size. The window keeps its grid meanwhile and shows the pane's output + /// laid out for the phone, so it says why, and offers the pane back. + fn render_lease_notices(&self, cx: &mut Context) -> Vec { + use gpui_component::Sizable as _; + use gpui_component::button::ButtonVariants as _; + let Some(tab) = self.tabs.get(self.active) else { + return Vec::new(); + }; + let theme = cx.theme(); + let (info, foreground, popover) = (theme.info, theme.foreground, theme.popover); + tab.pane + .terminals() + .into_iter() + .filter_map(|leaf| { + let by = leaf.read(cx).terminal.leased_by()?; + let id = leaf.entity_id().as_u64(); + Some( + crate::ui::notice::pill(info, cx) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(foreground) + .child(crate::ui::i18n::t_fmt( + crate::ui::i18n::L10nKey::PaneLeasedBy, + &[("by", &by)], + )), + ) + .child( + gpui_component::button::Button::new(gpui::SharedString::from(format!( + "lease-take-back-{id}" + ))) + .label(crate::ui::i18n::t( + crate::ui::i18n::L10nKey::RemoteActionTakeBack, + )) + .custom(crate::ui::theme::inverted_button(popover, cx)) + .small() + .on_click(move |_, _, cx| leaf.read(cx).terminal.take_back()), + ) + .into_any_element(), + ) + }) + .collect() + } + fn render_remote_input_notice(&self, cx: &mut Context) -> Option { if self.tabs.is_empty() { return None; @@ -8658,6 +8703,7 @@ impl Render for Tty7App { [self.render_remote_input_notice(cx), ssh_status] .into_iter() .flatten() + .chain(self.render_lease_notices(cx)) .collect(), ), |this, el| this.child(el), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index caf29282..dd89c864 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1399,6 +1399,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::RemoteNoticeDisconnected => "Not connected — typing has no effect", L10nKey::RemoteActionRetryNow => "Retry Now", L10nKey::RemoteActionTakeBack => "Take Back", + L10nKey::PaneLeasedBy => "In use on {by}, at its screen size", L10nKey::RemoteActionConnect => "Connect", L10nKey::RemoteActionRetry => "Retry", L10nKey::RemoteActionRemoveEntry => "Remove entry", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 626f0757..d3175d9d 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1455,6 +1455,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteNoticeDisconnected => "未接続です — 入力しても反映されません", L10nKey::RemoteActionRetryNow => "今すぐ再試行", L10nKey::RemoteActionTakeBack => "取り戻す", + L10nKey::PaneLeasedBy => "{by} で使用中(その画面サイズで表示)", L10nKey::RemoteActionConnect => "接続", L10nKey::RemoteActionRetry => "再試行", L10nKey::RemoteActionRemoveEntry => "エントリを削除", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index a693028b..3b4a7186 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1211,6 +1211,7 @@ l10n_keys! { RemoteNoticeDisconnected, RemoteActionRetryNow, RemoteActionTakeBack, + PaneLeasedBy, RemoteActionConnect, RemoteActionRetry, RemoteActionRemoveEntry, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6f12e6ca..3c40703f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1314,6 +1314,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::RemoteNoticeDisconnected => "未连接——输入无效", L10nKey::RemoteActionRetryNow => "立即重试", L10nKey::RemoteActionTakeBack => "收回", + L10nKey::PaneLeasedBy => "正在 {by} 上使用,按它的屏幕尺寸显示", L10nKey::RemoteActionConnect => "连接", L10nKey::RemoteActionRetry => "重试", L10nKey::RemoteActionRemoveEntry => "移除条目", diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 5b967996..fe7ef7a3 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -1226,6 +1226,11 @@ pub(crate) fn pane_workspace_for( host.host_id(), crate::daemon::protocol::FEATURE_RESIZE_ECHO, ); + let size_lease = remote_connect::HostLinks::peer_supports( + cx, + host.host_id(), + crate::daemon::protocol::FEATURE_SIZE_LEASE, + ); // The workspace's own name where it has one, the machine's otherwise — // what the pane's tab falls back to when nothing has titled it, and what // a dead link's "— disconnected" suffix hangs off. @@ -1241,6 +1246,7 @@ pub(crate) fn pane_workspace_for( spec, label, resize_echo, + size_lease, }; if let Ok(header) = pane.route_header() { remote_connect::note_origin(&header.target, &pane.target);