Mobile: an iOS pass from a user's seat (#1075)

* fix(mobile): draw a prompt's icons

Prompts built with Powerline and Nerd Font glyphs (branch, folder, git
status) showed as empty boxes: a phone has no such font to fall back on.
Ship the symbols-only Nerd Font, one cell wide, behind Hack.

* fix(gateway): send a phone the screen the desktop shows

A phone opening a pane was sent its raw output, ring segment by segment,
and read it with xterm.js. Across the pane's resizes that emulator wraps
and reflows unlike the desktop's, so shells' SIGWINCH redraws landed on
the wrong rows: prompts twice, old output under new prompts. Leaving a
full-screen program left stale lines behind the same way.

The gateway now reads the pane with the desktop's emulator and, on
opening, after a resize, and when the main screen comes back, sends the
phone that screen drawn afresh: scrollback, colours, cursor and modes.
Output in between still goes straight through.

* fix(mobile): the message box rides the keyboard on iOS

On iOS 26 the WebView, run edge to edge, does not say how much the
keyboard covers, so the message box and the dock stayed under it. The
app now hears the keyboard's frame natively and lays out above its top
edge, on iOS as on Android.

- WebKit's previous/next/Done bar over the keyboard is gone; a tap on
  nothing in particular, or on the pane, puts the keyboard away.
- Return sends however the keyboard delivers it.
- A shell's message box no longer capitalises or corrects: ls stays ls.
  An agent's keeps both, as a chat would.

* fix(gateway): a tab opened from the phone joins its repository's group

The desktop files a tab under its repository as its sidebar draws it, so
a tab opened from the phone while that window was out of sight stayed in
Ungrouped. The gateway now reads, for a tab the desktop has not filed,
which repository its directory is in, the way the desktop would.

* feat(mobile): close a tab from the phone

Swipe a tab's row left for Close, or use Close tab in a pane's menu. The
tab goes where the desktop puts a closed tab, onto the workspace's
recently-closed list, so tty7 on the computer can reopen it. A tab whose
agent is at work asks first.

A new CloseTab stream carries it; a machine that keeps no closed tabs
closes it for good and its panes are ended, as tty7 tab close does.

* feat(mobile): a machine's list reads at a glance, and the app opens where it was left

- The app reopens the machine it was last on; leaving for the list of
  machines is what makes it start there.
- A tab named after its directory goes by the directory's own name, its
  parent underneath, rather than a path cut off at the end that matters.
  An agent's row says where it works too.
- New tab asks which folder, from those the workspace has tabs in, the
  tab in front first; Workspace is asked only when there is a choice.
- The back button names where it goes in full until the title folds in.
- Search fields have a clear button; a few machines need no search.
- Rows swipe left to close their tab, and a pane's menu has Close tab.

* fix(mobile): agents' marks draw, and Return sends a sentence

- Claude Code's ⏺ and ⎿, ⏵⏵, ⚙, ⏰, ✔, braille spinners and the like
  showed as empty boxes: the phone's fonts do not reach a canvas for
  them. Noto's symbols and outline emoji ship, cut down to those blocks
  (about 80 KB), behind Hack and the prompt icons.
- The phone's keyboard reports Shift with Return whenever it has armed
  a capital — at the start, after a full stop — so a message ending in a
  sentence got a new line instead of being sent. Shift-Return starts a
  line only on a keyboard with a real Shift; Return delivered as typed
  text sends as well.

* fix(mobile): an agent's choices become buttons wherever they are on screen

The answers were looked for in the bottom 24 rows of the terminal, which
can be taller than the pane: Claude Code's question sat higher up and no
buttons came. The whole screen is read now.

* fix(mobile): a pane on its side keeps room for the pane

In landscape the bar, the key row, the page dots and the message box
left three lines of terminal. The bar slims and the keys and the message
box share a row, for eight. A message box's hint stays on one line, and
a row's hidden Close names its tab to VoiceOver.

* fix(mobile): Settings names the ⋯ menu with its own mark, and asks before clearing history

The phone's text font has no ⋯, so the note showed an empty box; the
menu button's icon stands in. Clearing the message history, which cannot
be undone, now asks first.

* fix(mobile): Changes shows the changed files, not just the untracked ones

The files' blocks shrank to nothing under a long list of untracked ones:
flex items that clip their overflow give up their height. They keep it
now, and the sheet scrolls. The untracked list sits flush and compact,
the top line totals the change, and lock files start folded.

* feat(mobile): pull a sheet down to put it away

Sheets showed a grabber but only closed from their × or the dimmed screen
around them. Pulled down — from the top, or from anywhere while their
content is scrolled to the start — they go; let go short of the way,
they settle back.

* fix(mobile): a machine that went away says so within seconds

The connection kept QUIC's default 30-second idle timeout, so a laptop
gone to sleep stayed "Direct · 1 ms" on the phone for half a minute, and
typing into it went nowhere without a word. The phone now gives up on a
silent machine after 12 seconds, a few keep-alives missed, says it
cannot reach it, and reconnects on its own once it is back.

* feat(mobile): keys and answers are felt

The key row, an agent's answer buttons and Send give the light tap the
phone's own keyboard does, and a row swiped far enough to open ticks as
it passes the point. Through the Tauri haptics plugin; nothing is felt
where there is no engine for it.

* feat(mobile): pair by pointing the phone's camera at the desktop's code

The pairing QR code reads tty7pair:…, and the app now answers to that
scheme: the phone's own Camera offers to open it in tty7, which pairs
straight away, as its Scan button does. No hunting for the button first.

* fix(settings): the pairing code can be read by the phone's camera

The phone app now opens from its tty7pair: link, so Show code says to
point the phone's camera at it first.

* fix(mobile): Android opens tty7pair: links too

The deep-link plugin's intent filter, written into the manifest by the
Android build.

* fix(mobile): pairing links without the deep-link plugin, cold launches included

The deep-link plugin's build script rewrites the iOS entitlements while
Xcode builds, which Xcode refuses ("modified during the build"), and a
link that launched the app was lost on the way in any case: UIKit hands
it to the first scene as it connects, and the event loop only passes on
links that come later.

- The tty7pair scheme is declared in Info.ios.plist and the Android
  manifest, and links are heard as Tauri's own Opened event.
- The scene delegate's connect is wrapped, before UIKit starts, to keep
  a launching link; the page asks for it once it listens, so neither
  order of arrival loses it.

* feat(mobile): another agent waiting or done says so over the pane you are in

Inside one pane, nothing told you another agent on the machine had
stopped for an answer or finished its turn; you had to back out and
look. The pane now watches the machine's tree too, and such a change
drops a card over the top of the pane, felt as it comes — the agent,
what it says or which tab — that opens that pane when tapped. What was
already so when the pane opened is not news.

* fix(mobile): with the lock on, the app switcher shows no panes

The phone keeps a picture of an app as it leaves, for its app switcher.
With the lock on, that picture showed whatever pane was open. The app is
now covered as it goes, and uncovered — or locked — as it comes back.

* fix(mobile): turning the lock off takes Face ID too

Anyone holding the phone, unlocked, could switch the app lock off from
Settings. Off now asks for Face ID or the passcode, as on does.

* fix(mobile): a pane that is not running says so, with a way on

After the machine's server restarted, panes no window had opened since
were gone, and the phone tried to watch them forever: "Daemon refused
Observe: no such pane 9. Reconnecting…". The gateway now marks panes
its server is not running; the list shows them as Not running, without
their last agent status, and opening one says what is going on and
offers a new tab in the same folder.

* feat(mobile): a workspace not on screen shows when an agent in it needs you

The switcher's chip for another workspace carries the status dot a
folded group does, so an agent waiting there is not hidden behind the
one you are looking at.

* fix(mobile): a new tab's agent starts, instead of waiting at the prompt

Opening a tab for Claude Code or Codex typed the command as soon as the
pane was live, while the shell was still printing its greeting; the
Enter was eaten and the command sat at the prompt unrun. It is typed once
the shell's output has gone quiet now, or after six seconds whatever.

Rows also say where a tab is the same way whatever it is named.

* fix(mobile): the first screen says how to pair in one step

With no machine paired yet, it now says where the code is on the
computer and that the phone's camera reads it.

* fix(mobile): a tap that closes a pane's menu does only that

Closing the ⋯ menu by tapping the pane also brought the keyboard up for
typing into it. The tap that puts a menu away is the menu's now.

* fix(mobile): second pass from a phone user's seat

- Chinese, Japanese and Korean text rendered as boxes in the terminal:
  the glyph atlas does not fall back past the web fonts, so the system's
  CJK fonts are named in the stack.
- A tab opened from the phone was drawn at the desktop's width first and
  then squeezed: the gateway now holds it at the phone's size from before
  the desktop hears of it, until the phone's own view takes over.
- A tab opened with no folder started wherever the server did; it starts
  at home, and the new-tab sheet offers Home.
- Going back to a machine showed a skeleton every time; its last tree is
  drawn at once and refreshed when the watch reports.
- Errors said the transport's chain, repeated: each part is said once, and
  an unreachable machine is explained in words.
- Wide panes say once that they can run at the phone's size; a pane taken
  over is taken over again on return.
- Back in a pane's history, a button jumps to the latest output.
- Find hides the key bar and message box and keeps the match clear of the
  bar; Copy keeps the pane's lines and offers Copy all.
- The message box pans a wide pane back to the cursor; Changes outside a
  repository says so plainly; machine rows count waiting agents; the back
  pill folds to a chevron with the title; filled reds use the system red.

* feat(mobile): attachments wait as chips beside the message box

A sent file used to drop its full temporary path into the box, five lines
of /var/folders/... underlined by the spell checker. It now waits as a
chip with its picture and name, can be taken back before sending, and its
path goes after the message when it is sent.

* fix(mobile): pairing says what went wrong, and leaves the code alone

The code field offered word suggestions over the keyboard; it is a code.
A wrong, expired or unreachable code is explained with what to do next
rather than the gateway's lower-case reason.

* feat(mobile): hold a tab's row for what can be done with it

A long press used to open the pane like a tap. It now brings up the
row's actions: open, open at the phone's size, Changes, a new tab in the
same folder, copy the folder's path, and close.

* fix(mobile): a pane on its way shows that it is coming

Opening a pane over a slow link left an empty screen until its replay
arrived. After a quarter second without it, three dots say it is coming.

