diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 8723d2ea..6c5851df 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -2,7 +2,6 @@ use std::path::PathBuf; use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; -use serde_json::json; use tty7_core::client::{ControlClient, PaneClient, PaneSession}; use tty7_core::core::agent_hooks::{HookAgent, HookTarget, HooksState, hooks_state}; use tty7_core::core::session::WorkspaceId; @@ -483,47 +482,11 @@ fn keys<'a>(routes: impl Iterator) -> String { } fn route_matches(name: &str, route: &RouteInfo) -> bool { - route.key == name || host_of(&route.key) == Some(name) -} - -fn host_of(key: &str) -> Option<&str> { - let first = key.split('|').next()?; - let after_user = first.split('@').nth(1)?; - after_user.split(':').next() + route.key == name || route.host() == Some(name) } fn target_for(route: &RouteInfo) -> Result { - if route.kind != "ssh" { - bail!( - "machine '{}' is a {} link — the CLI can only route over ssh links yet", - route.key, - route.kind - ); - } - if route.key.contains('|') { - bail!( - "machine '{}' is reached through a jump/proxy chain, which the CLI cannot \ - rebuild from the link key yet — use the GUI for this machine", - route.key - ); - } - let (user, rest) = route - .key - .split_once('@') - .ok_or_else(|| anyhow!("unrecognized machine key '{}'", route.key))?; - let (host, port) = rest - .rsplit_once(':') - .ok_or_else(|| anyhow!("unrecognized machine key '{}'", route.key))?; - let port: u16 = port - .parse() - .map_err(|_| anyhow!("unrecognized machine key '{}'", route.key))?; - let spec = serde_json::from_value(json!({ - "user": user, - "host": host, - "port": port, - "auth_mode": "auto", - }))?; - Ok(RouteTarget::Ssh(Box::new(spec))) + route.target().map_err(|e| anyhow!(e)) } #[cfg(test)] diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 3587f55c..61ddbdde 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -376,6 +376,52 @@ pub struct RouteInfo { pub connected: bool, } +impl RouteInfo { + /// The host a link reaches: `build-box` in `me@build-box:22`. `None` for a + /// key of another shape. + pub fn host(&self) -> Option<&str> { + let first = self.key.split('|').next()?; + let after_user = first.split('@').nth(1)?; + after_user.split(':').next() + } + + /// Rebuilds the target to route to this link over the local server, from + /// its key. + /// + /// Routing over a link the server already holds only needs the key's + /// `user@host:port` — the link is found by it and its credentials are + /// never used again — so an `auto` auth mode stands in for the profile's. + /// A jump/proxy chain would need the whole chain rebuilt, which the key + /// alone cannot do yet. + pub fn target(&self) -> Result { + let unrecognized = || format!("unrecognized machine key '{}'", self.key); + if self.kind != "ssh" { + return Err(format!( + "machine '{}' is a {} link — tty7 can only route over ssh links yet", + self.key, self.kind + )); + } + if self.key.contains('|') { + return Err(format!( + "machine '{}' is reached through a jump/proxy chain, which cannot be rebuilt \ + from the link key yet — use the GUI for this machine", + self.key + )); + } + let (user, rest) = self.key.split_once('@').ok_or_else(unrecognized)?; + let (host, port) = rest.rsplit_once(':').ok_or_else(unrecognized)?; + let port: u16 = port.parse().map_err(|_| unrecognized())?; + let spec = serde_json::from_value(serde_json::json!({ + "user": user, + "host": host, + "port": port, + "auth_mode": "auto", + })) + .map_err(|e| e.to_string())?; + Ok(crate::daemon::router::RouteTarget::Ssh(Box::new(spec))) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerStatus { pub pid: u32, diff --git a/crates/tty7-gateway/src/daemon.rs b/crates/tty7-gateway/src/daemon.rs index 2f11b495..6e553c3e 100644 --- a/crates/tty7-gateway/src/daemon.rs +++ b/crates/tty7-gateway/src/daemon.rs @@ -1,5 +1,7 @@ -//! The real [`Backend`]: this machine's tty7 server, over its local sockets. +//! The real [`Backend`]: this machine's tty7 server, over its local sockets — +//! and through it, every machine it holds an SSH link to. +use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::sync::Mutex; @@ -8,12 +10,13 @@ use std::time::Duration; use tty7_core::client::{ControlClient, PaneClient, PaneSession}; use tty7_core::core::machine::Machine; use tty7_core::daemon::control::{ - ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, WorkspaceId, + ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, RouteInfo, WorkspaceId, }; use tty7_core::daemon::protocol::{DaemonMsg, WinSize}; +use tty7_core::daemon::router::RouteTarget; use tty7_mobile_proto::{GridSize, TabCreated}; -use crate::serve::{Backend, PaneFeed}; +use crate::serve::{Backend, PaneFeed, Remote}; /// The size the gateway claims when it observes a pane. The daemon ignores it /// for an observer — the pane keeps the size its window gave it — but the @@ -37,6 +40,9 @@ const NEW_TAB_SIZE: WinSize = WinSize { pub struct Daemon { host: String, control: Mutex>, + /// A control connection per linked machine, routed through the local + /// server, by link key. + remote: Mutex>, } impl Default for Daemon { @@ -44,19 +50,23 @@ impl Default for Daemon { Daemon { host: hostname(), control: Mutex::new(None), + remote: Mutex::new(HashMap::new()), } } } impl Daemon { + fn hello(&self) -> ControlHello { + ControlHello::host_rpc( + format!("tty7-gateway-{}", std::process::id()), + self.host.clone(), + ) + } + fn request(&self, req: ControlRequest) -> io::Result { let mut slot = self.control.lock().unwrap_or_else(|e| e.into_inner()); if !slot.as_ref().is_some_and(ControlClient::is_connected) { - let hello = ControlHello::host_rpc( - format!("tty7-gateway-{}", std::process::id()), - self.host.clone(), - ); - *slot = Some(ControlClient::connect(&hello)?); + *slot = Some(ControlClient::connect(&self.hello())?); } let client = slot.as_ref().expect("connected just above"); let reply = client.request(req); @@ -65,6 +75,70 @@ impl Daemon { } reply } + + fn routes(&self) -> io::Result> { + match self.request(ControlRequest::Routes)? { + ReplyOk::Routes(routes) => Ok(routes), + other => Err(unexpected("Routes", &other)), + } + } + + /// Where to route for a linked machine — only over a link the desktop + /// already holds. Routing to a down one would have the server dial it + /// afresh, guessing at credentials the phone does not have. + fn target(&self, key: &str) -> io::Result { + let routes = self.routes()?; + let route = routes + .iter() + .find(|r| r.key == key) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no machine {key}")))?; + if !route.connected { + return Err(link_down(key)); + } + route.target().map_err(io::Error::other) + } + + fn request_on(&self, machine: Option<&str>, req: ControlRequest) -> io::Result { + let Some(key) = machine else { + return self.request(req); + }; + let mut clients = self.remote.lock().unwrap_or_else(|e| e.into_inner()); + if !clients.get(key).is_some_and(ControlClient::is_connected) { + let client = ControlClient::routed(self.target(key)?, &self.hello())?; + clients.insert(key.to_string(), client); + } + let reply = clients[key].request(req); + if reply.is_err() { + clients.remove(key); + } + reply + } + + fn panes_on(&self, machine: Option<&str>) -> io::Result { + match machine { + None => Ok(PaneClient::local()), + Some(key) => Ok(PaneClient::routed(self.target(key)?)), + } + } + + fn snapshot_on(&self, machine: Option<&str>) -> io::Result<(Machine, Vec)> { + let tree = match self.request_on(machine, ControlRequest::MachineGet)? { + ReplyOk::MachineTree(tree) => *tree, + other => return Err(unexpected("MachineGet", &other)), + }; + let agents = match self.request_on(machine, ControlRequest::AgentStates)? { + ReplyOk::AgentStates(agents) => agents, + other => return Err(unexpected("AgentStates", &other)), + }; + Ok((tree, agents)) + } +} + +fn link_down(key: &str) -> io::Error { + io::Error::new( + io::ErrorKind::NotConnected, + format!("the desktop's link to {key} is down — reconnect it from tty7 on the computer"), + ) } impl Backend for Daemon { @@ -73,30 +147,43 @@ impl Backend for Daemon { } fn snapshot(&self) -> io::Result<(Machine, Vec)> { - let machine = match self.request(ControlRequest::MachineGet)? { - ReplyOk::MachineTree(machine) => *machine, - other => return Err(unexpected("MachineGet", &other)), - }; - let agents = match self.request(ControlRequest::AgentStates)? { - ReplyOk::AgentStates(agents) => agents, - other => return Err(unexpected("AgentStates", &other)), - }; - Ok((machine, agents)) + self.snapshot_on(None) } - fn observe(&self, pane_id: u64) -> io::Result> { - let session = PaneClient::local().observe(pane_id, OBSERVE_SIZE)?; + fn remotes(&self) -> Vec { + let Ok(routes) = self.routes() else { + return Vec::new(); + }; + // Links that went away take their routed connections with them. + self.remote + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|key, _| routes.iter().any(|r| &r.key == key && r.connected)); + routes + .into_iter() + .map(|route| Remote { + name: route.host().unwrap_or(&route.key).to_string(), + snapshot: route.connected.then(|| self.snapshot_on(Some(&route.key))), + connected: route.connected, + key: route.key, + }) + .collect() + } + + fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result> { + let session = self.panes_on(machine)?.observe(pane_id, OBSERVE_SIZE)?; Ok(Box::new(Observed(session))) } - fn send_input(&self, pane_id: u64, bytes: &[u8]) -> io::Result<()> { - PaneClient::local().send_input(pane_id, bytes) + fn send_input(&self, machine: Option<&str>, pane_id: u64, bytes: &[u8]) -> io::Result<()> { + self.panes_on(machine)?.send_input(pane_id, bytes) } /// The two steps `tty7 tab new` takes: spawn a shell owned by the - /// workspace, then hang a tab on it. + /// workspace, then hang a tab on it — on whichever machine it lives on. fn new_tab( &self, + machine: Option<&str>, workspace_id: &str, cwd: Option, size: Option, @@ -113,7 +200,7 @@ impl Backend for Daemon { ..NEW_TAB_SIZE }); let owner = workspace.to_string(); - let session = PaneClient::local().spawn( + let session = self.panes_on(machine)?.spawn( cwd.as_deref().map(PathBuf::from), size, None, @@ -129,12 +216,13 @@ impl Backend for Daemon { agent: None, shell: None, }; - match self.request(ControlRequest::TabCreate { + let create = ControlRequest::TabCreate { workspace, at: None, pane: seed, tab: None, - })? { + }; + match self.request_on(machine, create)? { ReplyOk::TabTree(tab) => Ok(TabCreated { tab_id: tab.id.to_string(), pane_id: pane, diff --git a/crates/tty7-gateway/src/serve.rs b/crates/tty7-gateway/src/serve.rs index 38e99f16..6c139947 100644 --- a/crates/tty7-gateway/src/serve.rs +++ b/crates/tty7-gateway/src/serve.rs @@ -21,7 +21,7 @@ use tty7_core::daemon::control::PaneAgentState; use tty7_core::daemon::protocol::DaemonMsg; use tty7_mobile_proto::{ ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, PaneEvent, - TabCreated, Tree, read_frame, write_bytes, write_msg, + RemoteView, TabCreated, Tree, read_frame, write_bytes, write_msg, }; use crate::state::State; @@ -44,20 +44,35 @@ const PAIR_FAILURE_DELAY: Duration = Duration::from_secs(2); /// What the gateway needs from the machine it serves. The daemon is the real /// one; tests stand in a fake so they can drive the bridge without a PTY. +/// +/// `machine` names a remote the desktop is linked to, by its link key, and is +/// `None` for this machine itself. pub trait Backend: Send + Sync + 'static { fn hostname(&self) -> String; fn snapshot(&self) -> io::Result<(Machine, Vec)>; - fn observe(&self, pane_id: u64) -> io::Result>; - fn send_input(&self, pane_id: u64, bytes: &[u8]) -> io::Result<()>; + /// Every machine the desktop is linked to, each read through its link. + fn remotes(&self) -> Vec; + fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result>; + fn send_input(&self, machine: Option<&str>, pane_id: u64, bytes: &[u8]) -> io::Result<()>; /// Starts a shell in a new tab at the end of `workspace_id`. fn new_tab( &self, + machine: Option<&str>, workspace_id: &str, cwd: Option, size: Option, ) -> io::Result; } +/// One linked machine, as [`Backend::remotes`] reports it. +pub struct Remote { + pub key: String, + pub name: String, + pub connected: bool, + /// Its tree, read through the link; `None` while the link is down. + pub snapshot: Option)>>, +} + /// A read-only view onto one pane's output, as the daemon sends it. pub trait PaneFeed: Send { /// The next message, or `Ok(None)` if none arrived within `wait`. @@ -153,12 +168,15 @@ async fn serve_stream( workspace_id, cwd, size, + machine, } => { let created = { let backend = backend.clone(); - tokio::task::spawn_blocking(move || backend.new_tab(&workspace_id, cwd, size)) - .await - .map_err(io::Error::other)? + tokio::task::spawn_blocking(move || { + backend.new_tab(machine.as_deref(), &workspace_id, cwd, size) + }) + .await + .map_err(io::Error::other)? }; match created { Ok(created) => { @@ -170,17 +188,17 @@ async fn serve_stream( finish(send).await; Ok(()) } - Open::Pane { pane_id } => { + Open::Pane { pane_id, machine } => { let feed = { - let backend = backend.clone(); - tokio::task::spawn_blocking(move || backend.observe(pane_id)) + let (backend, machine) = (backend.clone(), machine.clone()); + tokio::task::spawn_blocking(move || backend.observe(machine.as_deref(), pane_id)) .await .map_err(io::Error::other)? }; match feed { Ok(feed) => { write_msg(&mut send, &ok).await?; - pane_stream(pane_id, feed, send, recv, backend).await + pane_stream(machine, pane_id, feed, send, recv, backend).await } Err(e) => { write_msg(&mut send, &denied(&e.to_string())).await?; @@ -252,7 +270,8 @@ fn watch_tree( let event = match backend.snapshot() { Ok((machine, agents)) => { failing = false; - let tree = tree::build(&host, &machine, &agents); + let mut tree = tree::build(&host, &machine, &agents); + tree.remotes = backend.remotes().into_iter().map(remote_view).collect(); if forced || last.as_ref() != Some(&tree) { last = Some(tree.clone()); Some(ControlEvent::Tree(tree)) @@ -283,6 +302,24 @@ fn watch_tree( } } +fn remote_view(remote: Remote) -> RemoteView { + let (workspaces, error) = match remote.snapshot { + Some(Ok((machine, agents))) => ( + tree::build(&remote.name, &machine, &agents).workspaces, + None, + ), + Some(Err(e)) => (Vec::new(), Some(e.to_string())), + None => (Vec::new(), None), + }; + RemoteView { + key: remote.key, + name: remote.name, + connected: remote.connected, + error, + workspaces, + } +} + /// What to say when the tty7 server can't be reached. No socket at all means /// no server is running, which is the common case and has a fix to name; any /// other failure is passed on as it came. @@ -301,6 +338,7 @@ enum Down { } async fn pane_stream( + machine: Option, pane_id: u64, feed: Box, mut send: SendStream, @@ -319,7 +357,7 @@ async fn pane_stream( .name(format!("gateway-input-{pane_id}")) .spawn(move || { for bytes in input_rx { - if let Err(e) = backend.send_input(pane_id, &bytes) { + if let Err(e) = backend.send_input(machine.as_deref(), pane_id, &bytes) { eprintln!("tty7-gateway: input to pane {pane_id}: {e}"); return; } diff --git a/crates/tty7-gateway/src/tree.rs b/crates/tty7-gateway/src/tree.rs index 2e65ef5d..dd87d2b0 100644 --- a/crates/tty7-gateway/src/tree.rs +++ b/crates/tty7-gateway/src/tree.rs @@ -50,6 +50,7 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { } }) .collect(), + remotes: Vec::new(), } } diff --git a/crates/tty7-gateway/tests/bridge.rs b/crates/tty7-gateway/tests/bridge.rs index 5b025de6..08408ed1 100644 --- a/crates/tty7-gateway/tests/bridge.rs +++ b/crates/tty7-gateway/tests/bridge.rs @@ -12,7 +12,7 @@ use iroh::endpoint::presets; use tty7_core::core::machine::{Machine, Tab, Workspace}; use tty7_core::daemon::control::PaneAgentState; use tty7_core::daemon::protocol::{DaemonMsg, WinSize}; -use tty7_gateway::serve::{self, Backend, PaneFeed}; +use tty7_gateway::serve::{self, Backend, PaneFeed, Remote}; use tty7_gateway::state::State; use tty7_mobile_client::{PaneItem, Session}; use tty7_mobile_proto::{ALPN, ControlEvent, GridSize, PairCode, PaneEvent, TabCreated}; @@ -56,10 +56,38 @@ impl Backend for FakeMachine { Ok((self.machine.clone(), Vec::new())) } - fn observe(&self, pane_id: u64) -> io::Result> { + /// One linked machine that is up, holding the same tree, and one whose + /// link is down. + fn remotes(&self) -> Vec { + vec![ + Remote { + key: "me@build-box:22".into(), + name: "build-box".into(), + connected: true, + snapshot: Some(Ok((self.machine.clone(), Vec::new()))), + }, + Remote { + key: "me@gone:22".into(), + name: "gone".into(), + connected: false, + snapshot: None, + }, + ] + } + + fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result> { + if machine == Some("me@gone:22") { + return Err(io::Error::other("the desktop's link to me@gone:22 is down")); + } if pane_id != 1 { return Err(io::Error::other(format!("no such pane {pane_id}"))); } + // The remote's pane 1 says where it is, so a test can tell it from + // the local one. + let prompt = match machine { + Some(key) => format!("{key}$ ").into_bytes(), + None => b"$ ".to_vec(), + }; let (tx, rx) = std_mpsc::channel(); *self.typed.lock().unwrap() = Some(tx); Ok(Box::new(FakeFeed { @@ -70,13 +98,13 @@ impl Backend for FakeMachine { cell_w: 8, cell_h: 16, }), - DaemonMsg::Snapshot(b"$ ".to_vec()), + DaemonMsg::Snapshot(prompt), ], typed: rx, })) } - fn send_input(&self, _pane_id: u64, bytes: &[u8]) -> io::Result<()> { + fn send_input(&self, _machine: Option<&str>, _pane_id: u64, bytes: &[u8]) -> io::Result<()> { let typed = self.typed.lock().unwrap(); typed .as_ref() @@ -87,6 +115,7 @@ impl Backend for FakeMachine { fn new_tab( &self, + machine: Option<&str>, workspace_id: &str, cwd: Option, size: Option, @@ -97,7 +126,7 @@ impl Backend for FakeMachine { // Echo what was asked for in the tab id, so the test can see it // crossed intact. Ok(TabCreated { - tab_id: format!("{cwd:?} {size:?}"), + tab_id: format!("{machine:?} {cwd:?} {size:?}"), pane_id: 2, }) } @@ -205,7 +234,7 @@ async fn an_unpaired_phone_is_turned_away() { let session = Session::connect(&rig.phone, &host).await.unwrap(); let err = within(session.control()).await.err().expect("denied"); assert!(err.to_string().contains("not paired"), "{err}"); - let err = within(session.pane(1)).await.err().expect("denied"); + let err = within(session.pane(None, 1)).await.err().expect("denied"); assert!(err.to_string().contains("not paired"), "{err}"); } @@ -230,7 +259,7 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() { Some(ControlEvent::Tree(t)) if t == first )); - let (mut keys, mut screen) = within(session.pane(1)).await.unwrap(); + let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap(); assert_eq!( within(screen.next()).await.unwrap(), Some(PaneItem::Event(PaneEvent::Size { @@ -254,7 +283,10 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() { ); assert_eq!(within(screen.next()).await.unwrap(), None, "stream ends"); - let err = within(session.pane(99)).await.err().expect("no such pane"); + let err = within(session.pane(None, 99)) + .await + .err() + .expect("no such pane"); assert!(err.to_string().contains("no such pane 99"), "{err}"); } @@ -281,16 +313,30 @@ async fn a_paired_phone_opens_a_tab() { let ws = &first.workspaces[0].id; let size = GridSize { cols: 56, rows: 40 }; - let created = within(session.new_tab(ws, Some("/src/demo".into()), Some(size))) + let created = within(session.new_tab(None, ws, Some("/src/demo".into()), Some(size))) .await .unwrap(); assert_eq!(created.pane_id, 2); assert_eq!( created.tab_id, - format!("{:?} {:?}", Some("/src/demo"), Some(size)) + format!( + "{:?} {:?} {:?}", + None::<&str>, + Some("/src/demo"), + Some(size) + ) ); - let err = within(session.new_tab("nope", None, None)) + let created = within(session.new_tab(Some("me@build-box:22"), ws, None, None)) + .await + .unwrap(); + assert!( + created.tab_id.starts_with("Some(\"me@build-box:22\")"), + "{}", + created.tab_id + ); + + let err = within(session.new_tab(None, "nope", None, None)) .await .unwrap_err(); assert!(err.to_string().contains("no workspace nope"), "{err}"); @@ -312,6 +358,40 @@ async fn an_unpaired_phone_cannot_open_a_tab() { }; let session = Session::connect(&rig.phone, &host).await.unwrap(); let ws = FakeMachine::new().machine.workspaces[0].id.to_string(); - let err = within(session.new_tab(&ws, None, None)).await.unwrap_err(); + let err = within(session.new_tab(None, &ws, None, None)) + .await + .unwrap_err(); assert!(err.to_string().contains("not paired"), "{err}"); } + +#[tokio::test(flavor = "multi_thread")] +async fn linked_machines_come_with_the_tree_and_their_panes_open() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + let (_, mut tree) = within(session.control()).await.unwrap().split(); + let Some(ControlEvent::Tree(tree)) = within(tree.next()).await.unwrap() else { + panic!("expected a tree first"); + }; + let [up, down] = &tree.remotes[..] else { + panic!("two remotes: {:?}", tree.remotes); + }; + assert_eq!((up.name.as_str(), up.connected), ("build-box", true)); + assert_eq!(up.workspaces[0].name, "pale-otter"); + assert_eq!((down.connected, down.workspaces.len()), (false, 0)); + + let (_, mut screen) = within(session.pane(Some(&up.key), 1)).await.unwrap(); + assert!(matches!( + within(screen.next()).await.unwrap(), + Some(PaneItem::Event(PaneEvent::Size { .. })) + )); + assert_eq!( + within(screen.next()).await.unwrap(), + Some(PaneItem::Output(b"me@build-box:22$ ".to_vec())) + ); + + let err = within(session.pane(Some(&down.key), 1)) + .await + .err() + .expect("down"); + assert!(err.to_string().contains("is down"), "{err}"); +} diff --git a/crates/tty7-mobile-client/examples/probe.rs b/crates/tty7-mobile-client/examples/probe.rs index dc065a4c..94f5e978 100644 --- a/crates/tty7-mobile-client/examples/probe.rs +++ b/crates/tty7-mobile-client/examples/probe.rs @@ -70,7 +70,7 @@ async fn main() -> Result<()> { let pane: u64 = args.get(1).context("type ")?.parse()?; let text = args.get(2).context("type ")?; let session = Session::connect(&endpoint, &host()?).await?; - let (mut keys, mut screen) = session.pane(pane).await?; + let (mut keys, mut screen) = session.pane(None, pane).await?; // Drain the replay: it ends when the pane goes quiet. let mut replay = 0usize; while let Ok(Ok(Some(item))) = @@ -120,7 +120,7 @@ async fn main() -> Result<()> { let cwd = args.get(2).cloned(); let session = Session::connect(&endpoint, &host()?).await?; let size = GridSize { cols: 56, rows: 40 }; - let created = session.new_tab(ws, cwd, Some(size)).await?; + let created = session.new_tab(None, ws, cwd, Some(size)).await?; println!( "opened tab {} with pane {}", created.tab_id, created.pane_id diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index fce7bc12..bd163121 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -141,9 +141,18 @@ impl Session { } /// Opens one pane: its output (replay first) arrives on the reader, and - /// keystrokes go in through the writer. - pub async fn pane(&self, pane_id: u64) -> Result<(PaneWriter, PaneReader)> { - let (send, recv) = open(&self.conn, &Open::Pane { pane_id }).await?; + /// keystrokes go in through the writer. `machine` is the key of the + /// remote it lives on, `None` for the gateway's own machine. + pub async fn pane( + &self, + machine: Option<&str>, + pane_id: u64, + ) -> Result<(PaneWriter, PaneReader)> { + let ask = Open::Pane { + pane_id, + machine: machine.map(str::to_string), + }; + let (send, recv) = open(&self.conn, &ask).await?; Ok((PaneWriter { send }, PaneReader { recv })) } } @@ -153,6 +162,7 @@ impl Session { /// pane to open with [`Session::pane`]. pub async fn new_tab( &self, + machine: Option<&str>, workspace_id: &str, cwd: Option, size: Option, @@ -161,6 +171,7 @@ impl Session { workspace_id: workspace_id.to_string(), cwd, size, + machine: machine.map(str::to_string), }; // A gateway from before new tabs cannot parse the ask and drops the // stream without a word; say what that means rather than that it hung diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index 86145baf..401fec0d 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -58,7 +58,13 @@ pub enum Open { /// desktop, and would take the pane away from the window showing it. /// Keystrokes go in beside the observer, so the phone can drive the pane /// without owning it. - Pane { pane_id: u64 }, + Pane { + pane_id: u64, + /// The [`RemoteView::key`] of the remote machine the pane lives on; + /// absent for the gateway's own machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, + }, /// Start a shell in a new tab at the end of a workspace. One-shot: the /// gateway creates the tab first, then answers `Ok` followed by a /// [`TabCreated`], or `Denied` with what went wrong. @@ -74,6 +80,9 @@ pub enum Open { /// window is showing the tab yet. One that later does resizes it. #[serde(default)] size: Option, + /// As on [`Open::Pane`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, }, } @@ -146,6 +155,28 @@ pub enum PaneEvent { pub struct Tree { pub host: String, pub workspaces: Vec, + /// The machines the desktop is linked to over SSH, each with its own + /// workspaces. The gateway reaches them through those links and never + /// dials one itself. + #[serde(default)] + pub remotes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteView { + /// The link's key (`me@build-box:22`): what [`Open::Pane`] and + /// [`Open::NewTab`] name the machine by. + pub key: String, + /// What to call it: the host, or the key when it has none. + pub name: String, + /// Whether the desktop's link to it is up. A down link lists no + /// workspaces; the desktop has to reconnect it. + pub connected: bool, + /// Why its workspaces could not be read, when the link is up but the far + /// end did not answer. + #[serde(default)] + pub error: Option, + pub workspaces: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -374,7 +405,10 @@ mod tests { #[test] fn frames_survive_arbitrary_chunking() { - let mut wire = encode_msg(&Open::Pane { pane_id: 7 }); + let mut wire = encode_msg(&Open::Pane { + pane_id: 7, + machine: None, + }); wire.extend(encode_bytes(b"\x1b[31mhi\r\n")); wire.extend(encode_msg(&PaneEvent::Size { cols: 80, rows: 24 })); @@ -388,7 +422,13 @@ mod tests { } } assert_eq!(frames.len(), 3, "chunk size {chunk}"); - assert_eq!(frames[0].msg::().unwrap(), Open::Pane { pane_id: 7 }); + assert_eq!( + frames[0].msg::().unwrap(), + Open::Pane { + pane_id: 7, + machine: None + } + ); assert_eq!(frames[1], Frame::Bytes(b"\x1b[31mhi\r\n".to_vec())); assert_eq!( frames[2].msg::().unwrap(), @@ -459,9 +499,20 @@ mod tests { Open::NewTab { workspace_id: "w".into(), cwd: None, - size: None + size: None, + machine: None } ); + // A local pane is asked for exactly as before remotes existed, so an + // older gateway still understands it. + assert_eq!( + serde_json::to_value(Open::Pane { + pane_id: 3, + machine: None + }) + .unwrap(), + serde_json::json!({"type": "pane", "pane_id": 3}) + ); } #[cfg(feature = "tokio")] diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index b16ffb39..38bac344 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -222,13 +222,14 @@ async fn refresh(state: State<'_, Arc>, host_id: String) -> CmdResult< async fn tab_new( state: State<'_, Arc>, host_id: String, + machine: Option, workspace_id: String, cwd: Option, size: Option, ) -> CmdResult { let session = state.session(&host_id).await?; session - .new_tab(&workspace_id, cwd, size) + .new_tab(machine.as_deref(), &workspace_id, cwd, size) .await .map_err(err) } @@ -240,11 +241,15 @@ async fn tab_new( async fn pane_open( state: State<'_, Arc>, host_id: String, + machine: Option, pane_id: u64, on_output: Channel, ) -> CmdResult { let session = state.session(&host_id).await?; - let (mut keys, mut screen) = session.pane(pane_id).await.map_err(err)?; + let (mut keys, mut screen) = session + .pane(machine.as_deref(), pane_id) + .await + .map_err(err)?; let handle = state.next_pane.fetch_add(1, Ordering::Relaxed); let (input_tx, mut input_rx) = mpsc::unbounded_channel::>(); state.panes.lock().await.insert(handle, input_tx); diff --git a/mobile/src/api.ts b/mobile/src/api.ts index aadc4499..6697102b 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -42,6 +42,16 @@ export interface WorkspaceView { export interface Tree { host: string; workspaces: WorkspaceView[]; + /** Machines the desktop is linked to over SSH. Absent from older gateways. */ + remotes?: RemoteView[]; +} + +export interface RemoteView { + key: string; + name: string; + connected: boolean; + error?: string | null; + workspaces: WorkspaceView[]; } export interface LinkInfo { @@ -78,8 +88,10 @@ export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) { export const refresh = (hostId: string) => invoke("refresh", { hostId }); /** Output arrives as ArrayBuffers of raw terminal bytes, events as objects. */ +/** `machine` is a remote's key, or null for the paired machine itself. */ export function paneOpen( hostId: string, + machine: string | null, paneId: number, onOutput: (bytes: Uint8Array) => void, onEvent: (event: PaneEvent) => void, @@ -89,7 +101,7 @@ export function paneOpen( if (msg instanceof ArrayBuffer) onOutput(new Uint8Array(msg)); else onEvent(msg); }; - return invoke("pane_open", { hostId, paneId, onOutput: channel }); + return invoke("pane_open", { hostId, machine, paneId, onOutput: channel }); } export interface TabCreated { @@ -99,10 +111,11 @@ export interface TabCreated { export const tabNew = ( hostId: string, + machine: string | null, workspaceId: string, cwd: string | null, size: { cols: number; rows: number } | null, -) => invoke("tab_new", { hostId, workspaceId, cwd, size }); +) => invoke("tab_new", { hostId, machine, workspaceId, cwd, size }); export const paneInput = (handle: number, data: string) => invoke("pane_input", { handle, data }); diff --git a/mobile/src/icons.ts b/mobile/src/icons.ts index be8e0407..d82d6956 100644 --- a/mobile/src/icons.ts +++ b/mobile/src/icons.ts @@ -11,6 +11,9 @@ export const icon = { more: ``, terminal: stroke(``), machine: stroke(``), + server: stroke( + ``, + ), keyboard: stroke( ``, ), diff --git a/mobile/src/main.ts b/mobile/src/main.ts index ee98f1b9..5cb1853c 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -453,38 +453,29 @@ const STATUS_WORD: Record = { idle: "Idle", }; +/** Which machine a pane or workspace is on: a remote the desktop is linked + * to, or null for the paired machine itself. */ +type Place = { key: string; name: string } | null; + function renderTree(host: Host, tree: Tree, failed: (message: string) => void): Node[] { const out: Node[] = []; + const remotes = tree.remotes ?? []; + const everywhere: [Place, WorkspaceView][] = [ + ...tree.workspaces.map((ws): [Place, WorkspaceView] => [null, ws]), + ...remotes.flatMap((r) => r.workspaces.map((ws): [Place, WorkspaceView] => [r, ws])), + ]; + const waiting: Node[] = []; - for (const ws of tree.workspaces) + for (const [place, ws] of everywhere) for (const tab of ws.tabs) for (const pane of tab.panes) - if (pane.agent && (pane.agent.status === "waiting" || pane.agent.status === "done")) - waiting.push(paneRow(host, tab, pane, workspaceName(ws.name))); - + if (pane.agent && (pane.agent.status === "waiting" || pane.agent.status === "done")) { + const where = place ? `${place.name} › ${workspaceName(ws.name)}` : workspaceName(ws.name); + waiting.push(paneRow(host, place, tab, pane, where)); + } if (waiting.length) out.push(section("Needs you", ...waiting)); - // A workspace is one card of its tabs, named as the desktop's sidebar names - // them; a split tab gives each of its panes a row. - for (const ws of tree.workspaces) { - out.push( - h( - "section", - { class: "group" }, - h( - "div", - { class: "group-head" }, - h("h2", { class: "group-title" }, workspaceName(ws.name)), - newTabButton(host, ws, failed), - ), - h( - "div", - { class: "card" }, - ...ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, tab, pane, null))), - ), - ), - ); - } + out.push(...tree.workspaces.map((ws) => workspaceGroup(host, null, ws, failed))); if (tree.workspaces.length === 0) { out.push( h( @@ -496,19 +487,79 @@ function renderTree(host: Host, tree: Tree, failed: (message: string) => void): ), ); } + + // Then every machine the desktop reaches over SSH, as the desktop's + // sidebar lists them: its own heading, its workspaces under it. + for (const remote of remotes) { + const state = remote.connected ? (remote.error ? "Not answering" : "Connected") : "Link down"; + out.push( + h( + "section", + { class: "remote" }, + h( + "div", + { class: "remote-head" }, + h("span", { class: "tile" }, ico("server")), + h( + "div", + { class: "remote-titles" }, + h("h2", { class: "remote-name" }, remote.name), + h( + "p", + { class: `link ${remote.connected && !remote.error ? "direct" : "offline"}` }, + h("span", { class: "link-dot" }), + `SSH · ${state}`, + ), + ), + ), + !remote.connected && + h( + "p", + { class: "remote-note" }, + `tty7 on ${host.name} lost its link to ${remote.name}. Reconnect it there to reach its workspaces.`, + ), + remote.error && h("p", { class: "remote-note" }, sentence(remote.error)), + remote.connected && + !remote.error && + remote.workspaces.length === 0 && + h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`), + ...remote.workspaces.map((ws) => workspaceGroup(host, remote, ws, failed)), + ), + ); + } return out; } +/** A workspace is one card of its tabs, named as the desktop's sidebar names + * them; a split tab gives each of its panes a row. */ +function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) { + return h( + "section", + { class: "group" }, + h( + "div", + { class: "group-head" }, + h("h2", { class: "group-title" }, workspaceName(ws.name)), + newTabButton(host, place, ws, failed), + ), + h( + "div", + { class: "card" }, + ...ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane, null))), + ), + ); +} + /** Starts a shell in a new tab at the end of a workspace, in the directory * its last tab is in, sized to this screen, and opens it. */ -function newTabButton(host: Host, ws: WorkspaceView, failed: (message: string) => void) { +function newTabButton(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) { const button = h("button", { class: "head-action" }, ico("plus"), "New tab"); button.onclick = async () => { button.disabled = true; const cwd = ws.tabs.at(-1)?.panes[0]?.cwd ?? null; try { - const created = await api.tabNew(host.id, ws.id, cwd, phoneGrid()); - terminalScreen(host, { id: created.pane_id, title: "shell", cwd }, "New tab"); + const created = await api.tabNew(host.id, place?.key ?? null, ws.id, cwd, phoneGrid()); + terminalScreen(host, place, { id: created.pane_id, title: "shell", cwd }, "New tab"); } catch (e) { failed(errorText(e)); button.disabled = false; @@ -538,7 +589,7 @@ function workspaceName(name: string) { /** A pane's row: its tab's name first, as on the desktop, then what the pane * is doing — its agent's state, or where its shell is. `where` names the * workspace when the row sits outside it, in "Needs you". */ -function paneRow(host: Host, tab: TabView, pane: PaneView, where: string | null) { +function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView, where: string | null) { const agent = pane.agent; const sub: Child[] = []; if (agent && agent.status !== "idle") @@ -547,14 +598,14 @@ function paneRow(host: Host, tab: TabView, pane: PaneView, where: string | null) // panes apart then is what runs in them. const namedByPath = /^[~/]/.test(tab.name); const split = tab.panes.length > 1 || namedByPath ? pane.title : null; - const place = agent || namedByPath ? null : shortPath(pane.cwd); - const detail = [where, split, agent?.message ?? place].filter(Boolean).join(" · "); + const dir = agent || namedByPath ? null : shortPath(pane.cwd); + const detail = [where, split, agent?.message ?? dir].filter(Boolean).join(" · "); if (detail) sub.push(sub.length ? ` · ${detail}` : detail); return h( "button", { class: tab.hibernated ? "row asleep" : "row", - onclick: () => terminalScreen(host, pane, tab.name), + onclick: () => terminalScreen(host, place, pane, tab.name), }, avatar(agent), h( @@ -720,13 +771,16 @@ const CELL_EM = 0.602; /** The smallest font a pane is read at before it pans instead of shrinking. */ const READABLE_PX = 11; -function terminalScreen(host: Host, pane: PaneView, title: string) { +function terminalScreen(host: Host, place: Place, pane: PaneView, title: string) { go("push", () => { // What the pane is doing and whether keystrokes will land, in words: the // one line under the title. const stateWord = h("span", {}, "Connecting…"); const state = h("span", { class: "term-state connecting" }, h("span", { class: "link-dot" }), stateWord); - const cwd = h("span", { class: "term-cwd" }, shortPath(pane.cwd)); + // On a remote, the path says which machine it is on, the way a prompt does. + const where = (path: string | null | undefined) => + place && path ? `${place.name}:${shortPath(path)}` : shortPath(path); + const cwd = h("span", { class: "term-cwd" }, where(pane.cwd)); const sub = h("span", { class: "term-sub" }, state, cwd); const zoom = h("button", { class: "nav-icon", ariaLabel: "Fit the whole width" }, ico("fit")); let head = avatar(pane.agent, "avatar small"); @@ -866,6 +920,7 @@ function terminalScreen(host: Host, pane: PaneView, title: string) { try { handle = await api.paneOpen( host.id, + place?.key ?? null, pane.id, (bytes) => term.write(bytes), (event) => { @@ -883,7 +938,7 @@ function terminalScreen(host: Host, pane: PaneView, title: string) { break; } case "cwd": - cwd.textContent = shortPath(event.path); + cwd.textContent = where(event.path); break; case "exited": handle = null; diff --git a/mobile/src/style.css b/mobile/src/style.css index 7e0e8c4d..b4a8cdee 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -338,6 +338,41 @@ code { overflow: hidden; } +.remote { + padding-top: 10px; +} + +.remote-head { + display: flex; + align-items: center; + gap: 12px; + margin: 0 20px 16px; +} + +.remote-titles { + min-width: 0; +} + +.remote-name { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.014em; + overflow-wrap: anywhere; +} + +.remote-head .link { + margin-top: 1px; + font-size: 14px; +} + +.remote-note { + margin: -4px 20px 24px; + font-size: 14px; + line-height: 1.45; + color: var(--ink-2); + text-wrap: pretty; +} + .row.asleep > .avatar { opacity: 0.45; }