feat(mobile): take a pane over at the phone's size

The terminal's phone button asks the gateway to run the pane at the
phone's grid (PaneRequest::TakeOver), which it turns into a size lease on
the observer connection, named after the paired device. The grid follows
the keyboard and rotation; leaving the pane, or the connection dropping,
gives it back. When the desktop takes it back the app says so and offers
to take it over again, never doing it on its own. A daemon too old for
leases is reported, and the pane keeps working.
This commit is contained in:
l0ng-ai
2026-09-27 12:50:40 +02:00
parent 39ff2e6643
commit 26d09d64c7
13 changed files with 557 additions and 62 deletions
+56 -8
View File
@@ -7,17 +7,17 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tty7_core::client::{ControlClient, PaneClient, PaneSession};
use tty7_core::client::{ControlClient, PaneClient, PaneInput, PaneOutput};
use tty7_core::core::machine::Machine;
use tty7_core::daemon::control::{
ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, RouteInfo, WorkspaceId,
};
use tty7_core::daemon::protocol::{DaemonMsg, WinSize};
use tty7_core::daemon::protocol::{DaemonMsg, FEATURE_SIZE_LEASE, LeaseRequest, WinSize};
use tty7_core::daemon::router::RouteTarget;
use tty7_mobile_proto::{GridSize, TabCreated};
use crate::poller::Poller;
use crate::serve::{Backend, PaneFeed, Remote};
use crate::serve::{Backend, PaneFeed, PaneLeases, Remote};
/// How long a tree read waits on linked machines before reporting the slow
/// ones as they last were. A healthy link answers well inside it.
@@ -226,8 +226,16 @@ impl Backend for Daemon {
}
fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result<Box<dyn PaneFeed>> {
let session = self.panes_on(machine)?.observe(pane_id, OBSERVE_SIZE)?;
Ok(Box::new(Observed(session)))
let client = self.panes_on(machine)?;
let (input, output) = client.observe(pane_id, OBSERVE_SIZE)?.split();
Ok(Box::new(Observed {
output,
leases: Some(Box::new(Leases {
input,
client,
supported: None,
})),
}))
}
fn send_input(&self, machine: Option<&str>, pane_id: u64, bytes: &[u8]) -> io::Result<()> {
@@ -287,12 +295,19 @@ impl Backend for Daemon {
}
}
struct Observed(PaneSession);
struct Observed {
output: PaneOutput,
leases: Option<Box<dyn PaneLeases>>,
}
impl PaneFeed for Observed {
fn leases(&mut self) -> Option<Box<dyn PaneLeases>> {
self.leases.take()
}
fn recv(&mut self, wait: Duration) -> io::Result<Option<DaemonMsg>> {
self.0.set_recv_timeout(Some(wait))?;
match self.0.recv() {
self.output.set_recv_timeout(Some(wait))?;
match self.output.recv() {
Ok(msg) => Ok(Some(msg)),
Err(e)
if matches!(
@@ -307,6 +322,39 @@ impl PaneFeed for Observed {
}
}
/// The observer connection's writing half, for leases.
struct Leases {
input: PaneInput,
client: PaneClient,
/// Whether the pane's daemon knows leases, asked on the first one. A
/// daemon that does not would drop the observer on reading one.
supported: Option<bool>,
}
impl PaneLeases for Leases {
fn send(&mut self, request: LeaseRequest) -> io::Result<()> {
let supported = match self.supported {
Some(known) => known,
None => {
let known = self
.client
.version()?
.features
.iter()
.any(|f| f == FEATURE_SIZE_LEASE);
*self.supported.insert(known)
}
};
if !supported {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"the tty7 running this pane is too old to take over; update it",
));
}
self.input.lease(request)
}
}
fn unexpected(req: &str, reply: &ReplyOk) -> io::Error {
io::Error::other(format!("unexpected answer to {req}: {reply:?}"))
}
+102 -20
View File
@@ -18,10 +18,10 @@ use tokio::sync::mpsc;
use tty7_core::core::cli_agent::{AgentSessionState, CLIAgent};
use tty7_core::core::machine::Machine;
use tty7_core::daemon::control::PaneAgentState;
use tty7_core::daemon::protocol::DaemonMsg;
use tty7_core::daemon::protocol::{DaemonMsg, LeaseRequest, WinSize};
use tty7_mobile_proto::{
ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, PaneEvent,
RemoteView, TabCreated, Tree, read_frame, write_bytes, write_msg,
PaneRequest, RemoteView, TabCreated, Tree, read_frame, write_bytes, write_msg,
};
use crate::state::State;
@@ -78,6 +78,19 @@ pub struct Remote {
pub trait PaneFeed: Send {
/// The next message, or `Ok(None)` if none arrived within `wait`.
fn recv(&mut self, wait: Duration) -> io::Result<Option<DaemonMsg>>;
/// The way back to the daemon on the same connection, for a take-over.
/// Taken once, before the feed goes to its reader thread.
fn leases(&mut self) -> Option<Box<dyn PaneLeases>> {
None
}
}
/// Asks the daemon to run a pane at the phone's size, over the connection
/// that observes it, so the lease ends when that connection does.
pub trait PaneLeases: Send {
/// Fails with `Unsupported` where the pane's daemon predates leases.
fn send(&mut self, request: LeaseRequest) -> io::Result<()>;
}
pub async fn run(endpoint: Endpoint, state: State, backend: Arc<dyn Backend>) {
@@ -199,7 +212,13 @@ async fn serve_stream(
match feed {
Ok(feed) => {
write_msg(&mut send, &ok).await?;
pane_stream(machine, pane_id, feed, send, recv, backend).await
// How the desktop names who has the pane.
let by = state
.devices()
.ok()
.and_then(|devices| devices.into_iter().find(|d| d.id == peer))
.map_or_else(|| "a phone".to_string(), |d| d.name);
pane_stream(machine, pane_id, by, feed, send, recv, backend).await
}
Err(e) => {
write_msg(&mut send, &denied(&e.to_string())).await?;
@@ -340,10 +359,17 @@ enum Down {
Event(PaneEvent),
}
/// What the phone sends up a pane stream, in the order it sent it.
enum Up {
Keys(Vec<u8>),
Lease(PaneRequest),
}
async fn pane_stream(
machine: Option<String>,
pane_id: u64,
feed: Box<dyn PaneFeed>,
by: String,
mut feed: Box<dyn PaneFeed>,
mut send: SendStream,
mut recv: RecvStream,
backend: Arc<dyn Backend>,
@@ -351,6 +377,7 @@ async fn pane_stream(
let (down_tx, mut down) = mpsc::channel::<Down>(PANE_BACKLOG);
// Weak, so the stream still ends when the pane's feed does.
let refused = down_tx.downgrade();
let mut leases = feed.leases();
std::thread::Builder::new()
.name(format!("gateway-pane-{pane_id}"))
.spawn(move || read_pane(feed, down_tx))?;
@@ -358,19 +385,58 @@ async fn pane_stream(
// Keystrokes go through one thread so they reach the pane in the order
// they were typed; each `send_input` is its own daemon round trip. One
// that fails is said to the phone, which would otherwise go on showing a
// live pane while what it types goes nowhere.
let (input_tx, input_rx) = std_mpsc::channel::<Vec<u8>>();
// live pane while what it types goes nowhere. Take-overs share the
// thread, so one sent after some keys lands after them.
let (input_tx, input_rx) = std_mpsc::channel::<Up>();
std::thread::Builder::new()
.name(format!("gateway-input-{pane_id}"))
.spawn(move || {
for bytes in input_rx {
if let Err(e) = backend.send_input(machine.as_deref(), pane_id, &bytes) {
log::debug!("mobile gateway: input to pane {pane_id}: {e}");
let message = format!("typing didn't reach the pane: {e}");
if let Some(down) = refused.upgrade() {
let _ = down.blocking_send(Down::Event(PaneEvent::Error { message }));
let tell = |event: PaneEvent| {
if let Some(down) = refused.upgrade() {
let _ = down.blocking_send(Down::Event(event));
}
};
let mut typing = true;
for up in input_rx {
match up {
// Once typing has failed, the phone has been told; later
// keys are dropped rather than ending the stream, which
// would read as the pane closing.
Up::Keys(_) if !typing => {}
Up::Keys(bytes) => {
if let Err(e) = backend.send_input(machine.as_deref(), pane_id, &bytes) {
log::debug!("mobile gateway: input to pane {pane_id}: {e}");
typing = false;
tell(PaneEvent::Error {
message: format!("typing didn't reach the pane: {e}"),
});
}
}
Up::Lease(request) => {
let request = match request {
PaneRequest::TakeOver { size } => LeaseRequest::Take {
size: lease_size(size),
by: by.clone(),
},
PaneRequest::Release => LeaseRequest::Release,
};
let sent = match leases.as_mut() {
Some(leases) => leases.send(request),
None => Err(io::Error::new(
io::ErrorKind::Unsupported,
"this pane can't be taken over",
)),
};
// The daemon answers a lease itself, with who holds
// it; only a request that never got there is ours
// to answer.
if let Err(e) = sent {
tell(PaneEvent::Lease {
held: false,
refused: Some(e.to_string()),
});
}
}
return;
}
}
})?;
@@ -378,15 +444,16 @@ async fn pane_stream(
loop {
tokio::select! {
frame = read_frame(&mut recv) => match frame? {
// Once typing has failed, the phone has been told; later
// keys are dropped rather than ending the stream, which would
// read as the pane closing.
Some(Frame::Bytes(bytes)) => {
let _ = input_tx.send(bytes);
let _ = input_tx.send(Up::Keys(bytes));
}
// A request this gateway does not know, from a newer app, is
// ignored rather than fatal, so the terminal keeps working.
Some(frame @ Frame::Json(_)) => {
if let Ok(request) = frame.msg::<PaneRequest>() {
let _ = input_tx.send(Up::Lease(request));
}
}
// No upstream messages yet; one from a newer app is ignored
// rather than fatal, so the terminal keeps working.
Some(Frame::Json(_)) => {}
None => return Ok(()),
},
item = down.recv() => match item {
@@ -449,6 +516,10 @@ fn read_pane(mut feed: Box<dyn PaneFeed>, down: mpsc::Sender<Down>) {
return;
}
DaemonMsg::Error(message) => Down::Event(PaneEvent::Error { message }),
DaemonMsg::Lease(holder) => Down::Event(PaneEvent::Lease {
held: holder.is_some(),
refused: None,
}),
_ => continue,
};
if down.blocking_send(item).is_err() {
@@ -457,6 +528,17 @@ fn read_pane(mut feed: Box<dyn PaneFeed>, down: mpsc::Sender<Down>) {
}
}
/// The phone's grid as the pty takes it, within sane bounds. Cell pixels are
/// nominal: nothing on the phone draws images at the pane's pixel size.
fn lease_size(size: GridSize) -> WinSize {
WinSize {
cols: size.cols.clamp(20, 500),
rows: size.rows.clamp(5, 300),
cell_w: 8,
cell_h: 16,
}
}
fn agent_event(agent: Option<CLIAgent>, status: Option<&AgentSessionState>) -> PaneEvent {
PaneEvent::Agent {
agent: agent.map(|agent| {
+119 -9
View File
@@ -11,11 +11,13 @@ use iroh::Endpoint;
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, Remote};
use tty7_core::daemon::protocol::{DaemonMsg, LeaseRequest, WinSize};
use tty7_gateway::serve::{self, Backend, PaneFeed, PaneLeases, Remote};
use tty7_gateway::state::State;
use tty7_mobile_client::{PaneItem, Session};
use tty7_mobile_proto::{ALPN, ControlEvent, GridSize, PairCode, PaneEvent, TabCreated};
use tty7_mobile_proto::{
ALPN, ControlEvent, GridSize, PairCode, PaneEvent, PaneRequest, TabCreated,
};
const WAIT: Duration = Duration::from_secs(10);
@@ -24,6 +26,8 @@ const WAIT: Duration = Duration::from_secs(10);
struct FakeMachine {
machine: Machine,
typed: Mutex<Option<std_mpsc::Sender<Vec<u8>>>>,
/// Every lease request that reached the "daemon".
leased: Arc<Mutex<Vec<LeaseRequest>>>,
}
impl FakeMachine {
@@ -43,6 +47,7 @@ impl FakeMachine {
panes,
},
typed: Mutex::new(None),
leased: Arc::default(),
}
}
}
@@ -96,7 +101,15 @@ impl Backend for FakeMachine {
};
let (tx, rx) = std_mpsc::channel();
*self.typed.lock().unwrap() = Some(tx);
let (answer, answers) = std_mpsc::channel();
// The remote named "old" runs a tty7 from before leases.
let leases = (machine != Some("old")).then(|| FakeLeases {
answer,
seen: self.leased.clone(),
});
Ok(Box::new(FakeFeed {
leases,
answers,
replay: vec![
DaemonMsg::Size(WinSize {
cols: 100,
@@ -144,16 +157,50 @@ impl Backend for FakeMachine {
struct FakeFeed {
replay: Vec<DaemonMsg>,
typed: std_mpsc::Receiver<Vec<u8>>,
leases: Option<FakeLeases>,
answers: std_mpsc::Receiver<DaemonMsg>,
}
/// Answers a lease the way the daemon does: the size, then who holds it.
struct FakeLeases {
answer: std_mpsc::Sender<DaemonMsg>,
seen: Arc<Mutex<Vec<LeaseRequest>>>,
}
impl PaneLeases for FakeLeases {
fn send(&mut self, request: LeaseRequest) -> io::Result<()> {
self.seen.lock().unwrap().push(request.clone());
let replies = match request {
LeaseRequest::Take { size, by } => {
vec![DaemonMsg::Size(size), DaemonMsg::Lease(Some(by))]
}
_ => vec![DaemonMsg::Lease(None)],
};
for reply in replies {
self.answer.send(reply).map_err(io::Error::other)?;
}
Ok(())
}
}
impl PaneFeed for FakeFeed {
fn leases(&mut self) -> Option<Box<dyn PaneLeases>> {
self.leases
.take()
.map(|l| Box::new(l) as Box<dyn PaneLeases>)
}
fn recv(&mut self, wait: Duration) -> io::Result<Option<DaemonMsg>> {
if !self.replay.is_empty() {
return Ok(Some(self.replay.remove(0)));
}
match self.typed.recv_timeout(wait) {
if let Ok(answer) = self.answers.try_recv() {
return Ok(Some(answer));
}
match self.typed.recv_timeout(wait.min(Duration::from_millis(20))) {
Ok(bytes) if bytes == b"exit\r" => Ok(Some(DaemonMsg::Exited { code: Some(0) })),
Ok(bytes) => Ok(Some(DaemonMsg::Output(bytes))),
// Short waits, so an answer queued meanwhile is not held up.
Err(std_mpsc::RecvTimeoutError::Timeout) => Ok(None),
Err(e) => Err(io::Error::other(e)),
}
@@ -162,6 +209,7 @@ impl PaneFeed for FakeFeed {
struct Rig {
_dir: tempfile::TempDir,
machine: Arc<FakeMachine>,
state: State,
gateway: Endpoint,
phone: Endpoint,
@@ -170,6 +218,7 @@ struct Rig {
impl Rig {
async fn new() -> Rig {
let dir = tempfile::tempdir().unwrap();
let machine = Arc::new(FakeMachine::new());
let state = State::open(dir.path().join("mobile")).unwrap();
let gateway = Endpoint::builder(presets::Minimal)
.secret_key(state.secret_key().unwrap())
@@ -177,14 +226,11 @@ impl Rig {
.bind()
.await
.unwrap();
tokio::spawn(serve::run(
gateway.clone(),
state.clone(),
Arc::new(FakeMachine::new()),
));
tokio::spawn(serve::run(gateway.clone(), state.clone(), machine.clone()));
let phone = Endpoint::builder(presets::Minimal).bind().await.unwrap();
Rig {
_dir: dir,
machine,
state,
gateway,
phone,
@@ -325,6 +371,70 @@ async fn typing_that_fails_is_reported_not_dropped() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() {
let rig = Rig::new().await;
let session = within(rig.paired()).await;
let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap();
within(screen.next()).await.unwrap(); // size
within(screen.next()).await.unwrap(); // prompt
let size = GridSize { cols: 44, rows: 31 };
keys.request(&PaneRequest::TakeOver { size }).await.unwrap();
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Event(PaneEvent::Size { cols: 44, rows: 31 }))
);
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Event(PaneEvent::Lease {
held: true,
refused: None
}))
);
// The desktop is told who has it: the name the phone paired under.
assert!(matches!(
&rig.machine.leased.lock().unwrap()[0],
LeaseRequest::Take { size, by } if (size.cols, size.rows) == (44, 31) && by == "test phone"
));
keys.request(&PaneRequest::Release).await.unwrap();
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Event(PaneEvent::Lease {
held: false,
refused: None
}))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_take_over_the_pane_cannot_do_is_refused_and_the_pane_stays_up() {
let rig = Rig::new().await;
let session = within(rig.paired()).await;
let (mut keys, mut screen) = within(session.pane(Some("old"), 1)).await.unwrap();
within(screen.next()).await.unwrap(); // size
within(screen.next()).await.unwrap(); // prompt
let size = GridSize { cols: 44, rows: 31 };
keys.request(&PaneRequest::TakeOver { size }).await.unwrap();
let Some(PaneItem::Event(PaneEvent::Lease {
held: false,
refused: Some(reason),
})) = within(screen.next()).await.unwrap()
else {
panic!("expected a refusal");
};
assert!(reason.contains("can't be taken over"), "{reason}");
keys.input(b"ls\r").await.unwrap();
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Output(b"ls\r".to_vec())),
"typing still works"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_revoked_phone_is_cut_off_at_its_next_stream() {
let rig = Rig::new().await;
+62 -2
View File
@@ -9,6 +9,9 @@
//! ```
//!
//! `newtab <workspace-id> [cwd]` opens a shell in a new tab, as the app does.
//! `take <pane-id> <cols> <rows> <secs>` takes the pane over at that size,
//! runs `stty size` in it, holds it for `secs` (printing whatever the pane
//! says, a desktop Take Back included), then gives it back.
//! Keeps its key and host in `$TTY7_PROBE_DIR` (default `./probe-state`).
use std::path::PathBuf;
@@ -17,7 +20,7 @@ use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail};
use iroh::SecretKey;
use tty7_mobile_client::{Host, PaneItem, Session};
use tty7_mobile_proto::{ControlEvent, GridSize};
use tty7_mobile_proto::{ControlEvent, GridSize, PaneEvent, PaneRequest};
fn dir() -> PathBuf {
std::env::var_os("TTY7_PROBE_DIR")
@@ -126,7 +129,64 @@ async fn main() -> Result<()> {
created.tab_id, created.pane_id
);
}
_ => bail!("usage: probe pair <code> | tree | type <pane> <text> | newtab <ws> [cwd]"),
Some("take") => {
let usage = "take <pane> <cols> <rows> <secs>";
let pane: u64 = args.get(1).context(usage)?.parse()?;
let cols: u16 = args.get(2).context(usage)?.parse()?;
let rows: u16 = args.get(3).context(usage)?.parse()?;
let secs: u64 = args.get(4).context(usage)?.parse()?;
let session = Session::connect(&endpoint, &host()?).await?;
let (mut keys, mut screen) = session.pane(None, pane).await?;
// Prints events as they come and collects output, for `wait`.
async fn watch(
screen: &mut tty7_mobile_client::PaneReader,
wait: Duration,
) -> Result<String> {
let mut out = Vec::new();
let until = Instant::now() + wait;
while let Ok(item) = tokio::time::timeout_at(until.into(), screen.next()).await {
match item? {
Some(PaneItem::Output(b)) => out.extend(b),
Some(PaneItem::Event(
e @ (PaneEvent::Size { .. } | PaneEvent::Lease { .. }),
)) => {
println!(" event: {e:?}")
}
Some(PaneItem::Event(_)) => {}
None => {
println!(" stream ended");
break;
}
}
}
Ok(String::from_utf8_lossy(&out).into_owned())
}
println!("replay:");
watch(&mut screen, Duration::from_millis(800)).await?;
println!("take over at {cols}x{rows}:");
keys.request(&PaneRequest::TakeOver {
size: GridSize { cols, rows },
})
.await?;
watch(&mut screen, Duration::from_millis(800)).await?;
keys.input(b"stty size\r").await?;
let out = watch(&mut screen, Duration::from_millis(1000)).await?;
println!(
"stty size said: {:?}",
out.lines()
.filter(|l| l.trim().chars().all(|c| c.is_ascii_digit() || c == ' ')
&& !l.trim().is_empty())
.collect::<Vec<_>>()
);
println!("holding for {secs}s:");
watch(&mut screen, Duration::from_secs(secs)).await?;
println!("release:");
keys.request(&PaneRequest::Release).await?;
watch(&mut screen, Duration::from_millis(800)).await?;
}
_ => bail!(
"usage: probe pair <code> | tree | type <pane> <text> | newtab <ws> [cwd] | take <pane> <cols> <rows> <secs>"
),
}
endpoint.close().await;
Ok(())
+7 -1
View File
@@ -15,7 +15,8 @@ use iroh_mdns_address_lookup::MdnsAddressLookup;
use serde::{Deserialize, Serialize};
use tty7_mobile_proto::{
ALPN, ControlEvent, ControlRequest, Frame, GridSize, MDNS_SERVICE, Open, OpenReply,
PROTOCOL_VERSION, PairCode, PaneEvent, TabCreated, read_frame, write_bytes, write_msg,
PROTOCOL_VERSION, PairCode, PaneEvent, PaneRequest, TabCreated, read_frame, write_bytes,
write_msg,
};
/// How long to wait for a gateway to answer an [`Open`].
@@ -275,6 +276,11 @@ impl PaneWriter {
Ok(())
}
pub async fn request(&mut self, request: &PaneRequest) -> Result<()> {
write_msg(&mut self.send, request).await?;
Ok(())
}
pub fn close(mut self) {
let _ = self.send.finish();
}
+43 -4
View File
@@ -58,10 +58,10 @@ pub enum Open {
/// Watch one pane and type into it.
///
/// The gateway observes the pane rather than attaching to it: an attach
/// would resize the pane to the phone's screen and reflow it under the
/// 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.
/// 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. The pane keeps the desktop's size unless the phone asks for its
/// own with [`PaneRequest::TakeOver`].
Pane {
pane_id: u64,
/// The [`RemoteView::key`] of the remote machine the pane lives on;
@@ -130,6 +130,19 @@ pub enum ControlEvent {
Error { message: String },
}
/// Phone → gateway on a pane stream, as JSON frames beside the keystrokes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PaneRequest {
/// Run the pane at the phone's size while this stream is open, or change
/// the size it runs at. The desktop keeps its window, is told the phone
/// has the pane, and can take it back; closing the stream gives it back.
/// Answered with [`PaneEvent::Lease`].
TakeOver { size: GridSize },
/// Give the pane back to the desktop's size.
Release,
}
/// Gateway → phone on a pane stream, beside the [`KIND_BYTES`] output frames.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -153,6 +166,14 @@ pub enum PaneEvent {
Error {
message: String,
},
/// Whether the pane runs at this phone's size. `held: false` after a
/// take-over means it ended: released, taken back on the desktop, or taken
/// over by another device. `refused` says why a take-over did not start.
Lease {
held: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
refused: Option<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
@@ -497,6 +518,24 @@ mod tests {
assert_eq!(json, serde_json::json!("waiting"));
}
#[test]
fn take_over_tags_are_the_ones_the_app_matches() {
let ask = serde_json::to_value(PaneRequest::TakeOver {
size: GridSize { cols: 50, rows: 30 },
})
.unwrap();
assert_eq!(
ask,
serde_json::json!({"type": "take_over", "size": {"cols": 50, "rows": 30}})
);
let held = serde_json::to_value(PaneEvent::Lease {
held: true,
refused: None,
})
.unwrap();
assert_eq!(held, serde_json::json!({"type": "lease", "held": true}));
}
#[test]
fn a_new_tab_asks_with_only_a_workspace() {
let open: Open =
+2 -2
View File
@@ -325,7 +325,7 @@ The ⋯ drops a 232px raised panel, 14px corners, Float shadow, from the top-rig
Under a machine's title: an 8px dot plus words, 15px ink-2 with tabular numerals. "Direct · 4 ms" (done green), "Relay · n ms" (waiting amber), "Connecting…" (ink-3, breathing), "Offline" (danger). The terminal header repeats it compactly as a 7px dot and a 600-weight word ("Live", "Connecting", "Offline", "Closed").
### Terminal screen and keybar (signature)
The header is a tinted bar with back chevron, small avatar, title over the state line and Hack cwd, and a single fit/zoom toggle shown only when the pane is too wide to read. The pane keeps the desktop's cols×rows; the font shrinks to fit, or holds at 11px and pans to follow the cursor. Errors float in as a raised banner over the top of the pane; a dropped connection is retried on its own (1s, 2s, 4s … 15s, and at once when the network comes back), with the last good screen left up under a "Reconnecting…" banner until the new replay replaces it. A copy key beside fit/zoom lays the whole buffer out as a plain page over the pane, wrapped to the phone and rejoined where the terminal wrapped, so the phone's own selection handles and Copy work on it. The keybar is a horizontally scrolling row of keycaps (esc, tab, ⇧tab, ctrl, ^C, arrows, paste, | / ~ -) fading out at the right edge, plus two fixed accent keys: compose and keyboard. Keys press to 0.94 scale in cell-press; the ctrl latch and an open compose box fill their key accent. The compose box docks on the keybar in the same surface: a pill field (16px system face, so iOS never zooms on focus) growing to about five lines, and a round accent send button. Send types the text, then Enter; several lines go in as one bracketed paste where the program asked for it. An agent's pane opens on the box with the keyboard down, so the screen is read before anything is typed.
The header is a tinted bar with back chevron, small avatar, title over the state line and Hack cwd, and a single fit/zoom toggle shown only when the pane is too wide to read. The pane keeps the desktop's cols×rows; the font shrinks to fit, or holds at 11px and pans to follow the cursor. Errors float in as a raised banner over the top of the pane; a dropped connection is retried on its own (1s, 2s, 4s … 15s, and at once when the network comes back), with the last good screen left up under a "Reconnecting…" banner until the new replay replaces it. A phone key beside it takes the pane over: it fills tint while held, the pane runs at the phone's grid at 11px with nothing to fit or pan, and the grid follows the keyboard and rotation once they settle. When the desktop takes it back, the banner says so with "Take over again"; taking over again is always a tap, never automatic. A copy key beside fit/zoom lays the whole buffer out as a plain page over the pane, wrapped to the phone and rejoined where the terminal wrapped, so the phone's own selection handles and Copy work on it. The keybar is a horizontally scrolling row of keycaps (esc, tab, ⇧tab, ctrl, ^C, arrows, paste, | / ~ -) fading out at the right edge, plus two fixed accent keys: compose and keyboard. Keys press to 0.94 scale in cell-press; the ctrl latch and an open compose box fill their key accent. The compose box docks on the keybar in the same surface: a pill field (16px system face, so iOS never zooms on focus) growing to about five lines, and a round accent send button. Send types the text, then Enter; several lines go in as one bracketed paste where the program asked for it. An agent's pane opens on the box with the keyboard down, so the screen is read before anything is typed.
### Empty and loading states
Empty: centred, 56px top padding, the Duo logo mark (76px) or a 64px accent tile, a 22px headline, a 16px ink-2 sentence, and at most one primary button. Loading: three skeleton rows with hairline avatars and 12px bones breathing at 1.6s.
@@ -350,4 +350,4 @@ Every screen change is a push or pop via View Transitions: 0.44s on `cubic-bezie
- **Don't** use working blue, waiting amber or done green for anything but state, and don't set text in them on a cell; use the status inks.
- **Don't** use ink-3 for text; it is for chevrons and idle dots.
- **Don't** uppercase labels or add eyebrow text above titles; group titles are 13px/600 sentence case.
- **Don't** resize a pane to the phone; it keeps the desktop's size and the phone fits or pans.
- **Don't** resize a pane to the phone unasked; it keeps the desktop's size and the phone fits or pans, until the user takes it over.
+6 -3
View File
@@ -37,6 +37,9 @@ connects peer-to-peer over iroh by public key, end-to-end encrypted.
title, cwd, and agent status, under the workspace it belongs to.
- A pane is **observed, not attached**: it keeps the desktop's size (cols × rows), and
the phone fits that width by shrinking the font. Input goes in beside the desktop.
- **Take over** runs the pane at the phone's size until the phone lets go or leaves. The
desktop keeps its window, says the phone has the pane, and can **Take Back** at any
time; only that explicit button takes it back, never typing on the desktop.
- Connection state matters and is shown: direct vs relay path, round-trip time,
connecting, offline.
@@ -48,8 +51,7 @@ connects peer-to-peer over iroh by public key, end-to-end encrypted.
for native selection.
- Forget a machine (requires re-pairing).
- No framework: vanilla TypeScript with a tiny DOM helper; xterm.js for the terminal.
- Not yet: QR scanning, push notifications, keychain storage, taking over a pane at the
phone's size.
- Not yet: QR scanning, push notifications, keychain storage.
## Brand Commitments
@@ -72,4 +74,5 @@ metrics to show and none should be invented.
2. Same truth as the desktop: names, grouping, and status mean exactly what they mean
on the desktop app.
3. Honest connection state: the user always knows whether keystrokes will land.
4. Never take the pane away from the desktop.
4. Never take the pane away from the desktop: a take-over only borrows its size, and the
desktop can always take it back.
+9 -4
View File
@@ -13,9 +13,15 @@ phone (Tauri: WebView + Rust) ──iroh──▶ tty7-gateway ──local so
- **Protocol**: `crates/tty7-mobile-proto`. The phone never speaks the daemon's own
protocols. The gateway exposes a small vocabulary: pair, a live tree of workspaces, tabs,
panes and agent status, and one stream per open pane.
- **Panes are observed, not attached.** A phone never resizes a pane or takes it away from
the desktop window showing it. Keystrokes go in beside the observer (`SendInput`). The
terminal keeps the desktop's size, and the app shrinks the font to fit the width.
- **Panes are observed, not attached.** A phone never takes a pane away from the desktop
window showing it. Keystrokes go in beside the observer (`SendInput`). The terminal keeps
the desktop's size, and the app shrinks the font to fit the width.
- **Take over.** The phone button runs the pane at the phone's grid instead: a size
lease the daemon holds for the observer (`ClientMsg::Lease`, feature `size-lease`). The
desktop window keeps its grid, shows "In use on <phone>" with **Take Back**, and a resize
there is remembered for when the lease ends. It ends when the phone lets go, when the
desktop takes it back, or when the phone's stream closes — a phone that just drops off
gives the pane back once the connection times out.
- **Auth**: pairing with a one-time code, valid 10 minutes, single use. After that the
gateway admits only the phone keys on its device list (`<config dir>/mobile/devices.json`).
@@ -109,5 +115,4 @@ devices on local networks" once. On iOS the app declares it in
but APNs/FCM delivery needs a small native plugin and a push relay.
- **Keychain / Keystore** for the phone's key. It lives in the app's private data
directory today.
- **Take over.** Attaching a pane at the phone's size, for when the desktop isn't in use.
- **A self-hosted iroh relay** for production, instead of n0's public ones.
+38 -7
View File
@@ -19,7 +19,7 @@ use tauri::ipc::{Channel, InvokeResponseBody};
use tauri::{Manager, State};
use tokio::sync::{Mutex, OnceCell, mpsc};
use tty7_mobile_client::{ControlSender, Host, LinkInfo, PaneItem, Session};
use tty7_mobile_proto::{ControlEvent, GridSize, PaneEvent, TabCreated, Tree};
use tty7_mobile_proto::{ControlEvent, GridSize, PaneEvent, PaneRequest, TabCreated, Tree};
/// How long output is gathered before it crosses to the WebView. One frame at
/// 60 Hz: shorter buys nothing on screen, longer starts to feel like lag.
@@ -37,12 +37,18 @@ struct AppState {
sessions: Mutex<HashMap<String, Session>>,
/// The refresh half of each machine's control stream, by host id.
controls: Mutex<HashMap<String, ControlSender>>,
panes: Mutex<HashMap<u32, mpsc::UnboundedSender<Vec<u8>>>>,
panes: Mutex<HashMap<u32, mpsc::UnboundedSender<Up>>>,
next_pane: AtomicU32,
}
type CmdResult<T> = Result<T, String>;
/// What goes up an open pane's stream, in the order the frontend sent it.
enum Up {
Keys(Vec<u8>),
Request(PaneRequest),
}
fn err(e: impl std::fmt::Display) -> String {
// `{:#}` walks anyhow's chain, so "could not reach studio: no route"
// reaches the user rather than just the outermost context.
@@ -251,7 +257,7 @@ async fn pane_open(
.await
.map_err(err)?;
let handle = state.next_pane.fetch_add(1, Ordering::Relaxed);
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Up>();
state.panes.lock().await.insert(handle, input_tx);
// A write that fails is said on the pane's channel, and the handle goes,
@@ -259,8 +265,12 @@ async fn pane_open(
let refused = on_output.clone();
let app_state = state.inner().clone();
tokio::spawn(async move {
while let Some(bytes) = input_rx.recv().await {
if let Err(e) = keys.input(&bytes).await {
while let Some(up) = input_rx.recv().await {
let sent = match &up {
Up::Keys(bytes) => keys.input(bytes).await,
Up::Request(request) => keys.request(request).await,
};
if let Err(e) = sent {
app_state.panes.lock().await.remove(&handle);
let event = PaneEvent::Error {
message: format!("typing didn't reach the computer: {}", err(e)),
@@ -346,7 +356,27 @@ async fn pane_open(
async fn pane_input(state: State<'_, Arc<AppState>>, handle: u32, data: String) -> CmdResult<()> {
match state.panes.lock().await.get(&handle) {
Some(tx) => tx
.send(data.into_bytes())
.send(Up::Keys(data.into_bytes()))
.map_err(|_| "the pane has closed".to_string()),
None => Err("the pane has closed".into()),
}
}
/// Runs the pane at `size` — the phone's screen — or, with none, gives it
/// back to the desktop's. Answered on the pane's channel with a `lease` event.
#[tauri::command]
async fn pane_lease(
state: State<'_, Arc<AppState>>,
handle: u32,
size: Option<GridSize>,
) -> CmdResult<()> {
let request = match size {
Some(size) => PaneRequest::TakeOver { size },
None => PaneRequest::Release,
};
match state.panes.lock().await.get(&handle) {
Some(tx) => tx
.send(Up::Request(request))
.map_err(|_| "the pane has closed".to_string()),
None => Err("the pane has closed".into()),
}
@@ -376,7 +406,8 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
hosts, pair, forget, watch, refresh, tab_new, pane_open, pane_input, pane_close
hosts, pair, forget, watch, refresh, tab_new, pane_open, pane_input, pane_lease,
pane_close
])
.run(tauri::generate_context!())
.expect("error while running tty7");
+7 -1
View File
@@ -72,7 +72,9 @@ export type PaneEvent =
| { type: "cwd"; path: string }
| { type: "agent"; agent: AgentView | null }
| { type: "exited"; code: number | null }
| { type: "error"; message: string };
| { type: "error"; message: string }
/** Whether the pane runs at this phone's size; `refused` says why not. */
| { type: "lease"; held: boolean; refused?: string | null };
export const hosts = () => invoke<Host[]>("hosts");
@@ -122,4 +124,8 @@ export const tabNew = (
export const paneInput = (handle: number, data: string) =>
invoke<void>("pane_input", { handle, data });
/** Run the pane at `size`, the phone's grid; `null` gives it back. */
export const paneLease = (handle: number, size: { cols: number; rows: number } | null) =>
invoke<void>("pane_lease", { handle, size });
export const paneClose = (handle: number) => invoke<void>("pane_close", { handle });
+1
View File
@@ -28,6 +28,7 @@ export const icon = {
paste: stroke(
`<rect x="6" y="4.5" width="12" height="16" rx="2"/><path d="M9.5 4.5V3.5h5v1M9.5 10.5h5M9.5 14.5h5"/>`,
),
phone: stroke(`<rect x="6.5" y="2.5" width="11" height="19" rx="2.6"/><path d="M10.5 18.5h3"/>`),
copy: stroke(`<rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2.2"/><path d="M15.5 8.5V6.2A2.2 2.2 0 0 0 13.3 4H6.2A2.2 2.2 0 0 0 4 6.2v7.1a2.2 2.2 0 0 0 2.2 2.2h2.3"/>`),
compose: stroke(`<path d="M20 11.5a8 8 0 0 1-11.6 7.1L4 20l1.4-4.2A8 8 0 1 1 20 11.5Z"/><path d="M8.5 10h7M8.5 13.5h4.5"/>`),
send: stroke(`<path d="M12 19V5.5M6 11l6-6 6 6"/>`, 2.4),
+105 -1
View File
@@ -836,6 +836,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
const sub = h("span", { class: "term-sub" }, state, cwd);
const zoom = h("button", { class: "nav-icon", ariaLabel: "Fit the whole width" }, ico("fit"));
const select = h("button", { class: "nav-icon", ariaLabel: "Select text" }, ico("copy"));
const take = h("button", { class: "nav-icon", ariaLabel: "Use this phone's screen size" }, ico("phone"));
let head = avatar(pane.agent, "avatar small");
const bar = h(
"header",
@@ -851,7 +852,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
head,
h("div", { class: "term-titles" }, h("span", { class: "term-title" }, title), sub),
),
h("div", { class: "nav-trail" }, select, zoom),
h("div", { class: "nav-trail" }, select, take, zoom),
);
const screenEl = h("div", { class: "term" });
const banner = h("div", { class: "term-banner-slot" });
@@ -915,6 +916,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
let composing = false;
// The pane exited: nothing to reconnect to.
let ended = false;
// The phone's size: `wanted` is what the user asked for, `leased` what
// the daemon confirmed, `sent` the grid last asked for.
let wanted = false;
let leased = false;
let releasing = false;
let sent = "";
let ctrlKey: HTMLButtonElement | null = null;
// Typing that does not get through is said, never dropped quietly: the
@@ -1057,6 +1064,14 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
return width <= 0 ? READABLE_PX : Math.min(14, Math.floor((width / cols / CELL_EM) * 10) / 10);
};
const fit = () => {
// Taken over, the pane is exactly the phone's width at the readable
// size: nothing to shrink, nothing to pan.
if (leased) {
zoom.hidden = true;
term.options.fontSize = READABLE_PX;
screenEl.classList.remove("panning");
return;
}
const fitted = fittedSize();
const cramped = fitted < READABLE_PX;
zoom.hidden = !cramped;
@@ -1078,6 +1093,84 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
readable = !readable;
fit();
};
// Taking the pane over (the lets are with the others above): it runs at
// the phone's grid while this screen is open, and the desktop keeps its
// window and can take it back.
const takeGrid = () => {
const width = screenEl.clientWidth - 12;
const height = screenEl.clientHeight - 16;
// A row's height, measured off what xterm drew and scaled to the size
// the pane will be read at; the metrics guess only before the first draw.
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
const now = term.options.fontSize ?? READABLE_PX;
const rowH =
drawn && term.rows && drawn.clientHeight
? ((drawn.clientHeight / term.rows) * READABLE_PX) / now
: READABLE_PX * 1.18;
return {
cols: Math.max(20, Math.floor(width / (READABLE_PX * CELL_EM))),
rows: Math.max(5, Math.floor(height / rowH)),
};
};
const askLease = () => {
if (handle === null || !live || !wanted) return;
const grid = takeGrid();
const key = `${grid.cols}x${grid.rows}`;
if (leased && key === sent) return;
sent = key;
api.paneLease(handle, grid).catch(() => {});
};
const showTake = () => {
take.classList.toggle("on", wanted);
take.ariaLabel = wanted ? "Give the pane back to the desktop's size" : "Use this phone's screen size";
};
take.onclick = () => {
wanted = !wanted;
showTake();
if (wanted) {
sent = "";
askLease();
} else if (handle !== null && leased) {
releasing = true;
api.paneLease(handle, null).catch(() => {});
}
};
// The keyboard coming up or the phone turning changes the grid; asked
// for once it settles, not on every step of the animation.
let regrid: number | undefined;
const regridSoon = () => {
clearTimeout(regrid);
if (wanted) regrid = window.setTimeout(askLease, 250);
};
const leaseEvent = (held: boolean, refused: string | null | undefined) => {
const was = leased;
leased = held;
if (held) {
banner.replaceChildren();
} else {
sent = "";
const ours = releasing;
releasing = false;
if (refused) {
wanted = false;
showBanner(sentence(refused));
} else if (was && !ours) {
// Nobody here let go: the desktop took it back, or another device
// took it over. Asking again is one tap, not automatic.
wanted = false;
showBanner("The desktop took this pane back.", {
label: "Take over again",
run: () => {
banner.replaceChildren();
take.click();
},
});
}
showTake();
}
fit();
};
term.onCursorMove(follow);
// The whole buffer, scrollback included, as lines of text. A row the
// terminal wrapped joins the one before it, so a long URL or path comes
@@ -1115,6 +1208,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
term.options.theme = terminalTheme();
};
window.addEventListener("resize", fit);
window.addEventListener("resize", regridSoon);
darkScheme.addEventListener("change", retheme);
const setState = (cls: string, label: string) => {
@@ -1189,6 +1283,9 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
case "error":
offline(event.message);
break;
case "lease":
leaseEvent(event.held, event.refused);
break;
}
},
);
@@ -1198,6 +1295,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
}
handle = opened;
live = true;
// A new stream starts at the desktop's size; the lease the old one
// held ended with it. Asked for again if it is still wanted.
leased = false;
sent = "";
askLease();
retry.reset();
banner.replaceChildren();
setState("live", "Live");
@@ -1221,6 +1323,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
retry.cancel();
window.removeEventListener("online", online);
window.removeEventListener("resize", fit);
window.removeEventListener("resize", regridSoon);
clearTimeout(regrid);
darkScheme.removeEventListener("change", retheme);
if (handle !== null) api.paneClose(handle);
term.dispose();