* fix(mobile): agent menus, stopped panes and new tabs, as used

- An agent's arrow-key menu with no numbers (Claude Code's 'trust this
  folder?') now gets answer buttons too; picking one moves to it and
  presses Enter.
- A pane that is not running greys out its keys and message box instead
  of taking typing that goes nowhere.
- Suggestions match where a word starts, so 'ls' no longer offers every
  message that mentions tools.
- A new tab's starting size counted neither the status bar nor the home
  indicator, so its last rows sat under the key bar.
- A tab opened on the phone keeps the phone's size on later visits, so it
  follows the keyboard instead of hiding behind it.

* fix(mobile): another agent waiting is not missed, and dismissing is only that

- The card for another agent that needs you stays until it is answered or
  put away (it went after 15 seconds); a finished one still goes on its
  own. While any other agent waits, the back button carries a dot.
- The card's dismiss, a banner's action and Jump to latest took themselves
  away under the finger, and the same tap then reached the pane and
  brought up its keyboard; a clear cover takes that tap now.
- A row's Current tag no longer gets cut to 'C...' by a long name.

* fix(mobile): Changes lists a new folder once, by name

The untracked list walked into every new directory — a generated folder
filled the sheet with thirty truncated paths. New directories come as one
entry, as git status shows them, and each entry reads as a name with the
end of its folder beside it.

* fix(mobile): bar buttons answer a 44pt square

