From 4c8c317b917770c18fc9744b13dbbe3bfbd68da7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:30:04 +0800 Subject: [PATCH] feat(mobile): files, changes, a custom key bar, and a steadier connection (#1029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone can now hand a pane a photo or file, read what an agent changed, answer its prompts with a tap, and keep its link across leaving a pane. Gateway and protocol: - `Open::Upload` puts a file (up to 20 MB) in the directory the desktop keeps pasted images in, under a name a shell takes unquoted, and answers with its path. Panes on this machine only for now. - `Open::Diff` answers with the working tree's changes against HEAD and its untracked files, for the directory a pane is in. - Both are one-shot streams; an older gateway drops them unanswered, which the client reports as "update tty7". App: - Attach button in the message box: the uploaded path goes into the draft. - Changes sheet from a pane's menu: a block per file, lines coloured. - Settings → Key bar: remove, reorder and add keys, from a catalog or written out (`/compact\r`, `^C`), across pages; reset to the default. - An agent's numbered choices show as buttons while it waits. - Pinch to zoom, tap a link to open it, find in the scrollback, a Copy button for text a program copies (OSC 52), and an optional Face ID lock. - Swipe from the left edge to go back; the iOS WebView has no page stack. - One workspace at a time on a machine, picked from a row of chips. - Agent avatars in their own colours. - Each screen's watch is its own: a second watch no longer ends the first, which showed as "Can't reach" and a reconnect after leaving a pane. - Dialing no longer holds the session lock, so Forget works while a machine is still connecting; its confirmation is an in-app sheet, since `window.confirm` shows nothing in the iOS WebView. - iOS 15 minimum, as App Store Connect will require from April 2027. - `scripts/testflight.sh` archives, signs for the App Store at export (the team has no devices for a development profile) and uploads. Co-authored-by: Claude --- crates/tty7-gateway/src/serve.rs | 189 +++- crates/tty7-gateway/tests/bridge.rs | 71 ++ crates/tty7-mobile-client/src/lib.rs | 70 +- crates/tty7-mobile-proto/src/lib.rs | 78 +- mobile/.gitignore | 1 + mobile/README.md | 10 + mobile/package-lock.json | 27 + mobile/package.json | 3 + mobile/scripts/testflight.sh | 85 ++ mobile/src-tauri/Cargo.lock | 645 +++++++++++++- mobile/src-tauri/Cargo.toml | 3 + mobile/src-tauri/Info.ios.plist | 4 + mobile/src-tauri/capabilities/default.json | 4 +- mobile/src-tauri/capabilities/mobile.json | 3 +- mobile/src-tauri/src/lib.rs | 161 +++- mobile/src-tauri/tauri.conf.json | 3 +- mobile/src/api.ts | 34 +- mobile/src/icons.ts | 3 + mobile/src/main.ts | 980 +++++++++++++++++++-- mobile/src/style.css | 397 +++++++++ 20 files changed, 2609 insertions(+), 162 deletions(-) create mode 100755 mobile/scripts/testflight.sh diff --git a/crates/tty7-gateway/src/serve.rs b/crates/tty7-gateway/src/serve.rs index fff4f377..05a27327 100644 --- a/crates/tty7-gateway/src/serve.rs +++ b/crates/tty7-gateway/src/serve.rs @@ -20,8 +20,9 @@ use tty7_core::core::machine::Machine; use tty7_core::daemon::control::PaneAgentState; use tty7_core::daemon::protocol::{DaemonMsg, LeaseRequest, WinSize}; use tty7_mobile_proto::{ - ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, PaneEvent, - PaneRequest, RemoteView, TabCreated, Tree, read_frame, write_bytes, write_msg, + ControlEvent, ControlRequest, Diff, Frame, GridSize, MAX_DIFF, MAX_UPLOAD, Open, OpenReply, + PROTOCOL_VERSION, PaneEvent, PaneRequest, RemoteView, TabCreated, Tree, Uploaded, read_frame, + write_bytes, write_msg, }; use crate::state::State; @@ -202,6 +203,56 @@ async fn serve_stream( finish(send).await; Ok(()) } + Open::Diff { cwd, machine } => { + let read = if machine.is_some() { + Err(io::Error::other( + "changes can only be read on this computer for now", + )) + } else { + tokio::task::spawn_blocking(move || read_diff(&cwd)) + .await + .map_err(io::Error::other)? + }; + match read { + Ok(diff) => { + write_msg(&mut send, &ok).await?; + write_msg(&mut send, &diff).await?; + } + Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?, + } + finish(send).await; + Ok(()) + } + Open::Upload { + name, + size, + machine, + } => { + let refusal = if machine.is_some() { + Some("files can only go to panes on this computer for now".to_string()) + } else if size > MAX_UPLOAD { + Some(format!( + "that file is {} MB; files up to {} MB can be sent", + size.div_ceil(1 << 20), + MAX_UPLOAD >> 20 + )) + } else { + None + }; + if let Some(reason) = refusal { + write_msg(&mut send, &denied(&reason)).await?; + finish(send).await; + return Ok(()); + } + write_msg(&mut send, &ok).await?; + // A failure after `Ok` is said in the place of the answer. + match receive_upload(&name, size, &mut recv).await { + Ok(path) => write_msg(&mut send, &Uploaded { path }).await?, + Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?, + } + finish(send).await; + Ok(()) + } Open::Pane { pane_id, machine } => { let feed = { let (backend, machine) = (backend.clone(), machine.clone()); @@ -230,6 +281,126 @@ async fn serve_stream( } } +/// The changes in the git working tree `cwd` is in, against its last commit +/// (against nothing, in a repository with no commits yet). +fn read_diff(cwd: &str) -> io::Result { + let git = |args: &[&str]| -> io::Result { + std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .stdin(std::process::Stdio::null()) + .output() + }; + let top = git(&["rev-parse", "--show-toplevel"])?; + if !top.status.success() { + return Err(io::Error::other(format!( + "{cwd} is not in a git repository" + ))); + } + let root = String::from_utf8_lossy(&top.stdout).trim().to_string(); + let args = ["--no-pager", "diff", "--no-color", "--no-ext-diff", "-M"]; + let mut out = git(&[&args[..], &["HEAD"]].concat())?; + if !out.status.success() { + // No commit yet: what is staged is all there is to show. + out = git(&[&args[..], &["--cached"]].concat())?; + } + if !out.status.success() { + return Err(io::Error::other( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + let truncated = out.stdout.len() > MAX_DIFF; + let mut patch = out.stdout; + patch.truncate(MAX_DIFF); + let untracked = git(&[ + "ls-files", + "--others", + "--exclude-standard", + "--full-name", + ":/", + ])?; + Ok(Diff { + root, + patch: String::from_utf8_lossy(&patch).into_owned(), + untracked: String::from_utf8_lossy(&untracked.stdout) + .lines() + .map(str::to_string) + .collect(), + truncated, + }) +} + +/// Reads a file the phone sends and writes it where the desktop keeps pasted +/// images, in a directory of its own so its name can stay as it was. +async fn receive_upload(name: &str, size: u64, recv: &mut RecvStream) -> io::Result { + let mut bytes = Vec::with_capacity(usize::try_from(size).unwrap_or(0)); + while let Some(frame) = read_frame(recv).await? { + match frame { + Frame::Bytes(chunk) => bytes.extend_from_slice(&chunk), + Frame::Json(_) => { + return Err(io::Error::other("the phone sent a message, not the file")); + } + } + if bytes.len() as u64 > size { + return Err(io::Error::other("the file was longer than the phone said")); + } + } + if bytes.len() as u64 != size { + return Err(io::Error::other("the file stopped partway; send it again")); + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir() + .join("tty7-clipboard") + .join(format!("phone-{nanos:x}")); + let path = dir.join(upload_file_name(name)); + let written = path.clone(); + tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&dir)?; + std::fs::write(&written, bytes) + }) + .await + .map_err(io::Error::other)??; + Ok(path.to_string_lossy().into_owned()) +} + +/// A name that is safe to write and to type into a shell unquoted: the last +/// path component, letters (any script), digits and `.-_` kept, the rest +/// made `_`, and no leading dot to hide it. +fn upload_file_name(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(""); + let safe: String = base + .chars() + .map(|c| { + if c.is_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + let safe = safe.trim_start_matches('.'); + if safe.is_empty() { + return "file".into(); + } + // Long names are cut from the middle of the stem; the extension is what + // tells an agent what the file is. + let chars: Vec = safe.chars().collect(); + if chars.len() <= 100 { + return safe.to_string(); + } + let ext = safe.rfind('.').map_or("", |i| &safe[i..]); + let ext: String = if ext.chars().count() <= 10 { + ext.to_string() + } else { + String::new() + }; + let stem: String = chars[..100 - ext.chars().count()].iter().collect(); + format!("{stem}{ext}") +} + fn denied(reason: &str) -> OpenReply { OpenReply::Denied { reason: reason.to_string(), @@ -557,6 +728,20 @@ fn short(id: &str) -> &str { mod tests { use super::*; + #[test] + fn an_upload_keeps_a_name_a_shell_can_take() { + assert_eq!(upload_file_name("IMG_0042.HEIC"), "IMG_0042.HEIC"); + assert_eq!(upload_file_name("截图 2026.png"), "截图_2026.png"); + assert_eq!(upload_file_name("../../etc/passwd"), "passwd"); + assert_eq!(upload_file_name("a$(rm -rf ~).txt"), "a__rm_-rf___.txt"); + assert_eq!(upload_file_name(".env"), "env"); + assert_eq!(upload_file_name(""), "file"); + let long = format!("{}.png", "x".repeat(300)); + let cut = upload_file_name(&long); + assert_eq!(cut.chars().count(), 100); + assert!(cut.ends_with(".png"), "{cut}"); + } + #[test] fn no_server_names_the_fix() { for kind in [io::ErrorKind::NotFound, io::ErrorKind::ConnectionRefused] { diff --git a/crates/tty7-gateway/tests/bridge.rs b/crates/tty7-gateway/tests/bridge.rs index 5a122cef..cdafd001 100644 --- a/crates/tty7-gateway/tests/bridge.rs +++ b/crates/tty7-gateway/tests/bridge.rs @@ -487,6 +487,77 @@ 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_sends_a_file_and_learns_where_it_went() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + + // Bigger than one frame of the upload, so it arrives in pieces. + let bytes: Vec = (0..700_000u32).map(|i| i as u8).collect(); + let path = within(session.upload(None, "../screen shot.png", &bytes)) + .await + .unwrap(); + let path = std::path::PathBuf::from(path); + assert!(path.is_absolute(), "{}", path.display()); + assert_eq!(path.file_name().unwrap(), "screen_shot.png"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + + // Only this machine takes files for now. + let err = within(session.upload(Some("me@build-box:22"), "a.txt", b"hi")) + .await + .unwrap_err(); + assert!(err.to_string().contains("this computer"), "{err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_paired_phone_reads_a_working_trees_changes() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + + let repo = std::env::temp_dir().join(format!("tty7-diff-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&repo); + std::fs::create_dir_all(repo.join("src")).unwrap(); + let run = |args: &[&str]| { + let ok = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap() + .status + .success(); + assert!(ok, "{args:?}"); + }; + run(&["init", "-q"]); + std::fs::write(repo.join("src/a.txt"), "one\n").unwrap(); + run(&["add", "."]); + 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(); + + // 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()]); + assert!(!diff.truncated); + + let err = within(session.diff(None, &std::env::temp_dir().to_string_lossy())) + .await + .unwrap_err(); + assert!(err.to_string().contains("not in a git repository"), "{err}"); + std::fs::remove_dir_all(&repo).unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn an_unpaired_phone_cannot_open_a_tab() { let rig = Rig::new().await; diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index 929da1de..203fe376 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -14,9 +14,9 @@ use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey}; 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, PaneRequest, TabCreated, read_frame, write_bytes, - write_msg, + ALPN, ControlEvent, ControlRequest, Diff, Frame, GridSize, MAX_UPLOAD, MDNS_SERVICE, Open, + OpenReply, PROTOCOL_VERSION, PairCode, PaneEvent, PaneRequest, TabCreated, Uploaded, + read_frame, write_bytes, write_msg, }; /// How long to wait for a gateway to answer an [`Open`]. @@ -210,6 +210,70 @@ impl Session { } } +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`]. + pub async fn upload(&self, machine: Option<&str>, name: &str, bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_UPLOAD { + bail!( + "that file is {} MB; files up to {} MB can be sent", + (bytes.len() as u64).div_ceil(1 << 20), + MAX_UPLOAD >> 20 + ); + } + let ask = Open::Upload { + name: name.to_string(), + size: bytes.len() as u64, + machine: machine.map(str::to_string), + }; + let (mut send, mut recv) = open(&self.conn, &ask).await.map_err(|e| { + if e.to_string().contains("without answering") { + anyhow!("tty7 on this computer is too old to take files — update it") + } else { + e + } + })?; + for chunk in bytes.chunks(UPLOAD_CHUNK) { + write_bytes(&mut send, chunk).await?; + } + let _ = send.finish(); + let answer = read_frame(&mut recv) + .await? + .ok_or_else(|| anyhow!("the machine did not say where the file went"))?; + if let Ok(OpenReply::Denied { reason }) = answer.msg::() { + bail!(reason); + } + Ok(answer.msg::()?.path) + } +} + +impl Session { + /// What has changed in the repository `cwd` is in, on `machine`. + pub async fn diff(&self, machine: Option<&str>, cwd: &str) -> Result { + let ask = Open::Diff { + cwd: cwd.to_string(), + machine: machine.map(str::to_string), + }; + let (mut send, mut recv) = open(&self.conn, &ask).await.map_err(|e| { + if e.to_string().contains("without answering") { + anyhow!("tty7 on this computer is too old to show changes — update it") + } else { + e + } + })?; + let _ = send.finish(); + let diff = read_frame(&mut recv) + .await? + .ok_or_else(|| anyhow!("the machine did not send the changes"))? + .msg::()?; + Ok(diff) + } +} + +/// How much of a file goes in one frame: well under the frame limit, and small +/// enough that a slow link shows progress rather than one long wait. +const UPLOAD_CHUNK: usize = 256 << 10; + async fn open(conn: &Connection, open: &Open) -> Result<(SendStream, RecvStream)> { let (mut send, mut recv) = conn.open_bi().await.context("opening a stream")?; write_msg(&mut send, open).await?; diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index e6e07787..cd6f1122 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -14,7 +14,11 @@ //! - a [`Open::Pane`] stream per terminal on screen: raw output down, raw //! keystrokes up, and a few [`PaneEvent`]s beside them; //! - a one-shot [`Open::NewTab`] stream that starts a shell in a new tab and -//! answers with a [`TabCreated`]. +//! answers with a [`TabCreated`]; +//! - a one-shot [`Open::Upload`] stream that carries a file from the phone to +//! the machine and answers with where it landed, an [`Uploaded`]; +//! - a one-shot [`Open::Diff`] stream that answers with a git working tree's +//! changes, a [`Diff`]. //! //! Every frame is `u32` little-endian length, a one-byte kind, then the //! payload — the same shape as the daemon's frames, so there is one framing @@ -38,6 +42,10 @@ pub const MDNS_SERVICE: &str = "tty7"; /// can tell the user to update rather than misread a newer gateway. pub const PROTOCOL_VERSION: u32 = 1; +/// The largest file an [`Open::Upload`] may carry: photos, screenshots and +/// small files, not a way to move a disk image. +pub const MAX_UPLOAD: u64 = 20 << 20; + /// The largest frame either side will accept. Output is forwarded in the chunks /// the daemon sends, and a replay segment is capped well below this on the /// daemon side; anything larger is a corrupt or hostile stream. @@ -88,6 +96,33 @@ pub enum Open { #[serde(default, skip_serializing_if = "Option::is_none")] machine: Option, }, + /// 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. + /// 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. + /// + /// A gateway older than this variant cannot parse it and drops the stream + /// unanswered. + Diff { + cwd: String, + /// As on [`Open::Pane`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, + }, + Upload { + /// The file's name on the phone; the gateway keeps what it safely can. + name: String, + /// Its length in bytes, at most [`MAX_UPLOAD`]. + size: u64, + /// As on [`Open::Pane`]: where the pane it is for runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -103,6 +138,32 @@ pub struct TabCreated { pub pane_id: u64, } +/// The answer on a [`Open::Diff`] stream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diff { + /// The repository's top directory. + pub root: String, + /// `git diff HEAD`: staged and unstaged together, as a unified diff. + pub patch: String, + /// Files git does not track yet (and does not ignore), by path from `root`. + #[serde(default)] + pub untracked: Vec, + /// The patch was cut at [`MAX_DIFF`] bytes. + #[serde(default)] + pub truncated: bool, +} + +/// The most of a patch a [`Diff`] carries: enough to review an agent's work +/// on a phone, not a vendored dependency's. +pub const MAX_DIFF: usize = 2 << 20; + +/// The answer on a [`Open::Upload`] stream once the file is written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Uploaded { + /// Where the file is, absolute, on the machine the pane runs on. + pub path: String, +} + /// The gateway's answer to an [`Open`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -562,6 +623,21 @@ mod tests { ); } + #[test] + fn an_upload_names_its_file_and_size() { + let open = Open::Upload { + name: "shot.png".into(), + size: 42, + machine: None, + }; + let wire = serde_json::to_value(&open).unwrap(); + assert_eq!( + wire, + serde_json::json!({"type": "upload", "name": "shot.png", "size": 42}) + ); + assert_eq!(serde_json::from_value::(wire).unwrap(), open); + } + #[cfg(feature = "tokio")] #[tokio::test] async fn async_reader_matches_the_decoder() { diff --git a/mobile/.gitignore b/mobile/.gitignore index 711bfc19..23ca045d 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ src-tauri/target/ src-tauri/gen/schemas/ +build/ diff --git a/mobile/README.md b/mobile/README.md index 4213baf4..8ffa3e57 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -71,6 +71,16 @@ npm run tauri ios init # once: generates src-tauri/gen/apple npm run tauri ios dev # simulator, or pick a connected device ``` +To send a build to TestFlight (Xcode signed in to an account on the team): + +```sh +scripts/testflight.sh # archive, sign for the App Store, upload +scripts/testflight.sh --no-upload # a signed .ipa in build/testflight/ instead +``` + +The build is `.`; `n` defaults to the time, so each upload is higher than the +last. `--build-number 7` picks it. + ### Android Needs Android Studio's SDK and NDK, with `ANDROID_HOME` and `NDK_HOME` set. diff --git a/mobile/package-lock.json b/mobile/package-lock.json index a640ee9a..4291c5ec 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", + "@tauri-apps/plugin-biometric": "^2.4.0", + "@tauri-apps/plugin-opener": "^2.6.0", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0" }, @@ -527,6 +530,30 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-biometric": { + "version": "2.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-biometric/-/plugin-biometric-2.4.0.tgz", + "integrity": "sha512-soFvDJ+oFLScaHJKgLecXLMcuuvHEbJKvClsZO24lkDl8JLLcxcyfFBqTDJjJh0azNUw/yLyJ+r7w/Z499t6UA==", + "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", + "integrity": "sha512-KIl9qkBGyAUrbzfCtdk+NRPTXUOwpMyUKsKN57H6/qA0q8auVywXpfUyWClqX6I/iH8PHmqAUa/KK9YZPWVQew==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, "node_modules/@xterm/addon-webgl": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 19002305..c5dc7c0e 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,9 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", + "@tauri-apps/plugin-biometric": "^2.4.0", + "@tauri-apps/plugin-opener": "^2.6.0", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0" }, diff --git a/mobile/scripts/testflight.sh b/mobile/scripts/testflight.sh new file mode 100755 index 00000000..3033d1c2 --- /dev/null +++ b/mobile/scripts/testflight.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Builds the iOS app and uploads it to App Store Connect for TestFlight. +# +# Usage: scripts/testflight.sh [--build-number N] [--no-upload] +# --build-number N the build is .N (default: the time, yyjjjHHMM) +# --no-upload export a signed .ipa into build/testflight/ instead +# +# Needs Xcode signed in (Settings → Accounts) to an account on team 78SZC3DQ7B. +# +# Why not just `tauri ios build --export-method app-store-connect`: +# - The archive is signed with a development profile first, and the team has +# no registered devices to make one from. So the archive is built unsigned +# and Xcode signs it for the App Store at export, where no device is needed. +# +# The default build number is the time — year, day of the year, hour, minute — +# so each upload is higher than the last without keeping a counter, and it +# stays under 2^31. +set -euo pipefail + +cd "$(dirname "$0")/.." + +UPLOAD=1 +N=$(date +%y%j%H%M) +while [ $# -gt 0 ]; do + case "$1" in + --no-upload) UPLOAD=0 ;; + --build-number) N="${2:?--build-number needs a number}"; shift ;; + *) echo "usage: $0 [--build-number N] [--no-upload]" >&2; exit 2 ;; + esac + shift +done + +TEAM=78SZC3DQ7B +PROJECT=src-tauri/gen/apple/tty7-mobile.xcodeproj/project.pbxproj +ARCHIVE=src-tauri/gen/apple/build/tty7-mobile_iOS.xcarchive +OUT=build/testflight +mkdir -p "$OUT" + +[ -f "$PROJECT" ] || npm run tauri ios init + +# Release signing off for the archive only; the project is put back however +# this exits. +cp "$PROJECT" "$OUT/project.pbxproj.orig" +trap 'cp "$OUT/project.pbxproj.orig" "$PROJECT"' EXIT +perl -0pi -e 's{(/\* release \*/ = \{\n\t+isa = XCBuildConfiguration;\n\t+buildSettings = \{\n)(?=(?:(?!\n\t+\};).)*?CODE_SIGN_ENTITLEMENTS)}{$1\t\t\t\tCODE_SIGNING_ALLOWED = NO;\n}sg' "$PROJECT" +if [ "$(grep -c 'CODE_SIGNING_ALLOWED = NO' "$PROJECT")" != 1 ]; then + echo "could not find the app target's release configuration in $PROJECT" >&2 + exit 1 +fi + +echo "==> Archiving build number $N" +rm -rf "$ARCHIVE" +npx tauri ios build --archive-only --build-number "$N" + +APP=$(ls -d "$ARCHIVE"/Products/Applications/*.app) +VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP/Info.plist") +BUILD=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP/Info.plist") + +DESTINATION=$([ "$UPLOAD" = 1 ] && echo upload || echo export) +cat > "$OUT/ExportOptions.plist" < + + + + methodapp-store-connect + destination$DESTINATION + teamID$TEAM + signingStyleautomatic + manageAppVersionAndBuildNumber + + +EOF + +echo "==> Signing$([ "$UPLOAD" = 1 ] && echo " and uploading") $VERSION ($BUILD)" +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportOptionsPlist "$OUT/ExportOptions.plist" \ + -exportPath "$OUT" \ + -allowProvisioningUpdates + +if [ "$UPLOAD" = 1 ]; then + echo "==> Uploaded $VERSION ($BUILD); it shows in TestFlight once App Store Connect has processed it" +else + echo "==> Signed .ipa in $OUT" +fi diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index da04b7da..21e441b8 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -112,6 +112,137 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -281,6 +412,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.0.0" @@ -475,7 +619,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -513,6 +657,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -1086,6 +1239,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.4.1" @@ -1097,6 +1256,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1124,6 +1304,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1444,8 +1644,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link", - "windows-result", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -1713,6 +1913,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1874,7 +2080,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2082,7 +2288,7 @@ dependencies = [ "socket2", "widestring", "windows-registry", - "windows-result", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -2275,6 +2481,25 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2318,7 +2543,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2388,7 +2613,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2539,6 +2764,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -2958,8 +3189,8 @@ dependencies = [ "tokio-util", "tracing", "web-sys", - "windows", - "windows-result", + "windows 0.62.2", + "windows-result 0.4.1", "wmi", ] @@ -3341,6 +3572,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3353,6 +3595,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -3414,7 +3666,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3527,6 +3779,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -3582,6 +3845,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3946,6 +4223,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.45" @@ -4774,8 +5064,8 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -4845,7 +5135,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.62.2", ] [[package]] @@ -4940,6 +5230,43 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-biometric" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd0e82d20c43b18fc4b2681092b48b25cc27a1381e37b14d71aa5beb2d5465" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4cf2c5723e6fe265a907591d5fae6b8b94edde39ad3876fdf425acf8d71f9b6" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows 0.61.3", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.0" @@ -4962,7 +5289,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", ] [[package]] @@ -4987,7 +5314,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", "wry", ] @@ -5040,6 +5367,19 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -5476,6 +5816,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-barcode-scanner", + "tauri-plugin-biometric", + "tauri-plugin-opener", "tokio", "tty7-mobile-client", "tty7-mobile-proto", @@ -5483,7 +5825,7 @@ dependencies = [ [[package]] name = "tty7-mobile-client" -version = "26.9.3" +version = "26.9.4" dependencies = [ "anyhow", "iroh", @@ -5495,7 +5837,7 @@ dependencies = [ [[package]] name = "tty7-mobile-proto" -version = "26.9.3" +version = "26.9.4" dependencies = [ "base64 0.22.1", "serde", @@ -5515,6 +5857,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -5835,8 +6188,8 @@ checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5857,8 +6210,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.21", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5914,16 +6267,38 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -5932,7 +6307,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5943,9 +6331,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -5954,9 +6353,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5981,20 +6380,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -6003,9 +6418,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -6014,7 +6438,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -6023,7 +6456,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6059,7 +6492,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6093,13 +6526,22 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6108,7 +6550,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6246,8 +6688,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.21", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -6294,8 +6736,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -6378,6 +6820,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -6483,3 +6995,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/mobile/src-tauri/Cargo.toml b/mobile/src-tauri/Cargo.toml index fd4db62c..1ed07a64 100644 --- a/mobile/src-tauri/Cargo.toml +++ b/mobile/src-tauri/Cargo.toml @@ -31,9 +31,12 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = [] } tokio = { version = "1", features = ["sync", "time", "macros"] } +tauri-plugin-opener = "2" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] tauri-plugin-barcode-scanner = "2" +# Face ID / passcode before the app shows anything, when turned on. +tauri-plugin-biometric = "2" # Setting one WKWebView property the Tauri API does not reach (`edge_to_edge`). [target.'cfg(target_os = "ios")'.dependencies] diff --git a/mobile/src-tauri/Info.ios.plist b/mobile/src-tauri/Info.ios.plist index c4dc529a..b2c9a55a 100644 --- a/mobile/src-tauri/Info.ios.plist +++ b/mobile/src-tauri/Info.ios.plist @@ -15,6 +15,10 @@ Settings → Mobile (tauri-plugin-barcode-scanner). --> NSCameraUsageDescription tty7 uses the camera to scan the pairing code your computer shows. + + NSFaceIDUsageDescription + tty7 uses Face ID to unlock the app when you turn on the lock in Settings. diff --git a/mobile/src-tauri/capabilities/default.json b/mobile/src-tauri/capabilities/default.json index 7e623717..dc3d54f5 100644 --- a/mobile/src-tauri/capabilities/default.json +++ b/mobile/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's single window: its own commands and the core IPC they ride on.", + "description": "The app's single window: its own commands, the core IPC they ride on, and opening a tapped link in the browser.", "windows": ["main"], - "permissions": ["core:default"] + "permissions": ["core:default", "opener:default"] } diff --git a/mobile/src-tauri/capabilities/mobile.json b/mobile/src-tauri/capabilities/mobile.json index 755848c3..5cac1044 100644 --- a/mobile/src-tauri/capabilities/mobile.json +++ b/mobile/src-tauri/capabilities/mobile.json @@ -8,6 +8,7 @@ "main" ], "permissions": [ - "barcode-scanner:default" + "barcode-scanner:default", + "biometric:default" ] } \ No newline at end of file diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index a3a81ebe..158abd3b 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -35,8 +35,10 @@ struct AppState { dir: PathBuf, endpoint: OnceCell, sessions: Mutex>, - /// The refresh half of each machine's control stream, by host id. - controls: Mutex>, + /// The refresh half of each open watch, by watch id, with its host id. + /// Each screen's watch is its own: one never ends another's stream. + controls: Mutex>, + next_watch: AtomicU32, panes: Mutex>>, next_pane: AtomicU32, } @@ -49,6 +51,18 @@ enum Up { Request(PaneRequest), } +#[cfg(test)] +mod tests { + #[test] + fn names_come_back_from_their_header_encoding() { + assert_eq!( + super::percent_decode("%E6%88%AA%E5%9B%BE%201.png"), + "截图 1.png" + ); + assert_eq!(super::percent_decode("100%"), "100%"); + } +} + 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. @@ -93,9 +107,11 @@ impl AppState { /// The live session to a machine, dialing a new one if there is none or /// the last one has dropped (the app was backgrounded, the network moved). + /// + /// The lock is not held while dialing: a machine that is offline takes a + /// long time to fail, and `forget` must not wait behind it. async fn session(&self, host_id: &str) -> CmdResult { - let mut sessions = self.sessions.lock().await; - if let Some(s) = sessions.get(host_id) + if let Some(s) = self.sessions.lock().await.get(host_id) && !s.is_closed() { return Ok(s.clone()); @@ -103,6 +119,19 @@ impl AppState { let host = self.host(host_id)?; let endpoint = self.endpoint().await.map_err(err)?; let session = Session::connect(endpoint, &host).await.map_err(err)?; + let mut sessions = self.sessions.lock().await; + // Forgotten while dialing: the session is not wanted. + if self.host(host_id).is_err() { + session.close(); + return Err("that machine is not paired".into()); + } + // Another call dialed it first: keep that one. + if let Some(s) = sessions.get(host_id) + && !s.is_closed() + { + session.close(); + return Ok(s.clone()); + } sessions.insert(host_id.to_string(), session.clone()); Ok(session) } @@ -149,12 +178,21 @@ async fn pair( #[tauri::command] async fn forget(state: State<'_, Arc>, host_id: String) -> CmdResult<()> { + // Off the list first, so a dial still under way finds it gone. + let mut hosts = state.load_hosts(); + hosts.retain(|h| h.id != host_id); + state.save_hosts(&hosts).map_err(err)?; if let Some(s) = state.sessions.lock().await.remove(&host_id) { s.close(); } - let mut hosts = state.load_hosts(); - hosts.retain(|h| h.id != host_id); - state.save_hosts(&hosts).map_err(err) + // After the close, so a refresh in flight on it fails rather than holds + // the lock. + state + .controls + .lock() + .await + .retain(|_, (host, _)| *host != host_id); + Ok(()) } /// What a machine's screen hears about it. @@ -174,14 +212,16 @@ async fn watch( state: State<'_, Arc>, host_id: String, on_event: Channel, -) -> CmdResult<()> { +) -> CmdResult { let session = state.session(&host_id).await?; let (sender, mut events) = session.control().await.map_err(err)?.split(); - state.controls.lock().await.insert(host_id.clone(), sender); + let id = state.next_watch.fetch_add(1, Ordering::Relaxed); + state.controls.lock().await.insert(id, (host_id, sender)); + let app_state = state.inner().clone(); tokio::spawn(async move { let mut link = tokio::time::interval(LINK_REPORT); - loop { + 'stream: loop { tokio::select! { event = events.next() => { // A gateway-side error (it lost the daemon) keeps the @@ -196,32 +236,41 @@ async fn watch( Err(e) => (TreeMsg::Error { message: err(e) }, true), }; if on_event.send(msg).is_err() { - return; + break 'stream; } if last { let _ = on_event.send(TreeMsg::Closed); - return; + break 'stream; } } _ = link.tick() => { if on_event.send(TreeMsg::Link { link: session.link() }).is_err() { - return; + break 'stream; } } } } + app_state.controls.lock().await.remove(&id); }); - Ok(()) + Ok(id) } #[tauri::command] -async fn refresh(state: State<'_, Arc>, host_id: String) -> CmdResult<()> { - match state.controls.lock().await.get_mut(&host_id) { - Some(sender) => sender.refresh().await.map_err(err), +async fn refresh(state: State<'_, Arc>, watch: u32) -> CmdResult<()> { + match state.controls.lock().await.get_mut(&watch) { + Some((_, sender)) => sender.refresh().await.map_err(err), None => Err("not watching that machine".into()), } } +/// Ends a watch: dropping its sender finishes the stream, the gateway stops +/// sending, and the reading task ends with it. +#[tauri::command] +async fn unwatch(state: State<'_, Arc>, watch: u32) -> CmdResult<()> { + state.controls.lock().await.remove(&watch); + Ok(()) +} + /// Starts a shell in a new tab at the end of a workspace. The frontend opens /// the returned pane like any other. #[tauri::command] @@ -240,6 +289,72 @@ async fn tab_new( .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 +/// name percent-encoded since a header is ASCII. +#[tauri::command] +async fn upload( + state: State<'_, Arc>, + request: tauri::ipc::Request<'_>, +) -> CmdResult { + let header = |name: &str| { + request + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(percent_decode) + }; + let host_id = header("x-host").ok_or("no machine given")?; + let name = header("x-name").unwrap_or_default(); + let machine = header("x-machine").filter(|m| !m.is_empty()); + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err("the file did not arrive as bytes".into()); + }; + let session = state.session(&host_id).await?; + session + .upload(machine.as_deref(), &name, bytes) + .await + .map_err(err) +} + +/// What has changed in the repository a pane's directory is in. +#[tauri::command] +async fn diff( + state: State<'_, Arc>, + host_id: String, + machine: Option, + cwd: String, +) -> CmdResult { + let session = state.session(&host_id).await?; + session.diff(machine.as_deref(), &cwd).await.map_err(err) +} + +/// Undoes `encodeURIComponent`. A malformed escape is kept as written. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = |b: u8| (b as char).to_digit(16); + match ( + bytes[i], + bytes.get(i + 1).copied().and_then(hex), + bytes.get(i + 2).copied().and_then(hex), + ) { + (b'%', Some(hi), Some(lo)) => { + out.push((hi * 16 + lo) as u8); + i += 3; + } + (b, _, _) => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + /// Opens a pane. Output arrives on `on_output` as `ArrayBuffer`s of raw /// terminal bytes, interleaved in order with JSON [`PaneEvent`]s; the returned /// handle addresses [`pane_input`] and [`pane_close`]. @@ -397,11 +512,14 @@ pub fn run() { // already in the tree. An `Err` means one is installed, which is fine. let _ = rustls::crypto::ring::default_provider().install_default(); - let builder = tauri::Builder::default(); + // Links tapped in a pane open in the phone's browser. + let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init()); // Scanning the pairing QR code. Phones only: the desktop dev build has no // camera to point and pastes the code instead. #[cfg(mobile)] - let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); + let builder = builder + .plugin(tauri_plugin_barcode_scanner::init()) + .plugin(tauri_plugin_biometric::init()); builder .setup(|app| { @@ -417,12 +535,13 @@ pub fn run() { controls: Mutex::new(HashMap::new()), panes: Mutex::new(HashMap::new()), next_pane: AtomicU32::new(1), + next_watch: AtomicU32::new(1), })); Ok(()) }) .invoke_handler(tauri::generate_handler![ - hosts, pair, forget, watch, refresh, tab_new, pane_open, pane_input, pane_lease, - pane_close, appearance + hosts, pair, forget, watch, unwatch, refresh, tab_new, upload, diff, pane_open, + pane_input, pane_lease, pane_close, appearance ]) .run(tauri::generate_context!()) .expect("error while running tty7"); diff --git a/mobile/src-tauri/tauri.conf.json b/mobile/src-tauri/tauri.conf.json index 573a67be..86a75b63 100644 --- a/mobile/src-tauri/tauri.conf.json +++ b/mobile/src-tauri/tauri.conf.json @@ -36,7 +36,8 @@ "frameworks": [ "SystemConfiguration" ], - "developmentTeam": "78SZC3DQ7B" + "developmentTeam": "78SZC3DQ7B", + "minimumSystemVersion": "15.0" } } } diff --git a/mobile/src/api.ts b/mobile/src/api.ts index b7223836..46eddd59 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -89,10 +89,13 @@ export const appearance = (style: "system" | "light" | "dark") => invoke(" export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) { const onEvent = new Channel(); onEvent.onmessage = onMsg; - return invoke("watch", { hostId, onEvent }); + return invoke("watch", { hostId, onEvent }); } -export const refresh = (hostId: string) => invoke("refresh", { hostId }); +/** Ends a watch by the id `watch` resolved with. */ +export const unwatch = (watch: number) => invoke("unwatch", { watch }); + +export const refresh = (watch: number) => invoke("refresh", { watch }); /** Output arrives as ArrayBuffers of raw terminal bytes, events as objects. */ /** `machine` is a remote's key, or null for the paired machine itself. */ @@ -131,4 +134,31 @@ export const paneInput = (handle: number, data: string) => export const paneLease = (handle: number, size: { cols: number; rows: number } | null) => invoke("pane_lease", { handle, size }); +/** The largest file the gateway takes (`tty7_mobile_proto::MAX_UPLOAD`), + * checked here too so a big video is refused before it is read in. */ +export const MAX_UPLOAD = 20 * 2 ** 20; + +/** Sends a file to the machine a pane runs on; resolves with its path there. + * The bytes go as the raw body; the rest rides in headers, encoded, since a + * header is ASCII and a file name need not be. */ +export const upload = (hostId: string, machine: string | null, name: string, bytes: Uint8Array) => + invoke("upload", bytes, { + headers: { + "x-host": encodeURIComponent(hostId), + "x-name": encodeURIComponent(name), + "x-machine": encodeURIComponent(machine ?? ""), + }, + }); + +export interface Diff { + root: string; + patch: string; + untracked: string[]; + truncated: boolean; +} + +/** What has changed in the repository `cwd` is in. */ +export const diff = (hostId: string, machine: string | null, cwd: string) => + invoke("diff", { hostId, machine, cwd }); + export const paneClose = (handle: number) => invoke("pane_close", { handle }); diff --git a/mobile/src/icons.ts b/mobile/src/icons.ts index 16fb5ac8..2dd68c92 100644 --- a/mobile/src/icons.ts +++ b/mobile/src/icons.ts @@ -42,6 +42,9 @@ export const icon = { settings: stroke( ``, ), + attach: stroke( + ``, + ), history: stroke(``), alert: stroke(``), }; diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 5a731619..fa5b4eff 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -3,18 +3,23 @@ import "./style.css"; import { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; +import { SearchAddon } from "@xterm/addon-search"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { authenticate, checkStatus } from "@tauri-apps/plugin-biometric"; import type { ITheme } from "@xterm/xterm"; import * as scanner from "@tauri-apps/plugin-barcode-scanner"; import { getVersion } from "@tauri-apps/api/app"; import * as api from "./api"; +import { MAX_UPLOAD } from "./api"; import type { AgentStatus, AgentView, Host, LinkInfo, PaneView, + RemoteView, TabView, Tree, WorkspaceView, @@ -99,9 +104,12 @@ interface Prefs { textSize: number; /** How a pane wider than the phone first shows: panning, or shrunk. */ wide: "readable" | "fit"; + /** Face ID (or the passcode) before the app shows, on launch and after a + * minute away. */ + lock: boolean; } -const PREFS: Prefs = { appearance: "system", textSize: 11, wide: "readable" }; +const PREFS: Prefs = { appearance: "system", textSize: 11, wide: "readable", lock: false }; const prefs: Prefs = (() => { try { @@ -188,9 +196,50 @@ let onResume: (() => void) | null = null; let onLeave: (() => void) | null = null; document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") onResume?.(); + if (document.visibilityState === "visible") { + if (prefs.lock && Date.now() - hiddenAt > LOCK_AFTER_MS) lock(); + onResume?.(); + } else hiddenAt = Date.now(); }); +// The app lock: a cover over everything until Face ID or the passcode says +// it is the phone's owner. Streams go on underneath; nothing shows. +const LOCK_AFTER_MS = 60_000; +let hiddenAt = Date.now(); +let cover: HTMLElement | null = null; +function lock() { + if (cover) return; + const unlock = h("button", { class: "button primary" }, "Unlock"); + cover = h( + "div", + { class: "lock-cover" }, + h("img", { class: "empty-mark", src: logoUrl, alt: "" }), + h("h2", { class: "empty-title" }, "tty7 is locked"), + unlock, + ); + const tryUnlock = async () => { + try { + await authenticate("Unlock tty7", { allowDeviceCredential: true }); + cover?.remove(); + cover = null; + } catch { + // Cancelled or failed: the cover stays, Unlock tries again. + } + }; + unlock.onclick = tryUnlock; + document.body.append(cover); + void tryUnlock(); +} + +/** Whether this phone can lock the app: Face ID, Touch ID or a passcode. */ +async function canLock() { + try { + return (await checkStatus()).isAvailable; + } catch { + return false; + } +} + const still = matchMedia("(prefers-reduced-motion: reduce)"); function go(direction: "push" | "pop", render: () => HTMLElement) { @@ -198,6 +247,7 @@ function go(direction: "push" | "pop", render: () => HTMLElement) { onLeave?.(); onLeave = null; onResume = null; + onBack = null; app.replaceChildren(render()); }; if (!document.startViewTransition || still.matches || !app.firstChild) { @@ -210,6 +260,63 @@ function go(direction: "push" | "pop", render: () => HTMLElement) { .finished.finally(() => delete document.documentElement.dataset.nav); } +// Swiping in from the left edge goes back, the way a phone's own apps do. The +// WebView has no stack of pages to do it natively — each screen is swapped in +// place (`go`) — so it is done here. Each pushed screen sets its way back. +let onBack: (() => void) | null = null; +const EDGE = 24; +let edgeSwipe: { x: number; y: number; t: number; claimed: boolean } | null = null; +// Listened for ahead of every screen's own touch handling, so the terminal's +// scrolling and a wide pane's sideways pan never see an edge swipe. +document.addEventListener( + "touchstart", + (e) => { + edgeSwipe = null; + if (!onBack || e.touches.length !== 1) return; + // Not from under a sheet: the scrim covers the screen it would leave. + if (!app.contains(e.target as Node)) return; + const t = e.touches[0]; + if (t.clientX > EDGE) return; + edgeSwipe = { x: t.clientX, y: t.clientY, t: e.timeStamp, claimed: false }; + }, + { capture: true, passive: true }, +); +document.addEventListener( + "touchmove", + (e) => { + if (!edgeSwipe || e.touches.length !== 1) return; + const t = e.touches[0]; + const dx = t.clientX - edgeSwipe.x; + const dy = Math.abs(t.clientY - edgeSwipe.y); + if (!edgeSwipe.claimed) { + if (Math.max(Math.abs(dx), dy) < 8) return; + // Mostly sideways and rightwards, it is a swipe back; anything else is + // the screen's own. + if (dx <= dy) return (edgeSwipe = null); + edgeSwipe.claimed = true; + } + e.preventDefault(); + e.stopPropagation(); + }, + { capture: true, passive: false }, +); +document.addEventListener( + "touchend", + (e) => { + const swipe = edgeSwipe; + edgeSwipe = null; + if (!swipe?.claimed) return; + e.preventDefault(); + e.stopPropagation(); + const t = e.changedTouches[0]; + const dx = t.clientX - swipe.x; + // Far enough across, or flicked. + if (dx > Math.min(80, innerWidth * 0.25) || dx / Math.max(1, e.timeStamp - swipe.t) > 0.4) onBack?.(); + }, + { capture: true, passive: false }, +); +document.addEventListener("touchcancel", () => (edgeSwipe = null), { capture: true, passive: true }); + interface ScreenParts { title: string; back?: { label: string; onclick: () => void }; @@ -225,6 +332,7 @@ interface ScreenParts { /** A screen with a large title that folds into the bar as it scrolls away. */ function screen(parts: ScreenParts) { const root = !parts.back; + onBack = parts.back?.onclick ?? null; const bar = h( "header", { class: "nav" }, @@ -401,6 +509,11 @@ function settingsScreen() { go("push", () => { const body = h("div", { class: "stack" }); const version = h("span", { class: "row-meta" }); + let lockable = false; + void canLock().then((ok) => { + lockable = ok; + if (ok) draw(); + }); void getVersion().then((v) => (version.textContent = v), () => {}); /** One setting's options, as rows with a check on the chosen one. */ @@ -416,6 +529,34 @@ function settingsScreen() { ); const note = (group: HTMLElement, text: string) => (group.append(h("p", { class: "group-note" }, text)), group); + const lockSetting = () => + note( + section( + "Privacy", + h( + "button", + { + class: "row choice", + onclick: async () => { + // Turning it on proves it works first, so no one locks + // themselves out. + if (!prefs.lock) { + try { + await authenticate("Turn on the tty7 lock", { allowDeviceCredential: true }); + } catch { + return; + } + } + setPref("lock", !prefs.lock); + draw(); + }, + }, + h("span", { class: "row-title" }, "Lock with Face ID"), + h("span", { class: prefs.lock ? "switch on" : "switch" }), + ), + ), + "Asks for Face ID or the passcode when tty7 opens, and when you come back after a minute away.", + ); const draw = () => { const saved = sentHistory().length; const clear = h( @@ -445,6 +586,20 @@ function settingsScreen() { section("Panes wider than the phone", ...choices(WIDE, prefs.wide, (v) => setPref("wide", v))), "How such a pane first shows. Switch any time from its ⋯ menu.", ), + note( + section( + "Key bar", + h( + "button", + { class: "row choice", onclick: () => keysScreen() }, + h("span", { class: "row-title" }, "Keys above the message box"), + h("span", { class: "row-meta" }, remembered("keys") && remembered("keys") !== "null" ? "Custom" : "Default"), + ico("chevron", "icon row-chevron"), + ), + ), + "Choose, order and add the keys a pane shows. A key can send any text, so a prompt you use often can be one tap.", + ), + ...(lockable ? [lockSetting()] : []), note( section("Message history", clear), "What you send from the message box is kept on this phone, to suggest as you write and to search from the history button.", @@ -464,6 +619,147 @@ function settingsScreen() { }); } +/** Settings → Key bar: the key row's pages, each key removable and movable, + * keys added from a catalog or written out. */ +function keysScreen() { + go("push", () => { + let pages = keyPages().map((p) => [...p]); + const body = h("div", { class: "stack" }); + const save = () => { + pages = pages.filter((p, i) => p.length || i === pages.length - 1); + if (!pages.length) pages = [[]]; + saveKeyPages(pages); + draw(); + }; + // Up past a page's first key goes to the end of the page before; down + // past its last, to the start of the next — when that page has room. + const move = (pi: number, ki: number, by: -1 | 1) => { + const page = pages[pi]; + const to = ki + by; + if (to >= 0 && to < page.length) { + [page[ki], page[to]] = [page[to], page[ki]]; + } else { + const other = pages[pi + by]; + if (!other || other.length >= KEYS_PER_PAGE) return; + const [k] = page.splice(ki, 1); + if (by < 0) other.push(k); + else other.unshift(k); + } + save(); + }; + const draw = () => { + const groups = pages.map((page, pi) => { + const rows = page.map((k, ki) => { + const first = pi === 0 && ki === 0; + const last = pi === pages.length - 1 && ki === page.length - 1; + return h( + "div", + { class: "row key-edit" }, + h("span", { class: "key key-sample" }, keyFace(k)), + h("span", { class: "row-title" }, k.latch ? "ctrl (hold for the next key)" : k.paste ? "Paste" : k.label), + h("button", { class: "nav-icon", ariaLabel: `Move ${k.label} up`, disabled: first, onclick: () => move(pi, ki, -1) }, ico("up")), + h("button", { class: "nav-icon", ariaLabel: `Move ${k.label} down`, disabled: last, onclick: () => move(pi, ki, 1) }, ico("down")), + h( + "button", + { class: "nav-icon danger", ariaLabel: `Remove ${k.label}`, onclick: () => (page.splice(ki, 1), save()) }, + ico("trash"), + ), + ); + }); + const full = page.length >= KEYS_PER_PAGE; + const add = h( + "button", + { class: "row choice", disabled: full, onclick: () => addKeySheet((k) => (page.push(k), save())) }, + h("span", { class: "row-title accent" }, full ? "This page is full" : "Add a key"), + ico("plus", "icon row-chevron"), + ); + return section(`Page ${pi + 1}`, ...rows, add); + }); + const more = h( + "button", + { + class: "row choice", + disabled: pages.length >= MAX_KEY_PAGES || pages[pages.length - 1].length === 0, + onclick: () => (pages.push([]), draw()), + }, + h("span", { class: "row-title accent" }, "Add a page"), + ); + const reset = h( + "button", + { + class: "row choice", + onclick: () => { + saveKeyPages(null); + pages = KEY_PAGES.map((p) => [...p]); + draw(); + }, + }, + h("span", { class: "row-title danger" }, "Reset to the default keys"), + ); + body.replaceChildren( + ...groups, + section(null, more, reset), + h("p", { class: "group-note keys-note" }, "Swipe the key row sideways in a pane to reach the next page."), + ); + }; + draw(); + return screen({ + title: "Key bar", + back: { label: "Settings", onclick: () => settingsScreen() }, + body: [body], + }); + }); +} + +/** Picks a key to add: one of the catalog's, or one written out. */ +function addKeySheet(add: (k: Key) => void) { + const grid = h( + "div", + { class: "key-catalog" }, + ...KEY_CATALOG.map((k) => + h("button", { class: "key", ariaLabel: k.label, onclick: () => (sheet.close(), add(k)) }, keyFace(k)), + ), + ); + const label = h("input", { class: "field-input", placeholder: "Label, e.g. compact", ariaLabel: "Label" }); + const sends = h("input", { + class: "field-input code", + placeholder: "Sends, e.g. /compact\\r", + autocapitalize: "off", + spellcheck: false, + ariaLabel: "What it sends", + }); + for (const f of [label, sends]) f.setAttribute("autocorrect", "off"); + const error = h("p", { class: "field-error", role: "alert" }); + const make = h("button", { class: "button primary wide" }, "Add key"); + make.onclick = () => { + const seq = keySequence(sends.value); + if (!seq) { + error.textContent = "Write what the key sends."; + return; + } + const name = label.value.trim() || sends.value.trim(); + sheet.close(); + add({ label: name, seq, flex: Math.min(2, Math.max(1, name.length / 4)) }); + }; + const sheet = openSheet( + "Add a key", + h( + "div", + { class: "sheet-body" }, + h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Keys"), grid), + h( + "section", + { class: "sheet-group" }, + h("h3", { class: "group-title" }, "Your own"), + h("div", { class: "card form-card" }, h("label", { class: "row field" }, label), h("label", { class: "row field" }, sends)), + h("p", { class: "group-note" }, "Text as written. ^C for ctrl-C; \\r for Enter, \\e for Esc, \\t for Tab."), + error, + make, + ), + ), + ); +} + function hostRow(host: Host) { const meta = hostMeta(host.id); return h( @@ -702,7 +998,10 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { renderLink(link, info); seen.set(host.id, { link: info, tabs: seen.get(host.id)?.tabs ?? null }); }; - showLink({ path: "connecting", rtt_ms: 0 }); + // Coming back to a machine whose link was up, it still is: the session + // outlives the screen, so the last report stands until a new one comes. + const lastLink = () => seen.get(host.id)?.link ?? { path: "connecting" as const, rtt_ms: 0 }; + showLink(lastLink()); const notice = h("div", { class: "notice-slot" }); const body = h("div", { class: "stack" }, skeleton()); @@ -710,22 +1009,39 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { let lastTree: Tree | null = null; let slow: number | undefined; let query = ""; + // The one workspace on screen, kept per machine across visits. + let picked = remembered(`workspace.${host.id}`); + const pick = (key: string) => { + picked = key; + remember(`workspace.${host.id}`, key); + draw(); + }; const draw = () => { - if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query)); + if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query, picked, pick)); }; const dock = floatingBar("Search tabs", (q) => { query = q; draw(); - }, { label: "New tab", run: () => lastTree && newTabSheet(host, lastTree, failed) }); + }, { label: "New tab", run: () => lastTree && newTabSheet(host, lastTree, failed, picked) }); const menu = menuButton([ - { label: "Refresh", icon: "refresh", run: () => api.refresh(host.id).catch(() => start()) }, + { + label: "Refresh", + icon: "refresh", + run: () => (watching === null ? start() : api.refresh(watching).catch(() => start())), + }, { label: "Forget this machine", icon: "trash", danger: true, run: async () => { - if (confirm(`Forget ${host.name}? You'll need a new pairing code to reach it again.`)) { + if ( + await confirmSheet( + `Forget ${host.name}?`, + "You'll need a new pairing code to reach it again.", + "Forget", + ) + ) { await api.forget(host.id); hostsScreen(); } @@ -744,6 +1060,15 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { const retry = retrier(() => start()); let offlineNow = false; + // The screen's one watch. Starting again ends the last, and only the + // newest is listened to: a watch that is ending says "closed", which is + // not this machine going away. + let watching: number | null = null; + let generation = 0; + const unwatch = () => { + if (watching !== null) api.unwatch(watching).catch(() => {}); + watching = null; + }; // The notice on screen is the dropped connection's, for a tree to clear. let dropped = false; // A network that comes back is the moment to try, not the next tick. @@ -754,6 +1079,8 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { onLeave = () => { alive = false; + generation++; + unwatch(); clearTimeout(slow); retry.cancel(); window.removeEventListener("online", online); @@ -784,13 +1111,15 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { }; const start = async () => { + const mine = ++generation; + unwatch(); offlineNow = false; retry.cancel(); clearTimeout(slow); // Retrying under a tree, the dropped connection's notice stays up to // say why the tree may be stale, until a new one replaces it. if (!(lastTree && dropped)) notice.replaceChildren(); - showLink({ path: "connecting", rtt_ms: 0 }); + showLink(lastLink()); if (!lastTree) body.replaceChildren(skeleton()); // A machine that never answers leaves nothing to show but a spinner; // say what is likely wrong while still trying. @@ -810,8 +1139,8 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { ); }, SLOW_CONNECT_MS); try { - await api.watch(host.id, (msg) => { - if (!alive) return; + const id = await api.watch(host.id, (msg) => { + if (!alive || mine !== generation) return; switch (msg.type) { case "tree": clearTimeout(slow); @@ -835,8 +1164,12 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { break; } }); + // Left, or started again, while this one was coming up: it is no + // one's. + if (!alive || mine !== generation) api.unwatch(id).catch(() => {}); + else watching = id; } catch (e) { - if (alive) { + if (alive && mine === generation) { clearTimeout(slow); offline(errorText(e)); } @@ -904,7 +1237,19 @@ function matching(ws: WorkspaceView, query: string): WorkspaceView { }; } -function renderTree(host: Host, tree: Tree, query = ""): Node[] { +/** What names one entry of the workspace switcher: a workspace here, one on + * a linked machine, or a linked machine with no workspaces to show. */ +function spaceKey(place: Place, ws: WorkspaceView | null) { + return `${place?.key ?? "local"}:${ws?.id ?? ""}`; +} + +function renderTree( + host: Host, + tree: Tree, + query: string, + picked: string | null, + pick: (key: string) => void, +): Node[] { if (query) { const groups = [ ...tree.workspaces.map((ws) => [null, matching(ws, query)] as const), @@ -914,12 +1259,9 @@ function renderTree(host: Host, tree: Tree, query = ""): Node[] { ? groups.map(([place, ws]) => workspaceGroup(host, place, ws, place?.name)) : [h("p", { class: "search-empty" }, `No tab matches “${query}”.`)]; } - const out: Node[] = []; const remotes = tree.remotes ?? []; - - out.push(...tree.workspaces.map((ws) => workspaceGroup(host, null, ws))); - if (tree.workspaces.length === 0) { - out.push( + if (tree.workspaces.length === 0 && remotes.length === 0) { + return [ h( "div", { class: "empty" }, @@ -927,74 +1269,122 @@ function renderTree(host: Host, tree: Tree, query = ""): Node[] { h("h2", { class: "empty-title" }, "Nothing open"), h("p", { class: "empty-body" }, `Open a tab in tty7 on ${host.name} and it appears here.`), ), + ]; + } + + // One workspace at a time, picked from a row above it: this machine's + // workspaces first, then those of every machine it reaches over SSH. A + // linked machine with none to show gets one entry for its state. + type Space = { key: string; label: string; count: number | null; remote: RemoteView | null; ws: WorkspaceView | null }; + const spaces: Space[] = [ + ...tree.workspaces.map((ws) => ({ + key: spaceKey(null, ws), + label: workspaceName(ws.name), + count: ws.tabs.length, + remote: null, + ws, + })), + ...remotes.flatMap((r): Space[] => { + const ready = r.connected && !r.error && !r.pending; + return ready && r.workspaces.length + ? r.workspaces.map((ws) => ({ + key: spaceKey(r, ws), + label: `${r.name} · ${workspaceName(ws.name)}`, + count: ws.tabs.length, + remote: r, + ws, + })) + : [{ key: spaceKey(r, null), label: r.name, count: null, remote: r, ws: null }]; + }), + ]; + const on = spaces.find((sp) => sp.key === picked) ?? spaces[0]; + + const out: Node[] = []; + if (spaces.length > 1) { + const bar = h( + "div", + { class: "ws-bar", role: "tablist", ariaLabel: "Workspaces" }, + ...spaces.map((sp) => { + const chip = h( + "button", + { class: sp === on ? "ws-chip on" : "ws-chip", role: "tab", onclick: () => pick(sp.key) }, + sp.remote && ico("server"), + h("span", { class: "ws-chip-name" }, sp.label), + sp.count !== null && h("span", { class: "ws-chip-count" }, String(sp.count)), + ); + chip.setAttribute("aria-selected", String(sp === on)); + return chip; + }), + ); + out.push(bar); + // The picked entry in view, when it is far along the row. + requestAnimationFrame(() => + bar.querySelector(".ws-chip.on")?.scrollIntoView({ block: "nearest", inline: "nearest" }), ); } - // Then every machine the desktop reaches over SSH, as the desktop's - // sidebar lists them: its own heading, its workspaces under it. - for (const remote of remotes) { - const state = !remote.connected - ? "Link down" - : remote.error - ? "Not answering" - : remote.pending - ? "Reading…" - : "Connected"; - const tone = !remote.connected || remote.error ? "offline" : remote.pending ? "connecting" : "direct"; - out.push( - h( - "section", - { class: "remote" }, - h( - "div", - { class: "remote-head" }, - h("span", { class: "tile" }, ico("server")), - h( - "div", - { class: "remote-titles" }, - h("h2", { class: "remote-name" }, remote.name), - h( - "p", - { class: `link ${tone}` }, - h("span", { class: "link-dot" }), - `SSH · ${state}`, - ), - ), - ), - !remote.connected && - h( - "p", - { class: "remote-note" }, - `tty7 on ${host.name} lost its link to ${remote.name}. Reconnect it there to reach its workspaces.`, - ), - remote.error && h("p", { class: "remote-note" }, sentence(remote.error)), - remote.pending && skeleton(), - remote.connected && - !remote.error && - !remote.pending && - remote.workspaces.length === 0 && - h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`), - ...remote.workspaces.map((ws) => workspaceGroup(host, remote, ws)), - ), - ); - } + const remote = on.remote; + if (remote) out.push(remoteState(host, remote)); + // The switcher already names it and counts its tabs. + if (on.ws) out.push(workspaceGroup(host, remote, on.ws, undefined, spaces.length === 1)); return out; } +/** A linked machine's heading: its name and the state of the link to it. */ +function remoteState(host: Host, remote: RemoteView) { + const state = !remote.connected + ? "Link down" + : remote.error + ? "Not answering" + : remote.pending + ? "Reading…" + : "Connected"; + const tone = !remote.connected || remote.error ? "offline" : remote.pending ? "connecting" : "direct"; + return h( + "section", + { class: "remote" }, + h( + "div", + { class: "remote-head" }, + h("span", { class: "tile" }, ico("server")), + h( + "div", + { class: "remote-titles" }, + h("h2", { class: "remote-name" }, remote.name), + h("p", { class: `link ${tone}` }, h("span", { class: "link-dot" }), `SSH · ${state}`), + ), + ), + !remote.connected && + h( + "p", + { class: "remote-note" }, + `tty7 on ${host.name} lost its link to ${remote.name}. Reconnect it there to reach its workspaces.`, + ), + remote.error && h("p", { class: "remote-note" }, sentence(remote.error)), + remote.pending && skeleton(), + remote.connected && + !remote.error && + !remote.pending && + remote.workspaces.length === 0 && + h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`), + ); +} + /** A workspace is one card of its tabs, named as the desktop's sidebar names * them; a split tab gives each of its panes a row. `where` names the machine * when a search mixes them. */ -function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, where?: string) { +function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, where?: string, headed = true) { const rows = ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane))); return h( "section", { class: "group" }, - h( - "div", - { class: "group-head" }, - h("h2", { class: "group-title" }, where ? `${where} · ${workspaceName(ws.name)}` : workspaceName(ws.name)), - h("span", { class: "group-count" }, String(ws.tabs.length)), - ), + headed && + h( + "div", + { class: "group-head" }, + h("h2", { class: "group-title" }, where ? `${where} · ${workspaceName(ws.name)}` : workspaceName(ws.name)), + h("span", { class: "group-count" }, String(ws.tabs.length)), + ), rows.length ? h("div", { class: "card" }, ...rows) : h("p", { class: "group-empty" }, "No tabs open."), ); } @@ -1025,7 +1415,7 @@ function remember(key: string, value: string) { /** The sheet "+" opens on a machine: pick what runs, pick the workspace, Open. * The tab starts in the directory its workspace's last tab is in, sized to * this screen, and the agent's command is typed into it once it is live. */ -function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void) { +function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, picked: string | null) { type Target = { place: Place; ws: WorkspaceView }; const targets: Target[] = [ ...tree.workspaces.map((ws) => ({ place: null, ws })), @@ -1034,7 +1424,8 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void) .flatMap((r) => r.workspaces.map((ws) => ({ place: { key: r.key, name: r.name }, ws }))), ]; let starter = Math.max(0, STARTERS.findIndex((s) => (s.kind ?? "shell") === remembered("newtab.agent"))); - let target = 0; + // The workspace on screen, unless it cannot take a new tab. + let target = Math.max(0, targets.findIndex((t) => spaceKey(t.place, t.ws) === picked)); const agents = h("div", { class: "agent-grid" }); const places = h("div", { class: "card" }); @@ -1124,6 +1515,97 @@ function openSheet(title: string, ...content: Child[]) { return { close, remove: () => scrim.remove() }; } +/** What has changed in the repository a pane is in: a block per file, its + * lines coloured, then the files git does not track yet. */ +function changesSheet(host: Host, place: Place, cwd: string) { + const body = h("div", { class: "sheet-body diff-body" }, skeleton()); + openSheet("Changes", body); + api.diff(host.id, place?.key ?? null, cwd).then( + (d) => body.replaceChildren(...renderDiff(d)), + (e) => body.replaceChildren(h("p", { class: "group-empty" }, sentence(errorText(e)))), + ); +} + +function renderDiff(d: api.Diff): Node[] { + const files = d.patch.split(/^(?=diff --git )/m).filter((f) => f.startsWith("diff --git ")); + const out: Node[] = [ + h("p", { class: "diff-root" }, shortPath(d.root), files.length || d.untracked.length ? "" : " · no changes"), + ]; + for (const f of files) { + const lines = f.split("\n"); + const name = + /^\+\+\+ b\/(.*)$/m.exec(f)?.[1] ?? /^--- a\/(.*)$/m.exec(f)?.[1] ?? /^diff --git a\/(.*) b\//.exec(f)?.[1] ?? "?"; + let add = 0; + let del = 0; + const code = h("div", { class: "diff-code" }); + let inHunk = false; + for (const line of lines) { + if (line.startsWith("@@")) inHunk = true; + if (!inHunk) continue; + const kind = line.startsWith("@@") ? "hunk" : line[0] === "+" ? "add" : line[0] === "-" ? "del" : "ctx"; + if (kind === "add") add++; + if (kind === "del") del++; + code.append(h("div", { class: `diff-line ${kind}` }, line || " ")); + } + const binary = !inHunk && /^Binary files/m.test(f); + const block = h( + "details", + { class: "diff-file", open: files.length <= 6 }, + h( + "summary", + {}, + h("span", { class: "diff-name" }, name), + h("span", { class: "diff-add" }, `+${add}`), + h("span", { class: "diff-del" }, `−${del}`), + ), + binary ? h("p", { class: "group-empty" }, "A binary file.") : code, + ); + out.push(block); + } + if (d.truncated) out.push(h("p", { class: "group-empty" }, "Cut short: the rest is too long to show here.")); + if (d.untracked.length) { + out.push( + section( + `Not tracked yet · ${d.untracked.length}`, + ...d.untracked.slice(0, 200).map((p) => h("div", { class: "row choice static" }, h("span", { class: "row-title diff-name" }, p))), + ), + ); + } + return out; +} + +/** Asks before something that cannot be undone. `window.confirm` is no use: + * the iOS WebView shows nothing for it and answers "no" at once. */ +function confirmSheet(title: string, text: string, action: string): Promise { + return new Promise((resolve) => { + let answered = false; + const answer = (yes: boolean) => { + if (answered) return; + answered = true; + sheet.close(); + resolve(yes); + }; + const sheet = openSheet( + title, + h( + "div", + { class: "sheet-body" }, + h("p", { class: "sheet-text" }, text), + h("button", { class: "button danger wide", onclick: () => answer(true) }, action), + h("button", { class: "button tinted wide", onclick: () => answer(false) }, "Cancel"), + ), + ); + // Closed with its × or by tapping outside it: a no. + const scrim = document.body.lastElementChild!; + new MutationObserver((_, obs) => { + if (!scrim.isConnected || scrim.classList.contains("leaving")) { + obs.disconnect(); + answer(false); + } + }).observe(scrim, { attributes: true, attributeFilter: ["class"] }); + }); +} + /** The grid that fills this screen at the readable size: what a tab started * here is spawned at, since no desktop window is showing it yet. */ function phoneGrid() { @@ -1188,6 +1670,10 @@ function avatar(agent: AgentView | null | undefined, cls = "avatar") { if (agent) { const look = agentLook(agent.kind); el.title = `${look.name}: ${STATUS_WORD[agent.status]}`; + // The agent's own colours, the disc the desktop's sidebar draws. + el.classList.add("branded"); + el.style.setProperty("--brand-field", look.field); + el.style.setProperty("--brand-ink", look.ink); if (look.mark) { const mark = h("span", { class: "mark" }); mark.style.setProperty("--mark", `url("${look.mark}")`); @@ -1326,7 +1812,7 @@ type Key = { }; /** The key row, a page at a time: what an agent and a shell need most first, - * a swipe away the rest. */ + * a swipe away the rest. Settings → Key bar changes it. */ const KEY_PAGES: Key[][] = [ [ { label: "esc", seq: "\x1b", flex: 1.2 }, @@ -1367,6 +1853,70 @@ const KEY_PAGES: Key[][] = [ ], ]; +/** A page holds this many keys before they get too narrow to hit. */ +const KEYS_PER_PAGE = 8; +const MAX_KEY_PAGES = 6; + +/** The key row as this phone has it: its own, or the default. */ +function keyPages(): Key[][] { + try { + const saved = JSON.parse(remembered("keys") ?? "null"); + if ( + Array.isArray(saved) && + saved.length && + saved.every( + (page) => + Array.isArray(page) && + page.every((k) => k && typeof k.label === "string" && typeof k.seq === "string"), + ) + ) + return saved; + } catch { + // Unreadable: the default stands. + } + return KEY_PAGES; +} + +function saveKeyPages(pages: Key[][] | null) { + remember("keys", pages ? JSON.stringify(pages.filter((p) => p.length)) : "null"); +} + +/** Keys to add from, beyond the default row: the rest of what a shell or an + * agent is driven with. */ +const KEY_CATALOG: Key[] = [ + ...KEY_PAGES.flat(), + { label: "ctrl D", text: "^D", seq: "\x04" }, + { label: "ctrl Z", text: "^Z", seq: "\x1a" }, + { label: "ctrl L", text: "^L", seq: "\x0c" }, + { label: "ctrl A", text: "^A", seq: "\x01" }, + { label: "ctrl E", text: "^E", seq: "\x05" }, + { label: "ctrl U", text: "^U", seq: "\x15" }, + { label: "ctrl W", text: "^W", seq: "\x17" }, + { label: "Page Up", text: "PgUp", seq: "\x1b[5~", flex: 1.3 }, + { label: "Page Down", text: "PgDn", seq: "\x1b[6~", flex: 1.3 }, + { label: "Delete", text: "del", seq: "\x1b[3~", flex: 1.2 }, + { label: "Backspace", text: "⌫", seq: "\x7f" }, + { label: "Space", text: "␣", seq: " " }, + ...[":", ";", "@", "#", "$", "*", "&", "=", "'", '"', "`", "\\", "<", ">", "[", "]", "{", "}", "(", ")"].map( + (c) => ({ label: c, seq: c }), + ), +].filter((k, i, all) => all.findIndex((o) => o.label === k.label) === i); + +/** What a custom key's "sends" field means: text as written, with `^X` for + * ctrl-X and `\e \r \n \t \\` for escape, Enter, newline, tab and a + * backslash. */ +function keySequence(written: string) { + return written.replace(/\^([A-Za-z@\[\]\\^_?])|\\([ernt\\])/g, (_, ctrl?: string, esc?: string) => { + if (ctrl) return ctrl === "?" ? "\x7f" : String.fromCharCode(ctrl.toUpperCase().charCodeAt(0) & 0x1f); + return { e: "\x1b", r: "\r", n: "\n", t: "\t", "\\": "\\" }[esc as "e"]; + }); +} + +/** A key as the bar shows it. */ +function keyFace(k: Key) { + return k.icon ? ico(k.icon) : (k.text ?? k.label); +} + /** Hack's advance width, in ems — what the fit divides by. */ const CELL_EM = 0.602; /** The smallest font a pane is read at before it pans instead of shrinking: @@ -1389,12 +1939,15 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const where = (path: string | null | undefined) => place && path ? `${place.name}:${shortPath(path)}` : shortPath(path); const cwd = h("span", { class: "term-cwd" }, where(pane.cwd)); + let paneCwd = pane.cwd ?? null; const sub = h("span", { class: "term-sub" }, state, cwd); // Copying, the fit and the phone's size live in the ⋯ menu; the bar keeps // only the way back and what this is. const menu = menuButton( () => [ { label: "Select text", icon: "copy", run: () => selecting(copyView.hidden) }, + { label: "Find", icon: "search", run: () => finding(true) }, + ...(paneCwd ? [{ label: "Changes", icon: "compose" as const, run: () => changesSheet(host, place, paneCwd!) }] : []), ...(cramped && !leased ? [ { @@ -1412,10 +1965,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, ], "round", ); + const back = () => hostScreen(host); + onBack = back; const bar = h( "header", { class: "term-nav" }, - h("button", { class: "round", ariaLabel: `Back to ${host.name}`, onclick: () => hostScreen(host) }, ico("back")), + h("button", { class: "round", ariaLabel: `Back to ${host.name}`, onclick: back }, ico("back")), h("div", { class: "term-titles" }, h("span", { class: "term-title" }, title), sub), menu, ); @@ -1432,6 +1987,29 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, h("div", { class: "term-copy-bar" }, h("span", {}, "Select text to copy"), copyDone), copyText, ); + // Find in the pane's scrollback: the match is selected and scrolled to. + const findInput = h("input", { + type: "search", + class: "search-input", + placeholder: "Find in this pane", + enterKeyHint: "search", + autocapitalize: "off", + spellcheck: false, + ariaLabel: "Find in this pane", + }); + findInput.setAttribute("autocorrect", "off"); + const findCount = h("span", { class: "find-count" }); + const findPrev = h("button", { class: "nav-icon", ariaLabel: "Previous match" }, ico("up")); + const findNext = h("button", { class: "nav-icon", ariaLabel: "Next match" }, ico("down")); + const findDone = h("button", { class: "button tinted small" }, "Done"); + const findBar = h( + "div", + { class: "find-bar", hidden: true }, + h("label", { class: "search" }, ico("search"), findInput, findCount), + findPrev, + findNext, + findDone, + ); const pages = h("div", { class: "key-pages" }); const dots = h("div", { class: "key-dots", ariaHidden: "true" }); @@ -1454,8 +2032,16 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // Past messages: the whole list, searchable, while the box is empty; the // ones that match, in place of the key row, as it is written in. const historyKey = h("button", { class: "round", ariaLabel: "History" }, ico("history")); - const compose = h("div", { class: "compose" }, historyKey, field, sendKey, keyboard); + // A photo or a file, put on the machine; its path goes into the box, for + // an agent to be pointed at with whatever is written around it. + const attachKey = h("button", { class: "round", ariaLabel: "Attach a photo or file" }, ico("attach")); + const picker = h("input", { type: "file", multiple: true, hidden: true }); + const compose = h("div", { class: "compose" }, attachKey, historyKey, field, sendKey, keyboard, picker); const suggest = h("div", { class: "suggest", hidden: true }); + // An agent's numbered choices — a permission to grant, a question — as + // buttons over the key row while it waits for an answer. + const answers = h("div", { class: "answers", hidden: true, role: "group", ariaLabel: "Answers" }); + let agentWaiting = pane.agent?.status === "waiting"; // What typing straight into the terminal goes through. xterm's own hidden // textarea is not: iOS input methods never commit into it (a pinyin // candidate stays unwritten), so this one, a plain field the keyboard @@ -1475,8 +2061,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, "div", { class: "screen term-screen" }, bar, - h("div", { class: "term-wrap" }, screenEl, typing, copyView, banner), - h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, suggest), dots, compose), + h("div", { class: "term-wrap" }, screenEl, typing, copyView, findBar, banner), + h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, answers, suggest), dots, compose), ); const term = new Terminal({ @@ -1494,6 +2080,77 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // which owns the pane, answers those already, and a second answer from // here arrives late, on a replay, and lands in the shell as typed text. disableStdin: true, + // The search add-on's match highlights. + allowProposedApi: true, + }); + + const search = new SearchAddon(); + term.loadAddon(search); + const findOptions = { caseSensitive: false, decorations: { + matchOverviewRuler: "#888888", + activeMatchColorOverviewRuler: "#ff9f0a", + matchBackground: "#ffd60a55", + activeMatchBackground: "#ff9f0a", + } }; + search.onDidChangeResults(({ resultIndex, resultCount }) => { + findCount.textContent = findInput.value + ? resultCount + ? `${resultIndex + 1} of ${resultCount}` + : "None" + : ""; + }); + const finding = (on: boolean) => { + findBar.hidden = !on; + if (on) findInput.focus(); + else { + search.clearDecorations(); + term.clearSelection(); + findInput.value = ""; + findCount.textContent = ""; + } + }; + // Up is back through the scrollback, as the pane reads. + const find = (back: boolean) => { + if (!findInput.value) return; + if (back) search.findPrevious(findInput.value, findOptions); + else search.findNext(findInput.value, findOptions); + }; + findInput.oninput = () => { + if (findInput.value) { + search.findPrevious(findInput.value, { ...findOptions, incremental: true }); + } else { + search.clearDecorations(); + term.clearSelection(); + findCount.textContent = ""; + } + }; + findInput.onkeydown = (e) => { + if (e.key === "Enter") find(!e.shiftKey); + }; + findPrev.onclick = () => find(true); + findNext.onclick = () => find(false); + findDone.onclick = () => finding(false); + + // A program copying (OSC 52: tmux, an editor, an agent's "copy") gets a + // Copy button rather than the clipboard outright: a replay repeats old + // copies, and iOS only lets a tap write the clipboard anyway. + term.parser.registerOscHandler(52, (data) => { + const b64 = data.slice(data.indexOf(";") + 1); + if (!b64 || b64 === "?" || !live) return true; + let text: string; + try { + text = new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))); + } catch { + return true; + } + showBanner("The pane copied some text.", { + label: "Copy", + run: () => { + void navigator.clipboard?.writeText(text).catch(() => {}); + clearBanner(); + }, + }); + return true; }); let handle: number | null = null; @@ -1572,7 +2229,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, } }; - for (const page of KEY_PAGES) { + for (const page of keyPages()) { const row = h("div", { class: "key-page" }); for (const k of page) { if (k.paste && !canPaste) continue; @@ -1632,8 +2289,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, ); suggest.hidden = found.length === 0; suggest.scrollLeft = 0; - pages.classList.toggle("covered", found.length > 0); - dots.classList.toggle("covered", found.length > 0); + pages.classList.toggle("covered", found.length > 0 || !answers.hidden); + dots.classList.toggle("covered", found.length > 0 || !answers.hidden); }; field.addEventListener("focus", offer); field.addEventListener("blur", offer); @@ -1690,6 +2347,42 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, ); fill(); }; + attachKey.onpointerdown = (e) => e.preventDefault(); + attachKey.onclick = () => { + if (place) { + showBanner(`Files can only go to panes on ${host.name} itself for now.`, { label: "OK", run: clearBanner }); + return; + } + picker.value = ""; + picker.click(); + }; + picker.onchange = async () => { + const files = [...(picker.files ?? [])]; + if (!files.length) return; + attachKey.classList.add("busy"); + attachKey.disabled = true; + const paths: string[] = []; + try { + for (const file of files) { + if (file.size > MAX_UPLOAD) { + throw new Error(`${file.name} is ${Math.ceil(file.size / 2 ** 20)} MB; files up to ${MAX_UPLOAD / 2 ** 20} MB can be sent`); + } + paths.push(await api.upload(host.id, null, file.name, new Uint8Array(await file.arrayBuffer()))); + } + } catch (e) { + showBanner(`Couldn't send the file: ${sentence(errorText(e))}`, { label: "OK", run: clearBanner }); + } finally { + attachKey.classList.remove("busy"); + attachKey.disabled = false; + } + if (!paths.length) return; + // Its own word, before and after, so it reads as a path whatever is + // written around it. + const before = field.value && !/\s$/.test(field.value) ? `${field.value} ` : field.value; + field.value = `${before}${paths.join(" ")} `; + edited(); + field.focus({ preventScroll: true }); + }; field.addEventListener("input", edited); // A password typed into the box is sent, never kept: the line the cursor // is on says what the pane asked for. @@ -1857,6 +2550,32 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // Finger movement not yet applied, and the frame that will apply it. let pending = 0; let frame = 0; + // The web address under a point on the screen, if any: the cell it falls + // in, then the line that cell is part of, wrapped rows joined. + const linkAt = (x: number, y: number) => { + const drawn = screenEl.querySelector(".xterm-screen"); + if (!drawn || !term.cols || !term.rows) return null; + const box = drawn.getBoundingClientRect(); + const col = Math.floor(((x - box.left) / box.width) * term.cols); + const row = Math.floor(((y - box.top) / box.height) * term.rows); + if (col < 0 || row < 0 || col >= term.cols || row >= term.rows) return null; + const buf = term.buffer.active; + const at = buf.viewportY + row; + let start = at; + while (start > 0 && buf.getLine(start)?.isWrapped) start--; + let text = ""; + for (let y2 = start; y2 === start || buf.getLine(y2)?.isWrapped; y2++) { + const line = buf.getLine(y2); + if (!line) break; + text += line.translateToString(false); + } + const offset = (at - start) * term.cols + col; + for (const m of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) { + const url = m[0].replace(/[.,;:!?)\]}'"]+$/, ""); + if (offset >= m.index && offset < m.index + url.length) return url; + } + return null; + }; const rowHeight = () => { const drawn = screenEl.querySelector(".xterm-screen"); return drawn && term.rows ? drawn.clientHeight / term.rows : 14; @@ -1988,13 +2707,56 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // focus to its own textarea. if (lifted && !lifted.axis && e.cancelable) { e.preventDefault(); - typing.focus({ preventScroll: true }); + // A link under the finger opens in the browser instead. + const at = e.changedTouches[0]; + const url = at && linkAt(at.clientX, at.clientY); + if (url) void openUrl(url).catch(() => {}); + else typing.focus({ preventScroll: true }); return; } if (lifted?.axis === "y") glide(lifted); }, { passive: false }, ); + // Pinching: out to read closer, in to see more. A step at a time, on + // lifting — from the whole width to the readable size, then through the + // text sizes Settings offers; the chosen size is kept as the setting. + let pinch: { from: number; to: number } | null = null; + const spread = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); + screenEl.addEventListener( + "touchstart", + (e) => { + if (e.touches.length === 2) pinch = { from: spread(e.touches), to: spread(e.touches) }; + }, + { passive: true }, + ); + screenEl.addEventListener( + "touchmove", + (e) => { + if (!pinch || e.touches.length !== 2) return; + e.preventDefault(); + pinch.to = spread(e.touches); + }, + { passive: false }, + ); + screenEl.addEventListener("touchend", (e) => { + if (!pinch || e.touches.length >= 2) return; + const scale = pinch.to / Math.max(1, pinch.from); + pinch = null; + if (scale > 0.87 && scale < 1.15) return; + const closer = scale > 1; + if (cramped && !leased && readable !== closer) { + toggleZoom(); + return; + } + const sizes = TEXT_SIZES.map((t) => t.value); + const at = sizes.indexOf(prefs.textSize); + const next = sizes[Math.min(sizes.length - 1, Math.max(0, (at < 0 ? 1 : at) + (closer ? 1 : -1)))]; + if (next === prefs.textSize) return; + setPref("textSize", next); + fit(); + askLease(); + }); const glide = (lifted: NonNullable) => { if (lifted.samples.length < 2) return; const [y0, t0] = lifted.samples[0]; @@ -2158,6 +2920,54 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, state.className = `term-state ${cls}`; stateWord.textContent = label; }; + const clearBanner = () => banner.replaceChildren(); + + // The choices on screen, read off the bottom of the pane when the agent + // is waiting: the last run of lines numbered from 1, as Claude Code and + // Codex draw them ("❯ 1. Yes", "2. No, and tell Claude…"), box edges and + // the cursor mark stripped. + const readChoices = () => { + const found: { n: string; label: string }[] = []; + if (agentWaiting) { + const buf = term.buffer.active; + const bottom = buf.baseY + term.rows; + let run: typeof found = []; + for (let y = Math.max(0, bottom - 24); y < bottom; y++) { + const text = (buf.getLine(y)?.translateToString(true) ?? "") + .replace(/^[\s│┃|]+|[\s│┃|]+$/g, "") + .replace(/^[❯›>▸●◉○]\s*/, ""); + const m = /^(\d{1,2})[.)]\s+(.+)$/.exec(text); + if (!m) continue; + if (m[1] === "1") run = []; + if (Number(m[1]) === run.length + 1) run.push({ n: m[1], label: m[2] }); + if (run.length >= 2) found.splice(0, found.length, ...run); + } + } + const shown = answers.dataset.shown ?? ""; + const now = found.map((c) => `${c.n}${c.label}`).join("\n"); + if (shown === now) return; + answers.dataset.shown = now; + answers.replaceChildren( + ...found.map((c) => { + const b = h("button", { class: "answer" }, h("b", {}, c.n), h("span", {}, c.label)); + b.onpointerdown = (e) => e.preventDefault(); + b.onclick = () => send(c.n); + return b; + }), + ); + answers.hidden = found.length === 0; + answers.scrollLeft = 0; + pages.classList.toggle("covered", found.length > 0 || !suggest.hidden); + dots.classList.toggle("covered", found.length > 0 || !suggest.hidden); + }; + let choicesFrame = 0; + term.onWriteParsed(() => { + if (!agentWaiting || choicesFrame) return; + choicesFrame = requestAnimationFrame(() => { + choicesFrame = 0; + readChoices(); + }); + }); const showBanner = (text: string, action?: { label: string; run: () => void }) => banner.replaceChildren( h( @@ -2206,11 +3016,14 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, fit(); break; case "agent": + agentWaiting = event.agent?.status === "waiting"; + readChoices(); field.placeholder = event.agent ? `Message ${agentLook(event.agent.kind).name}…` : "Type a command…"; break; case "cwd": + paneCwd = event.path; cwd.textContent = where(event.path); break; case "exited": @@ -2307,4 +3120,5 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }); } +if (prefs.lock) lock(); hostsScreen("push"); diff --git a/mobile/src/style.css b/mobile/src/style.css index 760faa50..4065fef8 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -310,6 +310,17 @@ code { height: 20px; } +/* Sending: pulses until the machine says where the file went. */ +.round.busy { + animation: busy 1s ease-in-out infinite alternate; +} + +@keyframes busy { + to { + opacity: 0.35; + } +} + .scroll { flex: 1; overflow-y: auto; @@ -436,6 +447,66 @@ code { padding-top: 10px; } +/* The workspace switcher: one row of choices that scrolls sideways, running + to the screen's edges. */ +.ws-bar { + display: flex; + gap: 8px; + margin: -6px 0 18px; + padding: 2px 20px; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + scroll-padding: 0 20px; +} + +.ws-bar::-webkit-scrollbar { + display: none; +} + +.ws-chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 36px; + max-width: 70vw; + padding: 0 14px; + border-radius: 18px; + background: var(--fill); + color: var(--ink); + font-size: 15px; + font-weight: 500; + transition: background 0.2s, color 0.2s; +} + +.ws-chip .icon { + flex: none; + width: 15px; + height: 15px; +} + +.ws-chip-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.ws-chip-count { + color: var(--ink-2); + font-variant-numeric: tabular-nums; +} + +.ws-chip.on { + background: var(--ink); + color: var(--canvas); +} + +.ws-chip.on .ws-chip-count { + color: inherit; + opacity: 0.6; +} + .remote-head { display: flex; align-items: center; @@ -638,6 +709,17 @@ code { mask: var(--mark) center / contain no-repeat; } +/* A hairline keeps a disc as dark as the page (Codex's black) a shape. */ +.avatar.branded { + background: var(--brand-field); + color: var(--brand-ink); + box-shadow: inset 0 0 0 0.5px var(--hair); +} + +.avatar.branded .mark { + background: var(--brand-ink); +} + .avatar .glyph { font: 600 12px var(--mono); letter-spacing: -0.06em; @@ -793,6 +875,11 @@ code { color: var(--ink); } +.button.danger { + background: var(--danger); + color: #fff; +} + .button.small { height: 34px; padding: 0 14px; @@ -1612,6 +1699,18 @@ html.scanning #app { gap: 20px; min-height: 0; overflow-y: auto; + /* Scrolling clips the sides too: room for a chosen card's ring there. */ + margin: 0 -4px; + padding: 2px 4px; +} + +.sheet-body:has(> .sheet-text) { + gap: 10px; +} + +.sheet-text { + margin: 0 4px 10px; + color: var(--ink-2); } .sheet-group .group-title { @@ -1797,6 +1896,63 @@ html[data-nav="pop"]::view-transition-new(root) { display: none; } +/* An agent's choices over the key row, the first the way to say yes. */ +.answers { + position: absolute; + inset: 0; + display: flex; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + overscroll-behavior-x: contain; +} + +.answers::-webkit-scrollbar { + display: none; +} + +.answer { + display: flex; + flex: none; + align-items: center; + gap: 8px; + max-width: 78%; + height: 42px; + padding: 0 14px 0 6px; + border-radius: 10px; + background: var(--cell); + box-shadow: + 0 0 0 0.5px var(--hair), + 0 1px 0 var(--hair); + font-size: 14px; + white-space: nowrap; +} + +.answer b { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border-radius: 7px; + background: var(--fill); + font: 600 14px var(--mono); +} + +.answer:first-child b { + background: var(--accent); + color: var(--accent-ink); +} + +.answer span { + overflow: hidden; + text-overflow: ellipsis; +} + +.answer:active { + opacity: 0.6; +} + .suggestion { flex: none; max-width: 78%; @@ -1852,3 +2008,244 @@ html[data-nav="pop"]::view-transition-new(root) { font-family: var(--mono); color: var(--ink-soft); } + +/* ------------------------------------------------------------------------ */ +/* Settings → Key bar */ + +.row-title.danger { + color: var(--danger); +} + +.row-title.accent { + color: var(--accent); +} + +.row.key-edit { + gap: 10px; +} + +.row.key-edit .row-title { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The key as the bar draws it, at a fixed size so the list lines up. */ +.key.key-sample { + flex: none; + width: 52px; + height: 34px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; +} + +.row.key-edit .nav-icon { + width: 32px; + height: 32px; + box-shadow: none; + background: var(--fill); +} + +.row.key-edit .nav-icon .icon { + width: 16px; + height: 16px; +} + +.row.key-edit .nav-icon:disabled { + opacity: 0.3; +} + +.row.key-edit .nav-icon.danger { + color: var(--danger); +} + +.key-catalog { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); + gap: 6px; +} + +.form-card .row.field { + padding-top: 0; + padding-bottom: 0; + min-height: 48px; +} + +.sheet-group .group-note { + padding-bottom: 8px; +} + +.keys-note { + margin: -8px 24px 24px; +} + +/* Find in a pane: a bar over the top of the terminal. */ +.find-bar { + position: absolute; + top: 8px; + left: 12px; + right: 12px; + z-index: 3; + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + border-radius: 22px; + background: var(--canvas); + box-shadow: var(--shadow); +} + +.find-bar .search { + flex: 1; + min-width: 0; +} + +.find-count { + flex: none; + font-size: 13px; + color: var(--ink-2); + font-variant-numeric: tabular-nums; +} + +.find-bar .nav-icon { + flex: none; + width: 34px; + height: 34px; + box-shadow: none; + background: var(--fill); +} + +/* The app lock: everything covered until Face ID or the passcode. */ +.lock-cover { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + background: var(--canvas); +} + +/* An on/off switch drawn as the system's. */ +.switch { + position: relative; + flex: none; + width: 51px; + height: 31px; + margin-left: auto; + border-radius: 16px; + background: var(--fill); + transition: background 0.2s; +} + +.switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 27px; + height: 27px; + border-radius: 50%; + background: #fff; + box-shadow: 0 2px 6px rgb(0 0 0 / 0.2); + transition: transform 0.2s var(--ease); +} + +.switch.on { + background: var(--done); +} + +.switch.on::after { + transform: translateX(20px); +} + +/* Changes: a pane's repository, file by file. */ +.diff-body { + gap: 10px; +} + +.diff-root { + margin: 0 4px; + font: 13px var(--mono); + color: var(--ink-2); +} + +.diff-file { + border-radius: 14px; + background: var(--cell); + box-shadow: 0 0 0 0.5px var(--hair); + overflow: hidden; +} + +.diff-file summary { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + list-style: none; + font-size: 14px; +} + +.diff-file summary::-webkit-details-marker { + display: none; +} + +.diff-name { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; + font-family: var(--mono); + font-size: 13px; +} + +.diff-add, +.diff-del { + flex: none; + font: 600 12px var(--mono); +} + +.diff-add { + color: var(--done); +} + +.diff-del { + color: var(--danger); +} + +.diff-code { + overflow-x: auto; + padding-bottom: 6px; + font: 11.5px/1.45 var(--mono); + -webkit-user-select: text; + user-select: text; +} + +.diff-line { + width: max-content; + min-width: 100%; + padding: 0 14px; + white-space: pre; +} + +.diff-line.add { + background: color-mix(in srgb, var(--done) 16%, transparent); +} + +.diff-line.del { + background: color-mix(in srgb, var(--danger) 14%, transparent); +} + +.diff-line.hunk { + margin-top: 4px; + color: var(--ink-2); + background: var(--fill); +}