From 618bf887e32e3797ea722bcae9385620611cbf23 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:32:42 +0200 Subject: [PATCH] feat(mobile): reach the machines the desktop is linked to over SSH A machine's screen now lists, under its own workspaces, every machine its tty7 holds an SSH link to, with that machine's workspaces. Panes there open, take input and get new tabs like local ones. "Needs you" gathers panes from all of them. - The gateway routes through the local server over links it already holds, the same way `tty7 -m ` does. It never dials a down link, because that would guess at credentials the phone does not have. A down link shows as "Link down", with a note to reconnect it on the desktop. - Protocol, additive: `Tree.remotes`, and an optional `machine` (the link key) on `Open::Pane` and `Open::NewTab`. A local pane is asked for exactly as before, so older gateways still understand it. - Rebuilding a route target from a link key moves from the CLI into tty7-core as `RouteInfo::target` / `RouteInfo::host`, so the CLI and the gateway share one rule. The CLI now calls it. --- crates/tty7-cli/src/backend/real.rs | 41 +----- crates/tty7-core/src/daemon/control.rs | 46 +++++++ crates/tty7-gateway/src/daemon.rs | 138 ++++++++++++++++---- crates/tty7-gateway/src/serve.rs | 62 +++++++-- crates/tty7-gateway/src/tree.rs | 1 + crates/tty7-gateway/tests/bridge.rs | 104 +++++++++++++-- crates/tty7-mobile-client/examples/probe.rs | 4 +- crates/tty7-mobile-client/src/lib.rs | 17 ++- crates/tty7-mobile-proto/src/lib.rs | 59 ++++++++- mobile/src-tauri/src/lib.rs | 9 +- mobile/src/api.ts | 17 ++- mobile/src/icons.ts | 3 + mobile/src/main.ts | 125 +++++++++++++----- mobile/src/style.css | 35 +++++ 14 files changed, 525 insertions(+), 136 deletions(-) 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; }