Back, More, Close and the pane's round buttons are drawn at 38pt and took
taps only there, under the 44pt a finger is owed. Each now answers a 44pt
square around the same drawing.
This commit is contained in:
l0ng-ai
2026-10-03 20:28:29 +08:00
committed by GitHub
parent 99b9327fab
commit 3990a05795
32 changed files with 2874 additions and 195 deletions
Generated
+1
View File
@@ -11638,6 +11638,7 @@ dependencies = [
name = "tty7-gateway"
version = "26.9.4"
dependencies = [
"alacritty_terminal",
"anyhow",
"clap",
"getrandom 0.3.4",
+3
View File
@@ -35,6 +35,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"
getrandom = "0.3"
# iroh takes its proxy as a `Url`.
url = "2"
# Reads a pane's output the way the desktop's window does, so a phone is sent
# the screen the desktop shows rather than bytes its own emulator re-wraps.
alacritty_terminal.workspace = true
# Draws the pairing code as a QR code right in the terminal.
qrcode = { version = "0.14", default-features = false }
+102 -8
View File
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use tty7_core::client::{ControlClient, PaneClient, PaneInput, PaneOutput};
use tty7_core::core::machine::Machine;
use tty7_core::core::machine::{Machine, TabId};
use tty7_core::daemon::control::{
ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, RouteInfo, WorkspaceId,
};
@@ -257,21 +257,37 @@ impl Backend for Daemon {
format!("no workspace {workspace_id}"),
)
})?;
let for_phone = size.is_some();
let size = size.map_or(NEW_TAB_SIZE, |s| WinSize {
cols: s.cols.clamp(20, 500),
rows: s.rows.clamp(5, 300),
..NEW_TAB_SIZE
});
let owner = workspace.to_string();
let session = self.panes_on(machine)?.spawn(
cwd.as_deref().map(PathBuf::from),
size,
None,
Some(owner.clone()),
Some(owner),
)?;
// A tab asked for with nowhere in mind starts at home, as a new
// window's does — not in whatever directory the server started in.
// A remote's own server knows its home; this one only knows this.
let start = match (cwd.as_deref(), machine) {
(Some(dir), _) => Some(PathBuf::from(dir)),
(None, None) => std::env::home_dir(),
(None, Some(_)) => None,
};
let session =
self.panes_on(machine)?
.spawn(start, size, None, Some(owner.clone()), Some(owner))?;
let pane = session.pane_id();
session.detach()?;
// The tab is about to reach the desktop, which lays every tab out at
// its window's size; the shell's first screen — a banner, a prompt —
// would be drawn that wide and then squeezed onto the phone. Held
// at the phone's size from before the desktop hears of it, until
// the phone's own view of it takes over.
if for_phone {
match self.observe(machine, pane) {
Ok(feed) => hold_for_phone(feed, size),
Err(e) => log::debug!("mobile gateway: holding new pane {pane}: {e}"),
}
}
let seed = PaneSeed {
pane,
cwd,
@@ -293,6 +309,52 @@ impl Backend for Daemon {
other => Err(unexpected("TabCreate", &other)),
}
}
fn running_panes(&self) -> Option<std::collections::HashSet<u64>> {
let panes = PaneClient::local().list().ok()?;
Some(
panes
.into_iter()
.filter(|p| p.alive)
.map(|p| p.pane_id)
.collect(),
)
}
/// As the desktop closes a tab: onto the workspace's recently-closed
/// list, its panes stopped with their screens kept, so it can be put
/// back. A machine that keeps no such list closes it for good, and its
/// panes are ended from here, as `tty7 tab close` does.
fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()> {
let invalid =
|what: &str| io::Error::new(io::ErrorKind::InvalidInput, format!("no {what}"));
let workspace: WorkspaceId = workspace_id
.parse()
.map_err(|_| invalid(&format!("workspace {workspace_id}")))?;
// A tab id is a UUID on the wire; it has no `FromStr` of its own.
let tab: TabId = serde_json::from_value(serde_json::Value::String(tab_id.to_string()))
.map_err(|_| invalid(&format!("tab {tab_id}")))?;
let remembered = ControlRequest::TabCloseRemembered {
workspace,
tab,
panes: Vec::new(),
};
let ended = match self.request_on(machine, remembered) {
Ok(_) => return Ok(()),
Err(e) if e.kind() == io::ErrorKind::Unsupported => {
match self.request_on(machine, ControlRequest::TabClose { workspace, tab })? {
ReplyOk::Panes(panes) => panes,
other => return Err(unexpected("TabClose", &other)),
}
}
Err(e) => return Err(e),
};
let panes = self.panes_on(machine)?;
for pane in ended {
panes.kill(pane)?;
}
Ok(())
}
}
struct Observed {
@@ -322,6 +384,38 @@ impl PaneFeed for Observed {
}
}
/// How long a new tab is held at the phone's size for the phone to open it.
const PHONE_HOLD: Duration = Duration::from_secs(10);
/// Takes the pane at `size` over `feed` and keeps it so on a thread of its
/// own, until the phone's stream takes the lease from it (the daemon tells
/// the one displaced), the pane ends, or [`PHONE_HOLD`] passes.
fn hold_for_phone(mut feed: Box<dyn PaneFeed>, size: WinSize) {
let Some(mut leases) = feed.leases() else {
return;
};
let take = LeaseRequest::Take {
size,
by: "a phone".to_string(),
};
if leases.send(take).is_err() {
return;
}
std::thread::spawn(move || {
let until = std::time::Instant::now() + PHONE_HOLD;
let mut held = false;
while std::time::Instant::now() < until {
match feed.recv(Duration::from_millis(250)) {
Ok(Some(DaemonMsg::Lease(Some(_)))) => held = true,
Ok(Some(DaemonMsg::Lease(None))) if held => return,
Ok(Some(DaemonMsg::Exited { .. })) | Err(_) => return,
_ => {}
}
}
drop(leases);
});
}
/// The observer connection's writing half, for leases.
struct Leases {
input: PaneInput,
+1
View File
@@ -7,6 +7,7 @@
//! never speaks the daemon's own protocols.
pub mod daemon;
mod mirror;
pub mod poller;
pub mod route;
pub mod serve;
+356
View File
@@ -0,0 +1,356 @@
//! A pane's screen as the desktop has it, drawn afresh for a phone.
//!
//! What the daemon hands an observer is the pane's raw output, ring segment by
//! ring segment, each at the size the pane was then. A desktop window plays
//! that back through alacritty and gets the screen it left. The phone plays it
//! through xterm.js, which wraps and reflows across those resizes in its own
//! way, so a shell's SIGWINCH redraws land on rows they were not meant for: a
//! prompt drawn twice, a program's output under the next prompt, half a logo
//! over yesterday's `ls`. The bytes are right; only the emulator that reads
//! them differs.
//!
//! So the gateway reads them with the desktop's own emulator, and the phone is
//! sent what that makes of them — the scrollback and the screen as plain
//! text and colours, the cursor, the modes that matter — at the moments its
//! own reading would part from the desktop's: on opening, after the pane
//! changes size, and when a full-screen program gives the main screen back.
//! Between those, output goes straight through, as before.
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor};
use std::fmt::Write as _;
/// How much history a fresh drawing carries: what the phone keeps itself
/// (main.ts `scrollback`).
const HISTORY: usize = 5000;
struct Size {
cols: usize,
rows: usize,
}
impl Dimensions for Size {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
pub struct Mirror {
term: Term<VoidListener>,
parser: Processor,
}
impl Mirror {
pub fn new(cols: u16, rows: u16) -> Mirror {
let config = Config {
scrolling_history: HISTORY,
..Config::default()
};
Mirror {
term: Term::new(config, &size(cols, rows), VoidListener),
parser: Processor::new(),
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.term.resize(size(cols, rows));
}
/// Reads output. True when it brought a full-screen program's exit: the
/// main screen is back, and the phone's copy of it is the one that went
/// stale while the program ran.
pub fn feed(&mut self, bytes: &[u8]) -> bool {
let was_alt = self.alt();
self.parser.advance(&mut self.term, bytes);
was_alt && !self.alt()
}
fn alt(&self) -> bool {
self.term.mode().contains(TermMode::ALT_SCREEN)
}
/// The whole picture, for a terminal of the same width to read from a
/// reset: history, screen, cursor and modes. The screen is written last
/// and the cursor placed relative to its end, so a terminal taller than
/// the pane shows it at the bottom with history above.
pub fn draw(&self) -> Vec<u8> {
let mut out = String::new();
// Everything back to the start, scrollback too.
out.push_str("\x1bc\x1b[3J");
let alt = self.alt();
if alt {
out.push_str("\x1b[?1049h");
}
let grid = self.term.grid();
let cols = grid.columns();
let rows = grid.screen_lines() as i32;
// The alternate screen keeps no history.
let history = if alt { 0 } else { grid.history_size() as i32 };
let mut pen = Pen::default();
for line in -history..rows {
let row = &grid[Line(line)];
let wrapped = row[Column(cols - 1)].flags.contains(Flags::WRAPLINE);
// Blank cells at the end of a line are left unwritten unless they
// carry a colour; a wrapped line is written whole, so the reader
// wraps it too and knows the two rows are one line.
let end = if wrapped {
cols
} else {
(0..cols)
.rposition(|c| !blank(&row[Column(c)]))
.map_or(0, |c| c + 1)
};
for c in 0..end {
let cell = &row[Column(c)];
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
pen.to(cell, &mut out);
out.push(cell.c);
if let Some(marks) = cell.zerowidth() {
out.extend(marks);
}
}
if line < rows - 1 && !wrapped {
pen.reset(&mut out);
out.push_str("\r\n");
}
}
pen.reset(&mut out);
let cursor = grid.cursor.point;
let up = rows - 1 - cursor.line.0;
if up > 0 {
let _ = write!(out, "\x1b[{up}A");
}
let _ = write!(out, "\x1b[{}G", cursor.column.0 + 1);
let mode = self.term.mode();
for (flag, set) in [
(TermMode::APP_CURSOR, "?1"),
(TermMode::BRACKETED_PASTE, "?2004"),
(TermMode::FOCUS_IN_OUT, "?1004"),
(TermMode::MOUSE_REPORT_CLICK, "?1000"),
(TermMode::MOUSE_DRAG, "?1002"),
(TermMode::MOUSE_MOTION, "?1003"),
(TermMode::SGR_MOUSE, "?1006"),
] {
if mode.contains(flag) {
let _ = write!(out, "\x1b[{set}h");
}
}
if mode.contains(TermMode::APP_KEYPAD) {
out.push_str("\x1b=");
}
if !mode.contains(TermMode::SHOW_CURSOR) {
out.push_str("\x1b[?25l");
}
out.into_bytes()
}
}
fn size(cols: u16, rows: u16) -> Size {
// A zero-sized grid panics inside alacritty's index arithmetic.
Size {
cols: usize::from(cols).max(2),
rows: usize::from(rows).max(1),
}
}
fn blank(cell: &Cell) -> bool {
cell.c == ' '
&& cell.bg == Color::Named(NamedColor::Background)
&& !cell
.flags
.intersects(Flags::INVERSE | Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
&& cell.zerowidth().is_none()
}
/// The SGR state written so far, so a run of like cells costs one sequence.
#[derive(Default, PartialEq)]
struct Pen {
set: Option<(Flags, Color, Color)>,
}
impl Pen {
fn to(&mut self, cell: &Cell, out: &mut String) {
let flags = cell.flags
& (Flags::BOLD
| Flags::DIM
| Flags::ITALIC
| Flags::ALL_UNDERLINES
| Flags::INVERSE
| Flags::HIDDEN
| Flags::STRIKEOUT);
let want = (flags, cell.fg, cell.bg);
if self.set == Some(want) {
return;
}
out.push_str("\x1b[0");
for (flag, code) in [
(Flags::BOLD, "1"),
(Flags::DIM, "2"),
(Flags::ITALIC, "3"),
(Flags::INVERSE, "7"),
(Flags::HIDDEN, "8"),
(Flags::STRIKEOUT, "9"),
] {
if flags.contains(flag) {
out.push(';');
out.push_str(code);
}
}
if flags.contains(Flags::DOUBLE_UNDERLINE) {
out.push_str(";21");
} else if flags.intersects(Flags::ALL_UNDERLINES) {
out.push_str(";4");
}
colour(cell.fg, false, out);
colour(cell.bg, true, out);
out.push('m');
self.set = Some(want);
}
fn reset(&mut self, out: &mut String) {
if self.set.is_some() {
out.push_str("\x1b[0m");
self.set = None;
}
}
}
fn colour(color: Color, bg: bool, out: &mut String) {
let base = if bg { 40 } else { 30 };
match color {
Color::Spec(rgb) => {
let _ = write!(out, ";{};2;{};{};{}", base + 8, rgb.r, rgb.g, rgb.b);
}
Color::Indexed(i) => {
let _ = write!(out, ";{};5;{i}", base + 8);
}
Color::Named(named) => {
let index = match named {
NamedColor::Foreground | NamedColor::Background => return,
NamedColor::DimBlack => 0,
NamedColor::DimRed => 1,
NamedColor::DimGreen => 2,
NamedColor::DimYellow => 3,
NamedColor::DimBlue => 4,
NamedColor::DimMagenta => 5,
NamedColor::DimCyan => 6,
NamedColor::DimWhite => 7,
NamedColor::BrightForeground | NamedColor::DimForeground | NamedColor::Cursor => {
return;
}
other => other as usize,
};
let code = if index < 8 {
base + index
} else {
base + 60 + index - 8
};
let _ = write!(out, ";{code}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What a terminal shows after reading `bytes`, as text, through the same
/// emulator: the drawing has to read back as the screen it was made from.
fn screen(mirror: &Mirror) -> Vec<String> {
let grid = mirror.term.grid();
let history = grid.history_size() as i32;
(-history..grid.screen_lines() as i32)
.map(|l| {
let row = &grid[Line(l)];
(0..grid.columns())
.map(|c| row[Column(c)].c)
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn redrawn(mirror: &Mirror) -> Mirror {
let grid = mirror.term.grid();
let mut copy = Mirror::new(grid.columns() as u16, grid.screen_lines() as u16);
copy.feed(&mirror.draw());
copy
}
#[test]
fn a_drawing_reads_back_as_the_screen_it_was_made_from() {
let mut m = Mirror::new(20, 4);
m.feed(b"one\r\ntwo\r\n\x1b[31mred\x1b[0m three\r\nfour\r\nfive $ ");
let copy = redrawn(&m);
assert_eq!(screen(&copy), screen(&m));
assert_eq!(copy.term.grid().cursor.point, m.term.grid().cursor.point);
// Colour survives.
let red = |m: &Mirror| {
let grid = m.term.grid();
let history = grid.history_size() as i32;
(-history..grid.screen_lines() as i32)
.flat_map(|l| (0..grid.columns()).map(move |c| (l, c)))
.find(|&(l, c)| {
grid[Line(l)][Column(c)].c == 'r' && grid[Line(l)][Column(c + 1)].c == 'e'
})
.map(|(l, c)| grid[Line(l)][Column(c)].fg)
};
assert_eq!(red(&copy), Some(Color::Named(NamedColor::Red)));
}
#[test]
fn a_line_longer_than_the_pane_stays_one_line() {
let mut m = Mirror::new(10, 3);
m.feed(b"abcdefghijklmno\r\n$ ");
let copy = redrawn(&m);
assert_eq!(screen(&copy), screen(&m));
let grid = copy.term.grid();
let first = grid.history_size() as i32;
let wrapped = (-first..grid.screen_lines() as i32)
.any(|l| grid[Line(l)][Column(9)].flags.contains(Flags::WRAPLINE));
assert!(wrapped, "the long line is still marked as wrapping");
}
#[test]
fn a_full_screen_program_is_drawn_on_the_alternate_screen() {
let mut m = Mirror::new(20, 4);
m.feed(b"$ less\r\n\x1b[?1049h\x1b[2J\x1b[1;1Hpage one\x1b[4;1H:");
let copy = redrawn(&m);
assert!(copy.alt());
assert_eq!(screen(&copy), screen(&m));
assert_eq!(copy.term.grid().cursor.point, m.term.grid().cursor.point);
}
#[test]
fn leaving_a_full_screen_program_is_told() {
let mut m = Mirror::new(20, 4);
assert!(!m.feed(b"\x1b[?1049hhi"));
assert!(m.feed(b"\x1b[?1049l$ "));
}
#[test]
fn wide_characters_take_their_two_cells_once() {
let mut m = Mirror::new(10, 2);
m.feed("你好 ok".as_bytes());
let copy = redrawn(&m);
assert_eq!(screen(&copy), screen(&m));
}
}
+106 -10
View File
@@ -7,10 +7,13 @@
//! channels are bounded: a phone that stops reading stalls its own thread, and
//! through it only its own daemon connection, never the gateway.
use std::collections::HashSet;
use std::io;
use std::sync::Arc;
use std::sync::mpsc as std_mpsc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::mirror::Mirror;
use iroh::Endpoint;
use iroh::endpoint::{Connection, RecvStream, SendStream};
@@ -63,6 +66,13 @@ pub trait Backend: Send + Sync + 'static {
cwd: Option<String>,
size: Option<GridSize>,
) -> io::Result<TabCreated>;
/// Closes a tab and ends its panes, keeping it to reopen where the
/// machine can.
fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()>;
/// The panes this machine's server has running, when it can say.
fn running_panes(&self) -> Option<HashSet<u64>> {
None
}
}
/// One linked machine, as [`Backend::remotes`] reports it.
@@ -203,6 +213,26 @@ async fn serve_stream(
finish(send).await;
Ok(())
}
Open::CloseTab {
workspace_id,
tab_id,
machine,
} => {
let closed = {
let backend = backend.clone();
tokio::task::spawn_blocking(move || {
backend.close_tab(machine.as_deref(), &workspace_id, &tab_id)
})
.await
.map_err(io::Error::other)?
};
match closed {
Ok(()) => write_msg(&mut send, &ok).await?,
Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?,
}
finish(send).await;
Ok(())
}
Open::Diff { cwd, machine } => {
let read = if machine.is_some() {
Err(io::Error::other(
@@ -313,10 +343,13 @@ fn read_diff(cwd: &str) -> io::Result<Diff> {
let truncated = out.stdout.len() > MAX_DIFF;
let mut patch = out.stdout;
patch.truncate(MAX_DIFF);
// A new directory is one entry, as `git status` shows it, not every
// file under it: a generated folder would bury the rest.
let untracked = git(&[
"ls-files",
"--others",
"--exclude-standard",
"--directory",
"--full-name",
":/",
])?;
@@ -461,7 +494,18 @@ fn watch_tree(
let event = match backend.snapshot() {
Ok((machine, agents)) => {
failing = false;
let mut tree = tree::build(&host, &machine, &agents);
// This machine's directories are this process's to read.
let running = backend.running_panes();
let mut tree = tree::build_with(
&host,
&machine,
&agents,
|cwd| {
tty7_core::core::git::head::read_head(std::path::Path::new(cwd))
.map(|h| h.home)
},
running.as_ref(),
);
tree.remotes = backend.remotes().into_iter().map(remote_view).collect();
if forced || last.as_ref() != Some(&tree) {
last = Some(tree.clone());
@@ -647,18 +691,34 @@ async fn pane_stream(
}
}
/// How long a fresh drawing of the screen waits for the output that made it
/// stale to stop, and the longest it waits while output keeps coming.
const DRAW_SETTLE: Duration = Duration::from_millis(30);
const DRAW_LIMIT: Duration = Duration::from_millis(200);
fn read_pane(mut feed: Box<dyn PaneFeed>, down: mpsc::Sender<Down>) {
// The daemon reports the agent and its status in separate messages; the
// phone gets both together.
let mut agent: Option<CLIAgent> = None;
let mut status: Option<AgentSessionState> = None;
// The pane as the desktop reads it (`mirror`), and since when the phone's
// copy has been out of date: from the start, while the replay comes in,
// then after a resize or a full-screen program's exit. Output that
// arrives meanwhile goes into the drawing rather than past it.
let mut mirror: Option<Mirror> = None;
let mut size = (80, 24);
let mut stale: Option<Instant> = Some(Instant::now());
loop {
if down.is_closed() {
return;
}
let msg = match feed.recv(FEED_TICK) {
Ok(Some(msg)) => msg,
Ok(None) => continue,
let wait = if stale.is_some() {
DRAW_SETTLE
} else {
FEED_TICK
};
let msg = match feed.recv(wait) {
Ok(msg) => msg,
Err(e) => {
let _ = down.blocking_send(Down::Event(PaneEvent::Error {
message: e.to_string(),
@@ -666,12 +726,48 @@ fn read_pane(mut feed: Box<dyn PaneFeed>, down: mpsc::Sender<Down>) {
return;
}
};
// Drawn once the burst is over, or anything other than output is
// about to be said, or it has waited long enough.
let burst = matches!(
msg,
Some(DaemonMsg::Size(_) | DaemonMsg::Snapshot(_) | DaemonMsg::Output(_))
);
if stale.is_some_and(|since| !burst || since.elapsed() >= DRAW_LIMIT) {
stale = None;
if let Some(mirror) = &mirror {
let (cols, rows) = size;
let drawn = [
Down::Event(PaneEvent::Size { cols, rows }),
Down::Bytes(mirror.draw()),
];
for item in drawn {
if down.blocking_send(item).is_err() {
return;
}
}
}
}
let Some(msg) = msg else { continue };
let item = match msg {
DaemonMsg::Size(size) => Down::Event(PaneEvent::Size {
cols: size.cols,
rows: size.rows,
}),
DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => Down::Bytes(bytes),
DaemonMsg::Size(new) => {
size = (new.cols, new.rows);
match &mut mirror {
Some(mirror) => mirror.resize(new.cols, new.rows),
None => mirror = Some(Mirror::new(new.cols, new.rows)),
}
stale.get_or_insert_with(Instant::now);
continue;
}
DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => {
let mirror = mirror.get_or_insert_with(|| Mirror::new(size.0, size.1));
if mirror.feed(&bytes) {
stale.get_or_insert_with(Instant::now);
}
if stale.is_some() {
continue;
}
Down::Bytes(bytes)
}
DaemonMsg::Cwd(path) => Down::Event(PaneEvent::Cwd {
path: path.to_string_lossy().into_owned(),
}),
+87 -4
View File
@@ -7,6 +7,9 @@
//! goes through `tab_view` so a tab reads the same here as in the CLI and the
//! desktop's switcher.
use std::collections::HashSet;
use std::path::PathBuf;
use tty7_core::core::cli_agent::{AgentSessionState, AgentStatus as CoreStatus, CLIAgent};
use tty7_core::core::group_key::{AutoKey, GroupKey, auto_names, pinned_names, place};
use tty7_core::core::machine::{Machine, PaneRecord, Workspace};
@@ -17,6 +20,20 @@ use tty7_mobile_proto::{
};
pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree {
build_with(host, machine, agents, |_| None, None)
}
/// [`build`], placing a tab the desktop has not filed yet by the repository
/// `repo_of` finds its directory in. The desktop files a tab as its sidebar
/// draws it, so one opened from the phone while that window is out of sight
/// would sit in Ungrouped until someone looked.
pub fn build_with(
host: &str,
machine: &Machine,
agents: &[PaneAgentState],
repo_of: impl Fn(&str) -> Option<PathBuf>,
running: Option<&HashSet<u64>>,
) -> Tree {
let mut workspaces: Vec<_> = machine.workspaces.iter().collect();
// Most recently used first: on a phone the one you were just in is the
// one you are coming back to.
@@ -46,11 +63,18 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree {
.root
.pane_ids()
.into_iter()
.map(|id| pane_view(id, &machine.panes, agents))
.map(|id| {
let mut view = pane_view(id, &machine.panes, agents);
// An asleep tab's panes are stopped on purpose,
// and say so as the tab.
view.stopped = !tab.hibernated
&& running.is_some_and(|running| !running.contains(&id));
view
})
.collect(),
})
.collect(),
groups: groups_of(ws),
groups: groups_of(ws, &machine.panes, &repo_of),
active_tab: ws.active_tab.map(|t| t.to_string()),
}
})
@@ -68,11 +92,22 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree {
/// writes the answer down so other readers need not. Auto grouping is taken
/// to be on, as it is by default; a desktop that turned it off still keeps
/// these hints, so its phone shows the repo groups its sidebar does not.
fn groups_of(ws: &Workspace) -> Vec<GroupView> {
fn groups_of(
ws: &Workspace,
panes: &[PaneRecord],
repo_of: &dyn Fn(&str) -> Option<PathBuf>,
) -> Vec<GroupView> {
let keys: Vec<Option<GroupKey>> = ws
.tabs
.iter()
.map(|tab| place(tab.group, &ws.groups, true, tab.last_auto.clone()))
.map(|tab| {
let auto = tab.last_auto.clone().or_else(|| {
let first = *tab.root.pane_ids().first()?;
let cwd = panes.iter().find(|p| p.id == first)?.cwd.as_deref()?;
repo_of(cwd).map(AutoKey::Repo)
});
place(tab.group, &ws.groups, true, auto)
})
.collect();
let members = |key: Option<&GroupKey>| -> Vec<String> {
ws.tabs
@@ -167,6 +202,7 @@ fn pane_view(id: u64, panes: &[PaneRecord], agents: &[PaneAgentState]) -> PaneVi
title,
cwd: record.and_then(|p| p.cwd.clone()),
agent,
stopped: false,
}
}
@@ -337,6 +373,53 @@ mod tests {
assert_eq!(view.active_tab.as_deref(), Some(ids[3].as_str()));
}
#[test]
fn a_tab_the_desktop_has_not_filed_goes_by_its_directorys_repo() {
let mut ws = workspace("w", 1, vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)]);
ws.tabs[0].last_auto = Some(AutoKey::Repo("/w/k6".into()));
let ids: Vec<String> = ws.tabs.iter().map(|t| t.id.to_string()).collect();
let machine = Machine {
workspaces: vec![ws],
panes: vec![
record(1, "zsh", None, "/w/k6"),
record(2, "zsh", None, "/w/k6/src"),
record(3, "zsh", None, "/tmp"),
],
};
let repo_of = |cwd: &str| cwd.starts_with("/w/k6").then(|| PathBuf::from("/w/k6"));
let view = &build_with("h", &machine, &[], repo_of, None).workspaces[0];
let groups: Vec<_> = view
.groups
.iter()
.map(|g| (g.name.as_deref(), g.tabs.clone()))
.collect();
assert_eq!(
groups,
[
(Some("k6"), vec![ids[0].clone(), ids[1].clone()]),
(Some("Ungrouped"), vec![ids[2].clone()]),
]
);
}
#[test]
fn a_pane_the_server_is_not_running_is_marked_stopped() {
let mut asleep = Tab::leaf(3);
asleep.hibernated = true;
let machine = Machine {
workspaces: vec![workspace("w", 1, vec![Tab::leaf(1), Tab::leaf(2), asleep])],
panes: Vec::new(),
};
let running = HashSet::from([1]);
let view = &build_with("h", &machine, &[], |_| None, Some(&running)).workspaces[0];
let stopped: Vec<bool> = view.tabs.iter().map(|t| t.panes[0].stopped).collect();
// The asleep tab says so as itself, not as a pane that stopped.
assert_eq!(stopped, [false, true, false]);
// A server that could not be asked marks nothing.
let view = &build("h", &machine, &[]).workspaces[0];
assert!(view.tabs.iter().all(|t| !t.panes[0].stopped));
}
#[test]
fn a_workspace_without_groups_is_one_headless_list() {
let machine = Machine {
+63 -18
View File
@@ -152,6 +152,19 @@ impl Backend for FakeMachine {
pane_id: 2,
})
}
fn close_tab(
&self,
_machine: Option<&str>,
workspace_id: &str,
tab_id: &str,
) -> io::Result<()> {
let ws = &self.machine.workspaces[0];
if workspace_id != ws.id.to_string() || ws.tabs.iter().all(|t| t.id.to_string() != tab_id) {
return Err(io::Error::other(format!("no tab {tab_id}")));
}
Ok(())
}
}
struct FakeFeed {
@@ -207,6 +220,15 @@ impl PaneFeed for FakeFeed {
}
}
/// A fresh drawing of a pane's screen (`mirror::Mirror::draw`), as text.
fn drawing(item: Option<PaneItem>) -> String {
let Some(PaneItem::Output(bytes)) = item else {
panic!("expected the screen drawn, got {item:?}");
};
assert!(bytes.starts_with(b"\x1bc"), "a drawing starts from a reset");
String::from_utf8_lossy(&bytes).into_owned()
}
struct Rig {
_dir: tempfile::TempDir,
machine: Arc<FakeMachine>,
@@ -322,10 +344,8 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() {
rows: 30
}))
);
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Output(b"$ ".to_vec()))
);
// The screen comes drawn afresh, as the desktop's emulator reads it.
assert!(drawing(within(screen.next()).await.unwrap()).contains("$"));
keys.input(b"ls\r").await.unwrap();
assert_eq!(
within(screen.next()).await.unwrap(),
@@ -351,7 +371,7 @@ async fn typing_that_fails_is_reported_not_dropped() {
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
within(screen.next()).await.unwrap(); // the screen, drawn
keys.input(b"refuse").await.unwrap();
let Some(PaneItem::Event(PaneEvent::Error { message })) = within(screen.next()).await.unwrap()
@@ -377,7 +397,7 @@ async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() {
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
within(screen.next()).await.unwrap(); // the screen, drawn
let size = GridSize { cols: 44, rows: 31 };
keys.request(&PaneRequest::TakeOver { size }).await.unwrap();
@@ -385,6 +405,8 @@ async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() {
within(screen.next()).await.unwrap(),
Some(PaneItem::Event(PaneEvent::Size { cols: 44, rows: 31 }))
);
// Redrawn at the new size rather than left to the phone to reflow.
assert!(drawing(within(screen.next()).await.unwrap()).contains("$"));
assert_eq!(
within(screen.next()).await.unwrap(),
Some(PaneItem::Event(PaneEvent::Lease {
@@ -414,7 +436,7 @@ async fn a_take_over_the_pane_cannot_do_is_refused_and_the_pane_stays_up() {
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
within(screen.next()).await.unwrap(); // the screen, drawn
let size = GridSize { cols: 44, rows: 31 };
keys.request(&PaneRequest::TakeOver { size }).await.unwrap();
@@ -451,7 +473,7 @@ async fn a_revoked_phone_is_cut_off_at_its_next_stream() {
async fn a_paired_phone_opens_a_tab() {
let rig = Rig::new().await;
let session = within(rig.paired()).await;
let (_, mut tree) = within(session.control()).await.unwrap().split();
let (_asks, mut tree) = within(session.control()).await.unwrap().split();
let Some(ControlEvent::Tree(first)) = within(tree.next()).await.unwrap() else {
panic!("expected a tree first");
};
@@ -487,6 +509,24 @@ async fn a_paired_phone_opens_a_tab() {
assert!(err.to_string().contains("no workspace nope"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_paired_phone_closes_a_tab() {
let rig = Rig::new().await;
let session = within(rig.paired()).await;
let (_asks, mut tree) = within(session.control()).await.unwrap().split();
let Some(ControlEvent::Tree(first)) = within(tree.next()).await.unwrap() else {
panic!("expected a tree first");
};
let ws = &first.workspaces[0];
within(session.close_tab(None, &ws.id, &ws.tabs[0].id))
.await
.unwrap();
let err = within(session.close_tab(None, &ws.id, "nope"))
.await
.unwrap_err();
assert!(err.to_string().contains("no tab nope"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_paired_phone_sends_a_file_and_learns_where_it_went() {
let rig = Rig::new().await;
@@ -543,12 +583,19 @@ async fn a_paired_phone_reads_a_working_trees_changes() {
run(&["commit", "-qm", "first"]);
std::fs::write(repo.join("src/a.txt"), "one\ntwo\n").unwrap();
std::fs::write(repo.join("new.txt"), "hi\n").unwrap();
std::fs::create_dir_all(repo.join("made/deep")).unwrap();
std::fs::write(repo.join("made/deep/x.txt"), "x\n").unwrap();
std::fs::write(repo.join("made/y.txt"), "y\n").unwrap();
// Asked from a directory inside the repository, as a pane's cwd often is.
let cwd = repo.join("src").to_string_lossy().into_owned();
let diff = within(session.diff(None, &cwd)).await.unwrap();
assert!(diff.patch.contains("+two"), "{}", diff.patch);
assert_eq!(diff.untracked, vec!["new.txt".to_string()]);
// A new directory comes as itself, not as each file in it.
assert_eq!(
diff.untracked,
vec!["made/".to_string(), "new.txt".to_string()]
);
assert!(!diff.truncated);
let err = within(session.diff(None, &std::env::temp_dir().to_string_lossy()))
@@ -584,7 +631,7 @@ async fn an_unpaired_phone_cannot_open_a_tab() {
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 (_asks, mut tree) = within(session.control()).await.unwrap().split();
let Some(ControlEvent::Tree(tree)) = within(tree.next()).await.unwrap() else {
panic!("expected a tree first");
};
@@ -597,15 +644,13 @@ async fn linked_machines_come_with_the_tree_and_their_panes_open() {
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 (_keys, mut screen) = within(session.pane(Some(&up.key), 1)).await.unwrap();
let first = within(screen.next()).await.unwrap();
assert!(
matches!(first, Some(PaneItem::Event(PaneEvent::Size { .. }))),
"{first:?}"
);
assert!(drawing(within(screen.next()).await.unwrap()).contains("me@build-box:22$"));
let err = within(session.pane(Some(&down.key), 1))
.await
+40 -1
View File
@@ -9,7 +9,7 @@ use std::str::FromStr as _;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use iroh::endpoint::{Connection, RecvStream, SendStream, presets};
use iroh::endpoint::{Connection, QuicTransportConfig, RecvStream, SendStream, presets};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey};
use iroh_mdns_address_lookup::MdnsAddressLookup;
use serde::{Deserialize, Serialize};
@@ -66,6 +66,7 @@ pub async fn bind(secret: SecretKey) -> Result<Endpoint> {
.advertise(false);
match Endpoint::builder(presets::N0)
.secret_key(secret.clone())
.transport_config(transport())
.address_lookup(mdns)
.bind()
.await
@@ -73,12 +74,25 @@ pub async fn bind(secret: SecretKey) -> Result<Endpoint> {
Ok(endpoint) => Ok(endpoint),
Err(_) => Endpoint::builder(presets::N0)
.secret_key(secret)
.transport_config(transport())
.bind()
.await
.context("could not start the connection"),
}
}
/// How long a machine may go unheard before its connection counts as gone.
/// Keep-alives go every few seconds, so this is a few of them missed: a
/// laptop that went to sleep shows as unreachable within seconds, not after
/// half a minute of the app saying the link is fine.
const IDLE_TIMEOUT: Duration = Duration::from_secs(12);
fn transport() -> QuicTransportConfig {
QuicTransportConfig::builder()
.max_idle_timeout(IDLE_TIMEOUT.try_into().ok())
.build()
}
/// Trades a pairing code for a [`Host`] the app can store and dial later.
pub async fn pair(endpoint: &Endpoint, code: &str, device_name: &str) -> Result<Host> {
let code = PairCode::decode(code).map_err(|e| anyhow!(e))?;
@@ -210,6 +224,31 @@ impl Session {
}
}
impl Session {
/// Closes a tab on `machine`, its panes with it.
pub async fn close_tab(
&self,
machine: Option<&str>,
workspace_id: &str,
tab_id: &str,
) -> Result<()> {
let ask = Open::CloseTab {
workspace_id: workspace_id.to_string(),
tab_id: tab_id.to_string(),
machine: machine.map(str::to_string),
};
let (mut send, _) = open(&self.conn, &ask).await.map_err(|e| {
if e.to_string().contains("without answering") {
anyhow!("tty7 on this computer is too old to close tabs — update it")
} else {
e
}
})?;
let _ = send.finish();
Ok(())
}
}
impl Session {
/// Sends a file to the machine for a pane to be handed, returning where
/// it landed. `machine` is where that pane runs, as on [`Session::pane`].
+22 -4
View File
@@ -96,13 +96,19 @@ pub enum Open {
#[serde(default, skip_serializing_if = "Option::is_none")]
machine: Option<String>,
},
/// Put a file from the phone on the machine, for a pane to be handed its
/// path. After `Ok` the phone sends the file as bytes frames and finishes
/// its side; the gateway answers [`Uploaded`] once the file is written.
/// `Denied` before any bytes: too big, or a machine files cannot go to.
/// Close a tab, its panes with it. One-shot: `Ok` once it is closed, or
/// `Denied` with why not. Where the machine keeps closed tabs, it goes on
/// the workspace's recently-closed list, for the desktop to reopen.
///
/// A gateway older than this variant cannot parse it and drops the stream
/// unanswered.
CloseTab {
workspace_id: String,
tab_id: String,
/// As on [`Open::Pane`].
#[serde(default, skip_serializing_if = "Option::is_none")]
machine: Option<String>,
},
/// What has changed in the git working tree `cwd` is in, against its last
/// commit: `Ok`, then a [`Diff`]. `Denied` when it is not in a repository.
///
@@ -114,6 +120,13 @@ pub enum Open {
#[serde(default, skip_serializing_if = "Option::is_none")]
machine: Option<String>,
},
/// Put a file from the phone on the machine, for a pane to be handed its
/// path. After `Ok` the phone sends the file as bytes frames and finishes
/// its side; the gateway answers [`Uploaded`] once the file is written.
/// `Denied` before any bytes: too big, or a machine files cannot go to.
///
/// A gateway older than this variant cannot parse it and drops the stream
/// unanswered.
Upload {
/// The file's name on the phone; the gateway keeps what it safely can.
name: String,
@@ -320,6 +333,11 @@ pub struct PaneView {
pub cwd: Option<String>,
#[serde(default)]
pub agent: Option<AgentView>,
/// Nothing is running in it: the machine's server restarted, and no
/// window has opened the tab since to start it again. Absent from an
/// older gateway, which cannot tell.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stopped: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+10
View File
@@ -11,6 +11,7 @@
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-barcode-scanner": "^2.5.0",
"@tauri-apps/plugin-biometric": "^2.4.0",
"@tauri-apps/plugin-haptics": "^2.4.1",
"@tauri-apps/plugin-opener": "^2.6.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.19.0",
@@ -539,6 +540,15 @@
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@tauri-apps/plugin-haptics": {
"version": "2.4.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-haptics/-/plugin-haptics-2.4.1.tgz",
"integrity": "sha512-y1t8CyXX4KWeJePamKwC6p/fpk5CV9DbvfcKiAgOHZWj8Gq4iILSxOp/KH4rleWz3A51oiDXbl+HdOugp5doww==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.6.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-opener/-/plugin-opener-2.6.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-barcode-scanner": "^2.5.0",
"@tauri-apps/plugin-biometric": "^2.4.0",
"@tauri-apps/plugin-haptics": "^2.4.1",
"@tauri-apps/plugin-opener": "^2.6.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.19.0",
+31 -16
View File
@@ -948,7 +948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 3.0.6",
]
[[package]]
@@ -1080,7 +1080,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1301,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1644,8 +1644,8 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-link 0.2.1",
"windows-result 0.4.1",
]
[[package]]
@@ -2080,7 +2080,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -3268,7 +3268,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3302,7 +3302,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -4233,7 +4233,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4291,7 +4291,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4951,7 +4951,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -5246,6 +5245,20 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-haptics"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f19d31ed9ea0775948e47104916565b9c9bb422c9bfdabaedfaa47c2494fd9b"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.6.0"
@@ -5375,10 +5388,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5809,6 +5822,7 @@ name = "tty7-mobile"
version = "0.1.0"
dependencies = [
"anyhow",
"block2",
"iroh",
"jni 0.21.1",
"objc2",
@@ -5819,6 +5833,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-barcode-scanner",
"tauri-plugin-biometric",
"tauri-plugin-haptics",
"tauri-plugin-opener",
"tokio",
"tty7-mobile-client",
@@ -6244,7 +6259,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6690,8 +6705,8 @@ dependencies = [
"log",
"serde",
"thiserror 2.0.21",
"windows 0.61.3",
"windows-core 0.61.2",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
+6 -1
View File
@@ -37,15 +37,20 @@ tauri-plugin-opener = "2"
tauri-plugin-barcode-scanner = "2"
# Face ID / passcode before the app shows anything, when turned on.
tauri-plugin-biometric = "2"
# A tap felt under the finger for the keys and answers, as the phone's own
# keyboard gives.
tauri-plugin-haptics = "2"
# Reading the system bars' size and leaving for the background (`insets`,
# `to_background`), which the Tauri API does not reach.
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
# Setting one WKWebView property the Tauri API does not reach (`edge_to_edge`).
# What the Tauri API does not reach: the WKWebView's insets (`edge_to_edge`),
# the keyboard's height and the bar WebKit puts over it (`keyboard`).
[target.'cfg(target_os = "ios")'.dependencies]
objc2 = "0.6"
block2 = "0.6"
# The Android library ships as built: stripping takes it from 30 MB to 20, and
# the APK from 32 to 23. iOS strips at export either way.
+13
View File
@@ -24,5 +24,18 @@
compliance question on every TestFlight upload. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- The desktop's pairing code is a tty7pair: link, so the phone's Camera
opens it in tty7 (lib.rs `opened`, `launch_link`). -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.tty7.mobile.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tty7pair</string>
</array>
</dict>
</array>
</dict>
</plist>
+2 -1
View File
@@ -9,6 +9,7 @@
],
"permissions": [
"barcode-scanner:default",
"biometric:default"
"biometric:default",
"haptics:default"
]
}
@@ -22,6 +22,13 @@
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- The desktop's pairing code is a tty7pair: link (lib.rs `opened`). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tty7pair" />
</intent-filter>
</activity>
<provider
@@ -15,13 +15,13 @@ class MainActivity : TauriActivity() {
// Edge to edge, the WebView is never resized for the keyboard, and it does
// not shrink the page's visual viewport either: the page could not tell the
// keyboard is up, and it would cover the message box. Its height is handed
// to the page instead (main.ts `android-keyboard`), which then lays out as
// to the page instead (main.ts `native-keyboard`), which then lays out as
// it does on iOS. The WebView still sees the insets itself.
override fun onWebViewCreate(webView: WebView) {
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / view.resources.displayMetrics.density
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('android-keyboard', { detail: $keyboard }))",
"window.dispatchEvent(new CustomEvent('native-keyboard', { detail: { height: $keyboard } }))",
null,
)
ViewCompat.onApplyWindowInsets(view, insets)
+311 -6
View File
@@ -53,6 +53,17 @@ enum Up {
#[cfg(test)]
mod tests {
#[test]
fn an_error_says_each_thing_once() {
let e = anyhow::anyhow!("aborted by peer: refused")
.context("aborted by peer: refused")
.context("could not reach studio");
assert_eq!(
super::err(e),
"could not reach studio: aborted by peer: refused"
);
}
#[test]
fn names_come_back_from_their_header_encoding() {
assert_eq!(
@@ -65,8 +76,17 @@ mod tests {
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.
format!("{e:#}")
// reaches the user rather than just the outermost context. A chain can
// say the same thing at several layers — QUIC's close reason is repeated
// by each wrapper around it — and once is enough.
let full = format!("{e:#}");
let mut said: Vec<&str> = Vec::new();
for part in full.split(": ") {
if !said.contains(&part) {
said.push(part);
}
}
said.join(": ")
}
impl AppState {
@@ -289,6 +309,22 @@ async fn tab_new(
.map_err(err)
}
/// Closes a tab and its panes; the machine keeps it to reopen where it can.
#[tauri::command]
async fn tab_close(
state: State<'_, Arc<AppState>>,
host_id: String,
machine: Option<String>,
workspace_id: String,
tab_id: String,
) -> CmdResult<()> {
let session = state.session(&host_id).await?;
session
.close_tab(machine.as_deref(), &workspace_id, &tab_id)
.await
.map_err(err)
}
/// Sends a file to the machine for a pane, returning its path there. The
/// file is the request's raw body, not JSON (a 20 MB photo would be 80 MB as
/// a number array); which machine and the file's name ride in headers, the
@@ -511,6 +547,8 @@ pub fn run() {
// unless a process-wide provider is installed first. `ring` is the one
// already in the tree. An `Err` means one is installed, which is fine.
let _ = rustls::crypto::ring::default_provider().install_default();
#[cfg(target_os = "ios")]
launch_link::catch();
// Links tapped in a pane open in the phone's browser.
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
@@ -519,13 +557,15 @@ pub fn run() {
#[cfg(mobile)]
let builder = builder
.plugin(tauri_plugin_barcode_scanner::init())
.plugin(tauri_plugin_biometric::init());
.plugin(tauri_plugin_biometric::init())
.plugin(tauri_plugin_haptics::init());
builder
.setup(|app| {
#[cfg(target_os = "ios")]
if let Some(window) = app.get_webview_window("main") {
edge_to_edge(&window);
keyboard(&window);
}
let dir = app.path().app_data_dir()?;
app.manage(Arc::new(AppState {
@@ -547,6 +587,7 @@ pub fn run() {
unwatch,
refresh,
tab_new,
tab_close,
upload,
diff,
pane_open,
@@ -555,10 +596,161 @@ pub fn run() {
pane_close,
appearance,
insets,
to_background
to_background,
opened_link
])
.run(tauri::generate_context!())
.expect("error while running tty7");
.build(tauri::generate_context!())
.expect("error while building tty7")
.run(|_app, _event| {
#[cfg(any(target_os = "ios", target_os = "android"))]
if let tauri::RunEvent::Opened { urls } = _event {
opened(_app, urls);
}
});
}
/// A link the app was opened with that the page has not taken yet: one that
/// launched the app arrives before the page is there to hear it.
static OPENED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// The desktop's pairing code is a `tty7pair:` link, so a phone's camera
/// pointed at its QR code opens the app with it (`Info.ios.plist`, the
/// Android manifest). Kept for the page to ask for, and told to it if it is
/// already listening.
#[cfg(any(target_os = "ios", target_os = "android"))]
fn opened(app: &tauri::AppHandle, urls: Vec<tauri::Url>) {
use tauri::Emitter as _;
let Some(link) = urls
.into_iter()
.map(String::from)
.find(|u| u.starts_with("tty7pair:"))
else {
return;
};
*OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link.clone());
let _ = app.emit("opened-link", link);
}
/// A link that launches the app on iOS. UIKit hands it to the app's first
/// scene as that connects, in the connection options, and the event loop
/// (tao) passes on only the links that come once the scene is up. So its
/// scene delegate's connect is wrapped to keep the link on the way. The
/// delegate class exists once the app delegate does, which is installed as
/// UIKit starts: the wrap goes on then.
#[cfg(target_os = "ios")]
mod launch_link {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject, Imp, Sel};
use objc2::sel;
use std::ffi::{CStr, c_char};
use std::sync::OnceLock;
static SET_DELEGATE: OnceLock<usize> = OnceLock::new();
static CONNECT: OnceLock<usize> = OnceLock::new();
type SetDelegate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
type Connect = unsafe extern "C-unwind" fn(
*mut AnyObject,
Sel,
*mut AnyObject,
*mut AnyObject,
*mut AnyObject,
);
/// Swaps `selector`'s implementation on `class` for `imp`, keeping the
/// one it had in `previous`.
unsafe fn wrap(class: *const AnyClass, selector: Sel, imp: Imp, previous: &OnceLock<usize>) {
unsafe {
let method = objc2::ffi::class_getInstanceMethod(class, selector);
if method.is_null() || previous.get().is_some() {
return;
}
if let Some(original) = objc2::ffi::method_setImplementation(method as *mut _, imp) {
let _ = previous.set(original as usize);
}
}
}
pub fn catch() {
if let Some(app) = AnyClass::get(c"UIApplication") {
unsafe {
let imp: Imp = std::mem::transmute(set_delegate as SetDelegate);
wrap(app, sel!(setDelegate:), imp, &SET_DELEGATE);
}
}
}
unsafe extern "C-unwind" fn set_delegate(
this: *mut AnyObject,
cmd: Sel,
delegate: *mut AnyObject,
) {
unsafe {
if let Some(&previous) = SET_DELEGATE.get() {
let previous: SetDelegate = std::mem::transmute(previous);
previous(this, cmd, delegate);
}
if let Some(scene) = AnyClass::get(c"TaoSceneDelegate") {
let imp: Imp = std::mem::transmute(connect as Connect);
wrap(
scene,
sel!(scene:willConnectToSession:options:),
imp,
&CONNECT,
);
}
}
}
unsafe extern "C-unwind" fn connect(
this: *mut AnyObject,
cmd: Sel,
scene: *mut AnyObject,
session: *mut AnyObject,
options: *mut AnyObject,
) {
unsafe {
if !options.is_null() {
let contexts: *mut AnyObject = msg_send![options, URLContexts];
let context: *mut AnyObject = if contexts.is_null() {
std::ptr::null_mut()
} else {
msg_send![contexts, anyObject]
};
let url: *mut AnyObject = if context.is_null() {
std::ptr::null_mut()
} else {
msg_send![context, URL]
};
let text: *mut AnyObject = if url.is_null() {
std::ptr::null_mut()
} else {
msg_send![url, absoluteString]
};
let utf8: *const c_char = if text.is_null() {
std::ptr::null()
} else {
msg_send![text, UTF8String]
};
if !utf8.is_null() {
let link = CStr::from_ptr(utf8).to_string_lossy().into_owned();
if link.starts_with("tty7pair:") {
*super::OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link);
}
}
}
if let Some(&previous) = CONNECT.get() {
let previous: Connect = std::mem::transmute(previous);
previous(this, cmd, scene, session, options);
}
}
}
}
/// The link the app was opened with, once.
#[tauri::command]
fn opened_link() -> Option<String> {
OPENED.lock().unwrap_or_else(|e| e.into_inner()).take()
}
/// Light, dark or the system's, for what the page does not draw itself: the
@@ -612,6 +804,119 @@ fn edge_to_edge(window: &tauri::WebviewWindow) {
});
}
/// The keyboard, told to the page. The WebView runs edge to edge, and so it
/// says the keyboard's size unreliably — not at all while WebKit's form bar
/// is up, and halfway through its move otherwise: the page would go on under
/// it, the message box with it. Where its top edge lands, and how long it
/// takes to get there, are sent as a `native-keyboard` event as it starts to
/// move, as Android's MainActivity sends its height. And WebKit's bar over it — the
/// previous, next and Done of a web form — goes: a terminal has no form to
/// step through, and the room is the pane's.
#[cfg(target_os = "ios")]
fn keyboard(window: &tauri::WebviewWindow) {
use block2::RcBlock;
use objc2::encode::{Encode, Encoding};
use objc2::runtime::{AnyClass, AnyObject, Imp, Sel};
use objc2::{msg_send, sel};
use std::ffi::{CStr, CString};
use std::ptr::{NonNull, null_mut};
#[repr(C)]
#[derive(Clone, Copy)]
struct Pair(f64, f64);
/// A CGRect: its origin, then its size, laid out alike.
#[repr(C)]
#[derive(Clone, Copy)]
struct Rect(Pair, Pair);
unsafe impl Encode for Rect {
const ENCODING: Encoding = Encoding::Struct(
"CGRect",
&[
Encoding::Struct("CGPoint", &[f64::ENCODING, f64::ENCODING]),
Encoding::Struct("CGSize", &[f64::ENCODING, f64::ENCODING]),
],
);
}
unsafe extern "C-unwind" fn no_bar(_this: *mut AnyObject, _cmd: Sel) -> *mut AnyObject {
null_mut()
}
unsafe fn string(s: &CStr) -> *mut AnyObject {
match AnyClass::get(c"NSString") {
Some(class) => msg_send![class, stringWithUTF8String: s.as_ptr()],
None => null_mut(),
}
}
// Set up on the main thread, where the WebView is handed over; it lives
// as long as the app, to measure against and to tell. Told directly, not
// through Tauri's `eval`, which waits on the main thread the keyboard is
// announced on.
let _ = window.with_webview(|webview| unsafe {
let wk = webview.inner() as *mut AnyObject;
if wk.is_null() {
return;
}
if let Some(content) = AnyClass::get(c"WKContentView") {
let imp: Imp = std::mem::transmute(
no_bar as unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> *mut AnyObject,
);
objc2::ffi::class_replaceMethod(
content as *const AnyClass as *mut AnyClass,
sel!(inputAccessoryView),
imp,
c"@@:".as_ptr(),
);
}
let (Some(center), Some(queue)) = (AnyClass::get(c"NSNotificationCenter"), AnyClass::get(c"NSOperationQueue"))
else {
return;
};
let center: *mut AnyObject = msg_send![center, defaultCenter];
let queue: *mut AnyObject = msg_send![queue, mainQueue];
let end_key: *mut AnyObject = msg_send![string(c"UIKeyboardFrameEndUserInfoKey"), retain];
let duration_key: *mut AnyObject = msg_send![string(c"UIKeyboardAnimationDurationUserInfoKey"), retain];
let changed = RcBlock::new(move |note: NonNull<AnyObject>| {
let info: *mut AnyObject = msg_send![note.as_ref(), userInfo];
if info.is_null() {
return;
}
let end: *mut AnyObject = msg_send![info, objectForKey: end_key];
if end.is_null() {
return;
}
let duration: *mut AnyObject = msg_send![info, objectForKey: duration_key];
let frame: Rect = msg_send![end, CGRectValue];
let seconds: f64 = if duration.is_null() { 0.25 } else { msg_send![duration, doubleValue] };
// Where its top edge will be, in the page's own pixels: what is
// above it is what the page has.
let local: Rect = msg_send![wk, convertRect: frame, fromView: null_mut::<AnyObject>()];
let Ok(script) = CString::new(format!(
"window.dispatchEvent(new CustomEvent('native-keyboard',{{detail:{{top:{},duration:{}}}}}))",
local.0 .1.round(),
(seconds * 1000.0).round()
)) else {
return;
};
let _: () = msg_send![
wk,
evaluateJavaScript: string(&script),
completionHandler: null_mut::<AnyObject>()
];
});
let token: *mut AnyObject = msg_send![
center,
addObserverForName: string(c"UIKeyboardWillChangeFrameNotification"),
object: null_mut::<AnyObject>(),
queue: queue,
usingBlock: &*changed
];
// Watched for as long as the app runs.
let _: *mut AnyObject = msg_send![token, retain];
});
}
/// The system bars and display cutout the page is drawn under, in CSS pixels.
/// Android runs the WebView edge to edge, yet WebViews before 140 report
/// `env(safe-area-inset-*)` as 0, so the page asks here. Zero elsewhere: iOS's
+1 -1
View File
@@ -19,7 +19,7 @@
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost"
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src ipc: http://ipc.localhost"
}
},
"bundle": {
+9
View File
@@ -24,6 +24,8 @@ export interface PaneView {
title: string;
cwd?: string | null;
agent?: AgentView | null;
/** Nothing runs in it: the machine's server restarted since. */
stopped?: boolean;
}
export interface TabView {
@@ -109,6 +111,9 @@ export interface Insets {
/** What the system bars cover, in CSS pixels. Android only: zero elsewhere. */
export const insets = () => invoke<Insets>("insets");
/** The `tty7pair:` link the app was opened with and has not handled, once. */
export const openedLink = () => invoke<string | null>("opened_link");
/** Sends the app to the background, as Back from the first screen does. */
export const toBackground = () => invoke<void>("to_background");
@@ -153,6 +158,10 @@ export const tabNew = (
size: { cols: number; rows: number } | null,
) => invoke<TabCreated>("tab_new", { hostId, machine, workspaceId, cwd, size });
/** Closes a tab and its panes; the machine keeps it to reopen where it can. */
export const tabClose = (hostId: string, machine: string | null, workspaceId: string, tabId: string) =>
invoke<void>("tab_close", { hostId, machine, workspaceId, tabId });
export const paneInput = (handle: number, data: string) =>
invoke<void>("pane_input", { handle, data });
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1075 -113
View File
File diff suppressed because it is too large Load Diff
+506 -7
View File
@@ -16,6 +16,38 @@
font-display: block;
}
/* The icons a prompt draws (Powerline arrows, a branch, a folder): Nerd
Fonts' symbols alone, one cell wide, for the code points Hack lacks. A
phone has no fonts of its own to fall back on. */
@font-face {
font-family: "Symbols Nerd Font Mono";
src: url("./assets/fonts/SymbolsNerdFontMono.woff2") format("woff2");
font-display: block;
}
/* What agents and TUIs draw their marks with — Claude Code's ⏺ and ⎿, ✔,
⚠, braille spinners — which the phone's own fonts leave as empty boxes
in a canvas. Noto's symbols, cut down to those blocks. */
@font-face {
font-family: "Noto Sans Symbols";
src: url("./assets/fonts/NotoSansSymbols-subset.woff2") format("woff2");
font-display: block;
}
@font-face {
font-family: "Noto Sans Symbols 2";
src: url("./assets/fonts/NotoSansSymbols2-subset.woff2") format("woff2");
font-display: block;
}
/* The few of those that are emoji by default (⏰ ✅ ❌ ✨), in outline, as
text: a canvas leaves them as boxes rather than reach for colour ones. */
@font-face {
font-family: "Noto Emoji";
src: url("./assets/fonts/NotoEmoji-subset.woff2") format("woff2");
font-display: block;
}
:root {
--canvas: #f3f3f1;
--cell: #fcfcfb;
@@ -34,6 +66,8 @@
--accent-ink: #fcfcfb;
--tint: var(--fill);
--danger: #c43c43;
/* A filled control's red: the system's own. */
--danger-fill: #ff3b30;
--warn-ink: #b7791f;
--ok-ink: #248a3d;
--term: var(--cell);
@@ -50,7 +84,7 @@
--done: #22c55e;
--ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", Roboto, "Segoe UI", system-ui, sans-serif;
--mono: Hack, "SF Mono", Menlo, ui-monospace, monospace;
--mono: Hack, "Symbols Nerd Font Mono", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Emoji", "SF Mono", "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo", Menlo, ui-monospace, monospace;
--ease: cubic-bezier(0.32, 0.72, 0, 1);
/* The home indicator's gap; none while the keyboard is up, which covers
@@ -90,6 +124,7 @@
--line: rgb(255 255 255 / 0.16);
--accent-ink: #18181a;
--danger: #f07878;
--danger-fill: #ff453a;
--warn-ink: #e0a345;
--ok-ink: #5fd07a;
--bar: rgb(30 30 32 / 0.82);
@@ -252,6 +287,12 @@ code {
transform: none;
}
/* Until it folds in, the title takes no room, so the way back is named in
full; once it shows, the back pill gives way to it. */
.nav:not(.folded) .nav-title {
max-width: 0;
}
/* Back is a floating pill that names where it goes. */
.nav-back {
display: flex;
@@ -278,6 +319,18 @@ code {
height: 20px;
}
/* Once the title shows, the pill would be cut to "Machin…" beside it: it
gives way to the round chevron alone, as the system's own bar does. */
.nav.folded .nav-back {
width: 38px;
padding: 0;
justify-content: center;
}
.nav.folded .nav-back span:last-child {
display: none;
}
.nav-back:active,
.nav-icon:active,
.round:active {
@@ -644,7 +697,10 @@ code {
opacity: 0.55;
}
.row + .row::before {
.row + .row::before,
.swipe + .swipe > .row::before,
.swipe + .row::before,
.row + .swipe > .row::before {
content: "";
position: absolute;
top: 0;
@@ -947,7 +1003,7 @@ code {
}
.button.danger {
background: var(--danger);
background: var(--danger-fill);
color: #fff;
}
@@ -1422,6 +1478,11 @@ html.scanning #app {
color: var(--ink-2);
}
.term-copy-actions {
display: flex;
gap: 8px;
}
.term-copy-text {
flex: 1;
min-height: 0;
@@ -1431,10 +1492,9 @@ html.scanning #app {
overscroll-behavior: contain;
font: 12.5px/1.45 var(--mono);
color: var(--ink);
/* Wrapped to the phone, not the desktop's width, so nothing needs panning
to select. */
white-space: pre-wrap;
overflow-wrap: anywhere;
/* Laid out as the pane has it: rewrapping to the phone breaks every
table, column and indented block into a jumble. A wide line pans. */
white-space: pre;
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
@@ -1471,6 +1531,16 @@ html.scanning #app {
/* The dock under the terminal: a row of keys a page at a time, then the
message box and the keyboard. */
.term-screen.finding .term-dock {
display: none;
}
/* Room above the first row for the find bar, so a match there can be
scrolled out from under it. */
.term-screen.finding .term .xterm {
margin-top: 60px;
}
.term-dock {
flex: none;
display: flex;
@@ -1626,9 +1696,28 @@ html.scanning #app {
bottom: calc(12px + var(--safe-bottom));
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.search-clear {
flex: none;
display: grid;
place-items: center;
width: 22px;
height: 22px;
margin-right: -4px;
border: 0;
border-radius: 11px;
background: var(--ink-3, #8e8e93);
color: var(--canvas);
}
.search .search-clear .icon {
width: 12px;
height: 12px;
}
/* The list softens into the canvas as it runs under the floating bar, as the
system's own bars do, so no row shows through sharp and cut in half. */
.screen:has(> .float-bar)::after {
@@ -2258,6 +2347,25 @@ html[data-nav="pop"]::view-transition-new(root) {
gap: 10px;
}
/* Each block keeps its height; the sheet scrolls rather than squeezing a
file to nothing under a long list of untracked ones. */
.diff-body > * {
flex-shrink: 0;
}
.diff-body > .group {
margin: 10px 0 0;
}
.diff-body .row {
min-height: 0;
padding: 9px 14px;
}
.diff-body .row-title {
font-size: 13px;
}
.diff-root {
margin: 0 4px;
font: 13px var(--mono);
@@ -2338,3 +2446,394 @@ html[data-nav="pop"]::view-transition-new(root) {
color: var(--ink-2);
background: var(--fill);
}
/* A row that slides to show an action behind it. */
.swipe {
position: relative;
overflow: hidden;
transition:
max-height 0.25s var(--ease),
opacity 0.2s;
max-height: 160px;
}
.swipe > .row {
background: var(--cell);
transition: transform 0.25s var(--ease);
}
.swipe.dragging > .row {
transition: none;
}
.swipe-action {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 88px;
border: 0;
background: var(--danger-fill);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 600;
}
.swipe.gone {
max-height: 0;
opacity: 0;
}
/* A phone on its side has height to spare for nothing but the pane: the bar
slims, and the keys and the message box share one row. */
@media (orientation: landscape) and (max-height: 500px) {
.term-nav {
height: calc(44px + var(--inset-top));
}
.term-dock {
flex-direction: row;
align-items: center;
gap: 10px;
padding-top: 6px;
padding-bottom: calc(6px + var(--safe-bottom));
}
.term-dock > .key-slot {
flex: 1.3;
min-width: 0;
}
.term-dock > .compose {
flex: 1;
min-width: 0;
}
.term-dock > .key-dots {
display: none;
}
.key {
height: 38px;
}
.compose-input {
min-height: 38px;
padding-top: 8px;
padding-bottom: 8px;
}
}
/* A hint is one line; it never makes the box taller than what is typed. */
.compose-input::placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A control's mark, named in running text. */
.inline-icon {
width: 1.15em;
height: 1.15em;
vertical-align: -0.2em;
}
/* Another agent on the machine wants a look: a card that drops over the
top of the pane, tapped to go there. */
.peek-slot {
position: absolute;
z-index: 6;
top: 8px;
right: max(10px, var(--inset-right));
left: max(10px, var(--inset-left));
pointer-events: none;
}
.peek {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: 22px;
background: var(--bar);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
box-shadow: var(--shadow);
color: var(--ink);
font: inherit;
text-align: left;
pointer-events: auto;
animation: peek-in 0.32s var(--ease);
}
.peek-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.peek-title {
font-size: 15px;
font-weight: 600;
}
.peek-sub {
overflow: hidden;
font-size: 13px;
color: var(--ink-2);
white-space: nowrap;
text-overflow: ellipsis;
}
@keyframes peek-in {
from {
opacity: 0;
transform: translateY(-16px);
}
}
/* The way back down from the history (main.ts `latest`). */
.to-latest {
position: absolute;
right: max(14px, var(--inset-right));
bottom: 12px;
z-index: 2;
width: 44px;
height: 44px;
border: 0;
border-radius: 22px;
display: grid;
place-items: center;
background: var(--raised);
color: var(--ink);
box-shadow: var(--shadow);
}
.to-latest[hidden] {
display: none;
}
.to-latest .icon {
width: 20px;
height: 20px;
}
.to-latest:active {
background: var(--cell-press);
}
/* Files waiting to go with the message (main.ts `attached`). */
.attach-chips {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
}
.attach-chips[hidden] {
display: none;
}
.attach-chip {
flex: none;
display: flex;
align-items: center;
gap: 8px;
max-width: 220px;
height: 44px;
padding: 4px 4px 4px 4px;
border-radius: 12px;
background: var(--cell);
box-shadow: 0 0 0 0.5px var(--hair);
}
.attach-thumb {
flex: none;
width: 36px;
height: 36px;
border-radius: 8px;
object-fit: cover;
display: grid;
place-items: center;
background: var(--fill);
color: var(--ink-2);
}
.attach-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--ink);
}
.attach-drop {
flex: none;
width: 28px;
height: 28px;
border: 0;
border-radius: 14px;
display: grid;
place-items: center;
background: transparent;
color: var(--ink-2);
}
.attach-drop .icon {
width: 14px;
height: 14px;
}
/* A row's held-down menu (main.ts `rowActions`). */
.row .row-title.danger,
.row .row-icon.danger {
color: var(--danger);
}
.row .row-icon {
margin-left: auto;
width: 20px;
height: 20px;
color: var(--ink-2);
}
.row {
-webkit-touch-callout: none;
}
/* Three dots while a pane's screen is on its way (main.ts `loading`). */
.term-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
pointer-events: none;
}
.term-loading[hidden] {
display: none;
}
.term-loading span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ink-3);
animation: loading-dot 1s ease-in-out infinite;
}
.term-loading span:nth-child(2) {
animation-delay: 0.15s;
}
.term-loading span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes loading-dot {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: none;
}
}
/* A pane that is not running: its keys and box are there but out of use. */
.term-screen.ended .key-slot,
.term-screen.ended .compose > button {
opacity: 0.35;
pointer-events: none;
}
.peek-close {
flex: none;
width: 30px;
height: 30px;
border: 0;
border-radius: 15px;
display: grid;
place-items: center;
background: var(--fill);
color: var(--ink-2);
}
.peek-close .icon {
width: 14px;
height: 14px;
}
/* Another agent is waiting: a dot on the way back to the list. */
.term-back {
position: relative;
}
.term-back.attention::after {
content: "";
position: absolute;
top: 2px;
right: 2px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--waiting);
box-shadow: 0 0 0 2px var(--canvas);
}
/* A row's name gives way before its tags do: "Current" stays whole. */
.row-title.tagged {
display: flex;
align-items: center;
}
.row-title.tagged .row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title.tagged .tag {
flex: none;
}
.peek.leaving {
opacity: 0;
transform: translateY(-8px);
transition:
opacity 0.2s,
transform 0.2s;
}
.term-shield {
position: absolute;
inset: 0;
z-index: 8;
}
/* The round bar buttons are drawn at 38pt but answer a 44pt square, the
least a finger should have to hit. */
.round,
.nav-icon,
.nav-back {
position: relative;
}
.round::before,
.nav-icon::before,
.nav-back::before {
content: "";
position: absolute;
inset: -3px;
}
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsMobilePairDesc => "Shows a one-time code for the tty7 app on your phone.",
L10nKey::SettingsMobilePairNeedsAccess => "Turn on phone access first.",
L10nKey::SettingsMobilePairScan => {
"Scan this with the tty7 app on your phone, or copy the code and paste it there."
"Point your phone's camera at this, or scan it in the tty7 app, or copy the code and paste it there."
}
L10nKey::SettingsMobilePairValid => "Expires in {time}. Works for one phone.",
L10nKey::SettingsMobileNewCode => "New code",
+1 -1
View File
@@ -71,7 +71,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"先にスマートフォンからのアクセスをオンにしてください。"
}
L10nKey::SettingsMobilePairScan => {
"スマートフォンの tty7 アプリでスキャンするか、コードをコピーして貼り付けてください。"
"スマートフォンのカメラを向けるか、tty7 アプリでスキャンするか、コードをコピーして貼り付けてください。"
}
L10nKey::SettingsMobilePairValid => {
"あと {time} で失効します。1 台のスマートフォンに限ります。"
+3 -1
View File
@@ -58,7 +58,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsMobileShowCode => "显示配对码",
L10nKey::SettingsMobilePairDesc => "生成一个一次性配对码,给手机上的 tty7 app 用。",
L10nKey::SettingsMobilePairNeedsAccess => "请先打开手机访问。",
L10nKey::SettingsMobilePairScan => "用手机上的 tty7 app 扫描,或者复制配对码粘贴过去。",
L10nKey::SettingsMobilePairScan => {
"用手机相机对准它,或在 tty7 app 里扫描,或者复制配对码粘贴过去。"
}
L10nKey::SettingsMobilePairValid => "{time} 后失效,只能配对一台手机。",
L10nKey::SettingsMobileNewCode => "换一个配对码",
L10nKey::SettingsMobilePairExpired => "这个配对码已过期。生成一个新的再配对。",