From 835af638a695188b7cd0402bad997aa48bf69f45 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:34:46 +0200 Subject: [PATCH 01/50] docs: the terminal on the bridge, designed for security and auditing A phone can open a real shell on a paired bridge. A shell is more than an agent in its most permissive mode, so the design puts the machine's owner in control and makes everything reconstructable afterwards: - three gates: terminals enabled on the machine, a paired identity, and the device approved on the machine (an allowlist the owner edits with the bridge's CLI; withdrawing a device closes its terminals at once); - a device id that survives session-key rotation, and a device name; - the direct link by default, relays only when the owner allows them; - a per-terminal input sequence, so a replayed keystroke event re-types nothing and keystrokes cannot evict command ids from the dedup set; - one writer, read-only attach, an explicit takeover, and a notice to every other device on each open, attach and takeover; - a hash-chained audit log and a full recording on the bridge, with input typed while echo is off recorded as hidden; - input sent when a program can react to it (Enter, Tab, Esc, arrows, chords) or on a pause, with a predicted echo, and a rate cap on relays. Co-Authored-By: Claude Code --- docs/TERMINAL.md | 256 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/TERMINAL.md diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md new file mode 100644 index 00000000..b22fe603 --- /dev/null +++ b/docs/TERMINAL.md @@ -0,0 +1,256 @@ +# Terminal on the bridge + +A phone can open a real terminal on a paired bridge: a shell in a +pseudo-terminal on the bridge's machine, drawn on the phone by a terminal +emulator, so interactive programs (an editor, `htop`, `git add -p`) work as +they would over SSH. This file is the design; `crates/protocol` is the spec +once it lands. + +A terminal is a shell as the bridge's own user — strictly more than an agent +in its most permissive mode. The design is therefore built around two goals +before convenience: **only the machine's owner decides who gets one**, and +**everything a terminal did can be reconstructed afterwards on the machine**. + +## Who may open a terminal + +Three gates, all of which must pass: + +1. **The bridge has terminals on.** `terminal.enabled` in `config.json` (or + `--terminal`). Off by default; without it the bridge does not advertise + the capability and refuses every terminal message. It is never switched + from the phone. +2. **The identity is paired**, by the same rule as every command: the event + is signed by a paired identity. +3. **The device is approved on the machine.** Every client of an identity + shares the identity key, so a stolen key would otherwise open a shell from + any new device. The bridge keeps an allowlist of device ids + (`terminal-allow.json` in its home, `0600`). A `terminal-open` from a + device not on it is refused (`terminal-exited {reason: "not-allowed", + device}`), the refusal is audited, and the phone shows the device id and + name to approve. The owner approves on the machine: + `codedeck-bridge terminal pending` lists refused devices, + `codedeck-bridge terminal allow ` approves one, + `codedeck-bridge terminal disallow ` withdraws it. These work while + the bridge runs (atomic file writes; the bridge re-reads the file, cached + by modification time, on every terminal message), and **withdrawing a + device closes its terminals at once** — the kill switch for a lost phone. + +Optionally, the **phone can require fingerprint or PIN** (Android +`BiometricPrompt`, device credential as fallback) before opening or attaching +to a terminal: an app setting, off by default. It protects an unlocked phone +in someone else's hand; the bridge does not rely on it. + +### Which device + +The bridge knows a client by its session key, which rotates. A terminal +needs a name that survives rotation, for the allowlist and the audit: + +- **Device id** = the first 16 bytes, in hex, of + `sha256("codedeck/device\0" + first session key + "\0")` — the key the + client first granted to this bridge, carried across every `replaces`. + Re-pairing or reinstalling makes a new device, which must be approved + again. +- **Device name**: the phone sends a label with its session-key grant + (`label`, user-editable, default the phone's model). A name is what a + person reads; the id is what the bridge trusts. + +## The shell + +- Starts in the workspace (`work.rs`'s root) or, opened from a session, in + that session's directory. The phone never sends a path. +- Runs with a **built environment**, not the bridge's: `PATH`, `HOME`, + `USER`, `LANG`, `SHELL`, `TERM=xterm-256color` (and on Windows the system + variables a shell needs). The bridge's own environment can hold provider + keys the user exported for the agents, and only each driver knows which + variables are secrets, so the shell inherits none of it. +- What runs: `terminal.command` when the owner sets one (a fixed program — + `tmux new -A -s phone`, a restricted shell), else `$SHELL`, else `/bin/sh`; + on Windows `pwsh`, else `powershell`, else `cmd`. +- **One writer.** Only the device that opened a terminal types into it. + Another device attaches read-only and can take over explicitly + (`terminal-takeover`), which is audited and moves the writer. + +## Capability + +`terminal` — the phone shows Terminal only when the heartbeat lists it. It +fits the rule in `capabilities.rs`: a phone that sent terminal messages to a +bridge without it would wait on a terminal that never opens. + +## Transport + +Terminal messages are ordinary CodeDeck messages, but they take **the direct +link by default**: a terminal opens only over a direct link (LAN, VPN, or an +onion endpoint through Orbot) unless the owner sets `terminal.relays: true`. +Even encrypted, a relay sees each event's size and timing, and keystroke +timing alone leaks what was typed; the direct link shows nobody either. +When relays are allowed, a terminal falls back to them like any message. + +- Both directions use `LIVE_KIND` (24515, ephemeral): **keystrokes are never + stored on a relay**. Phone→bridge input on the live kind is new: the direct + link accepts it besides commands (today it accepts only 4515), and, with + relays allowed, the bridge subscribes to it from its paired phones. +- **Every input carries `seq`**, per terminal, counting up by one from 1. The + bridge applies input in order, drops a `seq` it has already applied (a + relay's replay of a signed keystroke event re-types nothing), and holds a + later one for up to 2 s waiting for the gap, then asks the phone to resend + from the gap (`terminal-input-from`). Terminal input does **not** go + through the command dedup set (the 1000 most recent ids, `ingest.rs`): + a burst of keystrokes would push command ids out of it and reopen a replay + window for commands. The per-terminal `seq` is its dedup, and the + 300-second age limit still applies. +- **Output is coalesced**: the bridge flushes at most every 16 ms on the + direct link and every 100 ms on relays, or sooner past 16 KiB pending. + Larger flushes split by the existing `chunk` fragmentation. +- **Input is sent when a program can react to it, not per keystroke.** On + the normal screen (a shell prompt), printable characters wait on the phone + and go in one event when a key a program acts on is pressed — Enter, Tab + (completion), Esc, arrows, Backspace/Delete, any Ctrl/Alt chord — or when + typing pauses (150 ms on the direct link, 400 ms on relays), or past 256 + bytes. So `git che` + Tab is one event carrying `git che\t`, and the + completion comes back in one output frame. Characters still waiting are + drawn at once as a **predicted echo** (dimmed, at the cursor, as mosh + does) and replaced by the real output when it arrives, so batching never + feels like lag. On the alternate screen (vim, htop, less — every key is a + command there) there is no prediction and keys go after at most 20 ms on + the direct link and 60 ms on relays, coalescing a held key's repeats. The + emulator already knows which screen is showing; nothing extra crosses the + wire. A paste is one event. +- **A rate cap per terminal on relays**: at most 5 input events and 10 + output events a second, the rest merged into the next — relays rate-limit + their writers, and a terminal must not get the bridge's or the phone's key + throttled for the agents too. The direct link has no cap. +- **Privacy of size and timing.** NIP-44 already pads every payload to a + size bucket, so a relay sees bucketed sizes, not byte counts. Batching on + the keys above, rather than sending each keystroke, also hides the typing + rhythm that keystroke-timing analysis reads. Output is not compressed + before encryption: terminal output mixes what a command printed with what + an outsider can influence (a file, a log line), and a compressed length is + an oracle for that mix. +- **Output goes only to the devices showing that terminal**, by the + `watching` rule live text already follows (see `presence` in + `PROTOCOL.md`): a device watches a + terminal the way it watches a session. +- Loss is tolerated: every output frame carries the terminal's byte offset, + and a phone that sees a gap (or reconnects) asks for a replay from the + last offset it drew. The bridge keeps the last 256 KiB of each terminal's + output for that; older than that, the phone resets the screen and redraws + from what is kept (the program's next repaint fixes the rest). + +## Messages + +All carry `v` like every message. `term` is a terminal id the bridge assigns. + +| Message | Direction | Fields | +| --- | --- | --- | +| `terminal-open` | phone → bridge | `cols`, `rows`, `session?` (start in its directory) | +| `terminal-opened` | bridge → phone | `term`, `shell` (what runs, e.g. `/bin/bash`) | +| `terminal-input` | phone → bridge | `term`, `seq`, `data` (UTF-8 bytes, base64) | +| `terminal-input-from` | bridge → phone | `term`, `seq` (resend from here) | +| `terminal-output` | bridge → phone | `term`, `offset`, `data` (base64) | +| `terminal-resize` | phone → bridge | `term`, `cols`, `rows` (writer only) | +| `terminal-replay` | phone → bridge | `term`, `from` (byte offset; attaches read-only) | +| `terminal-takeover` | phone → bridge | `term` (become the writer) | +| `terminal-close` | phone → bridge | `term` (hang up: SIGHUP, then kill after 5 s) | +| `terminal-exited` | bridge → phone | `term?`, `code?`, `reason` (exit, killed, closed, idle, limit, not-allowed, disabled, transport), `device?` | +| `terminal-event` | bridge → every present client | `term`, `what` (opened, attached, takeover, closed, exited), `device`, `name`, `via`, `at` | + +The open terminals ride the session list the bridge already publishes +(`term`, `shell`, `opened`, `device`, `name`, `writer`, `cols`, `rows`), so a +phone that comes back (or a second phone) sees them and can attach with +`terminal-replay {from: 0}`. + +**Every other device is told.** `terminal-event` goes to each present client +of the identity, not just the one acting, and the phone shows it as a notice +("Terminal opened on by "). A phone that did not open a +terminal learns of it at once — the detection half of the allowlist. + +## Audit + +Kept on the bridge, under `/audit/` (`0700`, files `0600`, like the +state file), never sent anywhere: + +- **`terminal.jsonl`** — one line per event: opened (device id and name, + identity, transport `direct`/`onion`/`relay`, cwd, what runs, size), + attached, takeover, resized, closed, exited (reason, code, duration), and + refused (why: disabled, not-allowed, limit, transport, bad seq). Each line + carries `prev`, the SHA-256 of the line before it, so an edit or a deleted + line breaks the chain (`codedeck-bridge audit verify` checks it). The honest + limit: anyone running as the bridge's user can rewrite the whole file; the + chain catches tampering that is not that thorough, and `audit.syslog: true` + also copies each line to the system log (journald / syslog), which survives + even that. +- **`term-.cast`** — a full recording of each terminal in asciicast v2 + (replayable with `asciinema play`): every output frame, and every input + chunk with the device that typed it. **Input typed while the terminal has + echo off** — a password prompt — is recorded as `[input hidden: N bytes]`, + never the bytes: the bridge reads the PTY's termios (`portable-pty`'s + `MasterPty::get_termios`, the `ECHO` local flag) before recording each + chunk. On Windows, where ConPTY exposes no echo state, input is recorded as + byte counts only; what was typed still shows where the shell echoed it. + A recording can contain secrets a command printed (`cat .env`): it is + readable only by the bridge's user, and the owner chooses how long to keep + it. +- **Retention**: `audit.maxDays` (default 90) and `audit.maxBytes` (default + 1 GiB); the oldest whole files go first. The log of who opened what is + small and kept the longest. + +## Lifetime + +A terminal outlives a dropped connection — the point of a phone remote is +that the train goes into a tunnel. It ends when its shell exits, when its +writer closes it, when its device is withdrawn from the allowlist, or after +**1 h with no device attached** (`terminal.idleMinutes`, up to 24 h). At most +4 terminals per bridge; a fifth `terminal-open` is answered +`terminal-exited {reason: "limit"}`. A bridge restart ends them all (the +PTYs die with it), and each one's end is audited. + +## Bridge + +- `crates/bridge-core` routes the messages (paired identity, capability on, + device allowed, transport allowed, the terminal exists, the sender is its + writer, `seq` in order) and emits effects: spawn, write, resize, replay, + close, audit. It holds no PTY and no file. +- `crates/bridge-runtime` owns the PTYs through the `portable-pty` crate + (ConPTY on Windows, so the Windows bridge gets a real terminal too), the + per-terminal output ring and offset, the flush timer per transport, the + allowlist file and the audit files. + +## Phone + +- The emulator is Termux's `terminal-emulator` + `terminal-view` + (Apache-2.0), hosted in Compose through `AndroidView`. It already handles + VT100/xterm sequences, 256 colours, the alternate screen, selection and + the IME. +- An extra-keys row above the keyboard (Esc, Ctrl, Alt, Tab, arrows, `|`, + `~`, `/`), as in Termux — a phone keyboard has none of these. +- Reached from the machine's page (Terminal) and from a session's menu + (Terminal here, opened in the session's directory). Running terminals show + on the machine's page, with the device that opened each. +- A refused open shows the device's id and name and the command to run on + the machine. +- Pinch zooms the font; the emulator's size follows the view and every + change sends `terminal-resize`. +- Settings: "Require fingerprint for terminals" (off by default). + +## Traffic and battery + +Nothing is sent while no terminal is on screen: output goes only to devices +watching it, and an idle shell writes nothing. Typing costs one event per key a +program acts on or per pause (a command line with a completion is two or +three events, not thirty), output one per flush (at most ~60/s on the +direct link, 10/s on relays, usually far fewer); each is signed by the +identity and NIP-44 encrypted like every message. The audit costs only disk +on the bridge. `terminal-event` is one small message per open, attach, +takeover or close, to each present device. + +## Order + +1. Protocol: the messages, the capability, the live kind from the phone, + the device label on the session-key grant, fixtures in the corpus. +2. Bridge: config, device ids and the allowlist with its CLI, core routing + (gates, writer, `seq`), runtime PTYs, ring and flushing, the audit files + and `audit verify`; an end-to-end test drives a terminal through the + bridge (`echo` round trip, resize, replay after a gap, a replayed input + dropped, a withdrawn device's terminal closed). +3. Phone: client-core terminal state and offsets, the FFI surface, the + screen, the refusal and the notices, the fingerprint setting. From f9b92c1238921d4ec2436e252583add24aa33bd0 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:44:48 +0200 Subject: [PATCH 02/50] =?UTF-8?q?feat(protocol):=20the=20terminal=20wire?= =?UTF-8?q?=20=E2=80=94=20messages,=20capability,=20device=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone needs to drive a real shell on its paired bridge, so the v12 wire defines one, additively. Phone to bridge: `terminal-open` (with the optional session whose directory the shell starts in, since the phone never names a path), `terminal-input` (a per-terminal `seq`, so a replayed signed event types nothing twice), `terminal-resize`, `terminal-replay`, `terminal-takeover` and `terminal-close`. Bridge to phone: `terminal-opened`, `terminal-output` (byte-offset addressed, so a client can ask for exactly what it missed), `terminal-input-from`, `terminal-exited` and `terminal-event`. `terminal-input` and `terminal-output` ride LIVE_KIND — keystrokes and shell output are ephemeral, never stored on a relay — and output goes only to the clients watching that terminal, so `presence` gains `watchingTerminal` beside `watching`. The heartbeat gains `terminals[]` (`TerminalInfo`), so a phone lists shells it did not open. `TerminalEndReason`, `TerminalEventKind` and `TerminalVia` are open vocabularies with the crate's usual catch-all, so a value a newer bridge adds still decodes. The `terminal` capability is advertised only by a bridge whose owner has terminals switched on, so it is deliberately NOT in ALL_BRIDGE_CAPABILITIES: a phone that sent terminal messages to a bridge without it would wait on a terminal that never opens. `recipients::device_id` names a client by the FIRST session key it granted the bridge (pinned by a test vector), so a device keeps one identity across key rotations; `SessionKeyGrant` gains the user's `label` for the audit, display only. The bridge engine and the client runtime do not serve terminals yet, so their exhaustive matches ignore the new messages and the session list builds with `terminals: None`; no behaviour is added there. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/mod.rs | 1 + crates/bridge-core/src/engine/phone.rs | 15 +++ crates/bridge-runtime/src/relay.rs | 1 + crates/client-runtime/src/dispatch.rs | 9 ++ crates/client-runtime/src/runtime.rs | 6 +- crates/client-runtime/src/runtime/tests.rs | 2 +- crates/client-runtime/src/signer.rs | 1 + crates/protocol/fixtures/README.md | 3 +- crates/protocol/fixtures/corpus.json | 32 ++++- crates/protocol/src/capabilities.rs | 11 +- crates/protocol/src/commands.rs | 107 ++++++++++++++- crates/protocol/src/common.rs | 73 ++++++++++ crates/protocol/src/events.rs | 147 ++++++++++++++++++++- crates/protocol/src/kinds.rs | 8 +- crates/protocol/src/recipients.rs | 27 ++++ crates/protocol/tests/codec_conformance.rs | 15 ++- 16 files changed, 439 insertions(+), 19 deletions(-) diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index d7be0851..6474fe3b 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -613,6 +613,7 @@ impl Engine { removed_sessions: (!removed.is_empty()).then_some(removed), machine_offline: offline.then_some(true), direct: self.config.direct.clone(), + terminals: None, rev: Some(rev), }); match to { diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index abf7bad8..4b82b500 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -59,6 +59,12 @@ fn type_name(msg: &PhoneToBridge) -> &'static str { PhoneToBridge::ProviderProfilesRequest(_) => "provider-profiles-request", PhoneToBridge::SessionKey(_) => "session-key", PhoneToBridge::Presence(_) => "presence", + PhoneToBridge::TerminalOpen(_) => "terminal-open", + PhoneToBridge::TerminalInput(_) => "terminal-input", + PhoneToBridge::TerminalResize(_) => "terminal-resize", + PhoneToBridge::TerminalReplay(_) => "terminal-replay", + PhoneToBridge::TerminalTakeover(_) => "terminal-takeover", + PhoneToBridge::TerminalClose(_) => "terminal-close", } } @@ -163,6 +169,15 @@ impl Engine { let msg = self.provider_profiles_msg(); self.publish_to(phone, msg); } + // The engine runs no terminals: the bridge advertises the + // `terminal` capability only once one exists here, and until then + // a phone has no reason to send these. + PhoneToBridge::TerminalOpen(_) + | PhoneToBridge::TerminalInput(_) + | PhoneToBridge::TerminalResize(_) + | PhoneToBridge::TerminalReplay(_) + | PhoneToBridge::TerminalTakeover(_) + | PhoneToBridge::TerminalClose(_) => {} } } diff --git a/crates/bridge-runtime/src/relay.rs b/crates/bridge-runtime/src/relay.rs index dc8a3be1..0b3726d4 100644 --- a/crates/bridge-runtime/src/relay.rs +++ b/crates/bridge-runtime/src/relay.rs @@ -602,6 +602,7 @@ mod tests { removed_sessions: None, machine_offline: None, direct: None, + terminals: None, rev: None, }) } diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index fbd478d5..2bf06f7f 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -492,6 +492,14 @@ impl<'a> Router<'a> { ); r.ui_changed = true; } + // No terminal store yet: the bridge needs the `terminal` + // capability before it serves one, and nothing here holds a + // terminal's state until it does. + BridgeToPhone::TerminalOpened(_) + | BridgeToPhone::TerminalOutput(_) + | BridgeToPhone::TerminalInputFrom(_) + | BridgeToPhone::TerminalExited(_) + | BridgeToPhone::TerminalEvent(_) => {} } r } @@ -934,6 +942,7 @@ mod tests { removed_sessions: None, machine_offline: None, direct: None, + terminals: None, rev: None, } } diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index c707a341..a14088a2 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -1361,7 +1361,7 @@ impl Loop { for machine in self.stores.machines.machine_pubkeys() { if matches!(self.cipher_for(&machine), Cipher::SessionKey(_)) { let (tx, rx) = oneshot::channel(); - let away = PhoneToBridge::Presence(PresenceMsg { away: true, watching: None }); + let away = PhoneToBridge::Presence(PresenceMsg { away: true, watching: None, watching_terminal: None }); self.sign_and_publish(machine, away, Some(tx), None, true); sent.push(rx); } @@ -2215,7 +2215,7 @@ impl Loop { // The renewal also carries what this client watches now, so a // bridge that missed a change catches up within one period. let watching = self.watching_for(&machine); - self.sign_and_publish(machine, PhoneToBridge::Presence(PresenceMsg { away: false, watching }), None, None, true); + self.sign_and_publish(machine, PhoneToBridge::Presence(PresenceMsg { away: false, watching, watching_terminal: None }), None, None, true); } } @@ -2280,7 +2280,7 @@ impl Loop { if !matches!(self.cipher_for(machine), Cipher::SessionKey(_)) { return false; } - self.sign_and_publish(machine.to_string(), PhoneToBridge::Presence(PresenceMsg { away: false, watching }), None, None, true); + self.sign_and_publish(machine.to_string(), PhoneToBridge::Presence(PresenceMsg { away: false, watching, watching_terminal: None }), None, None, true); true } diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index c14a3956..60222963 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -2335,7 +2335,7 @@ async fn a_quiet_client_renews_its_presence_and_says_when_it_goes_away() { tokio::time::advance(STALE_WATCHDOG_EVERY + Duration::from_secs(1)).await; tokio::time::resume(); let renewal = next_command_via(&mut mock, &phone, key.pubkey_hex(), &machine).await; - assert!(matches!(renewal, Some(PhoneToBridge::Presence(PresenceMsg { away: false, watching: None }))), "{renewal:?}"); + assert!(matches!(renewal, Some(PhoneToBridge::Presence(PresenceMsg { away: false, watching: None, watching_terminal: None }))), "{renewal:?}"); core.stop(); let away = next_command_via(&mut mock, &phone, key.pubkey_hex(), &machine).await; diff --git a/crates/client-runtime/src/signer.rs b/crates/client-runtime/src/signer.rs index 49bf7e91..b3d95e82 100644 --- a/crates/client-runtime/src/signer.rs +++ b/crates/client-runtime/src/signer.rs @@ -110,6 +110,7 @@ impl PhoneKeys { bridge_pubkey_hex: bridge.to_string(), expires_at: self.session_expires_at, replaces: replaces.filter(|old| *old != self.session_pubkey_hex).map(str::to_string), + label: None, } } diff --git a/crates/protocol/fixtures/README.md b/crates/protocol/fixtures/README.md index 5644ad9b..8bf703c2 100644 --- a/crates/protocol/fixtures/README.md +++ b/crates/protocol/fixtures/README.md @@ -10,7 +10,8 @@ implementation of the protocol can test itself against. - `phoneToBridge.rejected` / `bridgeToPhone.rejected` — each is a decode error. - `forwardCompatible` — each still decodes: unknown fields are ignored, and an unknown value of an open vocabulary (entry type, tool, task and notice - kind, MCP status, session state, install state) decodes as its catch-all. + kind, MCP status, session state, install state, terminal end reason, + terminal event kind, terminal via) decodes as its catch-all. - Every message carries `"v": 12`, the protocol version the codec stamps and checks first; a message of another version, or none, is rejected. - **Complete:** the test asserts the `valid` fixtures cover every message diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 393feb3f..8c59f15b 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -71,7 +71,17 @@ { "v": 12, "type": "presence" }, { "v": 12, "type": "presence", "away": true }, { "v": 12, "type": "presence", "watching": "s1" }, - { "v": 12, "type": "presence", "watching": null } + { "v": 12, "type": "presence", "watching": null }, + { "v": 12, "type": "presence", "watching": "s1", "watchingTerminal": "t1" }, + { "v": 12, "type": "presence", "away": false, "watching": null, "watchingTerminal": null }, + { "v": 12, "type": "session-key", "sessionKey": { "pubkeyHex": "be", "bridgePubkeyHex": "cc", "expiresAt": 1800000000, "label": "Pixel 8" } }, + { "v": 12, "type": "terminal-open", "openId": "o1", "cols": 80, "rows": 24 }, + { "v": 12, "type": "terminal-open", "openId": "o2", "cols": 132, "rows": 43, "session": "s" }, + { "v": 12, "type": "terminal-input", "term": "t1", "seq": 1, "data": "bHMgLWxhCg==" }, + { "v": 12, "type": "terminal-resize", "term": "t1", "cols": 100, "rows": 30 }, + { "v": 12, "type": "terminal-replay", "term": "t1", "from": 4096 }, + { "v": 12, "type": "terminal-takeover", "term": "t1" }, + { "v": 12, "type": "terminal-close", "term": "t1" } ], "rejected": [ { "v": 13, "type": "input", "sessionId": "s", "text": "from a newer phone" }, @@ -108,7 +118,10 @@ { "v": 12, "type": "account-action", "agent": "opencode", "account": "openai", "action": { "kind": "teleport" } }, { "v": 12, "type": "mcp-action", "agent": "claude-code", "action": "add", "servers": [{ "name": "x", "transport": { "type": "websocket", "url": "wss://x" } }] }, { "v": 12, "type": "session-mcp-toggle", "sessionId": "s", "name": "github" }, - { "v": 12, "type": "keypress", "sessionId": "s", "key": "1", "context": "plan-approval" } + { "v": 12, "type": "keypress", "sessionId": "s", "key": "1", "context": "plan-approval" }, + { "v": 12, "type": "terminal-input", "term": "t1", "seq": -1, "data": "aGk=" }, + { "v": 12, "type": "terminal-open", "openId": "o1", "cols": 70000, "rows": 24 }, + { "v": 12, "type": "terminal-input", "term": "t1", "seq": 1 } ] }, "bridgeToPhone": { @@ -193,7 +206,16 @@ { "v": 12, "type": "pair-ack", "machine": "m", "ok": true }, { "v": 12, "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, { "v": 12, "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }, { "id": "OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "OpenCode Go", "contextWindow": 1000000 }], "modelsFromProvider": true, "hasToken": true }, { "id": "r", "agent": "opencode", "label": "DeepSeek", "baseUrl": "https://z", "models": [{ "id": "c" }], "hasToken": true, "error": "OpenCode already has a provider called 'deepseek'. Give this profile another name." }] }, - { "v": 12, "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } + { "v": 12, "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false }, + { "v": 12, "type": "terminal-opened", "openId": "o1", "term": "t1", "shell": "/bin/bash" }, + { "v": 12, "type": "terminal-output", "term": "t1", "offset": 0, "data": "bHMgLWxhCg==" }, + { "v": 12, "type": "terminal-input-from", "term": "t1", "seq": 4 }, + { "v": 12, "type": "terminal-exited", "term": "t1", "code": 0, "reason": "exit" }, + { "v": 12, "type": "terminal-exited", "term": "t1", "reason": "idle" }, + { "v": 12, "type": "terminal-exited", "openId": "o2", "reason": "not-allowed", "device": "bf1f111943b3930a4c64a5c06d9153b2" }, + { "v": 12, "type": "terminal-event", "term": "t1", "what": "takeover", "device": "bf1f111943b3930a4c64a5c06d9153b2", "name": "Pixel 8", "via": "relay", "at": 1792008058637 }, + { "v": 12, "type": "terminal-event", "term": "t1", "what": "closed", "device": "aa11", "via": "direct", "at": 1792008058638 }, + { "v": 12, "type": "sessions", "machine": "m", "sessions": [], "agents": [], "terminals": [{ "term": "t1", "shell": "/bin/bash", "opened": 1792008058637, "device": "bf1f111943b3930a4c64a5c06d9153b2", "name": "Pixel 8", "writer": "bf1f111943b3930a4c64a5c06d9153b2", "cols": 80, "rows": 24 }, { "term": "t2", "shell": "/bin/zsh", "opened": 1792008058638, "device": "aa11", "writer": "aa11", "cols": 132, "rows": 43 }] } ], "rejected": [ { "v": 10, "type": "input-ack", "sessionId": "s", "inputId": "i" }, @@ -255,6 +277,8 @@ { "dir": "b2p", "msg": { "v": 12, "type": "output", "sessionId": "s", "seq": 1, "entries": [{ "timestamp": "t", "entryType": "notice", "kind": "session_paused", "text": "x" }] } }, { "dir": "b2p", "msg": { "v": 12, "type": "output", "sessionId": "s", "seq": 1, "entries": [{ "timestamp": "t", "entryType": "background_task", "taskId": "b1", "kind": "cron", "title": "x", "status": "running" }] } }, { "dir": "b2p", "msg": { "v": 12, "type": "session-mcp", "sessionId": "s", "servers": [{ "name": "github", "status": "rate-limited" }] } }, - { "dir": "b2p", "msg": { "v": 12, "type": "sessions", "machine": "m", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "state": "waiting_plan" }], "agents": [{ "id": "pi", "displayName": "Pi", "install": { "state": "upgrading", "progress": 0.5 } }] } } + { "dir": "b2p", "msg": { "v": 12, "type": "sessions", "machine": "m", "sessions": [{ "id": "s", "agent": "opencode", "slug": "sl", "cwd": "/w", "lastActivity": "t", "lineCount": 1, "title": null, "project": "p", "state": "waiting_plan" }], "agents": [{ "id": "pi", "displayName": "Pi", "install": { "state": "upgrading", "progress": 0.5 } }] } }, + { "dir": "b2p", "msg": { "v": 12, "type": "terminal-exited", "term": "t1", "reason": "quarantined" } }, + { "dir": "b2p", "msg": { "v": 12, "type": "terminal-event", "term": "t1", "what": "hologram", "device": "aa11", "via": "telepathy", "at": 1792008058639 } } ] } diff --git a/crates/protocol/src/capabilities.rs b/crates/protocol/src/capabilities.rs index 4be9bc95..cfd34760 100644 --- a/crates/protocol/src/capabilities.rs +++ b/crates/protocol/src/capabilities.rs @@ -17,7 +17,10 @@ //! * [`FILES`]: the phone shows the attach control only when the bridge //! advertises it; //! * [`SESSION_KEYS`]: the phone grants a session key to a bridge that -//! honours one, and to none whose heartbeat lacks it. +//! honours one, and to none whose heartbeat lacks it; +//! * [`TERMINAL`]: the phone shows the terminal only when the bridge +//! advertises it, since a `terminal-open` sent to a bridge without it +//! would wait on a terminal that never opens. /// v12 = one copy of each message per client of an identity, each tagged /// with its recipient (see [`crate::recipients`]); a session-key grant names @@ -35,6 +38,12 @@ pub const FILES: &str = "files"; // uses one only once the bridge confirmed it: a bridge without it could not // read a payload encrypted with one. pub const SESSION_KEYS: &str = "session-keys"; +// The bridge runs real shells on its machine (`terminal-open` and the rest). +// A phone offers the terminal only against a bridge whose heartbeat carries +// this, and does NOT list it among the ones a reference bridge always has: +// whether terminals exist is the bridge owner's decision (a machine with +// terminals switched off advertises nothing here), not a build feature. +pub const TERMINAL: &str = "terminal"; /// Every capability the reference bridge ships with. pub const ALL_BRIDGE_CAPABILITIES: [&str; 2] = [FILES, SESSION_KEYS]; diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index 0fdab053..e459a38b 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -414,6 +414,12 @@ pub struct SessionKeyGrant { /// commands still encrypted with it. Absent for a client's first key. #[serde(default, skip_serializing_if = "Option::is_none")] pub replaces: Option, + /// The device's name as its user set it (the phone's model by default) — + /// what a person reads in the bridge's audit and allowlist. Display only: + /// the device id derived from this client's first key, never this label, + /// is what the bridge trusts. Absent when the phone has no name to give. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, } /// `presence`: this client is running and wants the identity's shared @@ -427,9 +433,10 @@ pub struct SessionKeyGrant { /// `watching` says which session this client shows live text for: only its /// `partial`s are sent to it, and only while it is present. Absent or /// `null` means it watches no session, so it gets no live text at all — -/// every other kind of update still comes. This is the only command that -/// changes what a client watches, and every `presence` does — a renewal -/// says it again; any other command leaves it as it was. +/// every other kind of update still comes. `watching_terminal` does the +/// same for a terminal (`terminal-output`), and is set the same way. +/// A `presence` is the only command that changes what a client watches, and +/// every one says both again — any other command leaves them as they were. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct PresenceMsg { @@ -438,6 +445,11 @@ pub struct PresenceMsg { /// The session whose live text this client shows, or `None` for none. #[serde(default, skip_serializing_if = "Option::is_none")] pub watching: Option, + /// The terminal whose output this client shows, or `None` for none: + /// `terminal-output` is sent only to the clients watching that terminal, + /// exactly as `partial` is sent only to those watching a session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub watching_terminal: Option, } /// How long a `presence` (or any command) keeps a client present: two @@ -493,6 +505,65 @@ pub struct SetProviderProfileMsg { pub profile: Option, } +// --- terminals --- + +/// Open a real shell on the bridge's machine (a bridge advertising +/// [`TERMINAL`](crate::capabilities::TERMINAL)). `open_id` is chosen by the +/// phone and echoed in `terminal-opened`, so a phone can match an answer to +/// the request that earned it. `cols`/`rows` are the window the shell starts +/// with; `session` starts it in that session's working directory — the phone +/// never names a path of its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenMsg { + pub open_id: String, + pub cols: u16, + pub rows: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, +} + +/// Type into a terminal. `seq` counts up by one from 1 within one terminal: +/// the bridge applies input in that order and drops a seq it has already +/// applied, so a signed event a relay replays types nothing twice. `data` is +/// base64 of the bytes, so a key press need not be valid UTF-8. Rides +/// [`LIVE_KIND`](crate::kinds::LIVE_KIND): keystrokes are never stored on a +/// relay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalInputMsg { + pub term: String, + #[specta(type = specta_typescript::Number)] + pub seq: u64, + pub data: String, +} + +/// Tell a terminal its window changed; only its writer's size counts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalResizeMsg { + pub term: String, + pub cols: u16, + pub rows: u16, +} + +/// Attach to a terminal read-only from `from`, a byte offset into its +/// output: the bridge resends what it still has of that output from there. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalReplayMsg { + pub term: String, + #[specta(type = specta_typescript::Number)] + pub from: u64, +} + +/// One terminal, by the id the bridge assigned it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalIdMsg { + pub term: String, +} + /// The phone→bridge message union. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(tag = "type", rename_all = "kebab-case")] @@ -539,6 +610,20 @@ pub enum PhoneToBridge { ProviderProfilesRequest(BareMsg), SessionKey(SessionKeyMsg), Presence(PresenceMsg), + /// Open a shell on a bridge with the `terminal` capability. Reply: + /// `terminal-opened`, or `terminal-exited` when the open is refused. + TerminalOpen(TerminalOpenMsg), + /// Type into a terminal, writer only. Rides LIVE_KIND. + TerminalInput(TerminalInputMsg), + /// The writer's new window size. + TerminalResize(TerminalResizeMsg), + /// Attach to a terminal read-only from a byte offset. + TerminalReplay(TerminalReplayMsg), + /// Become a terminal's writer. Audited: every present client is told + /// (`terminal-event`). + TerminalTakeover(TerminalIdMsg), + /// Hang up a terminal. + TerminalClose(TerminalIdMsg), } #[cfg(test)] @@ -612,6 +697,22 @@ mod tests { rt(&json!({"v":12,"type":"pair-request","npub":"npub1","pubkeyHex":"aa","label":"phone","token":"t","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000}})); rt(&json!({"v":12,"type":"provider-profiles-request"})); rt(&json!({"v":12,"type":"session-key","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000}})); + rt(&json!({"v":12,"type":"session-key","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000,"label":"Pixel 8"}})); + rt(&json!({"v":12,"type":"presence","watchingTerminal":"t1"})); + rt(&json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + rt(&json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24,"session":"s"})); + rt(&json!({"v":12,"type":"terminal-input","term":"t1","seq":1,"data":"aGk="})); + rt(&json!({"v":12,"type":"terminal-resize","term":"t1","cols":132,"rows":43})); + rt(&json!({"v":12,"type":"terminal-replay","term":"t1","from":4096})); + rt(&json!({"v":12,"type":"terminal-takeover","term":"t1"})); + rt(&json!({"v":12,"type":"terminal-close","term":"t1"})); + } + + #[test] + fn a_terminal_message_rejects_what_its_fields_cannot_hold() { + // A window size past u16, and input without its bytes. + assert!(serde_json::from_str::(r#"{"v":12,"type":"terminal-open","openId":"o","cols":70000,"rows":24}"#).is_err()); + assert!(serde_json::from_str::(r#"{"v":12,"type":"terminal-input","term":"t","seq":1}"#).is_err()); } #[test] diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index bc047dad..b4147e76 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -1447,6 +1447,79 @@ pub struct ContextItem { /// id is left unchanged. pub type CredentialValues = BTreeMap>; +// --- terminals --- + +/// Why a terminal stopped existing. Open: a reason a newer bridge adds +/// decodes as [`Unknown`](Self::Unknown) instead of failing the whole +/// message, and a phone that does not know one still knows the terminal is +/// gone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "kebab-case")] +pub enum TerminalEndReason { + /// The shell exited on its own; `code` carries what it returned. + Exit, + /// The bridge ended it (its owner stopped it, or the bridge is going + /// down). + Killed, + /// A client hung up (`terminal-close`). + Closed, + /// It sat idle past the bridge's limit. + Idle, + /// A `terminal-open` was refused: the bridge's terminals are full. + Limit, + /// A `terminal-open` was refused: this device may not open one. + NotAllowed, + /// A `terminal-open` was refused: the bridge's owner has terminals + /// switched off. + Disabled, + /// The bridge lost its pipe to the shell. + Transport, + /// The bridge restarted; the shell did not survive it. + Restart, + /// A reason this client does not know (a newer bridge's). + Unknown, +} + +open_vocabulary!(TerminalEndReason, Unknown, [Exit, Killed, Closed, Idle, Limit, NotAllowed, Disabled, Transport, Restart, Unknown]); + +/// What happened to a terminal, which every present client of the identity +/// is told so a phone that did not act learns at once. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "kebab-case")] +pub enum TerminalEventKind { + /// A client opened it. + Opened, + /// A client attached to its output without becoming the writer. + Attached, + /// A client became the writer. + Takeover, + /// A client hung up, or an attached client left. + Closed, + /// The shell ended (see `terminal-exited` for why). + Exited, + /// A kind this client does not know (a newer bridge's). + Unknown, +} + +open_vocabulary!(TerminalEventKind, Unknown, [Opened, Attached, Takeover, Closed, Exited, Unknown]); + +/// How the client that acted reached the bridge: what its command arrived +/// over, which a person reading the audit can tell apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "kebab-case")] +pub enum TerminalVia { + /// The bridge's direct link (LAN, VPN). + Direct, + /// The direct link over Tor: an `.onion` endpoint. + Onion, + /// A relay. + Relay, + /// A way this client does not know (a newer bridge's). + Unknown, +} + +open_vocabulary!(TerminalVia, Unknown, [Direct, Onion, Relay, Unknown]); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 3a1d7cb6..984da8b0 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -8,7 +8,7 @@ use super::common::{ AgentAction, AgentDescriptor, AgentStyle, AvailablePlugin, CredentialStatus, InstalledPlugin, McpAction, McpServerInfo, OptionChoice, OutputEntry, AgentAccount, PermissionRule, SessionMcpServer, SignInAttempt, SignInStatus, PartialKind, Persona, PluginAction, PluginMarketplace, ProviderProfileInfo, RemoteSessionInfo, SessionOption, - Subagent, UsageData, + Subagent, UsageData, TerminalEndReason, TerminalEventKind, TerminalVia, }; use crate::ranges::SeqRange; @@ -43,6 +43,10 @@ pub struct SessionListMsg { /// [`crate::direct`]). Absent when it serves no direct link. #[serde(default, skip_serializing_if = "Option::is_none")] pub direct: Option, + /// The terminals open on this bridge now: what a phone shows without + /// having opened one itself. Absent or empty when there are none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminals: Option>, /// The list's revision: strictly greater in each list the bridge /// publishes (its clock in ms, kept ahead of the last one). The same /// list reaches a phone over several relays and the direct link, and @@ -502,6 +506,107 @@ pub struct ProviderProfileAckMsg { pub error: Option, } +// --- terminals --- + +/// One terminal open on the bridge, as the heartbeat's `terminals` lists it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalInfo { + /// The bridge's id for it, which every other terminal message names. + pub term: String, + /// What runs in it (`/bin/bash`). + pub shell: String, + /// When it opened, ms since the Unix epoch. + #[specta(type = specta_typescript::Number)] + pub opened: u64, + /// The device that opened it (see + /// [`device_id`](crate::recipients::device_id)). + pub device: String, + /// That device's name as its user set it; absent when it gave none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The device whose input it takes now (the writer). + pub writer: String, + pub cols: u16, + pub rows: u16, +} + +/// The answer to `terminal-open`: the id the bridge gave the terminal, and +/// what runs in it. `open_id` echoes the request's, so a phone can match the +/// answer to the request that earned it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenedMsg { + pub open_id: String, + pub term: String, + /// The program the shell runs, e.g. `/bin/bash`. + pub shell: String, +} + +/// Output a terminal produced: `data` (base64 of the bytes) starts at +/// `offset`, a byte offset into everything this terminal has ever written. +/// Rides [`LIVE_KIND`](crate::kinds::LIVE_KIND) and is sent only to the +/// clients watching this terminal (see +/// [`PresenceMsg::watching_terminal`](crate::commands::PresenceMsg::watching_terminal)); +/// a client that missed a run asks for the bytes again with +/// `terminal-replay`, naming the offset it got to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOutputMsg { + pub term: String, + #[specta(type = specta_typescript::Number)] + pub offset: u64, + pub data: String, +} + +/// The input the bridge is still waiting for: it last applied `seq`, and +/// something after it never arrived, so the writer resends from `seq + 1`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalInputFromMsg { + pub term: String, + #[specta(type = specta_typescript::Number)] + pub seq: u64, +} + +/// A terminal is gone, and why. `term` names the terminal; `open_id` is set +/// instead when a `terminal-open` was refused (reason `limit`, `not-allowed` +/// or `disabled`), so the phone learns its open will never be answered any +/// other way. `code` carries the shell's exit status with `exit`; `device` +/// is set with `not-allowed`, naming the device the open was refused for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalExitedMsg { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub term: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + pub reason: TerminalEndReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device: Option, +} + +/// Something happened to a terminal, or to who is attached to it. Sent to +/// every present client of the identity, so a phone learns of a takeover or +/// a close at once even when another client did it. `device` is the device +/// that acted, `name` its user's name for it (absent when it gave none), and +/// `via` how its command reached the bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalEventMsg { + pub term: String, + pub what: TerminalEventKind, + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub via: TerminalVia, + /// When it happened, ms since the Unix epoch. + #[specta(type = specta_typescript::Number)] + pub at: u64, +} + /// The bridge→phone message union. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] #[serde(tag = "type", rename_all = "kebab-case")] @@ -538,6 +643,13 @@ pub enum BridgeToPhone { PairAck(PairAckMsg), ProviderProfiles(ProviderProfilesMsg), ProviderProfileAck(ProviderProfileAckMsg), + TerminalOpened(TerminalOpenedMsg), + /// Rides LIVE_KIND, to the clients watching `term` only. + TerminalOutput(TerminalOutputMsg), + TerminalInputFrom(TerminalInputFromMsg), + TerminalExited(TerminalExitedMsg), + /// To every present client of the identity. + TerminalEvent(TerminalEventMsg), } #[cfg(test)] @@ -677,6 +789,28 @@ mod tests { rt(&json!({"v":12,"type":"pair-ack","machine":"m","ok":false,"reason":"bad-token","relays":["wss://r"],"host":"cli"})); rt(&json!({"v":12,"type":"provider-profiles","machine":"m","profiles":[{"id":"p","agent":"claude-code","label":"L","baseUrl":"https://x","models":[{"id":"m"}],"hasToken":true}]})); rt(&json!({"v":12,"type":"provider-profile-ack","machine":"m","profileId":"p","success":true,"tokenValid":false})); + rt(&json!({"v":12,"type":"terminal-opened","openId":"o1","term":"t1","shell":"/bin/bash"})); + rt(&json!({"v":12,"type":"terminal-output","term":"t1","offset":0,"data":"bHMgLWxhCg=="})); + rt(&json!({"v":12,"type":"terminal-input-from","term":"t1","seq":4})); + rt(&json!({"v":12,"type":"terminal-exited","term":"t1","code":0,"reason":"exit"})); + rt(&json!({"v":12,"type":"terminal-exited","openId":"o1","reason":"not-allowed","device":"d1"})); + rt(&json!({"v":12,"type":"terminal-event","term":"t1","what":"takeover","device":"d1","name":"Pixel 8","via":"relay","at":1792008058637_u64})); + } + + #[test] + fn a_heartbeat_lists_terminals_both_shapes() { + let m = rt(&json!({"v":12,"type":"sessions","machine":"m","sessions":[],"agents":[], + "terminals":[{"term":"t1","shell":"/bin/bash","opened":1792008058637_u64,"device":"d1","name":"Pixel 8","writer":"d1","cols":80,"rows":24}, + {"term":"t2","shell":"/bin/zsh","opened":1792008058638_u64,"device":"d2","writer":"d2","cols":132,"rows":43}]})); + match m { + BridgeToPhone::Sessions(s) => { + let t = s.terminals.unwrap(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].name.as_deref(), Some("Pixel 8")); + assert_eq!(t[1].name, None); + } + _ => panic!(), + } } #[test] @@ -685,6 +819,17 @@ mod tests { assert!(serde_json::from_str::(r#"{"v":12,"type":"mode-confirmed","sessionId":"s","mode":"plan"}"#).is_err()); } + #[test] + fn an_unknown_terminal_vocabulary_value_is_its_catch_all() { + let m = rt(&json!({"v":12,"type":"terminal-exited","term":"t1","reason":"melted"})); + let BridgeToPhone::TerminalExited(e) = m else { panic!() }; + assert_eq!(e.reason, crate::common::TerminalEndReason::Unknown); + let m = rt(&json!({"v":12,"type":"terminal-event","term":"t1","what":"hologram","device":"d","via":"telepathy","at":1})); + let BridgeToPhone::TerminalEvent(e) = m else { panic!() }; + assert_eq!(e.what, crate::common::TerminalEventKind::Unknown); + assert_eq!(e.via, crate::common::TerminalVia::Unknown); + } + #[test] fn a_typed_entry_survives_the_full_message() { let m = rt(&json!({"v":12,"type":"output","sessionId":"s","seq":1,"entries":[{ diff --git a/crates/protocol/src/kinds.rs b/crates/protocol/src/kinds.rs index 88cb5c7f..555c0ccd 100644 --- a/crates/protocol/src/kinds.rs +++ b/crates/protocol/src/kinds.rs @@ -20,9 +20,11 @@ pub const COMMAND_KIND: u16 = 4515; /// [`RESPONSE_EXPIRY_SECONDS`]. pub const RESPONSE_KIND: u16 = 4516; -/// Bridge → phone live output/status/usage. Ephemeral (20000–29999): relays -/// broadcast but never store. Loss is acceptable by design — transcript sync -/// recovers anything missed. +/// Bridge → phone live output/status/usage, and phone → bridge terminal +/// input. Ephemeral (20000–29999): relays broadcast but never store, in both +/// directions — keystrokes are never stored on a relay. Loss is acceptable +/// by design: transcript sync recovers anything missed, and the terminal's +/// own `seq` (input) / `offset` (output) says what to resend. pub const LIVE_KIND: u16 = 24515; /// NIP-40 expiration applied to [`COMMAND_KIND`] events (seconds). diff --git a/crates/protocol/src/recipients.rs b/crates/protocol/src/recipients.rs index 50d46756..1ba986f1 100644 --- a/crates/protocol/src/recipients.rs +++ b/crates/protocol/src/recipients.rs @@ -37,6 +37,21 @@ pub fn fragment_d(recipient_tag: &str, index: usize) -> String { digest(&["codedeck/fragment", recipient_tag, &index.to_string()]) } +/// The id of the device a client is: the first 16 bytes, in hex, of +/// `sha256("codedeck/device\0" + first_session_key_hex + "\0")`, where the +/// key is the FIRST session key this client ever granted the bridge — the +/// one its later [`SessionKeyGrant::replaces`](crate::commands::SessionKeyGrant::replaces) +/// chains back to. The bridge derives it once per client and names it in a +/// terminal's `writer`, in every `terminal-event`, and in its audit and +/// allowlist, so a device keeps one identity while its key rotates; what a +/// signed grant calls itself (`label`) never takes part. Re-pairing, or +/// reinstalling with a fresh identity, makes a new device — the old chain of +/// keys is gone. Case-blind: both ends lowercase the key, as the recipient +/// tag does. +pub fn device_id(first_session_key_hex: &str) -> String { + digest(&["codedeck/device", &first_session_key_hex.to_ascii_lowercase()]) +} + /// The first 16 bytes of the SHA-256 of `parts`, each ended by a NUL (none /// of them holds one), as hex. fn digest(parts: &[&str]) -> String { @@ -91,4 +106,16 @@ mod tests { assert_eq!(tag, "4a7ce9e7ef864a34441d699f45429335"); assert_eq!(fragment_d(&tag, 2), "596da8b0093949098d6a58c85b4145cc"); } + + #[test] + fn a_device_id_is_pinned_and_case_blind() { + // The pin: sha256("codedeck/device\0" + first key + "\0")[..16]. + const FIRST_KEY: &str = "d0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeef"; + assert_eq!(device_id(FIRST_KEY), "bf1f111943b3930a4c64a5c06d9153b2"); + assert_eq!(device_id("D0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEF"), "bf1f111943b3930a4c64a5c06d9153b2"); + // A different first key is a different device. + assert_ne!(device_id("aa11"), device_id(FIRST_KEY)); + // The parts cannot run into each other, as for the recipient tag. + assert_ne!(device_id("aa"), device_id("aa00")); + } } diff --git a/crates/protocol/tests/codec_conformance.rs b/crates/protocol/tests/codec_conformance.rs index 542ce182..e03df823 100644 --- a/crates/protocol/tests/codec_conformance.rs +++ b/crates/protocol/tests/codec_conformance.rs @@ -103,9 +103,15 @@ fn p2b_type(m: &PhoneToBridge) -> &'static str { PhoneToBridge::ProviderProfilesRequest(_) => "provider-profiles-request", PhoneToBridge::SessionKey(_) => "session-key", PhoneToBridge::Presence(_) => "presence", + PhoneToBridge::TerminalOpen(_) => "terminal-open", + PhoneToBridge::TerminalInput(_) => "terminal-input", + PhoneToBridge::TerminalResize(_) => "terminal-resize", + PhoneToBridge::TerminalReplay(_) => "terminal-replay", + PhoneToBridge::TerminalTakeover(_) => "terminal-takeover", + PhoneToBridge::TerminalClose(_) => "terminal-close", } } -const P2B_TYPES: usize = 36; +const P2B_TYPES: usize = 42; /// Every bridge→phone message type, by wire name (see [`p2b_type`]). fn b2p_type(m: &BridgeToPhone) -> &'static str { @@ -142,9 +148,14 @@ fn b2p_type(m: &BridgeToPhone) -> &'static str { BridgeToPhone::PairAck(_) => "pair-ack", BridgeToPhone::ProviderProfiles(_) => "provider-profiles", BridgeToPhone::ProviderProfileAck(_) => "provider-profile-ack", + BridgeToPhone::TerminalOpened(_) => "terminal-opened", + BridgeToPhone::TerminalOutput(_) => "terminal-output", + BridgeToPhone::TerminalInputFrom(_) => "terminal-input-from", + BridgeToPhone::TerminalExited(_) => "terminal-exited", + BridgeToPhone::TerminalEvent(_) => "terminal-event", } } -const B2P_TYPES: usize = 32; +const B2P_TYPES: usize = 37; #[test] fn corpus_covers_every_message_type() { From 31721091a4f710bc2fac25999bc620e1eeaf23b4 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:44:53 +0200 Subject: [PATCH 03/50] docs: describe the terminal protocol Part 1 gains a Terminal section: the message list, the `terminal` capability (advertised only by a bridge whose owner has terminals switched on), LIVE_KIND in both directions, the per-terminal input `seq` and byte-offset output, `watchingTerminal`, the device id derived from a client's first session key, and the heartbeat's `terminals[]`. The session-key grant keeps its `label`, display only for the bridge's audit, and the capability and open-vocabulary lists name the new values. Co-Authored-By: Claude Code --- docs/PROTOCOL.md | 66 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 339ef154..55eaf597 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -28,7 +28,7 @@ data over); v12 sends a copy of each message per client of an identity (see | Purpose | Kind | Storage | |---|---|---| | Session-list heartbeat (`sessions`) | **30515** | NIP-33 replaceable, one per client (`d` = the copy's recipient tag); NIP-40 expiry 1 day | -| Live output / usage / partial text | **24515** | ephemeral — never stored; `partial` goes to the clients watching that session, loss is recoverable (sync / re-request / the completing entry) | +| Live output / usage / partial text, terminal output and keystrokes | **24515** | ephemeral — never stored; `partial` goes to the clients watching that session and `terminal-output` to those watching that terminal, loss is recoverable (sync / re-request / `terminal-replay` / the completing entry) | | Phone→bridge commands | **4515** | stored, NIP-40 expiry 1 h | | Bridge→phone responses (sync chunks, acks, lifecycle, pairing) | **4516** | stored, NIP-40 expiry 1 h | @@ -285,6 +285,53 @@ under its own name in `/.codedeck/uploads` and runs `text` with the path added as the session's next input — an image to be looked at, any other file named. At most 25 MiB; a relay-only upload must fit 200 chunks. +### Terminal + +With the `terminal` capability — which a bridge advertises only when its +owner has terminals switched on, unlike `files` and `session-keys` — a phone +opens a real shell on the bridge's machine: + +- `terminal-open {openId, cols, rows, session?}` opens one. `openId` is the + phone's own, echoed in the answer, so it can match a reply to the request + that earned it; `session` starts the shell in that session's directory + (the phone never names a path). Answer: `terminal-opened {openId, term, + shell}`, or `terminal-exited` with `openId` and a refusal `reason` + (`limit`, `not-allowed`, `disabled`). +- `terminal-input {term, seq, data}` types into it, writer only. `seq` counts + up by one from 1 per terminal: the bridge applies input in that order and + drops a `seq` it has already applied, so a relay replaying a signed event + types nothing twice. `data` is base64 of the bytes (a key press need not be + valid UTF-8). It rides **LIVE_KIND (24515)**, ephemeral like the output — + a keystroke is never stored on a relay. A gap the bridge waited for and did + not get comes back as `terminal-input-from {term, seq}`: resend from + `seq + 1`. +- `terminal-output {term, offset, data}` is what the shell wrote, `data` + base64 and `offset` the byte offset of its first byte in everything this + terminal has output. Also LIVE_KIND, and like `partial` it goes ONLY to the + present clients watching that terminal (`presence.watchingTerminal`) — a + client that missed a run reattaches with `terminal-replay {term, from}`, + which resends the output from `from` and what follows, read-only. +- `terminal-resize {term, cols, rows}`, `terminal-takeover {term}` (become + the writer) and `terminal-close {term}` (hang up). +- `terminal-exited {term?, openId?, code?, reason, device?}` says a terminal + is gone: `exit` with the shell's `code`, or `killed` / `closed` / `idle` / + `transport` / `restart`; the vocabulary is open, and a phone shows an + unknown reason as "ended". `openId` is set instead of `term` when an open + was refused, `device` when it was refused for that device. +- `terminal-event {term, what, device, name?, via, at}` tells EVERY present + client of the identity what happened (`opened`, `attached`, `takeover`, + `closed`, `exited`), with the device that acted, its `name` and how its + command arrived (`via`: `direct`, `onion` or `relay`), so a phone that did + not act learns at once — takeovers and closes are audited this way. +- The heartbeat's `terminals[]` (`TerminalInfo {term, shell, opened, device, + name?, writer, cols, rows}`) lists what is open, so a phone shows one it + did not open. +- **The device id** names a client across its key rotations: the first 16 + bytes, in hex, of `sha256("codedeck/device\0" + first session key + "\0")`, + the FIRST key the client granted this bridge — the one its `replaces` + chains back to (`crates/protocol/src/recipients.rs` pins it). Re-pairing or + reinstalling makes a new device. + ### Credentials `set-credentials {agent?, values: {id: secret | null}}` → `credentials-ack @@ -625,8 +672,11 @@ client's tags before and after a key rotation. A phone whose identity key lives in an external signer (NIP-55) should not ask it to decrypt every message. It grants a local key, once: -`session-key {sessionKey: {pubkeyHex, bridgePubkeyHex, expiresAt, replaces?}}` (or -`sessionKey` on its `pair-request`). `bridgePubkeyHex` names the one bridge +`session-key {sessionKey: {pubkeyHex, bridgePubkeyHex, expiresAt, replaces?, label?}}` (or +`sessionKey` on its `pair-request`). `label` is the device's name as its user +set it (the phone's model by default) — display only, for the bridge's audit +and allowlist; what the bridge trusts is the device id it derives from the +client's first key (see [Terminal](#terminal)). `bridgePubkeyHex` names the one bridge the grant is for; a bridge refuses a grant naming another, so a grant one bridge saw cannot be replayed to a second. A session key only ever keys NIP-44 payloads; it never signs. Every event keeps the same parties — the phone signs everything it @@ -717,8 +767,10 @@ ignored. The open vocabularies a bridge or agent may grow decode an unknown value as a catch-all instead of failing the whole message: an entry type as `unsupported` (it keeps its seq; the phone says it needs an update to show it), a tool or task kind as `other`, a notice kind as `other` (its text still -shows), and an MCP status, session state or install state as `unknown` (an -agent in an unknown install state runs no session). A known value with a +shows), and an MCP status, session state, install state, terminal end +reason, terminal event kind or terminal via as `unknown` (an agent in an +unknown install state runs no session; a terminal with an unknown reason is +shown as ended). A known value with a missing or mistyped field is still an error. The heartbeat carries `capabilities[]`, what this BRIDGE offers; what an @@ -727,7 +779,9 @@ a phone does, and a string is added only when a phone relying on it against a bridge without it would otherwise fail: - `files`: the phone shows the attach control only when present; -- `session-keys`: the phone grants a session key only when present. +- `session-keys`: the phone grants a session key only when present; +- `terminal`: the phone shows the terminal only when present (a bridge that + has terminals switched off does not advertise it). ### Oversize-event fragmentation (`chunk`) From b6d2cf3ee2c84becbc7783746ceedd43adb149c9 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:46:20 +0200 Subject: [PATCH 04/50] fix(protocol): terminal-input-from names the first seq to resend, and any reinstall is a new device The message's own name and the design read `seq` as where the writer resumes; its doc said the last one applied, so a phone following it would resend one event too late and leave a hole. A reinstall grants a fresh first session key whatever identity it signs with, so it is a new device in every case. Co-Authored-By: Claude Code --- crates/protocol/src/events.rs | 5 +++-- crates/protocol/src/recipients.rs | 6 +++--- docs/PROTOCOL.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 984da8b0..5e6999e8 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -559,8 +559,9 @@ pub struct TerminalOutputMsg { pub data: String, } -/// The input the bridge is still waiting for: it last applied `seq`, and -/// something after it never arrived, so the writer resends from `seq + 1`. +/// The input the bridge is still waiting for: `seq` is the first one that +/// never arrived (one more than the last it applied), and the writer resends +/// its input from there. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct TerminalInputFromMsg { diff --git a/crates/protocol/src/recipients.rs b/crates/protocol/src/recipients.rs index 1ba986f1..f4c17249 100644 --- a/crates/protocol/src/recipients.rs +++ b/crates/protocol/src/recipients.rs @@ -44,9 +44,9 @@ pub fn fragment_d(recipient_tag: &str, index: usize) -> String { /// chains back to. The bridge derives it once per client and names it in a /// terminal's `writer`, in every `terminal-event`, and in its audit and /// allowlist, so a device keeps one identity while its key rotates; what a -/// signed grant calls itself (`label`) never takes part. Re-pairing, or -/// reinstalling with a fresh identity, makes a new device — the old chain of -/// keys is gone. Case-blind: both ends lowercase the key, as the recipient +/// signed grant calls itself (`label`) never takes part. Re-pairing or +/// reinstalling makes a new device: the app grants a fresh first key, and +/// the old chain of keys is gone. Case-blind: both ends lowercase the key, as the recipient /// tag does. pub fn device_id(first_session_key_hex: &str) -> String { digest(&["codedeck/device", &first_session_key_hex.to_ascii_lowercase()]) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 55eaf597..eee2f10f 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -304,7 +304,7 @@ opens a real shell on the bridge's machine: valid UTF-8). It rides **LIVE_KIND (24515)**, ephemeral like the output — a keystroke is never stored on a relay. A gap the bridge waited for and did not get comes back as `terminal-input-from {term, seq}`: resend from - `seq + 1`. + `seq`, the first one that never arrived. - `terminal-output {term, offset, data}` is what the shell wrote, `data` base64 and `offset` the byte offset of its first byte in everything this terminal has output. Also LIVE_KIND, and like `partial` it goes ONLY to the From d68de34f201380024aed78d81ce80fd1a803680f Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:02:44 +0200 Subject: [PATCH 05/50] docs: the terminal design's message table matches the wire `openId` on the open and its answers, `restart` among the end reasons (and the vocabulary open), `data` as any bytes rather than UTF-8 (a key chord need not be valid UTF-8), and the device's `name` optional, as the protocol crate defines them. Co-Authored-By: Claude Code --- docs/TERMINAL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index b22fe603..6d8262bc 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -142,17 +142,17 @@ All carry `v` like every message. `term` is a terminal id the bridge assigns. | Message | Direction | Fields | | --- | --- | --- | -| `terminal-open` | phone → bridge | `cols`, `rows`, `session?` (start in its directory) | -| `terminal-opened` | bridge → phone | `term`, `shell` (what runs, e.g. `/bin/bash`) | -| `terminal-input` | phone → bridge | `term`, `seq`, `data` (UTF-8 bytes, base64) | +| `terminal-open` | phone → bridge | `openId` (the phone's own, echoed back), `cols`, `rows`, `session?` (start in its directory) | +| `terminal-opened` | bridge → phone | `openId`, `term`, `shell` (what runs, e.g. `/bin/bash`) | +| `terminal-input` | phone → bridge | `term`, `seq`, `data` (base64 of the bytes — a key press need not be valid UTF-8) | | `terminal-input-from` | bridge → phone | `term`, `seq` (resend from here) | | `terminal-output` | bridge → phone | `term`, `offset`, `data` (base64) | | `terminal-resize` | phone → bridge | `term`, `cols`, `rows` (writer only) | | `terminal-replay` | phone → bridge | `term`, `from` (byte offset; attaches read-only) | | `terminal-takeover` | phone → bridge | `term` (become the writer) | | `terminal-close` | phone → bridge | `term` (hang up: SIGHUP, then kill after 5 s) | -| `terminal-exited` | bridge → phone | `term?`, `code?`, `reason` (exit, killed, closed, idle, limit, not-allowed, disabled, transport), `device?` | -| `terminal-event` | bridge → every present client | `term`, `what` (opened, attached, takeover, closed, exited), `device`, `name`, `via`, `at` | +| `terminal-exited` | bridge → phone | `term?`, `openId?` (instead of `term` for a refused open), `code?`, `reason` (exit, killed, closed, idle, limit, not-allowed, disabled, transport, restart; open — an unknown one reads as ended), `device?` | +| `terminal-event` | bridge → every present client | `term`, `what` (opened, attached, takeover, closed, exited), `device`, `name?`, `via`, `at` | The open terminals ride the session list the bridge already publishes (`term`, `shell`, `opened`, `device`, `name`, `writer`, `cols`, `rows`), so a From f43abf0d8229def666d3bb1752ddcfd00b06180c Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:29:44 +0200 Subject: [PATCH 06/50] feat(bridge-core): a device id and a name per client, and where its command came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal needs to know which phone it is talking to across the phone's session-key rotations: the allowlist that says who may have a shell, the audit that says who typed, and the events that tell the other phones who did what all need a name that outlives a rotated key. Every client of an identity shares the identity key, so a stolen one would otherwise open a shell from a phone nobody approved — which is why the name is derived from the first session key the client granted this bridge (`protocol::recipients::device_id`), carried across every rotation, and approved on the machine. The label rides the grant and is display only: trimmed, one line, capped, never what the bridge trusts. A state file written before either field existed hydrates from the key it holds, and is stored back so the name survives the next rotation. The inbound path also learns how a command arrived and what kind of event carried it: a terminal's audit has to tell a relay from the bridge's own link (and an onion endpoint from a plain one), and a phone's keystrokes ride the live kind, which the engine has to tell apart from a command before it accepts either. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/mod.rs | 1 + crates/bridge-core/src/engine/phone.rs | 4 +- crates/bridge-core/src/engine/session_keys.rs | 80 +++++++++++++++++-- crates/bridge-core/src/ingest.rs | 1 + crates/bridge-core/src/io.rs | 43 +++++++++- crates/bridge-core/tests/session_keys.rs | 20 ++--- crates/bridge-core/tests/settings.rs | 4 +- crates/bridge-core/tests/support/mod.rs | 16 +++- crates/bridge-runtime/src/direct.rs | 6 +- crates/bridge-runtime/src/relay.rs | 8 +- crates/bridge-runtime/tests/direct.rs | 2 +- 11 files changed, 151 insertions(+), 34 deletions(-) diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 6474fe3b..040fe599 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -303,6 +303,7 @@ impl Engine { self.started = true; self.paired = load_or_default(self.store.get(store_keys::PAIRED_PHONES), "paired phones"); self.presume_present(); + self.hydrate_devices(); self.credentials = load_or_default(self.store.get(store_keys::CREDENTIALS), "credentials"); let profiles: StoredProfiles = load_or_default(self.store.get(store_keys::PROVIDER_PROFILES), "provider profiles"); self.profiles = profiles.profiles.into_iter().map(|p| (p.id.clone(), p)).collect(); diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 4b82b500..ed5821bb 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -81,7 +81,9 @@ impl Engine { self.on_pair_request(req, &event.pubkey); } } - Via::Commands => { + // Every way a command can reach the bridge: over a relay, or over + // the bridge's own link (directly, or with Tor in front of it). + Via::Relay | Via::Direct | Via::Onion => { if !self.paired.iter().any(|p| p.pubkey_hex == event.pubkey) { log::info!("[Engine] Ignoring event from unknown pubkey: {}...", short(&event.pubkey)); return; diff --git a/crates/bridge-core/src/engine/session_keys.rs b/crates/bridge-core/src/engine/session_keys.rs index 784c1135..b6446b4c 100644 --- a/crates/bridge-core/src/engine/session_keys.rs +++ b/crates/bridge-core/src/engine/session_keys.rs @@ -46,6 +46,7 @@ use protocol::commands::{ }; use protocol::crypto::npub_from_hex; use protocol::events::{BridgeToPhone, PartialMsg}; +use protocol::recipients::device_id; use super::Engine; use crate::io::{store_keys, Addressee, Effect, PhoneClient, WatchState}; @@ -54,6 +55,12 @@ use crate::io::{store_keys, Addressee, Effect, PhoneClient, WatchState}; /// so a client that keeps granting fresh keys cannot grow that without end. pub const MAX_CLIENTS: usize = 16; +/// The longest a device's own name for itself may be. It is display only — +/// written to the audit, shown on the owner's machine — so a name is trimmed, +/// stripped of the control characters that would break a log line apart, and +/// cut short: nothing that long is a name. +const MAX_LABEL_CHARS: usize = 64; + /// Clock skew allowed on a grant's `expiresAt`. const SKEW_SECS: u64 = 300; @@ -61,6 +68,14 @@ fn short(key: &str) -> &str { key.get(..8).unwrap_or(key) } +/// A device's name for itself, as it may be shown and written down: one line, +/// trimmed, and short enough that it is not a payload. +fn clean_label(label: Option) -> Option { + let label: String = label?.chars().filter(|c| !c.is_control()).collect(); + let trimmed = label.trim(); + (!trimmed.is_empty()).then(|| trimmed.chars().take(MAX_LABEL_CHARS).collect()) +} + impl Engine { fn now_secs(&self) -> u64 { self.now() / 1000 @@ -69,7 +84,7 @@ impl Engine { /// Accept `grant` for the paired `identity`, sent encrypted with /// `encrypted_with` (a session key, or the identity). Returns whether it /// took. - pub(super) fn grant_session_key(&mut self, identity: &str, grant: SessionKeyGrant, encrypted_with: &str) -> bool { + pub(super) fn grant_session_key(&mut self, identity: &str, mut grant: SessionKeyGrant, encrypted_with: &str) -> bool { let now = self.now_secs(); let refusal = if npub_from_hex(&grant.pubkey_hex).is_err() { Some("not a valid public key") @@ -101,11 +116,20 @@ impl Engine { if let Some(i) = renewed { let client = &mut phone.clients[i]; client.key.expires_at = client.key.expires_at.max(grant.expires_at); + client.label = clean_label(grant.label.take()); client.present_until_ms = present_until_ms; log::info!("[Engine] Session key {key}... renewed for {}...", short(identity)); } else if let Some(i) = rotated { let client = &mut phone.clients[i]; + let device = client.device.clone(); + // The name may change with a grant, the device does not: it is + // what the terminal allowlist and the audit know this client by, + // and it has to outlive every rotation. + let device = if device.is_empty() { device_id(&client.key.pubkey_hex) } else { device }; + let label = clean_label(grant.label.take()); client.previous = Some(std::mem::replace(&mut client.key, grant)); + client.device = device; + client.label = label; client.present_until_ms = present_until_ms; log::info!("[Engine] A client of {}... rotated to session key {key}...", short(identity)); } else { @@ -120,7 +144,19 @@ impl Engine { let dropped = phone.clients.remove(quietest); log::info!("[Engine] {}... has too many clients; dropping {}...", short(identity), short(&dropped.key.pubkey_hex)); } - phone.clients.push(PhoneClient { key: grant, previous: None, present_until_ms, watch: WatchState::default() }); + // The first key this client grants is what names its device for + // as long as the client exists (see `protocol::recipients`). + let device = device_id(&grant.pubkey_hex); + let label = clean_label(grant.label.take()); + phone.clients.push(PhoneClient { + key: grant, + previous: None, + device, + label, + present_until_ms, + watch: WatchState::default(), + watch_terminal: WatchState::default(), + }); log::info!("[Engine] Session key {key}... granted for a new client of {}...", short(identity)); } self.store_paired(); @@ -144,6 +180,24 @@ impl Engine { }) } + /// Give a device id to every client a state file written before devices + /// existed left without one, derived from the key it holds — the only key + /// there is to go on. Stored right after, so the name survives the + /// rotation that would otherwise change it: a device is the first key its + /// client ever granted this bridge. + pub(super) fn hydrate_devices(&mut self) { + let mut filled = false; + for client in self.paired.iter_mut().flat_map(|p| p.clients.iter_mut()) { + if client.device.is_empty() { + client.device = device_id(&client.key.pubkey_hex); + filled = true; + } + } + if filled { + self.store_paired(); + } + } + /// Drop lapsed keys, and the clients whose newest key lapsed; they would /// be skipped anyway, this keeps the stored list tidy. pub(super) fn prune_session_keys(&mut self) { @@ -189,14 +243,15 @@ impl Engine { /// /// `msg` is the command that carried it. A `presence` also says which /// session the client watches — a client that starts watching one is - /// caught up on that session's live parts at once — and `away` means it - /// watches nothing. Any other command says the client is running and - /// leaves what it watches as it was. + /// caught up on that session's live parts at once — and which terminal it + /// shows, which is what its output goes to. `away` means it watches + /// nothing. Any other command says the client is running and leaves what + /// it watches as it was. pub(super) fn note_presence(&mut self, identity: &str, key: &str, msg: &PhoneToBridge) -> bool { let now = self.now(); - let (away, watch) = match msg { - PhoneToBridge::Presence(m) => (m.away, Some(m.watching.clone())), - _ => (false, None), + let (away, watch, watch_terminal) = match msg { + PhoneToBridge::Presence(m) => (m.away, Some(m.watching.clone()), Some(m.watching_terminal.clone())), + _ => (false, None, None), }; let Some(phone) = self.paired.iter_mut().find(|p| p.pubkey_hex == identity) else { return false }; let holds = |c: &&mut PhoneClient| c.key.pubkey_hex == key || c.previous.as_ref().is_some_and(|k| k.pubkey_hex == key); @@ -219,6 +274,15 @@ impl Engine { client.watch = next; } } + // A terminal's output is not kept the way a part of live text is: a + // client that starts showing one asks for what it missed with + // `terminal-replay`, which the runtime serves from its ring. + if let Some(watching) = watch_terminal { + let next = WatchState::Watching(if away { None } else { watching }); + if next != client.watch_terminal { + client.watch_terminal = next; + } + } if let Some((client_key, session_id)) = catch_up { self.catch_up_live_text(identity, &client_key, &session_id); } diff --git a/crates/bridge-core/src/ingest.rs b/crates/bridge-core/src/ingest.rs index baaac708..70c7be60 100644 --- a/crates/bridge-core/src/ingest.rs +++ b/crates/bridge-core/src/ingest.rs @@ -175,6 +175,7 @@ mod tests { pubkey: from.pubkey_hex.clone(), created_at, content: encrypt_to(&from.secret_key, &self.bridge.pubkey_hex, json).unwrap(), + kind: protocol::kinds::COMMAND_KIND, } } fn event(&mut self, json: &str) -> InboundEvent { diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index 45f57ccf..c6d4f7da 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -11,7 +11,7 @@ use protocol::commands::UploadFileMsg; use protocol::events::BridgeToPhone; use serde::{Deserialize, Serialize}; -/// A command event from the relays, signature already verified. `content` is +/// A command event from a phone, signature already verified. `content` is /// still NIP-44 ciphertext. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InboundEvent { @@ -21,13 +21,30 @@ pub struct InboundEvent { /// Seconds since the Unix epoch. pub created_at: u64, pub content: String, + /// The event's kind: [`COMMAND_KIND`](protocol::kinds::COMMAND_KIND) for + /// every command, [`LIVE_KIND`](protocol::kinds::LIVE_KIND) for the + /// keystrokes a phone types into a terminal. What a command carries is + /// remembered by its event id so a relay's replay runs nothing twice; + /// live input must not be (a burst of keystrokes would push command ids + /// out of the dedup set), so the engine has to tell the two apart before + /// it accepts either. + pub kind: u16, } -/// Which subscription delivered an event. +/// Which subscription delivered an event, and how it reached the bridge. +/// The terminal audit and `terminal-event` name it, so a person reading them +/// can tell a command that came over a relay from one that came over the +/// bridge's own link — and, of those, one that took the phone through Tor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Via { - /// The standing subscription: commands from paired phones. - Commands, + /// The standing subscription over a relay: commands from paired phones. + Relay, + /// The standing subscription over the bridge's direct link (LAN, VPN). + Direct, + /// The standing subscription over the direct link's `.onion` endpoint + /// (the phone reached it through Orbot): the same link directly, with Tor + /// in front, which the audit keeps apart from a plain one. + Onion, /// The pairing window's authorless subscription. Pairing, } @@ -60,6 +77,18 @@ pub struct PhoneClient { /// it, until it lapses. #[serde(default, skip_serializing_if = "Option::is_none")] pub previous: Option, + /// The device this client is: [`device_id`](protocol::recipients::device_id) + /// of the FIRST key it granted this bridge, so its name in the terminal + /// allowlist and in the audit survives every rotation. Empty in a state + /// file written before devices existed; the engine derives it from the + /// key there on load. + #[serde(default)] + pub device: String, + /// The client's own name for its device, from its latest grant (a phone + /// sends its model by default). What the machine's owner reads when + /// deciding whether to approve a device; never what it trusts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, /// Until when (ms) the client is present: it gets the identity's shared /// updates (see `protocol::commands::PresenceMsg`). Not stored: a /// restarted bridge counts every client present for a lease, so none @@ -71,6 +100,12 @@ pub struct PhoneClient { /// live text until the client says again. #[serde(skip)] pub watch: WatchState, + /// The terminal whose output the client shows, set the same way `watch` + /// is: a `presence` says both. A terminal's output goes only to the + /// clients showing it. Not stored, for the same reason as `watch` — and + /// because a bridge that has just restarted has no terminals. + #[serde(skip)] + pub watch_terminal: WatchState, } /// Which session's live text a client wants. Only a `partial` is filtered by diff --git a/crates/bridge-core/tests/session_keys.rs b/crates/bridge-core/tests/session_keys.rs index 13cc3fe0..15416abe 100644 --- a/crates/bridge-core/tests/session_keys.rs +++ b/crates/bridge-core/tests/session_keys.rs @@ -58,7 +58,7 @@ fn to(phone: &Keypair, key: &Keypair) -> Vec> { /// A refresh from the phone, its payload encrypted with `key`. fn refresh_via(rig: &mut Rig, key: &Keypair) { let phone = rig.phone.clone(); - rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"refresh-sessions"}), Via::Commands); + rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"refresh-sessions"}), Via::Relay); } #[test] @@ -87,10 +87,10 @@ fn an_event_signed_by_the_session_key_is_not_heard() { let mut rig = Rig::new(); let key = generate_keypair(); grant(&mut rig, &key, NOW_SECS + DAY); - rig.phone_event(&key, json!({"v":12,"type":"refresh-sessions"}), Via::Commands); + rig.phone_event(&key, json!({"v":12,"type":"refresh-sessions"}), Via::Relay); assert!(heartbeats(&rig.take()).is_empty()); let bridge = rig.bridge.pubkey_hex.clone(); - rig.phone_event(&key, grant_msg(&bridge, &generate_keypair().pubkey_hex, NOW_SECS + DAY), Via::Commands); + rig.phone_event(&key, grant_msg(&bridge, &generate_keypair().pubkey_hex, NOW_SECS + DAY), Via::Relay); assert_eq!(client_keys(&rig).len(), 1, "a key cannot grant a key"); } @@ -150,12 +150,12 @@ fn a_key_belongs_to_one_phone() { } let b_key = generate_keypair(); let bridge = rig.bridge.pubkey_hex.clone(); - rig.phone_event(&b, grant_msg(&bridge, &b_key.pubkey_hex, NOW_SECS + DAY), Via::Commands); + rig.phone_event(&b, grant_msg(&bridge, &b_key.pubkey_hex, NOW_SECS + DAY), Via::Relay); rig.take(); // A names B's identity, then B's session key, as its own session key. for key in [&b.pubkey_hex, &b_key.pubkey_hex] { - rig.phone_event(&a, grant_msg(&bridge, key, NOW_SECS + DAY), Via::Commands); + rig.phone_event(&a, grant_msg(&bridge, key, NOW_SECS + DAY), Via::Relay); rig.take(); } let phones = rig.engine.paired_phones(); @@ -209,7 +209,7 @@ fn a_rotation_moves_its_client_to_the_new_key() { // Sent under the key it retires, as a client rotating does. let phone = rig.phone.pubkey_hex.clone(); let rig_phone = rig.phone.clone(); - rig.phone_event_via_key(&rig_phone, &phone_a, rotate_msg(&bridge, &a_next.pubkey_hex, &phone_a.pubkey_hex, NOW_SECS + 2 * DAY), Via::Commands); + rig.phone_event_via_key(&rig_phone, &phone_a, rotate_msg(&bridge, &a_next.pubkey_hex, &phone_a.pubkey_hex, NOW_SECS + 2 * DAY), Via::Relay); let sent = heartbeats(&rig.take()); assert_eq!(sent, vec![vec![(phone.clone(), a_next.pubkey_hex.clone()), (phone.clone(), phone_b.pubkey_hex.clone())]]); // A command still under the old key is read while the client rotates. @@ -237,7 +237,7 @@ fn only_the_client_holding_a_key_can_retire_it() { // Another client of the identity names the victim's key, under its own // key and under the identity: neither moves the victim's copies. let takeover = rotate_msg(&bridge, &intruder.pubkey_hex, &victim.pubkey_hex, NOW_SECS + DAY); - rig.phone_event_via_key(&phone, &other, takeover.clone(), Via::Commands); + rig.phone_event_via_key(&phone, &other, takeover.clone(), Via::Relay); rig.send(takeover); rig.take(); let keys = client_keys(&rig); @@ -328,7 +328,7 @@ fn a_pair_request_can_grant_the_first_key() { fn presence(rig: &mut Rig, key: &Keypair, away: bool) -> Vec { let phone = rig.phone.clone(); rig.take(); - rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"presence","away":away}), Via::Commands); + rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"presence","away":away}), Via::Relay); rig.take() } @@ -382,7 +382,7 @@ fn a_sync_goes_to_the_client_that_asked_alone() { grant(&mut rig, &other, NOW_SECS + DAY); rig.take(); let phone = rig.phone.clone(); - rig.phone_event_via_key(&phone, &asking, json!({"v":12,"type":"sync-request","sessionId":"s","haveRanges":[]}), Via::Commands); + rig.phone_event_via_key(&phone, &asking, json!({"v":12,"type":"sync-request","sessionId":"s","haveRanges":[]}), Via::Relay); let sync: Vec> = rig .take() .iter() @@ -403,7 +403,7 @@ fn a_sync_goes_to_the_client_that_asked_alone() { fn watching(rig: &mut Rig, key: &Keypair, session: Option<&str>) -> Vec { let phone = rig.phone.clone(); rig.take(); - rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"presence","watching":session}), Via::Commands); + rig.phone_event_via_key(&phone, key, json!({"v":12,"type":"presence","watching":session}), Via::Relay); rig.take() } diff --git a/crates/bridge-core/tests/settings.rs b/crates/bridge-core/tests/settings.rs index 279dee54..3466eb77 100644 --- a/crates/bridge-core/tests/settings.rs +++ b/crates/bridge-core/tests/settings.rs @@ -105,7 +105,7 @@ fn unpaired_senders_are_not_heard_on_the_command_path() { rig.host_up(); rig.take(); let stranger = generate_keypair(); - rig.phone_event(&stranger, json!({"v":12,"type":"create-session","agent":"alpha"}), Via::Commands); + rig.phone_event(&stranger, json!({"v":12,"type":"create-session","agent":"alpha"}), Via::Relay); assert!(rig.take().is_empty()); } @@ -618,7 +618,7 @@ fn an_ack_that_waits_on_a_check_still_goes_to_the_client_that_asked() { } rig.take(); let phone = rig.phone.clone(); - rig.phone_event_via_key(&phone, &asking, json!({"v":12,"type":"set-credentials","agent":"alpha","values":{"alpha_key":"sk"}}), Via::Commands); + rig.phone_event_via_key(&phone, &asking, json!({"v":12,"type":"set-credentials","agent":"alpha","values":{"alpha_key":"sk"}}), Via::Relay); let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::CheckCredential { .. })); rig.take(); rig.host_reply(&id, HostMessage::CredentialChecked { valid: Some(true) }); diff --git a/crates/bridge-core/tests/support/mod.rs b/crates/bridge-core/tests/support/mod.rs index 5ef411b2..0817e32d 100644 --- a/crates/bridge-core/tests/support/mod.rs +++ b/crates/bridge-core/tests/support/mod.rs @@ -221,8 +221,17 @@ impl Rig { self.phone_event_via_key(from, from, msg, via) } - /// An event written by `from` whose payload `key` encrypted. - pub fn phone_event_via_key(&mut self, from: &Keypair, key: &Keypair, mut msg: Value, via: Via) -> InboundEvent { + /// A command written by `from` whose payload `key` encrypted. + pub fn phone_event_via_key(&mut self, from: &Keypair, key: &Keypair, msg: Value, via: Via) -> InboundEvent { + self.event_of_kind(from, key, msg, via, protocol::kinds::COMMAND_KIND) + } + + /// A phone's keystrokes: the same, on the live kind terminal input rides. + pub fn live_event(&mut self, from: &Keypair, key: &Keypair, msg: Value, via: Via) -> InboundEvent { + self.event_of_kind(from, key, msg, via, protocol::kinds::LIVE_KIND) + } + + pub fn event_of_kind(&mut self, from: &Keypair, key: &Keypair, mut msg: Value, via: Via, kind: u16) -> InboundEvent { // Versioned as the phone's codec does; a test that means another // version names its own `v`. if let Some(fields) = msg.as_object_mut() { @@ -236,6 +245,7 @@ impl Rig { pubkey: from.pubkey_hex.clone(), created_at: self.system_now() / 1000, content: encrypt_to(&key.secret_key, &self.bridge.pubkey_hex, &msg.to_string()).unwrap(), + kind, }; self.input(Input::RelayEvent { event: event.clone(), via }); event @@ -244,7 +254,7 @@ impl Rig { /// Send a command from the paired phone. pub fn send(&mut self, msg: Value) { let phone = self.phone.clone(); - self.phone_event(&phone, msg, Via::Commands); + self.phone_event(&phone, msg, Via::Relay); } /// Messages published since the last take, with their recipients. diff --git a/crates/bridge-runtime/src/direct.rs b/crates/bridge-runtime/src/direct.rs index 335cde66..3b4e0c73 100644 --- a/crates/bridge-runtime/src/direct.rs +++ b/crates/bridge-runtime/src/direct.rs @@ -203,8 +203,8 @@ impl Hub { if !event_is_valid(&event) { return Err("invalid: bad signature"); } - let inbound = InboundEvent { id: event.id, pubkey: event.pubkey, created_at: event.created_at, content: event.content }; - let _ = self.inputs.send(Input::RelayEvent { event: inbound, via: Via::Commands }); + let inbound = InboundEvent { id: event.id, pubkey: event.pubkey, created_at: event.created_at, content: event.content, kind: event.kind }; + let _ = self.inputs.send(Input::RelayEvent { event: inbound, via: Via::Direct }); Ok(()) } } @@ -566,7 +566,7 @@ mod tests { let (bridge, phone) = (generate_keypair(), generate_keypair()); let hub = Hub::new(bridge.pubkey_hex.clone(), inputs); assert!(hub.accept(&phone.pubkey_hex, command(&phone, &bridge.pubkey_hex)).is_ok()); - assert!(matches!(rx.try_recv(), Ok(Input::RelayEvent { via: Via::Commands, .. }))); + assert!(matches!(rx.try_recv(), Ok(Input::RelayEvent { via: Via::Direct, .. }))); assert!(hub.accept(&phone.pubkey_hex, command(&generate_keypair(), &bridge.pubkey_hex)).is_err()); assert!(hub.accept(&phone.pubkey_hex, command(&phone, &generate_keypair().pubkey_hex)).is_err()); let mut forged = command(&phone, &bridge.pubkey_hex); diff --git a/crates/bridge-runtime/src/relay.rs b/crates/bridge-runtime/src/relay.rs index 0b3726d4..e71e8794 100644 --- a/crates/bridge-runtime/src/relay.rs +++ b/crates/bridge-runtime/src/relay.rs @@ -505,7 +505,10 @@ impl Relays { fn subscribe(&self, filter: Filter, via: Via) -> Box { let inputs = self.inputs.clone(); let label = match via { - Via::Commands => "command", + // Both command paths log the same way; an onion endpoint is a + // direct link with Tor in front of it, and neither is subscribed + // here. + Via::Relay | Via::Direct | Via::Onion => "command", Via::Pairing => "pairing", }; self.transport.subscribe( @@ -517,6 +520,7 @@ impl Relays { pubkey: event.pubkey.clone(), created_at: u64::try_from(event.created_at).unwrap_or(0), content: event.content.clone(), + kind: event.kind, }; let _ = inputs.send(Input::RelayEvent { event, via }); }), @@ -546,7 +550,7 @@ impl Relays { c_tags: vec![], since: Some(since as i64), }; - self.commands = Some(self.subscribe(filter, Via::Commands)); + self.commands = Some(self.subscribe(filter, Via::Relay)); } /// The pairing window's subscription: no author filter. diff --git a/crates/bridge-runtime/tests/direct.rs b/crates/bridge-runtime/tests/direct.rs index 18a2400d..cd7785df 100644 --- a/crates/bridge-runtime/tests/direct.rs +++ b/crates/bridge-runtime/tests/direct.rs @@ -121,7 +121,7 @@ async fn a_paired_phone_gets_its_events_and_sends_commands_over_the_pinned_link( send(&mut ws, DirectFrame::Event(command.clone())).await; assert_eq!(next_frame(&mut ws).await, DirectFrame::Ok { id: command.id.clone(), accepted: true, message: String::new() }); match engine.recv().await { - Some(Input::RelayEvent { event, via: Via::Commands }) => assert_eq!(event.id, command.id), + Some(Input::RelayEvent { event, via: Via::Direct }) => assert_eq!(event.id, command.id), _ => panic!("expected the command"), } // One the phone did not author is refused. From 88e95965effb796c4d73b3fa2e9f564a2f4d20a5 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:31:13 +0200 Subject: [PATCH 07/50] =?UTF-8?q?feat(bridge-core):=20route=20terminals=20?= =?UTF-8?q?=E2=80=94=20the=20gates,=20the=20writer,=20seq,=20and=20the=20a?= =?UTF-8?q?udit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal is a shell as the bridge's own user, strictly more than an agent in its most permissive mode, so who may have one is decided before convenience. Every terminal message passes three gates in order: the owner has terminals switched on, the way it travelled may carry one (not a relay unless the owner allows that, since even encrypted a relay sees the size and timing of each keystroke), and the device it came from is on the owner's allowlist. The identity key alone is not enough — every client of an identity holds it. What the engine decides it hands to the runtime as effects (spawn, write, resize, replay, hangup) and gets back as inputs (the shell started, output came, the shell ended): the engine runs no shell, holds no file and owns no timer of its own. A device withdrawn from the allowlist is the kill switch for a lost phone, and the terminals it opened end at once; one it had only taken over goes back to the device that opened it, so the owner keeps a terminal it can still use. A `seq` counts up by one within one writer's run — a takeover hands the pen over and tells the new writer where its numbering starts — and a seq already applied types nothing twice, which is what makes a replayed keystroke harmless. Input that arrives ahead of the one before it is held for two seconds, then the phone is asked to resend from the gap. Output goes only to the clients showing that terminal, and every decision leaves a line for the audit the runtime chains and stores: what a terminal did has to be reconstructible afterwards on the machine. The heartbeat lists what is open, and advertises the capability only when the owner has terminals on. Co-Authored-By: Claude Code --- crates/bridge-core/Cargo.toml | 3 + crates/bridge-core/src/engine/mod.rs | 40 +- crates/bridge-core/src/engine/pairing.rs | 20 + crates/bridge-core/src/engine/phone.rs | 26 +- crates/bridge-core/src/engine/session_keys.rs | 40 +- crates/bridge-core/src/engine/terminal.rs | 1146 +++++++++++++++++ crates/bridge-core/src/io.rs | 60 + crates/bridge-core/src/lib.rs | 5 +- crates/bridge-core/src/out.rs | 5 + crates/bridge-core/tests/support/mod.rs | 26 +- crates/bridge-core/tests/terminal.rs | 1033 +++++++++++++++ crates/bridge-runtime/src/runtime.rs | 16 + 12 files changed, 2391 insertions(+), 29 deletions(-) create mode 100644 crates/bridge-core/src/engine/terminal.rs create mode 100644 crates/bridge-core/tests/terminal.rs diff --git a/crates/bridge-core/Cargo.toml b/crates/bridge-core/Cargo.toml index be8dd64d..a7c19f3d 100644 --- a/crates/bridge-core/Cargo.toml +++ b/crates/bridge-core/Cargo.toml @@ -17,6 +17,9 @@ protocol = { path = "../protocol" } agent-protocol = { path = "../agent-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" +# The base64 a phone types into a terminal in, decoded before it reaches a +# pty: the same crate and version the protocol encodes it with. +base64 = "0.22" # Facade only: the runtime installs the backend. log = "0.4" regex = "1" diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 040fe599..45922231 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -28,10 +28,12 @@ mod phone; mod session_keys; pub use session_keys::MAX_CLIENTS; mod settings; +mod terminal; +pub use terminal::{TerminalAllowlist, TerminalAuditRecord, TerminalAuditWhat, TerminalConfig, PendingDevice}; use std::collections::BTreeMap; -use protocol::capabilities::{BridgeHostKind, ALL_BRIDGE_CAPABILITIES}; +use protocol::capabilities::{BridgeHostKind, ALL_BRIDGE_CAPABILITIES, TERMINAL}; use protocol::common::{OutputEntry, RemoteSessionInfo, SessionState}; use protocol::crypto::Keypair; use protocol::events::{BridgeToPhone, OutputMsg, SessionListMsg}; @@ -49,6 +51,7 @@ use crate::sync::{SyncConfig, SyncServer, SyncTimer}; use crate::time::iso; use host::{ConversationDelete, HostCall}; +use terminal::Terminals; pub const DEFAULT_HEARTBEAT_INTERVAL_MS: u64 = 60_000; /// How often a session's `committed` flag is checked against git (catches @@ -84,6 +87,9 @@ pub struct Config { pub card_timeout_ms: u64, pub pairing_window_ms: u64, pub sync: SyncConfig, + /// Whether this machine runs terminals, who may have one, and how many. + /// Off unless the owner turns it on; never switched from a phone. + pub terminal: TerminalConfig, } impl Config { @@ -102,6 +108,7 @@ impl Config { card_timeout_ms: DEFAULT_CARD_TIMEOUT_MS, pairing_window_ms: DEFAULT_PAIRING_WINDOW_MS, sync: SyncConfig::default(), + terminal: TerminalConfig::default(), } } } @@ -162,6 +169,8 @@ pub struct Engine { profile_refusals: BTreeMap, next_ticket: u64, sync: SyncServer, + /// The shells this bridge runs for phones, and who may have one. + terminals: Terminals, pairing: Option, pairing_epoch: u64, nacks: NackBudget, @@ -194,6 +203,7 @@ pub struct Engine { impl Engine { pub fn new(config: Config, ports: Ports) -> Self { let sync = SyncServer::new(config.sync); + let terminals = Terminals::new(config.terminal.clone()); Self { config, system: ports.system, @@ -218,6 +228,7 @@ impl Engine { profile_refusals: BTreeMap::new(), next_ticket: 0, sync, + terminals, pairing: None, pairing_epoch: 0, nacks: NackBudget::default(), @@ -258,6 +269,12 @@ impl Engine { Input::OpenPairing { duration_ms } => self.open_pairing(duration_ms), Input::ClosePairing => self.close_pairing(crate::io::PairingCloseReason::Closed, None), Input::WorkspaceChanged => self.list_dirty = true, + Input::TerminalSpawned { term, shell } => self.on_terminal_spawned(term, shell), + Input::TerminalSpawnFailed { term, note } => self.on_terminal_spawn_failed(term, note), + Input::TerminalOutput { term, offset, data } => self.on_terminal_output(term, offset, data), + Input::TerminalExited { term, code, reason } => self.on_terminal_exited(term, code, reason), + Input::TerminalAllowlist(allowlist) => self.on_terminal_allowlist(allowlist), + Input::Unpaired { identity } => self.unpair(&identity), Input::Shutdown => self.shutdown(), } self.requester = None; @@ -346,6 +363,12 @@ impl Engine { for id in ids { self.close_runner(&id, "Session closed"); } + // The runtime takes the ptys down with it: every shell ends, and each + // one's end is audited. + let identities: Vec = self.paired.iter().map(|p| p.pubkey_hex.clone()).collect(); + for identity in identities { + self.close_identity_terminals(&identity, protocol::common::TerminalEndReason::Restart); + } let timers: Vec<_> = self.out.timers.keys().copied().collect(); for timer in timers { self.out.cancel_timer(timer); @@ -396,6 +419,8 @@ impl Engine { self.close_pairing(crate::io::PairingCloseReason::Expired, None); } } + TimerKind::TerminalGap { term } => self.on_terminal_gap(&term), + TimerKind::TerminalIdle { term } => self.on_terminal_idle(&term), } } @@ -602,19 +627,28 @@ impl Engine { let credentials = self.bridge_credentials(); let removed = self.tombstones.ids(); let rev = self.next_list_rev(); + // The terminal capability is the owner's decision, so unlike the rest + // it is advertised only when this machine runs terminals: a phone that + // sent a `terminal-open` to a bridge without it would wait on a + // terminal that never opens. + let mut capabilities: Vec = ALL_BRIDGE_CAPABILITIES.iter().map(|c| c.to_string()).collect(); + if self.terminals_enabled() { + capabilities.push(TERMINAL.to_string()); + } + let terminals = self.terminal_infos(); let message = BridgeToPhone::Sessions(SessionListMsg { machine: self.config.machine.clone(), host: self.config.host_kind, sessions, agents, credentials, - capabilities: Some(ALL_BRIDGE_CAPABILITIES.iter().map(|c| c.to_string()).collect()), + capabilities: Some(capabilities), folders: Some(self.workspace.folders()), roots: Some(self.workspace.roots()), removed_sessions: (!removed.is_empty()).then_some(removed), machine_offline: offline.then_some(true), direct: self.config.direct.clone(), - terminals: None, + terminals: (!terminals.is_empty()).then_some(terminals), rev: Some(rev), }); match to { diff --git a/crates/bridge-core/src/engine/pairing.rs b/crates/bridge-core/src/engine/pairing.rs index a8ef60dd..d3cd7bd4 100644 --- a/crates/bridge-core/src/engine/pairing.rs +++ b/crates/bridge-core/src/engine/pairing.rs @@ -3,6 +3,7 @@ //! token, and the pair-request that echoes it. use protocol::commands::PairRequestMsg; +use protocol::common::TerminalEndReason; use protocol::crypto::npub_from_hex; use protocol::events::{BridgeToPhone, PairAckMsg, PairAckReason}; @@ -123,6 +124,25 @@ impl Engine { self.out.push(Effect::Notify { level: NotifyLevel::Info, text: format!("Phone \"{label}\" paired") }); self.out.push(Effect::RegisterPhone { pubkey_hex: from.to_string(), label }); } + + /// The owner unpaired an identity on the machine. Everything it could + /// send stops being heard, and the shells its devices hold end: a + /// machine's terminals are not left running for a phone that is no longer + /// paired. + pub(super) fn unpair(&mut self, identity: &str) { + let before = self.paired.len(); + self.paired.retain(|p| p.pubkey_hex != identity); + if self.paired.len() == before { + return; + } + log::info!("[Engine] Unpaired {}...", identity.get(..8).unwrap_or(identity)); + self.close_identity_terminals(identity, TerminalEndReason::Killed); + self.store_paired(); + // Nothing from that author is heard any more, and every other phone's + // list changed. + self.out.push(Effect::Resubscribe); + self.list_dirty = true; + } } #[cfg(test)] diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index ed5821bb..6bca16dc 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -18,7 +18,6 @@ use crate::catalog::{is_effort, is_mode}; use crate::io::{Addressee, Effect, InboundEvent, Via}; use crate::registry::{project_of, SessionRecord}; use crate::session::{option_answer, permission_summary, CardKind, Phase, Runner, Session, StartKind}; - /// The wire name of a command, for logs. (Commands can carry secrets, so /// they are never logged whole.) fn type_name(msg: &PhoneToBridge) -> &'static str { @@ -93,8 +92,8 @@ impl Engine { log::info!("[Engine] Received {} from {}...", type_name(&msg), short(&event.pubkey)); let client = Addressee { phone: event.pubkey.clone(), key: key.clone() }; // Any command says its client is running; a `presence` also - // says whether it still wants the shared updates and which - // session it watches. + // says whether it still wants the shared updates, which + // session it watches and which terminal it shows. if self.note_presence(&event.pubkey, &key, &msg) { self.want_list(client.clone()); } @@ -104,14 +103,14 @@ impl Engine { self.grant_session_key(&event.pubkey, m.session_key, &key); } PhoneToBridge::Presence(_) => {} - msg => self.dispatch(msg, &event.pubkey), + msg => self.dispatch(msg, &event.pubkey, via), } self.requester = None; } } } - fn dispatch(&mut self, msg: PhoneToBridge, phone: &str) { + fn dispatch(&mut self, msg: PhoneToBridge, phone: &str, via: Via) { match msg { PhoneToBridge::Input(m) => self.on_input(m), PhoneToBridge::PermissionResponse(m) => self.on_permission_response(m), @@ -171,15 +170,14 @@ impl Engine { let msg = self.provider_profiles_msg(); self.publish_to(phone, msg); } - // The engine runs no terminals: the bridge advertises the - // `terminal` capability only once one exists here, and until then - // a phone has no reason to send these. - PhoneToBridge::TerminalOpen(_) - | PhoneToBridge::TerminalInput(_) - | PhoneToBridge::TerminalResize(_) - | PhoneToBridge::TerminalReplay(_) - | PhoneToBridge::TerminalTakeover(_) - | PhoneToBridge::TerminalClose(_) => {} + // Terminals: the engine decides, the runtime runs their shells + // (see the `terminal` module). + PhoneToBridge::TerminalOpen(m) => self.on_terminal_open(m, super::terminal::via_of(via)), + PhoneToBridge::TerminalInput(m) => self.on_terminal_input(m, super::terminal::via_of(via)), + PhoneToBridge::TerminalResize(m) => self.on_terminal_resize(m, super::terminal::via_of(via)), + PhoneToBridge::TerminalReplay(m) => self.on_terminal_replay(m, super::terminal::via_of(via)), + PhoneToBridge::TerminalTakeover(m) => self.on_terminal_takeover(m, super::terminal::via_of(via)), + PhoneToBridge::TerminalClose(m) => self.on_terminal_close(m, super::terminal::via_of(via)), } } diff --git a/crates/bridge-core/src/engine/session_keys.rs b/crates/bridge-core/src/engine/session_keys.rs index b6446b4c..07a355b6 100644 --- a/crates/bridge-core/src/engine/session_keys.rs +++ b/crates/bridge-core/src/engine/session_keys.rs @@ -352,6 +352,19 @@ impl Engine { /// with no client at all is unchanged — its one copy is the identity's /// own, and nothing on that side renders live text. fn copy_keys_watching(&self, identity: &str, now_ms: u64, session: &str) -> Vec { + self.copy_keys_where(identity, now_ms, |c| c.watch.wants(session)) + } + + /// [`Self::copy_keys`], for the output of `term`: only the clients that + /// show that terminal get a copy — a terminal nobody shows costs nothing + /// to run out to. + fn copy_keys_watching_terminal(&self, identity: &str, now_ms: u64, term: &str) -> Vec { + self.copy_keys_where(identity, now_ms, |c| c.watch_terminal.shows(term)) + } + + /// The keys a message goes to: [`Self::copy_keys`], narrowed to the + /// clients a live stream is for. + fn copy_keys_where(&self, identity: &str, now_ms: u64, wanted: impl Fn(&PhoneClient) -> bool) -> Vec { let now_secs = now_ms / 1000; let Some(phone) = self.paired.iter().find(|p| p.pubkey_hex == identity) else { return vec![identity.to_string()] }; let live: Vec<&PhoneClient> = phone.clients.iter().filter(|c| c.key.expires_at > now_secs).collect(); @@ -360,29 +373,32 @@ impl Engine { } live.into_iter() .filter(|c| c.present_until_ms > now_ms) - .filter(|c| c.watch.wants(session)) + .filter(|c| wanted(c)) .map(|c| c.key.pubkey_hex.clone()) .collect() } /// Turn every publish in `effects` to an identity into a copy for each /// of its present clients; one already for a client stays as it is. - /// Live text is narrowed further, to the clients watching that session. + /// Live text is narrowed further, to the clients watching that session, + /// and a terminal's output to the clients showing that terminal. pub(super) fn address_publishes(&self, effects: &mut [Effect]) { let now = self.now(); for effect in effects { if let Effect::Publish { to, message } = effect { - let live_session = match message { - BridgeToPhone::Partial(m) => Some(m.session_id.clone()), - _ => None, + let stream = match message { + BridgeToPhone::Partial(m) => Stream::Session(m.session_id.clone()), + BridgeToPhone::TerminalOutput(m) => Stream::Terminal(m.term.clone()), + _ => Stream::Everyone, }; *to = to .iter() .flat_map(|a| { if a.key.is_empty() { - let keys = match &live_session { - Some(session) => self.copy_keys_watching(&a.phone, now, session), - None => self.copy_keys(&a.phone, now), + let keys = match &stream { + Stream::Session(session) => self.copy_keys_watching(&a.phone, now, session), + Stream::Terminal(term) => self.copy_keys_watching_terminal(&a.phone, now, term), + Stream::Everyone => self.copy_keys(&a.phone, now), }; keys.into_iter().map(|key| Addressee { phone: a.phone.clone(), key }).collect() } else { @@ -394,3 +410,11 @@ impl Engine { } } } + +/// What a publish to an identity is narrowed to: one session's live text, one +/// terminal's output, or every present client. +enum Stream { + Everyone, + Session(String), + Terminal(String), +} diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs new file mode 100644 index 00000000..7193729a --- /dev/null +++ b/crates/bridge-core/src/engine/terminal.rs @@ -0,0 +1,1146 @@ +//! Terminals: real shells on the bridge's machine, driven from a phone. +//! +//! A terminal is a shell as the bridge's own user — strictly more than an +//! agent in its most permissive mode — so who may have one is settled before +//! convenience. Three gates, checked in this order for every terminal +//! message: the bridge's owner has terminals switched on; the way the command +//! travelled may carry one (not a relay, unless the owner allows that); the +//! device it came from is on the owner's allowlist. The identity key alone is +//! not enough: every client of an identity holds that key, so a stolen one +//! would otherwise open a shell from a phone nobody approved, which is why a +//! device is named by the first session key it granted (a name that survives +//! rotation) and approved on the machine. +//! +//! The engine decides and records; it runs no shell, holds no file and owns +//! no timer of its own. A spawn, a write, a resize, a replay and a hangup are +//! [`Effect`]s the runtime carries out, and what the runtime learns — the +//! shell started, output came, the shell ended — comes back as +//! [`Input`](crate::io::Input)s. Every decision leaves a +//! [`TerminalAuditRecord`], which the runtime chains and stores: what a +//! terminal did has to be reconstructible afterwards on the machine. +//! +//! Invariants the rest of the engine relies on: +//! - a terminal belongs to the identity that opened it: only that identity's +//! clients may act on it, and everything told about it goes to them; +//! - one writer at a time, moved only by an explicit `terminal-takeover` +//! (audited, and told to every present client) — a message naming a +//! terminal that is gone, or is another identity's, is answered as one that +//! is over, so no client can probe for terminals that are not its own; +//! - `seq` counts up by one within one writer's run: a takeover hands the pen +//! over with the number the new writer starts at, and a seq already applied +//! types nothing twice, which is what makes a replayed keystroke harmless; +//! - output goes only to the clients showing that terminal (`presence` +//! `watching_terminal`), which is also what keeps it from sitting idle. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use protocol::commands::{TerminalIdMsg, TerminalInputMsg, TerminalOpenMsg, TerminalReplayMsg, TerminalResizeMsg}; +use protocol::common::{TerminalEndReason, TerminalEventKind, TerminalVia}; +use protocol::events::{ + BridgeToPhone, TerminalEventMsg, TerminalExitedMsg, TerminalInfo, TerminalInputFromMsg, TerminalOpenedMsg, + TerminalOutputMsg, +}; +use serde::{Deserialize, Serialize}; + +use super::Engine; +use crate::io::{Addressee, Effect, TimerId, Via, WatchState}; +use crate::out::{Out, TimerKind}; + +/// The most terminals one bridge runs at once: a fifth `terminal-open` is +/// answered `terminal-exited {reason: limit}`. +pub const DEFAULT_MAX_TERMINALS: usize = 4; +/// How long a terminal with no client attached to it lives. A phone remote is +/// used from trains and tunnels, so it outlives a dropped connection. +pub const DEFAULT_IDLE_SECS: u64 = 60 * 60; +/// How long input that arrived ahead of the one before it is held waiting for +/// the missing one: past this the phone is asked to resend from the gap. +pub const INPUT_GAP_WAIT_MS: u64 = 2_000; +/// The most bytes one `terminal-input` may carry, decoded. A paste is one +/// event and a typed line is a few, so nothing typed comes near it: a larger +/// one is a payload, not keystrokes. The encryption already bounds a single +/// event well below this, so it is a backstop against a future way in (a +/// chunked or unencrypted path) rather than a limit typing can meet. +pub const MAX_INPUT_BYTES: usize = 64 * 1024; +/// How many devices are kept waiting for the owner's approval. The least +/// recently seen beyond this are dropped: a phone that keeps trying is not a +/// reason for the list to grow without end. +pub const MAX_PENDING_DEVICES: usize = 64; + +/// What the bridge's owner decided about terminals, as the runtime reads it +/// out of the configuration. The engine reads it and adds nothing: terminals +/// are the owner's decision and are never switched on from a phone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalConfig { + /// Terminals are switched on. Off, the capability is not advertised, and + /// every terminal message is refused. + pub enabled: bool, + /// A terminal may be opened over a relay as well as over the bridge's own + /// link. Off — the default — a relay is refused: even encrypted, a relay + /// sees each event's size and timing, and keystroke timing alone leaks + /// what was typed. + pub relays_allowed: bool, + /// The most terminals one bridge runs at once. + pub max_terminals: usize, + /// How long a terminal with no client attached lives. 0 keeps an + /// unattended shell alive indefinitely. + pub idle_secs: u64, + /// Who may have a terminal here, and who asked and was refused. + pub allowlist: TerminalAllowlist, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + enabled: false, + relays_allowed: false, + max_terminals: DEFAULT_MAX_TERMINALS, + idle_secs: DEFAULT_IDLE_SECS, + allowlist: TerminalAllowlist::default(), + } + } +} + +/// The devices approved on this machine, and the ones that asked and were +/// refused. The runtime owns the file and its permissions; the engine is +/// handed this on start and again whenever the owner changes it, and records +/// a refusal by telling the runtime to write it down. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalAllowlist { + /// The device ids the owner approved (`codedeck-bridge terminal allow`). + /// Withdrawing one closes its terminals at once: the kill switch for a + /// lost phone. + #[serde(default)] + pub devices: BTreeSet, + /// The devices refused a terminal, oldest last seen first. + #[serde(default)] + pub pending: Vec, +} + +/// A device that asked for a terminal and was refused: what the owner reads +/// to decide whether to approve it. The label is the device's own, which a +/// person reads but the bridge never trusts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingDevice { + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// When it was last refused, ms since the Unix epoch. + pub last_seen_ms: u64, +} + +/// One line for the terminal audit: what happened, to which terminal, by +/// which device, and how that device's command reached the bridge. The engine +/// holds no file: the runtime chains the line to the one before it and +/// stores it under the bridge's home. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalAuditRecord { + /// When it happened, ms since the Unix epoch. + pub at_ms: u64, + /// The terminal's id, or the phone's own open id for an open that never + /// became a terminal. + pub term: String, + pub what: TerminalAuditWhat, + /// The identity the terminal belongs to, as its npub. + pub identity: String, + /// The device that acted, when one did. + pub device: Option, + /// That device's own name for itself, as its latest grant said. + pub label: Option, + /// How its command arrived, when a command is what did it. + pub via: Option, + /// Where the shell started, for an open. + pub cwd: Option, + /// What ran in it, once the runtime said. + pub shell: Option, + pub cols: Option, + pub rows: Option, + /// Why it ended, and what the shell returned. + pub reason: Option, + pub code: Option, + /// How long it lived, for an end. + pub duration_ms: Option, + /// What a refusal refused (input that is not base64, input of a size no + /// typing has, a seq that never arrived): the reason alone would not say. + pub why: Option, +} + +/// What a terminal audit line records. `Refused` is the only one about +/// something that did not happen, and carries `why`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalAuditWhat { + /// A shell started. + Opened, + /// A client attached to a terminal's output without becoming its writer. + Attached, + /// A client became the writer. + Takeover, + /// The writer's window changed. + Resized, + /// A client hung up. + Closed, + /// The shell ended, or the bridge ended it. + Exited, + /// A terminal message was refused. + Refused, +} + +impl TerminalAuditRecord { + /// A line about `term`, belonging to the identity that signs as `identity` + /// (its hex key, which is what everything else in the engine holds). + fn new(now_ms: u64, term: String, what: TerminalAuditWhat, identity: String) -> Self { + Self { + at_ms: now_ms, + term, + what, + identity: npub_of(&identity), + device: None, + label: None, + via: None, + cwd: None, + shell: None, + cols: None, + rows: None, + reason: None, + code: None, + duration_ms: None, + why: None, + } + } +} + +/// An identity as a person reads it: the npub of the hex key that signs every +/// command. A key that cannot be one is kept as it came, so the line still +/// says what happened rather than nothing. +fn npub_of(identity: &str) -> String { + protocol::crypto::npub_from_hex(identity).unwrap_or_else(|_| identity.to_string()) +} + +/// How a terminal command arrived, as the audit and `terminal-event` name it. +pub(crate) fn via_of(via: Via) -> TerminalVia { + match via { + Via::Relay => TerminalVia::Relay, + Via::Direct => TerminalVia::Direct, + Via::Onion => TerminalVia::Onion, + // The pairing window's subscription carries pair-requests and nothing + // else, so no terminal message can have come that way. + Via::Pairing => TerminalVia::Unknown, + } +} + +/// One terminal the bridge runs. +struct Terminal { + /// The identity that opened it: only its clients may act on it, and + /// everything told about it goes to them. + identity: String, + /// What runs in it, as the runtime reported it. + shell: String, + /// The device that opened it. Its label rides the heartbeat and the + /// events; the id is what the allowlist and the audit trust. + opener: String, + /// The device whose input it takes now. + writer: String, + cols: u16, + rows: u16, + cwd: String, + opened_ms: u64, + /// How the opener's command arrived, for later audit lines about it. + via: TerminalVia, + /// The last `seq` applied: input at or below it types nothing twice. + last_seq: u64, + /// Input that arrived ahead of the one before it, by seq: held, not + /// typed, until the gap fills or the wait runs out. + pending: BTreeMap>, + /// The timer asking the writer to resend from a gap that did not fill. + gap_timer: Option, + /// The timer that closes it once no client is attached any more. + idle_timer: Option, +} + +/// A `terminal-open` the runtime has been asked to start and has yet to +/// answer. It counts against the limit (the shell is coming) and is listed +/// nowhere until it runs. +struct PendingOpen { + identity: String, + /// The client that asked, alone: `terminal-opened` is its answer. + asker: Addressee, + device: Option, + label: Option, + via: TerminalVia, + cwd: String, + cols: u16, + rows: u16, + /// The phone's own id for the request, echoed back in the answer. + open_id: String, +} + +/// Whether a terminal is there and whose it is. +enum Held { + Mine, + Other, + Missing, +} + +/// Why a terminal message was refused, in the order the gates are checked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Refusal { + /// The bridge's owner has terminals switched off. + Disabled, + /// The way the command travelled may not carry a terminal. + Transport, + /// The device it came from is not on the owner's allowlist — or the + /// command named no device at all (it was encrypted with the identity's + /// own key, which every client of the identity shares). + NotAllowed, + /// The bridge's terminals are full. + Limit, +} + +impl Refusal { + /// The reason the phone is given, and the audit records. + fn reason(self) -> TerminalEndReason { + match self { + Refusal::Disabled => TerminalEndReason::Disabled, + Refusal::Transport => TerminalEndReason::Transport, + Refusal::NotAllowed => TerminalEndReason::NotAllowed, + Refusal::Limit => TerminalEndReason::Limit, + } + } + + /// What a person reading the audit needs beyond the reason's name. + fn note(self) -> &'static str { + match self { + Refusal::Disabled => "the bridge has terminals switched off", + Refusal::Transport => "a terminal may not be opened over this way", + Refusal::NotAllowed => "the device is not on the allowlist", + Refusal::Limit => "the bridge's terminals are full", + } + } +} + +/// The client whose command is being handled: who it is, and where to answer +/// it. A command encrypted with the identity's own key names no client, and so +/// no device — it can never pass the device gate. +struct Actor { + identity: String, + key: String, + device: Option, + label: Option, + via: TerminalVia, +} + +impl Actor { + fn addressee(&self) -> Addressee { + Addressee { phone: self.identity.clone(), key: self.key.clone() } + } + + /// The part of an audit line that says who acted and how it arrived. + fn fill(&self, rec: &mut TerminalAuditRecord) { + rec.device = self.device.clone(); + rec.label = self.label.clone(); + rec.via = Some(self.via); + } +} + +/// Everything the engine knows about the bridge's terminals. +pub(crate) struct Terminals { + config: TerminalConfig, + /// The terminals running now, by the id the engine gave each. + open: BTreeMap, + /// Opens the runtime has yet to answer, by terminal id. + spawning: BTreeMap, +} + +impl Terminals { + pub(crate) fn new(config: TerminalConfig) -> Self { + Self { config, open: BTreeMap::new(), spawning: BTreeMap::new() } + } + + fn enabled(&self) -> bool { + self.config.enabled + } + + /// Whether another terminal would be one too many, counting the opens the + /// runtime is still starting. + fn at_capacity(&self) -> bool { + self.open.len() + self.spawning.len() >= self.config.max_terminals + } + + fn held(&self, term: &str, identity: &str) -> Held { + match self.open.get(term) { + Some(t) if t.identity == identity => Held::Mine, + Some(_) => Held::Other, + None => Held::Missing, + } + } + + /// Type `bytes` as `seq`, the one after the last applied. + fn apply(&mut self, term: &str, seq: u64, bytes: Vec, out: &mut Out) { + let Some(t) = self.open.get_mut(term) else { return }; + t.last_seq = seq; + out.push(Effect::TerminalWrite { term: term.to_string(), bytes }); + } + + /// Take input that arrived ahead of the one before it and hold it: the + /// missing seq may still be in flight (a relay reordering two events, a + /// phone's event arriving late), and typing the later one first would put + /// its characters in the wrong order. The first hold arms the wait. + fn hold(&mut self, term: &str, seq: u64, bytes: Vec, out: &mut Out) { + let Some(t) = self.open.get_mut(term) else { return }; + t.pending.insert(seq, bytes); + if t.gap_timer.is_none() { + let id = out.set_timer(INPUT_GAP_WAIT_MS, TimerKind::TerminalGap { term: term.to_string() }); + t.gap_timer = Some(id); + } + } + + /// Type everything held behind the gap that just filled, in order, up to + /// the first one still missing. + fn flush_pending(&mut self, term: &str, out: &mut Out) { + loop { + let next = match self.open.get(term) { + Some(t) if !t.pending.is_empty() => t.last_seq + 1, + _ => return, + }; + let Some(t) = self.open.get_mut(term) else { return }; + let Some(bytes) = t.pending.remove(&next) else { break }; + t.last_seq = next; + out.push(Effect::TerminalWrite { term: term.to_string(), bytes }); + } + let Some(t) = self.open.get_mut(term) else { return }; + if let Some(id) = t.gap_timer.take() { + out.cancel_timer(id); + } + // A later gap is still open: it gets a wait of its own from here. + if !t.pending.is_empty() { + let id = out.set_timer(INPUT_GAP_WAIT_MS, TimerKind::TerminalGap { term: term.to_string() }); + t.gap_timer = Some(id); + } + } +} + +impl Engine { + /// Whether the heartbeat advertises the `terminal` capability. Only then + /// does a phone show a terminal: one it sent to a bridge without it would + /// wait on a terminal that never opens. + pub(crate) fn terminals_enabled(&self) -> bool { + self.terminals.enabled() + } + + /// The terminals open now, as the heartbeat lists them, so a phone that + /// comes back (or a second phone) sees what is running and can attach. + pub(crate) fn terminal_infos(&self) -> Vec { + self.terminals + .open + .iter() + .map(|(id, t)| TerminalInfo { + term: id.clone(), + shell: t.shell.clone(), + opened: t.opened_ms, + device: t.opener.clone(), + name: self.terminal_label(&t.identity, &t.opener), + writer: t.writer.clone(), + cols: t.cols, + rows: t.rows, + }) + .collect() + } + + /// The client whose command is being handled. None only when no command is + /// being handled at all — a bug, since a terminal message reaches here from + /// one. A command encrypted with the identity's own key names no client, + /// and so no device, which is what keeps a stolen identity key out. + fn terminal_actor(&self, via: TerminalVia) -> Option { + let requester = self.requester.clone()?; + let phone = self.paired.iter().find(|p| p.pubkey_hex == requester.phone)?; + let client = phone.clients.iter().find(|c| { + c.key.pubkey_hex == requester.key || c.previous.as_ref().is_some_and(|k| k.pubkey_hex == requester.key) + }); + Some(Actor { + // Only a state file written before devices were stored can leave + // this empty, and an empty device is on no allowlist. + device: client.map(|c| c.device.clone()).filter(|d| !d.is_empty()), + label: client.and_then(|c| c.label.clone()), + identity: requester.phone, + key: requester.key, + via, + }) + } + + /// The first gate `actor` fails, if any: terminals switched off, a way + /// that may not carry one, a device the owner has not approved. The limit + /// is checked by `terminal-open` alone — an open terminal is not affected + /// by it. + fn terminal_refusal(&self, actor: &Actor) -> Option { + if !self.terminals.enabled() { + return Some(Refusal::Disabled); + } + if actor.via == TerminalVia::Relay && !self.terminals.config.relays_allowed { + return Some(Refusal::Transport); + } + match &actor.device { + Some(device) if self.terminals.config.allowlist.devices.contains(device) => None, + _ => Some(Refusal::NotAllowed), + } + } + + /// Record one line for the terminal audit; the runtime chains and stores + /// it. + fn audit(&mut self, rec: TerminalAuditRecord) { + self.out.push(Effect::Audit(rec)); + } + + /// The name a device gave itself, from the latest grant of the client + /// that device is. + fn terminal_label(&self, identity: &str, device: &str) -> Option { + self.paired + .iter() + .find(|p| p.pubkey_hex == identity)? + .clients + .iter() + .find(|c| c.device == device)? + .label + .clone() + } + + /// The key to encrypt a message for the client that device is, or None + /// once that client is gone. + fn terminal_key_of(&self, identity: &str, device: &str) -> Option { + self.paired + .iter() + .find(|p| p.pubkey_hex == identity)? + .clients + .iter() + .find(|c| c.device == device) + .map(|c| c.key.pubkey_hex.clone()) + } + + /// Tell every present client of `identity` what happened to a terminal: + /// who acted, under what name, and how its command arrived. A phone that + /// did not act learns at once — the detection half of the allowlist. + fn terminal_event(&mut self, term: &str, what: TerminalEventKind, device: &str, identity: &str, via: TerminalVia) { + let at = self.now(); + let label = self.terminal_label(identity, device); + self.out.publish( + vec![identity.to_string()], + BridgeToPhone::TerminalEvent(TerminalEventMsg { + term: term.to_string(), + what, + device: device.to_string(), + name: label, + via, + at, + }), + ); + } + + /// A `terminal-open` refused: the phone gets its own open id back, so it + /// can show what the request that earned it was. + fn refuse_open(&mut self, open_id: &str, actor: &Actor, why: Refusal, note: Option) { + log::info!("[Engine] terminal-open refused: {why:?}"); + self.out.publish_addressed( + vec![actor.addressee()], + BridgeToPhone::TerminalExited(TerminalExitedMsg { + term: None, + open_id: Some(open_id.to_string()), + code: None, + reason: why.reason(), + device: matches!(why, Refusal::NotAllowed).then(|| actor.device.clone()).flatten(), + }), + ); + let mut rec = TerminalAuditRecord::new(self.now(), open_id.to_string(), TerminalAuditWhat::Refused, actor.identity.clone()); + actor.fill(&mut rec); + rec.reason = Some(why.reason()); + rec.why = note; + self.audit(rec); + if why == Refusal::NotAllowed { + self.note_pending(actor); + } + } + + /// A message about an open terminal refused at a gate: the phone is told + /// the terminal is not usable by it, with the gate's own reason. + fn refuse_term(&mut self, term: &str, actor: &Actor, why: Refusal) { + log::info!("[Engine] a terminal message was refused: {}", why.note()); + self.out.publish_addressed( + vec![actor.addressee()], + BridgeToPhone::TerminalExited(TerminalExitedMsg { + term: Some(term.to_string()), + open_id: None, + code: None, + reason: why.reason(), + device: matches!(why, Refusal::NotAllowed).then(|| actor.device.clone()).flatten(), + }), + ); + let mut rec = TerminalAuditRecord::new(self.now(), term.to_string(), TerminalAuditWhat::Refused, actor.identity.clone()); + actor.fill(&mut rec); + rec.reason = Some(why.reason()); + rec.why = Some(why.note().to_string()); + self.audit(rec); + if why == Refusal::NotAllowed { + self.note_pending(actor); + } + } + + /// A message naming a terminal this client may not act on: gone, or + /// another identity's. Both are answered the same way, so a client has no + /// way to probe for terminals that are not its own. + fn refuse_missing(&mut self, term: &str, actor: &Actor, why: &str) { + log::info!("[Engine] a terminal message was refused: {why}"); + self.out.publish_addressed( + vec![actor.addressee()], + BridgeToPhone::TerminalExited(TerminalExitedMsg { + term: Some(term.to_string()), + open_id: None, + code: None, + // It is over, whoever asked: the reason that says so without + // claiming how it ended. + reason: TerminalEndReason::Closed, + device: None, + }), + ); + let mut rec = TerminalAuditRecord::new(self.now(), term.to_string(), TerminalAuditWhat::Refused, actor.identity.clone()); + actor.fill(&mut rec); + rec.why = Some(why.to_string()); + self.audit(rec); + } + + /// Whether `term` is a terminal of `actor`'s identity, answering the + /// actor when it is not. + fn terminal_is_mine(&mut self, term: &str, actor: &Actor) -> bool { + match self.terminals.held(term, &actor.identity) { + Held::Mine => true, + Held::Other => { + self.refuse_missing(term, actor, "it is another identity's terminal"); + false + } + Held::Missing => { + self.refuse_missing(term, actor, "the bridge has no such terminal"); + false + } + } + } + + /// A device that is not on the allowlist asked: remember it, with what + /// the owner needs to approve it, and tell the runtime to write it down. + fn note_pending(&mut self, actor: &Actor) { + let Some(device) = actor.device.clone() else { return }; + let now = self.now(); + let pending = &mut self.terminals.config.allowlist.pending; + pending.retain(|p| p.device != device); + pending.push(PendingDevice { device, label: actor.label.clone(), last_seen_ms: now }); + // Oldest last seen first, so the owner reads the fresh ones last and + // a device that keeps trying cannot push them off the end. + pending.sort_by_key(|p| p.last_seen_ms); + if pending.len() > MAX_PENDING_DEVICES { + let drop = pending.len() - MAX_PENDING_DEVICES; + pending.drain(..drop); + } + let pending = pending.clone(); + self.out.push(Effect::TerminalPending(pending)); + } + + /// `terminal-open`: the gates, then a shell for the runtime to start. The + /// answer (`terminal-opened`) comes back as an input once it runs. + pub(super) fn on_terminal_open(&mut self, m: TerminalOpenMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_open(&m.open_id, &actor, why, None); + } + if self.terminals.at_capacity() { + return self.refuse_open(&m.open_id, &actor, Refusal::Limit, None); + } + // A terminal starts in the workspace, or in the directory of the + // session the phone named — never in a path it sent. + let named = m.session.as_deref().and_then(|id| self.sessions.get(id)).map(|s| s.rec.cwd.clone()); + let cwd = match named { + Some(cwd) => cwd, + None => { + if m.session.is_some() { + log::info!("[Engine] terminal-open named a session this bridge does not have — opening in the workspace root"); + } + self.workspace.resolve_cwd(None, false) + } + }; + let term = self.system.new_id(); + log::info!("[Engine] Starting terminal {term} for {}", actor.identity.get(..8).unwrap_or(&actor.identity)); + self.terminals.spawning.insert( + term.clone(), + PendingOpen { + identity: actor.identity.clone(), + asker: actor.addressee(), + device: actor.device.clone(), + label: actor.label.clone(), + via, + cwd: cwd.clone(), + cols: m.cols, + rows: m.rows, + open_id: m.open_id, + }, + ); + self.out.push(Effect::TerminalSpawn { term, cwd, cols: m.cols, rows: m.rows }); + } + + /// The runtime started a terminal's shell: the answer to its open, and + /// the news to every present client of the identity. + pub(super) fn on_terminal_spawned(&mut self, term: String, shell: String) { + let Some(open) = self.terminals.spawning.remove(&term) else { return }; + let now = self.now(); + self.out.publish_addressed( + vec![open.asker], + BridgeToPhone::TerminalOpened(TerminalOpenedMsg { open_id: open.open_id, term: term.clone(), shell: shell.clone() }), + ); + let idle_ms = self.terminals.config.idle_secs * 1000; + let idle_timer = (idle_ms > 0).then(|| self.out.set_timer(idle_ms, TimerKind::TerminalIdle { term: term.clone() })); + let opener = open.device.clone().unwrap_or_default(); + self.terminals.open.insert( + term.clone(), + Terminal { + identity: open.identity.clone(), + shell: shell.clone(), + writer: opener.clone(), + opener, + cols: open.cols, + rows: open.rows, + cwd: open.cwd.clone(), + opened_ms: now, + via: open.via, + last_seq: 0, + pending: BTreeMap::new(), + gap_timer: None, + idle_timer, + }, + ); + self.list_dirty = true; + if let Some(device) = &open.device { + self.terminal_event(&term, TerminalEventKind::Opened, device, &open.identity, open.via); + } + let mut rec = TerminalAuditRecord::new(now, term, TerminalAuditWhat::Opened, open.identity.clone()); + rec.device = open.device; + rec.label = open.label; + rec.via = Some(open.via); + rec.cwd = Some(open.cwd); + rec.shell = Some(shell); + rec.cols = Some(open.cols); + rec.rows = Some(open.rows); + self.audit(rec); + } + + /// The runtime could not start the shell. The open never became a + /// terminal, so the phone is answered the way a refused one is — its own + /// open id and the reason — rather than left waiting on a terminal that + /// will never appear. + pub(super) fn on_terminal_spawn_failed(&mut self, term: String, note: String) { + let Some(open) = self.terminals.spawning.remove(&term) else { return }; + log::warn!("[Engine] terminal {term} could not be started: {note}"); + self.out.publish_addressed( + vec![open.asker], + BridgeToPhone::TerminalExited(TerminalExitedMsg { + term: None, + open_id: Some(open.open_id.clone()), + code: None, + reason: TerminalEndReason::Transport, + device: None, + }), + ); + let mut rec = TerminalAuditRecord::new(self.now(), open.open_id, TerminalAuditWhat::Refused, open.identity.clone()); + rec.device = open.device; + rec.label = open.label; + rec.via = Some(open.via); + rec.cwd = Some(open.cwd); + rec.reason = Some(TerminalEndReason::Transport); + rec.why = Some(note); + self.audit(rec); + } + + /// Output a terminal wrote. It goes only to the clients showing that + /// terminal, exactly as live session text goes only to those watching the + /// session: the runtime coalesces it, and keeps the ring a client that + /// asked for a replay reads from. + pub(super) fn on_terminal_output(&mut self, term: String, offset: u64, data: String) { + let Some(identity) = self.terminals.open.get(&term).map(|t| t.identity.clone()) else { return }; + self.out.publish(vec![identity], BridgeToPhone::TerminalOutput(TerminalOutputMsg { term, offset, data })); + } + + /// The shell is gone. Whoever ended it — the shell itself, its pipe, the + /// bridge going down — every present client of the identity is told, and + /// the end is audited. + pub(super) fn on_terminal_exited(&mut self, term: String, code: Option, reason: TerminalEndReason) { + self.end_terminal(&term, reason, code, None); + } + + /// `terminal-input`: the writer's keystrokes, in the order it sent them. + pub(super) fn on_terminal_input(&mut self, m: TerminalInputMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_term(&m.term, &actor, why); + } + if !self.terminal_is_mine(&m.term, &actor) { + return; + } + // Only one device types into a terminal; another device's input is + // not a mistake to answer (it may be a phone that has not noticed a + // takeover yet), so it does nothing. + let Some(t) = self.terminals.open.get(&m.term) else { return }; + if actor.device.as_deref() != Some(t.writer.as_str()) { + log::info!("[Engine] terminal-input for {} from a device that is not its writer — dropped", m.term); + return; + } + // A seq already applied types nothing twice: a signed keystroke event + // a relay replays must not re-type, and a phone resending what it was + // asked for overlaps what did arrive. + if m.seq <= t.last_seq { + return; + } + let bytes = match STANDARD.decode(&m.data) { + Ok(bytes) if bytes.len() <= MAX_INPUT_BYTES => bytes, + Ok(bytes) => return self.refuse_input(&m.term, &actor, m.seq, &format!("input of {} bytes", bytes.len())), + Err(_) => return self.refuse_input(&m.term, &actor, m.seq, "input that is not base64"), + }; + let expected = self.terminals.open.get(&m.term).map(|t| t.last_seq + 1).unwrap_or(0); + if m.seq == expected { + self.terminals.apply(&m.term, m.seq, bytes, &mut self.out); + self.terminals.flush_pending(&m.term, &mut self.out); + } else { + // Ahead of the one before it: held for the wait, then the phone is + // asked for what is missing. + self.terminals.hold(&m.term, m.seq, bytes, &mut self.out); + } + } + + /// Input the engine will not type: too large to be typing, or not the + /// base64 the protocol carries it in. + fn refuse_input(&mut self, term: &str, actor: &Actor, seq: u64, why: &str) { + log::info!("[Engine] terminal-input for {term} at seq {seq} refused: {why}"); + let mut rec = TerminalAuditRecord::new(self.now(), term.to_string(), TerminalAuditWhat::Refused, actor.identity.clone()); + actor.fill(&mut rec); + rec.why = Some(format!("seq {seq}: {why}")); + self.audit(rec); + } + + /// The wait for a missing seq is over: the writer is asked to resend from + /// the gap, and what was held behind it is dropped — the phone resends + /// everything from the gap, which covers every held seq. + pub(super) fn on_terminal_gap(&mut self, term: &str) { + let Some(t) = self.terminals.open.get_mut(term) else { return }; + t.gap_timer = None; + if t.pending.is_empty() { + return; + } + t.pending.clear(); + let seq = t.last_seq + 1; + let (identity, writer) = (t.identity.clone(), t.writer.clone()); + log::info!("[Engine] terminal {term} is missing input from seq {seq}; asking its writer to resend"); + if let Some(key) = self.terminal_key_of(&identity, &writer) { + self.out.publish_addressed( + vec![Addressee { phone: identity.clone(), key }], + BridgeToPhone::TerminalInputFrom(TerminalInputFromMsg { term: term.to_string(), seq }), + ); + } + let label = self.terminal_label(&identity, &writer); + let mut rec = TerminalAuditRecord::new(self.now(), term.to_string(), TerminalAuditWhat::Refused, identity); + rec.device = Some(writer); + rec.label = label; + rec.why = Some(format!("seq {seq} never arrived")); + self.audit(rec); + } + + /// `terminal-resize`: only the window of the device that types counts. + pub(super) fn on_terminal_resize(&mut self, m: TerminalResizeMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_term(&m.term, &actor, why); + } + if !self.terminal_is_mine(&m.term, &actor) { + return; + } + let Some(t) = self.terminals.open.get_mut(&m.term) else { return }; + if actor.device.as_deref() != Some(t.writer.as_str()) { + log::info!("[Engine] terminal-resize for {} from a device that is not its writer — ignored", m.term); + return; + } + t.cols = m.cols; + t.rows = m.rows; + self.out.push(Effect::TerminalResize { term: m.term.clone(), cols: m.cols, rows: m.rows }); + let mut rec = TerminalAuditRecord::new(self.now(), m.term, TerminalAuditWhat::Resized, actor.identity.clone()); + actor.fill(&mut rec); + rec.cols = Some(m.cols); + rec.rows = Some(m.rows); + self.audit(rec); + } + + /// `terminal-replay`: attach to a terminal's output from a byte offset, + /// without becoming its writer. Any client of the identity may — this is + /// how a second phone reads a terminal another one runs. + pub(super) fn on_terminal_replay(&mut self, m: TerminalReplayMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_term(&m.term, &actor, why); + } + if !self.terminal_is_mine(&m.term, &actor) { + return; + } + // What a client shows is what its `presence` says, so a client that is + // already showing this terminal and is only catching up after a gap is + // not news: the others are told when someone attaches. + if !self.client_watches_terminal(&actor.identity, &actor.key, &m.term) { + if let Some(device) = actor.device.clone() { + self.terminal_event(&m.term, TerminalEventKind::Attached, &device, &actor.identity, via); + } + } + self.out.push(Effect::TerminalReplay { term: m.term.clone(), from: m.from, client: actor.addressee() }); + let mut rec = TerminalAuditRecord::new(self.now(), m.term, TerminalAuditWhat::Attached, actor.identity.clone()); + actor.fill(&mut rec); + rec.why = Some(format!("sent its output from byte {}", m.from)); + self.audit(rec); + } + + /// `terminal-takeover`: become the terminal's writer. Audited, and told + /// to every present client — a terminal that changes hands is exactly + /// what a phone that did not act must learn about. + pub(super) fn on_terminal_takeover(&mut self, m: TerminalIdMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_term(&m.term, &actor, why); + } + if !self.terminal_is_mine(&m.term, &actor) { + return; + } + let Some(device) = actor.device.clone() else { return }; + if self.terminals.open.get(&m.term).is_some_and(|t| t.writer == device) { + log::info!("[Engine] terminal-takeover for {} from the device that already writes it", m.term); + return; + } + if !self.hand_writer(&m.term, &device) { + return; + } + self.terminal_event(&m.term, TerminalEventKind::Takeover, &device, &actor.identity, via); + let mut rec = TerminalAuditRecord::new(self.now(), m.term, TerminalAuditWhat::Takeover, actor.identity.clone()); + actor.fill(&mut rec); + self.audit(rec); + } + + /// `terminal-close`: the writer hangs up its own terminal. + pub(super) fn on_terminal_close(&mut self, m: TerminalIdMsg, via: TerminalVia) { + let Some(actor) = self.terminal_actor(via) else { return }; + if let Some(why) = self.terminal_refusal(&actor) { + return self.refuse_term(&m.term, &actor, why); + } + if !self.terminal_is_mine(&m.term, &actor) { + return; + } + let writer = self.terminals.open.get(&m.term).map(|t| t.writer.clone()); + if actor.device != writer { + log::info!("[Engine] terminal-close for {} from a device that is not its writer — ignored", m.term); + return; + } + self.end_terminal(&m.term, TerminalEndReason::Closed, None, Some(&actor)); + } + + /// Hand the pen to `device`: its `seq` starts over. + /// + /// A `seq` is one writer's own count of what it has typed, so a new writer + /// must not have its first keystrokes dropped as ones already applied. It + /// is told where its numbering starts (`terminal-input-from`), the same + /// message a writer gets when the bridge is waiting on a gap. + fn hand_writer(&mut self, term: &str, device: &str) -> bool { + let Some(t) = self.terminals.open.get_mut(term) else { return false }; + t.writer = device.to_string(); + t.last_seq = 0; + t.pending.clear(); + if let Some(id) = t.gap_timer.take() { + self.out.cancel_timer(id); + } + let identity = t.identity.clone(); + if let Some(key) = self.terminal_key_of(&identity, device) { + self.out.publish_addressed( + vec![Addressee { phone: identity, key }], + BridgeToPhone::TerminalInputFrom(TerminalInputFromMsg { term: term.to_string(), seq: 1 }), + ); + } + true + } + + /// End a terminal and tell its identity's clients: one `terminal-exited` + /// with the reason (and the shell's code, when it returned one), and one + /// `terminal-event` so every present client learns it is over. `actor` is + /// the client that ended it, when a client did. + fn end_terminal(&mut self, term: &str, reason: TerminalEndReason, code: Option, actor: Option<&Actor>) { + let Some(t) = self.terminals.open.remove(term) else { return }; + for timer in [t.gap_timer, t.idle_timer].into_iter().flatten() { + self.out.cancel_timer(timer); + } + // A terminal the bridge decided to end is still running: hang it up. + // One the runtime reported already ended (its shell returned, its pipe + // broke) needs no effect — the runtime is the one that said so. + if !matches!(reason, TerminalEndReason::Exit | TerminalEndReason::Transport | TerminalEndReason::Restart) { + self.out.push(Effect::TerminalClose { term: term.to_string() }); + } + log::info!("[Engine] Terminal {term} ended: {reason:?}"); + self.out.publish( + vec![t.identity.clone()], + BridgeToPhone::TerminalExited(TerminalExitedMsg { + term: Some(term.to_string()), + open_id: None, + code, + reason, + device: None, + }), + ); + // Whoever acted, else the device whose terminal it was: an end nobody + // asked for is named after the client that opened it. + let device = match actor.and_then(|a| a.device.clone()) { + Some(device) => device, + None => t.opener.clone(), + }; + let via = actor.map(|a| a.via).unwrap_or(t.via); + if !device.is_empty() { + let what = if reason == TerminalEndReason::Closed { TerminalEventKind::Closed } else { TerminalEventKind::Exited }; + self.terminal_event(term, what, &device, &t.identity, via); + } + // A terminal nobody can act on any more is not what a client shows: + // the clients showing it are left showing none. + for phone in self.paired.iter_mut().filter(|p| p.pubkey_hex == t.identity) { + for client in &mut phone.clients { + if matches!(&client.watch_terminal, WatchState::Watching(Some(watching)) if watching == term) { + client.watch_terminal = WatchState::Watching(None); + } + } + } + let mut rec = TerminalAuditRecord::new( + self.now(), + term.to_string(), + if reason == TerminalEndReason::Closed { TerminalAuditWhat::Closed } else { TerminalAuditWhat::Exited }, + t.identity.clone(), + ); + rec.label = self.terminal_label(&t.identity, &device); + rec.device = (!device.is_empty()).then_some(device); + rec.via = Some(via); + rec.cwd = Some(t.cwd.clone()); + rec.shell = Some(t.shell.clone()); + rec.cols = Some(t.cols); + rec.rows = Some(t.rows); + rec.reason = Some(reason); + rec.code = code; + rec.duration_ms = Some(self.now().saturating_sub(t.opened_ms)); + self.audit(rec); + self.list_dirty = true; + } + + /// Close every terminal of `identity` — it is no longer paired, or the + /// bridge is stopping — with one reason for all of them. + pub(super) fn close_identity_terminals(&mut self, identity: &str, reason: TerminalEndReason) { + let terms: Vec = self + .terminals + .open + .iter() + .filter(|(_, t)| t.identity == identity) + .map(|(id, _)| id.clone()) + .collect(); + for term in terms { + self.end_terminal(&term, reason, None, None); + } + } + + /// The idle limit fired: a terminal no present client is attached to is + /// closed. One that still is gets the limit again from here, so a shell an + /// abandoned phone left running does not run for ever. + pub(super) fn on_terminal_idle(&mut self, term: &str) { + if let Some(t) = self.terminals.open.get_mut(term) { + t.idle_timer = None; + } + let attached = self.client_attached(term); + let idle_ms = self.terminals.config.idle_secs * 1000; + if attached { + if idle_ms > 0 { + let id = self.out.set_timer(idle_ms, TimerKind::TerminalIdle { term: term.to_string() }); + if let Some(t) = self.terminals.open.get_mut(term) { + t.idle_timer = Some(id); + } + } + return; + } + self.end_terminal(term, TerminalEndReason::Idle, None, None); + } + + /// Whether any present client of the terminal's identity shows it. What a + /// client shows is what its `presence` said, so a client that dropped off + /// the network is not a reason to keep a shell alive. + fn client_attached(&self, term: &str) -> bool { + let Some(t) = self.terminals.open.get(term) else { return false }; + let now = self.now(); + self.paired + .iter() + .filter(|p| p.pubkey_hex == t.identity) + .flat_map(|p| p.clients.iter()) + .any(|c| c.present_until_ms > now && c.watch_terminal.shows(term)) + } + + /// Whether the client holding `key` shows `term` now. + fn client_watches_terminal(&self, identity: &str, key: &str, term: &str) -> bool { + self.paired + .iter() + .find(|p| p.pubkey_hex == identity) + .and_then(|p| { + p.clients + .iter() + .find(|c| c.key.pubkey_hex == key || c.previous.as_ref().is_some_and(|k| k.pubkey_hex == key)) + }) + .is_some_and(|c| c.watch_terminal.shows(term)) + } + + /// The allowlist changed on the machine. A device withdrawn from it is the + /// kill switch for a lost phone: the terminals it opened end at once, and + /// one it only wrote into goes back to the device that opened it. + pub(super) fn on_terminal_allowlist(&mut self, allowlist: TerminalAllowlist) { + let mut merged = allowlist; + // A re-read that raced a refusal this engine has just recorded would + // otherwise forget it: both lists are merged, newest last seen wins. + for old in std::mem::take(&mut self.terminals.config.allowlist.pending) { + match merged.pending.iter_mut().find(|p| p.device == old.device) { + Some(kept) if kept.last_seen_ms >= old.last_seen_ms => {} + Some(kept) => *kept = old, + None => merged.pending.push(old), + } + } + merged.pending.sort_by_key(|p| p.last_seen_ms); + if merged.pending.len() > MAX_PENDING_DEVICES { + let drop = merged.pending.len() - MAX_PENDING_DEVICES; + merged.pending.drain(..drop); + } + let allowed = merged.devices.clone(); + self.terminals.config.allowlist = merged; + + let open: Vec<(String, String, String, String, TerminalVia)> = self + .terminals + .open + .iter() + .map(|(id, t)| (id.clone(), t.identity.clone(), t.opener.clone(), t.writer.clone(), t.via)) + .collect(); + for (term, identity, opener, writer, via) in open { + if !opener.is_empty() && !allowed.contains(&opener) { + log::info!("[Engine] Terminal {term} ends: its device was withdrawn from the allowlist"); + self.end_terminal(&term, TerminalEndReason::Killed, None, None); + continue; + } + if !writer.is_empty() && writer != opener && !allowed.contains(&writer) { + // A device withdrawn while writing into another device's + // terminal loses the pen rather than the terminal: it may not + // type any more, and the device that opened it gets it back. + log::info!("[Engine] Terminal {term} returns to its opener: its writer was withdrawn"); + if self.hand_writer(&term, &opener) { + self.terminal_event(&term, TerminalEventKind::Takeover, &opener, &identity, via); + let mut rec = TerminalAuditRecord::new(self.now(), term, TerminalAuditWhat::Takeover, identity.clone()); + rec.label = self.terminal_label(&identity, &opener); + rec.device = Some(opener); + rec.via = Some(via); + rec.why = Some(format!("the writer {writer} was withdrawn from the allowlist")); + self.audit(rec); + } + } + } + } +} diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index c6d4f7da..33df0041 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -8,9 +8,12 @@ use agent_protocol::{BridgeFrame, HostFrame}; use protocol::commands::UploadFileMsg; +use protocol::common::TerminalEndReason; use protocol::events::BridgeToPhone; use serde::{Deserialize, Serialize}; +use crate::engine::{PendingDevice, TerminalAllowlist, TerminalAuditRecord}; + /// A command event from a phone, signature already verified. `content` is /// still NIP-44 ciphertext. #[derive(Debug, Clone, PartialEq, Eq)] @@ -129,6 +132,16 @@ impl WatchState { WatchState::Watching(watching) => watching.as_deref() == Some(session), } } + + /// Whether this client shows `term`. Only one that said so does: a + /// terminal's output is not the completing entry of a row already on + /// screen, so an unasked client loses nothing by not being sent it — it + /// draws what it missed with `terminal-replay` from the runtime's ring + /// when it does attach, and a terminal nobody attached to is one the idle + /// limit is meant to close. + pub(crate) fn shows(&self, term: &str) -> bool { + matches!(self, WatchState::Watching(Some(watching)) if watching == term) + } } /// One copy of a message: for one client of an identity. @@ -199,6 +212,25 @@ pub enum Input { ClosePairing, /// The workspace roots or folders changed on disk: republish them. WorkspaceChanged, + /// A shell for [`Effect::TerminalSpawn`] is running; `shell` is what + /// runs in it. Answer to a `terminal-open`. + TerminalSpawned { term: String, shell: String }, + /// The shell could not be started (no PTY, no usable program): the open + /// is answered as a refusal, and `note` is what the audit records. + TerminalSpawnFailed { term: String, note: String }, + /// A terminal wrote output: `data`, base64, from byte `offset` of + /// everything it has ever written. Goes to the clients showing it. + TerminalOutput { term: String, offset: u64, data: String }, + /// A terminal's shell is gone: `code` when it exited on its own, else the + /// `reason` the runtime saw (its pipe broke, the bridge is going down). + TerminalExited { term: String, code: Option, reason: TerminalEndReason }, + /// The terminal allowlist changed on disk — the owner ran + /// `codedeck-bridge terminal allow|disallow` on the machine: replace what + /// the engine holds and close the terminals of a device withdrawn. + TerminalAllowlist(TerminalAllowlist), + /// The owner unpaired an identity on the machine: it is heard from no + /// more, and the terminals its devices hold end. + Unpaired { identity: String }, /// Stop: end every session, publish the offline heartbeat, then /// [`Effect::Stopped`]. Shutdown, @@ -238,6 +270,34 @@ pub enum Effect { /// Fetch / reassemble an uploaded image into the workspace; answer with /// [`Input::FileReady`] once it is on disk. HandleFileUpload(UploadFileMsg), + /// Start a shell for `term` in `cwd`, at `cols`×`rows`; answer with + /// [`Input::TerminalSpawned`] once it runs, or + /// [`Input::TerminalSpawnFailed`]. The runtime starts it with a built + /// environment, never the bridge's own: the bridge's may hold the + /// agents' provider keys. + TerminalSpawn { term: String, cwd: String, cols: u16, rows: u16 }, + /// Type `bytes` into a terminal, in the order the engine emitted them. + /// The engine has already checked the sender is the writer and that no + /// earlier input is missing. + TerminalWrite { term: String, bytes: Vec }, + /// The writer's window changed. + TerminalResize { term: String, cols: u16, rows: u16 }, + /// Send `client` this terminal's output from byte `from`, and what + /// follows: it attached to a terminal it was not showing. + TerminalReplay { term: String, from: u64, client: Addressee }, + /// Hang up a terminal: SIGHUP, then kill once it does not go. The engine + /// has already ended it; this is the shell's part. + TerminalClose { term: String }, + /// The devices waiting for the owner's approval changed: write them where + /// the owner lists them (`codedeck-bridge terminal pending`). Sent whole, + /// so the file always says what the engine knows. + TerminalPending(Vec), + /// One line for the terminal audit, in the order the engine decided it. + /// The engine holds no file: the runtime chains the line to the one + /// before it and stores it, and denies the audit to no one running as + /// the bridge's user — the chain catches tampering that is not that + /// thorough. + Audit(TerminalAuditRecord), /// Shutdown is complete: every publish before this one must still go /// out, then the runtime may close the host and the relays. Stopped, diff --git a/crates/bridge-core/src/lib.rs b/crates/bridge-core/src/lib.rs index 670d7d7f..89212ed7 100644 --- a/crates/bridge-core/src/lib.rs +++ b/crates/bridge-core/src/lib.rs @@ -32,6 +32,9 @@ pub mod settings; mod sync; pub mod time; -pub use engine::{Config, Engine, DEFAULT_HEARTBEAT_INTERVAL_MS, MAX_CLIENTS}; +pub use engine::{ + Config, Engine, PendingDevice, TerminalAllowlist, TerminalAuditRecord, TerminalAuditWhat, TerminalConfig, + DEFAULT_HEARTBEAT_INTERVAL_MS, MAX_CLIENTS, +}; pub use sync::SyncConfig; pub use io::*; diff --git a/crates/bridge-core/src/out.rs b/crates/bridge-core/src/out.rs index 343a79f3..d62d312e 100644 --- a/crates/bridge-core/src/out.rs +++ b/crates/bridge-core/src/out.rs @@ -19,6 +19,11 @@ pub(crate) enum TimerKind { SyncAck(String), SyncIdle(String), PairingExpiry { epoch: u64 }, + /// A terminal's input arrived ahead of the one before it: the wait is over + /// and the phone is asked to resend from the gap. + TerminalGap { term: String }, + /// A terminal has had no client attached to it for the idle limit. + TerminalIdle { term: String }, } #[derive(Default)] diff --git a/crates/bridge-core/tests/support/mod.rs b/crates/bridge-core/tests/support/mod.rs index 0817e32d..3c73784e 100644 --- a/crates/bridge-core/tests/support/mod.rs +++ b/crates/bridge-core/tests/support/mod.rs @@ -12,7 +12,7 @@ use agent_protocol::{ }; use bridge_core::ports::memory::{MemoryStore, MemoryTranscripts, MemoryWorkspace, TestSystem}; use bridge_core::ports::Ports; -use bridge_core::{Config, Effect, Engine, InboundEvent, Input, PairedPhone, TimerId, Via}; +use bridge_core::{Config, Effect, Engine, InboundEvent, Input, PairedPhone, TerminalConfig, TimerId, Via}; use protocol::common::{AgentStyle, AgentSupports, EntryBody, OptionChoice, OutputEntry, Role}; use protocol::crypto::{encrypt_to, generate_keypair, Keypair}; use protocol::events::BridgeToPhone; @@ -105,6 +105,9 @@ pub struct Rig { /// Armed timers: id → due time. timers: BTreeMap, host_seq: u64, + /// The terminal settings the engine was given; kept so a restarted rig is + /// the same bridge. + terminal: TerminalConfig, } pub struct RigOptions { @@ -112,11 +115,20 @@ pub struct RigOptions { pub store: MemoryStore, pub transcripts: MemoryTranscripts, pub configure: fn(&mut Config), + /// What the owner decided about terminals (off by default, as on a real + /// machine). + pub terminal: TerminalConfig, } impl Default for RigOptions { fn default() -> Self { - Self { paired: true, store: MemoryStore::default(), transcripts: MemoryTranscripts::default(), configure: |_| {} } + Self { + paired: true, + store: MemoryStore::default(), + transcripts: MemoryTranscripts::default(), + configure: |_| {}, + terminal: TerminalConfig::default(), + } } } @@ -146,6 +158,7 @@ impl Rig { let mut config = Config::new(bridge.clone(), "laptop"); config.relays = vec!["wss://relay.example".into()]; config.bridge_version = "11.0.0-test".into(); + config.terminal = options.terminal.clone(); (options.configure)(&mut config); let ports = Ports { system: Box::new(system.clone()), @@ -164,6 +177,7 @@ impl Rig { effects: Vec::new(), timers: BTreeMap::new(), host_seq: 0, + terminal: options.terminal, }; rig.input(Input::Start); rig @@ -171,7 +185,13 @@ impl Rig { /// A fresh engine over the same storage: a bridge restart. pub fn restart(self) -> Self { - let options = RigOptions { paired: false, store: self.store.clone(), transcripts: self.transcripts.clone(), configure: |_| {} }; + let options = RigOptions { + paired: false, + store: self.store.clone(), + transcripts: self.transcripts.clone(), + configure: |_| {}, + terminal: self.terminal.clone(), + }; let rig = Self::build(options, self.bridge.clone(), self.phone.clone()); rig.system.set_now(self.system_now()); rig diff --git a/crates/bridge-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs new file mode 100644 index 00000000..2ae7b73f --- /dev/null +++ b/crates/bridge-core/tests/terminal.rs @@ -0,0 +1,1033 @@ +//! Terminals through the engine: the gates that decide who may have one, the +//! writer, `seq`, who is told what, and how a terminal ends. +//! +//! Everything runs against in-memory ports and a manual clock: a terminal is +//! a state machine the runtime drives, and the tests drive it the same way. + +mod support; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use bridge_core::{ + Addressee, Effect, Input, TerminalAllowlist, TerminalAuditRecord, TerminalAuditWhat, TerminalConfig, Via, +}; +use protocol::common::{TerminalEndReason, TerminalEventKind, TerminalVia}; +use protocol::crypto::{generate_keypair, Keypair}; +use protocol::events::{ + BridgeToPhone, TerminalEventMsg, TerminalExitedMsg, TerminalInfo, TerminalOpenedMsg, TerminalOutputMsg, +}; +use protocol::recipients::device_id; +use serde_json::{json, Value}; +use support::*; + +const NOW_SECS: u64 = T0 / 1000; +const DAY: u64 = 24 * 3600; + +/// A rig with terminals on, and the paired phone's client granted a session +/// key: the client is what a device is derived from, so nothing can be opened +/// until it has one. +struct World { + rig: Rig, + /// The session key the client granted: its commands are encrypted with it. + key: Keypair, + device: String, +} + +impl World { + fn new() -> Self { + Self::with(TerminalConfig { enabled: true, ..Default::default() }) + } + + /// A world whose owner decided `terminal`. + fn with(terminal: TerminalConfig) -> Self { + let rig = Rig::with(RigOptions { terminal, ..Default::default() }); + let key = generate_keypair(); + let device = device_id(&key.pubkey_hex); + let mut world = Self { rig, key, device }; + world.grant(&world.key.clone(), "Pixel 8"); + let device = world.device.clone(); + world.allow(&[&device]); + world.rig.take(); + world + } + + /// The identity grants `key` to this bridge: from then on its commands are + /// encrypted with it, and the device it is, is what a terminal needs + /// approved. The grant itself is encrypted with the identity, which is all + /// the bridge knows yet. + fn grant(&mut self, key: &Keypair, label: &str) { + let bridge = self.rig.bridge.pubkey_hex.clone(); + let phone = self.rig.phone.clone(); + self.rig.phone_event( + &phone, + json!({"v":12,"type":"session-key","sessionKey":{ + "pubkeyHex": key.pubkey_hex, "bridgePubkeyHex": bridge, + "expiresAt": NOW_SECS + 90 * DAY, "label": label}}), + Via::Direct, + ); + self.rig.take(); + } + + /// The owner's allowlist, as `codedeck-bridge terminal allow|disallow` + /// writes it. + fn allow(&mut self, devices: &[&str]) { + self.rig.input(Input::TerminalAllowlist(TerminalAllowlist { + devices: devices.iter().map(|d| d.to_string()).collect(), + ..Default::default() + })); + } + + /// Another device of the same identity: its own session key (and so its + /// own device), approved like the first. + fn other_device(&mut self) -> (Keypair, String) { + let key = generate_keypair(); + let device = device_id(&key.pubkey_hex); + self.grant(&key, "Tablet"); + let mut devices = vec![self.device.clone()]; + devices.push(device.clone()); + let refs: Vec<&str> = devices.iter().map(String::as_str).collect(); + self.allow(&refs); + self.rig.take(); + (key, device) + } + + /// A whole second identity pairs with this bridge, its client's key + /// granted in the pair-request. Returns that client's device. + fn pair_identity(&mut self, phone: &Keypair, key: &Keypair, label: &str) -> String { + self.rig.input(Input::OpenPairing { duration_ms: None }); + let token = self + .rig + .take() + .into_iter() + .find_map(|e| match e { + Effect::PresentPairing(info) => Some(info.token), + _ => None, + }) + .expect("the pairing window is presented"); + let bridge = self.rig.bridge.pubkey_hex.clone(); + self.rig.phone_event( + phone, + json!({"v":12,"type":"pair-request","npub": phone.npub, "pubkeyHex": phone.pubkey_hex, + "label": label, "token": token, + "sessionKey": {"pubkeyHex": key.pubkey_hex, "bridgePubkeyHex": bridge, + "expiresAt": NOW_SECS + 90 * DAY, "label": label}}), + Via::Pairing, + ); + self.rig.take(); + device_id(&key.pubkey_hex) + } + + /// A command from the client, over the bridge's own link. + fn command(&mut self, msg: Value) { + let phone = self.rig.phone.clone(); + self.rig.phone_event_via_key(&phone, &self.key, msg, Via::Direct); + } + + /// The same, from another device. + fn command_from(&mut self, key: &Keypair, msg: Value) { + let phone = self.rig.phone.clone(); + self.rig.phone_event_via_key(&phone, key, msg, Via::Direct); + } + + /// The same, over a relay. + fn command_via_relay(&mut self, msg: Value) { + let phone = self.rig.phone.clone(); + let key = self.key.clone(); + self.rig.phone_event_via_key(&phone, &key, msg, Via::Relay); + } + + /// A keystroke event, on the live kind terminal input rides. + fn input(&mut self, term: &str, seq: u64, text: &str) { + let key = self.key.clone(); + self.input_from(&key, term, seq, text); + } + + /// The same, from another device. + fn input_from(&mut self, key: &Keypair, term: &str, seq: u64, text: &str) { + let phone = self.rig.phone.clone(); + self.rig.live_event( + &phone, + key, + json!({"v":12,"type":"terminal-input","term":term,"seq":seq,"data":STANDARD.encode(text)}), + Via::Direct, + ); + } + + /// What the client shows: which terminal, or none. + fn presence(&mut self, watching_terminal: Option<&str>) { + self.command(json!({"v":12,"type":"presence","watchingTerminal":watching_terminal})); + } + + /// Open a terminal and take it through to running. Returns its id. + fn open(&mut self) -> String { + let key = self.key.clone(); + self.open_from(&key) + } + + /// The same, from another device. + fn open_from(&mut self, key: &Keypair) -> String { + self.command_from(key, json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (term, _cwd) = self.spawned().expect("the runtime was asked to start a shell"); + self.rig.input(Input::TerminalSpawned { term: term.clone(), shell: "/bin/bash".into() }); + self.rig.take(); + term + } + + /// The first spawn the runtime has been asked for, if any: (term, cwd). + fn spawned(&mut self) -> Option<(String, String)> { + self.acts().into_iter().find_map(|e| match e { + Effect::TerminalSpawn { term, cwd, .. } => Some((term, cwd)), + _ => None, + }) + } + + /// The runtime's output for a terminal. + fn say(&mut self, term: &str, offset: u64, text: &str) { + self.rig.input(Input::TerminalOutput { term: term.to_string(), offset, data: STANDARD.encode(text) }); + } + + /// What the engine decided since the last look, and what it published, + /// kept apart. Also remembers the addressees, which a publish's copies + /// are keyed by. + fn take(&mut self) -> (Vec, Vec) { + let mut acts = Vec::new(); + let mut msgs = Vec::new(); + for effect in self.rig.take() { + match effect { + Effect::Publish { to, message } => msgs.push((to, message)), + other => acts.push(other), + } + } + (acts, msgs) + } + + fn acts(&mut self) -> Vec { + self.take().0 + } + + fn messages(&mut self) -> Vec { + self.take().1 + } +} + +// --- reading what the engine decided --- + +/// One publish: the copies it was addressed to, and the message. +type Published = (Vec, BridgeToPhone); + +fn exits(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::TerminalExited(e) => Some(e.clone()), + _ => None, + }) + .collect() +} + +fn opened(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::TerminalOpened(o) => Some(o.clone()), + _ => None, + }) + .collect() +} + +/// Every `terminal-event`, as `(what, device)`. +fn events(msgs: &[Published]) -> Vec<(TerminalEventKind, String)> { + events_full(msgs).into_iter().map(|e| (e.what, e.device)).collect() +} + +fn events_full(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::TerminalEvent(e) => Some(e.clone()), + _ => None, + }) + .collect() +} + +/// What the bridge told a phone to resend from: (term, seq). +fn input_from(msgs: &[Published]) -> Vec<(String, u64)> { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::TerminalInputFrom(f) => Some((f.term.clone(), f.seq)), + _ => None, + }) + .collect() +} + +fn output(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::TerminalOutput(o) => Some(o.clone()), + _ => None, + }) + .collect() +} + +/// What the runtime was asked to type: (term, bytes). +fn typed(acts: &[Effect]) -> Vec<(String, String)> { + acts.iter() + .filter_map(|e| match e { + Effect::TerminalWrite { term, bytes } => Some((term.clone(), String::from_utf8_lossy(bytes).to_string())), + _ => None, + }) + .collect() +} + +fn closed(acts: &[Effect]) -> Vec { + acts.iter() + .filter_map(|e| match e { + Effect::TerminalClose { term } => Some(term.clone()), + _ => None, + }) + .collect() +} + +fn audits(acts: &[Effect]) -> Vec { + acts.iter() + .filter_map(|e| match e { + Effect::Audit(record) => Some(record.clone()), + _ => None, + }) + .collect() +} + +fn audits_of(acts: &[Effect], what: TerminalAuditWhat) -> Vec { + audits(acts).into_iter().filter(|r| r.what == what).collect() +} + +fn pending(acts: &[Effect]) -> Vec> { + acts.iter() + .filter_map(|e| match e { + Effect::TerminalPending(p) => Some(p.clone()), + _ => None, + }) + .collect() +} + +/// Just the messages of a take, for the checks whose addressees do not +/// matter. +fn only(msgs: &[Published]) -> Vec { + msgs.iter().map(|(_, m)| m.clone()).collect() +} + +/// The terminals a heartbeat lists, if it lists any. +fn heartbeat_terminals(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::Sessions(s) => s.terminals.clone(), + _ => None, + }) + .flatten() + .collect() +} + +fn heartbeat_capabilities(msgs: &[Published]) -> Vec { + msgs.iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::Sessions(s) => s.capabilities.clone(), + _ => None, + }) + .flatten() + .collect() +} + +// --- the gates --- + +#[test] +fn a_bridge_with_terminals_off_refuses_every_open_and_advertises_nothing() { + let mut world = World::with(TerminalConfig { enabled: false, ..Default::default() }); + world.rig.take(); + world.command(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + + let (acts, msgs) = world.take(); + let refusal = exits(&msgs); + assert_eq!(refusal.len(), 1); + assert_eq!(refusal[0].open_id.as_deref(), Some("o1")); + assert_eq!(refusal[0].reason, TerminalEndReason::Disabled); + assert!(refusal[0].term.is_none(), "no terminal came of it"); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalSpawn { .. })), "no shell is started"); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Refused)[0].reason, Some(TerminalEndReason::Disabled)); + + // The heartbeat says nothing about terminals, so a phone shows none. + world.rig.input(Input::WorkspaceChanged); + let capabilities = heartbeat_capabilities(&world.messages()); + assert!(!capabilities.contains(&"terminal".to_string()), "capabilities: {capabilities:?}"); + assert!(capabilities.contains(&"files".to_string())); +} + +#[test] +fn the_heartbeat_advertises_terminals_and_lists_what_runs() { + let mut world = World::new(); + let term = world.open(); + + world.rig.input(Input::WorkspaceChanged); + let msgs = world.messages(); + assert!(heartbeat_capabilities(&msgs).contains(&"terminal".to_string())); + let listed = heartbeat_terminals(&msgs); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].term, term); + assert_eq!(listed[0].shell, "/bin/bash"); + assert_eq!(listed[0].device, world.device); + assert_eq!(listed[0].name.as_deref(), Some("Pixel 8")); + assert_eq!(listed[0].writer, world.device); + assert_eq!((listed[0].cols, listed[0].rows), (80, 24)); + + // A heartbeat with no terminal open omits the list entirely. + world.command(json!({"v":12,"type":"terminal-close","term": term})); + world.rig.input(Input::WorkspaceChanged); + let msgs = world.messages(); + assert!(heartbeat_terminals(&msgs).is_empty()); + assert!(only(&msgs).iter().any(|m| matches!(m, BridgeToPhone::Sessions(s) if s.terminals.is_none()))); +} + +#[test] +fn a_relay_may_not_carry_a_terminal_unless_the_owner_allows_it() { + let mut world = World::new(); + world.command_via_relay(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (acts, msgs) = world.take(); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Transport); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalSpawn { .. }))); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Refused)[0].via, Some(TerminalVia::Relay)); + + // With relays allowed, the same open starts a shell — and the audit says + // over what it came. + let mut world = World::with(TerminalConfig { enabled: true, relays_allowed: true, ..Default::default() }); + world.command_via_relay(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (acts, _) = world.take(); + assert!(acts.iter().any(|e| matches!(e, Effect::TerminalSpawn { .. }))); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Refused).len(), 0); +} + +#[test] +fn a_device_off_the_allowlist_is_refused_and_the_owner_is_given_what_to_approve() { + let mut world = World::new(); + // A device nobody approved: the identity key is the same, the session key + // it granted is new — which is what a stolen key on a new phone looks + // like, and what the allowlist is for. + let stolen = generate_keypair(); + let device = device_id(&stolen.pubkey_hex); + world.grant(&stolen, "Stolen phone"); + world.allow(&[]); + world.command_from(&stolen, json!({"v":12,"type":"terminal-open","openId":"o7","cols":80,"rows":24})); + + let (acts, msgs) = world.take(); + let refusal = exits(&msgs); + assert_eq!(refusal[0].reason, TerminalEndReason::NotAllowed); + assert_eq!(refusal[0].open_id.as_deref(), Some("o7")); + assert_eq!(refusal[0].device.as_deref(), Some(device.as_str()), "the phone is told which device to approve"); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalSpawn { .. }))); + + let waiting = pending(&acts); + assert_eq!(waiting.len(), 1, "the owner is handed what to approve"); + assert_eq!(waiting[0][0].device, device); + assert_eq!(waiting[0][0].label.as_deref(), Some("Stolen phone")); + assert_eq!(waiting[0][0].last_seen_ms, world.rig.system_now()); + // The refusal is audited with who it was for. + let rec = &audits_of(&acts, TerminalAuditWhat::Refused)[0]; + assert_eq!(rec.device.as_deref(), Some(device.as_str())); + assert_eq!(rec.label.as_deref(), Some("Stolen phone")); + assert_eq!(rec.reason, Some(TerminalEndReason::NotAllowed)); + + // Approved, the same device may open one. + world.allow(&[&device]); + world.command_from(&stolen, json!({"v":12,"type":"terminal-open","openId":"o8","cols":80,"rows":24})); + let (acts, _) = world.take(); + assert!(acts.iter().any(|e| matches!(e, Effect::TerminalSpawn { .. }))); + + // Refused again once the owner takes it away, and the list of waiting + // devices keeps only the newest sighting of each. + world.allow(&[]); + world.command_from(&stolen, json!({"v":12,"type":"terminal-open","openId":"o9","cols":80,"rows":24})); + let (acts, _) = world.take(); + let waiting = pending(&acts); + assert_eq!(waiting[0].len(), 1); + assert_eq!(waiting[0][0].label.as_deref(), Some("Stolen phone")); +} + +#[test] +fn the_fifth_terminal_is_refused_and_an_unanswered_open_already_counts() { + let mut world = World::new(); + let first = world.open(); + let _second = world.open(); + let _third = world.open(); + let _fourth = world.open(); + + world.command(json!({"v":12,"type":"terminal-open","openId":"o5","cols":80,"rows":24})); + let (acts, msgs) = world.take(); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Limit); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Refused)[0].reason, Some(TerminalEndReason::Limit)); + + // Opening is not free until a shell runs: the shell that is coming takes + // a slot of its own. + world.command(json!({"v":12,"type":"terminal-close","term": first})); + world.rig.take(); + world.command(json!({"v":12,"type":"terminal-open","openId":"o6","cols":80,"rows":24})); + world.command(json!({"v":12,"type":"terminal-open","openId":"o7","cols":80,"rows":24})); + let (_, msgs) = world.take(); + assert_eq!(exits(&msgs).len(), 1, "the second open meets the limit while the first is still starting"); +} + +#[test] +fn an_open_without_a_client_is_refused_rather_than_ignored() { + let mut world = World::new(); + // Encrypted with the identity's own key: no client, and so no device, so + // a stolen identity key is not a way in. + let phone = world.rig.phone.clone(); + world.rig.phone_event(&phone, json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24}), Via::Direct); + let (acts, msgs) = world.take(); + let refusal = exits(&msgs); + assert_eq!(refusal[0].reason, TerminalEndReason::NotAllowed); + assert!(refusal[0].device.is_none(), "there is no device to name"); + assert!(pending(&acts).is_empty(), "and none to record"); +} + +// --- input --- + +#[test] +fn input_is_typed_in_seq_order_and_one_already_applied_types_nothing() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.input(&term, 1, "ls\n"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "ls\n".to_string())]); + + // The same, and anything older: a relay replaying a signed keystroke + // event types nothing twice. + world.input(&term, 1, "ls\n"); + world.input(&term, 0, "rm -rf /\n"); + assert!(typed(&world.acts()).is_empty()); + + world.input(&term, 2, "pwd\n"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "pwd\n".to_string())]); +} + +#[test] +fn a_gap_is_held_for_two_seconds_and_then_the_writer_resends_from_it() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + // Out of order: held, not typed. + world.input(&term, 2, "two"); + assert!(typed(&world.acts()).is_empty()); + + world.rig.advance(2_000); + let (acts, msgs) = world.take(); + assert!(typed(&acts).is_empty(), "what was held is not typed out of order"); + assert_eq!(input_from(&msgs), vec![(term.clone(), 1)]); + // The gap is what the audit records, not a terminal that ended. + let rec = &audits_of(&acts, TerminalAuditWhat::Refused)[0]; + assert_eq!(rec.why.as_deref(), Some("seq 1 never arrived")); + assert_eq!(rec.reason, None); + + // The writer resends from the gap: what was held behind it was dropped, + // and the resend types in order. + world.input(&term, 1, "one"); + world.input(&term, 2, "two"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "one".to_string()), (term.clone(), "two".to_string())]); +} + +#[test] +fn a_gap_that_fills_in_time_types_the_held_input_in_order() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.input(&term, 1, "one"); + world.input(&term, 3, "three"); + world.input(&term, 4, "four"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "one".to_string())], "3 and 4 wait for 2"); + + world.input(&term, 2, "two"); + let (acts, msgs) = world.take(); + assert_eq!( + typed(&acts), + vec![(term.clone(), "two".to_string()), (term.clone(), "three".to_string()), (term.clone(), "four".to_string())] + ); + assert!(input_from(&msgs).is_empty(), "nothing is missing any more"); +} + +#[test] +fn input_that_is_not_base64_is_refused_and_audited() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + let phone = world.rig.phone.clone(); + let key = world.key.clone(); + world.rig.live_event( + &phone, + &key, + json!({"v":12,"type":"terminal-input","term": term, "seq":1, "data":"not base64 !!!"}), + Via::Direct, + ); + let acts = world.acts(); + assert!(typed(&acts).is_empty()); + let rec = &audits(&acts)[0]; + assert_eq!(rec.what, TerminalAuditWhat::Refused); + assert!(rec.why.as_deref().unwrap().contains("base64"), "{:?}", rec.why); + assert_eq!(rec.device.as_deref(), Some(world.device.as_str()), "and who sent it"); + + // The terminal is untouched by it: the next seq still types. + world.input(&term, 1, "ls\n"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "ls\n".to_string())]); +} + +#[test] +fn only_the_writer_types_and_only_its_window_counts() { + let mut world = World::new(); + let (other, other_device) = world.other_device(); + let term = world.open(); + world.rig.take(); + + let input = json!({"v":12,"type":"terminal-input","term": term, "seq":1, "data": STANDARD.encode("hi")}); + world.command_from(&other, input.clone()); + assert!(typed(&world.acts()).is_empty(), "a device that is not the writer does not type"); + + world.command_from(&other, json!({"v":12,"type":"terminal-resize","term": term, "cols":132,"rows":43})); + let acts = world.acts(); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalResize { .. })), "and does not resize it"); + + world.command_from(&other, json!({"v":12,"type":"terminal-close","term": term})); + let (acts, msgs) = world.take(); + assert!(closed(&acts).is_empty(), "and does not close it"); + assert!(exits(&msgs).is_empty()); + + // The writer's own window counts, and is audited. + world.command(json!({"v":12,"type":"terminal-resize","term": term, "cols":132,"rows":43})); + let acts = world.acts(); + assert!(acts.iter().any(|e| matches!(e, Effect::TerminalResize { cols: 132, rows: 43, .. }))); + let resized = &audits_of(&acts, TerminalAuditWhat::Resized)[0]; + assert_eq!((resized.cols, resized.rows), (Some(132), Some(43))); + assert_eq!(resized.device.as_deref(), Some(world.device.as_str())); + let _ = other_device; +} + +#[test] +fn a_takeover_moves_the_pen_and_starts_the_new_writer_at_one() { + let mut world = World::new(); + let (other, other_device) = world.other_device(); + let term = world.open(); + world.input(&term, 1, "mine\n"); + world.rig.take(); + + world.command_from(&other, json!({"v":12,"type":"terminal-takeover","term": term})); + let (acts, msgs) = world.take(); + assert_eq!(events(&msgs), vec![(TerminalEventKind::Takeover, other_device.clone())]); + let rec = &audits_of(&acts, TerminalAuditWhat::Takeover)[0]; + assert_eq!(rec.device.as_deref(), Some(other_device.as_str())); + assert_eq!(rec.label.as_deref(), Some("Tablet")); + // The new writer is told where its numbering starts, so its first + // keystrokes are not dropped as ones already applied. + assert_eq!(input_from(&msgs), vec![(term.clone(), 1)]); + + world.input_from(&other, &term, 1, "yours\n"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "yours\n".to_string())]); + + // The old writer's input no longer counts, and the heartbeat says who + // writes now. + let phone = world.rig.phone.clone(); + let key = world.key.clone(); + world.rig.live_event( + &phone, + &key, + json!({"v":12,"type":"terminal-input","term": term, "seq":2, "data": STANDARD.encode("still mine")}), + Via::Direct, + ); + assert!(typed(&world.acts()).is_empty()); + world.rig.input(Input::WorkspaceChanged); + let listed = heartbeat_terminals(&world.messages()); + assert_eq!(listed[0].writer, other_device); + assert_eq!(listed[0].device, world.device, "the opener is still the opener"); +} + +// --- who is told what --- + +#[test] +fn output_goes_only_to_the_clients_showing_that_terminal() { + let mut world = World::new(); + let (other, _) = world.other_device(); + let term = world.open(); + world.presence(Some(&term)); + world.rig.input(Input::WorkspaceChanged); + world.rig.take(); + + world.say(&term, 0, "hello"); + let (_, msgs) = world.take(); + let out = output(&msgs); + assert_eq!(out.len(), 1); + assert_eq!((out[0].offset, out[0].data.as_str()), (0, STANDARD.encode("hello").as_str())); + + // A second device that does not show it gets nothing; one that does, gets + // a copy of its own. + let phone = world.rig.phone.clone(); + world.rig.phone_event_via_key( + &phone, + &other, + json!({"v":12,"type":"presence","watchingTerminal": term}), + Via::Direct, + ); + world.rig.take(); + world.say(&term, 5, "there"); + let (_, msgs) = world.take(); + let copies: Vec> = msgs + .iter() + .filter(|(_, m)| matches!(m, BridgeToPhone::TerminalOutput(_))) + .map(|(to, _)| to.iter().map(|a| a.key.clone()).collect()) + .collect(); + assert_eq!(copies, vec![vec![world.key.pubkey_hex.clone(), other.pubkey_hex.clone()]]); + + // Away means nothing more, however it was watching: the client that went + // away is not sent the next frame, and the one still showing it is. + world.command(json!({"v":12,"type":"presence","away":true})); + world.rig.take(); + world.say(&term, 10, "gone"); + let (_, msgs) = world.take(); + let copies: Vec> = msgs + .iter() + .filter(|(_, m)| matches!(m, BridgeToPhone::TerminalOutput(_))) + .map(|(to, _)| to.iter().map(|a| a.key.clone()).collect()) + .collect(); + assert_eq!(copies, vec![vec![other.pubkey_hex.clone()]]); +} + +#[test] +fn every_present_client_of_the_identity_is_told_what_happened() { + let mut world = World::new(); + let (other, other_device) = world.other_device(); + world.rig.take(); + + // The open itself: the answer to the asker alone, the news to both. + world.command(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (term, _) = world.spawned().unwrap(); + world.rig.input(Input::TerminalSpawned { term: term.clone(), shell: "/bin/bash".into() }); + let (acts, msgs) = world.take(); + // The audit says who opened it, over what, and what it runs. + let line = &audits_of(&acts, TerminalAuditWhat::Opened)[0]; + assert_eq!(line.term, term); + assert_eq!(line.identity, world.rig.phone.npub); + assert_eq!(line.device.as_deref(), Some(world.device.as_str())); + assert_eq!(line.label.as_deref(), Some("Pixel 8")); + assert_eq!(line.via, Some(TerminalVia::Direct)); + assert_eq!(line.cwd.as_deref(), Some("/w")); + assert_eq!(line.shell.as_deref(), Some("/bin/bash")); + assert_eq!((line.cols, line.rows), (Some(80), Some(24))); + assert_eq!((line.reason, line.code, line.duration_ms), (None, None, None)); + let answer = msgs.iter().find(|(_, m)| matches!(m, BridgeToPhone::TerminalOpened(_))).unwrap(); + assert_eq!(answer.0, vec![Addressee { phone: world.rig.phone.pubkey_hex.clone(), key: world.key.pubkey_hex.clone() }]); + assert_eq!(opened(&msgs)[0].shell, "/bin/bash"); + let news = msgs.iter().find(|(_, m)| matches!(m, BridgeToPhone::TerminalEvent(_))).unwrap(); + // Addressed to the identity, and copied to each present client of it, so + // a phone that did not open it learns at once. + let keys: Vec = news.0.iter().map(|a| a.key.clone()).collect(); + assert_eq!(keys, vec![world.key.pubkey_hex.clone(), other.pubkey_hex.clone()]); + assert_eq!(events(&msgs), vec![(TerminalEventKind::Opened, world.device.clone())]); + let full = events_full(&msgs); + assert_eq!(full[0].name.as_deref(), Some("Pixel 8")); + assert_eq!(full[0].via, TerminalVia::Direct); + assert_eq!(full[0].at, world.rig.system_now()); + + // An attach from the other device is announced, with its name. + world.command_from(&other, json!({"v":12,"type":"terminal-replay","term": term,"from":0})); + let (acts, msgs) = world.take(); + assert_eq!(events(&msgs), vec![(TerminalEventKind::Attached, other_device.clone())]); + assert_eq!(events_full(&msgs)[0].name.as_deref(), Some("Tablet")); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Attached)[0].device.as_deref(), Some(other_device.as_str())); + // The replay went to the client that asked, from the byte it named. + let (from, client) = acts + .iter() + .find_map(|e| match e { + Effect::TerminalReplay { from, client, .. } => Some((*from, client.clone())), + _ => None, + }) + .unwrap(); + assert_eq!(from, 0); + assert_eq!(client.key, other.pubkey_hex); +} + +#[test] +fn a_replay_from_a_client_already_showing_the_terminal_is_not_news() { + let mut world = World::new(); + let term = world.open(); + world.presence(Some(&term)); + world.rig.take(); + + // Catching up after a gap is not an attach: the client said it shows it. + world.command(json!({"v":12,"type":"terminal-replay","term": term,"from": 4096})); + let (acts, msgs) = world.take(); + assert!(events(&msgs).is_empty(), "a client already showing it is not attaching"); + assert!( + acts.iter().any(|e| matches!(e, Effect::TerminalReplay { from: 4096, .. })), + "it still gets what it missed" + ); +} + +#[test] +fn a_message_for_a_terminal_that_is_gone_is_answered_as_one_that_is_over() { + let mut world = World::new(); + let term = world.open(); + world.command(json!({"v":12,"type":"terminal-close","term": term})); + world.rig.take(); + + world.command(json!({"v":12,"type":"terminal-replay","term": term,"from":0})); + let (acts, msgs) = world.take(); + let answer = exits(&msgs); + assert_eq!(answer[0].term.as_deref(), Some(term.as_str())); + assert_eq!(answer[0].reason, TerminalEndReason::Closed); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalReplay { .. }))); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Refused)[0].why.as_deref(), Some("the bridge has no such terminal")); +} + +#[test] +fn another_identitys_terminal_is_not_one_this_client_can_see() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + // A whole second identity pairs with the same bridge, with a client of its + // own — and its device is on the allowlist too, so what stops it is whose + // terminal this is, not which device asked. + let other_phone = generate_keypair(); + let other_client = generate_keypair(); + let other_device = world.pair_identity(&other_phone, &other_client, "Other's phone"); + world.allow(&[&world.device.clone(), &other_device]); + world.rig.take(); + + let input = json!({"v":12,"type":"terminal-input","term": term, "seq":1, "data": STANDARD.encode("mine")}); + world.rig.phone_event_via_key(&other_phone, &other_client, input, Via::Direct); + world.rig.phone_event_via_key(&other_phone, &other_client, json!({"v":12,"type":"terminal-close","term": term}), Via::Direct); + world.rig.phone_event_via_key(&other_phone, &other_client, json!({"v":12,"type":"terminal-replay","term": term, "from": 0}), Via::Direct); + + let (acts, msgs) = world.take(); + assert!(typed(&acts).is_empty(), "another identity does not type into this terminal"); + assert!(closed(&acts).is_empty(), "and does not close it"); + assert!(acts.iter().all(|e| !matches!(e, Effect::TerminalReplay { .. })), "and is not sent its output"); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Closed, "to it, the terminal is simply over"); + assert_eq!( + audits_of(&acts, TerminalAuditWhat::Refused)[0].why.as_deref(), + Some("it is another identity's terminal") + ); +} + +// --- ends --- + +#[test] +fn a_device_withdrawn_from_the_allowlist_loses_its_terminals_at_once() { + let mut world = World::new(); + let (other, other_device) = world.other_device(); + let mine = world.open(); + let theirs = world.open_from(&other); + // The other device also writes into this one's terminal. + world.command_from(&other, json!({"v":12,"type":"terminal-takeover","term": mine})); + world.rig.take(); + + // Withdrawing the other device is the kill switch for a lost phone: the + // terminal it opened ends at once. + world.allow(&[&world.device.clone()]); + let (acts, msgs) = world.take(); + assert_eq!(closed(&acts), vec![theirs.clone()]); + let ended = exits(&msgs); + assert_eq!(ended[0].term.as_deref(), Some(theirs.as_str())); + assert_eq!(ended[0].reason, TerminalEndReason::Killed); + let rec = &audits_of(&acts, TerminalAuditWhat::Exited)[0]; + assert_eq!(rec.reason, Some(TerminalEndReason::Killed)); + assert_eq!(rec.device.as_deref(), Some(other_device.as_str())); + // The terminal it had only taken over is not the withdrawn device's: it + // stays, with the pen back in the hands of the device that opened it. + let takeover = &audits_of(&acts, TerminalAuditWhat::Takeover)[0]; + assert_eq!(takeover.term, mine); + assert_eq!(takeover.device.as_deref(), Some(world.device.as_str())); + assert!(takeover.why.as_deref().unwrap().contains("withdrawn")); + let told = events(&msgs); + assert_eq!(told.len(), 2); + assert!(told.contains(&(TerminalEventKind::Exited, other_device))); + assert!(told.contains(&(TerminalEventKind::Takeover, world.device.clone()))); + world.rig.input(Input::WorkspaceChanged); + let listed = heartbeat_terminals(&world.messages()); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].term, mine); + assert_eq!(listed[0].writer, world.device); + // And its input types again, without the new writer being told to + // renumber... it was: the pen moved, so its numbering starts at 1. + world.input(&mine, 1, "back\n"); + assert_eq!(typed(&world.acts()), vec![(mine.clone(), "back\n".to_string())]); + + // Withdrawing this device too ends what is left. + world.allow(&[]); + let (acts, _) = world.take(); + assert_eq!(closed(&acts), vec![mine]); +} + +#[test] +fn a_terminal_nobody_shows_is_closed_once_it_has_sat_idle() { + let mut world = World::with(TerminalConfig { enabled: true, idle_secs: 60, ..Default::default() }); + let term = world.open(); + + // Nobody is showing it (the client said so when it granted its key: it + // shows nothing). + world.rig.advance(60_000); + let (acts, msgs) = world.take(); + assert_eq!(closed(&acts), vec![term.clone()]); + let ended = exits(&msgs); + assert_eq!(ended[0].reason, TerminalEndReason::Idle); + let rec = &audits_of(&acts, TerminalAuditWhat::Exited)[0]; + assert_eq!(rec.reason, Some(TerminalEndReason::Idle)); + assert_eq!(rec.duration_ms, Some(60_000), "how long it lived is in the audit"); + assert_eq!(rec.cwd.as_deref(), Some("/w"), "and what it ran in"); + + // One a client is showing outlives the limit, and ends once it stops. + let mut world = World::with(TerminalConfig { enabled: true, idle_secs: 60, ..Default::default() }); + let term = world.open(); + world.presence(Some(&term)); + world.rig.take(); + world.rig.advance(60_000); + let (acts, msgs) = world.take(); + assert!(closed(&acts).is_empty(), "a client is watching the output"); + assert!(exits(&msgs).is_empty()); + world.presence(None); + world.rig.advance(60_000); + let (acts, _) = world.take(); + assert_eq!(closed(&acts), vec![term], "and once it stops, the limit runs from here"); +} + +#[test] +fn a_shell_that_ends_is_told_to_everyone_with_its_code() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.rig.input(Input::TerminalExited { term: term.clone(), code: Some(0), reason: TerminalEndReason::Exit }); + let (acts, msgs) = world.take(); + let ended = exits(&msgs); + assert_eq!((ended[0].term.as_deref(), ended[0].code), (Some(term.as_str()), Some(0))); + assert_eq!(ended[0].reason, TerminalEndReason::Exit); + assert_eq!(events(&msgs), vec![(TerminalEventKind::Exited, world.device.clone())]); + assert!(closed(&acts).is_empty(), "the shell is already gone: nothing to hang up"); + let rec = &audits_of(&acts, TerminalAuditWhat::Exited)[0]; + assert_eq!((rec.reason, rec.code), (Some(TerminalEndReason::Exit), Some(0))); + assert_eq!(rec.shell.as_deref(), Some("/bin/bash")); + assert_eq!(rec.duration_ms, Some(0)); +} + +#[test] +fn hanging_up_is_a_closed_terminal_and_the_runtime_is_told_to_hang_up() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.command(json!({"v":12,"type":"terminal-close","term": term})); + let (acts, msgs) = world.take(); + assert_eq!(closed(&acts), vec![term.clone()]); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Closed); + assert_eq!(events(&msgs), vec![(TerminalEventKind::Closed, world.device.clone())]); + assert_eq!(audits_of(&acts, TerminalAuditWhat::Closed)[0].reason, Some(TerminalEndReason::Closed)); +} + +#[test] +fn unpairing_an_identity_closes_what_its_devices_were_running() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.rig.input(Input::Unpaired { identity: world.rig.phone.pubkey_hex.clone() }); + let (acts, msgs) = world.take(); + assert_eq!(closed(&acts), vec![term.clone()]); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Killed); + assert!(world.rig.engine.paired_phones().is_empty(), "and it is heard from no more"); + assert!(acts.iter().any(|e| matches!(e, Effect::Resubscribe))); +} + +#[test] +fn a_bridge_shutting_down_ends_its_terminals_and_audits_each() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + world.rig.input(Input::Shutdown); + let (acts, msgs) = world.take(); + assert_eq!(exits(&msgs)[0].reason, TerminalEndReason::Restart); + let rec = &audits_of(&acts, TerminalAuditWhat::Exited)[0]; + assert_eq!((rec.term.as_str(), rec.reason), (term.as_str(), Some(TerminalEndReason::Restart))); + assert!(closed(&acts).is_empty(), "the ptys die with the bridge"); +} + +#[test] +fn the_runtime_failing_to_start_a_shell_answers_the_open_it_never_became() { + let mut world = World::new(); + world.command(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (term, cwd) = world.spawned().unwrap(); + assert_eq!(cwd, "/w"); + world.rig.input(Input::TerminalSpawnFailed { term: term.clone(), note: "no pty".into() }); + + let (acts, msgs) = world.take(); + let refusal = exits(&msgs); + assert_eq!(refusal[0].open_id.as_deref(), Some("o1"), "the phone's own open id comes back"); + assert_eq!(refusal[0].reason, TerminalEndReason::Transport); + let rec = &audits_of(&acts, TerminalAuditWhat::Refused)[0]; + assert_eq!(rec.why.as_deref(), Some("no pty")); + assert_eq!(rec.cwd.as_deref(), Some("/w"), "where it would have run is still recorded"); + // Nothing is left holding a slot. + assert!(heartbeat_terminals(&{ world.rig.input(Input::WorkspaceChanged); world.messages() }).is_empty()); +} + +// --- devices --- + +#[test] +fn a_device_survives_its_keys_rotation_but_a_new_client_is_a_new_device() { + let mut world = World::new(); + let first = world.device.clone(); + let next = generate_keypair(); + let bridge = world.rig.bridge.pubkey_hex.clone(); + let phone = world.rig.phone.clone(); + world.rig.phone_event_via_key( + &phone, + &world.key, + json!({"v":12,"type":"session-key","sessionKey":{ + "pubkeyHex": next.pubkey_hex, "bridgePubkeyHex": bridge, + "expiresAt": NOW_SECS + 90 * DAY, "replaces": world.key.pubkey_hex, "label": "Pixel 8 Pro"}}), + Via::Direct, + ); + world.rig.take(); + + let clients = &world.rig.engine.paired_phones()[0].clients; + assert_eq!(clients.len(), 1, "a rotation is not a new client"); + assert_eq!(clients[0].device, first, "the device is the FIRST key this client granted"); + assert_eq!(clients[0].label.as_deref(), Some("Pixel 8 Pro"), "the name follows the latest grant"); + // The device is still the one on the allowlist, so its terminal still + // works with the new key. + let key = world.key.clone(); + world.key = next; + world.command(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (acts, _) = world.take(); + assert!(acts.iter().any(|e| matches!(e, Effect::TerminalSpawn { .. })), "the key rotated under the same device"); + let _ = key; + + // A second client of the same identity is a device of its own. + let (_, other_device) = world.other_device(); + assert_ne!(other_device, world.device); +} + +#[test] +fn a_state_file_from_before_devices_existed_still_hydrates() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + // What an older bridge stored: clients with keys and no device. + let phones = json!([{"npub": world.rig.phone.npub, "pubkeyHex": world.rig.phone.pubkey_hex, + "label":"Pixel", "pairedAt":"t", + "clients":[{"key":{"pubkeyHex": world.key.pubkey_hex, "bridgePubkeyHex": world.rig.bridge.pubkey_hex, + "expiresAt": NOW_SECS + 90 * DAY, "label":"Pixel 8"}}]}]); + world.rig.store.put("pairedPhones", &phones.to_string()); + let mut restarted = world.rig.restart(); + restarted.take(); + let clients = &restarted.engine.paired_phones()[0].clients; + assert_eq!(clients[0].device, device_id(&world.key.pubkey_hex), "derived from the key it holds"); + // Written back, so the name survives the next rotation. + assert!(restarted.store.snapshot()["pairedPhones"].contains(&device_id(&world.key.pubkey_hex))); + let _ = term; +} diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 0adfa313..5e16cbeb 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -295,6 +295,22 @@ impl Runtime { }); } Effect::Stopped => return true, + // The engine decides terminals and the runtime serves them: the + // ptys, the per-terminal ring of output, the allowlist file and + // the audit. None of that is here, and with terminals switched off + // — the only state a configuration leaves them in — the engine + // emits none of these effects. + Effect::TerminalSpawn { .. } + | Effect::TerminalWrite { .. } + | Effect::TerminalResize { .. } + | Effect::TerminalReplay { .. } + | Effect::TerminalClose { .. } => {} + Effect::TerminalPending(pending) => { + log::debug!("[Runtime] {} device(s) are waiting for terminal approval", pending.len()); + } + Effect::Audit(record) => { + log::debug!("[Runtime] terminal audit line: {:?} {}", record.what, record.term); + } } false } From 0f85fd8162e20838663873b6f744a97513de8107 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:31:54 +0200 Subject: [PATCH 08/50] feat(bridge-core): read a phone's keystrokes without spending a command's dedup slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal input rides the live kind, and a typing phone sends an event per key a program reacts to and per pause. Remembering those ids in the command dedup set — the last 1000 — would push every command id out of it within minutes, and a command id that has aged out is a command a relay's replay window runs a second time. What stops a replayed keystroke from typing twice is the terminal's own `seq`, which the engine checks per terminal, so live input is read on a path of its own that remembers nothing and does not move the cursor the command subscription resumes from. The five-minute age limit still applies. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/phone.rs | 18 +++++- crates/bridge-core/src/ingest.rs | 85 +++++++++++++++++++++++--- crates/bridge-core/tests/terminal.rs | 38 ++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 6bca16dc..1a750498 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -88,7 +88,23 @@ impl Engine { return; } let keys = self.payload_keys(&event.pubkey, now_secs); - let Some((msg, key)) = self.ingest.accept(event, &self.config.keys, &keys, now_secs) else { return }; + // A phone's keystrokes ride the live kind. Their per-terminal + // `seq` is what keeps a replayed one from typing twice, so + // they must not take a slot in the command dedup set: a burst + // of typing would push command ids out of it and reopen the + // replay window that remembering them closes. + let accepted = if event.kind == protocol::kinds::LIVE_KIND { + self.ingest.accept_live(event, &self.config.keys, &keys, now_secs) + } else { + self.ingest.accept(event, &self.config.keys, &keys, now_secs) + }; + let Some((msg, key)) = accepted else { return }; + // The live kind carries terminal input from a phone and + // nothing else. + if event.kind == protocol::kinds::LIVE_KIND && !matches!(msg, PhoneToBridge::TerminalInput(_)) { + log::info!("[Engine] Ignoring a live event that is not terminal input"); + return; + } log::info!("[Engine] Received {} from {}...", type_name(&msg), short(&event.pubkey)); let client = Addressee { phone: event.pubkey.clone(), key: key.clone() }; // Any command says its client is running; a `presence` also diff --git a/crates/bridge-core/src/ingest.rs b/crates/bridge-core/src/ingest.rs index 70c7be60..767ae3f1 100644 --- a/crates/bridge-core/src/ingest.rs +++ b/crates/bridge-core/src/ingest.rs @@ -14,6 +14,9 @@ //! a reconnect subscribes with `since` = last seen − 5 s, so the relay //! replays recent commands, and without the ids they would run twice //! (duplicate sessions, re-sent input). +//! - Terminal keystrokes take the live kind and [`Ingest::accept_live`] +//! instead: their per-terminal `seq` is the dedup, and remembering their +//! ids here would age command ids out of the set (see below). //! - The pairing window's subscription has no author filter, so it attracts //! junk: anything that does not decrypt is dropped silently and only then //! takes a dedup slot, and only a `pair-request` gets through. @@ -82,6 +85,9 @@ impl Ingest { /// keys, newest first: the payload is encrypted with one of them or with /// the author's identity. Returns the command and the key it was /// encrypted with: which client sent it. + /// + /// The event id is remembered, so a relay replaying it runs nothing + /// twice. Keystrokes take [`Self::accept_live`] instead. pub fn accept( &mut self, event: &InboundEvent, @@ -89,13 +95,61 @@ impl Ingest { session_keys: &[String], now_secs: u64, ) -> Option<(PhoneToBridge, String)> { - if event.created_at + MAX_EVENT_AGE_SECS < now_secs { - log::info!("[Ingest] Ignoring stale event ({}s old)", now_secs - event.created_at); + if self.stale(event, now_secs) { return None; } + // Remembered before it is read, as it always was: a paired phone's + // junk takes a slot rather than being read again on every redelivery. if !self.mark(&event.id) { return None; } + let opened = self.open(event, keys, session_keys); + if opened.is_some() { + self.last_seen = self.last_seen.max(event.created_at); + } + opened + } + + /// A phone's keystrokes, which ride the live kind: the same event, read + /// without remembering its id. + /// + /// They must not take a dedup slot. A typing phone sends an event per key + /// a program reacts to and per pause, so a few minutes at a terminal + /// would push every command id out of the set of + /// [`MAX_PROCESSED_IDS`] — and a command id that has aged out is a + /// command a relay's replay window runs a second time. What stops a + /// replayed keystroke from typing twice is the terminal's own `seq`, + /// which the engine checks per terminal. The age limit still applies: an + /// old event is not typed. + /// + /// The cursor does not move either: it is where the command subscription + /// resumes from, and a keystroke is not a command. + pub fn accept_live( + &mut self, + event: &InboundEvent, + keys: &Keypair, + session_keys: &[String], + now_secs: u64, + ) -> Option<(PhoneToBridge, String)> { + if self.stale(event, now_secs) { + return None; + } + self.open(event, keys, session_keys) + } + + /// Whether an event is too old to act on: a relay that does not honour + /// `since` must not replay old commands (or type old keystrokes). + fn stale(&self, event: &InboundEvent, now_secs: u64) -> bool { + if event.created_at + MAX_EVENT_AGE_SECS < now_secs { + log::info!("[Ingest] Ignoring stale event ({}s old)", now_secs - event.created_at); + return true; + } + false + } + + /// Decrypt and decode an event from the standing subscription: the + /// command it carries, and the key its payload came under. + fn open(&self, event: &InboundEvent, keys: &Keypair, session_keys: &[String]) -> Option<(PhoneToBridge, String)> { let opened = session_keys .iter() .chain(std::iter::once(&event.pubkey)) @@ -104,15 +158,13 @@ impl Ingest { log::info!("[Ingest] Failed to decrypt event {}...", short(&event.id)); return None; }; - let msg = match protocol::decode_phone_to_bridge(&plaintext) { - Ok(msg) => msg, + match protocol::decode_phone_to_bridge(&plaintext) { + Ok(msg) => Some((msg, key)), Err(err) => { log::info!("[Ingest] Dropping invalid payload from {}...: {err}", short(&event.pubkey)); - return None; + None } - }; - self.last_seen = self.last_seen.max(event.created_at); - Some((msg, key)) + } } /// An event from the pairing window's subscription. @@ -240,6 +292,23 @@ mod tests { assert_eq!(f.ingest.processed_ids().len(), MAX_PROCESSED_IDS); } + #[test] + fn live_input_is_read_without_taking_a_dedup_slot_or_moving_the_cursor() { + let mut f = Fixture::new(); + let ev = f.event(INPUT); + assert!(f.ingest.accept_live(&ev, &f.bridge, &[], NOW).is_some()); + // Read again, and again: nothing was remembered, so the same event + // reads every time — the terminal's own seq, not this set, is what + // stops a replayed keystroke from typing twice. + assert!(f.ingest.accept_live(&ev, &f.bridge, &[], NOW).is_some()); + assert!(f.ingest.processed_ids().is_empty()); + assert_eq!(f.ingest.last_seen(), 0, "the command cursor does not move"); + // The age limit still holds. + let phone = f.phone.clone(); + let old = f.event_from(&phone, INPUT, NOW - MAX_EVENT_AGE_SECS - 1); + assert!(f.ingest.accept_live(&old, &f.bridge, &[], NOW).is_none()); + } + #[test] fn a_persisted_dedup_set_survives_a_restart() { let mut f = Fixture::new(); diff --git a/crates/bridge-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs index 2ae7b73f..a09febd8 100644 --- a/crates/bridge-core/tests/terminal.rs +++ b/crates/bridge-core/tests/terminal.rs @@ -1031,3 +1031,41 @@ fn a_state_file_from_before_devices_existed_still_hydrates() { assert!(restarted.store.snapshot()["pairedPhones"].contains(&device_id(&world.key.pubkey_hex))); let _ = term; } + +// --- the command dedup set --- + +#[test] +fn terminal_input_never_evicts_a_command_id_from_the_dedup_set() { + let mut world = World::new(); + let term = world.open(); + let client_phone = world.rig.phone.clone(); + let key = world.key.clone(); + let phone = world.rig.phone.clone(); + world.rig.take(); + + // A command the relay may well replay: its id is remembered so the replay + // runs nothing. The heartbeat is how it shows: a refresh answers the + // client that asked. + let command = world.rig.phone_event_via_key(&phone, &key, json!({"v":12,"type":"refresh-sessions"}), Via::Direct); + assert_eq!(world.messages().len(), 1, "the refresh is answered"); + let since = world.rig.engine.commands_filter().since; + + for i in 0..2000 { + world.rig.live_event( + &client_phone, + &key, + json!({"v":12,"type":"terminal-input","term": term, "seq": i + 1, "data": STANDARD.encode("x")}), + Via::Direct, + ); + world.rig.take(); + } + + // The keystrokes typed, and moved nothing else: the cursor the command + // subscription resumes from is where the command left it. + assert_eq!(world.rig.engine.commands_filter().since, since); + // And the command's id is still remembered: the same event runs nothing a + // second time. + let via = Via::Direct; + world.rig.input(Input::RelayEvent { event: command, via }); + assert!(world.messages().is_empty(), "a replayed command is still a no-op"); +} From 9b708a5df069f24933d967caa2040657d171481c Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:38:42 +0200 Subject: [PATCH 09/50] fix(bridge-core): a device's terminal seq never starts over, and held input is capped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal kept one `last_seq` for the whole terminal and reset it to 0 on a takeover, so a device's seq only meant anything while it held the pen. Device A typed 1..5, B took over and began its own count at 1, A took the pen back and began at 1 again: a relay replaying A's earlier signed `terminal-input` events — which ingest still accepts up to 300 s old — typed them into the shell a second time. The count is now per device (`Terminal.last_seq`, a map by device id) and is never reset. A device's seq on a terminal only ever grows, so nothing it sent before can be typed again whoever wrote in between, and a takeover tells the new writer the seq its own count resumes at through `terminal-input-from` — the same message a writer gets when the bridge waits on a gap. Applying, flushing, the dedup and the gap all read the current writer's entry; a device that never typed is at 0, so its first input is seq 1. Held input was unbounded as well: `hold` took any out-of-order seq, so a writer could send seq last+1_000_000, or thousands of events, and spend the bridge's memory. A seq more than MAX_HELD_INPUTS (256) ahead of the writer's last applied is now not held at all, and what is held is capped at MAX_HELD_INPUTS entries and MAX_HELD_INPUT_BYTES (1 MiB). On either overflow everything held is dropped, the gap timer cancelled, the writer is told to resend from its last applied + 1, and a Refused audit line says what overflowed — the path the gap timeout already took, now shared as `resend_from`. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/terminal.rs | 150 ++++++++++++++----- crates/bridge-core/tests/terminal.rs | 173 +++++++++++++++++++++- docs/PROTOCOL.md | 17 ++- docs/TERMINAL.md | 27 ++-- 4 files changed, 312 insertions(+), 55 deletions(-) diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs index 7193729a..c212dadc 100644 --- a/crates/bridge-core/src/engine/terminal.rs +++ b/crates/bridge-core/src/engine/terminal.rs @@ -26,9 +26,12 @@ //! (audited, and told to every present client) — a message naming a //! terminal that is gone, or is another identity's, is answered as one that //! is over, so no client can probe for terminals that are not its own; -//! - `seq` counts up by one within one writer's run: a takeover hands the pen -//! over with the number the new writer starts at, and a seq already applied -//! types nothing twice, which is what makes a replayed keystroke harmless; +//! - a device's `seq` on a terminal only ever grows: it counts what that +//! device has typed there and is kept per device, so a takeover hands the +//! pen over with the number the new writer resumes at and nothing a device +//! sent before can be typed again whoever wrote in between — which is what +//! makes a replayed keystroke harmless. A phone keeps one counter per +//! terminal and resumes from the seq `terminal-input-from` names; //! - output goes only to the clients showing that terminal (`presence` //! `watching_terminal`), which is also what keeps it from sitting idle. @@ -63,6 +66,14 @@ pub const INPUT_GAP_WAIT_MS: u64 = 2_000; /// event well below this, so it is a backstop against a future way in (a /// chunked or unencrypted path) rather than a limit typing can meet. pub const MAX_INPUT_BYTES: usize = 64 * 1024; +/// The most inputs one terminal holds waiting for a gap, by count. A writer +/// whose input is this far out of order is not one whose missing event is +/// still in flight: holding the rest would only be a way to spend the +/// bridge's memory, so it is asked to resend instead. +pub const MAX_HELD_INPUTS: usize = 256; +/// The most bytes those held inputs may add up to, for the same reason: a +/// keystroke run is a few bytes, and 1 MiB of them is a flood, not typing. +pub const MAX_HELD_INPUT_BYTES: usize = 1 << 20; /// How many devices are kept waiting for the owner's approval. The least /// recently seen beyond this are dropped: a phone that keeps trying is not a /// reason for the list to grow without end. @@ -164,7 +175,8 @@ pub struct TerminalAuditRecord { /// How long it lived, for an end. pub duration_ms: Option, /// What a refusal refused (input that is not base64, input of a size no - /// typing has, a seq that never arrived): the reason alone would not say. + /// typing has, a seq that never arrived, or one too far ahead to be part + /// of a run): the reason alone would not say. pub why: Option, } @@ -249,8 +261,12 @@ struct Terminal { opened_ms: u64, /// How the opener's command arrived, for later audit lines about it. via: TerminalVia, - /// The last `seq` applied: input at or below it types nothing twice. - last_seq: u64, + /// The last `seq` applied, by the device that sent it: a device's own + /// count of what it has typed here, which only ever grows. Keeping it per + /// device — and never resetting it, not even for a takeover — is what + /// stops a device's earlier keystrokes from being typed a second time + /// when it takes the pen back. A device that never typed is at 0. + last_seq: BTreeMap, /// Input that arrived ahead of the one before it, by seq: held, not /// typed, until the gap fills or the wait runs out. pending: BTreeMap>, @@ -260,6 +276,19 @@ struct Terminal { idle_timer: Option, } +impl Terminal { + /// The last `seq` `device` got typed here, and so the seq its next one + /// must be. A device that never typed is at 0: its first input is seq 1. + fn last_seq_of(&self, device: &str) -> u64 { + self.last_seq.get(device).copied().unwrap_or(0) + } + + /// The same for the device writing now. + fn last_seq(&self) -> u64 { + self.last_seq_of(&self.writer) + } +} + /// A `terminal-open` the runtime has been asked to start and has yet to /// answer. It counts against the limit (the shell is coming) and is listed /// nowhere until it runs. @@ -377,10 +406,13 @@ impl Terminals { } } - /// Type `bytes` as `seq`, the one after the last applied. + /// Type `bytes` as `seq`, the one after the last applied, and remember + /// that the writer got this far: the count is the writer's own, and it + /// never goes back. fn apply(&mut self, term: &str, seq: u64, bytes: Vec, out: &mut Out) { let Some(t) = self.open.get_mut(term) else { return }; - t.last_seq = seq; + let writer = t.writer.clone(); + t.last_seq.insert(writer, seq); out.push(Effect::TerminalWrite { term: term.to_string(), bytes }); } @@ -388,13 +420,34 @@ impl Terminals { /// missing seq may still be in flight (a relay reordering two events, a /// phone's event arriving late), and typing the later one first would put /// its characters in the wrong order. The first hold arms the wait. - fn hold(&mut self, term: &str, seq: u64, bytes: Vec, out: &mut Out) { - let Some(t) = self.open.get_mut(term) else { return }; + /// + /// What is held is bounded, because a writer can send any seq it likes: a + /// seq too far ahead to be a run with a hole in it, or more held inputs + /// or bytes than a run of typing could be, is not held at all. `Some` is + /// the reason, which the caller turns into asking the writer to resend — + /// holding it would only be a way to spend the bridge's memory. + fn hold(&mut self, term: &str, seq: u64, bytes: Vec, out: &mut Out) -> Option { + let t = self.open.get_mut(term)?; + let last = t.last_seq(); + if seq > last.saturating_add(MAX_HELD_INPUTS as u64) { + return Some(format!("input at seq {seq} is more than {MAX_HELD_INPUTS} ahead of seq {last}")); + } t.pending.insert(seq, bytes); + // The ahead-of rule above already keeps the count under this — one + // window holds one fewer than its width — so this is the backstop for + // a writer that gets past it some other way, not the limit. + if t.pending.len() > MAX_HELD_INPUTS { + return Some(format!("more than {MAX_HELD_INPUTS} inputs held")); + } + let held: usize = t.pending.values().map(Vec::len).sum(); + if held > MAX_HELD_INPUT_BYTES { + return Some(format!("more than {MAX_HELD_INPUT_BYTES} bytes of input held")); + } if t.gap_timer.is_none() { let id = out.set_timer(INPUT_GAP_WAIT_MS, TimerKind::TerminalGap { term: term.to_string() }); t.gap_timer = Some(id); } + None } /// Type everything held behind the gap that just filled, in order, up to @@ -402,12 +455,13 @@ impl Terminals { fn flush_pending(&mut self, term: &str, out: &mut Out) { loop { let next = match self.open.get(term) { - Some(t) if !t.pending.is_empty() => t.last_seq + 1, + Some(t) if !t.pending.is_empty() => t.last_seq() + 1, _ => return, }; let Some(t) = self.open.get_mut(term) else { return }; let Some(bytes) = t.pending.remove(&next) else { break }; - t.last_seq = next; + let writer = t.writer.clone(); + t.last_seq.insert(writer, next); out.push(Effect::TerminalWrite { term: term.to_string(), bytes }); } let Some(t) = self.open.get_mut(term) else { return }; @@ -708,7 +762,7 @@ impl Engine { cwd: open.cwd.clone(), opened_ms: now, via: open.via, - last_seq: 0, + last_seq: BTreeMap::new(), pending: BTreeMap::new(), gap_timer: None, idle_timer, @@ -789,10 +843,12 @@ impl Engine { log::info!("[Engine] terminal-input for {} from a device that is not its writer — dropped", m.term); return; } - // A seq already applied types nothing twice: a signed keystroke event - // a relay replays must not re-type, and a phone resending what it was + // A seq already applied by this device types nothing twice: a signed + // keystroke event a relay replays must not re-type — not even after + // another device has written in between, which is why the count is + // kept per device and never reset — and a phone resending what it was // asked for overlaps what did arrive. - if m.seq <= t.last_seq { + if m.seq <= t.last_seq() { return; } let bytes = match STANDARD.decode(&m.data) { @@ -800,14 +856,15 @@ impl Engine { Ok(bytes) => return self.refuse_input(&m.term, &actor, m.seq, &format!("input of {} bytes", bytes.len())), Err(_) => return self.refuse_input(&m.term, &actor, m.seq, "input that is not base64"), }; - let expected = self.terminals.open.get(&m.term).map(|t| t.last_seq + 1).unwrap_or(0); + let expected = self.terminals.open.get(&m.term).map(|t| t.last_seq() + 1).unwrap_or(0); if m.seq == expected { self.terminals.apply(&m.term, m.seq, bytes, &mut self.out); self.terminals.flush_pending(&m.term, &mut self.out); - } else { - // Ahead of the one before it: held for the wait, then the phone is - // asked for what is missing. - self.terminals.hold(&m.term, m.seq, bytes, &mut self.out); + } else if let Some(why) = self.terminals.hold(&m.term, m.seq, bytes, &mut self.out) { + // Held input past its cap, or a seq no run with a hole in it could + // reach: what is held is dropped and the writer is asked for the + // run it is missing, which covers every held seq. + self.resend_from(&m.term, why); } } @@ -825,15 +882,32 @@ impl Engine { /// the gap, and what was held behind it is dropped — the phone resends /// everything from the gap, which covers every held seq. pub(super) fn on_terminal_gap(&mut self, term: &str) { + let seq = { + let Some(t) = self.terminals.open.get_mut(term) else { return }; + t.gap_timer = None; + if t.pending.is_empty() { + return; + } + t.last_seq() + 1 + }; + self.resend_from(term, format!("seq {seq} never arrived")); + } + + /// Drop what a terminal holds and ask its writer to resend from its own + /// next seq: the seq the terminal is waiting on, which is the writer's + /// last applied plus one — the numbering is that device's own, so a + /// device that has just taken the pen resumes from where it left off. + /// `why` is what the audit records: a gap that never filled, or input + /// held past what a run of typing could be. + fn resend_from(&mut self, term: &str, why: String) { let Some(t) = self.terminals.open.get_mut(term) else { return }; - t.gap_timer = None; - if t.pending.is_empty() { - return; - } t.pending.clear(); - let seq = t.last_seq + 1; + if let Some(id) = t.gap_timer.take() { + self.out.cancel_timer(id); + } + let seq = t.last_seq() + 1; let (identity, writer) = (t.identity.clone(), t.writer.clone()); - log::info!("[Engine] terminal {term} is missing input from seq {seq}; asking its writer to resend"); + log::info!("[Engine] terminal {term} asks its writer to resend from seq {seq}: {why}"); if let Some(key) = self.terminal_key_of(&identity, &writer) { self.out.publish_addressed( vec![Addressee { phone: identity.clone(), key }], @@ -844,7 +918,7 @@ impl Engine { let mut rec = TerminalAuditRecord::new(self.now(), term.to_string(), TerminalAuditWhat::Refused, identity); rec.device = Some(writer); rec.label = label; - rec.why = Some(format!("seq {seq} never arrived")); + rec.why = Some(why); self.audit(rec); } @@ -940,25 +1014,31 @@ impl Engine { self.end_terminal(&m.term, TerminalEndReason::Closed, None, Some(&actor)); } - /// Hand the pen to `device`: its `seq` starts over. + /// Hand the pen to `device`, and tell it the seq its own count resumes + /// at. /// - /// A `seq` is one writer's own count of what it has typed, so a new writer - /// must not have its first keystrokes dropped as ones already applied. It - /// is told where its numbering starts (`terminal-input-from`), the same - /// message a writer gets when the bridge is waiting on a gap. + /// A `seq` is one device's own count of what it has typed on this + /// terminal, so it only ever grows: nothing is reset here, because a + /// device that wrote before — then lost the pen and got it back — must + /// not have what it already sent typed again, and its numbering must not + /// make the bridge drop its next keystrokes as ones already applied. + /// Which is why it is told where to resume (`terminal-input-from`), the + /// same message a writer gets when the bridge is waiting on a gap. What + /// does not survive the hand-over is the input held for the old writer's + /// gap: that writer is not the one who will resend it. fn hand_writer(&mut self, term: &str, device: &str) -> bool { let Some(t) = self.terminals.open.get_mut(term) else { return false }; t.writer = device.to_string(); - t.last_seq = 0; t.pending.clear(); if let Some(id) = t.gap_timer.take() { self.out.cancel_timer(id); } let identity = t.identity.clone(); + let seq = t.last_seq_of(device) + 1; if let Some(key) = self.terminal_key_of(&identity, device) { self.out.publish_addressed( vec![Addressee { phone: identity, key }], - BridgeToPhone::TerminalInputFrom(TerminalInputFromMsg { term: term.to_string(), seq: 1 }), + BridgeToPhone::TerminalInputFrom(TerminalInputFromMsg { term: term.to_string(), seq }), ); } true diff --git a/crates/bridge-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs index a09febd8..a1cb69f8 100644 --- a/crates/bridge-core/tests/terminal.rs +++ b/crates/bridge-core/tests/terminal.rs @@ -621,8 +621,9 @@ fn a_takeover_moves_the_pen_and_starts_the_new_writer_at_one() { let rec = &audits_of(&acts, TerminalAuditWhat::Takeover)[0]; assert_eq!(rec.device.as_deref(), Some(other_device.as_str())); assert_eq!(rec.label.as_deref(), Some("Tablet")); - // The new writer is told where its numbering starts, so its first - // keystrokes are not dropped as ones already applied. + // The new writer is told where its own numbering resumes, so its first + // keystrokes are not dropped as ones already applied. This device has + // never typed into this terminal, so it resumes at 1. assert_eq!(input_from(&msgs), vec![(term.clone(), 1)]); world.input_from(&other, &term, 1, "yours\n"); @@ -645,6 +646,170 @@ fn a_takeover_moves_the_pen_and_starts_the_new_writer_at_one() { assert_eq!(listed[0].device, world.device, "the opener is still the opener"); } +#[test] +fn a_devices_seq_never_starts_over_so_its_earlier_keystrokes_cannot_be_replayed() { + let mut world = World::new(); + let (other, _) = world.other_device(); + let term = world.open(); + + // A types its run, 1 to 3. + for (seq, text) in [(1, "one"), (2, "two"), (3, "three")] { + world.input(&term, seq, text); + } + assert_eq!(typed(&world.acts()).len(), 3); + world.rig.take(); + + // B takes the pen and types a run of its own, from 1. + world.command_from(&other, json!({"v":12,"type":"terminal-takeover","term": term})); + let (_, msgs) = world.take(); + assert_eq!(input_from(&msgs), vec![(term.clone(), 1)], "B has typed nothing here yet"); + world.input_from(&other, &term, 1, "b-one"); + world.input_from(&other, &term, 2, "b-two"); + assert_eq!(typed(&world.acts()).len(), 2); + world.rig.take(); + + // A takes it back: its own count resumes where it left off, not at 1. + world.command(json!({"v":12,"type":"terminal-takeover","term": term})); + let (_, msgs) = world.take(); + assert_eq!(input_from(&msgs), vec![(term.clone(), 4)]); + + // A relay replaying A's earlier signed keystrokes now types nothing — + // the seq it applies is A's own, so a device's run cannot be typed twice + // whoever wrote in between... + world.input(&term, 1, "one"); + world.input(&term, 2, "two"); + world.input(&term, 3, "three"); + assert!(typed(&world.acts()).is_empty(), "a device's seq never starts over"); + // ...and what A sends next does. + world.input(&term, 4, "four"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "four".to_string())]); +} + +#[test] +fn a_device_taking_the_pen_back_resumes_from_its_own_last_seq() { + let mut world = World::new(); + let (other, _) = world.other_device(); + let term = world.open(); + + // B's first takeover of a terminal it has never typed in: seq 1. + world.command_from(&other, json!({"v":12,"type":"terminal-takeover","term": term})); + assert_eq!(input_from(&world.messages()), vec![(term.clone(), 1)]); + world.input_from(&other, &term, 1, "b1"); + world.input_from(&other, &term, 2, "b2"); + assert_eq!(typed(&world.acts()).len(), 2); + + // A takes the pen and types a run of its own, and B gets it back: B + // resumes from its own 3, not from 1 again. + world.command(json!({"v":12,"type":"terminal-takeover","term": term})); + assert_eq!(input_from(&world.messages()), vec![(term.clone(), 1)], "A has typed nothing here yet"); + world.input(&term, 1, "a1"); + assert_eq!(typed(&world.acts()).len(), 1); + world.command_from(&other, json!({"v":12,"type":"terminal-takeover","term": term})); + assert_eq!(input_from(&world.messages()), vec![(term.clone(), 3)]); + world.input_from(&other, &term, 3, "b3"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "b3".to_string())]); +} + +#[test] +fn a_seq_too_far_ahead_to_be_a_gap_is_not_held() { + let mut world = World::new(); + let term = world.open(); + world.input(&term, 1, "one"); + world.rig.take(); + + // One behind a gap of one: held, and the wait armed. + world.input(&term, 3, "three"); + assert!(typed(&world.acts()).is_empty(), "3 waits for 2"); + + // A seq no run with a hole in it could reach: not held at all — holding + // it, and everything a writer could pile up behind it, would only be a + // way to spend the bridge's memory. + world.input(&term, 1_000_000, "far ahead"); + let (acts, msgs) = world.take(); + assert!(typed(&acts).is_empty()); + assert_eq!(input_from(&msgs), vec![(term.clone(), 2)], "resend from the one it is missing"); + let rec = &audits_of(&acts, TerminalAuditWhat::Refused)[0]; + assert!(rec.why.as_deref().unwrap().contains("ahead"), "{:?}", rec.why); + assert_eq!(rec.device.as_deref(), Some(world.device.as_str()), "and who sent it"); + assert_eq!(rec.reason, None, "the terminal is not over, only its input"); + + // What was held went with it, and the wait is over: the timer that would + // have asked for the gap sends nothing. + world.rig.advance(2_000); + let (acts, msgs) = world.take(); + assert!(input_from(&msgs).is_empty(), "the gap timer was cancelled with what was held"); + assert!(audits_of(&acts, TerminalAuditWhat::Refused).is_empty()); + + // The writer resends what it was asked for, and typing goes on from there. + world.input(&term, 2, "two"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "two".to_string())]); +} + +#[test] +fn held_input_past_its_byte_cap_is_dropped_and_the_writer_is_asked_to_resend() { + let mut world = World::new(); + let term = world.open(); + world.input(&term, 1, "one"); + world.rig.take(); + + // Each of these is a valid input on its own — 40 KiB of typing in one + // event, as large as the encrypted wire carries at all — and 25 of them + // are just under the 1 MiB a terminal holds, which is still held. + let big = "x".repeat(40 * 1024); + for seq in 3..=27u64 { + world.input(&term, seq, &big); + } + let acts = world.acts(); + assert!(typed(&acts).is_empty(), "none of it is typed out of order"); + assert!(audits_of(&acts, TerminalAuditWhat::Refused).is_empty(), "just under 1 MiB is still held"); + + // The 26th is past it: nothing more is held, and the writer is asked for + // the run it is missing. + world.input(&term, 28, &big); + let (acts, msgs) = world.take(); + assert!(typed(&acts).is_empty()); + assert_eq!(input_from(&msgs), vec![(term.clone(), 2)]); + let rec = &audits_of(&acts, TerminalAuditWhat::Refused)[0]; + assert!(rec.why.as_deref().unwrap().contains("bytes"), "{:?}", rec.why); + assert_eq!(rec.device.as_deref(), Some(world.device.as_str())); + + // Nothing is left held and no wait is running: the timer that would have + // asked for the gap sends nothing. + world.rig.advance(2_000); + assert!(input_from(&world.messages()).is_empty()); + world.input(&term, 2, "two"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "two".to_string())]); +} + +#[test] +fn a_full_window_of_held_input_is_kept_and_one_past_it_is_not() { + let mut world = World::new(); + let term = world.open(); + world.rig.take(); + + // Every seq the ahead-of rule lets in, and no more: 2..=256 is a window + // of 256, so the count cap is never met before that rule is — it is the + // backstop, not the limit. + for seq in 2..=256u64 { + world.input(&term, seq, &seq.to_string()); + } + let acts = world.acts(); + assert!(typed(&acts).is_empty(), "they all wait for 1"); + assert!(audits_of(&acts, TerminalAuditWhat::Refused).is_empty(), "a full window is still held"); + + // One past the window is refused like any other seq too far ahead. + world.input(&term, 257, "past the window"); + let (acts, msgs) = world.take(); + assert!(typed(&acts).is_empty()); + assert_eq!(input_from(&msgs), vec![(term.clone(), 1)], "the terminal is still waiting on 1"); + assert!(audits_of(&acts, TerminalAuditWhat::Refused)[0].why.as_deref().unwrap().contains("ahead")); + + // And the window that was in flight went with the refusal: the writer is + // asked for it again and types from where it was told to. + world.input(&term, 1, "one"); + assert_eq!(typed(&world.acts()), vec![(term.clone(), "one".to_string())]); +} + // --- who is told what --- #[test] @@ -851,8 +1016,8 @@ fn a_device_withdrawn_from_the_allowlist_loses_its_terminals_at_once() { assert_eq!(listed.len(), 1); assert_eq!(listed[0].term, mine); assert_eq!(listed[0].writer, world.device); - // And its input types again, without the new writer being told to - // renumber... it was: the pen moved, so its numbering starts at 1. + // And its input types again: the pen moved back, and this device has + // never typed into that terminal, so its numbering starts at 1. world.input(&mine, 1, "back\n"); assert_eq!(typed(&world.acts()), vec![(mine.clone(), "back\n".to_string())]); diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index eee2f10f..e1238ce2 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -298,13 +298,16 @@ opens a real shell on the bridge's machine: shell}`, or `terminal-exited` with `openId` and a refusal `reason` (`limit`, `not-allowed`, `disabled`). - `terminal-input {term, seq, data}` types into it, writer only. `seq` counts - up by one from 1 per terminal: the bridge applies input in that order and - drops a `seq` it has already applied, so a relay replaying a signed event - types nothing twice. `data` is base64 of the bytes (a key press need not be - valid UTF-8). It rides **LIVE_KIND (24515)**, ephemeral like the output — - a keystroke is never stored on a relay. A gap the bridge waited for and did - not get comes back as `terminal-input-from {term, seq}`: resend from - `seq`, the first one that never arrived. + up by one from 1 **per terminal and per device**: the bridge applies input + in that order and drops a `seq` it has already applied, and a device's + count only ever grows — a takeover does not start it over — so a relay + replaying a signed event types nothing twice however many writers came in + between. `data` is base64 of the bytes (a key press need not be valid + UTF-8). It rides **LIVE_KIND (24515)**, ephemeral like the output — a + keystroke is never stored on a relay. `terminal-input-from {term, seq}` + says resend from `seq`: the first one that never arrived, or — to a device + that has just taken the pen — the seq its own count resumes at, which the + phone adopts. - `terminal-output {term, offset, data}` is what the shell wrote, `data` base64 and `offset` the byte offset of its first byte in everything this terminal has output. Also LIVE_KIND, and like `partial` it goes ONLY to the diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 6d8262bc..5c9169f5 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -68,7 +68,9 @@ needs a name that survives rotation, for the allowlist and the audit: on Windows `pwsh`, else `powershell`, else `cmd`. - **One writer.** Only the device that opened a terminal types into it. Another device attaches read-only and can take over explicitly - (`terminal-takeover`), which is audited and moves the writer. + (`terminal-takeover`), which is audited and moves the writer. The new + writer's `seq` resumes where its own last one ended — a takeover never + restarts a device's count. ## Capability @@ -89,15 +91,22 @@ When relays are allowed, a terminal falls back to them like any message. stored on a relay**. Phone→bridge input on the live kind is new: the direct link accepts it besides commands (today it accepts only 4515), and, with relays allowed, the bridge subscribes to it from its paired phones. -- **Every input carries `seq`**, per terminal, counting up by one from 1. The - bridge applies input in order, drops a `seq` it has already applied (a - relay's replay of a signed keystroke event re-types nothing), and holds a - later one for up to 2 s waiting for the gap, then asks the phone to resend - from the gap (`terminal-input-from`). Terminal input does **not** go +- **Every input carries `seq`**, per terminal **and per device**, counting up + by one from 1. The bridge applies input in order, drops a `seq` it has + already applied (a relay's replay of a signed keystroke event re-types + nothing), and holds a later one for up to 2 s waiting for the gap, then + asks the phone to resend from the gap (`terminal-input-from`). A device's + `seq` on a terminal only ever grows: a takeover does not start it over, so + nothing that device sent before can be typed again whoever wrote in + between. The `terminal-input-from` a takeover sends names the seq that + device resumes at — the phone keeps one counter per terminal and takes the + number it is given. Held input is capped at 256 inputs / 1 MiB; past + either, or past a seq no run with a hole in it could reach, what is held is + dropped and the writer is asked to resend. Terminal input does **not** go through the command dedup set (the 1000 most recent ids, `ingest.rs`): a burst of keystrokes would push command ids out of it and reopen a replay - window for commands. The per-terminal `seq` is its dedup, and the - 300-second age limit still applies. + window for commands. The per-terminal, per-device `seq` is its dedup, and + the 300-second age limit still applies. - **Output is coalesced**: the bridge flushes at most every 16 ms on the direct link and every 100 ms on relays, or sooner past 16 KiB pending. Larger flushes split by the existing `chunk` fragmentation. @@ -145,7 +154,7 @@ All carry `v` like every message. `term` is a terminal id the bridge assigns. | `terminal-open` | phone → bridge | `openId` (the phone's own, echoed back), `cols`, `rows`, `session?` (start in its directory) | | `terminal-opened` | bridge → phone | `openId`, `term`, `shell` (what runs, e.g. `/bin/bash`) | | `terminal-input` | phone → bridge | `term`, `seq`, `data` (base64 of the bytes — a key press need not be valid UTF-8) | -| `terminal-input-from` | bridge → phone | `term`, `seq` (resend from here) | +| `terminal-input-from` | bridge → phone | `term`, `seq` (resend from here; on a takeover, the seq this device's own count resumes at) | | `terminal-output` | bridge → phone | `term`, `offset`, `data` (base64) | | `terminal-resize` | phone → bridge | `term`, `cols`, `rows` (writer only) | | `terminal-replay` | phone → bridge | `term`, `from` (byte offset; attaches read-only) | From b4a7acb0114ec498baa5430104967d5039255a41 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:47:44 +0200 Subject: [PATCH 10/50] fix(bridge-core): a heartbeat names only the receiving identity's own terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal belongs to the identity that opened it: only that identity's clients may act on it, and everything told about it goes to them. The session-list heartbeat broke that, listing every open terminal to every paired identity — so one phone learned another identity's terminal id, its shell, and which device was running it. `terminal_infos` now takes an identity and returns only its terminals. The heartbeat is built once without a terminal list: identities owning none share that single publish, exactly as before, and each identity owning at least one gets a copy of its own. The addressed path groups the addressees by identity and sends each group the copy for it. Every copy of one heartbeat keeps the same `rev` — it is one session list. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/mod.rs | 59 ++++++++++++-- crates/bridge-core/src/engine/terminal.rs | 14 +++- crates/bridge-core/tests/terminal.rs | 97 +++++++++++++++++++++++ crates/protocol/src/events.rs | 10 ++- docs/PROTOCOL.md | 7 +- docs/TERMINAL.md | 7 +- 6 files changed, 179 insertions(+), 15 deletions(-) diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 45922231..4258765f 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -36,7 +36,7 @@ use std::collections::BTreeMap; use protocol::capabilities::{BridgeHostKind, ALL_BRIDGE_CAPABILITIES, TERMINAL}; use protocol::common::{OutputEntry, RemoteSessionInfo, SessionState}; use protocol::crypto::Keypair; -use protocol::events::{BridgeToPhone, OutputMsg, SessionListMsg}; +use protocol::events::{BridgeToPhone, OutputMsg, SessionListMsg, TerminalInfo}; use crate::catalog::Catalog; use crate::ingest::{since_for_connect, CommandsFilter, Ingest}; @@ -611,6 +611,17 @@ impl Engine { /// The session-list heartbeat: sessions, agents, credentials, /// capabilities, folders and tombstones. To every present client, or /// only to `to`. + /// + /// A terminal belongs to the identity that opened it, and only that + /// identity's clients may act on it, so its `terminals` list holds the + /// receiving identity's own terminals and no other identity ever hears of + /// them — not their ids, their shells, nor which device runs them. The + /// list is built once without terminals and goes out unchanged to every + /// identity that owns none (one publish, as before); each identity that + /// owns at least one gets a copy of its own. The cost is therefore one + /// extra heartbeat event per identity with a terminal open, and none for + /// the others. Every copy of one heartbeat carries the same `rev`: it is + /// the same session list. fn publish_list(&mut self, offline: bool, to: Option>) { self.list_dirty = false; if self.paired.is_empty() { @@ -635,8 +646,10 @@ impl Engine { if self.terminals_enabled() { capabilities.push(TERMINAL.to_string()); } - let terminals = self.terminal_infos(); - let message = BridgeToPhone::Sessions(SessionListMsg { + // Built once, without terminals: an identity that owns none gets this + // one unchanged, and each identity that owns some gets a copy of its + // own. Every copy keeps this `rev` — they are one list. + let base = SessionListMsg { machine: self.config.machine.clone(), host: self.config.host_kind, sessions, @@ -648,12 +661,44 @@ impl Engine { removed_sessions: (!removed.is_empty()).then_some(removed), machine_offline: offline.then_some(true), direct: self.config.direct.clone(), - terminals: (!terminals.is_empty()).then_some(terminals), + terminals: None, rev: Some(rev), - }); + }; match to { - Some(to) => self.out.publish_addressed(to, message), - None => self.publish_all(message), + // One copy per identity asked, carrying that identity's own + // terminals; several clients of one identity share it. + Some(to) => { + let mut by_phone: BTreeMap> = BTreeMap::new(); + for addressee in to { + by_phone.entry(addressee.phone.clone()).or_default().push(addressee); + } + for (identity, group) in by_phone { + let own = self.terminal_infos(&identity); + let mut list = base.clone(); + list.terminals = (!own.is_empty()).then_some(own); + self.out.publish_addressed(group, BridgeToPhone::Sessions(list)); + } + } + None => { + let mut shared = Vec::new(); + let mut owners: Vec<(String, Vec)> = Vec::new(); + for phone in self.phones() { + let own = self.terminal_infos(&phone); + if own.is_empty() { + shared.push(phone); + } else { + owners.push((phone, own)); + } + } + for (identity, own) in owners { + let mut list = base.clone(); + list.terminals = Some(own); + self.out.publish(vec![identity], BridgeToPhone::Sessions(list)); + } + // Identities owning no terminal share one publish: the cost + // of a terminal is not paid by a phone that has none. + self.out.publish(shared, BridgeToPhone::Sessions(base)); + } } } diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs index c212dadc..53a8f012 100644 --- a/crates/bridge-core/src/engine/terminal.rs +++ b/crates/bridge-core/src/engine/terminal.rs @@ -484,12 +484,20 @@ impl Engine { self.terminals.enabled() } - /// The terminals open now, as the heartbeat lists them, so a phone that - /// comes back (or a second phone) sees what is running and can attach. - pub(crate) fn terminal_infos(&self) -> Vec { + /// The terminals open now that belong to `identity`, as the heartbeat + /// lists them to that identity, so a phone that comes back (or a second + /// phone of the same identity) sees what is running and can attach. + /// + /// A terminal belongs to the identity that opened it, and only that + /// identity's clients may act on it: a heartbeat never names another + /// identity's terminal — not its id, its shell, nor the device running + /// it — which is what keeps one paired phone from learning what another + /// is doing on the machine. + pub(crate) fn terminal_infos(&self, identity: &str) -> Vec { self.terminals .open .iter() + .filter(|(_, t)| t.identity == identity) .map(|(id, t)| TerminalInfo { term: id.clone(), shell: t.shell.clone(), diff --git a/crates/bridge-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs index a1cb69f8..67fc1284 100644 --- a/crates/bridge-core/tests/terminal.rs +++ b/crates/bridge-core/tests/terminal.rs @@ -129,6 +129,11 @@ impl World { self.rig.phone_event_via_key(&phone, key, msg, Via::Direct); } + /// The same, from another paired identity's client. + fn command_as(&mut self, phone: &Keypair, key: &Keypair, msg: Value) { + self.rig.phone_event_via_key(phone, key, msg, Via::Direct); + } + /// The same, over a relay. fn command_via_relay(&mut self, msg: Value) { let phone = self.rig.phone.clone(); @@ -334,6 +339,21 @@ fn heartbeat_capabilities(msgs: &[Published]) -> Vec { .collect() } +/// What each heartbeat listed, as `(identity it went to, its terminals)`: one +/// entry per addressed identity, since several clients of one identity share +/// a copy. +fn heartbeat_terminals_by_identity(msgs: &[Published]) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + for (to, m) in msgs { + let BridgeToPhone::Sessions(s) = m else { continue }; + let terms = s.terminals.clone().unwrap_or_default(); + for addressee in to { + out.push((addressee.phone.clone(), terms.clone())); + } + } + out +} + // --- the gates --- #[test] @@ -383,6 +403,83 @@ fn the_heartbeat_advertises_terminals_and_lists_what_runs() { assert!(only(&msgs).iter().any(|m| matches!(m, BridgeToPhone::Sessions(s) if s.terminals.is_none()))); } +#[test] +fn a_heartbeat_names_only_the_identity_that_opened_the_terminal() { + let mut world = World::new(); + let other_phone = generate_keypair(); + let other_client = generate_keypair(); + world.pair_identity(&other_phone, &other_client, "Other's phone"); + + // With no terminal open anywhere, a heartbeat is still one publish to + // every identity: the second identity costs nothing while it has none. + world.rig.input(Input::WorkspaceChanged); + let msgs = world.messages(); + assert_eq!(msgs.len(), 1, "one publish, as before"); + assert_eq!(msgs[0].0.len(), 2, "addressed to both identities"); + assert!(matches!(&msgs[0].1, BridgeToPhone::Sessions(s) if s.terminals.is_none())); + + // A opens one. A's heartbeat names it; the other identity's names + // neither it nor the device running it. + let term = world.open(); + world.rig.input(Input::WorkspaceChanged); + let msgs = world.messages(); + let listed = heartbeat_terminals_by_identity(&msgs); + let mine = listed.iter().find(|(phone, _)| phone == &world.rig.phone.pubkey_hex).expect("A is sent a heartbeat"); + assert_eq!(mine.1.len(), 1); + assert_eq!(mine.1[0].term, term); + assert_eq!(mine.1[0].shell, "/bin/bash"); + assert_eq!(mine.1[0].device, world.device); + let theirs = listed.iter().find(|(phone, _)| phone == &other_phone.pubkey_hex).expect("B is sent a heartbeat"); + assert!(theirs.1.is_empty(), "B is told nothing of A's terminal"); + assert!( + only(&msgs).iter().any(|m| matches!(m, BridgeToPhone::Sessions(s) if s.terminals.is_none())), + "B's copy omits the field rather than carrying an empty list" + ); + // Two heartbeats now — A's own copy, and the one shared by the identities + // owning none — and both carry the same revision: it is one list. + assert_eq!(msgs.len(), 2); + let revs: Vec = msgs + .iter() + .filter_map(|(_, m)| match m { + BridgeToPhone::Sessions(s) => s.rev, + _ => None, + }) + .collect(); + assert_eq!(revs.len(), 2); + assert_eq!(revs[0], revs[1]); +} + +#[test] +fn an_identity_asking_for_a_list_is_answered_with_its_own_terminals_only() { + let mut world = World::new(); + let term = world.open(); + + let other_phone = generate_keypair(); + let other_client = generate_keypair(); + world.pair_identity(&other_phone, &other_client, "Other's phone"); + world.rig.take(); + + // The identity with no terminal asks for a list of its own: it is answered + // alone, and the answer names no terminal. + world.command_as(&other_phone, &other_client, json!({"v":12,"type":"refresh-sessions"})); + let (_, msgs) = world.take(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].0.len(), 1, "the asking client alone"); + assert_eq!(msgs[0].0[0].phone, other_phone.pubkey_hex); + let BridgeToPhone::Sessions(answer) = &msgs[0].1 else { panic!("a heartbeat") }; + assert!(answer.terminals.is_none(), "B is told nothing of A's terminal"); + + // The identity that opened it asks: its copy names its own terminal. + world.command(json!({"v":12,"type":"refresh-sessions"})); + let (_, msgs) = world.take(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].0[0].phone, world.rig.phone.pubkey_hex); + let BridgeToPhone::Sessions(answer) = &msgs[0].1 else { panic!("a heartbeat") }; + let own = answer.terminals.clone().expect("A's own terminal is listed"); + assert_eq!(own.len(), 1); + assert_eq!(own[0].term, term); +} + #[test] fn a_relay_may_not_carry_a_terminal_unless_the_owner_allows_it() { let mut world = World::new(); diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 5e6999e8..5fd9c469 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -43,8 +43,11 @@ pub struct SessionListMsg { /// [`crate::direct`]). Absent when it serves no direct link. #[serde(default, skip_serializing_if = "Option::is_none")] pub direct: Option, - /// The terminals open on this bridge now: what a phone shows without - /// having opened one itself. Absent or empty when there are none. + /// The terminals open on this bridge that belong to the identity this + /// list is addressed to. A terminal is only ever told to the identity + /// that opened it, so no other identity's heartbeat names one — not its + /// id, its shell, nor the device running it. Absent or empty when the + /// receiving identity has none. #[serde(default, skip_serializing_if = "Option::is_none")] pub terminals: Option>, /// The list's revision: strictly greater in each list the bridge @@ -508,7 +511,8 @@ pub struct ProviderProfileAckMsg { // --- terminals --- -/// One terminal open on the bridge, as the heartbeat's `terminals` lists it. +/// One terminal open on the bridge, as the heartbeat's `terminals` lists it +/// to the identity that opened it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct TerminalInfo { diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index e1238ce2..10365b78 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -328,7 +328,12 @@ opens a real shell on the bridge's machine: not act learns at once — takeovers and closes are audited this way. - The heartbeat's `terminals[]` (`TerminalInfo {term, shell, opened, device, name?, writer, cols, rows}`) lists what is open, so a phone shows one it - did not open. + did not open. It lists only the receiving identity's OWN terminals: a + terminal belongs to the identity that opened it, so another identity's + heartbeat does not name it at all — not its id, its shell, nor the device + running it. An identity with a terminal open receives a heartbeat event of + its own, carrying the same `rev` as the shared list; an identity with none + receives the shared list unchanged. - **The device id** names a client across its key rotations: the first 16 bytes, in hex, of `sha256("codedeck/device\0" + first session key + "\0")`, the FIRST key the client granted this bridge — the one its `replaces` diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 5c9169f5..28feb55e 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -166,7 +166,12 @@ All carry `v` like every message. `term` is a terminal id the bridge assigns. The open terminals ride the session list the bridge already publishes (`term`, `shell`, `opened`, `device`, `name`, `writer`, `cols`, `rows`), so a phone that comes back (or a second phone) sees them and can attach with -`terminal-replay {from: 0}`. +`terminal-replay {from: 0}`. That list holds **only the receiving identity's +own terminals**: a terminal belongs to the identity that opened it, so no +other paired identity's heartbeat names it — not its id, its shell, nor the +device running it. An identity with a terminal open gets a heartbeat event of +its own; one with none gets the single shared list, so the cost of a terminal +is paid only by the identity that has it. **Every other device is told.** `terminal-event` goes to each present client of the identity, not just the one acting, and the phone shows it as a notice From 9795edc2b0baa45ce8566bdcf28ae4d745e21444 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:53:45 +0200 Subject: [PATCH 11/50] build: the lockfile records base64 as a bridge-core dependency The terminal engine decodes a phone's base64 keystrokes in bridge-core, which added the crate to its manifest; CI builds with --locked, which refuses a lockfile that would have to change. Co-Authored-By: Claude Code --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 7299deb1..0e5c18ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,7 @@ name = "bridge-core" version = "1.3.0-rc4" dependencies = [ "agent-protocol", + "base64 0.22.1", "log", "protocol", "regex", From 7de356335c33bfe7e45938433e3020f086486104 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:17:58 +0200 Subject: [PATCH 12/50] build(bridge-runtime): portable-pty for real terminals, and the nix it reads termios from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal needs a real pty — a unix pty, ConPTY on Windows — and, on unix, the pty's termios to tell whether the shell is echoing what is typed: input typed with echo off is a password, and must not reach the recording. portable-pty 0.9 builds against nix 0.28, which is the version pinned here: its `get_termios` returns *that* `Termios`, and a different nix would be a different type, so the ECHO flag could not be read off it. Co-Authored-By: Claude Code --- Cargo.lock | 126 ++++++++++++++++++++++++++++++- crates/bridge-runtime/Cargo.toml | 12 +++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e5c18ec..04b226c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,8 +390,10 @@ dependencies = [ "futures-util", "hex", "log", + "nix", "nostr", "nostr-transport", + "portable-pty", "protocol", "qrcode", "rand 0.8.8", @@ -485,6 +487,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -743,6 +751,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.18.0" @@ -816,6 +830,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1328,6 +1353,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -1411,6 +1442,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1579,6 +1622,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1640,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -1681,7 +1745,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", @@ -2083,6 +2147,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2105,6 +2180,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -2913,6 +3004,28 @@ dependencies = [ "nom", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -3010,6 +3123,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/crates/bridge-runtime/Cargo.toml b/crates/bridge-runtime/Cargo.toml index 5f9c9cae..64832dc8 100644 --- a/crates/bridge-runtime/Cargo.toml +++ b/crates/bridge-runtime/Cargo.toml @@ -48,6 +48,18 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring", rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +# Terminals (src/terminal.rs): a real pty per terminal the phone opens — a +# unix pty, ConPTY on Windows — so interactive programs behave as they do +# over ssh. The runtime owns it; the engine only decides. +portable-pty = "0.9" + +# The pty's termios, to tell whether the shell is echoing what is typed +# (a password prompt): input typed with echo off is recorded as its byte +# count, never its bytes. Pinned to the nix portable-pty 0.9 builds against: +# `get_termios` returns *its* `Termios`, and a different nix would be a +# different type. +[target.'cfg(unix)'.dependencies] +nix = { version = "0.28", features = ["term"] } [dev-dependencies] tempfile = "3" From 41c161c6c30557b293dec8638df496a238b89f57 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:18:09 +0200 Subject: [PATCH 13/50] feat(bridge): run terminals in real ptys, with a ring of their output and a recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine decides everything a terminal does — who may open one, who may type, what order input goes in — and emits effects, but nothing carried them out: the terminal arms in the event loop were empty. This is the other half. `crates/bridge-runtime/src/terminal` owns the ptys, the per-terminal ring of output and the recording; `runtime.rs` wires the arms to it. Spawn. The owner's configured `terminal.command` if there is one, else `$SHELL` or `/bin/sh` on unix, `pwsh`/`powershell`/`cmd` on Windows. The shell runs with a built environment — `env_clear` first, because a `CommandBuilder` seeds itself with the bridge's own environment, which can hold the agents' provider keys. A terminal is a shell as the bridge's user, so the bridge's own context is not the shell's to have. Output. One reader thread per pty hands bytes to a coalescer that delivers `Input::TerminalOutput` at most every 16 ms, or sooner past 16 KiB pending (the transport coalesces again per link). A 256 KiB ring addresses every byte by its offset in the terminal's whole output, so a phone that reconnects, or sees a gap, can replay from where it left off; a replay goes only to the client the engine named, and an oversize one is fragmented by the transport like any other message. Recording. `/audit/term-.cast`, asciicast v2 (`0700` directory, `0600` file on unix): every output frame, every input chunk, every resize. Input typed while the pty's echo is off is recorded as its size and never its bytes — a password prompt is exactly what must not be written to disk — and where no echo state can be read (a ConPTY) input is a byte count whatever it was, since nothing can say. Ending. A shell that exits is reaped by its reader thread, which then reports `Input::TerminalExited`; the runtime drops the pty without signalling it again, because by then the pid is free and could belong to another process. A `TerminalClose` signals the shell, and dropping the bridge kills every shell it started — a terminal is not meant to outlive the bridge that owns it. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/lib.rs | 1 + crates/bridge-runtime/src/runtime.rs | 56 +- crates/bridge-runtime/src/terminal/cast.rs | 186 ++++++ crates/bridge-runtime/src/terminal/mod.rs | 700 +++++++++++++++++++++ crates/bridge-runtime/src/terminal/ring.rs | 164 +++++ 5 files changed, 1098 insertions(+), 9 deletions(-) create mode 100644 crates/bridge-runtime/src/terminal/cast.rs create mode 100644 crates/bridge-runtime/src/terminal/mod.rs create mode 100644 crates/bridge-runtime/src/terminal/ring.rs diff --git a/crates/bridge-runtime/src/lib.rs b/crates/bridge-runtime/src/lib.rs index 4a1ed520..6734c928 100644 --- a/crates/bridge-runtime/src/lib.rs +++ b/crates/bridge-runtime/src/lib.rs @@ -14,6 +14,7 @@ pub mod qr; pub mod relay; pub mod runtime; pub mod state; +pub mod terminal; pub mod transcripts; pub mod work; pub mod workspace; diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 5e16cbeb..f5790d66 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -11,10 +11,13 @@ use std::io::Write; use std::path::PathBuf; use std::rc::Rc; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; use bridge_core::ports::{Ports, System}; use bridge_core::{Config as EngineConfig, Effect, Engine, Input, NotifyLevel, PairingCloseReason, PairingWindowInfo, TimerId}; use protocol::commands::UploadFileMsg; use protocol::crypto::Keypair; +use protocol::events::{BridgeToPhone, TerminalOutputMsg}; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -22,6 +25,7 @@ use crate::config::Config; use crate::host::{self, HostCommand, HostHandle}; use crate::relay::Relays; use crate::state::StateFile; +use crate::terminal::Terminals; use crate::transcripts::FileTranscripts; use crate::workspace::FsWorkspace; use crate::uploads::{self, Uploads}; @@ -96,6 +100,10 @@ struct Runtime { uploads: Rc>, pairing_urls: Option>, direct: Option, + /// The ptys behind the terminals the engine decided to open, with their + /// rings of output and their recordings. Empty unless the owner turned + /// terminals on: the engine emits no terminal effect otherwise. + terminals: Terminals, } fn say(line: &str) { @@ -149,6 +157,7 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio } let nostr_http = work::NostrHttp::new(config.tor_proxy.as_deref())?; + let terminals = Terminals::new(config.home.join("audit"), inputs.clone()); let mut rt = Runtime { engine: Engine::new(engine_config, ports), config, @@ -163,6 +172,7 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio uploads: Rc::new(RefCell::new(Uploads::new(&first_root))), pairing_urls: options.pairing_urls, direct, + terminals, }; rt.step(Input::Start).await; @@ -208,6 +218,12 @@ impl Runtime { /// Handle one input; true once the engine has stopped and everything is /// flushed. async fn step(&mut self, input: Input) -> bool { + // The shell is gone: the pty is dropped before the engine hears + // about it. `forget` does not signal it — it has already exited, and + // by then its pid may belong to another process. + if let Input::TerminalExited { term, .. } = &input { + self.terminals.forget(term); + } let effects = self.engine.handle(input); for effect in effects { if self.execute(effect) { @@ -296,15 +312,37 @@ impl Runtime { } Effect::Stopped => return true, // The engine decides terminals and the runtime serves them: the - // ptys, the per-terminal ring of output, the allowlist file and - // the audit. None of that is here, and with terminals switched off - // — the only state a configuration leaves them in — the engine - // emits none of these effects. - Effect::TerminalSpawn { .. } - | Effect::TerminalWrite { .. } - | Effect::TerminalResize { .. } - | Effect::TerminalReplay { .. } - | Effect::TerminalClose { .. } => {} + // ptys, the per-terminal ring of output and the recordings + // (`crate::terminal`). With terminals switched off — the only + // state a configuration leaves them in — the engine emits none of + // these effects, so a bridge without terminals starts no shell. + Effect::TerminalSpawn { term, cwd, cols, rows } => { + // `None`: the owner has configured no `terminal.command`, so + // the shell runs. A failure is answered as a refusal, not + // left as a terminal that never appears. + let result = self.terminals.spawn(&term, &cwd, cols, rows, None); + let _ = match result { + Ok(shell) => self.inputs.send(Input::TerminalSpawned { term, shell }), + Err(note) => self.inputs.send(Input::TerminalSpawnFailed { term, note }), + }; + } + Effect::TerminalWrite { term, bytes } => self.terminals.write(&term, &bytes), + Effect::TerminalResize { term, cols, rows } => self.terminals.resize(&term, cols, rows), + Effect::TerminalReplay { term, from, client } => { + // The engine named the one client this goes to (a terminal's + // output is not a broadcast); the runtime only serves the + // ring it keeps. An oversize replay is split into `chunk` + // fragments by the transport, like any other message. + if let Some((offset, data)) = self.terminals.replay(&term, from) { + let message = BridgeToPhone::TerminalOutput(TerminalOutputMsg { + term, + offset, + data: STANDARD.encode(data), + }); + self.relays.publish(vec![client], message); + } + } + Effect::TerminalClose { term } => self.terminals.close(&term), Effect::TerminalPending(pending) => { log::debug!("[Runtime] {} device(s) are waiting for terminal approval", pending.len()); } diff --git a/crates/bridge-runtime/src/terminal/cast.rs b/crates/bridge-runtime/src/terminal/cast.rs new file mode 100644 index 00000000..7746d095 --- /dev/null +++ b/crates/bridge-runtime/src/terminal/cast.rs @@ -0,0 +1,186 @@ +//! The recording of one terminal, in asciicast v2 — replayable with +//! `asciinema play`, and readable only by the bridge's user. +//! +//! A recording is a line per thing that happened: the header, then output +//! (`o`), input (`i`) and resizes (`r`), each with the seconds since the +//! recording started. It is written under `/audit/` (`0700`, file +//! `0600` on unix), the same place the terminal audit log lives. +//! +//! What a command printed can be a secret (`cat .env`), and so can what was +//! typed — a password prompt turns the terminal's echo off, and the bytes a +//! person types then are the one thing that must never reach the file. So the +//! caller checks the pty's echo state before recording a chunk and records a +//! byte count instead of the bytes when it is off. When the state cannot be +//! read at all it is treated as off: losing a keystroke to a placeholder is a +//! small loss, writing a password to disk is not. + +#[cfg(unix)] +use std::fs::DirBuilder; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +/// The directory recordings live in, created `0700` like the bridge's own +/// state, so nothing but the bridge's user can list them. +#[cfg(unix)] +const DIR_MODE: u32 = 0o700; +/// A recording's own mode: it may hold anything a command printed. +#[cfg(unix)] +const FILE_MODE: u32 = 0o600; + +/// One terminal's recording. +pub struct Cast { + out: BufWriter, + /// Event times are seconds since this, as asciicast v2 wants. + started: Instant, +} + +impl Cast { + /// Open `/` and write the v2 header. The directory is created + /// if it is not there, and both carry the modes above on unix. + pub fn open(path: &Path, cols: u16, rows: u16) -> std::io::Result { + if let Some(dir) = path.parent() { + create_dir(dir)?; + } + let file = open_file(path)?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let mut cast = Self { out: BufWriter::new(file), started: Instant::now() }; + // The header is an object, not an event: the terminal's size as it + // opened, and when that was. + writeln!(cast.out, "{{\"version\":2,\"width\":{cols},\"height\":{rows},\"timestamp\":{timestamp}}}")?; + cast.out.flush()?; + Ok(cast) + } + + /// What the terminal wrote, as text. A pty's output is a byte stream that + /// need not be valid UTF-8 (a program can print half a sequence, or + /// arbitrary bytes); asciicast carries text, so it is lossily decoded — + /// the phone redraws from the same bytes it was sent, and the recording + /// is for a person reading it later. + pub fn output(&mut self, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes).into_owned(); + self.event("o", &text); + } + + /// What was typed, when the terminal was echoing: the shell's own echo + /// will also appear in the output, which is how asciicast normally reads. + /// (On Windows there is no echo state to read, so nothing calls this.) + #[cfg_attr(windows, allow(dead_code))] + pub fn input(&mut self, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes).into_owned(); + self.event("i", &text); + } + + /// What was typed while the terminal had echo off — a password. Only its + /// size is kept: the bytes are exactly what the terminal was hiding. + #[cfg_attr(windows, allow(dead_code))] + pub fn input_hidden(&mut self, bytes: usize) { + self.event("i", &format!("[input hidden: {bytes} bytes]")); + } + + /// What was typed where the platform gives no echo state to read (a + /// ConPTY): the bytes are not recorded, and neither is whether they were + /// hidden, because nothing can say. + #[cfg_attr(unix, allow(dead_code))] + pub fn input_count(&mut self, bytes: usize) { + self.event("i", &format!("[input: {bytes} bytes]")); + } + + /// The writer's window changed. + pub fn resize(&mut self, cols: u16, rows: u16) { + self.event("r", &format!("{cols}x{rows}")); + } + + /// Append one `[seconds, code, data]` event. Flushed per event so a + /// bridge that dies mid-session still leaves everything up to that point + /// readable. + fn event(&mut self, code: &str, data: &str) { + let at = self.started.elapsed().as_secs_f64(); + let line = serde_json::json!([at, code, data]).to_string(); + if writeln!(self.out, "{line}").and_then(|_| self.out.flush()).is_err() { + // A recording that cannot be written must not take the terminal + // down with it: the shell keeps running, and the terminal audit + // log still says what happened. + log::warn!("[Terminal] could not append to a recording"); + } + } +} + +#[cfg(unix)] +fn create_dir(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + match DirBuilder::new().mode(DIR_MODE).create(dir) { + Ok(()) => Ok(()), + // Already there: keep whatever the owner set, like the state file. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e), + } +} + +#[cfg(not(unix))] +fn create_dir(dir: &Path) -> std::io::Result<()> { + match std::fs::create_dir(dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e), + } +} + +#[cfg(unix)] +fn open_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new().write(true).create(true).truncate(true).mode(FILE_MODE).open(path) +} + +#[cfg(not(unix))] +fn open_file(path: &Path) -> std::io::Result { + // Windows has no unix modes; the file inherits the directory's ACL. + OpenOptions::new().write(true).create(true).truncate(true).open(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(path: &Path) -> Vec { + std::fs::read_to_string(path).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect() + } + + #[test] + fn writes_a_v2_header_and_one_event_per_thing_that_happened() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("audit").join("term-x.cast"); + let mut cast = Cast::open(&path, 80, 24).unwrap(); + cast.output(b"hello"); + cast.input(b"ls\n"); + cast.input_hidden(8); + cast.resize(100, 40); + + let lines = lines(&path); + assert_eq!(lines[0]["version"], 2); + assert_eq!(lines[0]["width"], 80); + assert_eq!(lines[0]["height"], 24); + assert!(lines[0]["timestamp"].as_u64().unwrap() > 0); + assert_eq!(lines[1], serde_json::json!([lines[1][0], "o", "hello"])); + assert_eq!(lines[2], serde_json::json!([lines[2][0], "i", "ls\n"])); + assert_eq!(lines[3], serde_json::json!([lines[3][0], "i", "[input hidden: 8 bytes]"])); + assert_eq!(lines[4], serde_json::json!([lines[4][0], "r", "100x40"])); + // Times only move forward, and the first is at or after zero. + let times: Vec = lines[1..].iter().map(|l| l[0].as_f64().unwrap()).collect(); + assert!(times.windows(2).all(|w| w[0] <= w[1])); + } + + #[cfg(unix)] + #[test] + fn the_recording_and_its_directory_are_the_bridge_users_alone() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("audit").join("term-y.cast"); + Cast::open(&path, 80, 24).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let dir_mode = std::fs::metadata(dir.path().join("audit")).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700); + } +} diff --git a/crates/bridge-runtime/src/terminal/mod.rs b/crates/bridge-runtime/src/terminal/mod.rs new file mode 100644 index 00000000..bd34754d --- /dev/null +++ b/crates/bridge-runtime/src/terminal/mod.rs @@ -0,0 +1,700 @@ +//! The bridge's terminals: the pty a phone types into, the ring of its +//! output, and the recording of what happened in it. +//! +//! The engine decides everything a terminal does — who may open one, who may +//! type, what order input goes in — and emits effects; this module is the +//! other half, the part that owns a real pty and a real child process. It +//! starts a shell in a pseudo-terminal (a unix pty, ConPTY on Windows, so an +//! interactive program behaves as it does over ssh), reads what the shell +//! writes, keeps the last 256 KiB of it for a phone that comes back, and +//! writes a recording of the whole session under the bridge's home. +//! +//! Two things shape the design: +//! +//! - **The shell runs with a built environment, never the bridge's.** The +//! bridge's own environment can hold the agents' provider keys — the user +//! exported them for the agents, and only each driver knows which are +//! secrets — so the shell is handed only what a shell needs, and nothing +//! else survives. A terminal is a shell as the bridge's user, which is +//! strictly more than an agent in its most permissive mode, so nothing of +//! the bridge's own context is passed on. +//! - **A pty is a thread.** Reading a pty blocks, and the bridge's event +//! loop must not, so each terminal gets one reader thread that does +//! nothing but read and hand bytes on. Everything else — the ring, the +//! recording, the engine — stays on the event loop. + +mod cast; +mod ring; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use bridge_core::Input; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use protocol::common::TerminalEndReason; +use tokio::sync::mpsc; + +use cast::Cast; +use ring::Ring; + +/// How often a terminal's output is handed to the engine, when it is not +/// already past [`FLUSH_BYTES`]. The engine publishes it to the phones +/// showing the terminal; the transport coalesces again per link (16 ms on +/// the direct link, 100 ms on relays), so this is the first of the two +/// batching steps, and it keeps a chatty program from costing one event per +/// write. +const FLUSH_INTERVAL: Duration = Duration::from_millis(16); +/// Hand output on as soon as this much is pending, however recent the last +/// flush was: past it, waiting only adds latency to what is already a burst. +const FLUSH_BYTES: usize = 16 * 1024; +/// How much a reader thread asks for at a time. +const READ_BYTES: usize = 16 * 1024; + +/// One terminal the bridge is running: the pty, what to write into it, how +/// to end it, and where its output and recording live. +struct Pty { + /// The control end: resizing, and reading the terminal's termios to see + /// whether it is echoing. + master: Box, + /// The input end. Dropping it sends EOF to the shell. + writer: Box, + /// A handle that can signal the child without owning it: the reader + /// thread owns the child (it is the one that waits for it), and this is + /// how the event loop ends a terminal the engine decided is over. + killer: Box, + /// The last of the terminal's output, for a phone that comes back. + ring: Rc>, + /// The session's recording. + cast: Rc>, + /// Set once the shell has been reaped. Signalling a reaped pid is not + /// harmless — the number may belong to another process by then — so + /// [`Drop`] checks it before killing, and the reader thread sets it the + /// moment `wait` returns. + exited: Arc, +} + +impl Drop for Pty { + fn drop(&mut self) { + // The last resort: a pty dropped without an explicit close (the + // bridge going down, a panic unwinding) must not leave its shell + // running with nothing attached to it. + if !self.exited.load(Ordering::SeqCst) { + let _ = self.killer.kill(); + } + } +} + +/// The bridge's terminals, by the id the engine gave each. +/// +/// The engine holds the decisions; this holds the ptys, the rings and the +/// recordings, and drops all of them when the bridge stops — a shell is not +/// meant to outlive the bridge that owns it. +pub struct Terminals { + /// Where the recordings go (`/audit`). + audit_dir: PathBuf, + /// The event loop's channel, for what a terminal reports back. + inputs: mpsc::UnboundedSender, + ptys: HashMap, +} + +impl Terminals { + pub fn new(audit_dir: PathBuf, inputs: mpsc::UnboundedSender) -> Self { + Self { audit_dir, inputs, ptys: HashMap::new() } + } + + /// Start a shell for `term` in `cwd`, `cols`×`rows`, and take it under + /// this bridge's wing. `command` is the owner's configured + /// `terminal.command` (a fixed program such as `tmux new -A -s phone`); + /// `None` runs the shell. `Ok` is what runs, as the phone is told it; + /// `Err` is why it could not be started, for the audit and the phone. + /// + /// The recording is opened before any process exists, and a failure + /// after the shell has started kills it: a shell nothing can reconstruct + /// afterwards, or one left running with no terminal attached to it, is + /// worse than an open that fails. + pub fn spawn( + &mut self, + term: &str, + cwd: &str, + cols: u16, + rows: u16, + command: Option<&[String]>, + ) -> Result { + let (program, args, shell) = shell_command(command); + + let cast = Cast::open(&self.audit_dir.join(format!("term-{term}.cast")), cols, rows) + .map_err(|e| format!("could not open the recording: {e}"))?; + let ring = Rc::new(RefCell::new(Ring::new())); + let cast = Rc::new(RefCell::new(cast)); + + let mut cmd = CommandBuilder::new(&program); + cmd.args(&args); + cmd.cwd(cwd); + apply_env(&mut cmd, &shell); + + let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }; + let pair = native_pty_system().openpty(size).map_err(|e| format!("no pty: {e}"))?; + let child = pair.slave.spawn_command(cmd).map_err(|e| format!("could not start {shell}: {e}"))?; + // The parent's copy of the slave must go now: while it is open the + // master never sees EOF, so a shell that exits would leave the + // reader thread waiting on a terminal that is already gone. + drop(pair.slave); + let mut killer = child.clone_killer(); + let reader = match pair.master.try_clone_reader() { + Ok(reader) => reader, + Err(e) => return Err(kill_first(&mut killer, format!("no reader for the pty: {e}"))), + }; + let writer = match pair.master.take_writer() { + Ok(writer) => writer, + Err(e) => return Err(kill_first(&mut killer, format!("no writer for the pty: {e}"))), + }; + + let exited = Arc::new(AtomicBool::new(false)); + + // The reader thread: read the pty, hand the bytes to the coalescer, + // and when the pty closes, wait for the shell and say it is over. + let (chunks, chunk_rx) = mpsc::unbounded_channel::>(); + let inputs = self.inputs.clone(); + let term_owned = term.to_string(); + let exited_in_thread = Arc::clone(&exited); + let started = std::thread::Builder::new().name(format!("pty-{term}")).spawn(move || { + let mut reader = reader; + let mut child = child; + let mut buf = vec![0u8; READ_BYTES]; + loop { + match reader.read(&mut buf) { + // EOF: the slave side is gone, so the shell is exiting + // (or has been killed). Anything still buffered has + // already been handed on. + Ok(0) => break, + Ok(n) => { + if chunks.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + // Reaping the child is what frees its pid: do it before saying it + // is gone, so nothing can signal that pid again. + let code = child.wait().ok().map(|status| status.exit_code() as i32); + exited_in_thread.store(true, Ordering::SeqCst); + let _ = inputs.send(Input::TerminalExited { term: term_owned, code, reason: TerminalEndReason::Exit }); + }); + if let Err(e) = started { + // The closure (and with it the child handle) was dropped unsent; + // the cloned killer still knows how to end the shell. + return Err(kill_first(&mut killer, format!("could not start a reader thread: {e}"))); + } + + // The coalescer: batch the reader's chunks into the engine's frames. + tokio::task::spawn_local(coalesce(term.to_string(), chunk_rx, Rc::clone(&ring), Rc::clone(&cast), self.inputs.clone())); + + self.ptys.insert(term.to_string(), Pty { master: pair.master, writer, killer, ring, cast, exited }); + Ok(shell) + } + + /// Type `bytes` into `term`, and record it. The engine has already + /// checked the sender is the writer and that nothing earlier is missing. + pub fn write(&mut self, term: &str, bytes: &[u8]) { + let Some(pty) = self.ptys.get_mut(term) else { return }; + if let Err(e) = pty.writer.write_all(bytes).and_then(|()| pty.writer.flush()) { + log::warn!("[Terminal] could not write to {term}: {e}"); + } + record_input(pty, bytes); + } + + /// The writer's window changed. + pub fn resize(&mut self, term: &str, cols: u16, rows: u16) { + let Some(pty) = self.ptys.get_mut(term) else { return }; + if let Err(e) = pty.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) { + log::warn!("[Terminal] could not resize {term}: {e}"); + } + pty.cast.borrow_mut().resize(cols, rows); + } + + /// The output to send a client that attached to `term` from byte `from`: + /// the offset it really starts at (the ring's oldest when `from` is + /// older than what is kept, so the phone learns to reset its screen) and + /// the bytes. `None` when there is nothing to send. + pub fn replay(&self, term: &str, from: u64) -> Option<(u64, Vec)> { + let pty = self.ptys.get(term)?; + let ring = pty.ring.borrow(); + if ring.is_empty() { + return None; + } + let (offset, data) = ring.read_from(from); + (!data.is_empty()).then_some((offset, data)) + } + + /// Hang up `term`: signal the shell and drop the pty. The engine has + /// already ended it; this is the shell's part. + pub fn close(&mut self, term: &str) { + if let Some(mut pty) = self.ptys.remove(term) { + let _ = pty.killer.kill(); + pty.exited.store(true, Ordering::SeqCst); + } + } + + /// Forget `term` without signalling it: the shell has already exited + /// (that is why the engine was told), so its pid must not be signalled + /// again — by the time the reader thread reaped it, the number could + /// belong to another process. + pub fn forget(&mut self, term: &str) { + if let Some(pty) = self.ptys.remove(term) { + pty.exited.store(true, Ordering::SeqCst); + } + } +} + +impl Drop for Terminals { + fn drop(&mut self) { + // The bridge is stopping: every shell it started ends with it. This + // is explicit rather than left to the map being dropped, because the + // reader threads hold nothing of the map — but a shell whose reader + // thread is still blocked in `read` would otherwise outlive us. + for (_, mut pty) in std::mem::take(&mut self.ptys) { + let _ = pty.killer.kill(); + pty.exited.store(true, Ordering::SeqCst); + } + } +} + +/// Record one input chunk, hiding the bytes when the terminal is not echoing +/// them. +/// +/// A shell that turns echo off is asking for something it does not want on +/// screen — a password, a key — and the bytes a person types then are the +/// one thing the recording must never hold. On unix the pty's termios says +/// whether echo is on; where that cannot be read (a ConPTY, or a pty that +/// will not answer) the bytes are not recorded either, because losing a +/// keystroke to a byte count is a small loss and writing a password to disk +/// is not. +fn record_input(pty: &Pty, bytes: &[u8]) { + #[cfg(unix)] + { + let echoing = pty + .master + .get_termios() + .is_some_and(|t| t.local_flags.contains(nix::sys::termios::LocalFlags::ECHO)); + if echoing { + pty.cast.borrow_mut().input(bytes); + } else { + pty.cast.borrow_mut().input_hidden(bytes.len()); + } + } + #[cfg(not(unix))] + { + // A ConPTY exposes no echo state, so what was typed is recorded as a + // byte count whatever it was: the shell's own echo still shows it in + // the output where it was echoed at all. + pty.cast.borrow_mut().input_count(bytes.len()); + } +} + +/// Batch what a reader thread hands over into engine frames: one +/// [`Input::TerminalOutput`] per [`FLUSH_INTERVAL`], or sooner past +/// [`FLUSH_BYTES`]. The bytes go on the wire base64 (the engine's input +/// carries them that way, and the phone decodes them). +/// +/// Chunks are contiguous in the terminal's stream — one reader thread writes +/// them in the order the shell produced them — so a batch is one range and +/// its offset is the first chunk's. +async fn coalesce( + term: String, + mut chunks: mpsc::UnboundedReceiver>, + ring: Rc>, + cast: Rc>, + inputs: mpsc::UnboundedSender, +) { + let mut pending: Vec = Vec::new(); + let mut start: u64 = 0; + loop { + if pending.is_empty() { + match chunks.recv().await { + Some(data) => { + start = ring.borrow_mut().push(&data); + cast.borrow_mut().output(&data); + pending = data; + } + None => break, + } + } + if pending.len() >= FLUSH_BYTES { + emit(&term, start, &pending, &inputs); + pending.clear(); + continue; + } + match tokio::time::timeout(FLUSH_INTERVAL, chunks.recv()).await { + Ok(Some(data)) => { + ring.borrow_mut().push(&data); + cast.borrow_mut().output(&data); + pending.extend_from_slice(&data); + } + // The reader thread is gone: the terminal is over, so send what + // is left and stop. + Ok(None) => { + emit(&term, start, &pending, &inputs); + break; + } + Err(_) => { + emit(&term, start, &pending, &inputs); + pending.clear(); + } + } + } +} + +fn emit(term: &str, offset: u64, data: &[u8], inputs: &mpsc::UnboundedSender) { + let _ = inputs.send(Input::TerminalOutput { term: term.to_string(), offset, data: STANDARD.encode(data) }); +} + +/// What to run in a terminal: the owner's configured command, else the +/// shell. `Ok` is (program, arguments, what the phone is told is running). +fn shell_command(command: Option<&[String]>) -> (String, Vec, String) { + if let Some(parts) = command { + if let Some((program, args)) = parts.split_first() { + // The phone shows the whole line: `tmux new -A -s phone` says + // more about what is running than `tmux` does. + return (program.clone(), args.to_vec(), parts.join(" ")); + } + } + let shell = default_shell(); + (shell.clone(), Vec::new(), shell) +} + +/// The shell to run when the owner has configured none: `$SHELL`, else +/// `/bin/sh` on unix; on Windows `pwsh`, else `powershell`, else `cmd` — in +/// that order, the modern PowerShell first, the one every Windows has last. +#[cfg(unix)] +fn default_shell() -> String { + std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".to_string()) +} + +#[cfg(windows)] +fn default_shell() -> String { + for name in ["pwsh", "powershell", "cmd"] { + if on_path(name) { + return name.to_string(); + } + } + std::env::var("ComSpec").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "cmd".to_string()) +} + +/// Whether `name` is a program on `PATH` (on Windows, trying each extension +/// `PATHEXT` names), so the first shell that is actually installed is the +/// one chosen. +#[cfg(windows)] +fn on_path(name: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { return false }; + let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + std::env::split_paths(&path).any(|dir| { + let base = dir.join(name); + base.is_file() || exts.split(';').filter(|e| !e.is_empty()).any(|e| dir.join(format!("{name}{e}")).is_file()) + }) +} + +/// Signal a shell that was started and then could not be taken over, and +/// hand `note` back as the reason. A shell whose pty this bridge is not +/// going to hold must not be left running with nothing attached to it. +fn kill_first(killer: &mut Box, note: String) -> String { + let _ = killer.kill(); + note +} + +/// Hand the shell a built environment: `env_clear` first, because a +/// [`CommandBuilder`] seeds itself with the bridge's own environment (the +/// one that may hold the agents' provider keys), and then only what a shell +/// needs to work. +fn apply_env(cmd: &mut CommandBuilder, shell: &str) { + cmd.env_clear(); + for (name, value) in built_env(shell) { + cmd.env(name, value); + } +} + +/// The variables the shell is given, and no others. +#[cfg(unix)] +fn built_env(shell: &str) -> Vec<(&'static str, String)> { + let mut env = Vec::new(); + for name in ["PATH", "HOME", "USER", "LANG"] { + if let Ok(value) = std::env::var(name) { + if !value.is_empty() { + env.push((name, value)); + } + } + } + if !env.iter().any(|(name, _)| *name == "PATH") { + env.push(("PATH", "/usr/local/bin:/usr/bin:/bin".to_string())); + } + env.push(("SHELL", shell.to_string())); + // A terminal, so programs colour and page rather than think they are + // writing to a file. + env.push(("TERM", "xterm-256color".to_string())); + env +} + +/// On Windows a shell needs the system variables it reads to find programs +/// and its own directories; the same rule applies — only these. +#[cfg(windows)] +fn built_env(_shell: &str) -> Vec<(&'static str, String)> { + let mut env = Vec::new(); + for name in ["PATH", "SystemRoot", "ComSpec", "PATHEXT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP"] { + if let Ok(value) = std::env::var(name) { + if !value.is_empty() { + env.push((name, value)); + } + } + } + env +} + +#[cfg(test)] +mod tests { + // The environment lock below is deliberately held for a whole test, + // awaits and all: it is what serializes the tests that spawn a shell + // against the one that puts a fake secret in the process environment. + // Each test runs on its own single-threaded runtime, so nothing on that + // thread can want the lock while it is held. + #![allow(clippy::await_holding_lock)] + + use super::*; + + /// Tests here read and write the process environment (a pty inherits + /// none of it, which is what is being checked), and `std::env` is not + /// safe to mutate while another thread reads it — so they take turns. + #[cfg(unix)] + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + #[cfg(unix)] + fn rig() -> (Terminals, mpsc::UnboundedReceiver, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("a temp dir"); + let (inputs, rx) = mpsc::unbounded_channel(); + (Terminals::new(dir.path().join("audit"), inputs), rx, dir) + } + + /// The next `Input::TerminalOutput` from a terminal, or a panic if none + /// arrives in time. + #[cfg(unix)] + async fn next_output(rx: &mut mpsc::UnboundedReceiver, what: &str) -> (u64, Vec) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(left, rx.recv()).await { + Ok(Some(Input::TerminalOutput { offset, data, .. })) => { + return (offset, STANDARD.decode(data).expect("base64 from the bridge")); + } + Ok(Some(_)) => {} + Ok(None) => panic!("the bridge stopped before it sent {what}"), + Err(_) => panic!("timed out waiting for {what}"), + } + } + } + + /// Read from `rx` until the output so far contains `needle`: the frames + /// seen (offset and length each), and everything they carried. + #[cfg(unix)] + async fn collect_until(rx: &mut mpsc::UnboundedReceiver, needle: &str) -> (Vec<(u64, usize)>, Vec) { + let mut frames: Vec<(u64, usize)> = Vec::new(); + let mut seen = Vec::new(); + loop { + let (offset, data) = next_output(rx, needle).await; + frames.push((offset, data.len())); + seen.extend_from_slice(&data); + if String::from_utf8_lossy(&seen).contains(needle) { + return (frames, seen); + } + } + } + + #[cfg(unix)] + #[tokio::test] + async fn a_shell_runs_and_its_output_frames_are_contiguous_with_growing_offsets() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, mut rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + let shell = terms.spawn("t1", &cwd, 80, 24, None).expect("a shell"); + assert!(shell.ends_with("sh"), "the shell is {shell}"); + + terms.write("t1", b"echo one\n"); + let (mut frames, _) = collect_until(&mut rx, "one").await; + + // A second command, well past the flush interval, so its + // output is a frame of its own. + tokio::time::sleep(Duration::from_millis(80)).await; + terms.write("t1", b"echo two\n"); + frames.extend(collect_until(&mut rx, "two").await.0); + + assert!(frames.len() >= 2, "two commands should not be one frame: {frames:?}"); + // Nothing is ever dropped between the shell and the phone, so + // each frame starts exactly where the last one ended, and the + // offsets grow with the terminal's output. + let mut expected = None; + for (offset, len) in &frames { + if let Some(expected) = expected { + assert_eq!(*offset, expected, "a frame started where it should not: {frames:?}"); + } + expected = Some(*offset + *len as u64); + } + assert!(frames[0].0 < frames[1].0); + + terms.close("t1"); + }) + .await; + } + + #[cfg(unix)] + #[tokio::test] + async fn the_shell_gets_a_built_environment_and_nothing_of_the_bridges() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, mut rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + // A variable only the bridge's own environment holds — a + // provider key the user exported for the agents. It must not + // reach a shell that runs as the bridge's user. + std::env::set_var("CODEDECK_TEST_SECRET", "must-not-leak"); + terms.spawn("t2", &cwd, 80, 24, Some(&["env".to_string()])).expect("env runs"); + let (_, out) = collect_until(&mut rx, "PATH=").await; + std::env::remove_var("CODEDECK_TEST_SECRET"); + + let text = String::from_utf8_lossy(&out).into_owned(); + assert!(!text.contains("CODEDECK_TEST_SECRET"), "the bridge's environment leaked:\n{text}"); + assert!(text.contains("TERM=xterm-256color"), "no TERM:\n{text}"); + assert!(text.contains("PATH="), "no PATH:\n{text}"); + let names: Vec<&str> = + text.lines().filter_map(|line| line.split_once('=').map(|(name, _)| name)).collect(); + for name in names { + assert!( + ["PATH", "HOME", "USER", "LANG", "SHELL", "TERM"].contains(&name), + "the shell was given a variable it should not have: {name}" + ); + } + terms.close("t2"); + }) + .await; + } + + #[cfg(unix)] + #[tokio::test] + async fn input_typed_with_echo_off_is_recorded_as_its_size_not_its_bytes() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, mut rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + terms.spawn("t3", &cwd, 80, 24, None).expect("a shell"); + + // The shell turns its own echo off, the way a password prompt + // does, and says so once it has. + terms.write("t3", b"stty -echo; echo READY\n"); + collect_until(&mut rx, "READY").await; + terms.write("t3", b"hunter2\n"); + + let cast = std::fs::read_to_string(dir.path().join("audit").join("term-t3.cast")).expect("a recording"); + assert!(cast.contains("[input hidden: 8 bytes]"), "echo-off input was not hidden:\n{cast}"); + assert!(!cast.contains("hunter2"), "echo-off input was recorded:\n{cast}"); + // What was typed while the shell was still echoing is kept. + assert!(cast.contains("stty -echo"), "echoing input was not recorded:\n{cast}"); + + terms.close("t3"); + }) + .await; + } + + #[cfg(unix)] + #[tokio::test] + async fn a_shell_that_exits_reports_its_code_and_its_recording_is_complete() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, mut rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + terms.spawn("t4", &cwd, 80, 24, None).expect("a shell"); + terms.write("t4", b"echo bye; exit 7\n"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(left, rx.recv()).await { + Ok(Some(Input::TerminalExited { term, code, reason })) => { + assert_eq!(term, "t4"); + assert_eq!(reason, TerminalEndReason::Exit); + assert_eq!(code, Some(7)); + break; + } + Ok(Some(_)) => {} + Ok(None) | Err(_) => panic!("the shell's exit was never reported"), + } + } + terms.forget("t4"); + + // The shell's last words are handed to the coalescer before + // the exit is reported, but not necessarily recorded yet — + // give the recording its moment to catch up. + let path = dir.path().join("audit").join("term-t4.cast"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut cast = String::new(); + while tokio::time::Instant::now() < deadline { + cast = std::fs::read_to_string(&path).unwrap_or_default(); + if cast.contains("bye") { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(cast.contains("bye"), "the recording missed the output:\n{cast}"); + }) + .await; + } + + /// `terminal.command` is a hook for the owner's own program; the engine + /// passes `None` today, so the only thing to check here is that the line + /// is taken apart as it is written. + #[test] + fn a_configured_command_runs_its_program_with_its_arguments() { + let command = vec!["tmux".to_string(), "new".to_string(), "-A".to_string(), "-s".to_string(), "phone".to_string()]; + let (program, args, shown) = shell_command(Some(&command)); + assert_eq!(program, "tmux"); + assert_eq!(args, ["new", "-A", "-s", "phone"]); + assert_eq!(shown, "tmux new -A -s phone"); + // An empty command falls back to the shell rather than running + // nothing. + let (program, args, shown) = shell_command(Some(&[])); + assert!(!program.is_empty()); + assert!(args.is_empty()); + assert_eq!(program, shown); + } + + /// A terminal the engine never told us about is not a panic: a message + /// can arrive after the terminal it names is gone. + #[test] + fn acting_on_a_terminal_that_is_not_there_does_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (inputs, _rx) = mpsc::unbounded_channel(); + let mut terms = Terminals::new(dir.path().to_path_buf(), inputs); + terms.write("gone", b"x"); + terms.resize("gone", 100, 40); + terms.close("gone"); + terms.forget("gone"); + assert!(terms.replay("gone", 0).is_none()); + } +} diff --git a/crates/bridge-runtime/src/terminal/ring.rs b/crates/bridge-runtime/src/terminal/ring.rs new file mode 100644 index 00000000..1f69e3a2 --- /dev/null +++ b/crates/bridge-runtime/src/terminal/ring.rs @@ -0,0 +1,164 @@ +//! The last 256 KiB of a terminal's output, addressed by absolute byte +//! offset. +//! +//! A phone that reconnects, or sees a gap a relay dropped, asks for a replay +//! from the last byte offset it drew; the ring is what the bridge answers it +//! with. Bytes older than the ring are gone on purpose — a phone that asks +//! for one is told where the ring really starts and resets its screen — so +//! the offset a terminal's output is numbered by is **not** the ring's own +//! position: it is the count of everything the terminal has ever written, and +//! it only ever grows. `oldest()` is where the ring's first byte sits in that +//! count, so a caller can always tell "you asked too far back" from "you are +//! up to date". + +/// How much of a terminal's output is kept for a phone that comes back. +pub const CAPACITY: usize = 256 * 1024; + +/// A bounded byte log: the newest [`CAPACITY`] bytes, each addressed by its +/// offset in the whole stream. +#[derive(Debug)] +pub struct Ring { + /// The bytes held, oldest first, once the ring has wrapped; before it + /// fills this is simply the stream so far (`start` stays 0). + buf: Vec, + /// Where the oldest byte sits in `buf`; 0 until the ring has wrapped. + start: usize, + /// Every byte ever pushed — and so the offset of the next one. + total: u64, +} + +impl Ring { + pub fn new() -> Self { + Self { buf: Vec::new(), start: 0, total: 0 } + } + + /// The offset of the next byte a terminal writes: one past everything it + /// has written. + #[cfg_attr(not(test), allow(dead_code))] + pub fn total(&self) -> u64 { + self.total + } + + /// The offset of the oldest byte still held. A replay asked for an offset + /// before this starts here instead. + pub fn oldest(&self) -> u64 { + self.total - self.buf.len() as u64 + } + + /// How many bytes are held. + #[cfg_attr(not(test), allow(dead_code))] + pub fn len(&self) -> usize { + self.buf.len() + } + + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + + /// Append `data` and return the offset of its first byte. Past + /// [`CAPACITY`] the oldest bytes are overwritten, so the ring always + /// holds the newest output — which is what a phone catching up wants. + pub fn push(&mut self, data: &[u8]) -> u64 { + let offset = self.total; + self.total += data.len() as u64; + for &b in data { + if self.buf.len() < CAPACITY { + self.buf.push(b); + } else { + self.buf[self.start] = b; + self.start = (self.start + 1) % CAPACITY; + } + } + offset + } + + /// Everything from `from` on, and the offset it actually starts at — + /// which is `from` itself when it is still held, and [`oldest`](Self::oldest) + /// when it is not. The returned offset is what the phone must number the + /// bytes it is sent by, so a phone that asked too far back learns where + /// the ring really begins. + pub fn read_from(&self, from: u64) -> (u64, Vec) { + let held = self.buf.len(); + let oldest = self.oldest(); + let start = from.max(oldest); + if held == 0 || start >= self.total { + return (self.total, Vec::new()); + } + let skip = (start - oldest) as usize; + let mut out = Vec::with_capacity(held - skip); + for i in 0..held - skip { + out.push(self.buf[(self.start + skip + i) % held]); + } + (start, out) + } +} + +impl Default for Ring { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offsets_are_the_stream_count_not_the_position_in_the_ring() { + let mut ring = Ring::new(); + assert_eq!(ring.push(b"abc"), 0); + assert_eq!(ring.push(b"def"), 3); + assert_eq!(ring.total(), 6); + assert_eq!(ring.oldest(), 0); + assert_eq!(ring.read_from(0), (0, b"abcdef".to_vec())); + assert_eq!(ring.read_from(3), (3, b"def".to_vec())); + assert_eq!(ring.read_from(6), (6, Vec::new())); + } + + #[test] + fn wraparound_keeps_the_newest_capacity_bytes_with_correct_offsets() { + let mut ring = Ring::new(); + // One byte over capacity, so exactly one byte must have been dropped. + let chunk = vec![b'x'; 4096]; + let mut pushed = 0u64; + while pushed < CAPACITY as u64 + 1 { + ring.push(&chunk); + pushed += chunk.len() as u64; + } + assert_eq!(ring.total(), pushed); + assert_eq!(ring.len(), CAPACITY); + assert_eq!(ring.oldest(), pushed - CAPACITY as u64); + + // The oldest byte is gone; asking for it starts at the ring's start. + let (start, all) = ring.read_from(0); + assert_eq!(start, pushed - CAPACITY as u64); + assert_eq!(all.len(), CAPACITY); + + // A byte still held is served from exactly where it was written. + let (start, tail) = ring.read_from(ring.oldest() + 1); + assert_eq!(start, ring.oldest() + 1); + assert_eq!(tail.len(), CAPACITY - 1); + + // Bytes written after a wraparound read back in the order they went in. + let mut ring = Ring::new(); + ring.push(&vec![b'a'; CAPACITY]); + ring.push(b"hello"); + assert_eq!(ring.total(), CAPACITY as u64 + 5); + assert_eq!(ring.read_from(CAPACITY as u64), (CAPACITY as u64, b"hello".to_vec())); + assert_eq!(ring.read_from(0).1.len(), CAPACITY); + assert_eq!(ring.read_from(0).1[CAPACITY - 5..], b"hello"[..]); + } + + #[test] + fn a_push_larger_than_capacity_keeps_only_its_tail() { + let mut ring = Ring::new(); + let big = vec![b'z'; CAPACITY + 100]; + ring.push(&big); + assert_eq!(ring.total(), CAPACITY as u64 + 100); + assert_eq!(ring.len(), CAPACITY); + assert_eq!(ring.oldest(), 100); + let (start, all) = ring.read_from(0); + assert_eq!(start, 100); + assert_eq!(all, vec![b'z'; CAPACITY]); + } +} From cc54de5d1b9fe11504034a5070c93593eb26117d Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:29:54 +0200 Subject: [PATCH 14/50] perf(bridge): keep a terminal's writes, recording and output off the bridge's thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge is one current-thread runtime shared by the relays, the engine, the direct links and the web page, so nothing a terminal does may block it or flood it. Three things did. A write to a terminal's pty happened on the bridge's thread. A program that is not reading its input — `sleep`, a stopped debugger — fills the pty's input buffer (about 4 KiB on Linux) and then the write blocks, which would block every phone's link with it. Each terminal now has a writer thread owning the pty's input end, fed by a bounded queue; `write` only queues, and drops the input with a warning when the queue is full, which is the sign the program has stopped reading. Input dropped that way is not recorded: the recording says what the shell received. The recording was written and flushed a line at a time on the bridge's thread, so a slow disk reached the event loop. Each terminal now has a recorder thread owning the cast file, fed by a channel of events. The echo check stays on the bridge's thread — a quick ioctl — so the event already says whether what was typed was a secret. Output was unbounded: the reader thread ran as fast as the shell wrote, and the coalescer emitted as soon as 16 KiB were pending. The queue between them is now small, and the coalescer emits at most one frame per 16 ms, never more than 16 KiB. What is beyond waits for the next interval, in order and contiguous. A shell writing faster than the phones can be sent it therefore runs at that pace — its output backs up, the reader blocks, the pty fills, its own write blocks — like a terminal on a slow line, and the bridge's thread spends a bounded amount on it whatever runs. Tests: a write to a program that is not reading returns at once (1 MiB into `sleep 30`, asserted under 200 ms); `yes | head -c 1000000` produces at most one frame per 16 ms of elapsed time, with contiguous offsets. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/terminal/cast.rs | 4 - crates/bridge-runtime/src/terminal/mod.rs | 443 +++++++++++++++------ docs/TERMINAL.md | 11 + 3 files changed, 342 insertions(+), 116 deletions(-) diff --git a/crates/bridge-runtime/src/terminal/cast.rs b/crates/bridge-runtime/src/terminal/cast.rs index 7746d095..c5781679 100644 --- a/crates/bridge-runtime/src/terminal/cast.rs +++ b/crates/bridge-runtime/src/terminal/cast.rs @@ -65,8 +65,6 @@ impl Cast { /// What was typed, when the terminal was echoing: the shell's own echo /// will also appear in the output, which is how asciicast normally reads. - /// (On Windows there is no echo state to read, so nothing calls this.) - #[cfg_attr(windows, allow(dead_code))] pub fn input(&mut self, bytes: &[u8]) { let text = String::from_utf8_lossy(bytes).into_owned(); self.event("i", &text); @@ -74,7 +72,6 @@ impl Cast { /// What was typed while the terminal had echo off — a password. Only its /// size is kept: the bytes are exactly what the terminal was hiding. - #[cfg_attr(windows, allow(dead_code))] pub fn input_hidden(&mut self, bytes: usize) { self.event("i", &format!("[input hidden: {bytes} bytes]")); } @@ -82,7 +79,6 @@ impl Cast { /// What was typed where the platform gives no echo state to read (a /// ConPTY): the bytes are not recorded, and neither is whether they were /// hidden, because nothing can say. - #[cfg_attr(unix, allow(dead_code))] pub fn input_count(&mut self, bytes: usize) { self.event("i", &format!("[input: {bytes} bytes]")); } diff --git a/crates/bridge-runtime/src/terminal/mod.rs b/crates/bridge-runtime/src/terminal/mod.rs index bd34754d..3e74d7e1 100644 --- a/crates/bridge-runtime/src/terminal/mod.rs +++ b/crates/bridge-runtime/src/terminal/mod.rs @@ -9,7 +9,7 @@ //! writes, keeps the last 256 KiB of it for a phone that comes back, and //! writes a recording of the whole session under the bridge's home. //! -//! Two things shape the design: +//! Three things shape the design: //! //! - **The shell runs with a built environment, never the bridge's.** The //! bridge's own environment can hold the agents' provider keys — the user @@ -21,7 +21,18 @@ //! - **A pty is a thread.** Reading a pty blocks, and the bridge's event //! loop must not, so each terminal gets one reader thread that does //! nothing but read and hand bytes on. Everything else — the ring, the -//! recording, the engine — stays on the event loop. +//! engine — stays on the event loop. +//! - **A terminal never slows the phones' links.** The bridge runs relays, +//! the direct links, the web page and the engine on one thread, so no +//! terminal may block it or flood it. A terminal's input and its recording +//! each have a thread of their own, and the bridge only ever hands work +//! over: input goes through a bounded queue and is dropped when it is +//! full, and the recording is written by a thread that reads a channel. +//! Output is paced rather than passed through — the reader thread is +//! throttled by a small queue, and the coalescer emits at most one frame +//! per 16 ms — so a shell writing faster than the phones can be sent it +//! runs at that pace, like a terminal on a slow line, instead of spending +//! the bridge's thread on it. mod cast; mod ring; @@ -32,6 +43,7 @@ use std::io::{Read, Write}; use std::path::PathBuf; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver as StdReceiver, Sender as StdSender, SyncSender, TrySendError}; use std::sync::Arc; use std::time::Duration; @@ -45,40 +57,72 @@ use tokio::sync::mpsc; use cast::Cast; use ring::Ring; -/// How often a terminal's output is handed to the engine, when it is not -/// already past [`FLUSH_BYTES`]. The engine publishes it to the phones -/// showing the terminal; the transport coalesces again per link (16 ms on -/// the direct link, 100 ms on relays), so this is the first of the two -/// batching steps, and it keeps a chatty program from costing one event per -/// write. +/// How often a terminal's output is handed to the engine. The engine +/// publishes it to the phones showing the terminal; the transport coalesces +/// again per link (16 ms on the direct link, 100 ms on relays). This is also +/// the pace a terminal runs at: whatever a shell writes, at most one frame +/// goes out per interval, so a fast writer cannot spend the bridge's thread. const FLUSH_INTERVAL: Duration = Duration::from_millis(16); -/// Hand output on as soon as this much is pending, however recent the last -/// flush was: past it, waiting only adds latency to what is already a burst. +/// The most one frame carries. A shell writing faster than a frame per +/// interval fills the queues behind it and blocks on its own pty, which is +/// what makes it run at this pace rather than the bridge's. const FLUSH_BYTES: usize = 16 * 1024; /// How much a reader thread asks for at a time. const READ_BYTES: usize = 16 * 1024; +/// How many input chunks a terminal's writer thread may be behind before the +/// bridge stops queuing. Input is a keystroke or a paste, so this is a lot: +/// past it the program is not reading what it is sent. +const INPUT_QUEUE: usize = 64; +/// How many chunks of output the reader thread may run ahead of the +/// coalescer before it blocks. A small queue, because this is the +/// backpressure that paces a shell writing faster than the phones can be +/// sent it. +const OUTPUT_QUEUE: usize = 4; + +/// What a terminal's recorder thread is told to write. The echo state is +/// read on the bridge's thread (a quick ioctl), so the event already says +/// whether what was typed was a secret. +enum CastEvent { + /// What the shell wrote. + Output(Vec), + /// What was typed, echoed by the shell itself. + Input(Vec), + /// What was typed with echo off: the size, never the bytes. + InputHidden(usize), + /// What was typed where the platform has no echo state to read: the size, + /// with no claim either way. + InputCount(usize), + Resize(u16, u16), + /// Tests only: a marker the recorder answers once it has written + /// everything sent before it, so a test can read the file and know it is + /// complete. Unix only, because only the unix tests spawn a shell. + #[cfg(all(test, unix))] + Sync(StdSender<()>), +} -/// One terminal the bridge is running: the pty, what to write into it, how -/// to end it, and where its output and recording live. +/// One terminal the bridge is running: the pty, where its input and its +/// recording go, and how to end it. struct Pty { /// The control end: resizing, and reading the terminal's termios to see /// whether it is echoing. master: Box, - /// The input end. Dropping it sends EOF to the shell. - writer: Box, /// A handle that can signal the child without owning it: the reader /// thread owns the child (it is the one that waits for it), and this is /// how the event loop ends a terminal the engine decided is over. killer: Box, /// The last of the terminal's output, for a phone that comes back. ring: Rc>, - /// The session's recording. - cast: Rc>, /// Set once the shell has been reaped. Signalling a reaped pid is not /// harmless — the number may belong to another process by then — so /// [`Drop`] checks it before killing, and the reader thread sets it the /// moment `wait` returns. exited: Arc, + /// Input to the shell, written by a thread of its own. Bounded, and + /// filled without waiting: a program that has stopped reading its input + /// must not make the bridge wait. + input: SyncSender>, + /// What the recorder thread writes down. + recording: StdSender, } impl Drop for Pty { @@ -133,7 +177,6 @@ impl Terminals { let cast = Cast::open(&self.audit_dir.join(format!("term-{term}.cast")), cols, rows) .map_err(|e| format!("could not open the recording: {e}"))?; let ring = Rc::new(RefCell::new(Ring::new())); - let cast = Rc::new(RefCell::new(cast)); let mut cmd = CommandBuilder::new(&program); cmd.args(&args); @@ -159,10 +202,29 @@ impl Terminals { let exited = Arc::new(AtomicBool::new(false)); - // The reader thread: read the pty, hand the bytes to the coalescer, - // and when the pty closes, wait for the shell and say it is over. - let (chunks, chunk_rx) = mpsc::unbounded_channel::>(); + // The recorder thread owns the file, so a slow disk never reaches + // the bridge's thread. Its queue is unbounded on purpose: output + // arrives at most a frame per interval (the reader thread is what + // the queues throttle), and a bounded queue here would only turn a + // slow disk into a stalled shell. + let (recording, recording_rx) = std::sync::mpsc::channel::(); + if let Err(e) = start_recorder(term, cast, recording_rx) { + return Err(kill_first(&mut killer, e)); + } + + // The writer thread owns the pty's input end: a write to a program + // that is not reading blocks there, not on the bridge. + let (input, input_rx) = sync_channel::>(INPUT_QUEUE); + if let Err(e) = start_writer(term, writer, input_rx) { + return Err(kill_first(&mut killer, e)); + } + + // The reader thread: read the pty, hand the bytes to the recorder + // and the coalescer, and when the pty closes, wait for the shell and + // say it is over. + let (chunks, chunk_rx) = mpsc::channel::>(OUTPUT_QUEUE); let inputs = self.inputs.clone(); + let recording_in_thread = recording.clone(); let term_owned = term.to_string(); let exited_in_thread = Arc::clone(&exited); let started = std::thread::Builder::new().name(format!("pty-{term}")).spawn(move || { @@ -176,7 +238,12 @@ impl Terminals { // already been handed on. Ok(0) => break, Ok(n) => { - if chunks.send(buf[..n].to_vec()).is_err() { + let data = buf[..n].to_vec(); + let _ = recording_in_thread.send(CastEvent::Output(data.clone())); + // Bounded: past the coalescer's queue this blocks, + // which is what stops a shell writing faster than + // the phones can be sent it from running away. + if chunks.blocking_send(data).is_err() { break; } } @@ -197,29 +264,49 @@ impl Terminals { } // The coalescer: batch the reader's chunks into the engine's frames. - tokio::task::spawn_local(coalesce(term.to_string(), chunk_rx, Rc::clone(&ring), Rc::clone(&cast), self.inputs.clone())); + tokio::task::spawn_local(coalesce(term.to_string(), chunk_rx, Rc::clone(&ring), self.inputs.clone())); - self.ptys.insert(term.to_string(), Pty { master: pair.master, writer, killer, ring, cast, exited }); + self.ptys.insert(term.to_string(), Pty { master: pair.master, killer, ring, exited, input, recording }); Ok(shell) } /// Type `bytes` into `term`, and record it. The engine has already /// checked the sender is the writer and that nothing earlier is missing. + /// + /// Nothing here waits. The write is queued for the terminal's writer + /// thread, and if that queue is full — the program is not reading what + /// it is sent — the input is dropped rather than held: a keystroke into + /// a program that has stopped reading must not become the bridge's + /// problem, and the phone is told its `seq` was applied either way. What + /// is dropped is not recorded: the recording says what the shell + /// received. pub fn write(&mut self, term: &str, bytes: &[u8]) { - let Some(pty) = self.ptys.get_mut(term) else { return }; - if let Err(e) = pty.writer.write_all(bytes).and_then(|()| pty.writer.flush()) { - log::warn!("[Terminal] could not write to {term}: {e}"); + let Some(pty) = self.ptys.get(term) else { return }; + // The echo state is a quick ioctl on the pty, so it is read here: + // the event the recorder gets already says whether the bytes are a + // secret, and the recorder thread does nothing but write. + let event = input_event(pty, bytes); + match pty.input.try_send(bytes.to_vec()) { + Ok(()) => { + let _ = pty.recording.send(event); + } + Err(TrySendError::Full(_)) => { + log::warn!("[Terminal] {term} is not reading its input: dropped {} bytes", bytes.len()); + } + // The writer thread is gone: the shell is over, and the engine + // will hear about it as an exit. + Err(TrySendError::Disconnected(_)) => {} } - record_input(pty, bytes); } - /// The writer's window changed. + /// The writer's window changed. An ioctl, which does not block, so it + /// stays on the bridge's thread. pub fn resize(&mut self, term: &str, cols: u16, rows: u16) { let Some(pty) = self.ptys.get_mut(term) else { return }; if let Err(e) = pty.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) { log::warn!("[Terminal] could not resize {term}: {e}"); } - pty.cast.borrow_mut().resize(cols, rows); + let _ = pty.recording.send(CastEvent::Resize(cols, rows)); } /// The output to send a client that attached to `term` from byte `from`: @@ -254,6 +341,18 @@ impl Terminals { pty.exited.store(true, Ordering::SeqCst); } } + + /// Tests only: wait until `term`'s recorder has written everything it + /// was sent before this call, so a test can read the file and see a + /// whole session. Unix only, because only the unix tests spawn a shell. + #[cfg(all(test, unix))] + fn flush_recording(&self, term: &str) { + let Some(pty) = self.ptys.get(term) else { return }; + let (done, wait) = std::sync::mpsc::channel(); + if pty.recording.send(CastEvent::Sync(done)).is_ok() { + let _ = wait.recv_timeout(Duration::from_secs(5)); + } + } } impl Drop for Terminals { @@ -269,87 +368,140 @@ impl Drop for Terminals { } } -/// Record one input chunk, hiding the bytes when the terminal is not echoing -/// them. +/// The recording event for one input chunk. /// /// A shell that turns echo off is asking for something it does not want on /// screen — a password, a key — and the bytes a person types then are the -/// one thing the recording must never hold. On unix the pty's termios says -/// whether echo is on; where that cannot be read (a ConPTY, or a pty that -/// will not answer) the bytes are not recorded either, because losing a -/// keystroke to a byte count is a small loss and writing a password to disk -/// is not. -fn record_input(pty: &Pty, bytes: &[u8]) { - #[cfg(unix)] - { - let echoing = pty - .master - .get_termios() - .is_some_and(|t| t.local_flags.contains(nix::sys::termios::LocalFlags::ECHO)); - if echoing { - pty.cast.borrow_mut().input(bytes); - } else { - pty.cast.borrow_mut().input_hidden(bytes.len()); - } - } - #[cfg(not(unix))] - { - // A ConPTY exposes no echo state, so what was typed is recorded as a - // byte count whatever it was: the shell's own echo still shows it in - // the output where it was echoed at all. - pty.cast.borrow_mut().input_count(bytes.len()); +/// one thing the recording must never hold. So the pty's echo state is read +/// at the moment of the write: echoing, the bytes are kept; not echoing, or +/// echoing in a way that cannot be read (a ConPTY), only the size is, since +/// losing a keystroke to a byte count is a small loss and writing a password +/// to disk is not. +fn input_event(pty: &Pty, bytes: &[u8]) -> CastEvent { + match echo_state(pty) { + Some(true) => CastEvent::Input(bytes.to_vec()), + Some(false) => CastEvent::InputHidden(bytes.len()), + None => CastEvent::InputCount(bytes.len()), } } -/// Batch what a reader thread hands over into engine frames: one -/// [`Input::TerminalOutput`] per [`FLUSH_INTERVAL`], or sooner past -/// [`FLUSH_BYTES`]. The bytes go on the wire base64 (the engine's input -/// carries them that way, and the phone decodes them). +#[cfg(unix)] +fn echo_state(pty: &Pty) -> Option { + pty.master.get_termios().map(|t| t.local_flags.contains(nix::sys::termios::LocalFlags::ECHO)) +} + +/// A ConPTY exposes no echo state, so nothing can say whether what was typed +/// was echoed or hidden. +#[cfg(not(unix))] +fn echo_state(_pty: &Pty) -> Option { + None +} + +/// The thread that owns a terminal's input end. +fn start_writer(term: &str, mut writer: Box, input: StdReceiver>) -> Result<(), String> { + let name = format!("pty-in-{term}"); + let term = term.to_string(); + std::thread::Builder::new() + .name(name) + .spawn(move || { + while let Ok(chunk) = input.recv() { + if let Err(e) = writer.write_all(&chunk).and_then(|()| writer.flush()) { + // A write that fails means the pty is gone; the reader + // thread reports the shell's exit, so there is nothing + // to do here but stop. + log::warn!("[Terminal] could not write to {term}: {e}"); + return; + } + } + }) + .map(|_| ()) + .map_err(|e| format!("could not start a writer thread: {e}")) +} + +/// The thread that owns a terminal's recording. +fn start_recorder(term: &str, cast: Cast, events: StdReceiver) -> Result<(), String> { + let name = format!("pty-cast-{term}"); + std::thread::Builder::new() + .name(name) + .spawn(move || { + let mut cast = cast; + while let Ok(event) = events.recv() { + match event { + CastEvent::Output(bytes) => cast.output(&bytes), + CastEvent::Input(bytes) => cast.input(&bytes), + CastEvent::InputHidden(bytes) => cast.input_hidden(bytes), + CastEvent::InputCount(bytes) => cast.input_count(bytes), + CastEvent::Resize(cols, rows) => cast.resize(cols, rows), + #[cfg(all(test, unix))] + CastEvent::Sync(done) => { + let _ = done.send(()); + } + } + } + }) + .map(|_| ()) + .map_err(|e| format!("could not start a recorder thread: {e}")) +} + +/// Batch what a reader thread hands over into engine frames: at most one +/// [`Input::TerminalOutput`] per [`FLUSH_INTERVAL`], and never more than +/// [`FLUSH_BYTES`] in it. What is beyond waits for the next interval, in +/// order and contiguous, so a shell that writes faster than the phones can +/// be sent it runs at this pace — its output backs up through the bounded +/// queue to the reader thread, and the reader thread's blocking send fills +/// the pty, which blocks the shell's own write, as a terminal on a slow line +/// does. /// -/// Chunks are contiguous in the terminal's stream — one reader thread writes -/// them in the order the shell produced them — so a batch is one range and -/// its offset is the first chunk's. +/// The bytes go on the wire base64 (the engine's input carries them that +/// way, and the phone decodes them). Chunks are contiguous in the terminal's +/// stream — one reader thread writes them in the order the shell produced +/// them — so a frame is one range and its offset is the first chunk's. async fn coalesce( term: String, - mut chunks: mpsc::UnboundedReceiver>, + mut chunks: mpsc::Receiver>, ring: Rc>, - cast: Rc>, inputs: mpsc::UnboundedSender, ) { let mut pending: Vec = Vec::new(); let mut start: u64 = 0; loop { - if pending.is_empty() { - match chunks.recv().await { - Some(data) => { - start = ring.borrow_mut().push(&data); - cast.borrow_mut().output(&data); - pending = data; + // Gather for one interval, but no more than a frame's worth: past + // that the channel is left alone and the shell waits. + let deadline = tokio::time::Instant::now() + FLUSH_INTERVAL; + while pending.len() < FLUSH_BYTES { + match tokio::time::timeout_at(deadline, chunks.recv()).await { + Ok(Some(data)) => { + let offset = ring.borrow_mut().push(&data); + if pending.is_empty() { + start = offset; + } + pending.extend_from_slice(&data); + } + Ok(None) => { + // The reader thread is gone: the terminal is over. Send + // what is left, in order, a frame at a time. + while !pending.is_empty() { + let take = pending.len().min(FLUSH_BYTES); + emit(&term, start, &pending[..take], &inputs); + pending.drain(..take); + start += take as u64; + } + return; } - None => break, + Err(_) => break, } } if pending.len() >= FLUSH_BYTES { - emit(&term, start, &pending, &inputs); - pending.clear(); - continue; + // A frame is in hand: hold it until the interval is up, so the + // shell is paced by the interval rather than by how fast the + // bridge's thread can run. + tokio::time::sleep_until(deadline).await; } - match tokio::time::timeout(FLUSH_INTERVAL, chunks.recv()).await { - Ok(Some(data)) => { - ring.borrow_mut().push(&data); - cast.borrow_mut().output(&data); - pending.extend_from_slice(&data); - } - // The reader thread is gone: the terminal is over, so send what - // is left and stop. - Ok(None) => { - emit(&term, start, &pending, &inputs); - break; - } - Err(_) => { - emit(&term, start, &pending, &inputs); - pending.clear(); - } + if !pending.is_empty() { + let take = pending.len().min(FLUSH_BYTES); + emit(&term, start, &pending[..take], &inputs); + pending.drain(..take); + start += take as u64; } } } @@ -519,6 +671,19 @@ mod tests { } } + /// Frames are contiguous — nothing is ever dropped between the shell and + /// the phone, so each one starts where the last ended. + #[cfg(unix)] + fn assert_contiguous(frames: &[(u64, usize)]) { + let mut expected = None; + for (offset, len) in frames { + if let Some(expected) = expected { + assert_eq!(*offset, expected, "a frame started where it should not: {frames:?}"); + } + expected = Some(*offset + *len as u64); + } + } + #[cfg(unix)] #[tokio::test] async fn a_shell_runs_and_its_output_frames_are_contiguous_with_growing_offsets() { @@ -541,16 +706,7 @@ mod tests { frames.extend(collect_until(&mut rx, "two").await.0); assert!(frames.len() >= 2, "two commands should not be one frame: {frames:?}"); - // Nothing is ever dropped between the shell and the phone, so - // each frame starts exactly where the last one ended, and the - // offsets grow with the terminal's output. - let mut expected = None; - for (offset, len) in &frames { - if let Some(expected) = expected { - assert_eq!(*offset, expected, "a frame started where it should not: {frames:?}"); - } - expected = Some(*offset + *len as u64); - } + assert_contiguous(&frames); assert!(frames[0].0 < frames[1].0); terms.close("t1"); @@ -608,6 +764,7 @@ mod tests { terms.write("t3", b"stty -echo; echo READY\n"); collect_until(&mut rx, "READY").await; terms.write("t3", b"hunter2\n"); + terms.flush_recording("t3"); let cast = std::fs::read_to_string(dir.path().join("audit").join("term-t3.cast")).expect("a recording"); assert!(cast.contains("[input hidden: 8 bytes]"), "echo-off input was not hidden:\n{cast}"); @@ -647,21 +804,83 @@ mod tests { } } terms.forget("t4"); + // The shell's last words reach the recorder's thread through + // its own queue, so wait for that thread to catch up. + terms.flush_recording("t4"); + + let cast = std::fs::read_to_string(dir.path().join("audit").join("term-t4.cast")).expect("a recording"); + assert!(cast.contains("bye"), "the recording missed the output:\n{cast}"); + }) + .await; + } - // The shell's last words are handed to the coalescer before - // the exit is reported, but not necessarily recorded yet — - // give the recording its moment to catch up. - let path = dir.path().join("audit").join("term-t4.cast"); - let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - let mut cast = String::new(); - while tokio::time::Instant::now() < deadline { - cast = std::fs::read_to_string(&path).unwrap_or_default(); - if cast.contains("bye") { - break; + /// A program that has stopped reading its input must not make the + /// bridge wait: the write is queued and the call returns. + #[cfg(unix)] + #[tokio::test] + async fn a_write_does_not_wait_for_a_program_that_is_not_reading() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, _rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + terms + .spawn("t5", &cwd, 80, 24, Some(&["sleep".to_string(), "30".to_string()])) + .expect("sleep runs"); + // Far more than any pty's input buffer: written to the pty + // directly, this would block once the buffer filled. + let big = vec![b'x'; 1024 * 1024]; + let started = std::time::Instant::now(); + terms.write("t5", &big); + let took = started.elapsed(); + assert!(took < Duration::from_millis(200), "the write waited {took:?} on a program that is not reading"); + terms.close("t5"); + }) + .await; + } + + /// Output is paced: a shell that writes as fast as it can is served at + /// one frame per interval, so it costs the bridge's thread a bounded + /// amount however much it writes. + #[cfg(unix)] + #[tokio::test] + async fn a_shell_that_writes_faster_than_it_is_read_is_paced_by_the_interval() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, mut rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + terms.spawn("t6", &cwd, 80, 24, None).expect("a shell"); + + let started = tokio::time::Instant::now(); + terms.write("t6", b"yes | head -c 1000000\n"); + let mut frames: Vec<(u64, usize)> = Vec::new(); + let mut received = 0usize; + let deadline = started + Duration::from_secs(60); + while received < 1_000_000 { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(left, rx.recv()).await { + Ok(Some(Input::TerminalOutput { offset, data, .. })) => { + let len = STANDARD.decode(&data).expect("base64 from the bridge").len(); + frames.push((offset, len)); + received += len; + } + Ok(Some(_)) => {} + Ok(None) | Err(_) => panic!("timed out after {received} bytes"), } - tokio::time::sleep(Duration::from_millis(20)).await; } - assert!(cast.contains("bye"), "the recording missed the output:\n{cast}"); + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + assert_contiguous(&frames); + // One frame per interval is the cap: any more would mean the + // shell was served faster than the terminal's own pace. + assert!( + frames.len() as f64 <= elapsed_ms / 16.0 + 2.0, + "{} frames in {elapsed_ms:.0} ms — the cap is one per 16 ms", + frames.len() + ); + terms.close("t6"); }) .await; } diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 28feb55e..ad027a67 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -128,6 +128,17 @@ When relays are allowed, a terminal falls back to them like any message. output events a second, the rest merged into the next — relays rate-limit their writers, and a terminal must not get the bridge's or the phone's key throttled for the agents too. The direct link has no cap. +- **A terminal never slows the phones' links.** The bridge runs the relays, + the direct links, the web page and the engine on one thread, so no + terminal may block it or flood it. A terminal's input and its recording + each have a thread of their own, and the bridge only hands work over: + input goes into a bounded queue and is dropped, with a warning, when the + program has stopped reading it. Output is paced rather than passed + through — the reader thread is throttled by a small queue, and the bridge + emits at most one frame per 16 ms — so a shell writing faster than the + phones can be sent it runs at that pace, its pty filling and its write + blocking, like a terminal on a slow line. What a terminal costs the + bridge's thread is therefore bounded, whatever runs in it. - **Privacy of size and timing.** NIP-44 already pads every payload to a size bucket, so a relay sees bucketed sizes, not byte counts. Batching on the keys above, rather than sending each keystroke, also hides the typing From 5868d104b309429ea1a7ca849ec4b3aec3624096 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:41:28 +0200 Subject: [PATCH 15/50] feat(bridge-core): make the terminal audit record serializable The terminal audit is one JSON line per event, and the record the engine hands the runtime is exactly what that line is made of. Deriving Serialize with camelCase names, and leaving a field out when it is absent (a refusal has no shell, an open no exit code), lets the runtime serialize the record straight, so the log's schema tracks the type rather than a hand-written mirror that can drift from it. The `what` variant names are the line's `what` values. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/terminal.rs | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs index 53a8f012..106fd55a 100644 --- a/crates/bridge-core/src/engine/terminal.rs +++ b/crates/bridge-core/src/engine/terminal.rs @@ -147,7 +147,14 @@ pub struct PendingDevice { /// which device, and how that device's command reached the bridge. The engine /// holds no file: the runtime chains the line to the one before it and /// stores it under the bridge's home. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// The runtime serializes this straight to the audit's JSON line, so the field +/// names and the `what` values here are the audit's schema: renaming a field +/// changes the log's shape. Fields that are absent for an event (a refusal has +/// no `shell`, an open has no `code`) are left out of the line rather than +/// written as null. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] pub struct TerminalAuditRecord { /// When it happened, ms since the Unix epoch. pub at_ms: u64, @@ -158,31 +165,44 @@ pub struct TerminalAuditRecord { /// The identity the terminal belongs to, as its npub. pub identity: String, /// The device that acted, when one did. + #[serde(skip_serializing_if = "Option::is_none")] pub device: Option, /// That device's own name for itself, as its latest grant said. + #[serde(skip_serializing_if = "Option::is_none")] pub label: Option, /// How its command arrived, when a command is what did it. + #[serde(skip_serializing_if = "Option::is_none")] pub via: Option, /// Where the shell started, for an open. + #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// What ran in it, once the runtime said. + #[serde(skip_serializing_if = "Option::is_none")] pub shell: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub rows: Option, /// Why it ended, and what the shell returned. + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub code: Option, /// How long it lived, for an end. + #[serde(skip_serializing_if = "Option::is_none")] pub duration_ms: Option, /// What a refusal refused (input that is not base64, input of a size no /// typing has, a seq that never arrived, or one too far ahead to be part /// of a run): the reason alone would not say. + #[serde(skip_serializing_if = "Option::is_none")] pub why: Option, } /// What a terminal audit line records. `Refused` is the only one about -/// something that did not happen, and carries `why`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// something that did not happen, and carries `why`. The variant names are +/// the `what` values in the audit line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] pub enum TerminalAuditWhat { /// A shell started. Opened, From 055dde8e2e15d625922f527b042e1299cba4fc2f Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:41:38 +0200 Subject: [PATCH 16/50] feat(bridge-runtime): terminal config, device allowlist, audit log and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge half of terminals that is not the PTYs: the owner's configuration, the device allowlist file, the audit, and the commands that drive them. Configuration: a `terminal` object (enabled, relays, idleMinutes up to a day, maxTerminals, command) and an `audit` object (maxDays, maxBytes, syslog), following the usual precedence — flags > environment > file > default — with `--terminal` / `CODEDECK_TERMINAL` switching terminals on. `command` is only carried here; the PTY side reads it. Unknown keys inside the new objects are warned about like the top-level ones. The allowlist (`/terminal-allow.json`, 0600, written atomically) holds the approved device ids and the devices refused a terminal. Its cache re-reads the file only when its mtime moves, so the runtime can check it cheaply before routing a command. A missing or corrupt file reads as empty, which refuses every device — fail closed. `codedeck-bridge terminal pending|allow|disallow` edits it while the bridge runs; an ambiguous id prefix is refused rather than guessed. A write goes through a thread of its own, so the runtime's single thread never writes the file. The audit (`/audit/terminal.jsonl`, 0700/0600) is one JSON line per event, each carrying `prev`, the SHA-256 of the line before it (the first line's is all zeros). It is written by a thread of its own, fed by a channel and fsyncing each line off the runtime's single thread. `audit verify` recomputes the chain and reports the first break with a non-zero exit; the honest limit is that anyone running as the bridge's user can rewrite the whole file. `audit.syslog` also emits each line to the `audit` log target. Retention removes the oldest whole `term-*.cast` recordings past `audit.maxDays` or over `audit.maxBytes`; `terminal.jsonl` is kept. Co-Authored-By: Claude Code --- config.example.json | 14 +- crates/bridge-runtime/src/audit.rs | 455 ++++++++++++++++++++ crates/bridge-runtime/src/config.rs | 197 ++++++++- crates/bridge-runtime/src/lib.rs | 2 + crates/bridge-runtime/src/main.rs | 84 ++++ crates/bridge-runtime/src/terminal_allow.rs | 379 ++++++++++++++++ 6 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 crates/bridge-runtime/src/audit.rs create mode 100644 crates/bridge-runtime/src/terminal_allow.rs diff --git a/config.example.json b/config.example.json index 979b851a..4134239a 100644 --- a/config.example.json +++ b/config.example.json @@ -19,5 +19,17 @@ "blossomRegisterToken": "admin-token", "transcriptKeepLast": 5000, "agentHostPath": "/opt/codedeck/agent-host/dist/main.js", - "nodePath": "/usr/bin/node" + "nodePath": "/usr/bin/node", + "terminal": { + "enabled": false, + "relays": false, + "idleMinutes": 60, + "maxTerminals": 4, + "command": ["/bin/bash", "-l"] + }, + "audit": { + "maxDays": 90, + "maxBytes": 1073741824, + "syslog": false + } } diff --git a/crates/bridge-runtime/src/audit.rs b/crates/bridge-runtime/src/audit.rs new file mode 100644 index 00000000..c3cf3600 --- /dev/null +++ b/crates/bridge-runtime/src/audit.rs @@ -0,0 +1,455 @@ +//! The terminal audit: `/audit/terminal.jsonl`, a hash-chained log of +//! what every terminal did, and the retention of the asciicast recordings +//! beside it. +//! +//! The engine decides and records; this owns the file. Every +//! [`TerminalAuditRecord`] the engine emits becomes one JSON line, and each +//! line carries `prev`: the SHA-256, in hex, of the line before it (the first +//! line's `prev` is 64 zeros). An edited or deleted line breaks the chain at +//! the next line, which `codedeck-bridge audit verify` finds. The honest +//! limit, the same one [`docs/TERMINAL.md`](../../../docs/TERMINAL.md) states: +//! anyone running as the bridge's user can rewrite the whole file, chain and +//! all; the chain catches tampering that is not that thorough, and +//! `audit.syslog: true` also copies each line to the system log, which +//! survives even that. +//! +//! Nothing here runs on the bridge's one runtime thread. Writes go to a thread +//! of their own, fed by a channel and writing lines in order, so an audit +//! never blocks a relay, a session or a terminal. That thread also runs +//! retention: the oldest whole `term-*.cast` recordings are removed when they +//! are older than `audit.maxDays` or while the directory is larger than +//! `audit.maxBytes` (`terminal.jsonl` itself is kept — it is small and is the +//! record of who opened what). + +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime}; + +use bridge_core::TerminalAuditRecord; +use sha2::{Digest, Sha256}; + +/// The directory the audit lives in, under the bridge home (`0700`). +pub const AUDIT_DIR: &str = "audit"; +/// The chained log of terminal events (`0600`). +pub const LOG_FILE: &str = "terminal.jsonl"; +/// `prev` of the first line: a 32-byte SHA-256 in hex, all zeros, so every +/// line carries one and `verify` needs no special case for the first. +const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +/// The prefix and suffix a recording file has (`term-.cast`). +const CAST_PREFIX: &str = "term-"; +const CAST_SUFFIX: &str = ".cast"; +/// How often the writer thread runs retention. +const RETENTION_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// How the audit is kept. 0 for `max_days` / `max_bytes` means no limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Retention { + pub max_days: u64, + pub max_bytes: u64, +} + +/// The audit's writer thread and the channel that feeds it. Dropping this +/// closes the channel and joins the thread, so everything already recorded is +/// on disk before the process ends. +pub struct Audit { + tx: Option>, + thread: Option>, +} + +impl Audit { + /// Start the audit under `home`, continuing the chain from the last line + /// already on disk (a blocking read before the runtime serves anything is + /// fine), and running retention once at once and then daily. A failure to + /// open the log or start the thread is logged and leaves the audit off: + /// the bridge keeps running, and `record` warns that a line was lost. + pub fn open(home: &Path, syslog: bool, retention: Retention) -> Audit { + let dir = home.join(AUDIT_DIR); + if let Err(e) = fs::create_dir_all(&dir) { + log::error!("[Terminal] cannot create the audit directory {}: {e}", dir.display()); + return Audit { tx: None, thread: None }; + } + crate::state::restrict(&dir, 0o700); + let log = dir.join(LOG_FILE); + let prev = match last_line_hash(&log) { + Ok(prev) => prev, + Err(err) => { + log::error!("[Terminal] cannot continue the terminal audit: {err}"); + return Audit { tx: None, thread: None }; + } + }; + let (tx, rx) = mpsc::channel::(); + let thread = std::thread::Builder::new() + .name("terminal-audit".into()) + .spawn(move || writer_loop(dir, log, rx, syslog, retention, prev)) + .map_err(|e| log::error!("[Terminal] cannot start the terminal audit thread: {e}")) + .ok(); + Audit { tx: Some(tx), thread } + } + + /// Record one line. Sent to the writer thread, never written here. + pub fn record(&self, record: TerminalAuditRecord) { + match &self.tx { + Some(tx) => { + if tx.send(record).is_err() { + log::warn!("[Terminal] the audit writer thread is gone; a line was lost"); + } + } + None => log::warn!("[Terminal] the audit is not running; a line was lost"), + } + } +} + +impl Drop for Audit { + fn drop(&mut self) { + // Closing the channel ends the writer loop once it has drained. + self.tx.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn writer_loop( + dir: PathBuf, + log: PathBuf, + rx: Receiver, + syslog: bool, + retention: Retention, + mut prev: String, +) { + let file = match OpenOptions::new().create(true).append(true).open(&log) { + Ok(file) => file, + Err(e) => { + log::error!("[Terminal] cannot open the audit log {}: {e}", log.display()); + return; + } + }; + crate::state::restrict(&log, 0o600); + let mut out = BufWriter::new(file); + + run_retention(&dir, retention); + let mut next_retention = Instant::now() + RETENTION_INTERVAL; + loop { + let wait = next_retention.saturating_duration_since(Instant::now()); + match rx.recv_timeout(wait) { + Ok(record) => match write_line(&mut out, &record, &prev) { + Ok(line) => { + prev = hash_hex(line.as_bytes()); + if syslog { + // The audit's log target. There is no syslog client + // here: `audit.syslog` routes each line to the `audit` + // target, so a log handler that forwards to the system + // log (journald, rsyslog) keeps a copy that survives + // the file being rewritten. + log::info!(target: "audit", "{line}"); + } + } + Err(e) => log::error!("[Terminal] cannot write the audit log: {e}"), + }, + Err(RecvTimeoutError::Timeout) => { + run_retention(&dir, retention); + next_retention = Instant::now() + RETENTION_INTERVAL; + } + Err(RecvTimeoutError::Disconnected) => { + let _ = out.flush(); + return; + } + } + } +} + +/// Serialize one record as a JSON line with `prev`, write it, and fsync. The +/// returned string is the line without its newline — what the next line's +/// `prev` hashes. +fn write_line(out: &mut BufWriter, record: &TerminalAuditRecord, prev: &str) -> std::io::Result { + let mut value = serde_json::to_value(record).expect("a terminal audit record serializes"); + if let serde_json::Value::Object(map) = &mut value { + map.insert("prev".to_string(), serde_json::Value::String(prev.to_string())); + } + let line = serde_json::to_string(&value).expect("a JSON object serializes"); + out.write_all(line.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + // Durability matters for a security log, and this is off the runtime + // thread: pay the fsync here rather than risk losing the tail. + out.get_ref().sync_all()?; + Ok(line) +} + +fn hash_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// The chain head to continue from: the hash of the file's last line, or +/// [`GENESIS`] when there is no file or it has no lines. +fn last_line_hash(log: &Path) -> Result { + let file = match File::open(log) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(GENESIS.to_string()), + Err(e) => return Err(format!("cannot read {}: {e}", log.display())), + }; + let mut prev = GENESIS.to_string(); + for line in BufReader::new(file).lines() { + let line = line.map_err(|e| format!("cannot read {}: {e}", log.display()))?; + if line.is_empty() { + continue; + } + prev = hash_hex(line.as_bytes()); + } + Ok(prev) +} + +/// Walk the log and recompute the chain. `Ok(n)` is the number of lines; +/// `Err` names the first line whose `prev` does not match the line before it +/// (1-based) — the line after an edited or deleted one, which is where the +/// break shows. +pub fn verify(log: &Path) -> Result { + let file = match File::open(log) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(format!("cannot read {}: {e}", log.display())), + }; + let mut prev = GENESIS.to_string(); + let mut n = 0u64; + for line in BufReader::new(file).lines() { + let line = line.map_err(|e| format!("cannot read {}: {e}", log.display()))?; + n += 1; + if line.is_empty() { + return Err(format!("line {n}: the line is empty")); + } + let value: serde_json::Value = + serde_json::from_str(&line).map_err(|e| format!("line {n}: not valid JSON ({e})"))?; + let got = value.get("prev").and_then(|v| v.as_str()).unwrap_or(""); + if got != prev { + let found = if got.is_empty() { "(missing)".to_string() } else { got.to_string() }; + return Err(format!("line {n}: prev is {found}, but the line before it hashes to {prev}")); + } + prev = hash_hex(line.as_bytes()); + } + Ok(n) +} + +/// Remove the oldest whole recordings under `dir` that are older than +/// `max_days`, or while the directory is larger than `max_bytes`. Oldest +/// first; `terminal.jsonl` is never removed. +fn run_retention(dir: &Path, retention: Retention) { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + log::warn!("[Terminal] cannot read the audit directory {} for retention: {e}", dir.display()); + return; + } + }; + let mut casts: Vec<(PathBuf, SystemTime, u64)> = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with(CAST_PREFIX) || !name.ends_with(CAST_SUFFIX) { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + casts.push((entry.path(), mtime, meta.len())); + } + casts.sort_by_key(|(_, mtime, _)| *mtime); + + let cutoff = (retention.max_days > 0) + .then(|| SystemTime::now().checked_sub(Duration::from_secs(retention.max_days * 86_400)).unwrap_or(SystemTime::UNIX_EPOCH)); + let mut total: u64 = casts.iter().map(|(_, _, len)| len).sum(); + for (path, mtime, len) in casts { + let too_old = cutoff.is_some_and(|cutoff| mtime < cutoff); + let too_big = retention.max_bytes > 0 && total > retention.max_bytes; + if !too_old && !too_big { + // Sorted oldest first: nothing later is older or a better + // candidate for the size limit. + break; + } + match fs::remove_file(&path) { + Ok(()) => { + total = total.saturating_sub(len); + log::info!("[Terminal] audit retention removed {}", path.display()); + } + Err(e) => log::warn!("[Terminal] cannot remove {}: {e}", path.display()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_core::TerminalAuditWhat; + + fn record(at_ms: u64, term: &str) -> TerminalAuditRecord { + TerminalAuditRecord { + at_ms, + term: term.to_string(), + what: TerminalAuditWhat::Opened, + identity: "npub1example".to_string(), + device: Some("a1b2".to_string()), + label: Some("Pixel 8".to_string()), + via: Some(protocol::common::TerminalVia::Direct), + cwd: Some("/home/me".to_string()), + shell: Some("/bin/bash".to_string()), + cols: Some(80), + rows: Some(24), + reason: None, + code: None, + duration_ms: None, + why: None, + } + } + + fn lines(path: &Path) -> Vec { + fs::read_to_string(path).unwrap().lines().map(str::to_string).collect() + } + + #[test] + fn three_lines_verify_and_the_chain_links_them() { + let dir = tempfile::tempdir().unwrap(); + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + audit.record(record(2, "t2")); + audit.record(record(3, "t3")); + drop(audit); // joins the writer thread, so the file is complete + + let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); + assert_eq!(verify(&log), Ok(3)); + let lines = lines(&log); + // Each line's prev is the hash of the one before it; the first is GENESIS. + let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap(); + assert_eq!(value["prev"], GENESIS); + assert_eq!(value["what"], "opened"); + assert_eq!(value["atMs"], 1); + let value2: serde_json::Value = serde_json::from_str(&lines[1]).unwrap(); + assert_eq!(value2["prev"], hash_hex(lines[0].as_bytes())); + } + + #[test] + fn the_chain_continues_across_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); + + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + audit.record(record(2, "t2")); + drop(audit); + + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(3, "t3")); + drop(audit); + + // If the second run had started from GENESIS instead of the last + // line's hash, line 3's prev would not match and this would fail. + assert_eq!(verify(&log), Ok(3)); + } + + #[test] + fn an_edited_line_breaks_the_chain_at_the_next_line() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + audit.record(record(2, "t2")); + audit.record(record(3, "t3")); + drop(audit); + + let mut lines = lines(&log); + // Edit the middle line: line 3's prev no longer matches line 2. + lines[1] = lines[1].replace("\"t2\"", "\"tX\""); + fs::write(&log, lines.join("\n") + "\n").unwrap(); + + let err = verify(&log).unwrap_err(); + assert!(err.contains("line 3"), "{err}"); + } + + #[test] + fn a_deleted_line_breaks_the_chain() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + audit.record(record(2, "t2")); + audit.record(record(3, "t3")); + drop(audit); + + let lines = lines(&log); + fs::write(&log, format!("{}\n{}\n", lines[0], lines[2])).unwrap(); + + let err = verify(&log).unwrap_err(); + assert!(err.contains("line 2"), "{err}"); + } + + #[test] + fn an_empty_or_missing_log_verifies_as_zero() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(verify(&dir.path().join("nope.jsonl")), Ok(0)); + fs::write(dir.path().join("empty.jsonl"), "").unwrap(); + assert_eq!(verify(&dir.path().join("empty.jsonl")), Ok(0)); + } + + #[test] + fn syslog_lines_go_to_the_audit_target() { + // The line is emitted to the `audit` log target; a test just proves it + // does not panic and the file is written either way. + let dir = tempfile::tempdir().unwrap(); + let audit = Audit::open(dir.path(), true, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + drop(audit); + assert_eq!(verify(&dir.path().join(AUDIT_DIR).join(LOG_FILE)), Ok(1)); + } + + fn cast(dir: &Path, name: &str, bytes: usize, age_days: u64) { + let path = dir.join(name); + fs::write(&path, vec![b'x'; bytes]).unwrap(); + let when = SystemTime::now() - Duration::from_secs(age_days * 86_400); + let file = OpenOptions::new().write(true).open(&path).unwrap(); + file.set_modified(when).unwrap(); + } + + #[test] + fn retention_removes_the_oldest_by_age() { + let dir = tempfile::tempdir().unwrap(); + cast(dir.path(), "term-a.cast", 10, 3); + cast(dir.path(), "term-b.cast", 10, 1); + cast(dir.path(), "term-c.cast", 10, 0); + fs::write(dir.path().join(LOG_FILE), "kept\n").unwrap(); + + run_retention(dir.path(), Retention { max_days: 2, max_bytes: 0 }); + + assert!(!dir.path().join("term-a.cast").exists(), "the oldest, over the age limit, is gone"); + assert!(dir.path().join("term-b.cast").exists()); + assert!(dir.path().join("term-c.cast").exists()); + assert!(dir.path().join(LOG_FILE).exists(), "the event log is never removed"); + } + + #[test] + fn retention_removes_the_oldest_while_over_the_size_limit() { + let dir = tempfile::tempdir().unwrap(); + cast(dir.path(), "term-a.cast", 100, 2); + cast(dir.path(), "term-b.cast", 100, 1); + cast(dir.path(), "term-c.cast", 100, 0); + + // Room for two of the three: the oldest goes. + run_retention(dir.path(), Retention { max_days: 0, max_bytes: 200 }); + + assert!(!dir.path().join("term-a.cast").exists()); + assert!(dir.path().join("term-b.cast").exists()); + assert!(dir.path().join("term-c.cast").exists()); + } + + #[test] + fn retention_leaves_other_files_and_the_log_alone() { + let dir = tempfile::tempdir().unwrap(); + cast(dir.path(), "term-a.cast", 10, 30); + fs::write(dir.path().join("notes.txt"), "keep me").unwrap(); + run_retention(dir.path(), Retention { max_days: 1, max_bytes: 0 }); + assert!(!dir.path().join("term-a.cast").exists()); + assert!(dir.path().join("notes.txt").exists()); + } +} diff --git a/crates/bridge-runtime/src/config.rs b/crates/bridge-runtime/src/config.rs index b38fefc2..ed6ff654 100644 --- a/crates/bridge-runtime/src/config.rs +++ b/crates/bridge-runtime/src/config.rs @@ -26,6 +26,9 @@ pub struct Flags { pub agent_host: Option, pub service: bool, pub test_mode: bool, + /// `--terminal`: run terminals on this bridge. Never switched from a + /// phone — the machine's owner decides. + pub terminal: bool, pub direct_listen: Option, pub direct_onion_listen: Option, pub direct_endpoints: Vec, @@ -52,6 +55,8 @@ struct FileConfig { agent_host_path: Option, node_path: Option, direct: Option, + terminal: Option, + audit: Option, /// Keys this bridge does not know, kept only to warn about them. #[serde(flatten)] unknown: BTreeMap, @@ -67,12 +72,47 @@ struct FileDirect { unknown: BTreeMap, } +/// The `terminal` object: whether this machine runs terminals, who may open +/// one, and what runs in it. Off unless the owner turns it on. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTerminal { + enabled: Option, + /// Whether a terminal may open over a relay as well as the direct link. + relays: Option, + /// How long a terminal with no client attached lives. 0 keeps it until it + /// ends on its own. + idle_minutes: Option, + max_terminals: Option, + /// The program to run in a terminal, when the owner wants a fixed one. + command: Option>, + #[serde(flatten)] + unknown: BTreeMap, +} + +/// The `audit` object: how long recordings are kept and whether each audit +/// line is also copied to the system log. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileAudit { + max_days: Option, + max_bytes: Option, + syslog: Option, + #[serde(flatten)] + unknown: BTreeMap, +} + /// The keys in `config.json` this bridge does not know (a typo, or a flat /// `"direct.listen"` for the nested `"direct": {"listen": …}`), which it /// would otherwise ignore without a word. fn unknown_keys(file: &FileConfig) -> Vec { - let nested = file.direct.iter().flat_map(|d| d.unknown.keys().map(|k| format!("direct.{k}"))); - file.unknown.keys().cloned().chain(nested).collect() + let nested = |prefix: &str, keys: std::collections::btree_map::Keys<'_, String, serde_json::Value>| { + keys.map(|k| format!("{prefix}.{k}")).collect::>() + }; + let direct = file.direct.iter().flat_map(|d| nested("direct", d.unknown.keys())); + let terminal = file.terminal.iter().flat_map(|t| nested("terminal", t.unknown.keys())); + let audit = file.audit.iter().flat_map(|a| nested("audit", a.unknown.keys())); + file.unknown.keys().cloned().chain(direct).chain(terminal).chain(audit).collect() } /// The direct link (see `crate::direct`): where to listen, and what to @@ -141,6 +181,89 @@ fn direct_config(flags: &Flags, file: Option) -> Result>, +} + +impl Default for TerminalSettings { + fn default() -> Self { + Self { + enabled: false, + relays_allowed: false, + idle_minutes: DEFAULT_TERMINAL_IDLE_MINUTES, + max_terminals: bridge_core::TerminalConfig::default().max_terminals, + command: None, + } + } +} + +/// How the terminal audit is kept: how long recordings live, how large the +/// audit directory may grow, and whether each line is also copied to the +/// system log. 0 for `max_days` / `max_bytes` means no limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditSettings { + pub max_days: u64, + pub max_bytes: u64, + pub syslog: bool, +} + +impl Default for AuditSettings { + fn default() -> Self { + Self { max_days: DEFAULT_AUDIT_MAX_DAYS, max_bytes: DEFAULT_AUDIT_MAX_BYTES, syslog: false } + } +} + +fn terminal_settings(flags: &Flags, file: Option) -> TerminalSettings { + let file = file.unwrap_or_default(); + // Flags win, then the environment, then the file, then the default. The + // flag and the environment variable can only switch terminals on. + let enabled = if flags.terminal { + true + } else if let Some(v) = env_bool("CODEDECK_TERMINAL") { + v + } else { + file.enabled.unwrap_or(false) + }; + let defaults = TerminalSettings::default(); + TerminalSettings { + enabled, + relays_allowed: file.relays.unwrap_or(defaults.relays_allowed), + idle_minutes: file.idle_minutes.unwrap_or(defaults.idle_minutes).min(MAX_TERMINAL_IDLE_MINUTES), + max_terminals: file.max_terminals.unwrap_or(defaults.max_terminals), + command: file.command.filter(|c| !c.is_empty()), + } +} + +fn audit_settings(file: Option) -> AuditSettings { + let file = file.unwrap_or_default(); + let defaults = AuditSettings::default(); + AuditSettings { + max_days: file.max_days.unwrap_or(defaults.max_days), + max_bytes: file.max_bytes.unwrap_or(defaults.max_bytes), + syslog: file.syslog.unwrap_or(defaults.syslog), + } +} + /// An admin endpoint that registers a paired phone's pubkey. #[derive(Clone)] pub struct RegisterEndpoint { @@ -177,6 +300,11 @@ pub struct Config { /// Environment handed to the agent host (driver settings). pub host_env: BTreeMap, pub direct: DirectConfig, + /// Terminals on this machine: whether they run, who may have one, and what + /// runs in one. + pub terminal: TerminalSettings, + /// How the terminal audit is kept. + pub audit: AuditSettings, } /// Read an env var; empty counts as unset (Compose's `${VAR:-}` defines every @@ -382,6 +510,8 @@ pub fn load(flags: &Flags) -> Result { put("CODEDECK_DEEPSEEK_HOME", Some(home.join("dsh").to_string_lossy().into_owned())); let direct = direct_config(flags, file.direct)?; + let terminal = terminal_settings(flags, file.terminal); + let audit = audit_settings(file.audit); let agent_host_path = flags .agent_host @@ -409,6 +539,8 @@ pub fn load(flags: &Flags) -> Result { test_mode, host_env, direct, + terminal, + audit, home, }) } @@ -456,6 +588,8 @@ mod tests { agent_host_path, node_path, direct, + terminal, + audit, unknown: _, } = file; let set = [ @@ -485,6 +619,65 @@ mod tests { let direct = FileDirect { listen, onion_listen, endpoints, unknown: Default::default() }; direct_config(&Flags::default(), Some(direct)).expect("the example's direct section is valid"); proxy_host_port(&tor_proxy_url.unwrap()).expect("the example's proxy URL is valid"); + + let FileTerminal { enabled, relays, idle_minutes, max_terminals, command, unknown: _ } = + terminal.expect("config.example.json sets terminal"); + assert!( + enabled.is_some() && relays.is_some() && idle_minutes.is_some() && max_terminals.is_some() && command.is_some(), + "config.example.json sets every terminal key" + ); + let FileAudit { max_days, max_bytes, syslog, unknown: _ } = audit.expect("config.example.json sets audit"); + assert!(max_days.is_some() && max_bytes.is_some() && syslog.is_some(), "config.example.json sets every audit key"); + } + + #[test] + fn terminal_settings_read_the_file_and_the_flag_wins() { + // Defaults: terminals off, the engine's own limits. (Only when the + // environment does not itself switch them on.) + if env("CODEDECK_TERMINAL").is_none() { + assert_eq!(terminal_settings(&Flags::default(), None), TerminalSettings::default()); + } + + let file: FileTerminal = serde_json::from_str( + r#"{"enabled":true,"relays":true,"idleMinutes":30,"maxTerminals":2,"command":["tmux","new"]}"#, + ) + .unwrap(); + let settings = terminal_settings(&Flags::default(), Some(file)); + assert!(settings.enabled && settings.relays_allowed); + assert_eq!(settings.idle_minutes, 30); + assert_eq!(settings.max_terminals, 2); + assert_eq!(settings.command.as_deref(), Some(&["tmux".to_string(), "new".to_string()][..])); + + // The flag turns terminals on whatever the file says. + let file: FileTerminal = serde_json::from_str(r#"{"enabled":false}"#).unwrap(); + assert!(terminal_settings(&Flags { terminal: true, ..Default::default() }, Some(file)).enabled); + + // An idle time is capped at a day, and an empty command is none. + let file: FileTerminal = serde_json::from_str(r#"{"idleMinutes":100000,"command":[]}"#).unwrap(); + let settings = terminal_settings(&Flags::default(), Some(file)); + assert_eq!(settings.idle_minutes, MAX_TERMINAL_IDLE_MINUTES); + assert_eq!(settings.command, None); + } + + #[test] + fn audit_settings_read_the_file_with_defaults() { + assert_eq!(audit_settings(None), AuditSettings::default()); + assert_eq!(AuditSettings::default().max_days, DEFAULT_AUDIT_MAX_DAYS); + assert_eq!(AuditSettings::default().max_bytes, DEFAULT_AUDIT_MAX_BYTES); + let file: FileAudit = serde_json::from_str(r#"{"maxDays":7,"maxBytes":1024,"syslog":true}"#).unwrap(); + assert_eq!(audit_settings(Some(file)), AuditSettings { max_days: 7, max_bytes: 1024, syslog: true }); + } + + #[test] + fn unknown_keys_in_the_terminal_and_audit_objects_are_named() { + let file: FileConfig = serde_json::from_str( + r#"{"terminal":{"enabled":true,"idleMinute":30},"audit":{"maxDay":7},"terminal.enabled":true}"#, + ) + .unwrap(); + assert_eq!( + unknown_keys(&file), + vec!["terminal.enabled".to_string(), "terminal.idleMinute".to_string(), "audit.maxDay".to_string()] + ); } #[test] diff --git a/crates/bridge-runtime/src/lib.rs b/crates/bridge-runtime/src/lib.rs index 6734c928..dc273ac4 100644 --- a/crates/bridge-runtime/src/lib.rs +++ b/crates/bridge-runtime/src/lib.rs @@ -6,6 +6,7 @@ //! The engine decides; this crate only carries out its effects and feeds it //! what happened. +pub mod audit; pub mod config; pub mod direct; pub mod host; @@ -15,6 +16,7 @@ pub mod relay; pub mod runtime; pub mod state; pub mod terminal; +pub mod terminal_allow; pub mod transcripts; pub mod work; pub mod workspace; diff --git a/crates/bridge-runtime/src/main.rs b/crates/bridge-runtime/src/main.rs index 632428c6..f4658fdf 100644 --- a/crates/bridge-runtime/src/main.rs +++ b/crates/bridge-runtime/src/main.rs @@ -11,6 +11,7 @@ use bridge_runtime::config::{self, Config, Flags}; use bridge_runtime::runtime::{self, Mode, Options, Outcome}; use bridge_runtime::state::{acquire_lock, lock_holder, StateFile}; use bridge_runtime::workspace::FsWorkspace; +use bridge_runtime::{audit, terminal_allow}; use clap::{Parser, Subcommand}; use protocol::capabilities::PROTOCOL_VERSION; @@ -61,6 +62,9 @@ struct Cli { /// Agents answer canned test commands instead of running for real (no API key needed) [env: CODEDECK_TEST_MODE] #[arg(long, global = true)] test_mode: bool, + /// Run terminals on this bridge (a shell a paired, approved phone drives). Off unless set; never switched from a phone [env: CODEDECK_TERMINAL] + #[arg(long, global = true)] + terminal: bool, /// Serve phones directly over wss (self-signed, pinned) on this ip:port, e.g. 0.0.0.0:7447 [env: CODEDECK_DIRECT_LISTEN] #[arg(long, global = true)] direct_listen: Option, @@ -93,10 +97,36 @@ enum Command { #[command(subcommand)] action: AgentsCommand, }, + /// Approve or withdraw the devices allowed a terminal on this machine + Terminal { + #[command(subcommand)] + action: TerminalCommand, + }, + /// Inspect the terminal audit + Audit { + #[command(subcommand)] + action: AuditCommand, + }, /// Print the version Version, } +#[derive(Subcommand)] +enum TerminalCommand { + /// Devices that asked for a terminal and were refused, newest last seen last + Pending, + /// Approve a device (its id, or an unambiguous prefix of one) + Allow { device: String }, + /// Withdraw a device's approval; a running bridge closes its terminals at once + Disallow { device: String }, +} + +#[derive(Subcommand)] +enum AuditCommand { + /// Check the terminal audit's hash chain, printing the line count or the first break + Verify, +} + #[derive(Subcommand)] enum AgentsCommand { /// Every agent and where it stands on this machine @@ -139,6 +169,7 @@ fn main() -> ExitCode { agent_host: cli.agent_host.clone(), service: cli.service, test_mode: cli.test_mode, + terminal: cli.terminal, direct_listen: cli.direct_listen.clone(), direct_onion_listen: cli.direct_onion_listen.clone(), direct_endpoints: cli.direct_endpoints.clone(), @@ -167,11 +198,64 @@ fn main() -> ExitCode { Ok(ExitCode::SUCCESS) } Command::Agents { action } => agents(&config, action), + Command::Terminal { action } => terminal(&config, action), + Command::Audit { action } => audit_cmd(&config, action), Command::Version => unreachable!(), }; result.unwrap_or_else(|err| fail(&err)) } +/// `terminal pending|allow|disallow`: the owner's decision about which devices +/// may open a shell on this machine. These only edit +/// `/terminal-allow.json` atomically, so they work while the bridge +/// runs: it re-reads the file (cached by mtime) on the next terminal message +/// and within a few seconds otherwise, and a withdrawn device's terminals end +/// at once. +fn terminal(config: &Config, action: TerminalCommand) -> Result { + let path = terminal_allow::path_in(&config.home); + match action { + TerminalCommand::Pending => { + let list = terminal_allow::read(&path); + if list.pending.is_empty() { + println!("No device is waiting for terminal approval."); + } else { + println!("Devices waiting for approval (approve one with `codedeck-bridge terminal allow `):"); + for line in terminal_allow::pending_lines(&list) { + println!("{line}"); + } + } + } + TerminalCommand::Allow { device } => println!("{}", terminal_allow::allow(&path, &device)?), + TerminalCommand::Disallow { device } => println!("{}", terminal_allow::disallow(&path, &device)?), + } + Ok(ExitCode::SUCCESS) +} + +/// `audit verify`: walk the terminal audit and recompute its hash chain. A +/// break (an edited or deleted line) is reported with its line number and the +/// command exits non-zero. +fn audit_cmd(config: &Config, action: AuditCommand) -> Result { + match action { + AuditCommand::Verify => { + let path = config.home.join(audit::AUDIT_DIR).join(audit::LOG_FILE); + match audit::verify(&path) { + Ok(0) => { + println!("The terminal audit is empty ({}).", path.display()); + Ok(ExitCode::SUCCESS) + } + Ok(lines) => { + println!("OK: {lines} line(s), the chain is intact ({}).", path.display()); + Ok(ExitCode::SUCCESS) + } + Err(err) => { + eprintln!("audit verify: {err}"); + Ok(ExitCode::FAILURE) + } + } + } + } +} + /// Runs the agent host's own command, with the environment the bridge gives /// it when serving, so it works on the same agent cache. fn agents(config: &Config, action: AgentsCommand) -> Result { diff --git a/crates/bridge-runtime/src/terminal_allow.rs b/crates/bridge-runtime/src/terminal_allow.rs new file mode 100644 index 00000000..ba05ef73 --- /dev/null +++ b/crates/bridge-runtime/src/terminal_allow.rs @@ -0,0 +1,379 @@ +//! `/terminal-allow.json` — the devices the machine's owner approved +//! for a terminal, and the ones that asked and were refused. +//! +//! Every client of a paired identity shares the identity key, so the key alone +//! cannot say which phone is asking; a device is named by the first session +//! key it granted this bridge (a name that survives rotation) and approved on +//! the machine. The file holds two lists — the approved device ids, and the +//! devices refused a terminal with the label and time the owner needs to +//! decide about them: +//! +//! ```json +//! { "devices": ["a1b2…"], "pending": [{ "device": "c3d4…", "label": "Pixel 8", "lastSeenMs": 1700000000000 }] } +//! ``` +//! +//! It is owner-only (`0600`) and written atomically (a temporary file, then a +//! rename), so a crash never leaves half a file, and the CLI +//! (`codedeck-bridge terminal allow|disallow`) edits it while the bridge runs. +//! The bridge re-reads it, cached by modification time, so a change takes +//! effect without a restart: [`Cache::changed`] is one `metadata` call, and +//! the file is read only when its mtime moved. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Sender}; +use std::thread::JoinHandle; +use std::time::SystemTime; + +use bridge_core::{PendingDevice, TerminalAllowlist}; + +/// The file's name in the bridge home. +pub const ALLOWLIST_FILE: &str = "terminal-allow.json"; + +/// The allowlist file under `home`. +pub fn path_in(home: &Path) -> PathBuf { + home.join(ALLOWLIST_FILE) +} + +/// Read the file. Total: a missing or unreadable file is an empty list, and a +/// corrupt one is an empty list with a warning. An empty list refuses every +/// device — fail closed, the safe reading of a file the owner may have edited +/// by hand. +pub fn read(path: &Path) -> TerminalAllowlist { + match fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { + log::warn!("[Terminal] {} is not a valid allowlist ({e}); treating it as empty", path.display()); + TerminalAllowlist::default() + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => TerminalAllowlist::default(), + Err(e) => { + log::warn!("[Terminal] cannot read {}: {e}; treating it as empty", path.display()); + TerminalAllowlist::default() + } + } +} + +/// Write the list atomically and owner-only. +pub fn write(path: &Path, list: &TerminalAllowlist) -> Result<(), String> { + let json = serde_json::to_vec_pretty(list).expect("an allowlist serializes"); + crate::state::write_private(path, &json).map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +/// The allowlist file and the modification time last seen. The runtime shares +/// one of these between the command path and its periodic check, so a change +/// is noticed once and read once. +pub struct Cache { + path: PathBuf, + seen: Option, + checked: bool, +} + +impl Cache { + pub fn new(home: &Path) -> Self { + Self { path: path_in(home), seen: None, checked: false } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The list when the file changed since the last check, else `None`. The + /// first call always returns the file's content, so it doubles as the + /// start-up read. + pub fn changed(&mut self) -> Option { + let mtime = fs::metadata(&self.path).ok().and_then(|m| m.modified().ok()); + if self.checked && mtime == self.seen { + return None; + } + self.checked = true; + self.seen = mtime; + Some(read(&self.path)) + } + + /// The engine's refused-device list merged with the approved devices as + /// they are on disk now (the owner may have approved or withdrawn one in + /// the CLI while this bridge ran). Nothing is written here: the caller + /// hands the result to a [`Writer`] thread and to the engine, whose own + /// copy of the approved devices may otherwise be a file change behind. + /// Reading on the runtime thread is fine; writing the file is not. + pub fn merge_pending(&self, pending: &[PendingDevice]) -> TerminalAllowlist { + let mut list = read(&self.path); + list.pending = pending.to_vec(); + list + } +} + +/// Writes the allowlist on a thread of its own, so the bridge's single runtime +/// thread never touches the file (the same reason the audit has a writer +/// thread). Fed whole lists in order; each list is complete, so the last write +/// wins. +pub struct Writer { + tx: Option>, + thread: Option>, +} + +impl Writer { + pub fn start(path: PathBuf) -> Writer { + let (tx, rx) = mpsc::channel::(); + let thread = std::thread::Builder::new() + .name("terminal-allow".into()) + .spawn(move || { + while let Ok(list) = rx.recv() { + if let Err(err) = write(&path, &list) { + log::warn!("[Terminal] cannot write the allowlist: {err}"); + } + } + }) + .map_err(|e| log::error!("[Terminal] cannot start the allowlist writer thread: {e}")) + .ok(); + Writer { tx: Some(tx), thread } + } + + /// Queue a list to write. Dropping the returned handle later flushes it. + pub fn write(&self, list: TerminalAllowlist) { + if let Some(tx) = &self.tx { + if tx.send(list).is_err() { + log::warn!("[Terminal] the allowlist writer thread is gone; a change was lost"); + } + } + } +} + +impl Drop for Writer { + fn drop(&mut self) { + // Closing the channel ends the writer thread once it has drained. + self.tx.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// The refused devices as lines for a person: id, label, when last seen. +pub fn pending_lines(list: &TerminalAllowlist) -> Vec { + list.pending + .iter() + .map(|p| { + let label = p.label.as_deref().filter(|l| !l.is_empty()).unwrap_or("(no name)"); + format!(" {} \"{}\" last seen {}", p.device, label, when(p.last_seen_ms)) + }) + .collect() +} + +/// A Unix-epoch millisecond timestamp as an ISO-8601 UTC instant. +fn when(ms: u64) -> String { + bridge_core::time::iso(ms) +} + +/// Approve a device: move it from the refused list to the approved one. +/// `target` is the id, or an unambiguous prefix of one. +pub fn allow(path: &Path, target: &str) -> Result { + let mut list = read(path); + if resolve_device(&list.devices, target)?.is_some() { + return Ok(format!("\"{target}\" is already approved.")); + } + let Some(id) = resolve_pending(&list.pending, target)? else { + return Err(format!( + "no device waiting for approval matches \"{target}\". Run `codedeck-bridge terminal pending` to list them." + )); + }; + let label = list.pending.iter().find(|p| p.device == id).and_then(|p| p.label.clone()); + list.pending.retain(|p| p.device != id); + list.devices.insert(id.clone()); + write(path, &list)?; + Ok(format!("Approved {id}{}.", label.map(|l| format!(" \"{l}\"")).unwrap_or_default())) +} + +/// Withdraw a device's approval: remove it from the approved list. A running +/// bridge closes that device's terminals the moment it next reads the file. +pub fn disallow(path: &Path, target: &str) -> Result { + let mut list = read(path); + let Some(id) = resolve_device(&list.devices, target)? else { + return Err(format!( + "no approved device matches \"{target}\". Run `codedeck-bridge terminal pending`, or read {}, to see them.", + path.display() + )); + }; + list.devices.remove(&id); + write(path, &list)?; + Ok(format!("Withdrew {id}. A running bridge closes its terminals at once.")) +} + +/// The approved device id `target` names: itself, or the single one it is a +/// prefix of. `Ok(None)` when nothing matches; an error when more than one +/// does, so an ambiguous prefix is never resolved by guessing. +fn resolve_device(devices: &BTreeSet, target: &str) -> Result, String> { + if target.is_empty() { + return Ok(None); + } + if devices.contains(target) { + return Ok(Some(target.to_string())); + } + let matches: Vec<&String> = devices.iter().filter(|d| d.starts_with(target)).collect(); + unique(target, matches) +} + +/// The same for a device in the refused list, returning its id. +fn resolve_pending(pending: &[PendingDevice], target: &str) -> Result, String> { + if target.is_empty() { + return Ok(None); + } + if let Some(p) = pending.iter().find(|p| p.device == target) { + return Ok(Some(p.device.clone())); + } + let matches: Vec<&String> = pending.iter().map(|p| &p.device).filter(|d| d.starts_with(target)).collect(); + unique(target, matches) +} + +fn unique(target: &str, matches: Vec<&String>) -> Result, String> { + match matches.as_slice() { + [] => Ok(None), + [one] => Ok(Some((*one).clone())), + many => Err(format!( + "\"{target}\" matches more than one device: {}", + many.iter().map(|d| d.as_str()).collect::>().join(", ") + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn list_with(devices: &[&str], pending: &[(&str, &str)]) -> TerminalAllowlist { + TerminalAllowlist { + devices: devices.iter().map(|d| d.to_string()).collect(), + pending: pending + .iter() + .map(|(device, label)| PendingDevice { + device: device.to_string(), + label: Some(label.to_string()), + last_seen_ms: 1_700_000_000_000, + }) + .collect(), + } + } + + #[test] + fn a_missing_or_corrupt_file_reads_as_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + assert_eq!(read(&path), TerminalAllowlist::default()); + fs::write(&path, "{not json").unwrap(); + assert_eq!(read(&path), TerminalAllowlist::default()); + } + + #[test] + fn the_list_round_trips_and_the_file_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let list = list_with(&["aaaa", "bbbb"], &[("cccc", "Pixel 8")]); + write(&path, &list).unwrap(); + assert_eq!(read(&path), list); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + #[test] + fn the_cache_re_reads_only_when_the_mtime_moved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let mut cache = Cache::new(dir.path()); + + // The first check reads (an absent file reads as empty)... + assert_eq!(cache.changed(), Some(TerminalAllowlist::default())); + // ...and a second with nothing changed is one metadata call and None. + assert_eq!(cache.changed(), None); + + // A write is noticed once (the file went from absent to present, so + // its mtime moved), and not read again until it moves again. + write(&path, &list_with(&["aaaa"], &[])).unwrap(); + let seen = cache.changed().expect("the changed file is read"); + assert!(seen.devices.contains("aaaa")); + assert_eq!(cache.changed(), None, "no second read without a new change"); + } + + #[test] + fn merge_pending_keeps_the_approved_devices_as_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + // The owner approved one device in the CLI since the bridge last read. + write(&path, &list_with(&["aaaa"], &[])).unwrap(); + + let cache = Cache::new(dir.path()); + let pending = vec![PendingDevice { device: "cccc".into(), label: Some("Pixel 8".into()), last_seen_ms: 5 }]; + let merged = cache.merge_pending(&pending); + assert!(merged.devices.contains("aaaa"), "the device on disk is kept"); + assert_eq!(merged.pending, pending); + // Merging only reads: the file is unchanged until a Writer writes it. + assert!(read(&path).pending.is_empty()); + } + + #[test] + fn the_writer_writes_on_its_own_thread() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let writer = Writer::start(path.clone()); + writer.write(list_with(&["aaaa"], &[("cccc", "Pixel 8")])); + drop(writer); // joins, so the write has landed + let list = read(&path); + assert!(list.devices.contains("aaaa")); + assert_eq!(list.pending.len(), 1); + } + + #[test] + fn allow_moves_a_pending_device_and_accepts_an_unambiguous_prefix() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&[], &[("c3d4e5f6", "Pixel 8")])).unwrap(); + + let message = allow(&path, "c3d4").unwrap(); + assert!(message.contains("c3d4e5f6")); + let list = read(&path); + assert!(list.devices.contains("c3d4e5f6")); + assert!(list.pending.is_empty()); + // Approving it again is not an error. + assert!(allow(&path, "c3d4e5f6").unwrap().contains("already approved")); + } + + #[test] + fn an_ambiguous_prefix_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&[], &[("aaaa1111", "one"), ("aaaa2222", "two")])).unwrap(); + let err = allow(&path, "aaaa").unwrap_err(); + assert!(err.contains("more than one device"), "{err}"); + } + + #[test] + fn allow_with_no_match_names_how_to_list_them() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let err = allow(&path, "nope").unwrap_err(); + assert!(err.contains("terminal pending"), "{err}"); + } + + #[test] + fn disallow_removes_an_approved_device_and_reports_no_match() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&["aaaa1111", "bbbb2222"], &[])).unwrap(); + + let message = disallow(&path, "aaaa").unwrap(); + assert!(message.contains("aaaa1111")); + assert_eq!(read(&path).devices, ["bbbb2222".to_string()].into_iter().collect()); + assert!(disallow(&path, "cccc").unwrap_err().contains("no approved device")); + } + + #[test] + fn pending_lines_name_id_label_and_time() { + let list = list_with(&[], &[("c3d4e5f6", "Pixel 8")]); + let lines = pending_lines(&list); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("c3d4e5f6") && lines[0].contains("Pixel 8"), "{}", lines[0]); + } +} From b19bd40336c87b54e4a2baf9f8b518f5c94f5b2d Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:41:47 +0200 Subject: [PATCH 17/50] feat(bridge-runtime): wire the allowlist reload and the audit into the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime now builds the engine's TerminalConfig from the configuration and the allowlist file at start, opens the audit and allowlist writer threads, and carries out the two effects that touch the terminal files. The allowlist is checked on the command path — one `metadata` call before each inbound event, with the file read only when its mtime moved — so `terminal disallow` takes effect on the very next terminal command. The runtime cannot tell which commands are terminal ones (they are NIP-44 ciphertext until the engine decrypts them), so the check covers every inbound event. A second check every five seconds closes a withdrawn device's terminals even when no terminal message arrives. The two share one mtime cache, so a change is noticed and read once. `Effect::TerminalPending` merges the refused devices with the approved ones as they are on disk (the owner may have run `terminal allow` since the engine last read the file), hands the merged list to the writer thread and to the engine — whose own copy of the approved devices would otherwise be a file change behind — and writes nothing on the runtime thread. `Effect::Audit` goes to the writer thread and never blocks the runtime. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/runtime.rs | 102 +++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index f5790d66..bea4ff6e 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -10,11 +10,15 @@ use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; use std::rc::Rc; +use std::time::Duration; use base64::engine::general_purpose::STANDARD; use base64::Engine as _; use bridge_core::ports::{Ports, System}; -use bridge_core::{Config as EngineConfig, Effect, Engine, Input, NotifyLevel, PairingCloseReason, PairingWindowInfo, TimerId}; +use bridge_core::{ + Config as EngineConfig, Effect, Engine, Input, NotifyLevel, PairingCloseReason, PairingWindowInfo, TerminalConfig, + TimerId, +}; use protocol::commands::UploadFileMsg; use protocol::crypto::Keypair; use protocol::events::{BridgeToPhone, TerminalOutputMsg}; @@ -26,10 +30,11 @@ use crate::host::{self, HostCommand, HostHandle}; use crate::relay::Relays; use crate::state::StateFile; use crate::terminal::Terminals; +use crate::terminal_allow::{self, Cache as AllowlistCache}; use crate::transcripts::FileTranscripts; use crate::workspace::FsWorkspace; use crate::uploads::{self, Uploads}; -use crate::{qr, work}; +use crate::{audit, qr, work}; /// The real clock, ids and environment. pub struct RealSystem; @@ -104,6 +109,14 @@ struct Runtime { /// rings of output and their recordings. Empty unless the owner turned /// terminals on: the engine emits no terminal effect otherwise. terminals: Terminals, + /// The terminal audit's writer thread. + audit: audit::Audit, + /// `/terminal-allow.json`, shared with the periodic change check so + /// its mtime is read once between the two. + allowlist: Rc>, + /// Writes the allowlist file, on a thread of its own: the runtime's single + /// thread never writes it. + allowlist_writer: terminal_allow::Writer, } fn say(line: &str) { @@ -120,6 +133,18 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio engine_config.host_kind = Some(config.host_kind); engine_config.relays = config.relays.clone(); engine_config.bridge_version = crate::version().to_string(); + // The owner's decision about terminals, and the devices already approved. + // The allowlist file is read once here (the cache's first check) and again + // whenever its mtime moves. + let mut allowlist_cache = AllowlistCache::new(&config.home); + let allowlist = allowlist_cache.changed().unwrap_or_default(); + engine_config.terminal = TerminalConfig { + enabled: config.terminal.enabled, + relays_allowed: config.terminal.relays_allowed, + max_terminals: config.terminal.max_terminals, + idle_secs: config.terminal.idle_minutes * 60, + allowlist, + }; let first_root = config.workspace_roots[0].clone(); if let Some(keep) = config.transcript_keep_last { engine_config.transcript_keep_last = keep; @@ -158,6 +183,14 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio let nostr_http = work::NostrHttp::new(config.tor_proxy.as_deref())?; let terminals = Terminals::new(config.home.join("audit"), inputs.clone()); + // The audit's writer thread, fed by a channel: the runtime never touches + // the log file itself. + let audit = audit::Audit::open( + &config.home, + config.audit.syslog, + audit::Retention { max_days: config.audit.max_days, max_bytes: config.audit.max_bytes }, + ); + let allowlist_writer = terminal_allow::Writer::start(terminal_allow::path_in(&config.home)); let mut rt = Runtime { engine: Engine::new(engine_config, ports), config, @@ -173,7 +206,13 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio pairing_urls: options.pairing_urls, direct, terminals, + audit, + allowlist: Rc::new(RefCell::new(allowlist_cache)), + allowlist_writer, }; + // The periodic half of the allowlist check: a `terminal disallow` with no + // terminal message arriving still closes a terminal, within a few seconds. + watch_allowlist(Rc::clone(&rt.allowlist), rt.inputs.clone()); rt.step(Input::Start).await; if rt.mode == Mode::Pair || rt.engine.paired_phones().is_empty() { @@ -190,6 +229,28 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio Ok(rt.outcome) } +/// Watch the terminal allowlist file for a change every few seconds, so a +/// `terminal disallow` closes a device's terminals even when no terminal +/// message arrives to carry the check. The check is one `metadata` call; the +/// file is read only when its mtime moved, and the same cache is shared with +/// the per-message check, so the change is noticed and read once. +fn watch_allowlist(cache: Rc>, inputs: mpsc::UnboundedSender) { + tokio::task::spawn_local(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(5)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The first tick is immediate; the start-up read already happened. + ticker.tick().await; + loop { + ticker.tick().await; + if let Some(list) = cache.borrow_mut().changed() { + if inputs.send(Input::TerminalAllowlist(list)).is_err() { + return; + } + } + } + }); +} + fn watch_signals(inputs: mpsc::UnboundedSender) { tokio::task::spawn_local(async move { #[cfg(unix)] @@ -224,6 +285,29 @@ impl Runtime { if let Input::TerminalExited { term, .. } = &input { self.terminals.forget(term); } + // A terminal command can arrive between the periodic checks, so the + // allowlist's mtime is checked here too, before the command is routed: + // `terminal disallow` then takes effect on the very next command, not + // up to five seconds later. The runtime cannot tell which commands are + // terminal ones — they are NIP-44 ciphertext until the engine decrypts + // them — so this covers every inbound event, at the cost of one + // `metadata` call each and a read only when the file moved. A bridge + // with terminals off (the default) has nothing to check. + let changed = if self.config.terminal.enabled && matches!(input, Input::RelayEvent { .. }) { + self.allowlist.borrow_mut().changed() + } else { + None + }; + if let Some(list) = changed { + if self.dispatch(Input::TerminalAllowlist(list)).await { + return true; + } + } + self.dispatch(input).await + } + + /// Feed one input to the engine and carry out its effects in order. + async fn dispatch(&mut self, input: Input) -> bool { let effects = self.engine.handle(input); for effect in effects { if self.execute(effect) { @@ -344,11 +428,17 @@ impl Runtime { } Effect::TerminalClose { term } => self.terminals.close(&term), Effect::TerminalPending(pending) => { - log::debug!("[Runtime] {} device(s) are waiting for terminal approval", pending.len()); - } - Effect::Audit(record) => { - log::debug!("[Runtime] terminal audit line: {:?} {}", record.what, record.term); + // Merge the refused devices with the approved ones as they are + // on disk now (the owner may have run `terminal allow` since + // this engine last read the file). The write goes to a thread + // of its own — never the runtime's — and the engine is handed + // the merged list, whose approved devices would otherwise be a + // file change behind. + let merged = self.allowlist.borrow().merge_pending(&pending); + self.allowlist_writer.write(merged.clone()); + let _ = self.inputs.send(Input::TerminalAllowlist(merged)); } + Effect::Audit(record) => self.audit.record(record), } false } From 038ca5a34e03b8fe1c1e0c691350d3e13643cdf6 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:41:48 +0200 Subject: [PATCH 18/50] docs(bridge): the terminal section How to enable terminals, approve and withdraw devices, what the audit records and how `audit verify` checks it, retention, and the honest limit of the hash chain: anyone running as the bridge's user can rewrite the whole file. Co-Authored-By: Claude Code --- docs/BRIDGE.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 1d42c9bc..31530253 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -134,6 +134,9 @@ install runbook. | `folders` | The project folders a phone can start sessions in. | | `agents list` | Every agent and where it stands on this machine: ready (and from where), not installed, or removed. | | `agents install …` / `agents remove …` | Install agents at the version this build pins, or remove what CodeDeck installed of them. A running bridge sees the change once its agent host restarts; the phone's Install and Remove take effect at once. | +| `terminal pending` | The devices that asked for a terminal and were refused, with their id, name and when they were last seen. | +| `terminal allow ` / `terminal disallow ` | Approve a device for a terminal, or withdraw it (an unambiguous id prefix works). These edit the allowlist file directly, so they work while the bridge runs; a withdrawn device's terminals close at once. | +| `audit verify` | Recompute the terminal audit's hash chain; prints the line count, or the first broken line and exits non-zero. | | `version` | The bridge version and protocol version. | `--test-mode` (or `CODEDECK_TEST_MODE=1`) makes the agents answer canned @@ -164,6 +167,9 @@ you need. | `direct.listen` | `CODEDECK_DIRECT_LISTEN` / `--direct-listen` | Serve phones directly over `wss://` on this `ip:port` (e.g. `0.0.0.0:7447`); off by default | | `direct.onionListen` | `CODEDECK_DIRECT_ONION_LISTEN` / `--direct-onion-listen` | A plain `ws://` listener for an onion service to forward to; loopback only (e.g. `127.0.0.1:7448`) | | `direct.endpoints` | `CODEDECK_DIRECT_ENDPOINTS` / `--direct-endpoint` | The URLs phones dial, in order: `wss://host:port`, or `ws://.onion:port` (default: the `wss://` listener's LAN address; none in a container, which only sees its own) | +| `terminal.enabled` | `CODEDECK_TERMINAL` / `--terminal` | Run terminals on this bridge — see [Terminal](#terminal) (default: off) | +| `terminal.relays`, `terminal.idleMinutes`, `terminal.maxTerminals`, `terminal.command` | — | Whether a terminal may open over a relay, how long an unattended one lives (default 60, up to 1440 minutes, 0 keeps it), how many run at once (default 4), and a fixed program to run instead of the user's shell | +| `audit.maxDays`, `audit.maxBytes`, `audit.syslog` | — | Terminal recording retention (default 90 days / 1 GiB, 0 = no limit) and whether each audit line is also copied to the system log (default: off) | When a Tor proxy is set, all Nostr traffic goes through it: relay connections, file downloads from a Blossom server, and pubkey registration on @@ -212,6 +218,61 @@ the heartbeat): add the host's addresses to `direct.endpoints`, or on the phone, in the machine's settings. To turn the link off, remove `direct` from `config.json`. +## Terminal + +A phone can open a real terminal on the bridge: a shell in a pseudo-terminal +as the bridge's own user, drawn by the app's terminal emulator, so an editor, +`htop` or `git add -p` work as they would over SSH. It is strictly more than +an agent in its most permissive mode, so it is off until the owner turns it on +and every device must be approved. The full design is in +[`TERMINAL.md`](TERMINAL.md). + +**Enable it** with `terminal.enabled` in `config.json`, `--terminal`, or +`CODEDECK_TERMINAL=1`. The bridge then advertises the capability and the phone +shows Terminal. With it off, every terminal message is refused. Terminals take +the direct link by default (LAN, VPN, or an onion endpoint through Orbot); +`terminal.relays: true` also lets them fall back to a relay, at the cost of a +relay seeing each event's size and timing. + +**Approve devices.** The identity key is shared by every client of an +identity, so a device is named by the first session key it granted this bridge +(an id that survives rotation) and approved on the machine. When a device that +is not approved asks, the bridge refuses it and records it; the phone shows +the id and name and the command to run: + +```sh +codedeck-bridge terminal pending # refused devices: id, name, last seen +codedeck-bridge terminal allow 1a2b3c4d # approve (an unambiguous id prefix works) +codedeck-bridge terminal disallow 1a2b3c4d +``` + +These edit `/terminal-allow.json` (owner-only, written atomically) and +work while the bridge runs: it re-reads the file — cached by its modification +time — on the next terminal message and within a few seconds otherwise. +**Withdrawing a device closes its terminals at once**, the kill switch for a +lost phone. + +**What is recorded.** Under `/audit/` (`0700`, files `0600`): + +- `terminal.jsonl` — one JSON line per event (opened, attached, takeover, + resized, closed, exited, refused), each with the device, identity, transport + and reason. Every line carries `prev`, the SHA-256 of the line before it (the + first line's is all zeros), so an edited or deleted line breaks the chain: + `codedeck-bridge audit verify` recomputes it and prints the line count, or + the first broken line, exiting non-zero. The honest limit: anyone running as + the bridge's user can rewrite the whole file, chain and all. The chain + catches tampering that is not that thorough; `audit.syslog: true` also + copies each line to the log target `audit` (journald / rsyslog, if a handler + forwards it), which survives even that. +- `term-.cast` — a full recording of each terminal in asciicast v2 + (replayable with `asciinema play`). Input typed at a password prompt is + recorded as a byte count, never the bytes. + +**Retention** removes the oldest whole recordings under `audit/` when they are +older than `audit.maxDays` (default 90) or while the directory is larger than +`audit.maxBytes` (default 1 GiB); 0 means no limit. `terminal.jsonl` is kept — +it is small and is the record of who opened what. + ## Files In ``: @@ -226,6 +287,10 @@ In ``: - `direct/cert.der`, `direct/key.der` — the direct link's certificate and its key (0600). Deleting them makes a new one; phones pick up the new pin from the next heartbeat. +- `terminal-allow.json` — the devices approved for a terminal, and the ones + refused (0600). See [Terminal](#terminal). +- `audit/` — the terminal audit: `terminal.jsonl` and the `term-*.cast` + recordings (0700, files 0600). ## Build from source From ed431c57ebf05ca16d2c938582b7c043cbaf0c6b Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:46:57 +0200 Subject: [PATCH 19/50] perf(bridge): no allowlist check on a bridge with terminals off, and the configured command runs The allowlist's mtime was read before every inbound event and every five seconds on every bridge, terminals or not; with terminals off (the default) there is no terminal to close, so neither check runs. On a Docker Desktop host the file sits on a slow file share, where each read of its metadata is time the phones' traffic waits. The PTY side now runs `terminal.command` when the owner set one, instead of always the shell. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/runtime.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index bea4ff6e..4749368a 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -212,7 +212,10 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio }; // The periodic half of the allowlist check: a `terminal disallow` with no // terminal message arriving still closes a terminal, within a few seconds. - watch_allowlist(Rc::clone(&rt.allowlist), rt.inputs.clone()); + // With terminals off there is no terminal to close. + if rt.config.terminal.enabled { + watch_allowlist(Rc::clone(&rt.allowlist), rt.inputs.clone()); + } rt.step(Input::Start).await; if rt.mode == Mode::Pair || rt.engine.paired_phones().is_empty() { @@ -404,7 +407,7 @@ impl Runtime { // `None`: the owner has configured no `terminal.command`, so // the shell runs. A failure is answered as a refusal, not // left as a terminal that never appears. - let result = self.terminals.spawn(&term, &cwd, cols, rows, None); + let result = self.terminals.spawn(&term, &cwd, cols, rows, self.config.terminal.command.as_deref()); let _ = match result { Ok(shell) => self.inputs.send(Input::TerminalSpawned { term, shell }), Err(note) => self.inputs.send(Input::TerminalSpawnFailed { term, note }), From 1999f145c209c3a9c0645de6c20435d18aae5857 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:47:01 +0200 Subject: [PATCH 20/50] =?UTF-8?q?feat(bridge-runtime):=20the=20terminal's?= =?UTF-8?q?=20transport=20=E2=80=94=20live=20kind,=20a=20low-priority=20la?= =?UTF-8?q?ne,=20relay=20pacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal output now rides the live kind (24515, ephemeral) and never the stored response kind: a shell's output can hold a secret, and a stored frame would outlive the terminal. Loss is fine — every frame carries the terminal's byte offset, so a phone that sees a gap replays from the ring. A terminal is the bridge's lowest-priority traffic and must never slow the phones' other traffic, so: - the direct link accepts terminal input on the live kind beside commands (the engine takes only terminal-input on it), and each connection gets a second, bounded lane for terminal output. The serve loop drains the normal lane first and the terminal lane only when that is empty (a biased select), and drops the oldest frame when the lane is full — a gap the phone replays. - over a relay, terminal output is merged per terminal and sent once per 100 ms window, at most ten a second; past the held cap the oldest bytes are dropped. A terminal cannot spend the bridge's relay rate limit on the agents' messages, and the window runs off the main loop's own turn so a busy backlog cannot starve it. - the relay subscription carries the live kind only when the owner allows a terminal over relays (terminals on and terminal.relays), so a bridge without that never subscribes to it and a relay never sees a keystroke. Options.terminal is the seam the configuration fills in; it defaults to terminals off, so nothing changes for a run that sets nothing. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/direct.rs | 140 ++++++++-- crates/bridge-runtime/src/relay.rs | 359 +++++++++++++++++++++++--- crates/bridge-runtime/src/runtime.rs | 12 +- crates/bridge-runtime/tests/direct.rs | 55 +++- 4 files changed, 513 insertions(+), 53 deletions(-) diff --git a/crates/bridge-runtime/src/direct.rs b/crates/bridge-runtime/src/direct.rs index 3b4e0c73..4bb5b3d1 100644 --- a/crates/bridge-runtime/src/direct.rs +++ b/crates/bridge-runtime/src/direct.rs @@ -28,7 +28,7 @@ use futures_util::{SinkExt, StreamExt}; use protocol::direct::{ check_direct_auth, decode_direct_frame, encode_direct_frame, event_is_valid, DirectFrame, DirectInfo, OUTBOX_SECS, }; -use protocol::kinds::COMMAND_KIND; +use protocol::kinds::{COMMAND_KIND, LIVE_KIND}; use protocol::nostr_event::SignedEvent; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncWrite}; @@ -54,6 +54,12 @@ const OUTBOX_CAP: usize = 5000; /// Largest frame accepted from a phone. Commands are small; an image goes /// through Blossom or relay-sized chunks. const MAX_FRAME_BYTES: usize = 256 * 1024; +/// How many terminal-output frames one connection may have waiting. Bounded, +/// because a terminal is the bridge's lowest-priority traffic: when the lane +/// is full the oldest frame is dropped, and the phone sees a gap in the +/// terminal's byte offsets and asks for a replay from the ring. A lost frame +/// costs a round trip, never the phone's other traffic. +const TERMINAL_LANE_CAP: usize = 64; fn now_secs() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) @@ -98,6 +104,36 @@ struct Conn { /// The recipient tags its `HELLO` named: only those copies go to it. recipients: HashSet, frames: mpsc::UnboundedSender, + /// Terminal output for this connection, on a lane of its own: it goes out + /// only when `frames` has nothing waiting, so a terminal can never delay + /// the phone's other traffic. + term: Rc, +} + +/// A connection's low-priority lane: terminal-output frames, bounded, oldest +/// dropped first when full. Shared between the publisher (which pushes) and +/// the connection's serve loop (which sends and is woken by it). +#[derive(Default)] +struct TermLane { + queue: RefCell>, + wake: tokio::sync::Notify, +} + +impl TermLane { + fn push(&self, frame: String) { + { + let mut queue = self.queue.borrow_mut(); + if queue.len() >= TERMINAL_LANE_CAP { + queue.pop_front(); + } + queue.push_back(frame); + } + self.wake.notify_one(); + } + + fn pop(&self) -> Option { + self.queue.borrow_mut().pop_front() + } } impl Conn { @@ -139,8 +175,10 @@ impl Hub { } /// `event`, published for `identity`: to its connections that read its - /// copy now, and to the outbox for one that resumes later. - pub fn deliver(&self, identity: &str, event: &SignedEvent) { + /// copy now, and to the outbox for one that resumes later. `terminal` says + /// the copy is terminal output, which rides the connection's low-priority + /// lane instead of its normal one. + pub fn deliver(&self, identity: &str, event: &SignedEvent, terminal: bool) { let now = now_secs(); { let mut outbox = self.outbox.borrow_mut(); @@ -151,7 +189,11 @@ impl Hub { } let frame = encode_direct_frame(&DirectFrame::Event(event.clone())); for conn in self.conns.borrow().values().filter(|c| c.reads(identity, event)) { - let _ = conn.frames.send(frame.clone()); + if terminal { + conn.term.push(frame.clone()); + } else { + let _ = conn.frames.send(frame.clone()); + } } } @@ -167,13 +209,14 @@ impl Hub { .collect() } - fn register(&self, identity: &str, recipients: Vec) -> (u64, mpsc::UnboundedReceiver) { + fn register(&self, identity: &str, recipients: Vec) -> (u64, mpsc::UnboundedReceiver, Rc) { let id = self.next_id.get(); self.next_id.set(id + 1); let (frames, rx) = mpsc::unbounded_channel(); - let conn = Conn { identity: identity.to_string(), recipients: recipients.into_iter().collect(), frames }; + let term = Rc::new(TermLane::default()); + let conn = Conn { identity: identity.to_string(), recipients: recipients.into_iter().collect(), frames, term: Rc::clone(&term) }; self.conns.borrow_mut().insert(id, conn); - (id, rx) + (id, rx, term) } fn unregister(&self, id: u64) { @@ -185,9 +228,10 @@ impl Hub { } /// Hand a phone's event to the engine if it is a command from `identity` - /// to this bridge; the answer for its `OK`. + /// to this bridge, or terminal input on the live kind (the engine takes + /// only `terminal-input` on that kind); the answer for its `OK`. fn accept(&self, identity: &str, event: SignedEvent) -> Result<(), &'static str> { - if event.kind != COMMAND_KIND { + if event.kind != COMMAND_KIND && event.kind != LIVE_KIND { return Err("invalid: not a command"); } if event.pubkey != identity { @@ -384,14 +428,30 @@ async fn serve(stream: S, hub: &Hub) { } }; - let (id, mut outbound) = hub.register(&identity, recipients); + let (id, mut outbound, term) = hub.register(&identity, recipients); log::info!("[Direct] {}... connected", short(&identity)); let mut ok = sink.send(send(&DirectFrame::Ready)).await.is_ok(); for event in hub.since(id, since) { ok = ok && sink.send(send(&DirectFrame::Event(event))).await.is_ok(); } while ok { + // The normal lane first, always: a terminal frame goes out only when + // nothing else is waiting. `try_recv` catches a frame already queued; + // the biased select below keeps the same order while blocking. + match outbound.try_recv() { + Ok(text) => { + ok = sink.send(Message::Text(text)).await.is_ok(); + continue; + } + Err(mpsc::error::TryRecvError::Disconnected) => { + // Unpaired, or the bridge is stopping. + let _ = sink.send(send(&DirectFrame::Closed("closed".into()))).await; + break; + } + Err(mpsc::error::TryRecvError::Empty) => {} + } tokio::select! { + biased; frame = outbound.recv() => match frame { Some(text) => ok = sink.send(Message::Text(text)).await.is_ok(), None => { @@ -400,6 +460,16 @@ async fn serve(stream: S, hub: &Hub) { ok = false; } }, + _ = term.wake.notified() => { + // Drain the lane: the normal lane was empty a moment ago, and + // draining keeps the notify permit from being missed. + while let Some(text) = term.pop() { + if sink.send(Message::Text(text)).await.is_err() { + ok = false; + break; + } + } + }, incoming = tokio::time::timeout(IDLE_TIMEOUT, source.next()) => match incoming { Ok(Some(Ok(Message::Text(text)))) => match decode_direct_frame(&text) { Ok(DirectFrame::Event(event)) => { @@ -524,9 +594,13 @@ mod tests { } fn command(from: &protocol::crypto::Keypair, to: &str) -> SignedEvent { + signed(from, to, COMMAND_KIND) + } + + fn signed(from: &protocol::crypto::Keypair, to: &str, kind: u16) -> SignedEvent { let keys = nostr::Keys::new(from.secret_key.clone()); let tag = nostr::Tag::public_key(nostr::PublicKey::from_hex(to).unwrap()); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(COMMAND_KIND), "x").tag(tag).sign_with_keys(&keys).unwrap(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), "x").tag(tag).sign_with_keys(&keys).unwrap(); SignedEvent::from_nostr(&event) } @@ -546,17 +620,17 @@ mod tests { let old = copy_for("p1", now - OUTBOX_SECS - 5); let (mine, other_client, untagged) = (copy_for("p1", now), copy_for("p2", now), command(&generate_keypair(), &generate_keypair().pubkey_hex)); for event in [&old, &mine, &other_client, &untagged] { - hub.deliver("p", event); + hub.deliver("p", event, false); } // Another identity naming p's tag reads nothing of p's. - hub.deliver("q", ©_for("q1", now)); - let (p1, _) = hub.register("p", vec!["p1".into()]); - let (q, _) = hub.register("q", vec!["p1".into()]); + hub.deliver("q", ©_for("q1", now), false); + let (p1, _, _) = hub.register("p", vec!["p1".into()]); + let (q, _, _) = hub.register("q", vec!["p1".into()]); assert_eq!(hub.since(p1, 0), std::slice::from_ref(&mine), "the stale one was dropped; only p1's copy"); assert!(hub.since(p1, mine.created_at + 1).is_empty()); assert!(hub.since(q, 0).is_empty()); hub.set_paired(&["p".into()]); - let (q1, _) = hub.register("q", vec!["q1".into()]); + let (q1, _, _) = hub.register("q", vec!["q1".into()]); assert!(hub.since(q1, 0).is_empty(), "an unpaired identity's events go"); } @@ -577,6 +651,40 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn terminal_input_on_the_live_kind_is_accepted_from_the_same_identity() { + let (inputs, mut rx) = mpsc::unbounded_channel(); + let (bridge, phone) = (generate_keypair(), generate_keypair()); + let hub = Hub::new(bridge.pubkey_hex.clone(), inputs); + // The keystrokes ride the live kind; the engine routes them by kind, + // so it is set here too. + assert!(hub.accept(&phone.pubkey_hex, signed(&phone, &bridge.pubkey_hex, LIVE_KIND)).is_ok()); + assert!(matches!(rx.try_recv(), Ok(Input::RelayEvent { event, via: Via::Direct }) if event.kind == LIVE_KIND)); + // Still only from the authenticated identity, and to this bridge. + assert!(hub.accept(&phone.pubkey_hex, signed(&generate_keypair(), &bridge.pubkey_hex, LIVE_KIND)).is_err()); + assert!(hub.accept(&phone.pubkey_hex, signed(&phone, &generate_keypair().pubkey_hex, LIVE_KIND)).is_err()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn a_connections_terminal_lane_drops_the_oldest_when_full() { + let lane = TermLane::default(); + for i in 0..TERMINAL_LANE_CAP { + lane.push(format!("f{i}")); + } + assert_eq!(lane.pop().as_deref(), Some("f0")); + // Refill to the cap and then one more: the oldest of what is left + // goes, and the newest is kept, so the phone sees a gap and replays. + lane.push("a".into()); + lane.push("b".into()); + assert_eq!(lane.pop().as_deref(), Some("f2")); + let mut last = None; + while let Some(f) = lane.pop() { + last = Some(f); + } + assert_eq!(last.as_deref(), Some("b")); + } + #[test] fn a_certificate_is_made_once_and_kept() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/bridge-runtime/src/relay.rs b/crates/bridge-runtime/src/relay.rs index e71e8794..02b0b4b0 100644 --- a/crates/bridge-runtime/src/relay.rs +++ b/crates/bridge-runtime/src/relay.rs @@ -31,7 +31,9 @@ use protocol::kinds::{ }; use protocol::recipients::{self, fragment_d}; use protocol::nostr_event::SignedEvent; -use protocol::events::{OutputMsg, PartialMsg}; +use protocol::events::{OutputMsg, PartialMsg, TerminalOutputMsg}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; use futures_util::future::LocalBoxFuture; use futures_util::stream::{FuturesUnordered, StreamExt}; use std::collections::VecDeque; @@ -43,7 +45,16 @@ pub fn kind_for(message: &BridgeToPhone) -> (u16, Option) { BridgeToPhone::Sessions(_) => (SESSION_LIST_KIND, Some(SESSION_LIST_EXPIRY_SECONDS)), // A partial is the one that must never be stored: it is only text on // its way to the entry that completes it. - BridgeToPhone::Output(_) | BridgeToPhone::Usage(_) | BridgeToPhone::Partial(_) => (LIVE_KIND, None), + // Terminal output rides the live kind too: it is the other half of + // the terminal's byte stream, and a relay must never store it — the + // bytes a shell printed can hold a secret, and a stored frame would + // outlive the terminal itself. Loss is fine: every frame carries the + // terminal's byte offset, and a phone that sees a gap replays from + // the ring. + BridgeToPhone::Output(_) + | BridgeToPhone::Usage(_) + | BridgeToPhone::Partial(_) + | BridgeToPhone::TerminalOutput(_) => (LIVE_KIND, None), _ => (RESPONSE_KIND, Some(RESPONSE_EXPIRY_SECONDS)), } } @@ -264,6 +275,9 @@ fn lane_of(message: &BridgeToPhone) -> Option { // In its session's lane, so it never lands after the entry that // completes its part, and never holds up anything else. BridgeToPhone::Partial(p) => Some(format!("output {}", p.session_id)), + // A terminal's output in a lane of its own: one terminal's burst + // never delays another's, and neither delays a session's. + BridgeToPhone::TerminalOutput(t) => Some(format!("terminal {}", t.term)), BridgeToPhone::Sessions(_) => Some("heartbeat".to_string()), _ => None, } @@ -358,15 +372,184 @@ impl Flight { } } +/// Terminal output over the relays is paced, not passed through. A shell +/// writes as fast as it likes, and the runtime hands the bridge one frame at +/// most every 16 ms; a relay must see far less, or a terminal spends the +/// bridge's rate limit and gets its commands throttled with it. Frames for +/// one terminal are merged while they wait — they are contiguous byte ranges, +/// so merging is appending the bytes and keeping the first offset — and one +/// merged frame goes out per window, at most ten a second whatever the shell +/// does. This is the bridge's lowest-priority traffic. +const TERMINAL_FLUSH: std::time::Duration = std::time::Duration::from_millis(100); +/// The most merged bytes held for one terminal before the oldest are dropped. +/// The phone then sees a gap in the terminal's byte offsets and asks for a +/// replay from the ring — the recovery the design already has — so the bridge +/// holds a bounded amount for a terminal that outruns the relays. +const TERMINAL_PENDING_MAX_BYTES: usize = 256 * 1024; + +/// Terminal output waiting for its flush window, one entry per (terminal, +/// clients) pair. +#[derive(Default)] +struct TerminalPace { + pending: Vec, +} + +struct PendingTerminal { + to: Vec, + term: String, + /// The first byte of the merged range. + offset: u64, + /// The merged bytes, contiguous from `offset`. + data: Vec, + /// When this terminal's window is up. + due: tokio::time::Instant, +} + +impl PendingTerminal { + /// Whether `msg` continues this range: the same terminal, the same + /// clients, and the byte straight after what is held. + fn continues(&self, to: &[Addressee], msg: &TerminalOutputMsg) -> bool { + self.term == msg.term && self.to.as_slice() == to && self.offset + self.data.len() as u64 == msg.offset + } +} + +impl TerminalPace { + /// Fold one frame in: append it to the terminal's pending range, or start + /// one. Only a frame that continues a range is appended; one that does not + /// starts an entry of its own — the design makes frames contiguous, so + /// this is the safe fallback, not the common case. + fn merge(&mut self, to: Vec, msg: &TerminalOutputMsg, now: tokio::time::Instant) { + let bytes = STANDARD.decode(&msg.data).unwrap_or_default(); + if let Some(p) = self.pending.iter_mut().find(|p| p.continues(&to, msg)) { + p.data.extend_from_slice(&bytes); + // Past the cap, drop the oldest: a gap the phone replays from the + // ring. + if p.data.len() > TERMINAL_PENDING_MAX_BYTES { + let drop = p.data.len() - TERMINAL_PENDING_MAX_BYTES; + p.data.drain(..drop); + p.offset += drop as u64; + } + return; + } + self.pending.push(PendingTerminal { to, term: msg.term.clone(), offset: msg.offset, data: bytes, due: now + TERMINAL_FLUSH }); + } + + /// Take the terminal-output jobs queued right behind the front into the + /// range they continue, so they cost one event rather than one each. + fn absorb(&mut self, backlog: &mut VecDeque, now: tokio::time::Instant) { + while let Some(Job::Publish { to, message }) = backlog.front() { + let BridgeToPhone::TerminalOutput(msg) = message.as_ref() else { break }; + if !self.pending.iter().any(|p| p.continues(to, msg)) { + break; + } + let Some(Job::Publish { to, message }) = backlog.pop_front() else { break }; + let BridgeToPhone::TerminalOutput(msg) = *message else { break }; + self.merge(to, &msg, now); + } + } + + fn has_pending(&self) -> bool { + !self.pending.is_empty() + } + + /// The pending entries whose window is up, removed. + fn take_due(&mut self, now: tokio::time::Instant) -> Vec { + let mut ready = Vec::new(); + let mut wait = Vec::new(); + for p in self.pending.drain(..) { + if p.due <= now { + ready.push(p); + } else { + wait.push(p); + } + } + self.pending = wait; + ready + } + + /// Every pending entry, for a flush. + fn take_all(&mut self) -> Vec { + std::mem::take(&mut self.pending) + } + + /// Resolves when the earliest pending window is up (never, with none). + async fn wait_due(&self) { + if let Some(due) = self.pending.iter().map(|p| p.due).min() { + tokio::time::sleep_until(due).await; + } + } +} + +/// Send what a terminal has merged whose window is up (`all` for a flush). +/// Each goes on its terminal's own lane, so a terminal never waits on a +/// session's publish. +async fn flush_terminals( + terminal: &mut TerminalPace, + factory: &mut EventFactory, + flight: &mut Flight, + publisher: &WsTransport, + direct: Option<&Rc>, + all: bool, +) { + let ready = if all { terminal.take_all() } else { terminal.take_due(tokio::time::Instant::now()) }; + for p in ready { + let message = BridgeToPhone::TerminalOutput(TerminalOutputMsg { term: p.term, offset: p.offset, data: STANDARD.encode(&p.data) }); + let lane = lane_of(&message); + match &lane { + Some(lane) => flight.room_in(lane).await, + None => flight.land_all().await, + } + send_message(factory, flight, publisher, direct, lane, p.to, &message).await; + } +} + +/// Build the events for one message to `to`, hand each copy to the direct link +/// and start its relay publish. The caller has already waited for the lane to +/// be free. +async fn send_message( + factory: &mut EventFactory, + flight: &mut Flight, + publisher: &WsTransport, + direct: Option<&Rc>, + lane: Option, + to: Vec, + message: &BridgeToPhone, +) { + let beat = matches!(message, BridgeToPhone::Sessions(_)); + // Terminal output rides the direct link's low-priority lane; every other + // copy rides its normal one. + let is_terminal = matches!(message, BridgeToPhone::TerminalOutput(_)); + for addressee in to { + let events = match factory.events(message, &addressee, now_secs()) { + Ok(events) => events, + Err(err) => { + let phone = &addressee.phone; + log::error!("[Relays] Could not build an event for {}...: {err}", &phone[..8.min(phone.len())]); + continue; + } + }; + if let Some(direct) = direct { + for event in &events { + direct.deliver(&addressee.phone, event, is_terminal); + } + } + let beat = beat.then(|| flight.next_beat(&addressee.phone)); + let sending = send(publisher.clone(), flight.quiet.clone(), lane.clone(), addressee.phone, beat, events); + match &lane { + Some(lane) => flight.start(lane, sending), + // Out of any lane: nothing overlaps it. + None => flight.settle(sending.await), + } + } +} + enum Job { Publish { to: Vec, message: Box }, /// Resolves once every job queued before it is done. Flush(oneshot::Sender<()>), /// A relay's socket came up or went down. RelaysChanged, -} - -/// The relay connections, the bridge's subscriptions on them, and the +}/// The relay connections, the bridge's subscriptions on them, and the /// publish queue. Single-threaded (the transport is). pub struct Relays { transport: WsTransport, @@ -403,7 +586,14 @@ impl Relays { // Jobs taken off the queue but not yet done: the outputs queued // behind a publish go out together with it. let mut backlog: VecDeque = VecDeque::new(); + // Terminal output waiting for its flush window (see TerminalPace). + let mut terminal = TerminalPace::default(); loop { + // A terminal whose window is up goes out before anything else + // this turn. Doing it here, not only when the queue is idle, + // is what keeps a busy backlog from starving a terminal: the + // send is on the terminal's own lane, so it waits on nothing. + flush_terminals(&mut terminal, &mut factory, &mut flight, &publisher, direct.as_ref(), false).await; let job = match backlog.pop_front() { Some(job) => job, None => tokio::select! { @@ -415,14 +605,34 @@ impl Relays { flight.settle(sent); continue; } + // A terminal's window is up: wake the loop, which + // sends it above. + _ = terminal.wait_due(), if terminal.has_pending() => continue, }, }; match job { Job::Flush(done) => { + // What a terminal merged is part of what a flush + // promises has gone out. + flush_terminals(&mut terminal, &mut factory, &mut flight, &publisher, direct.as_ref(), true).await; flight.land_all().await; let _ = done.send(()); } Job::Publish { to, mut message } => { + if matches!(message.as_ref(), BridgeToPhone::TerminalOutput(_)) { + // Paced, not passed through: merge it into the + // terminal's pending range and let its window + // carry it out. + while let Ok(next) = queue.try_recv() { + backlog.push_back(next); + } + let now = tokio::time::Instant::now(); + if let BridgeToPhone::TerminalOutput(msg) = message.as_ref() { + terminal.merge(to, msg, now); + } + terminal.absorb(&mut backlog, now); + continue; + } let lane = lane_of(&message); match &lane { Some(lane) => flight.room_in(lane).await, @@ -445,28 +655,7 @@ impl Relays { if beat && superseded(&to, &backlog) { continue; } - for addressee in to { - let events = match factory.events(&message, &addressee, now_secs()) { - Ok(events) => events, - Err(err) => { - let phone = &addressee.phone; - log::error!("[Relays] Could not build an event for {}...: {err}", &phone[..8.min(phone.len())]); - continue; - } - }; - if let Some(direct) = &direct { - for event in &events { - direct.deliver(&addressee.phone, event); - } - } - let beat = beat.then(|| flight.next_beat(&addressee.phone)); - let sending = send(publisher.clone(), flight.quiet.clone(), lane.clone(), addressee.phone, beat, events); - match &lane { - Some(lane) => flight.start(lane, sending), - // Out of any lane: nothing overlaps it. - None => flight.settle(sending.await), - } - } + send_message(&mut factory, &mut flight, &publisher, direct.as_ref(), lane, to, &message).await; } Job::RelaysChanged => { flight.land_all().await; @@ -534,16 +723,24 @@ impl Relays { ) } - /// Replace the command subscription (no paired phone = none). - pub fn subscribe_commands(&mut self, authors: Vec, since: u64) { + /// Replace the command subscription (no paired phone = none). `live` also + /// subscribes to the live kind, so a paired phone's terminal input reaches + /// the bridge over a relay as its commands do. Only when the owner allows + /// a terminal over relays: off, the bridge keeps the plain command + /// subscription and a relay never sees a keystroke. + pub fn subscribe_commands(&mut self, authors: Vec, since: u64, live: bool) { if let Some(old) = self.commands.take() { old.close(); } if authors.is_empty() { return; } + let mut kinds = vec![COMMAND_KIND]; + if live { + kinds.push(LIVE_KIND); + } let filter = Filter { - kinds: vec![COMMAND_KIND], + kinds, authors, p_tags: vec![self.bridge_pubkey.clone()], d_tags: vec![], @@ -681,6 +878,22 @@ mod tests { let ev = f.events(&ack, &to(&phone.pubkey_hex), 1000).unwrap().remove(0); assert_eq!(ev.kind, RESPONSE_KIND); assert_eq!(tag(&ev, "expiration").unwrap().parse::().unwrap(), ev.created_at + 3600); + + // Terminal output rides the live kind and is never stored: the bytes + // a shell printed may hold a secret, and a stored frame would outlive + // the terminal. + let out = BridgeToPhone::TerminalOutput(TerminalOutputMsg { term: "t".into(), offset: 0, data: "aGk=".into() }); + let ev = f.events(&out, &to(&phone.pubkey_hex), 1000).unwrap().remove(0); + assert_eq!((ev.kind, tag(&ev, "expiration")), (LIVE_KIND, None)); + // The small, state-like terminal messages are stored responses: a + // phone that was briefly away still learns a terminal opened or ended. + let opened = BridgeToPhone::TerminalOpened(protocol::events::TerminalOpenedMsg { + open_id: "o".into(), + term: "t".into(), + shell: "/bin/sh".into(), + }); + let ev = f.events(&opened, &to(&phone.pubkey_hex), 1000).unwrap().remove(0); + assert_eq!(ev.kind, RESPONSE_KIND); } fn to(phone: &str) -> Addressee { @@ -835,6 +1048,92 @@ mod tests { assert!(lane_of(&heartbeat()).is_some()); let end = BridgeToPhone::SyncEnd(protocol::events::SyncEndMsg { session_id: "s".into(), sync_id: "y".into(), delivered_ranges: vec![] }); assert_eq!(lane_of(&end), None); + // Each terminal's output overlaps another's, and never a session's. + let a = lane_of(&BridgeToPhone::TerminalOutput(output_msg("t", 0, b"a"))); + let b = lane_of(&BridgeToPhone::TerminalOutput(output_msg("u", 0, b"a"))); + assert!(a.is_some() && b.is_some() && a != b); + assert_ne!(a, lane_of(&BridgeToPhone::Output(output("s", 1, &["a"])))); + } + + fn output_msg(term: &str, offset: u64, data: &[u8]) -> TerminalOutputMsg { + TerminalOutputMsg { term: term.into(), offset, data: STANDARD.encode(data) } + } + + fn term_job(to: &[Addressee], msg: TerminalOutputMsg) -> Job { + Job::Publish { to: to.to_vec(), message: Box::new(BridgeToPhone::TerminalOutput(msg)) } + } + + #[test] + fn terminal_output_merges_into_one_range_and_goes_once_a_window() { + let phone = [to("p")]; + let t0 = tokio::time::Instant::now(); + let mut pace = TerminalPace::default(); + pace.merge(phone.to_vec(), &output_msg("t", 0, b"ab"), t0); + pace.merge(phone.to_vec(), &output_msg("t", 2, b"cd"), t0); + // Contiguous frames merge, and the window starts with the first. + assert_eq!(pace.pending.len(), 1); + assert_eq!((pace.pending[0].offset, pace.pending[0].data.as_slice()), (0, &b"abcd"[..])); + assert!(pace.take_due(t0).is_empty(), "not due yet"); + let ready = pace.take_due(t0 + TERMINAL_FLUSH); + assert_eq!((ready.len(), ready[0].offset, ready[0].data.as_slice()), (1, 0, &b"abcd"[..])); + assert!(!pace.has_pending()); + } + + #[test] + fn a_terminal_frame_that_does_not_continue_the_range_starts_its_own() { + let phone = [to("p")]; + let t0 = tokio::time::Instant::now(); + let mut pace = TerminalPace::default(); + pace.merge(phone.to_vec(), &output_msg("t", 0, b"ab"), t0); + // A gap (a dropped frame) must not be papered over by appending. + pace.merge(phone.to_vec(), &output_msg("t", 5, b"cd"), t0); + assert_eq!(pace.pending.len(), 2); + assert_eq!(pace.pending[1].offset, 5); + } + + #[test] + fn terminal_output_past_the_cap_drops_the_oldest_bytes() { + let phone = [to("p")]; + let t0 = tokio::time::Instant::now(); + let mut pace = TerminalPace::default(); + let half = TERMINAL_PENDING_MAX_BYTES / 2; + pace.merge(phone.to_vec(), &output_msg("t", 0, &vec![b'a'; half]), t0); + pace.merge(phone.to_vec(), &output_msg("t", half as u64, &vec![b'b'; half]), t0); + pace.merge(phone.to_vec(), &output_msg("t", (half * 2) as u64, &vec![b'c'; half]), t0); + assert_eq!(pace.pending[0].data.len(), TERMINAL_PENDING_MAX_BYTES); + // The oldest bytes are gone, so the offset the phone is told to draw + // from has moved: it sees a gap and replays from the ring. + assert_eq!(pace.pending[0].offset, half as u64); + assert_eq!(pace.pending[0].data[0], b'b'); + } + + #[test] + fn terminal_jobs_queued_behind_a_frame_join_it() { + let phone = [to("p")]; + let t0 = tokio::time::Instant::now(); + let mut pace = TerminalPace::default(); + pace.merge(phone.to_vec(), &output_msg("t", 0, b"a"), t0); + let mut backlog = VecDeque::from([ + term_job(&phone, output_msg("t", 1, b"b")), + // Another terminal, then a gap: both stay. + term_job(&phone, output_msg("u", 0, b"x")), + term_job(&phone, output_msg("t", 9, b"c")), + ]); + pace.absorb(&mut backlog, t0); + assert_eq!((pace.pending.len(), pace.pending[0].data.as_slice(), backlog.len()), (1, &b"ab"[..], 2)); + } + + #[tokio::test] + async fn a_terminal_window_that_is_not_due_does_not_block() { + // `wait_due` resolves only once the window is up; with nothing + // pending it is never awaited (the select guards on `has_pending`). + let t0 = tokio::time::Instant::now(); + let mut pace = TerminalPace::default(); + pace.merge(vec![to("p")], &output_msg("t", 0, b"a"), t0); + let waited = tokio::time::timeout(TERMINAL_FLUSH / 2, pace.wait_due()).await; + assert!(waited.is_err(), "the window is not up yet"); + tokio::time::sleep(TERMINAL_FLUSH).await; + tokio::time::timeout(std::time::Duration::from_millis(50), pace.wait_due()).await.expect("the window is up"); } fn sent(lane: &str, phone: &str, beat: Option, delivered: bool) -> Sent { diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 4749368a..24e9331b 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -105,6 +105,10 @@ struct Runtime { uploads: Rc>, pairing_urls: Option>, direct: Option, + /// Whether the relay subscription also carries the live kind, so a paired + /// phone's terminal input reaches the bridge over a relay. True only when + /// the owner allows a terminal over relays. + terminals_on_relays: bool, /// The ptys behind the terminals the engine decided to open, with their /// rings of output and their recordings. Empty unless the owner turned /// terminals on: the engine emits no terminal effect otherwise. @@ -145,6 +149,11 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio idle_secs: config.terminal.idle_minutes * 60, allowlist, }; + // Whether the bridge must hear a phone's keystrokes over a relay as well + // as its own link. Off unless the owner allows a terminal over relays, so + // a bridge without that never subscribes to the live kind and a relay + // never sees one. + let terminals_on_relays = config.terminal.enabled && config.terminal.relays_allowed; let first_root = config.workspace_roots[0].clone(); if let Some(keep) = config.transcript_keep_last { engine_config.transcript_keep_last = keep; @@ -205,6 +214,7 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio uploads: Rc::new(RefCell::new(Uploads::new(&first_root))), pairing_urls: options.pairing_urls, direct, + terminals_on_relays, terminals, audit, allowlist: Rc::new(RefCell::new(allowlist_cache)), @@ -352,7 +362,7 @@ impl Runtime { if let Some(direct) = &self.direct { direct.hub.set_paired(&filter.authors); } - self.relays.subscribe_commands(filter.authors, filter.since); + self.relays.subscribe_commands(filter.authors, filter.since, self.terminals_on_relays); } Effect::OpenPairingSubscription { since } => self.relays.open_pairing(since), Effect::ClosePairingSubscription => self.relays.close_pairing(), diff --git a/crates/bridge-runtime/tests/direct.rs b/crates/bridge-runtime/tests/direct.rs index cd7785df..a722413c 100644 --- a/crates/bridge-runtime/tests/direct.rs +++ b/crates/bridge-runtime/tests/direct.rs @@ -10,7 +10,7 @@ use bridge_runtime::direct; use futures_util::{SinkExt, StreamExt}; use protocol::crypto::{generate_keypair, Keypair}; use protocol::direct::{decode_direct_frame, encode_direct_frame, DirectFrame}; -use protocol::kinds::COMMAND_KIND; +use protocol::kinds::{COMMAND_KIND, LIVE_KIND}; use protocol::nip42::build_auth_event; use protocol::nostr_event::SignedEvent; use rustls::pki_types::ServerName; @@ -104,16 +104,16 @@ async fn a_paired_phone_gets_its_events_and_sends_commands_over_the_pinned_link( // copy, not another client's of the same identity. let theirs = copy(&bridge, 4516, &phone.pubkey_hex, "theirs", "earlier"); let earlier = event(&bridge, 4516, &phone.pubkey_hex, "earlier"); - link.hub.deliver(&phone.pubkey_hex, &theirs); - link.hub.deliver(&phone.pubkey_hex, &earlier); + link.hub.deliver(&phone.pubkey_hex, &theirs, false); + link.hub.deliver(&phone.pubkey_hex, &earlier, false); let (mut ws, answer) = hello(&endpoint, &pin, &phone, now_secs() - 60).await; assert_eq!(answer, DirectFrame::Ready); assert_eq!(next_frame(&mut ws).await, DirectFrame::Event(earlier)); // Published while connected: pushed at once, again only its own. - link.hub.deliver(&phone.pubkey_hex, ©(&bridge, 24515, &phone.pubkey_hex, "theirs", "live")); + link.hub.deliver(&phone.pubkey_hex, ©(&bridge, 24515, &phone.pubkey_hex, "theirs", "live"), false); let live = event(&bridge, 24515, &phone.pubkey_hex, "live"); - link.hub.deliver(&phone.pubkey_hex, &live); + link.hub.deliver(&phone.pubkey_hex, &live, false); assert_eq!(next_frame(&mut ws).await, DirectFrame::Event(live)); // A command goes to the engine as a relay's would, and is OKed. @@ -145,6 +145,49 @@ async fn wait_until(cond: impl Fn() -> bool) { } } +/// The live kind carries a phone's keystrokes into the engine the same way a +/// command does, and terminal output reaches the phone on a lane that never +/// overtakes a normal frame. +#[tokio::test] +async fn a_paired_phone_sends_live_input_and_terminal_output_never_jumps_a_normal_frame() { + LocalSet::new() + .run_until(async { + let home = tempfile::tempdir().unwrap(); + let (bridge, phone) = (generate_keypair(), generate_keypair()); + let (inputs, mut engine) = mpsc::unbounded_channel(); + let config = DirectConfig { listen: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; + let mut link = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs).await.unwrap().unwrap(); + let endpoint = link.info.endpoints[0].clone(); + let pin = link.info.cert_sha256.clone().unwrap(); + link.hub.set_paired(std::slice::from_ref(&phone.pubkey_hex)); + let (mut ws, answer) = hello(&endpoint, &pin, &phone, now_secs() - 60).await; + assert_eq!(answer, DirectFrame::Ready); + + // Terminal input on the live kind: accepted, and the engine is + // told which kind it was. + let input = event(&phone, LIVE_KIND, &bridge.pubkey_hex, "keystrokes"); + send(&mut ws, DirectFrame::Event(input.clone())).await; + assert_eq!(next_frame(&mut ws).await, DirectFrame::Ok { id: input.id.clone(), accepted: true, message: String::new() }); + match engine.recv().await { + Some(Input::RelayEvent { event, via: Via::Direct }) => assert_eq!((event.id, event.kind), (input.id, LIVE_KIND)), + _ => panic!("expected the live event"), + } + + // A normal frame and a terminal one queued together: the normal + // one goes first, the terminal one after — a terminal never + // overtakes the phone's other traffic. + let normal = event(&bridge, 4516, &phone.pubkey_hex, "normal"); + let output = copy(&bridge, LIVE_KIND, &phone.pubkey_hex, MINE, "output"); + link.hub.deliver(&phone.pubkey_hex, &normal, false); + link.hub.deliver(&phone.pubkey_hex, &output, true); + assert_eq!(next_frame(&mut ws).await, DirectFrame::Event(normal)); + assert_eq!(next_frame(&mut ws).await, DirectFrame::Event(output)); + + link.shutdown(); + }) + .await; +} + /// The phone's link against the real server: up on the pinned endpoint, /// events in, a command out and acknowledged, down when unpaired; never up /// with the wrong pin. @@ -192,7 +235,7 @@ async fn the_phone_link_talks_to_the_bridge() { assert_eq!(states.borrow().as_slice(), [Some(server.info.endpoints[0].clone())]); let live = event(&bridge, 24515, &phone.pubkey_hex, "live"); - server.hub.deliver(&phone.pubkey_hex, &live); + server.hub.deliver(&phone.pubkey_hex, &live, false); wait_until(|| !events.borrow().is_empty()).await; assert_eq!(events.borrow()[0].id, live.id); From 64dc2bb0b7f3b09a01b52f065b636aacbe579b73 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:47:13 +0200 Subject: [PATCH 21/50] docs: the terminal's two lanes, the relay pacing, and live input on the direct link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal's Transport section said the bridge flushes every 16 ms on the direct link and every 100 ms on relays "or sooner past 16 KiB pending" — the 16 KiB is the frame cap, not an early flush, and the relay's 100 ms is a pacing the relay path now enforces by merging. State both as they are, and fold the two lanes into the "never slows the phones' links" bullet rather than beside it: the direct link's bounded terminal lane that goes out only when the normal lane is empty, and the relay's merge-and-pace, so a terminal can never spend the bridge's rate limit on the agents' messages. The direct link paragraph in PROTOCOL.md said it takes only command events (4515); it now also takes terminal input on the live kind, with the engine accepting only terminal-input there, and the standing subscription carries the live kind when the owner allows a terminal over relays. Co-Authored-By: Claude Code --- docs/PROTOCOL.md | 10 +++++++--- docs/TERMINAL.md | 34 +++++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 10365b78..08e02196 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -56,7 +56,10 @@ The stored cursor moves at command/response cadence, not at output cadence — that is what keeps a burst of output from starving a response. The bridge subscribes to 4515 from paired authors with -`since = lastSeen − 5s` (crash-gap grace). The relay therefore REPLAYS +`since = lastSeen − 5s` (crash-gap grace). When the owner allows a terminal +over relays it subscribes to the live kind (24515) from the same authors +too, so a phone's keystrokes reach it there; off, the live kind is not +subscribed and a relay sees none. The relay therefore REPLAYS recently processed commands after a restart — the bridge persists its dedup event-id set alongside the cursor so the replay is a no-op. @@ -757,8 +760,9 @@ spec. - After `READY` the bridge sends what it published for that identity since `since` (seconds; it keeps an hour of it), then everything new — only the copies tagged with one of the `recipients`, never another client's. It - takes only command events (4515) authored by the identity that said - `HELLO`. + takes command events (4515) and terminal input on the live kind (24515), + each authored by the identity that said `HELLO`; the engine accepts only + `terminal-input` on the live kind, so nothing else can ride it in. ### Version and capabilities diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index ad027a67..34006e05 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -88,8 +88,8 @@ timing alone leaks what was typed; the direct link shows nobody either. When relays are allowed, a terminal falls back to them like any message. - Both directions use `LIVE_KIND` (24515, ephemeral): **keystrokes are never - stored on a relay**. Phone→bridge input on the live kind is new: the direct - link accepts it besides commands (today it accepts only 4515), and, with + stored on a relay**. Phone→bridge input on the live kind goes beside + commands: the direct link accepts it besides commands (4515), and, with relays allowed, the bridge subscribes to it from its paired phones. - **Every input carries `seq`**, per terminal **and per device**, counting up by one from 1. The bridge applies input in order, drops a `seq` it has @@ -107,9 +107,11 @@ When relays are allowed, a terminal falls back to them like any message. a burst of keystrokes would push command ids out of it and reopen a replay window for commands. The per-terminal, per-device `seq` is its dedup, and the 300-second age limit still applies. -- **Output is coalesced**: the bridge flushes at most every 16 ms on the - direct link and every 100 ms on relays, or sooner past 16 KiB pending. - Larger flushes split by the existing `chunk` fragmentation. +- **Output is coalesced and paced**: the bridge flushes a terminal's output at + most every 16 ms on the direct link, each frame at most 16 KiB; over a relay + it merges the frames for a terminal and sends one merged frame per 100 ms — + at most 10 a second, whatever the shell does. Larger flushes split by the + existing `chunk` fragmentation. - **Input is sent when a program can react to it, not per keystroke.** On the normal screen (a shell prompt), printable characters wait on the phone and go in one event when a key a program acts on is pressed — Enter, Tab @@ -135,10 +137,19 @@ When relays are allowed, a terminal falls back to them like any message. input goes into a bounded queue and is dropped, with a warning, when the program has stopped reading it. Output is paced rather than passed through — the reader thread is throttled by a small queue, and the bridge - emits at most one frame per 16 ms — so a shell writing faster than the - phones can be sent it runs at that pace, its pty filling and its write - blocking, like a terminal on a slow line. What a terminal costs the - bridge's thread is therefore bounded, whatever runs in it. + emits at most one frame per 16 ms — and it goes out on a lane of its own: + each direct connection keeps a small bounded lane for terminal output (64 + frames) beside its normal one, and the serve loop drains the normal lane + first and the terminal lane only when that is empty, so a terminal can + never delay a command, a card or a heartbeat. When the terminal lane is + full the oldest frame is dropped, which the phone sees as a gap in the + terminal's byte offsets and answers with a replay from the ring. Over the + relays the same idea is the pacing above: output is merged per terminal + and sent on a schedule rather than passed through, so a terminal cannot + spend the bridge's relay rate limit on a session's messages. A shell + writing faster than the phones can be sent it therefore runs at that pace, + its pty filling and its write blocking, like a terminal on a slow line. + What a terminal costs the bridge's thread is bounded, whatever runs in it. - **Privacy of size and timing.** NIP-44 already pads every payload to a size bucket, so a relay sees bucketed sizes, not byte counts. Batching on the keys above, rather than sending each keystroke, also hides the typing @@ -237,8 +248,9 @@ PTYs die with it), and each one's end is audited. close, audit. It holds no PTY and no file. - `crates/bridge-runtime` owns the PTYs through the `portable-pty` crate (ConPTY on Windows, so the Windows bridge gets a real terminal too), the - per-terminal output ring and offset, the flush timer per transport, the - allowlist file and the audit files. + per-terminal output ring and offset, the coalescing that turns a shell's + writes into at most one frame per 16 ms, the relay pacing, the allowlist + file and the audit files. ## Phone From c9a6962ed71853653111dc799b5a833c1505c809 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:06:42 +0200 Subject: [PATCH 22/50] test(bridge-runtime): a phone opens a terminal and types through the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole terminal path, end to end over a relay: a phone the test has already approved opens a terminal, says it shows it, types `echo hi` on the live kind, and sees the shell's output come back as `terminal-output`. It exercises the transport this branch adds — the live-kind subscription that only exists because the owner allows a terminal over relays, and the relay pacing of the output. The phone core has no terminal API yet, so the messages go out as raw events, signed by the identity and NIP-44 encrypted from the session key to the bridge, and the replies are read from the core's observer (which the core calls for every decoded message, terminal ones included). The session key is seeded in the phone's KV at the real grant lifetime, so the test knows the device id the allowlist must name. Unix only: the bridge runs a real pty. Co-Authored-By: Claude Code --- crates/bridge-runtime/tests/e2e.rs | 212 +++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/crates/bridge-runtime/tests/e2e.rs b/crates/bridge-runtime/tests/e2e.rs index f44fe7c5..762e2791 100644 --- a/crates/bridge-runtime/tests/e2e.rs +++ b/crates/bridge-runtime/tests/e2e.rs @@ -32,6 +32,16 @@ use client_runtime::protocol::events::BridgeToPhone; use client_runtime::{Core, CoreConfig, CoreObserver, CorePorts, Intent, LocalSigner, MemoryTranscriptStore, SystemClock, TimeEntropy, TranscriptStore}; use tokio::sync::{mpsc, oneshot}; use tokio::task::{JoinHandle, LocalSet}; +#[cfg(unix)] +use base64::engine::general_purpose::STANDARD; +#[cfg(unix)] +use base64::Engine as _; +#[cfg(unix)] +use client_runtime::protocol::crypto::Keypair; +#[cfg(unix)] +use client_runtime::MemoryKv; +#[cfg(unix)] +use nostr_transport::{WsConfig, WsTransport}; const TIMEOUT: Duration = Duration::from_secs(20); @@ -47,6 +57,59 @@ impl CoreObserver for Observer { fn bridge_message(&self, _: String, _: BridgeToPhone) {} } +/// Keeps the terminal messages the bridge sends this phone. The phone core has +/// no terminal store yet, so it drops them; the observer is where the wire can +/// still be checked. +#[cfg(unix)] +#[derive(Default)] +struct TerminalObserver { + opened: std::cell::RefCell>, + exited: std::cell::RefCell>, + /// Every terminal-output frame, decoded from its base64, in order. + output: std::cell::RefCell>, + /// The type of every message seen, for a failure's diagnosis. + seen: std::cell::RefCell>, +} + +#[cfg(unix)] +impl TerminalObserver { + fn seen(&self) -> String { + String::from_utf8_lossy(&self.output.borrow()).into_owned() + } + fn refused(&self) -> Option { + self.exited.borrow().as_ref().map(|m| format!("{m:?}")) + } + fn trace(&self) -> String { + self.seen.borrow().join(", ") + } +} + +#[cfg(unix)] +impl CoreObserver for TerminalObserver { + fn connection_changed(&self, _: ConnectionStatus, _: bool, _: &[String]) {} + fn bridge_message(&self, _: String, msg: BridgeToPhone) { + let name = match &msg { + BridgeToPhone::TerminalOpened(_) => "terminal-opened", + BridgeToPhone::TerminalOutput(_) => "terminal-output", + BridgeToPhone::TerminalInputFrom(_) => "terminal-input-from", + BridgeToPhone::TerminalExited(_) => "terminal-exited", + BridgeToPhone::TerminalEvent(_) => "terminal-event", + _ => "other", + }; + self.seen.borrow_mut().push(name.to_string()); + match msg { + BridgeToPhone::TerminalOpened(m) => *self.opened.borrow_mut() = Some(m), + BridgeToPhone::TerminalExited(m) => *self.exited.borrow_mut() = Some(m), + BridgeToPhone::TerminalOutput(m) => { + if let Ok(bytes) = STANDARD.decode(&m.data) { + self.output.borrow_mut().extend_from_slice(&bytes); + } + } + _ => {} + } + } +} + struct Bridge { stop: oneshot::Sender<()>, task: JoinHandle>, @@ -54,6 +117,13 @@ struct Bridge { } fn start_bridge(relay: &str, home: &Path, workspace: &Path) -> Bridge { + start_bridge_with(relay, home, workspace, None) +} + +/// `terminal_device`: turn terminals on (relays allowed) and approve this +/// device id, for the terminal test. The allowlist file is read when the +/// bridge starts, so it is written before `run`. +fn start_bridge_with(relay: &str, home: &Path, workspace: &Path, terminal_device: Option<&str>) -> Bridge { let flags = Flags { home: Some(home.into()), machine_name: Some("e2e".into()), @@ -64,6 +134,12 @@ fn start_bridge(relay: &str, home: &Path, workspace: &Path) -> Bridge { }; let mut config = config::load(&flags).expect("config"); config.host_env.insert("CODEDECK_AGENT_HOST_DRIVERS".into(), "fake".into()); + if let Some(device) = terminal_device { + config.terminal.enabled = true; + config.terminal.relays_allowed = true; + let allow = serde_json::json!({ "devices": [device], "pending": [] }); + std::fs::write(home.join("terminal-allow.json"), allow.to_string()).unwrap(); + } let state = StateFile::open(home).unwrap(); let keys = state.identity().unwrap(); let (stop, stop_rx) = oneshot::channel(); @@ -286,3 +362,139 @@ async fn a_phone_drives_the_real_bridge_through_a_relay() { }) .await; } + +/// Publish one phone→bridge message as a raw event: signed by the identity, +/// NIP-44 encrypted from the session key to the bridge (the direction the +/// phone's own API uses), tagged to the bridge, of `kind`. +#[cfg(unix)] +async fn publish( + transport: &WsTransport, + identity: &Keypair, + session: &Keypair, + bridge: &str, + kind: u16, + msg: client_runtime::protocol::commands::PhoneToBridge, +) { + let plain = client_runtime::protocol::encode_phone_to_bridge(&msg).expect("encodes"); + let content = client_runtime::protocol::crypto::encrypt_to(&session.secret_key, bridge, &plain).expect("encrypts"); + let keys = nostr::Keys::new(identity.secret_key.clone()); + let tag = nostr::Tag::public_key(nostr::PublicKey::from_hex(bridge).unwrap()); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), content).tag(tag).sign_with_keys(&keys).unwrap(); + let signed = client_runtime::protocol::nostr_event::SignedEvent::from_nostr(&event); + let result = transport.publish_confirmed(&signed, Duration::from_secs(5), 2).await; + assert!(result.verdict.is_delivered(), "publish of kind {kind}: {:?} {:?}", result.verdict, result.detail); +} + +/// The terminal's whole path over a relay: a phone opens a terminal on the +/// bridge, types `echo hi`, and sees the output come back. The keystrokes ride +/// the live kind (which the bridge subscribes to only because the owner allows +/// a terminal over relays), and the output is the bridge's coalesced, +/// relay-paced `terminal-output`. Unix only: the bridge runs a real pty. +#[cfg(unix)] +#[tokio::test(flavor = "current_thread")] +#[ignore = "needs node and a built packages/agent-host bundle"] +async fn a_phone_opens_a_terminal_and_types_through_the_bridge() { + use client_runtime::client_core::stores::session_key::{SessionKey, SessionKeyRing, GRANT_LIFETIME_SECS}; + use client_runtime::protocol::commands::{PhoneToBridge, PresenceMsg, TerminalInputMsg, TerminalOpenMsg}; + use client_runtime::protocol::kinds::{COMMAND_KIND, LIVE_KIND}; + use client_runtime::protocol::recipients::device_id; + + assert!(bundle().exists(), "agent host bundle missing at {} — build packages/agent-host first", bundle().display()); + let _ = env_logger::builder().is_test(true).filter_level(log::LevelFilter::Info).try_init(); + LocalSet::new() + .run_until(async { + let relay = support::relay::start().await; + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + + // A phone whose session key the test already knows: the bridge + // names the device by it, so the allowlist can name it too. + let phone = generate_keypair(); + let session = generate_keypair(); + let device = device_id(&session.pubkey_hex); + let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let ring = SessionKeyRing { + current: SessionKey { keypair: session.clone(), expires_at: now_ms / 1000 + GRANT_LIFETIME_SECS }, + previous: None, + }; + let kv = Rc::new(MemoryKv::seeded([(client_runtime::stores::SESSION_KEYS_KEY, ring.encode())])); + + let mut bridge = start_bridge_with(&relay, home.path(), workspace.path(), Some(&device)); + + let store = Rc::new(MemoryTranscriptStore::new()); + let observer = Rc::new(TerminalObserver::default()); + let core = Core::spawn( + CoreConfig::new(Rc::new(LocalSigner(phone.clone())), None, false), + CorePorts { kv: kv.clone(), transcript_store: store.clone(), ..CorePorts::default() }, + observer.clone(), + Rc::new(SystemClock), + Rc::new(TimeEntropy), + ) + .await; + core.start(); + let pairing_url = tokio::time::timeout(TIMEOUT, bridge.urls.recv()).await.expect("a pairing window opens").unwrap(); + core.dispatch(Intent::BeginPairing { url: pairing_url, label: "e2e-terminal".into() }).await; + until("pairing", || async { + let view = core.pairing_view().await?; + assert_ne!(view.phase, "failed", "pairing failed: {:?}", view.error); + (view.phase == "paired").then_some(()) + }) + .await; + let machine = until("the machine", || async { core.machines_view().await.machines.keys().next().cloned() }).await; + + // The phone core has no terminal API yet, so the terminal messages + // go out as raw events, encrypted with the session key the pairing + // just granted. + let transport = WsTransport::new(WsConfig { relays: vec![relay.clone()], auth: Rc::new(phone.clone()), proxy: None }); + transport.ensure_connected(); + until("the raw relay link", || async { (!transport.connected_relays().is_empty()).then_some(()) }).await; + + publish( + &transport, + &phone, + &session, + &machine, + COMMAND_KIND, + PhoneToBridge::TerminalOpen(TerminalOpenMsg { open_id: "o1".into(), cols: 80, rows: 24, session: None }), + ) + .await; + let term = until("terminal-opened", || async { + if let Some(m) = observer.opened.borrow().as_ref() { + return Some(m.term.clone()); + } + if let Some(why) = observer.refused() { + panic!("the bridge refused the open: {why} (saw: {})", observer.trace()); + } + None + }) + .await; + + // Say this client shows the terminal: output goes only to the + // clients that do. + publish( + &transport, + &phone, + &session, + &machine, + COMMAND_KIND, + PhoneToBridge::Presence(PresenceMsg { away: false, watching: None, watching_terminal: Some(term.clone()) }), + ) + .await; + + // Type into it, on the live kind. + publish( + &transport, + &phone, + &session, + &machine, + LIVE_KIND, + PhoneToBridge::TerminalInput(TerminalInputMsg { term: term.clone(), seq: 1, data: STANDARD.encode(b"echo hi\n") }), + ) + .await; + until("the echo", || async { observer.seen().contains("hi").then_some(()) }).await; + assert!(observer.seen().contains("hi"), "the shell's output reached the phone: {:?}", observer.seen()); + + bridge.stop().await; + }) + .await; +} From 82e20616ad731c355c9242a44a63408553c63b3b Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:12:40 +0200 Subject: [PATCH 23/50] perf(bridge): a direct link sends one terminal frame per turn, after the phone's own frames The terminal lane was drained whole once woken: up to 64 frames of 16 KiB went out before the loop looked at the normal lane again, which on an onion endpoint can hold a session's event back for seconds. Now one terminal frame goes per turn of the loop, and the phone's incoming frames are read before terminal output too, so neither a session's events nor a phone's commands ever wait behind a terminal's backlog. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/direct.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/bridge-runtime/src/direct.rs b/crates/bridge-runtime/src/direct.rs index 4bb5b3d1..2f61e96f 100644 --- a/crates/bridge-runtime/src/direct.rs +++ b/crates/bridge-runtime/src/direct.rs @@ -134,6 +134,15 @@ impl TermLane { fn pop(&self) -> Option { self.queue.borrow_mut().pop_front() } + + /// Ready once a frame is waiting. A `push` while nobody waits leaves + /// `Notify`'s permit behind, so no wake-up is lost between the check and + /// the wait. + async fn ready(&self) { + while self.queue.borrow().is_empty() { + self.wake.notified().await; + } + } } impl Conn { @@ -460,16 +469,6 @@ async fn serve(stream: S, hub: &Hub) { ok = false; } }, - _ = term.wake.notified() => { - // Drain the lane: the normal lane was empty a moment ago, and - // draining keeps the notify permit from being missed. - while let Some(text) = term.pop() { - if sink.send(Message::Text(text)).await.is_err() { - ok = false; - break; - } - } - }, incoming = tokio::time::timeout(IDLE_TIMEOUT, source.next()) => match incoming { Ok(Some(Ok(Message::Text(text)))) => match decode_direct_frame(&text) { Ok(DirectFrame::Event(event)) => { @@ -489,6 +488,16 @@ async fn serve(stream: S, hub: &Hub) { Ok(Some(Ok(Message::Binary(_) | Message::Frame(_)))) => {} _ => ok = false, }, + // Terminal output last, one frame per turn: the loop then looks at + // the normal lane and at the phone's own frames again before the + // next one, so a terminal's backlog (up to its whole lane) never + // stands in front of a session's event or a phone's command — on + // an onion endpoint a megabyte of it could take seconds. + () = term.ready() => { + if let Some(text) = term.pop() { + ok = sink.send(Message::Text(text)).await.is_ok(); + } + }, } } hub.unregister(id); From 9a07df23bbaaadd68fce058ab169135eb1388125 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:20:37 +0200 Subject: [PATCH 24/50] test(bridge-runtime): the echo-off test waits for the shell's output, not its echo of the command The test waited for READY, which the shell's echo of the typed command line already contains before stty has run; on a fast runner the password was then typed while echo was still on, and recorded as it should be. The marker is now split in the command, so only the command's output matches. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/terminal/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/bridge-runtime/src/terminal/mod.rs b/crates/bridge-runtime/src/terminal/mod.rs index 3e74d7e1..f44e5a80 100644 --- a/crates/bridge-runtime/src/terminal/mod.rs +++ b/crates/bridge-runtime/src/terminal/mod.rs @@ -760,8 +760,11 @@ mod tests { terms.spawn("t3", &cwd, 80, 24, None).expect("a shell"); // The shell turns its own echo off, the way a password prompt - // does, and says so once it has. - terms.write("t3", b"stty -echo; echo READY\n"); + // does, and says so once it has. The marker is split in the + // command (`RE""ADY`), so the shell's echo of the command line + // itself — which comes before `stty` has run — does not count + // as the shell being ready. + terms.write("t3", b"stty -echo; echo RE\"\"ADY\n"); collect_until(&mut rx, "READY").await; terms.write("t3", b"hunter2\n"); terms.flush_recording("t3"); From 14ec9223c720e66f436d0419f9c6d064cc7a2536 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:13:53 +0200 Subject: [PATCH 25/50] feat(client-core): terminal state, input batching, seq and output offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone half of `docs/TERMINAL.md`: a pure store holding the terminals a bridge lists, the one on screen, and everything that keeps a keystroke stream off the UI's snapshot path. Input is batched, not sent per keystroke. On the normal screen printable bytes wait and go in one event when a key a program could act on arrives (Enter, Tab, Esc and so any escape sequence, Backspace/Delete, any Ctrl chord, an Alt prefix), when typing pauses (150 ms over the direct link, 400 ms over relays) or past 256 bytes; a paste is one event. On the alternate screen every key is a command, so bytes go after at most 20/60 ms and a held key's repeats coalesce. Over relays at most 5 input events a second go out, the rest staying buffered and merging into the next. The flush deadline the store returns is absolute, so a change of transport is picked up on the next keystroke rather than by a re-armed window. One seq per (machine, term) counts up from 1 and never restarts. The last 256 sent events (at most 1 MiB) are kept: a `terminal-input-from` resends from the seq it names when that is still held, and otherwise the count continues from it — which is also how a takeover tells this device where to resume. Older than what is held cannot be resent, and is not. Output is not state: `output` hands the decoded bytes and their offset back for the host to emit on its own event. A frame at the expected offset is passed on; one beyond it means a frame was lost, so the store asks for a replay from the expected offset once per gap and drops frames until it arrives; one that starts behind the expected offset is trimmed to the part not yet drawn, or dropped when it is wholly behind. The terminal this phone is attached to is the writer by the heartbeat's `writer` and by every `terminal-event`'s `device`, which a bridge derives from the first session key this install ever made. The session-key ring now keeps that first key's public half so the id stays the same however often the key rotates — otherwise a terminal would lose track of who is typing after one renewal. `terminal-input` rides LIVE_KIND: keystrokes are never stored on a relay, and timing alone leaks what was typed. Every other command keeps riding COMMAND_KIND with its NIP-40 expiration, which an ephemeral event has no use for. Co-Authored-By: Claude Code --- crates/bridge-runtime/tests/e2e.rs | 8 +- crates/client-core/src/bridge_api.rs | 37 +- crates/client-core/src/stores/mod.rs | 1 + crates/client-core/src/stores/session_key.rs | 93 +- crates/client-core/src/stores/terminals.rs | 1155 ++++++++++++++++++ 5 files changed, 1272 insertions(+), 22 deletions(-) create mode 100644 crates/client-core/src/stores/terminals.rs diff --git a/crates/bridge-runtime/tests/e2e.rs b/crates/bridge-runtime/tests/e2e.rs index 762e2791..0a95836b 100644 --- a/crates/bridge-runtime/tests/e2e.rs +++ b/crates/bridge-runtime/tests/e2e.rs @@ -413,10 +413,10 @@ async fn a_phone_opens_a_terminal_and_types_through_the_bridge() { let session = generate_keypair(); let device = device_id(&session.pubkey_hex); let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; - let ring = SessionKeyRing { - current: SessionKey { keypair: session.clone(), expires_at: now_ms / 1000 + GRANT_LIFETIME_SECS }, - previous: None, - }; + let ring = SessionKeyRing::single(SessionKey { + keypair: session.clone(), + expires_at: now_ms / 1000 + GRANT_LIFETIME_SECS, + }); let kv = Rc::new(MemoryKv::seeded([(client_runtime::stores::SESSION_KEYS_KEY, ring.encode())])); let mut bridge = start_bridge_with(&relay, home.path(), workspace.path(), Some(&device)); diff --git a/crates/client-core/src/bridge_api.rs b/crates/client-core/src/bridge_api.rs index 31e3806e..8166b87a 100644 --- a/crates/client-core/src/bridge_api.rs +++ b/crates/client-core/src/bridge_api.rs @@ -29,7 +29,7 @@ use protocol::nostr_event::SignedEvent; use protocol::codec::{encode_phone_to_bridge, DecodeError}; use protocol::commands::PhoneToBridge; use protocol::events::BridgeToPhone; -use protocol::kinds::{COMMAND_EXPIRY_SECONDS, COMMAND_KIND}; +use protocol::kinds::{COMMAND_EXPIRY_SECONDS, COMMAND_KIND, LIVE_KIND}; /// Most-recent invalid-payload records kept for diagnostics (older ones drop). const INVALID_RECORDS_CAP: usize = 100; @@ -39,12 +39,20 @@ const INVALID_RECORDS_CAP: usize = 100; /// Which Nostr kind a phone→bridge command rides. /// /// Unlike bridge→phone traffic — split three ways by storage class, see -/// `client_runtime::nostr_client` — every phone→bridge command is a stored +/// `client_runtime::nostr_client` — a phone→bridge command is a stored /// [`COMMAND_KIND`] event: a briefly-offline bridge still receives it on /// resubscribe, and NIP-40 expires it after [`COMMAND_EXPIRY_SECONDS`]. This /// function is the single place that policy is stated. -pub fn kind_for_message(_msg: &PhoneToBridge) -> u16 { - COMMAND_KIND +/// +/// The one exception is `terminal-input`: keystrokes are never stored on a +/// relay, even encrypted, since timing and size alone leak what was typed +/// (see `docs/TERMINAL.md`, "Transport"). It rides [`LIVE_KIND`], whose loss +/// is already tolerated — the terminal's own `seq` says what to resend. +pub fn kind_for_message(msg: &PhoneToBridge) -> u16 { + match msg { + PhoneToBridge::TerminalInput(_) => LIVE_KIND, + _ => COMMAND_KIND, + } } /// Why a command could not be built. Every variant is a caller error surfaced at @@ -88,12 +96,16 @@ pub fn command_event( PublicKey::from_hex(machine_pubkey_hex).map_err(|_| CryptoError::InvalidKey)?; let author_pk = PublicKey::from_hex(author_pubkey_hex).map_err(|_| CryptoError::InvalidKey)?; let created_at = now_ms / 1000; - let expiration = created_at + COMMAND_EXPIRY_SECONDS; - let mut event = EventBuilder::new(Kind::Custom(kind_for_message(msg)), content) - .tags([ - Tag::public_key(machine_pk), - Tag::expiration(Timestamp::from(expiration)), - ]) + let kind = kind_for_message(msg); + let mut tags = vec![Tag::public_key(machine_pk)]; + // An expiration tag says nothing about an ephemeral event (a relay never + // stores it); it is added only where it is what keeps a stored command + // from outliving its use. + if kind == COMMAND_KIND { + tags.push(Tag::expiration(Timestamp::from(created_at + COMMAND_EXPIRY_SECONDS))); + } + let mut event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) .custom_created_at(Timestamp::from(created_at)) .build(author_pk); event.ensure_id(); @@ -373,10 +385,13 @@ mod tests { // --- outbound ----------------------------------------------------------- #[test] - fn kind_for_message_is_always_command_kind() { + fn every_command_rides_the_command_kind_but_terminal_input_rides_the_live_one() { let msg = decode_phone_to_bridge(r#"{"v":12,"type":"input","sessionId":"s","text":"hi"}"#).unwrap(); assert_eq!(kind_for_message(&msg), COMMAND_KIND); + let keys = decode_phone_to_bridge(r#"{"v":12,"type":"terminal-input","term":"t","seq":1,"data":"aGk="}"#) + .unwrap(); + assert_eq!(kind_for_message(&keys), LIVE_KIND); } #[test] diff --git a/crates/client-core/src/stores/mod.rs b/crates/client-core/src/stores/mod.rs index 1a9dbcee..a6357093 100644 --- a/crates/client-core/src/stores/mod.rs +++ b/crates/client-core/src/stores/mod.rs @@ -12,5 +12,6 @@ pub mod pairing; pub mod pending_sessions; pub mod quick_prompts; pub mod settings; +pub mod terminals; pub mod transcript; pub mod ui; diff --git a/crates/client-core/src/stores/session_key.rs b/crates/client-core/src/stores/session_key.rs index 972e1fc0..5dfd82a5 100644 --- a/crates/client-core/src/stores/session_key.rs +++ b/crates/client-core/src/stores/session_key.rs @@ -82,6 +82,14 @@ impl SessionKey { pub struct SessionKeyRing { pub current: SessionKey, pub previous: Option, + /// The public half of the first session key this install ever made — the + /// one every later grant's `replaces` chain leads back to. A bridge names + /// this client as a device by it + /// ([`protocol::recipients::device_id`]), and a terminal names the device + /// that opened, attached to or took one over the same way, so the phone + /// must keep the first key's public half for as long as the install lives + /// however often the key itself rotates. Public: no secret is added here. + first_pubkey_hex: String, } /// The ring as the host stores it. Holds secrets; its `Debug` shows none. @@ -93,6 +101,10 @@ struct StoredRing { current: StoredKey, #[serde(default, skip_serializing_if = "Option::is_none")] previous: Option, + /// Absent in a ring stored before the phone kept this; the older of the + /// keys it holds is the best reading of the first one then. + #[serde(default, skip_serializing_if = "Option::is_none")] + first_pubkey_hex: Option, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -130,14 +142,26 @@ impl SessionKeyRing { pub fn load(stored: Option<&str>, now_ms: u64) -> (Self, bool) { let parsed = stored.and_then(|s| serde_json::from_str::(s).ok()).and_then(|r| { let current = r.current.key()?; - Some(Self { current, previous: r.previous.and_then(|p| p.key()) }) + let previous = r.previous.and_then(|p| p.key()); + // A ring stored before the first key was kept: the older key it + // still holds is the closest reading of it. + let first_pubkey_hex = r.first_pubkey_hex.unwrap_or_else(|| { + previous + .as_ref() + .map_or_else(|| current.pubkey_hex().to_string(), |p| p.pubkey_hex().to_string()) + }); + Some(Self { current, previous, first_pubkey_hex }) }); match parsed { Some(mut ring) => { let rotated = ring.rotate_if_due(now_ms); (ring, rotated) } - None => (Self { current: SessionKey::fresh(now_ms), previous: None }, true), + None => { + let current = SessionKey::fresh(now_ms); + let first_pubkey_hex = current.pubkey_hex().to_string(); + (Self { current, previous: None, first_pubkey_hex }, true) + } } } @@ -148,7 +172,30 @@ impl SessionKeyRing { } fn stored(&self) -> StoredRing { - StoredRing { current: StoredKey::of(&self.current), previous: self.previous.as_ref().map(StoredKey::of) } + StoredRing { + current: StoredKey::of(&self.current), + previous: self.previous.as_ref().map(StoredKey::of), + first_pubkey_hex: Some(self.first_pubkey_hex.clone()), + } + } + + /// The public half of the first key this install ever made. + pub fn first_pubkey_hex(&self) -> &str { + &self.first_pubkey_hex + } + + /// A ring holding one key and no history — the shape a fresh install + /// has, and what a caller that only cares about the current key wants. + pub fn single(current: SessionKey) -> Self { + let first_pubkey_hex = current.pubkey_hex().to_string(); + Self { current, previous: None, first_pubkey_hex } + } + + /// The id this install is a device under, as a bridge names it in a + /// terminal's `writer`, in every `terminal-event` and in its audit and + /// allowlist ([`protocol::recipients::device_id`]). + pub fn device_id(&self) -> String { + protocol::recipients::device_id(&self.first_pubkey_hex) } /// Replace the current key with a fresh one once less than @@ -339,8 +386,7 @@ mod tests { } #[test] - fn the_debug_form_holds_no_secret() { - let (ring, _) = SessionKeyRing::load(None, NOW); + fn the_debug_form_holds_no_secret() { let (ring, _) = SessionKeyRing::load(None, NOW); assert!(!format!("{ring:?}").contains(&ring.current.keypair.secret_hex())); } @@ -364,8 +410,41 @@ mod tests { assert!(stale.previous.is_none()); } - fn store(caps: &[&str]) -> MachinesState { - let mut s = MachinesState::new(Default::default(), MergeOptions::default()); + #[test] + fn the_device_id_survives_every_rotation() { + let (mut ring, _) = SessionKeyRing::load(None, NOW); + let first = ring.first_pubkey_hex().to_string(); + assert_eq!(ring.device_id(), protocol::recipients::device_id(&first)); + let lifetime_ms = GRANT_LIFETIME_SECS * 1000; + assert!(ring.rotate_if_due(NOW + lifetime_ms - 29 * DAY_MS)); + // The second rotation drops the first key from the ring; the device + // this install is stays the same, since a bridge derives it once. + assert!(ring.rotate_if_due(NOW + 2 * lifetime_ms - 29 * DAY_MS)); + assert_eq!(ring.first_pubkey_hex(), first); + let (again, _) = SessionKeyRing::load(Some(&ring.encode()), NOW); + assert_eq!(again.first_pubkey_hex(), first); + assert_eq!(again.device_id(), ring.device_id()); + } + + #[test] + fn a_ring_stored_before_the_first_key_was_kept_reads_its_older_key() { + // The shape an older build wrote: no `firstPubkeyHex`. + let (ring, _) = SessionKeyRing::load(None, NOW); + let json = ring.encode(); + let without: String = serde_json::to_string( + &serde_json::from_str::(&json) + .map(|mut v| { + v.as_object_mut().unwrap().remove("firstPubkeyHex"); + v + }) + .unwrap(), + ) + .unwrap(); + let (again, _) = SessionKeyRing::load(Some(&without), NOW); + assert_eq!(again.first_pubkey_hex(), ring.current.pubkey_hex()); + } + + fn store(caps: &[&str]) -> MachinesState { let mut s = MachinesState::new(Default::default(), MergeOptions::default()); for m in ["m", "n"] { s.register_machine(m, "laptop", None, None, &[]); s.machines.get_mut(m).unwrap().capabilities = caps.iter().map(|c| c.to_string()).collect(); diff --git a/crates/client-core/src/stores/terminals.rs b/crates/client-core/src/stores/terminals.rs new file mode 100644 index 00000000..87ed9a80 --- /dev/null +++ b/crates/client-core/src/stores/terminals.rs @@ -0,0 +1,1155 @@ +//! terminals — the shells a bridge lists and the one this phone has on +//! screen, with the input batching and the output offsets that keep a +//! keystroke stream out of the UI's snapshot path. +//! +//! Three things live here, all pure: +//! +//! - **The list.** Each heartbeat names the terminals open on that bridge for +//! this identity ([`protocol::events::TerminalInfo`]). It is the only place +//! a second phone, or this one after a restart, learns a terminal exists. +//! +//! - **The attached terminal** — at most one, the one on screen. It carries +//! the bridge's `term` (absent while an open is still pending), whether this +//! device is the writer, the next output byte offset expected, the last +//! refusal and the reason it ended. +//! +//! - **Input.** Typing is not sent per keystroke: printable bytes wait on the +//! phone and go in one event when a program could act on them, when typing +//! pauses, or past a cap (see `docs/TERMINAL.md`, "Transport"). Every event +//! carries a `seq` counting up by one per terminal, and what was sent is +//! kept a while so a `terminal-input-from` can be answered by resending it. +//! +//! Output is not state: [`TerminalsState::output`] hands the bytes back for +//! the host to emit as its own event, so a terminal producing megabytes a +//! second never re-renders a view. + +use std::collections::{BTreeMap, VecDeque}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; + +use protocol::commands::{TerminalInputMsg, TerminalOpenMsg, TerminalReplayMsg}; +use protocol::common::{TerminalEndReason, TerminalEventKind}; +use protocol::events::{ + TerminalEventMsg, TerminalExitedMsg, TerminalInfo, TerminalInputFromMsg, TerminalOutputMsg, +}; + +/// How many sent input events are kept for a resend, oldest dropped first. +pub const INPUT_KEEP_EVENTS: usize = 256; +/// How many bytes of sent input are kept for a resend. +pub const INPUT_KEEP_BYTES: usize = 1 << 20; +/// Bytes of typed input that go in one event at most on the normal screen. +pub const INPUT_BATCH_BYTES: usize = 256; +/// How long printable input waits on the normal screen before it goes anyway +/// — a typing pause — over the direct link and over relays. +pub const PAUSE_DIRECT_MS: u64 = 150; +pub const PAUSE_RELAY_MS: u64 = 400; +/// How long a key waits on the alternate screen (every key is a command +/// there), over the direct link and over relays. +pub const ALT_PAUSE_DIRECT_MS: u64 = 20; +pub const ALT_PAUSE_RELAY_MS: u64 = 60; +/// Input events a terminal may send a second over relays; the rest is merged +/// into the next. +pub const RELAY_INPUTS_PER_SEC: u32 = 5; +/// The window [`RELAY_INPUTS_PER_SEC`] counts over. +pub const RELAY_WINDOW_MS: u64 = 1000; + +/// Where a machine is reached right now — the pause and the rate cap differ +/// between the direct link and the relays. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputTransport { + Direct, + Relay, +} + +impl InputTransport { + /// How long bytes wait before going anyway on this transport. + fn pause_ms(self, alternate_screen: bool) -> u64 { + match (self, alternate_screen) { + (InputTransport::Direct, false) => PAUSE_DIRECT_MS, + (InputTransport::Relay, false) => PAUSE_RELAY_MS, + (InputTransport::Direct, true) => ALT_PAUSE_DIRECT_MS, + (InputTransport::Relay, true) => ALT_PAUSE_RELAY_MS, + } + } + + /// Only the relays rate-limit their writers; the direct link has no cap. + fn capped(self) -> bool { + self == InputTransport::Relay + } +} + +/// Why a `terminal-open` was refused, as the phone shows it: the reason, and +/// for `not-allowed` the device the bridge refused — its id always, its name +/// when one was ever seen for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub reason: TerminalEndReason, + pub device: Option, + pub name: Option, +} + +/// Why the terminal on screen ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ended { + pub reason: TerminalEndReason, + pub code: Option, +} + +/// The terminal this phone has on screen. `term` is absent while an open is +/// still pending (`open_id` names it) or after a refusal, which never became +/// a terminal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attached { + pub machine: String, + pub term: Option, + pub open_id: Option, + pub shell: Option, + /// Whether this device's input is the one the terminal takes. False while + /// attaching read-only, and after another device takes over. + pub writer: bool, + pub cols: u16, + pub rows: u16, + /// The next output byte offset expected: frames are drawn from here. + pub expected: u64, + /// A frame was lost and a replay asked for; frames are dropped until one + /// arrives at `expected`. + pub replay_pending: bool, + pub refusal: Option, + pub ended: Option, +} + +/// What one input step asks the host to do: events to publish, when to come +/// back for the bytes still waiting, and what those bytes are (the app draws +/// them as its predicted echo). +#[derive(Debug, Default, PartialEq)] +pub struct InputStep { + /// The machine the events go to — the attached terminal's, `None` when + /// nothing is attached. + pub machine: Option, + pub sends: Vec, + /// When to flush again, as an absolute ms deadline. `None` when nothing + /// is waiting. + pub flush_at_ms: Option, + /// Bytes typed and not yet sent. + pub waiting: Vec, +} + +impl InputStep { + fn empty() -> Self { + Self::default() + } +} + +/// One output frame for the app. The bytes are raw, not base64: the host +/// emits them as its own event, never through a view snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputFrame { + pub offset: u64, + pub data: Vec, + /// The bridge is asked for a replay from here (a frame was lost). Sent + /// once per gap, not per frame. + pub replay_from: Option, +} + +/// The typed-but-unsent bytes and the `seq` of one terminal. +#[derive(Debug, Clone, Default)] +struct InputState { + /// The `seq` the next input event carries; counts up by one from 1 and + /// never restarts (a takeover only tells us what to resume at). + next_seq: u64, + pending: Vec, + alternate_screen: bool, + /// When the first and the last of `pending` were typed (ms). + since_ms: u64, + last_ms: u64, + /// Sent events kept for a resend: `(seq, bytes)`, oldest first. + sent: VecDeque<(u64, Vec)>, + sent_bytes: usize, + /// The next held `seq` to resend, when a `terminal-input-from` asked for + /// one that is still held. + resend_from: Option, + /// The relay rate-cap window: its start and how many events went in it. + window_start_ms: u64, + window_count: u32, +} + +impl InputState { + fn fresh(now: u64) -> Self { + Self { next_seq: 1, window_start_ms: now, ..Self::default() } + } + + /// The time the relay cap next lets an event through, when it is full. + fn cap_at(&self, transport: InputTransport) -> Option { + (transport.capped() && self.window_count >= RELAY_INPUTS_PER_SEC) + .then(|| self.window_start_ms.saturating_add(RELAY_WINDOW_MS)) + } + + /// When the host must come back: for bytes still waiting, and for a + /// resend the cap held back. + fn deadline(&self, transport: InputTransport) -> Option { + let mut at: Option = None; + if !self.pending.is_empty() { + let pause = transport.pause_ms(self.alternate_screen); + // On the alternate screen the window runs from the first key, so + // a held key's repeats cannot postpone it past the cap; on the + // normal screen it runs from the last, which is the typing pause. + let from = if self.alternate_screen { self.since_ms } else { self.last_ms }; + at = Some(from.saturating_add(pause)); + } + if let Some(cap) = self.cap_at(transport) { + if !self.pending.is_empty() || self.resend_from.is_some() { + at = Some(at.map_or(cap, |x| x.max(cap))); + } + } + at + } + + /// Keep a sent event for a possible resend, dropping the oldest past the + /// caps. An event too big to ever fit is not kept at all. + fn hold(&mut self, seq: u64, bytes: &[u8]) { + if bytes.len() > INPUT_KEEP_BYTES { + return; + } + self.sent.push_back((seq, bytes.to_vec())); + self.sent_bytes += bytes.len(); + while self.sent.len() > INPUT_KEEP_EVENTS || self.sent_bytes > INPUT_KEEP_BYTES { + let Some((_, dropped)) = self.sent.pop_front() else { break }; + self.sent_bytes = self.sent_bytes.saturating_sub(dropped.len()); + } + } + + fn held(&self, seq: u64) -> Option<&[u8]> { + self.sent.iter().find(|(s, _)| *s == seq).map(|(_, b)| b.as_slice()) + } + + /// Take one event's bytes out of what is waiting: the whole buffer, or a + /// batch of it when more than one event's worth is buffered. + fn take_batch(&mut self) -> Vec { + let take = if self.pending.len() > INPUT_BATCH_BYTES { + INPUT_BATCH_BYTES + } else { + self.pending.len() + }; + self.pending.drain(..take).collect() + } +} + +/// The terminals of every machine, and the one on screen. +#[derive(Debug, Default)] +pub struct TerminalsState { + /// machine → term → what the heartbeat lists. + lists: BTreeMap>, + /// Device id → the name its user gave it, learned from the heartbeat and + /// from `terminal-event`. Used to name the device a refusal is about. + names: BTreeMap, + attached: Option, + inputs: BTreeMap<(String, String), InputState>, + /// The id this install is a device under + /// ([`protocol::recipients::device_id`]), so the heartbeat's `writer` and + /// a `terminal-event`'s `device` can be read as "us" or "another phone". + /// Empty until the host sets it. + device_id: String, + /// Counts the opens this run started, so each carries its own `open_id`. + open_seq: u64, +} + +impl TerminalsState { + /// The device id of this install. Set once by the host, from the first + /// session key it ever granted a bridge. + pub fn set_device_id(&mut self, device_id: String) { + self.device_id = device_id; + } + + pub fn device_id(&self) -> &str { + &self.device_id + } + + /// The terminals `machine`'s heartbeat lists, oldest opened first. + pub fn list(&self, machine: &str) -> Vec { + let mut list: Vec = self + .lists + .get(machine) + .map(|m| m.values().cloned().collect()) + .unwrap_or_default(); + list.sort_by_key(|t| t.opened); + list + } + + /// Every machine's list, for the view. + pub fn lists(&self) -> BTreeMap> { + self.lists.keys().map(|m| (m.clone(), self.list(m))).collect() + } + + pub fn attached(&self) -> Option<&Attached> { + self.attached.as_ref() + } + + /// Whether a terminal is on screen right now. + pub fn is_watching(&self) -> bool { + self.attached.is_some() + } + + /// The machine of the terminal on screen. + pub fn watching_machine(&self) -> Option<&str> { + self.attached.as_ref().map(|a| a.machine.as_str()) + } + + /// The terminal on screen, when it is `machine`'s — the term a `presence` + /// to that machine watches. + pub fn watching_term(&self, machine: &str) -> Option { + let a = self.attached.as_ref()?; + (a.machine == machine).then(|| a.term.clone())? + } + + /// Bytes typed and not yet sent, for the app's predicted echo. + pub fn waiting_input(&self) -> Vec { + let Some(a) = self.attached.as_ref() else { return Vec::new() }; + let Some(term) = a.term.as_deref() else { return Vec::new() }; + self.inputs + .get(&(a.machine.clone(), term.to_string())) + .map(|s| s.pending.clone()) + .unwrap_or_default() + } + + /// The heartbeat's terminal list for `machine`. Returns whether anything + /// the view shows changed. + pub fn apply_list(&mut self, machine: &str, terminals: &[TerminalInfo]) -> bool { + for t in terminals { + if let Some(name) = &t.name { + self.names.insert(t.device.clone(), name.clone()); + } + } + let next: BTreeMap = + terminals.iter().map(|t| (t.term.clone(), t.clone())).collect(); + let changed = self.lists.get(machine) != Some(&next); + if changed { + self.lists.insert(machine.to_string(), next); + } + // A terminal this phone has on screen that the list no longer names is + // gone on the bridge (its shell exited, or the bridge restarted). + let gone = self + .attached + .as_ref() + .filter(|a| a.machine == machine && a.term.is_some()) + .and_then(|a| a.term.clone()) + .filter(|term| !terminals.iter().any(|t| &t.term == term)); + if let Some(term) = gone { + self.attached = None; + self.inputs.remove(&(machine.to_string(), term)); + return true; + } + changed + } + + /// Forget a machine and anything of its on screen (the machine was + /// removed, or unpaired). + pub fn forget_machine(&mut self, machine: &str) { + self.lists.remove(machine); + self.inputs.retain(|(m, _), _| m != machine); + if self.attached.as_ref().is_some_and(|a| a.machine == machine) { + self.attached = None; + } + } + + // --- opens, attaches, detaches ----------------------------------------- + + /// Start an open: the slot goes to "opening" and the command to send is + /// returned. The `open_id` is this phone's own, echoed back by + /// `terminal-opened` (or named by a refused `terminal-exited`). + pub fn begin_open( + &mut self, + machine: &str, + session: Option, + cols: u16, + rows: u16, + ) -> TerminalOpenMsg { + self.open_seq += 1; + let open_id = format!("o{}", self.open_seq); + self.attached = Some(Attached { + machine: machine.to_string(), + term: None, + open_id: Some(open_id.clone()), + shell: None, + writer: true, + cols, + rows, + expected: 0, + replay_pending: false, + refusal: None, + ended: None, + }); + TerminalOpenMsg { open_id, cols, rows, session } + } + + /// Attach to a terminal the heartbeat lists, read-only, from the start of + /// what the bridge still holds. Returns the replay command to send. + pub fn begin_attach(&mut self, machine: &str, term: &str) -> TerminalReplayMsg { + let info = self.lists.get(machine).and_then(|m| m.get(term)).cloned(); + let writer = info + .as_ref() + .is_some_and(|t| !self.device_id.is_empty() && t.writer == self.device_id); + self.attached = Some(Attached { + machine: machine.to_string(), + term: Some(term.to_string()), + open_id: None, + shell: info.as_ref().map(|t| t.shell.clone()), + writer, + cols: info.as_ref().map_or(80, |t| t.cols), + rows: info.as_ref().map_or(24, |t| t.rows), + expected: 0, + replay_pending: false, + refusal: None, + ended: None, + }); + self.inputs + .entry((machine.to_string(), term.to_string())) + .or_insert_with(|| InputState::fresh(0)); + TerminalReplayMsg { term: term.to_string(), from: 0 } + } + + /// Leave the terminal on screen: nothing is watched any more, and whatever + /// was typed but unsent goes out first (the terminal outlives this phone's + /// screen — see `docs/TERMINAL.md`, "Lifetime"). The direct link's timing + /// is used, since a detach is the user's last word and should not wait on + /// the relay cap. + pub fn detach(&mut self, now: u64) -> Vec { + let step = self.flush(InputTransport::Direct, now); + self.attached = None; + step.sends + } + + // --- input -------------------------------------------------------------- + + /// Type into the terminal on screen. `alternate_screen` is what the + /// emulator says it is showing: on the normal screen printable bytes wait + /// for a key a program acts on, on the alternate screen every key is a + /// command and goes after the short window. + pub fn input( + &mut self, + machine: &str, + term: &str, + bytes: &[u8], + alternate_screen: bool, + transport: InputTransport, + now: u64, + ) -> InputStep { + if bytes.is_empty() || !self.is_attached(machine, term) { + return InputStep::empty(); + } + let state = self + .inputs + .entry((machine.to_string(), term.to_string())) + .or_insert_with(|| InputState::fresh(now)); + if state.pending.is_empty() { + state.since_ms = now; + } + state.last_ms = now; + state.alternate_screen = alternate_screen; + state.pending.extend_from_slice(bytes); + // A key a program can act on sends what is waiting with it: `git che` + // and Tab go as one `git che\t`. On the alternate screen every key is + // such a key, and the window is what paces them, so nothing is forced. + let acts = !alternate_screen + && (state.pending.len() > INPUT_BATCH_BYTES || bytes.iter().any(|b| is_action_key(*b))); + if acts { + self.flush(transport, now) + } else { + self.step_for(machine, transport) + } + } + + /// Paste: the whole thing goes as one event, after whatever was typed. + pub fn paste( + &mut self, + machine: &str, + term: &str, + bytes: &[u8], + transport: InputTransport, + now: u64, + ) -> InputStep { + if bytes.is_empty() || !self.is_attached(machine, term) { + return InputStep::empty(); + } + let state = self + .inputs + .entry((machine.to_string(), term.to_string())) + .or_insert_with(|| InputState::fresh(now)); + if state.pending.is_empty() { + state.since_ms = now; + } + state.last_ms = now; + state.pending.extend_from_slice(bytes); + self.flush(transport, now) + } + + /// The flush deadline fired: send what is waiting. + pub fn flush_due(&mut self, transport: InputTransport, now: u64) -> InputStep { + self.flush(transport, now) + } + + /// The bridge is still waiting for input from `seq` (or, on a takeover, + /// names the seq this device's count resumes at): adopt it and resend what + /// is still held from there. + pub fn input_from( + &mut self, + machine: &str, + msg: &TerminalInputFromMsg, + transport: InputTransport, + now: u64, + ) -> InputStep { + if msg.seq == 0 { + return InputStep::empty(); + } + let key = (machine.to_string(), msg.term.clone()); + let Some(state) = self.inputs.get_mut(&key) else { return InputStep::empty() }; + let oldest = state.sent.front().map(|(s, _)| *s); + let newest = state.sent.back().map(|(s, _)| *s); + match (oldest, newest) { + // Held: resend from there, keeping the counter where it is. + (Some(oldest), Some(newest)) if msg.seq >= oldest && msg.seq <= newest => { + state.resend_from = Some(msg.seq); + } + // Older than anything held, or ahead of what was sent: nothing to + // resend, so the count continues from what the bridge named. + _ => { + state.next_seq = msg.seq; + state.resend_from = None; + } + } + self.flush(transport, now) + } + + /// Whether `(machine, term)` is the terminal on screen. + pub fn is_attached(&self, machine: &str, term: &str) -> bool { + self.attached + .as_ref() + .is_some_and(|a| a.machine == machine && a.term.as_deref() == Some(term)) + } + + // --- the bridge's answers ---------------------------------------------- + + /// `terminal-opened`: the open this phone asked for has a terminal. + /// Returns whether the view changed. + pub fn opened(&mut self, machine: &str, open_id: &str, term: &str, shell: &str, now: u64) -> bool { + let Some(a) = self.attached.as_mut() else { return false }; + if a.machine != machine || a.open_id.as_deref() != Some(open_id) { + return false; + } + a.term = Some(term.to_string()); + a.open_id = None; + a.shell = Some(shell.to_string()); + a.writer = true; + a.expected = 0; + a.replay_pending = false; + a.refusal = None; + a.ended = None; + self.inputs + .insert((machine.to_string(), term.to_string()), InputState::fresh(now)); + true + } + + /// `terminal-output` for the terminal on screen. `None` when it is for + /// another terminal or the frame is dropped (an overlap already drawn, or + /// a frame ahead of a replay that was asked for). + pub fn output(&mut self, machine: &str, msg: &TerminalOutputMsg) -> Option { + let a = self.attached.as_mut()?; + if a.machine != machine || a.term.as_deref() != Some(msg.term.as_str()) { + return None; + } + let bytes = STANDARD.decode(&msg.data).ok()?; + if msg.offset > a.expected { + // A frame was lost. Ask for the bytes from where the screen is + // once per gap, and drop what arrives until the replay does. + if a.replay_pending { + return None; + } + a.replay_pending = true; + return Some(OutputFrame { offset: a.expected, data: Vec::new(), replay_from: Some(a.expected) }); + } + let skip = (a.expected - msg.offset) as usize; + if skip >= bytes.len() { + return None; + } + let data = bytes[skip..].to_vec(); + let offset = msg.offset + skip as u64; + a.expected = msg.offset + bytes.len() as u64; + a.replay_pending = false; + Some(OutputFrame { offset, data, replay_from: None }) + } + + /// `terminal-exited`: a refusal of this phone's open (`open_id`), the end + /// of the terminal on screen (`term`), or a terminal the list names. + /// Returns whether the view changed. + pub fn exited(&mut self, machine: &str, msg: &TerminalExitedMsg) -> bool { + if let Some(open_id) = &msg.open_id { + let names = &self.names; + let Some(a) = self.attached.as_mut() else { return false }; + if a.machine != machine || a.open_id.as_deref() != Some(open_id.as_str()) { + return false; + } + a.open_id = None; + a.writer = false; + a.refusal = Some(Refusal { + reason: msg.reason, + device: msg.device.clone(), + name: msg.device.as_ref().and_then(|d| names.get(d).cloned()), + }); + return true; + } + let Some(term) = &msg.term else { return false }; + let mut changed = false; + if let Some(a) = self.attached.as_mut() { + if a.machine == machine && a.term.as_deref() == Some(term.as_str()) { + a.ended = Some(Ended { reason: msg.reason, code: msg.code }); + a.writer = false; + a.replay_pending = false; + changed = true; + } + } + self.inputs.remove(&(machine.to_string(), term.clone())); + if let Some(list) = self.lists.get_mut(machine) { + changed |= list.remove(term).is_some(); + } + changed + } + + /// `terminal-event`: every present client is told who opened, attached, + /// took over or closed a terminal. What matters here is the writer: a + /// takeover by another phone makes this one read-only. + pub fn event(&mut self, machine: &str, msg: &TerminalEventMsg) -> bool { + if let Some(name) = &msg.name { + self.names.insert(msg.device.clone(), name.clone()); + } + let ours = !self.device_id.is_empty() && msg.device == self.device_id; + let Some(a) = self.attached.as_mut() else { return false }; + if a.machine != machine || a.term.as_deref() != Some(msg.term.as_str()) { + return false; + } + match msg.what { + TerminalEventKind::Takeover => { + let changed = a.writer != ours; + a.writer = ours; + changed + } + TerminalEventKind::Closed | TerminalEventKind::Exited => { + let changed = a.writer; + a.writer = false; + changed + } + _ => false, + } + } + + // --- the flush itself --------------------------------------------------- + + /// Send what the cap allows, from the typed bytes first and then from a + /// resend the bridge asked for. + fn flush(&mut self, transport: InputTransport, now: u64) -> InputStep { + let Some(a) = self.attached.as_ref() else { return InputStep::empty() }; + let Some(term) = a.term.clone() else { return InputStep::empty() }; + let machine = a.machine.clone(); + let Some(state) = self.inputs.get_mut(&(machine.clone(), term.clone())) else { + return InputStep::empty(); + }; + let mut sends = Vec::new(); + loop { + // The window has run out: the cap starts again from now. + if transport.capped() && now >= state.window_start_ms.saturating_add(RELAY_WINDOW_MS) { + state.window_start_ms = now; + state.window_count = 0; + } + if state.cap_at(transport).is_some() { + break; + } + let next = if !state.pending.is_empty() { + let seq = state.next_seq; + Some((seq, state.take_batch(), false)) + } else if let Some(from) = state.resend_from { + match state.held(from).map(<[u8]>::to_vec) { + Some(bytes) => Some((from, bytes, true)), + None => { + // Nothing that old is held any more: the resend is over. + state.resend_from = None; + None + } + } + } else { + None + }; + let Some((seq, bytes, resending)) = next else { break }; + if resending { + state.resend_from = Some(seq + 1); + } else { + state.next_seq = seq + 1; + state.hold(seq, &bytes); + } + state.window_count += 1; + sends.push(TerminalInputMsg { term: term.clone(), seq, data: STANDARD.encode(&bytes) }); + } + let waiting = state.pending.clone(); + let flush_at_ms = state.deadline(transport); + InputStep { machine: Some(machine), sends, flush_at_ms, waiting } + } + + /// The step for a call that sent nothing: just when to come back. + fn step_for(&self, machine: &str, transport: InputTransport) -> InputStep { + let Some(a) = self.attached.as_ref() else { return InputStep::empty() }; + let Some(term) = a.term.clone() else { return InputStep::empty() }; + let Some(state) = self.inputs.get(&(machine.to_string(), term)) else { + return InputStep::empty(); + }; + InputStep { + machine: Some(machine.to_string()), + sends: Vec::new(), + flush_at_ms: state.deadline(transport), + waiting: state.pending.clone(), + } + } +} + +/// A byte a program acts on rather than one it echoes: Enter, Tab, Esc (and +/// so any escape sequence, arrows included), any Ctrl chord, and Backspace or +/// Delete. Alt is an Esc prefix, so the same test covers it. +fn is_action_key(b: u8) -> bool { + b < 0x20 || b == 0x7f +} + +#[cfg(test)] +mod tests { + use super::*; + use protocol::common::TerminalVia; + + const M: &str = "machine"; + const T: &str = "t1"; + + fn info(term: &str, writer: &str) -> TerminalInfo { + TerminalInfo { + term: term.into(), + shell: "/bin/bash".into(), + opened: 1, + device: writer.into(), + name: Some("Pixel".into()), + writer: writer.into(), + cols: 80, + rows: 24, + } + } + + /// A store with one terminal open on `M`, attached and the writer. + fn attached() -> TerminalsState { + let mut s = TerminalsState::default(); + s.set_device_id("me".into()); + s.apply_list(M, &[info(T, "me")]); + s.begin_attach(M, T); + s + } + + fn typed(s: &mut TerminalsState, bytes: &[u8], alt: bool, at: u64) -> InputStep { + s.input(M, T, bytes, alt, InputTransport::Direct, at) + } + + fn seqs(step: &InputStep) -> Vec { + step.sends.iter().map(|m| m.seq).collect() + } + + fn bytes_of(step: &InputStep) -> Vec> { + step.sends + .iter() + .map(|m| STANDARD.decode(&m.data).unwrap()) + .collect() + } + + #[test] + fn a_heartbeat_list_is_kept_per_machine() { + let mut s = TerminalsState::default(); + assert!(s.apply_list(M, &[info("t1", "me")])); + assert!(!s.apply_list(M, &[info("t1", "me")]), "the same list is not a change"); + assert!(s.apply_list("other", &[info("t9", "x")])); + assert_eq!(s.list(M).len(), 1); + assert_eq!(s.lists().len(), 2); + } + + #[test] + fn an_open_waits_on_its_open_id_and_becomes_the_writer() { + let mut s = TerminalsState::default(); + let cmd = s.begin_open(M, Some("s1".into()), 100, 40); + assert_eq!(s.attached().unwrap().open_id.as_deref(), Some(cmd.open_id.as_str())); + assert!(s.attached().unwrap().writer, "the opener is the writer from the start"); + assert_eq!(s.attached().unwrap().term, None); + + assert!(s.opened(M, &cmd.open_id, T, "/bin/bash", 0)); + let a = s.attached().unwrap(); + assert_eq!(a.term.as_deref(), Some(T)); + assert_eq!(a.shell.as_deref(), Some("/bin/bash")); + assert!(a.writer); + assert!(s.is_watching()); + } + + #[test] + fn another_open_ids_answer_is_ignored() { + let mut s = TerminalsState::default(); + s.begin_open(M, None, 80, 24); + assert!(!s.opened(M, "not-mine", T, "/bin/sh", 0)); + assert_eq!(s.attached().unwrap().term, None); + } + + #[test] + fn a_refused_open_keeps_the_device_and_its_name() { + let mut s = TerminalsState::default(); + s.apply_list(M, &[info(T, "d1")]); // learns d1's name + let cmd = s.begin_open(M, None, 80, 24); + assert!(s.exited( + M, + &TerminalExitedMsg { + term: None, + open_id: Some(cmd.open_id), + code: None, + reason: TerminalEndReason::NotAllowed, + device: Some("d1".into()), + } + )); + let refusal = s.attached().unwrap().refusal.clone().unwrap(); + assert_eq!(refusal.reason, TerminalEndReason::NotAllowed); + assert_eq!(refusal.device.as_deref(), Some("d1")); + assert_eq!(refusal.name.as_deref(), Some("Pixel")); + assert!(!s.attached().unwrap().writer, "a refused open is not a writer"); + } + + #[test] + fn an_exit_marks_the_terminal_and_drops_it_from_the_list() { + let mut s = attached(); + assert!(s.exited( + M, + &TerminalExitedMsg { + term: Some(T.into()), + open_id: None, + code: Some(0), + reason: TerminalEndReason::Exit, + device: None, + } + )); + let ended = s.attached().unwrap().ended.clone().unwrap(); + assert_eq!(ended.reason, TerminalEndReason::Exit); + assert_eq!(ended.code, Some(0)); + assert!(s.list(M).is_empty()); + assert!(!s.attached().unwrap().writer); + } + + #[test] + fn a_list_that_no_longer_names_the_terminal_clears_the_screen() { + let mut s = attached(); + assert!(s.apply_list(M, &[])); + assert!(s.attached().is_none()); + assert!(!s.is_watching()); + } + + #[test] + fn a_takeover_by_another_phone_makes_this_one_read_only() { + let mut s = attached(); + assert!(s.attached().unwrap().writer); + assert!(s.event( + M, + &TerminalEventMsg { + term: T.into(), + what: TerminalEventKind::Takeover, + device: "other".into(), + name: Some("Tablet".into()), + via: TerminalVia::Relay, + at: 5, + } + )); + assert!(!s.attached().unwrap().writer); + // Its own takeover event leaves it the writer. + assert!(s.event( + M, + &TerminalEventMsg { + term: T.into(), + what: TerminalEventKind::Takeover, + device: "me".into(), + name: None, + via: TerminalVia::Direct, + at: 6, + } + )); + assert!(s.attached().unwrap().writer); + } + + // --- batching ----------------------------------------------------------- + + #[test] + fn a_completion_key_sends_what_waited_with_it() { + let mut s = attached(); + assert!(typed(&mut s, b"git che", false, 0).sends.is_empty(), "printable bytes wait"); + let step = typed(&mut s, b"\t", false, 10); + assert_eq!(bytes_of(&step), vec![b"git che\t".to_vec()]); + assert_eq!(seqs(&step), vec![1]); + assert_eq!(step.flush_at_ms, None); + assert!(step.waiting.is_empty()); + } + + #[test] + fn typing_then_a_pause_sends_one_event_at_the_pause() { + let mut s = attached(); + let step = typed(&mut s, b"ls", false, 100); + assert!(step.sends.is_empty()); + assert_eq!(step.flush_at_ms, Some(100 + PAUSE_DIRECT_MS)); + assert_eq!(step.waiting, b"ls"); + let step = s.flush_due(InputTransport::Direct, 250); + assert_eq!(bytes_of(&step), vec![b"ls".to_vec()]); + assert_eq!(step.flush_at_ms, None); + } + + #[test] + fn a_second_keystroke_pushes_the_pause_out() { + let mut s = attached(); + typed(&mut s, b"l", false, 100); + let step = typed(&mut s, b"s", false, 200); + assert_eq!(step.flush_at_ms, Some(200 + PAUSE_DIRECT_MS)); + } + + #[test] + fn past_the_batch_cap_the_bytes_are_split() { + let mut s = attached(); + let step = typed(&mut s, &vec![b'a'; 300], false, 0); + let sent = bytes_of(&step); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].len(), INPUT_BATCH_BYTES); + assert_eq!(sent[1].len(), 300 - INPUT_BATCH_BYTES); + assert_eq!(seqs(&step), vec![1, 2]); + } + + #[test] + fn the_alternate_screen_flushes_after_the_short_window() { + let mut s = attached(); + let step = typed(&mut s, b"\x1b[A", true, 100); + assert!(step.sends.is_empty(), "nothing is forced on the alternate screen"); + assert_eq!(step.flush_at_ms, Some(100 + ALT_PAUSE_DIRECT_MS)); + // A held key's repeat does not push the window out. + let step = typed(&mut s, b"\x1b[A", true, 110); + assert_eq!(step.flush_at_ms, Some(100 + ALT_PAUSE_DIRECT_MS)); + let step = s.flush_due(InputTransport::Direct, 120); + assert_eq!(bytes_of(&step), vec![b"\x1b[A\x1b[A".to_vec()]); + } + + #[test] + fn a_paste_is_one_event() { + let mut s = attached(); + let step = s.paste(M, T, b"a whole line of text\n", InputTransport::Direct, 0); + assert_eq!(bytes_of(&step), vec![b"a whole line of text\n".to_vec()]); + assert_eq!(seqs(&step), vec![1]); + } + + #[test] + fn over_relays_the_pause_is_longer_and_the_rate_is_capped() { + let mut s = attached(); + let step = s.input(M, T, b"x", false, InputTransport::Relay, 0); + assert_eq!(step.flush_at_ms, Some(PAUSE_RELAY_MS)); + // Five events in one second; the rest is merged into the next. + let mut sent = 0; + for i in 0..6u64 { + sent += s.input(M, T, b"a\r", false, InputTransport::Relay, i).sends.len(); + } + assert_eq!(sent, RELAY_INPUTS_PER_SEC as usize, "the cap merges the rest"); + let step = s.input(M, T, b"b\r", false, InputTransport::Relay, 100); + assert!(step.sends.is_empty()); + assert_eq!(step.flush_at_ms, Some(RELAY_WINDOW_MS), "held until the window turns"); + let step = s.flush_due(InputTransport::Relay, RELAY_WINDOW_MS); + assert_eq!(bytes_of(&step).concat(), b"a\rb\r".to_vec()); + } + + #[test] + fn the_direct_link_has_no_rate_cap() { + let mut s = attached(); + let mut sent = 0; + for i in 0..10u64 { + sent += typed(&mut s, b"a\r", false, i).sends.len(); + } + assert_eq!(sent, 10); + } + + // --- seq and resends ---------------------------------------------------- + + #[test] + fn every_event_counts_up_by_one() { + let mut s = attached(); + assert_eq!(seqs(&typed(&mut s, b"a\r", false, 0)), vec![1]); + assert_eq!(seqs(&typed(&mut s, b"b\r", false, 1)), vec![2]); + assert_eq!(seqs(&s.paste(M, T, b"c", InputTransport::Direct, 2)), vec![3]); + } + + #[test] + fn a_gap_in_the_seq_resends_what_is_still_held() { + let mut s = attached(); + typed(&mut s, b"a\r", false, 0); + typed(&mut s, b"b\r", false, 1); + typed(&mut s, b"c\r", false, 2); + let step = s.input_from( + M, + &TerminalInputFromMsg { term: T.into(), seq: 2 }, + InputTransport::Direct, + 3, + ); + assert_eq!(seqs(&step), vec![2, 3]); + assert_eq!(bytes_of(&step), vec![b"b\r".to_vec(), b"c\r".to_vec()]); + // The counter is where it was: the next event is 4, not 3 again. + assert_eq!(seqs(&typed(&mut s, b"d\r", false, 4)), vec![4]); + } + + #[test] + fn a_seq_nothing_is_held_for_continues_from_there() { + let mut s = attached(); + typed(&mut s, b"a\r", false, 0); + let step = s.input_from( + M, + &TerminalInputFromMsg { term: T.into(), seq: 9 }, + InputTransport::Direct, + 1, + ); + assert!(step.sends.is_empty(), "nothing older than what is held can be resent"); + assert_eq!(seqs(&typed(&mut s, b"b\r", false, 2)), vec![9]); + } + + #[test] + fn a_takeover_adopts_the_seq_the_bridge_names() { + let mut s = attached(); + typed(&mut s, b"a\r", false, 0); + typed(&mut s, b"b\r", false, 1); + // A takeover names one past the last this device's count reached: + // nothing is resent, and the counter carries on from there. + let step = s.input_from( + M, + &TerminalInputFromMsg { term: T.into(), seq: 3 }, + InputTransport::Direct, + 2, + ); + assert!(step.sends.is_empty()); + assert_eq!(seqs(&typed(&mut s, b"c\r", false, 3)), vec![3]); + } + + #[test] + fn only_the_last_kept_events_can_be_resent() { + let mut s = attached(); + for i in 0..(INPUT_KEEP_EVENTS as u64 + 5) { + typed(&mut s, b"x\r", false, i); + } + // seq 1 fell out of the buffer: the count continues from what the + // bridge named rather than typing old input again. + let step = s.input_from( + M, + &TerminalInputFromMsg { term: T.into(), seq: 1 }, + InputTransport::Direct, + 1000, + ); + assert!(step.sends.is_empty()); + assert_eq!(seqs(&typed(&mut s, b"y\r", false, 1001)), vec![1]); + } + + // --- output ------------------------------------------------------------- + + fn out(offset: u64, text: &str) -> TerminalOutputMsg { + TerminalOutputMsg { term: T.into(), offset, data: STANDARD.encode(text.as_bytes()) } + } + + #[test] + fn output_in_order_is_handed_straight_to_the_app() { + let mut s = attached(); + let f = s.output(M, &out(0, "ls -la\n")).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (0, b"ls -la\n".as_slice())); + assert_eq!(s.attached().unwrap().expected, 7); + let f = s.output(M, &out(7, "total 0\n")).unwrap(); + assert_eq!(f.offset, 7); + assert_eq!(s.attached().unwrap().expected, 15); + } + + #[test] + fn a_gap_asks_for_a_replay_once_and_drops_until_it_arrives() { + let mut s = attached(); + s.output(M, &out(0, "abcd")); + let f = s.output(M, &out(10, "lost")).unwrap(); + assert_eq!(f.replay_from, Some(4), "asked from where the screen is"); + assert!(f.data.is_empty()); + assert_eq!(s.output(M, &out(20, "still lost")), None, "one request per gap"); + let f = s.output(M, &out(4, "efgh")).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (4, b"efgh".as_slice())); + assert_eq!(s.attached().unwrap().expected, 8); + assert!(!s.attached().unwrap().replay_pending); + } + + #[test] + fn an_overlapping_frame_is_trimmed() { + let mut s = attached(); + s.output(M, &out(0, "abcd")); + let f = s.output(M, &out(2, "cdef")).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (4, b"ef".as_slice())); + assert_eq!(s.attached().unwrap().expected, 6); + // Wholly behind what was drawn: dropped. + assert_eq!(s.output(M, &out(0, "ab")), None); + assert_eq!(s.attached().unwrap().expected, 6); + } + + #[test] + fn output_for_another_terminal_or_a_bad_payload_is_dropped() { + let mut s = attached(); + let mut other = out(0, "x"); + other.term = "t9".into(); + assert_eq!(s.output(M, &other), None); + assert_eq!( + s.output(M, &TerminalOutputMsg { term: T.into(), offset: 0, data: "not base64!".into() }), + None + ); + assert_eq!(s.attached().unwrap().expected, 0); + } + + // --- presence and lifetime --------------------------------------------- + + #[test] + fn watching_follows_the_attached_terminal() { + let mut s = TerminalsState::default(); + assert!(!s.is_watching()); + assert_eq!(s.watching_term(M), None); + s.begin_attach(M, T); + assert_eq!(s.watching_machine(), Some(M)); + assert_eq!(s.watching_term(M), Some(T.into())); + assert_eq!(s.watching_term("other"), None); + s.detach(0); + assert!(!s.is_watching()); + assert_eq!(s.watching_term(M), None); + } + + #[test] + fn detaching_sends_what_was_typed_but_unsent() { + let mut s = attached(); + typed(&mut s, b"half typed", false, 0); + let sends = s.detach(10); + assert_eq!(sends.len(), 1); + assert_eq!(STANDARD.decode(&sends[0].data).unwrap(), b"half typed".to_vec()); + assert!(s.attached().is_none()); + } + + #[test] + fn the_waiting_bytes_are_the_predicted_echo() { + let mut s = attached(); + typed(&mut s, b"git che", false, 0); + assert_eq!(s.waiting_input(), b"git che".to_vec()); + typed(&mut s, b"\t", false, 1); + assert!(s.waiting_input().is_empty()); + } + + #[test] + fn typing_into_a_terminal_that_is_not_on_screen_does_nothing() { + let mut s = attached(); + assert!(s.input(M, "t9", b"x\r", false, InputTransport::Direct, 0).sends.is_empty()); + assert!(s.input("other", T, b"x\r", false, InputTransport::Direct, 0).sends.is_empty()); + } + + #[test] + fn forgetting_a_machine_clears_its_list_and_the_screen() { + let mut s = attached(); + s.apply_list("other", &[info("t9", "x")]); + s.forget_machine(M); + assert!(s.attached().is_none()); + assert!(s.list(M).is_empty()); + assert_eq!(s.list("other").len(), 1); + } +} From 21bd7949e8976e59ec8946f011c85f1f55bf9f39 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:14:07 +0200 Subject: [PATCH 26/50] feat(client-runtime): route terminal messages, pace input, stream output Wires the terminal store into the event loop. - `Router` handles the five terminal messages: `terminal-opened` claims the pending open, `terminal-output` is decoded and handed back as `TerminalOutputEvent`, a gap answers itself with `terminal-replay`, `terminal-input-from` resends from the seq it names, and `terminal-exited` / `terminal-event` update the slot. The heartbeat's `terminals` list is applied on every session list. - `CoreEvent::TerminalOutput { machine, term, offset, bytes }` is the dedicated stream the hard requirement asks for: raw bytes on their own event, so a shell writing megabytes a second never re-renders a view. The view keeps only the low-frequency half (`UiView::terminals`). - Input batching runs on its own timer (`Msg::TerminalFlush`), armed from the absolute deadline the store returns, so a keystroke re-arms it and the relay rate cap's window end is respected. The transport for the pause and the cap is read from the live direct link, per machine. - `presence` now carries `watching_terminal` alongside `watching`. The settle logic compares a per-machine `Watch { session, terminal }` map, so a terminal on one machine and a session on another are each told, and a machine left behind is told nothing is watched there. - The store's device id comes from the session-key ring's first key, so a terminal can tell this phone's input from another client's. - `OpenTerminal` is refused unless the machine advertises the `terminal` capability: sending it to a bridge without one would wait on an answer that never comes. Removing a machine forgets its terminals. Co-Authored-By: Claude Code --- crates/client-runtime/src/dispatch.rs | 105 +++++++++- crates/client-runtime/src/intent.rs | 143 ++++++++++++- crates/client-runtime/src/lib.rs | 6 +- crates/client-runtime/src/runtime.rs | 218 ++++++++++++++++---- crates/client-runtime/src/runtime/backup.rs | 3 +- crates/client-runtime/src/runtime/tests.rs | 213 ++++++++++++++++++- crates/client-runtime/src/stores.rs | 7 + crates/client-runtime/src/view.rs | 80 ++++++- 8 files changed, 712 insertions(+), 63 deletions(-) diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index 2bf06f7f..20104116 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -21,6 +21,7 @@ use client_core::stores::pairing::{ }; use client_core::stores::fetches::Fetch; use client_core::stores::live_text::LiveChange; +use client_core::stores::terminals::{InputTransport, InputStep}; use client_core::stores::transcript::SyncEffect; use client_core::stores::ui::{CredentialsAckInput, ProviderProfileAckInput}; use protocol::commands::{ @@ -30,10 +31,21 @@ use protocol::common::{EntryBody, SessionOption, SessionState, SignInStatus}; use protocol::events::BridgeToPhone; use crate::ports::{TranscriptRow, TranscriptStore}; +use crate::runtime::TerminalFlush; use crate::signer::PhoneKeys; use crate::stores::CoreStores; use client_core::stores::ui::UiEffect; +/// One terminal output frame the host emits as its own event, raw bytes and +/// all — never through a view snapshot (see `docs/TERMINAL.md`, "Transport"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalOutputEvent { + pub machine: String, + pub term: String, + pub offset: u64, + pub data: Vec, +} + /// A `client_core` store the runtime must re-serialize to the `Kv` after a /// route mutated it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -108,6 +120,12 @@ pub struct RouteResult { pub folder_ack: Option, /// Effects of sessions gone: cancel what was notified for them. pub ui_effects: Vec, + /// Terminal output frames for the terminal on screen — raw bytes, on + /// their own path so a busy shell never re-renders a view. + pub terminal_output: Vec, + /// A `terminal-input-from` was answered: arm or clear the flush timer for + /// whatever is still waiting. + pub terminal_flush: Option, } impl RouteResult { @@ -123,6 +141,21 @@ impl RouteResult { msg, }); } + + /// A terminal input step: the events it produced, and when to come back + /// for the bytes it could not send. + fn terminal_step(&mut self, machine: &str, step: InputStep) { + if step.machine.is_none() { + return; + } + for msg in step.sends { + self.send(machine, PhoneToBridge::TerminalInput(msg)); + } + self.terminal_flush = Some(match step.flush_at_ms { + Some(at_ms) => TerminalFlush::Arm { at_ms }, + None => TerminalFlush::Clear, + }); + } } pub struct Router<'a> { @@ -137,6 +170,9 @@ pub struct Router<'a> { pub notify_enabled: bool, /// A build with an in-app chime seam. pub ping_available: bool, + /// Where the routing machine is reached right now: terminal input is + /// paced differently over the direct link and over the relays. + pub terminal_transport: InputTransport, } impl<'a> Router<'a> { @@ -156,6 +192,7 @@ impl<'a> Router<'a> { visible: true, notify_enabled: true, ping_available: false, + terminal_transport: InputTransport::Direct, } } @@ -492,14 +529,55 @@ impl<'a> Router<'a> { ); r.ui_changed = true; } - // No terminal store yet: the bridge needs the `terminal` - // capability before it serves one, and nothing here holds a - // terminal's state until it does. - BridgeToPhone::TerminalOpened(_) - | BridgeToPhone::TerminalOutput(_) - | BridgeToPhone::TerminalInputFrom(_) - | BridgeToPhone::TerminalExited(_) - | BridgeToPhone::TerminalEvent(_) => {} + // --- terminals (see `docs/TERMINAL.md`) --- + // + // Only the terminal on screen is acted on: output for another + // terminal is not sent to this client anyway (the bridge sends it + // only to watchers), and input is dropped by the store unless it + // names that terminal. + BridgeToPhone::TerminalOpened(m) => { + if self.stores.terminals.opened(machine, &m.open_id, &m.term, &m.shell, self.now) { + r.ui_changed = true; + } + } + BridgeToPhone::TerminalOutput(m) => { + if let Some(frame) = self.stores.terminals.output(machine, m) { + if let Some(from) = frame.replay_from { + r.send( + machine, + PhoneToBridge::TerminalReplay(protocol::commands::TerminalReplayMsg { + term: m.term.clone(), + from, + }), + ); + } + if !frame.data.is_empty() { + r.terminal_output.push(TerminalOutputEvent { + machine: machine.to_string(), + term: m.term.clone(), + offset: frame.offset, + data: frame.data, + }); + } + } + } + BridgeToPhone::TerminalInputFrom(m) => { + let step = self + .stores + .terminals + .input_from(machine, m, self.terminal_transport, self.now); + r.terminal_step(machine, step); + } + BridgeToPhone::TerminalExited(m) => { + if self.stores.terminals.exited(machine, m) { + r.ui_changed = true; + } + } + BridgeToPhone::TerminalEvent(m) => { + if self.stores.terminals.event(machine, m) { + r.ui_changed = true; + } + } } r } @@ -619,6 +697,17 @@ impl<'a> Router<'a> { let unclosed = self.stores.machines.apply_session_list(machine, m, self.now); r.persist(StoreId::Machines); r.heartbeat = Some((machine.to_string(), self.now)); + // The terminals this identity owns on the bridge. Absent means it + // owns none (the bridge sends one shared list to everyone without + // one), so the list empties — and a terminal the phone still shows, + // which the bridge no longer runs, goes with it. + if self + .stores + .terminals + .apply_list(machine, m.terminals.as_deref().unwrap_or(&[])) + { + r.ui_changed = true; + } // The session on screen may have opened before this catalog named its // agent's style, or its agent may have changed version. let on_screen = self.stores.ui.selected_session.clone().filter(|s| self.viewing_session(machine, s)); diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index d20b7d80..bdd847e7 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -15,18 +15,20 @@ use client_core::stores::outbox::OutboxState; use client_core::stores::pairing::{ parse_manual_pair, parse_pairing_url, pairing_reducer, PairingEvent, PAIR_ACK_TIMEOUT_MS, }; +use client_core::stores::terminals::{InputStep, InputTransport}; use client_core::stores::ui::{UiEffect, UndoToast}; use protocol::commands::{ AccountActionMsg, AccountsRequestMsg, AgentActionMsg, BareMsg, CreateFolderMsg, CreateSessionMsg, InputMsg, McpActionMsg, McpRequestMsg, ModelsRequestMsg, PermissionResponseMsg, PermissionRulesRemoveMsg, PermissionRulesRequestMsg, PersonasRequestMsg, PhoneToBridge, PlanResponseMsg, PluginActionMsg, PluginsRequestMsg, ProviderProfileWrite, QuestionAnswer, QuestionResponseMsg, SessionIdMsg, SessionMcpToggleMsg, SetCredentialsMsg, SetOptionMsg, SetProviderProfileMsg, - StopTaskMsg, + StopTaskMsg, TerminalIdMsg, TerminalResizeMsg, }; use protocol::common::{AccountAction, AgentAction, CredentialValues, McpAction, McpServerSpec, PluginAction, SessionOption}; use protocol::events::McpAckMsg; use serde::{Deserialize, Serialize}; use crate::dispatch::{apply_pairing_effects, PairDeadline, Send, StoreId}; +use crate::runtime::TerminalFlush; use crate::signer::PhoneKeys; use crate::stores::CoreStores; @@ -71,6 +73,10 @@ pub struct IntentCtx { pub now: u64, /// App visibility — `SelectSession` only clears unread while visible. pub visible: bool, + /// Where the machine terminal input is for is reached right now. Only the + /// terminal intents read it: the pause before bytes go, and the relay + /// rate cap, differ between the direct link and the relays. + pub terminal_transport: InputTransport, } #[derive(Debug, Default, PartialEq)] @@ -106,6 +112,9 @@ pub struct IntentResult { /// (there: remote tombstones; here: `RemoveMachine` forgetting every /// session the machine had). pub transcript_removed: Vec<(String, String)>, + /// Typed terminal input is waiting: arm (or clear) the flush timer for + /// this absolute ms deadline. + pub terminal_flush: Option, } impl IntentResult { @@ -120,6 +129,22 @@ impl IntentResult { self.persist.push(id); } } + + /// A terminal input step: the events it produced, and when to come back + /// for the bytes it could not send. A step with no machine means nothing + /// is attached, so the timer is left alone. + fn terminal_step(&mut self, machine: &str, step: InputStep) { + if step.machine.is_none() { + return; + } + for msg in step.sends { + self.send(machine, PhoneToBridge::TerminalInput(msg)); + } + self.terminal_flush = Some(match step.flush_at_ms { + Some(at_ms) => TerminalFlush::Arm { at_ms }, + None => TerminalFlush::Clear, + }); + } } /// The user action. Serializable so a binding can pass it across the FFI. @@ -466,6 +491,63 @@ pub enum Intent { card_id: String, key: String, }, + + // --- terminals (see `docs/TERMINAL.md`) --- + /// Open a real shell on `machine` (one advertising the `terminal` + /// capability). `session` starts it in that session's directory; the + /// phone never names a path. The bridge answers `terminal-opened` (or + /// refuses with `terminal-exited`). + OpenTerminal { + machine: String, + session: Option, + cols: u16, + rows: u16, + }, + /// Put a terminal the heartbeat lists on screen, read-only, replaying its + /// output from the start of what the bridge still holds. + AttachTerminal { + machine: String, + term: String, + }, + /// Leave the terminal on screen: presence stops watching it, so the + /// bridge sends no more output. The shell itself keeps running. + DetachTerminal { + machine: String, + }, + /// Type into the terminal on screen. `alternate_screen` is what the + /// emulator says it is showing: on the normal screen printable bytes wait + /// for a key a program acts on, on the alternate screen they go after the + /// short window. Paced and sequenced by the store, not sent per keystroke. + TerminalInput { + machine: String, + term: String, + bytes: Vec, + alternate_screen: bool, + }, + /// Paste: the whole thing goes as one event. + TerminalPaste { + machine: String, + term: String, + bytes: Vec, + }, + /// The emulator's window changed. Only the writer's size counts. + ResizeTerminal { + machine: String, + term: String, + cols: u16, + rows: u16, + }, + /// Become the terminal's writer. Audited on the bridge; every present + /// client is told. + TakeOverTerminal { + machine: String, + term: String, + }, + /// Hang up: the shell is asked to end, and killed if it does not. + CloseTerminal { + machine: String, + term: String, + }, } pub fn apply( @@ -586,6 +668,10 @@ pub fn apply( } // Unsent messages for sessions it no longer listed, too. stores.outbox.forget_machine(&pubkey_hex); + // Its terminals went with it: a bridge that is gone lists + // none, and a terminal still on screen would be one nothing + // can answer for. + stores.terminals.forget_machine(&pubkey_hex); r.persist(StoreId::Outbox); r.ui_changed = true; if stores.ui.selected_machine.as_deref() == Some(pubkey_hex.as_str()) { @@ -967,6 +1053,58 @@ pub fn apply( stores.ui.set_plan_approval_choice(&card_id, &key); r.ui_changed = true; } + + // --- terminals --- + Intent::OpenTerminal { machine, session, cols, rows } => { + // A bridge that does not advertise terminals never opens one, and + // the phone shows Terminal only against one that does: sending + // the command would wait on an answer that never comes. + let advertises = stores + .machines + .machine(&machine) + .is_some_and(|m| m.capabilities.iter().any(|c| c == protocol::capabilities::TERMINAL)); + if !advertises { + return r; + } + let cmd = stores.terminals.begin_open(&machine, session, cols, rows); + r.send(&machine, PhoneToBridge::TerminalOpen(cmd)); + r.ui_changed = true; + } + Intent::AttachTerminal { machine, term } => { + let cmd = stores.terminals.begin_attach(&machine, &term); + r.send(&machine, PhoneToBridge::TerminalReplay(cmd)); + r.ui_changed = true; + } + Intent::DetachTerminal { machine } => { + // Whatever was typed but unsent goes first: the terminal outlives + // this phone's screen. + for msg in stores.terminals.detach(ctx.now) { + r.send(&machine, PhoneToBridge::TerminalInput(msg)); + } + r.terminal_flush = Some(TerminalFlush::Clear); + r.ui_changed = true; + } + Intent::TerminalInput { machine, term, bytes, alternate_screen } => { + let step = stores + .terminals + .input(&machine, &term, &bytes, alternate_screen, ctx.terminal_transport, ctx.now); + r.terminal_step(&machine, step); + } + Intent::TerminalPaste { machine, term, bytes } => { + let step = stores + .terminals + .paste(&machine, &term, &bytes, ctx.terminal_transport, ctx.now); + r.terminal_step(&machine, step); + } + Intent::ResizeTerminal { machine, term, cols, rows } => { + r.send(&machine, PhoneToBridge::TerminalResize(TerminalResizeMsg { term, cols, rows })); + } + Intent::TakeOverTerminal { machine, term } => { + r.send(&machine, PhoneToBridge::TerminalTakeover(TerminalIdMsg { term })); + } + Intent::CloseTerminal { machine, term } => { + r.send(&machine, PhoneToBridge::TerminalClose(TerminalIdMsg { term })); + } } r } @@ -1084,6 +1222,7 @@ mod tests { IntentCtx { now: 1_000, visible: true, + terminal_transport: InputTransport::Direct, } } @@ -1567,7 +1706,7 @@ mod tests { Intent::RequestProviderProfiles { machine: "m".into() }, Intent::RequestModels { machine: "m".into(), agent: "claude".into() }, ] { - sent += apply(s, intent, &kp, IntentCtx { now, visible: true }).sends.len(); + sent += apply(s, intent, &kp, IntentCtx { now, visible: true, terminal_transport: InputTransport::Direct }).sends.len(); } std::mem::take(&mut sent) }; diff --git a/crates/client-runtime/src/lib.rs b/crates/client-runtime/src/lib.rs index 13b0b4ed..8723217f 100644 --- a/crates/client-runtime/src/lib.rs +++ b/crates/client-runtime/src/lib.rs @@ -45,9 +45,9 @@ pub use intent::{Intent, IntentCtx, SessionFileSend}; pub use ports::{Kv, MemoryKv, MemoryTranscriptStore, Notifier, NullNotifier, SessionKeyStore, TranscriptStore}; pub use stores::{CoreStores, HydratedCore}; pub use view::{ - BackupView, ConnectionView, MachinesView, OutboxView, PairingView, - PendingSessionsView, QuickPromptsView, SettingsView, TranscriptRowsView, TranscriptRowView, - TranscriptSyncView, UiView, + AttachedTerminalView, BackupView, ConnectionView, MachinesView, OutboxView, PairingView, + PendingSessionsView, QuickPromptsView, SettingsView, TerminalEndedView, TerminalRefusalView, + TerminalsView, TranscriptRowsView, TranscriptRowView, TranscriptSyncView, UiView, }; /// Re-export the pure core and the wire contract so hosts have one diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index a14088a2..06d3f813 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -16,11 +16,12 @@ use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use client_core::bridge_api::{BridgeApi, EgressError, IncomingEvent, Ingested}; use client_core::stores::machines::Incompatible; use client_core::stores::session_key::{Recipient, SessionKeyRing}; +use client_core::stores::terminals::InputTransport; use protocol::capabilities::PROTOCOL_VERSION; use nostr_transport::direct::{DirectConfig as LinkConfig, DirectHandlers, DirectLink}; use nostr_transport::{PublishResult, PublishVerdict}; @@ -91,6 +92,16 @@ const ACK_BATCH_WINDOW: Duration = Duration::from_millis(500); /// before it goes to the relays instead. const DIRECT_PUBLISH_WAIT: Duration = Duration::from_secs(5); +/// What one `presence` tells a machine this client is showing: the session +/// whose live text it draws, and the terminal whose output it draws. Both are +/// `None` for a machine it shows neither of, which is how a bridge is told to +/// stop sending them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct Watch { + session: Option, + terminal: Option, +} + /// Where a machine's direct link goes: its endpoints, the certificate pin, /// and the proxy (Orbot) in force. #[derive(Debug, Clone, PartialEq, Eq)] @@ -219,6 +230,12 @@ pub enum CoreEvent { /// landed, or the session stopped running). Shown after the transcript's /// rows; never stored. LiveText { machine: String, session_id: String, key: String, text: Option }, + /// Output a terminal on screen produced: `bytes` (already base64-decoded) + /// start at `offset`, a byte offset into everything that terminal has + /// written. On its own event, never a view: a shell can produce megabytes + /// a second, and a view snapshot per frame would slow every other slice + /// down. The consumer draws the bytes and advances its own cursor. + TerminalOutput { machine: String, term: String, offset: u64, bytes: Vec }, /// The correlated response to `Intent::CreateFolder` — matched by /// `request_id` off this stream (no store, no view: nothing to persist or /// re-fetch for a one-shot RPC-style exchange). @@ -237,6 +254,16 @@ pub enum CoreEvent { // --- config --------------------------------------------------------------- +/// What an input step asks the loop's flush timer to do — the timer twin of +/// `client_core::stores::terminals::InputStep::flush_at_ms`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalFlush { + /// Arm (or re-arm) the flush at this absolute ms deadline. + Arm { at_ms: u64 }, + /// Nothing is waiting: stop the timer. + Clear, +} + /// The relays are not part of the configuration: the transport dials the /// paired machines' own (see [`CoreStores::relay_set`]). pub struct CoreConfig { @@ -417,6 +444,12 @@ impl Core { ); let (signer_jobs, jobs_rx) = mpsc::unbounded_channel::(); tokio::task::spawn_local(run_signer(Rc::clone(&config.identity), jobs_rx, tx.clone())); + let mut stores = hydrated.stores; + // The device this install is, as a bridge names it in a terminal's + // `writer` and in every `terminal-event`: a hash of the first session + // key it ever made, so a terminal can tell this phone's input from + // another client's. + stores.terminals.set_device_id(session.device_id()); let event_loop = Loop { reconnect: config.reconnect, signer: config.identity, @@ -445,7 +478,8 @@ impl Core { pair_timer: None, unanswered_timer: None, watch_timer: None, - watch_sent: None, + watch_sent: BTreeMap::new(), + terminal_flush_timer: None, undo_timer: None, backup_timer: None, backup_busy: false, @@ -474,7 +508,7 @@ impl Core { links_on: false, link_ping: PING_EVERY, self_tx: tx.clone(), - stores: hydrated.stores, + stores, kv: ports.kv, transcript_store: ports.transcript_store, notifier: ports.notifier, @@ -660,6 +694,7 @@ impl Core { credentials_status: Default::default(), provider_profile_status: Default::default(), undo_toast: None, + terminals: Default::default(), }) } @@ -688,6 +723,9 @@ enum Msg { VisibilitySettled, /// The coalescing window for a change of what this client watches closed. WatchSettle, + /// The terminal input batching window closed, or the relay rate cap + /// lifted: send whatever is still waiting. + TerminalFlush, StaleWatchdog, /// The host woke the device to check the connection. Keepalive(oneshot::Sender<()>), @@ -990,9 +1028,12 @@ struct Loop { /// The coalescing window for a change of what this client watches (see /// [`Loop::watch_changed`]). watch_timer: Option, - /// The machine last told what this client watches, and which session: - /// what a change is measured against. - watch_sent: Option<(String, Option)>, + /// The machine last told what this client watches, and what: what a + /// change is measured against. + watch_sent: BTreeMap, + /// The terminal input batching window, or the relay rate cap lifting + /// (see `client_core::stores::terminals`). + terminal_flush_timer: Option, /// The delete-controller's 4 s undo window. undo_timer: Option, /// The pending config backup save (see `runtime::backup`). @@ -1175,6 +1216,7 @@ impl Loop { abort(&mut self.watch_timer); self.settle_watch(); } + Msg::TerminalFlush => self.on_terminal_flush(), Msg::SocketOpen => { let at = self.clock.now_ms(); self.dispatch(ConnectionEvent::SocketOpen { at }); @@ -1338,7 +1380,7 @@ impl Loop { ConnectionEffect::RefreshAndReconcile => { // A fresh connection: the bridge may have restarted and // forgotten what this client watches, so say it again. - self.watch_sent = None; + self.watch_sent.clear(); self.watch_changed(); let _ = self.self_tx.send(Msg::RefreshReconcile { reconnected: true }); } @@ -1673,6 +1715,20 @@ impl Loop { if let Some((machine, session_id)) = r.transcript_appended { self.emit(CoreEvent::TranscriptAppended { machine, session_id }); } + // Raw bytes on their own event: a terminal's output never goes + // through a view, so a busy shell costs the UI nothing it is not + // drawing. + for frame in r.terminal_output { + self.emit(CoreEvent::TerminalOutput { + machine: frame.machine, + term: frame.term, + offset: frame.offset, + bytes: frame.data, + }); + } + if let Some(flush) = r.terminal_flush { + self.apply_terminal_flush(flush); + } // After the transcript event, so a completed row's entry is there to // take its place when the row goes. for (machine, session_id, change) in r.live_text { @@ -1847,6 +1903,7 @@ impl Loop { let ctx = IntentCtx { now: self.clock.now_ms(), visible: self.conn.visible, + terminal_transport: self.transport_for_intent(&intent), }; let result = apply_intent(&mut self.stores, intent, &self.keys, ctx); let session_file_send = result.session_file_send.clone(); @@ -1863,6 +1920,65 @@ impl Loop { reply } + /// Where `machine` is reached right now: its direct link when one is up, + /// else the relays. Terminal input is paced differently for each. + fn terminal_transport(&self, machine: &str) -> InputTransport { + if self.links_up.contains_key(machine) { + InputTransport::Direct + } else { + InputTransport::Relay + } + } + + /// The transport an intent's terminal input would ride — the machine the + /// intent names, for the two intents that carry typed bytes. Every other + /// intent ignores it. + fn transport_for_intent(&self, intent: &Intent) -> InputTransport { + match intent { + Intent::TerminalInput { machine, .. } | Intent::TerminalPaste { machine, .. } => { + self.terminal_transport(machine) + } + _ => InputTransport::Direct, + } + } + + /// Arm, re-arm or clear the terminal input flush timer from what a store + /// step asked for. + fn apply_terminal_flush(&mut self, flush: TerminalFlush) { + match flush { + TerminalFlush::Arm { at_ms } => { + let delay = at_ms.saturating_sub(self.clock.now_ms()); + abort(&mut self.terminal_flush_timer); + self.terminal_flush_timer = Some(self.arm(delay, Msg::TerminalFlush)); + } + TerminalFlush::Clear => abort(&mut self.terminal_flush_timer), + } + } + + /// The batching window closed: send what the terminal still holds. + fn on_terminal_flush(&mut self) { + self.terminal_flush_timer = None; + let transport = self + .stores + .terminals + .watching_machine() + .map(|m| self.terminal_transport(m)) + .unwrap_or(InputTransport::Direct); + let step = self.stores.terminals.flush_due(transport, self.clock.now_ms()); + let Some(machine) = step.machine.clone() else { return }; + for msg in step.sends { + self.on_send(machine.clone(), PhoneToBridge::TerminalInput(msg), None); + } + if let Some(at_ms) = step.flush_at_ms { + let delay = at_ms.saturating_sub(self.clock.now_ms()); + // A deadline the store says is already past, with nothing sent, + // would arm a timer that fires at once and never advances. + if delay > 0 { + self.terminal_flush_timer = Some(self.arm(delay, Msg::TerminalFlush)); + } + } + } + /// The ports an image send needs, cloned out of the loop so the send can /// run as its own task. fn image_send_ctx(&self, machine: &str) -> FileSendCtx { @@ -1941,6 +2057,9 @@ impl Loop { if r.ui_changed { self.state_changed(SliceId::Ui); } + if let Some(flush) = r.terminal_flush { + self.apply_terminal_flush(flush); + } if !r.transcript_removed.is_empty() { self.state_changed(SliceId::Transcript); } @@ -2214,73 +2333,82 @@ impl Loop { for machine in due { // The renewal also carries what this client watches now, so a // bridge that missed a change catches up within one period. - let watching = self.watching_for(&machine); - self.sign_and_publish(machine, PhoneToBridge::Presence(PresenceMsg { away: false, watching, watching_terminal: None }), None, None, true); + let watch = self.watch_for(&machine); + self.sign_and_publish(machine, PhoneToBridge::Presence(PresenceMsg { away: false, watching: watch.session, watching_terminal: watch.terminal }), None, None, true); } } - /// What this client shows live text for right now: the session on screen - /// while the app is in the foreground, nothing otherwise. - fn watch_now(&self) -> Option<(String, String)> { + /// What this client shows live text and terminal output for right now: + /// the session on screen and the terminal on screen, while the app is in + /// the foreground, nothing otherwise. A machine with neither is absent — + /// it is told nothing is watched there, if it was ever told otherwise. + fn watch_now(&self) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); if !self.conn.visible { - return None; + return out; } - match (&self.stores.ui.selected_machine, &self.stores.ui.selected_session) { - (Some(machine), Some(session)) => Some((machine.clone(), session.clone())), - _ => None, + if let (Some(machine), Some(session)) = + (&self.stores.ui.selected_machine, &self.stores.ui.selected_session) + { + out.entry(machine.clone()).or_default().session = Some(session.clone()); + } + if let Some(machine) = self.stores.terminals.watching_machine() { + let term = self.stores.terminals.attached().and_then(|a| a.term.clone()); + if let Some(term) = term { + out.entry(machine.to_string()).or_default().terminal = Some(term); + } } + out } - /// The `watching` a `presence` to `machine` carries: the session it is - /// showing, or `None` for any other machine. - fn watching_for(&self, machine: &str) -> Option { - match self.watch_now() { - Some((shown, session)) if shown == machine => Some(session), - _ => None, - } + /// What a `presence` to `machine` carries right now. + fn watch_for(&self, machine: &str) -> Watch { + self.watch_now().remove(machine).unwrap_or_default() } - /// What this client watches may have changed — another session on - /// screen, or the app hidden. Say it once the choice has settled. + /// What this client watches may have changed — another session or + /// terminal on screen, or the app hidden. Say it once the choice has + /// settled. fn watch_changed(&mut self) { if self.watch_timer.is_none() { self.watch_timer = Some(self.arm(WATCH_SETTLE_MS, Msg::WatchSettle)); } } - /// The settle window closed: tell each machine whose `watching` differs - /// from what it was last told. Leaving a machine for another also tells - /// the one left behind that nothing is watched there any more. + /// The settle window closed: tell each machine whose `watching` / + /// `watching_terminal` differs from what it was last told. Leaving a + /// machine for another also tells the one left behind that nothing is + /// watched there any more. fn settle_watch(&mut self) { - let desired = self.watch_now().map(|(machine, session)| (machine, Some(session))); - let previous = self.watch_sent.clone(); - if previous == desired { - return; - } - if let Some((machine, _)) = &previous { - if !desired.as_ref().is_some_and(|(next, _)| next == machine) { - self.send_watching(machine, None); - } - } + let desired = self.watch_now(); + let previous = std::mem::take(&mut self.watch_sent); // Only what a machine was told counts as sent: one that could not be // (no session key to use right now) is tried again at the next // settle, instead of being taken for told and never retried. - let told = match &desired { - Some((machine, session)) => self.send_watching(machine, session.clone()), - None => true, - }; - self.watch_sent = if told { desired } else { None }; + let mut told: BTreeMap = BTreeMap::new(); + for (machine, watch) in &desired { + if previous.get(machine) == Some(watch) || self.send_watching(machine, watch.clone()) { + told.insert(machine.clone(), watch.clone()); + } + } + for (machine, watch) in previous { + if desired.contains_key(&machine) || watch == Watch::default() { + continue; + } + self.send_watching(&machine, Watch::default()); + } + self.watch_sent = told; } /// One `presence` saying what this client watches, to one machine. Only /// a machine that knows this client by its session key is told: a copy /// addressed to the identity reaches no client, and so no live text. /// Returns whether it went out. - fn send_watching(&mut self, machine: &str, watching: Option) -> bool { + fn send_watching(&mut self, machine: &str, watch: Watch) -> bool { if !matches!(self.cipher_for(machine), Cipher::SessionKey(_)) { return false; } - self.sign_and_publish(machine.to_string(), PhoneToBridge::Presence(PresenceMsg { away: false, watching, watching_terminal: None }), None, None, true); + self.sign_and_publish(machine.to_string(), PhoneToBridge::Presence(PresenceMsg { away: false, watching: watch.session, watching_terminal: watch.terminal }), None, None, true); true } diff --git a/crates/client-runtime/src/runtime/backup.rs b/crates/client-runtime/src/runtime/backup.rs index 20d43413..2aa4db97 100644 --- a/crates/client-runtime/src/runtime/backup.rs +++ b/crates/client-runtime/src/runtime/backup.rs @@ -27,6 +27,7 @@ use std::time::Duration; use client_core::stores::backup::{backup_d_tag, backup_fingerprint, build_backup, merge_backup, ConfigBackup, BACKUP_KIND}; use client_core::stores::pairing::is_relay_url; +use client_core::stores::terminals::InputTransport; use nostr_transport::{Filter, SubCallbacks, Transport}; use tokio::sync::oneshot; @@ -321,7 +322,7 @@ impl Loop { // Removed on another device: removed here as a removal from this // device would be, with what is kept about the machine. for pubkey_hex in outcome.removed { - let ctx = IntentCtx { now, visible: self.conn.visible }; + let ctx = IntentCtx { now, visible: self.conn.visible, terminal_transport: InputTransport::Direct }; let result = apply_intent(&mut self.stores, Intent::RemoveMachine { pubkey_hex }, &self.keys, ctx); self.interpret_intent(result).await; } diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index 60222963..0437ebdf 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -2296,7 +2296,7 @@ async fn a_quiet_client_renews_its_presence_and_says_when_it_goes_away() { let start_ms: u64 = 1_000_000; let key = SessionKey { keypair: generate_keypair(), expires_at: start_ms / 1000 + 80 * 24 * 3600 }; let store = MemoryKeyStore::default(); - *store.0.borrow_mut() = Some(SessionKeyRing { current: key.clone(), previous: None }.encode()); + *store.0.borrow_mut() = Some(SessionKeyRing::single(key.clone()).encode()); let mut state = client_core::stores::machines::MachinesState::default(); state.register_machine(&machine.pubkey_hex, "bridge", None, None, &[]); state.machines.get_mut(&machine.pubkey_hex).unwrap().session_grant = Some(SessionGrant { @@ -2421,7 +2421,7 @@ async fn a_rotated_key_is_granted_and_the_old_one_dropped_once_confirmed() { let now_secs = 1_000; let old = SessionKey { keypair: generate_keypair(), expires_at: now_secs + 10 * 24 * 3600 }; let store = MemoryKeyStore::default(); - *store.0.borrow_mut() = Some(SessionKeyRing { current: old.clone(), previous: None }.encode()); + *store.0.borrow_mut() = Some(SessionKeyRing::single(old.clone()).encode()); let mut state = client_core::stores::machines::MachinesState::default(); state.register_machine(&machine.pubkey_hex, "bridge", None, None, &[]); state.machines.get_mut(&machine.pubkey_hex).unwrap().session_grant = Some(SessionGrant { @@ -2978,7 +2978,7 @@ async fn a_relay_address_the_phone_may_not_dial_is_refused() { /// A started core that `machine` knows by the session key `key`. async fn core_watched_by(mock: &MockRelay, phone: &Keypair, key: &SessionKey, machine: &Keypair) -> Core { let store = MemoryKeyStore::default(); - *store.0.borrow_mut() = Some(SessionKeyRing { current: key.clone(), previous: None }.encode()); + *store.0.borrow_mut() = Some(SessionKeyRing::single(key.clone()).encode()); let mut state = client_core::stores::machines::MachinesState::default(); state.register_machine(&machine.pubkey_hex, "bridge", None, None, &[]); state.machines.get_mut(&machine.pubkey_hex).unwrap().session_grant = Some(SessionGrant { @@ -3117,3 +3117,210 @@ async fn switching_sessions_quickly_sends_one_presence() { }) .await; } + +/// Every command this client sends `machine` within `wait`. +async fn commands_within( + mock: &mut MockRelay, + payload_key: &str, + machine: &Keypair, + wait: Duration, +) -> Vec { + let deadline = std::time::Instant::now() + wait; + let mut sent = Vec::new(); + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + break; + } + let Some(frame) = mock.frame_within(left).await else { break }; + let Ok(v) = serde_json::from_str::>(&frame) else { continue }; + if v[0] != "EVENT" { + continue; + } + let Ok(ev) = serde_json::from_value::(v[1].clone()) else { continue }; + let Ok(plain) = protocol::crypto::decrypt_from(&machine.secret_key, payload_key, &ev.content) else { continue }; + if let Ok(msg) = protocol::codec::decode_phone_to_bridge(&plain) { + sent.push(msg); + } + } + sent +} + +/// Every `terminal-input` this client sends `machine` within `wait`. +async fn terminal_inputs_within( + mock: &mut MockRelay, + payload_key: &str, + machine: &Keypair, + wait: Duration, +) -> Vec { + commands_within(mock, payload_key, machine, wait) + .await + .into_iter() + .filter_map(|m| match m { + PhoneToBridge::TerminalInput(m) => Some(m), + _ => None, + }) + .collect() +} + +/// The terminal on screen is what the bridge is told to send output for: +/// attaching names it in the presence, and detaching stops it. Nothing is +/// watched otherwise, so a terminal costs a backgrounded phone nothing. +#[tokio::test] +async fn the_terminal_on_screen_is_what_the_bridge_is_told() { + LocalSet::new() + .run_until(async { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let key = SessionKey { keypair: generate_keypair(), expires_at: 1_000 + 80 * 24 * 3600 }; + let core = core_watched_by(&mock, &phone, &key, &machine).await; + eose_all(&mut mock).await; + let _ = next_command_via(&mut mock, &phone, key.pubkey_hex(), &machine).await; + + core.dispatch(Intent::AttachTerminal { machine: machine.pubkey_hex.clone(), term: "t1".into() }) + .await; + let sent = presences_within(&mut mock, key.pubkey_hex(), &machine, Duration::from_millis(700)).await; + assert!( + sent.iter().any(|p| p.watching_terminal.as_deref() == Some("t1")), + "the bridge must be told which terminal is on screen: {sent:?}" + ); + + core.dispatch(Intent::DetachTerminal { machine: machine.pubkey_hex.clone() }).await; + let sent = presences_within(&mut mock, key.pubkey_hex(), &machine, Duration::from_millis(700)).await; + assert!( + sent.iter().any(|p| p.watching_terminal.is_none() && p.watching.is_none()), + "detaching must stop the output: {sent:?}" + ); + }) + .await; +} + +/// Typing is paced by the core, not sent per keystroke: printable bytes wait +/// and go with the key a program acts on, as one sequenced command. +#[tokio::test] +async fn typing_into_a_terminal_goes_out_as_one_batched_command() { + LocalSet::new() + .run_until(async { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let key = SessionKey { keypair: generate_keypair(), expires_at: 1_000 + 80 * 24 * 3600 }; + let core = core_watched_by(&mock, &phone, &key, &machine).await; + eose_all(&mut mock).await; + let _ = next_command_via(&mut mock, &phone, key.pubkey_hex(), &machine).await; + + core.dispatch(Intent::AttachTerminal { machine: machine.pubkey_hex.clone(), term: "t1".into() }) + .await; + core.dispatch(Intent::TerminalInput { + machine: machine.pubkey_hex.clone(), + term: "t1".into(), + bytes: b"git che".to_vec(), + alternate_screen: false, + }) + .await; + // Nothing yet: the bytes are waiting for a key a program acts on. + assert!(terminal_inputs_within(&mut mock, key.pubkey_hex(), &machine, Duration::from_millis(120)) + .await + .is_empty()); + + core.dispatch(Intent::TerminalInput { + machine: machine.pubkey_hex.clone(), + term: "t1".into(), + bytes: b"\t".to_vec(), + alternate_screen: false, + }) + .await; + let sent = terminal_inputs_within(&mut mock, key.pubkey_hex(), &machine, Duration::from_millis(700)).await; + assert_eq!(sent.len(), 1, "one command for the whole line: {sent:?}"); + assert_eq!(sent[0].seq, 1); + assert_eq!(sent[0].data, "Z2l0IGNoZQk=", "git che plus the Tab"); + }) + .await; +} + +/// Terminal output never goes through a view: it arrives as its own event, +/// already base64-decoded, with the offset it starts at. +#[tokio::test] +async fn terminal_output_reaches_the_host_on_its_own_event() { + LocalSet::new() + .run_until(async { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let spy = Rc::new(Spy::default()); + let core = core_for(&mock, &phone, Rc::clone(&spy)).await; + core.set_machines(vec![machine.pubkey_hex.clone()]); + core.start(); + eose_all(&mut mock).await; + + core.dispatch(Intent::AttachTerminal { machine: machine.pubkey_hex.clone(), term: "t1".into() }) + .await; + settle().await; + + // "hi" in base64, the shell's first output at offset 0. + push_bridge_to_phone_event_at( + &mock, + &machine, + &phone.pubkey_hex, + "cd-1", + &BridgeToPhone::TerminalOutput(protocol::events::TerminalOutputMsg { + term: "t1".into(), + offset: 0, + data: "aGk=".into(), + }), + LIVE_KIND, + None, + ); + settle().await; + + { + let events = spy.events.lock().unwrap(); + assert!( + events.iter().any(|e| matches!( + e, + CoreEvent::TerminalOutput { term, offset: 0, bytes, .. } + if term == "t1" && bytes == b"hi" + )), + "the frame must arrive as its own event: {events:?}" + ); + } + let view = core.ui_view().await; + let attached = view.terminals.attached.expect("a terminal on screen"); + assert_eq!(attached.expected_offset, 2, "the screen advanced past what it drew"); + }) + .await; +} + +/// A bridge that does not advertise terminals never opens one: the command +/// would wait on an answer that never comes. +#[tokio::test] +async fn opening_a_terminal_needs_the_bridge_to_advertise_it() { + LocalSet::new() + .run_until(async { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let key = SessionKey { keypair: generate_keypair(), expires_at: 1_000 + 80 * 24 * 3600 }; + // `core_watched_by` registers a machine with no capabilities. + let core = core_watched_by(&mock, &phone, &key, &machine).await; + eose_all(&mut mock).await; + let _ = next_command_via(&mut mock, &phone, key.pubkey_hex(), &machine).await; + + core.dispatch(Intent::OpenTerminal { + machine: machine.pubkey_hex.clone(), + session: None, + cols: 80, + rows: 24, + }) + .await; + + assert!(core.ui_view().await.terminals.attached.is_none(), "nothing on screen"); + let sent = commands_within(&mut mock, key.pubkey_hex(), &machine, Duration::from_millis(400)).await; + assert!( + !sent.iter().any(|m| matches!(m, PhoneToBridge::TerminalOpen(_))), + "no open went out: {sent:?}" + ); + }) + .await; +} diff --git a/crates/client-runtime/src/stores.rs b/crates/client-runtime/src/stores.rs index 79520b97..2ea431df 100644 --- a/crates/client-runtime/src/stores.rs +++ b/crates/client-runtime/src/stores.rs @@ -24,6 +24,7 @@ use protocol::commands::{AgentStyleRequestMsg, PhoneToBridge}; use client_core::stores::agent_styles::{hydrate_agent_styles, serialize_agent_styles, AgentStyles}; use client_core::stores::backup::{hydrate_backup_config, serialize_backup_config, BackupConfig, BACKUP_STORAGE_KEY}; use client_core::stores::settings::{hydrate_settings, serialize_settings, SettingsState}; +use client_core::stores::terminals::TerminalsState; use crate::backup::BackupStatus; use client_core::stores::transcript::TranscriptState; @@ -83,6 +84,11 @@ pub struct CoreStores { pub delete_controller: DeleteController, /// Replies still being written: never persisted. pub live_text: LiveTextState, + /// The terminals each machine lists and the one on screen. Never + /// persisted: the list is the bridge's heartbeat and the attached + /// terminal is this phone's own screen, both meaningless after a restart + /// (a terminal that outlived it is attached again from the heartbeat). + pub terminals: TerminalsState, } /// What [`CoreStores::forget_session`] dropped: anything of the UI's (a dot, @@ -269,6 +275,7 @@ pub async fn hydrate( notifications: NotificationCoordinator::default(), delete_controller: DeleteController::default(), live_text: LiveTextState::default(), + terminals: TerminalsState::default(), }, last_stored_seen, } diff --git a/crates/client-runtime/src/view.rs b/crates/client-runtime/src/view.rs index d7793ebf..01dfbd51 100644 --- a/crates/client-runtime/src/view.rs +++ b/crates/client-runtime/src/view.rs @@ -23,7 +23,8 @@ use client_core::stores::quick_prompts::QuickPrompt; use client_core::stores::settings::SettingsData; use client_core::stores::transcript::{SyncState, TranscriptState}; use client_core::stores::ui::{CredentialsAck, ProviderProfileAck, UndoToast}; -use protocol::common::AgentStyle; +use protocol::common::{AgentStyle, TerminalEndReason}; +use protocol::events::TerminalInfo; use serde::Serialize; use crate::ports::TranscriptStore; @@ -202,6 +203,82 @@ pub struct UiView { pub credentials_status: BTreeMap, pub provider_profile_status: BTreeMap, pub undo_toast: Option, + /// The terminals, low-frequency state only: the frames themselves go to + /// the host on `CoreEvent::TerminalOutput`, never through a view. + pub terminals: TerminalsView, +} + +/// The terminals each machine lists, and the one this phone has on screen. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalsView { + /// machine → the terminals its heartbeat lists, oldest opened first. + pub by_machine: BTreeMap>, + pub attached: Option, +} + +/// The terminal on screen: what the bridge calls it, whether this device is +/// the writer, and how the last open or the end went. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct AttachedTerminalView { + pub machine: String, + /// The bridge's id, absent while the open is still pending. + pub term: Option, + /// This phone's own id for a pending open, echoed by `terminal-opened`. + pub open_id: Option, + pub shell: Option, + pub writer: bool, + pub cols: u16, + pub rows: u16, + #[specta(type = specta_typescript::Number)] + pub expected_offset: u64, + /// Bytes typed and not yet sent — the app draws them dimmed at the cursor + /// as the predicted echo until the real output arrives. + pub waiting_input: Vec, + pub refusal: Option, + pub ended: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalRefusalView { + pub reason: TerminalEndReason, + /// The device the open was refused for; its name when one was ever seen. + pub device: Option, + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalEndedView { + pub reason: TerminalEndReason, + pub code: Option, +} + +impl TerminalsView { + pub fn from_stores(s: &CoreStores) -> Self { + Self { + by_machine: s.terminals.lists(), + attached: s.terminals.attached().map(|a| AttachedTerminalView { + machine: a.machine.clone(), + term: a.term.clone(), + open_id: a.open_id.clone(), + shell: a.shell.clone(), + writer: a.writer, + cols: a.cols, + rows: a.rows, + expected_offset: a.expected, + waiting_input: s.terminals.waiting_input(), + refusal: a.refusal.as_ref().map(|r| TerminalRefusalView { + reason: r.reason, + device: r.device.clone(), + name: r.name.clone(), + }), + ended: a.ended.as_ref().map(|e| TerminalEndedView { reason: e.reason, code: e.code }), + }), + } + } } impl UiView { @@ -216,6 +293,7 @@ impl UiView { credentials_status: ui.credentials_status.clone(), provider_profile_status: ui.provider_profile_status.clone(), undo_toast: ui.undo_toast.clone(), + terminals: TerminalsView::from_stores(s), } } } From c509965f001348f76a2fb4c04ced7dfd3ae55a44 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:14:20 +0200 Subject: [PATCH 27/50] feat(client-ffi): the terminal intents, view and output event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight terminal actions cross as `UniffiIntent` variants — `OpenTerminal`, `AttachTerminal`, `DetachTerminal`, `TerminalInput`, `TerminalPaste`, `ResizeTerminal`, `TakeOverTerminal`, `CloseTerminal` — mapped field for field, bytes included. `UniffiUiView` gains `terminals`: every machine's list as `UniffiTerminalInfo`, and the attached terminal as `UniffiAttachedTerminal` — its term and pending `openId`, whether this device is the writer, its size, the next output offset expected, the bytes typed and not yet sent (the predicted echo), and the refusal or the end with its reason. Output frames do not come through here: they arrive on `CoreListener.on_event(CoreEvent.TerminalOutput)`, already base64-decoded, with the offset they start at. The Kotlin bindings under `apps/android/app/src/main/java/uniffi/` are regenerated from this surface. Co-Authored-By: Claude Code --- .../main/java/uniffi/client_ffi/client_ffi.kt | 853 +++++++++++++++++- .../uniffi/client_runtime/client_runtime.kt | 93 +- crates/client-ffi/src/intent.rs | 150 +++ crates/client-ffi/src/lib.rs | 7 +- crates/client-ffi/src/views.rs | 120 +++ 5 files changed, 1212 insertions(+), 11 deletions(-) diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index c352b07e..6cc562d1 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -1614,6 +1614,29 @@ public object FfiConverterUInt: FfiConverter { } } +/** + * @suppress + */ +public object FfiConverterInt: FfiConverter { + override fun lift(value: Int): Int { + return value + } + + override fun read(buf: ByteBuffer): Int { + return buf.getInt() + } + + override fun lower(value: Int): Int { + return value + } + + override fun allocationSize(value: Int) = 4UL + + override fun write(value: Int, buf: ByteBuffer) { + buf.putInt(value) + } +} + /** * @suppress */ @@ -5404,6 +5427,116 @@ public object FfiConverterTypeUniffiAgentStyle: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiAttachedTerminal { + return UniffiAttachedTerminal( + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterUShort.read(buf), + FfiConverterUShort.read(buf), + FfiConverterULong.read(buf), + FfiConverterByteArray.read(buf), + FfiConverterOptionalTypeUniffiTerminalRefusal.read(buf), + FfiConverterOptionalTypeUniffiTerminalEnded.read(buf), + ) + } + + override fun allocationSize(value: UniffiAttachedTerminal) = ( + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterOptionalString.allocationSize(value.`term`) + + FfiConverterOptionalString.allocationSize(value.`openId`) + + FfiConverterOptionalString.allocationSize(value.`shell`) + + FfiConverterBoolean.allocationSize(value.`writer`) + + FfiConverterUShort.allocationSize(value.`cols`) + + FfiConverterUShort.allocationSize(value.`rows`) + + FfiConverterULong.allocationSize(value.`expectedOffset`) + + FfiConverterByteArray.allocationSize(value.`waitingInput`) + + FfiConverterOptionalTypeUniffiTerminalRefusal.allocationSize(value.`refusal`) + + FfiConverterOptionalTypeUniffiTerminalEnded.allocationSize(value.`ended`) + ) + + override fun write(value: UniffiAttachedTerminal, buf: ByteBuffer) { + FfiConverterString.write(value.`machine`, buf) + FfiConverterOptionalString.write(value.`term`, buf) + FfiConverterOptionalString.write(value.`openId`, buf) + FfiConverterOptionalString.write(value.`shell`, buf) + FfiConverterBoolean.write(value.`writer`, buf) + FfiConverterUShort.write(value.`cols`, buf) + FfiConverterUShort.write(value.`rows`, buf) + FfiConverterULong.write(value.`expectedOffset`, buf) + FfiConverterByteArray.write(value.`waitingInput`, buf) + FfiConverterOptionalTypeUniffiTerminalRefusal.write(value.`refusal`, buf) + FfiConverterOptionalTypeUniffiTerminalEnded.write(value.`ended`, buf) + } +} + + + /** * A plugin a marketplace offers that is not installed. */ @@ -8259,6 +8392,233 @@ public object FfiConverterTypeUniffiSlashCommand: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalEnded { + return UniffiTerminalEnded( + FfiConverterString.read(buf), + FfiConverterOptionalInt.read(buf), + ) + } + + override fun allocationSize(value: UniffiTerminalEnded) = ( + FfiConverterString.allocationSize(value.`reason`) + + FfiConverterOptionalInt.allocationSize(value.`code`) + ) + + override fun write(value: UniffiTerminalEnded, buf: ByteBuffer) { + FfiConverterString.write(value.`reason`, buf) + FfiConverterOptionalInt.write(value.`code`, buf) + } +} + + + +/** + * One terminal a machine lists — mirrors `protocol::events::TerminalInfo`. + */ +data class UniffiTerminalInfo ( + /** + * The bridge's id, which every terminal message names. + */ + var `term`: kotlin.String + , + /** + * What runs in it (`/bin/bash`). + */ + var `shell`: kotlin.String + , + var `opened`: kotlin.ULong + , + /** + * The device that opened it (an opaque id; `writer` names the one whose + * input it takes now). + */ + var `device`: kotlin.String + , + /** + * That device's name, when its user gave one. + */ + var `name`: kotlin.String? + , + var `writer`: kotlin.String + , + var `cols`: kotlin.UShort + , + var `rows`: kotlin.UShort + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeUniffiTerminalInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalInfo { + return UniffiTerminalInfo( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterULong.read(buf), + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterString.read(buf), + FfiConverterUShort.read(buf), + FfiConverterUShort.read(buf), + ) + } + + override fun allocationSize(value: UniffiTerminalInfo) = ( + FfiConverterString.allocationSize(value.`term`) + + FfiConverterString.allocationSize(value.`shell`) + + FfiConverterULong.allocationSize(value.`opened`) + + FfiConverterString.allocationSize(value.`device`) + + FfiConverterOptionalString.allocationSize(value.`name`) + + FfiConverterString.allocationSize(value.`writer`) + + FfiConverterUShort.allocationSize(value.`cols`) + + FfiConverterUShort.allocationSize(value.`rows`) + ) + + override fun write(value: UniffiTerminalInfo, buf: ByteBuffer) { + FfiConverterString.write(value.`term`, buf) + FfiConverterString.write(value.`shell`, buf) + FfiConverterULong.write(value.`opened`, buf) + FfiConverterString.write(value.`device`, buf) + FfiConverterOptionalString.write(value.`name`, buf) + FfiConverterString.write(value.`writer`, buf) + FfiConverterUShort.write(value.`cols`, buf) + FfiConverterUShort.write(value.`rows`, buf) + } +} + + + +data class UniffiTerminalRefusal ( + /** + * A `terminal-exited` reason (`not-allowed`, `limit`, `disabled`, …); + * an unknown one reads as `unknown`. + */ + var `reason`: kotlin.String + , + /** + * The device the open was refused for, for `not-allowed`. + */ + var `device`: kotlin.String? + , + /** + * That device's name, when one was ever seen for it. + */ + var `name`: kotlin.String? + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeUniffiTerminalRefusal: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalRefusal { + return UniffiTerminalRefusal( + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + ) + } + + override fun allocationSize(value: UniffiTerminalRefusal) = ( + FfiConverterString.allocationSize(value.`reason`) + + FfiConverterOptionalString.allocationSize(value.`device`) + + FfiConverterOptionalString.allocationSize(value.`name`) + ) + + override fun write(value: UniffiTerminalRefusal, buf: ByteBuffer) { + FfiConverterString.write(value.`reason`, buf) + FfiConverterOptionalString.write(value.`device`, buf) + FfiConverterOptionalString.write(value.`name`, buf) + } +} + + + +/** + * The terminals each machine lists, and the one this phone has on screen. + */ +data class UniffiTerminalsView ( + /** + * machine pubkey -> the terminals its heartbeat lists, oldest opened + * first. Each carries the bridge's `term`, its shell, the device that + * opened it and the device whose input it takes now. + */ + var `byMachine`: Map> + , + var `attached`: UniffiAttachedTerminal? + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeUniffiTerminalsView: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalsView { + return UniffiTerminalsView( + FfiConverterMapStringSequenceTypeUniffiTerminalInfo.read(buf), + FfiConverterOptionalTypeUniffiAttachedTerminal.read(buf), + ) + } + + override fun allocationSize(value: UniffiTerminalsView) = ( + FfiConverterMapStringSequenceTypeUniffiTerminalInfo.allocationSize(value.`byMachine`) + + FfiConverterOptionalTypeUniffiAttachedTerminal.allocationSize(value.`attached`) + ) + + override fun write(value: UniffiTerminalsView, buf: ByteBuffer) { + FfiConverterMapStringSequenceTypeUniffiTerminalInfo.write(value.`byMachine`, buf) + FfiConverterOptionalTypeUniffiAttachedTerminal.write(value.`attached`, buf) + } +} + + + /** * A session's grouped transcript as a change against the one the caller * already has. Streaming appends a row (or grows the last one) many times a @@ -8390,6 +8750,12 @@ data class UniffiUiView ( * Present while a delete's undo window is open. */ var `undoToast`: UniffiUndoToast? + , + /** + * The terminals, low-frequency state only — output frames arrive on + * `CoreListener.on_event(CoreEvent.TerminalOutput)`, never here. + */ + var `terminals`: UniffiTerminalsView ){ @@ -8414,6 +8780,7 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringTypeUniffiCredentialsAck.read(buf), FfiConverterMapStringTypeUniffiProviderProfileAck.read(buf), FfiConverterOptionalTypeUniffiUndoToast.read(buf), + FfiConverterTypeUniffiTerminalsView.read(buf), ) } @@ -8425,7 +8792,8 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringString.allocationSize(value.`planApprovalChoices`) + FfiConverterMapStringTypeUniffiCredentialsAck.allocationSize(value.`credentialsStatus`) + FfiConverterMapStringTypeUniffiProviderProfileAck.allocationSize(value.`providerProfileStatus`) + - FfiConverterOptionalTypeUniffiUndoToast.allocationSize(value.`undoToast`) + FfiConverterOptionalTypeUniffiUndoToast.allocationSize(value.`undoToast`) + + FfiConverterTypeUniffiTerminalsView.allocationSize(value.`terminals`) ) override fun write(value: UniffiUiView, buf: ByteBuffer) { @@ -8437,6 +8805,7 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringTypeUniffiCredentialsAck.write(value.`credentialsStatus`, buf) FfiConverterMapStringTypeUniffiProviderProfileAck.write(value.`providerProfileStatus`, buf) FfiConverterOptionalTypeUniffiUndoToast.write(value.`undoToast`, buf) + FfiConverterTypeUniffiTerminalsView.write(value.`terminals`, buf) } } @@ -9906,15 +10275,134 @@ sealed class UniffiIntent { companion object } + /** + * Open a real shell on `machine` — one whose heartbeat advertises the + * `terminal` capability. `session` starts it in that session's + * directory; the phone never names a path. The answer lands in + * `UniffiUiView.terminals.attached` (`terminal-opened`, or a refusal). + */ + data class OpenTerminal( + val `machine`: kotlin.String, + val `session`: kotlin.String?, + val `cols`: kotlin.UShort, + val `rows`: kotlin.UShort) : UniffiIntent() + + { + + companion object + } + /** + * Put a terminal the machine's list names on screen, read-only, + * replaying what the bridge still holds. Output arrives on + * `CoreListener.on_event(CoreEvent.TerminalOutput)`. + */ + data class AttachTerminal( + val `machine`: kotlin.String, + val `term`: kotlin.String) : UniffiIntent() + + { + + companion object + } - + /** + * Leave the terminal on screen: the bridge stops sending its output. The + * shell keeps running. + */ + data class DetachTerminal( + val `machine`: kotlin.String) : UniffiIntent() + + { + + companion object + } + + /** + * Type into the terminal on screen. `alternate_screen` is what the + * emulator reports: on the normal screen printable bytes wait for a key + * a program acts on (or a pause), on the alternate screen they go after + * a short window. Pacing, sequencing and retries are the core's. + */ + data class TerminalInput( + val `machine`: kotlin.String, + val `term`: kotlin.String, + val `bytes`: kotlin.ByteArray, + val `alternateScreen`: kotlin.Boolean) : UniffiIntent() + + { + - companion object -} + companion object + } + + /** + * Paste: the bytes go as one event. + */ + data class TerminalPaste( + val `machine`: kotlin.String, + val `term`: kotlin.String, + val `bytes`: kotlin.ByteArray) : UniffiIntent() + + { + + + companion object + } + + /** + * The emulator's window changed; only the writer's size counts. + */ + data class ResizeTerminal( + val `machine`: kotlin.String, + val `term`: kotlin.String, + val `cols`: kotlin.UShort, + val `rows`: kotlin.UShort) : UniffiIntent() + + { + + + companion object + } + + /** + * Become the terminal's writer, so its input is this device's. + */ + data class TakeOverTerminal( + val `machine`: kotlin.String, + val `term`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + /** + * Hang up the shell. + */ + data class CloseTerminal( + val `machine`: kotlin.String, + val `term`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + + + + + + + + companion object +} /** * @suppress @@ -10184,6 +10672,44 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.DisableBackup( FfiConverterBoolean.read(buf), ) + 61 -> UniffiIntent.OpenTerminal( + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterUShort.read(buf), + FfiConverterUShort.read(buf), + ) + 62 -> UniffiIntent.AttachTerminal( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + 63 -> UniffiIntent.DetachTerminal( + FfiConverterString.read(buf), + ) + 64 -> UniffiIntent.TerminalInput( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterByteArray.read(buf), + FfiConverterBoolean.read(buf), + ) + 65 -> UniffiIntent.TerminalPaste( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterByteArray.read(buf), + ) + 66 -> UniffiIntent.ResizeTerminal( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterUShort.read(buf), + FfiConverterUShort.read(buf), + ) + 67 -> UniffiIntent.TakeOverTerminal( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + 68 -> UniffiIntent.CloseTerminal( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -10695,6 +11221,76 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterOptionalString.allocationSize(value.`session`) + + FfiConverterUShort.allocationSize(value.`cols`) + + FfiConverterUShort.allocationSize(value.`rows`) + ) + } + is UniffiIntent.AttachTerminal -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + ) + } + is UniffiIntent.DetachTerminal -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + ) + } + is UniffiIntent.TerminalInput -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + + FfiConverterByteArray.allocationSize(value.`bytes`) + + FfiConverterBoolean.allocationSize(value.`alternateScreen`) + ) + } + is UniffiIntent.TerminalPaste -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + + FfiConverterByteArray.allocationSize(value.`bytes`) + ) + } + is UniffiIntent.ResizeTerminal -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + + FfiConverterUShort.allocationSize(value.`cols`) + + FfiConverterUShort.allocationSize(value.`rows`) + ) + } + is UniffiIntent.TakeOverTerminal -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + ) + } + is UniffiIntent.CloseTerminal -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + ) + } } override fun write(value: UniffiIntent, buf: ByteBuffer) { @@ -11085,6 +11681,60 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + buf.putInt(61) + FfiConverterString.write(value.`machine`, buf) + FfiConverterOptionalString.write(value.`session`, buf) + FfiConverterUShort.write(value.`cols`, buf) + FfiConverterUShort.write(value.`rows`, buf) + Unit + } + is UniffiIntent.AttachTerminal -> { + buf.putInt(62) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + Unit + } + is UniffiIntent.DetachTerminal -> { + buf.putInt(63) + FfiConverterString.write(value.`machine`, buf) + Unit + } + is UniffiIntent.TerminalInput -> { + buf.putInt(64) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + FfiConverterByteArray.write(value.`bytes`, buf) + FfiConverterBoolean.write(value.`alternateScreen`, buf) + Unit + } + is UniffiIntent.TerminalPaste -> { + buf.putInt(65) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + FfiConverterByteArray.write(value.`bytes`, buf) + Unit + } + is UniffiIntent.ResizeTerminal -> { + buf.putInt(66) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + FfiConverterUShort.write(value.`cols`, buf) + FfiConverterUShort.write(value.`rows`, buf) + Unit + } + is UniffiIntent.TakeOverTerminal -> { + buf.putInt(67) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + Unit + } + is UniffiIntent.CloseTerminal -> { + buf.putInt(68) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -11448,6 +12098,38 @@ public object FfiConverterOptionalUInt: FfiConverterRustBuffer { +/** + * @suppress + */ +public object FfiConverterOptionalInt: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.Int? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterInt.read(buf) + } + + override fun allocationSize(value: kotlin.Int?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterInt.allocationSize(value) + } + } + + override fun write(value: kotlin.Int?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterInt.write(value, buf) + } + } +} + + + + /** * @suppress */ @@ -11672,6 +12354,38 @@ public object FfiConverterOptionalTypeUniffiAgentStyle: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiAttachedTerminal? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeUniffiAttachedTerminal.read(buf) + } + + override fun allocationSize(value: UniffiAttachedTerminal?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeUniffiAttachedTerminal.allocationSize(value) + } + } + + override fun write(value: UniffiAttachedTerminal?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeUniffiAttachedTerminal.write(value, buf) + } + } +} + + + + /** * @suppress */ @@ -12056,6 +12770,70 @@ public object FfiConverterOptionalTypeUniffiSignIn: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalEnded? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeUniffiTerminalEnded.read(buf) + } + + override fun allocationSize(value: UniffiTerminalEnded?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeUniffiTerminalEnded.allocationSize(value) + } + } + + override fun write(value: UniffiTerminalEnded?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeUniffiTerminalEnded.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalTypeUniffiTerminalRefusal: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiTerminalRefusal? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeUniffiTerminalRefusal.read(buf) + } + + override fun allocationSize(value: UniffiTerminalRefusal?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeUniffiTerminalRefusal.allocationSize(value) + } + } + + override fun write(value: UniffiTerminalRefusal?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeUniffiTerminalRefusal.write(value, buf) + } + } +} + + + + /** * @suppress */ @@ -13424,6 +14202,34 @@ public object FfiConverterSequenceTypeUniffiSlashCommand: FfiConverterRustBuffer +/** + * @suppress + */ +public object FfiConverterSequenceTypeUniffiTerminalInfo: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeUniffiTerminalInfo.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeUniffiTerminalInfo.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeUniffiTerminalInfo.write(it, buf) + } + } +} + + + + /** * @suppress */ @@ -13647,6 +14453,45 @@ public object FfiConverterMapStringSequenceString: FfiConverterRustBuffer>> { + override fun read(buf: ByteBuffer): Map> { + val len = buf.getInt() + return buildMap>(len) { + repeat(len) { + val k = FfiConverterString.read(buf) + val v = FfiConverterSequenceTypeUniffiTerminalInfo.read(buf) + this[k] = v + } + } + } + + override fun allocationSize(value: Map>): ULong { + val spaceForMapSize = 4UL + val spaceForChildren = value.map { (k, v) -> + FfiConverterString.allocationSize(k) + + FfiConverterSequenceTypeUniffiTerminalInfo.allocationSize(v) + }.sum() + return spaceForMapSize + spaceForChildren + } + + override fun write(value: Map>, buf: ByteBuffer) { + buf.putInt(value.size) + // The parens on `(k, v)` here ensure we're calling the right method, + // which is important for compatibility with older android devices. + // Ref https://blog.danlew.net/2017/03/16/kotlin-puzzler-whose-line-is-it-anyways/ + value.forEach { (k, v) -> + FfiConverterString.write(k, buf) + FfiConverterSequenceTypeUniffiTerminalInfo.write(v, buf) + } + } +} + + + + diff --git a/apps/android/app/src/main/java/uniffi/client_runtime/client_runtime.kt b/apps/android/app/src/main/java/uniffi/client_runtime/client_runtime.kt index c1cc7cc1..b3cb8baf 100644 --- a/apps/android/app/src/main/java/uniffi/client_runtime/client_runtime.kt +++ b/apps/android/app/src/main/java/uniffi/client_runtime/client_runtime.kt @@ -901,6 +901,29 @@ object UniffiWithHandle * */ object NoHandle +/** + * @suppress + */ +public object FfiConverterULong: FfiConverter { + override fun lift(value: Long): ULong { + return value.toULong() + } + + override fun read(buf: ByteBuffer): ULong { + return lift(buf.getLong()) + } + + override fun lower(value: ULong): Long { + return value.toLong() + } + + override fun allocationSize(value: ULong) = 8UL + + override fun write(value: ULong, buf: ByteBuffer) { + buf.putLong(value.toLong()) + } +} + /** * @suppress */ @@ -981,6 +1004,25 @@ public object FfiConverterString: FfiConverter { } } +/** + * @suppress + */ +public object FfiConverterByteArray: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ByteArray { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr + } + override fun allocationSize(value: ByteArray): ULong { + return 4UL + value.size.toULong() + } + override fun write(value: ByteArray, buf: ByteBuffer) { + buf.putInt(value.size) + buf.put(value) + } +} + data class ConnectionView ( @@ -1180,6 +1222,25 @@ sealed class CoreEvent { companion object } + /** + * Output a terminal on screen produced: `bytes` (already base64-decoded) + * start at `offset`, a byte offset into everything that terminal has + * written. On its own event, never a view: a shell can produce megabytes + * a second, and a view snapshot per frame would slow every other slice + * down. The consumer draws the bytes and advances its own cursor. + */ + data class TerminalOutput( + val `machine`: kotlin.String, + val `term`: kotlin.String, + val `offset`: kotlin.ULong, + val `bytes`: kotlin.ByteArray) : CoreEvent() + + { + + + companion object + } + /** * The correlated response to `Intent::CreateFolder` — matched by * `request_id` off this stream (no store, no view: nothing to persist or @@ -1245,13 +1306,19 @@ public object FfiConverterTypeCoreEvent : FfiConverterRustBuffer{ FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), ) - 7 -> CoreEvent.FolderAck( + 7 -> CoreEvent.TerminalOutput( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterULong.read(buf), + FfiConverterByteArray.read(buf), + ) + 8 -> CoreEvent.FolderAck( FfiConverterString.read(buf), FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterOptionalString.read(buf), ) - 8 -> CoreEvent.Ping + 9 -> CoreEvent.Ping else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -1304,6 +1371,16 @@ public object FfiConverterTypeCoreEvent : FfiConverterRustBuffer{ + FfiConverterOptionalString.allocationSize(value.`text`) ) } + is CoreEvent.TerminalOutput -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`term`) + + FfiConverterULong.allocationSize(value.`offset`) + + FfiConverterByteArray.allocationSize(value.`bytes`) + ) + } is CoreEvent.FolderAck -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -1359,8 +1436,16 @@ public object FfiConverterTypeCoreEvent : FfiConverterRustBuffer{ FfiConverterOptionalString.write(value.`text`, buf) Unit } - is CoreEvent.FolderAck -> { + is CoreEvent.TerminalOutput -> { buf.putInt(7) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`term`, buf) + FfiConverterULong.write(value.`offset`, buf) + FfiConverterByteArray.write(value.`bytes`, buf) + Unit + } + is CoreEvent.FolderAck -> { + buf.putInt(8) FfiConverterString.write(value.`requestId`, buf) FfiConverterBoolean.write(value.`success`, buf) FfiConverterOptionalString.write(value.`path`, buf) @@ -1368,7 +1453,7 @@ public object FfiConverterTypeCoreEvent : FfiConverterRustBuffer{ Unit } is CoreEvent.Ping -> { - buf.putInt(8) + buf.putInt(9) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 4e6f198e..3e55362a 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -502,6 +502,63 @@ pub enum UniffiIntent { DisableBackup { delete: bool, }, + + // --- terminals (see `docs/TERMINAL.md`) --- + /// Open a real shell on `machine` — one whose heartbeat advertises the + /// `terminal` capability. `session` starts it in that session's + /// directory; the phone never names a path. The answer lands in + /// `UniffiUiView.terminals.attached` (`terminal-opened`, or a refusal). + OpenTerminal { + machine: String, + session: Option, + cols: u16, + rows: u16, + }, + /// Put a terminal the machine's list names on screen, read-only, + /// replaying what the bridge still holds. Output arrives on + /// `CoreListener.on_event(CoreEvent.TerminalOutput)`. + AttachTerminal { + machine: String, + term: String, + }, + /// Leave the terminal on screen: the bridge stops sending its output. The + /// shell keeps running. + DetachTerminal { + machine: String, + }, + /// Type into the terminal on screen. `alternate_screen` is what the + /// emulator reports: on the normal screen printable bytes wait for a key + /// a program acts on (or a pause), on the alternate screen they go after + /// a short window. Pacing, sequencing and retries are the core's. + TerminalInput { + machine: String, + term: String, + bytes: Vec, + alternate_screen: bool, + }, + /// Paste: the bytes go as one event. + TerminalPaste { + machine: String, + term: String, + bytes: Vec, + }, + /// The emulator's window changed; only the writer's size counts. + ResizeTerminal { + machine: String, + term: String, + cols: u16, + rows: u16, + }, + /// Become the terminal's writer, so its input is this device's. + TakeOverTerminal { + machine: String, + term: String, + }, + /// Hang up the shell. + CloseTerminal { + machine: String, + term: String, + }, } #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -695,6 +752,22 @@ impl TryFrom for Intent { UniffiIntent::SetBackupRelay { url } => Intent::SetBackupRelay(url), UniffiIntent::BackupNow => Intent::BackupNow, UniffiIntent::DisableBackup { delete } => Intent::DisableBackup { delete }, + UniffiIntent::OpenTerminal { machine, session, cols, rows } => { + Intent::OpenTerminal { machine, session, cols, rows } + } + UniffiIntent::AttachTerminal { machine, term } => Intent::AttachTerminal { machine, term }, + UniffiIntent::DetachTerminal { machine } => Intent::DetachTerminal { machine }, + UniffiIntent::TerminalInput { machine, term, bytes, alternate_screen } => { + Intent::TerminalInput { machine, term, bytes, alternate_screen } + } + UniffiIntent::TerminalPaste { machine, term, bytes } => { + Intent::TerminalPaste { machine, term, bytes } + } + UniffiIntent::ResizeTerminal { machine, term, cols, rows } => { + Intent::ResizeTerminal { machine, term, cols, rows } + } + UniffiIntent::TakeOverTerminal { machine, term } => Intent::TakeOverTerminal { machine, term }, + UniffiIntent::CloseTerminal { machine, term } => Intent::CloseTerminal { machine, term }, }) } } @@ -925,4 +998,81 @@ mod tests { let mapped: Intent = intent.try_into().unwrap(); assert_eq!(mapped, Intent::DismissPendingSession { pending_id: "p1".into() }); } + + #[test] + fn the_terminal_intents_map_field_for_field() { + let mapped: Intent = UniffiIntent::OpenTerminal { + machine: "m".into(), + session: Some("s1".into()), + cols: 100, + rows: 40, + } + .try_into() + .unwrap(); + assert_eq!( + mapped, + Intent::OpenTerminal { machine: "m".into(), session: Some("s1".into()), cols: 100, rows: 40 } + ); + + let mapped: Intent = UniffiIntent::AttachTerminal { machine: "m".into(), term: "t1".into() } + .try_into() + .unwrap(); + assert_eq!(mapped, Intent::AttachTerminal { machine: "m".into(), term: "t1".into() }); + + let mapped: Intent = UniffiIntent::DetachTerminal { machine: "m".into() }.try_into().unwrap(); + assert_eq!(mapped, Intent::DetachTerminal { machine: "m".into() }); + + let mapped: Intent = UniffiIntent::TerminalInput { + machine: "m".into(), + term: "t1".into(), + bytes: b"\x1b[A".to_vec(), + alternate_screen: true, + } + .try_into() + .unwrap(); + assert_eq!( + mapped, + Intent::TerminalInput { + machine: "m".into(), + term: "t1".into(), + bytes: b"\x1b[A".to_vec(), + alternate_screen: true, + } + ); + + let mapped: Intent = UniffiIntent::TerminalPaste { + machine: "m".into(), + term: "t1".into(), + bytes: b"pasted".to_vec(), + } + .try_into() + .unwrap(); + assert_eq!( + mapped, + Intent::TerminalPaste { machine: "m".into(), term: "t1".into(), bytes: b"pasted".to_vec() } + ); + + let mapped: Intent = UniffiIntent::ResizeTerminal { + machine: "m".into(), + term: "t1".into(), + cols: 132, + rows: 43, + } + .try_into() + .unwrap(); + assert_eq!( + mapped, + Intent::ResizeTerminal { machine: "m".into(), term: "t1".into(), cols: 132, rows: 43 } + ); + + let mapped: Intent = UniffiIntent::TakeOverTerminal { machine: "m".into(), term: "t1".into() } + .try_into() + .unwrap(); + assert_eq!(mapped, Intent::TakeOverTerminal { machine: "m".into(), term: "t1".into() }); + + let mapped: Intent = UniffiIntent::CloseTerminal { machine: "m".into(), term: "t1".into() } + .try_into() + .unwrap(); + assert_eq!(mapped, Intent::CloseTerminal { machine: "m".into(), term: "t1".into() }); + } } diff --git a/crates/client-ffi/src/lib.rs b/crates/client-ffi/src/lib.rs index e917a478..b085e2ec 100644 --- a/crates/client-ffi/src/lib.rs +++ b/crates/client-ffi/src/lib.rs @@ -52,9 +52,10 @@ pub use signer::{ }; use observer::UniffiObserver; pub use views::{ - UniffiMachinesView, UniffiOutboxView, UniffiPairingCandidateView, UniffiPairingView, - UniffiPendingSessionsView, UniffiQuickPromptsView, UniffiSettingsView, UniffiTranscriptDelta, - UniffiUiView, + UniffiAttachedTerminal, UniffiMachinesView, UniffiOutboxView, UniffiPairingCandidateView, + UniffiPairingView, UniffiPendingSessionsView, UniffiQuickPromptsView, UniffiSettingsView, + UniffiTerminalEnded, UniffiTerminalInfo, UniffiTerminalRefusal, UniffiTerminalsView, + UniffiTranscriptDelta, UniffiUiView, }; use views::{ build_uniffi_machines_view, build_uniffi_outbox_view, build_uniffi_pairing_view, diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 8b9c1fc5..82da86e2 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -1270,6 +1270,82 @@ pub struct UniffiUiView { pub provider_profile_status: HashMap, /// Present while a delete's undo window is open. pub undo_toast: Option, + /// The terminals, low-frequency state only — output frames arrive on + /// `CoreListener.on_event(CoreEvent.TerminalOutput)`, never here. + pub terminals: UniffiTerminalsView, +} + +/// The terminals each machine lists, and the one this phone has on screen. +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiTerminalsView { + /// machine pubkey -> the terminals its heartbeat lists, oldest opened + /// first. Each carries the bridge's `term`, its shell, the device that + /// opened it and the device whose input it takes now. + pub by_machine: HashMap>, + pub attached: Option, +} + +/// One terminal a machine lists — mirrors `protocol::events::TerminalInfo`. +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiTerminalInfo { + /// The bridge's id, which every terminal message names. + pub term: String, + /// What runs in it (`/bin/bash`). + pub shell: String, + pub opened: u64, + /// The device that opened it (an opaque id; `writer` names the one whose + /// input it takes now). + pub device: String, + /// That device's name, when its user gave one. + pub name: Option, + pub writer: String, + pub cols: u16, + pub rows: u16, +} + +/// The terminal on screen. +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiAttachedTerminal { + pub machine: String, + /// The bridge's id, absent while an open is still pending. + pub term: Option, + /// This phone's own id for a pending open, echoed by `terminal-opened`. + pub open_id: Option, + pub shell: Option, + /// Whether this device's typing is the one the terminal takes. When + /// false the screen is read-only until `TakeOverTerminal`. + pub writer: bool, + pub cols: u16, + pub rows: u16, + /// The next output byte offset the app should expect. The `offset` on + /// each `CoreEvent.TerminalOutput` counts from the same origin. + pub expected_offset: u64, + /// Bytes typed and not yet sent — drawn dimmed at the cursor as the + /// predicted echo until the real output replaces them. + pub waiting_input: Vec, + /// Set when a `terminal-open` was refused. + pub refusal: Option, + /// Set when the terminal ended. + pub ended: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiTerminalRefusal { + /// A `terminal-exited` reason (`not-allowed`, `limit`, `disabled`, …); + /// an unknown one reads as `unknown`. + pub reason: String, + /// The device the open was refused for, for `not-allowed`. + pub device: Option, + /// That device's name, when one was ever seen for it. + pub name: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct UniffiTerminalEnded { + /// `exit`, `killed`, `closed`, `idle`, `limit`, `transport`, `restart`… + pub reason: String, + /// The shell's exit status, with `exit`. + pub code: Option, } pub fn build_uniffi_ui_view(v: &UiView) -> UniffiUiView { @@ -1323,6 +1399,50 @@ pub fn build_uniffi_ui_view(v: &UiView) -> UniffiUiView { session_id: t.session_id, label: t.label, }), + terminals: UniffiTerminalsView { + by_machine: v + .terminals + .by_machine + .iter() + .map(|(machine, list)| { + ( + machine.clone(), + list.iter() + .map(|t| UniffiTerminalInfo { + term: t.term.clone(), + shell: t.shell.clone(), + opened: t.opened, + device: t.device.clone(), + name: t.name.clone(), + writer: t.writer.clone(), + cols: t.cols, + rows: t.rows, + }) + .collect(), + ) + }) + .collect(), + attached: v.terminals.attached.as_ref().map(|a| UniffiAttachedTerminal { + machine: a.machine.clone(), + term: a.term.clone(), + open_id: a.open_id.clone(), + shell: a.shell.clone(), + writer: a.writer, + cols: a.cols, + rows: a.rows, + expected_offset: a.expected_offset, + waiting_input: a.waiting_input.clone(), + refusal: a.refusal.as_ref().map(|r| UniffiTerminalRefusal { + reason: wire_str(&r.reason), + device: r.device.clone(), + name: r.name.clone(), + }), + ended: a.ended.as_ref().map(|e| UniffiTerminalEnded { + reason: wire_str(&e.reason), + code: e.code, + }), + }), + }, } } From f350b887f1a257dc7a92adde6b7ba3f55c11cdac Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:14:20 +0200 Subject: [PATCH 28/50] docs(client): the terminal section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State, presence, input batching, seq, output offsets and what the app gets — the phone half of `docs/TERMINAL.md`, in the layer document a contributor reads first. Co-Authored-By: Claude Code --- docs/CLIENT.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 3ad3c9c8..c0998863 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -308,6 +308,72 @@ group of one finished call adds a line under it after the mark, saying what the call came to (the lines a read returned, what a search found, what an edit changed, a command's first line of output). +### Terminal + +A terminal is a real shell on the bridge's machine, drawn by the app's +emulator (`docs/TERMINAL.md` is the design). The core keeps its state and +paces its input; the app draws and collects keys. + +**State** (`stores::terminals`, never persisted). Each machine's heartbeat +lists the terminals that belong to this identity — the only way a second +phone, or this one after a restart, learns one exists. On top of that sits +**the attached terminal**, at most one, the one on screen: the bridge's +`term` (absent while an open is pending, which the phone's own `openId` +names), whether this device is the writer, the next output byte offset +expected, the last refusal (its reason, and the device the bridge refused +with the name that device was ever seen under) and the reason it ended. A +device is named by `protocol::recipients::device_id` of the first session +key this install ever made, which the session-key ring now keeps so the +heartbeat's `writer` and every `terminal-event`'s `device` can be read as +"us" or "another phone". + +**Presence.** `watchingTerminal` is set, per machine, while a terminal of +that machine is on screen and the app is visible — the same rule `watching` +follows for live text, and what makes the bridge send output only to the +devices showing it. Detaching (or backgrounding the app) clears it. A +terminal costs a backgrounded phone nothing. + +**Input.** Typing is not sent per keystroke. On the normal screen printable +bytes wait and go in one event when a key a program could act on is pressed +(Enter, Tab, Esc, an arrow or any escape sequence, Backspace/Delete, any +Ctrl chord, an Alt prefix), when typing pauses (150 ms over the direct link, +400 ms over relays), or past 256 bytes; a paste is always one event. On the +alternate screen — vim, htop, less — every key is a command, so there is no +prediction and bytes go after at most 20 ms / 60 ms, coalescing a held key's +repeats. Over relays a terminal sends at most 5 input events a second; the +rest stays buffered and merges into the next. The bytes still waiting are in +the view, for the app's predicted echo (dimmed, at the cursor, replaced by +the real output). The core arms its own timer for the flush — a change of +transport is picked up on the next keystroke, not by a re-armed window. + +**Seq.** One counter per (machine, term), from 1, +1 per input event. The +last 256 events (at most 1 MiB) are kept; a `terminal-input-from {term, +seq}` resends from `seq` if it is still held, and otherwise the count simply +continues from the number the bridge named — which is also how a takeover +tells this device where to resume. Nothing older than what is held can be +resent. + +**Output.** `terminal-output {term, offset, data}` is base64-decoded in the +core and handed to the app as its own event, +`CoreEvent::TerminalOutput { machine, term, offset, bytes }` — never through +a view, since a shell can write megabytes a second and a view snapshot per +frame would slow every other slice down. A frame at the expected offset is +passed on and the offset advances. A frame beyond it means one was lost: the +core asks for `terminal-replay` from the expected offset **once per gap** and +drops frames until the replay arrives. A frame that starts before the +expected offset is trimmed to the part not yet drawn, or dropped when it is +entirely behind. + +**What the app gets.** `UiView.terminals` (`UniffiUiView.terminals`) is the +low-frequency half: every machine's list, and the attached terminal's term, +writer flag, size, expected offset, waiting bytes, refusal and end. The +intents are `OpenTerminal`, `AttachTerminal`, `DetachTerminal`, +`TerminalInput`, `TerminalPaste`, `ResizeTerminal`, `TakeOverTerminal` and +`CloseTerminal`; `OpenTerminal` is ignored unless the machine's heartbeat +advertises the `terminal` capability. `terminal-input` is the one phone +command that does not ride `COMMAND_KIND`: keystrokes ride `LIVE_KIND` and +are never stored on a relay. + ### Direct link A bridge can also be reached without the relays (the wire is in From 6848034f3fa3a5627ae591e213089f93a475ea2b Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:35:56 +0200 Subject: [PATCH 29/50] feat(android): the terminal's screens around the emulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A machine's "Terminal" group: the terminals running there, each with what runs in it and the device that opened it, and "Open a terminal". A bridge that does not advertise terminals says where its owner turns them on; the phone never does. - The terminal page: opening, refused (for an unapproved phone, the command that approves it on the machine, copyable — approval happens on the machine only), and ended (why, in a line). A running terminal gets one slim bar — back, what runs, close for the writer or Take over for a device that watches — and the rest of the screen is the emulator's, which plugs in where the snapshots draw a few lines of a shell. Co-Authored-By: Claude Code --- .../plus/ui/screens/TerminalScreen.kt | 227 ++++++++++++++++++ .../codedeck/plus/ui/DesignSnapshotTest.kt | 53 ++++ ...i_DesignSnapshotTest_machine_terminals.png | Bin 0 -> 46039 bytes ...s.ui_DesignSnapshotTest_terminal_ended.png | Bin 0 -> 21779 bytes ...ui_DesignSnapshotTest_terminal_opening.png | Bin 0 -> 9642 bytes ...ui_DesignSnapshotTest_terminal_refused.png | Bin 0 -> 59093 bytes ...i_DesignSnapshotTest_terminal_watching.png | Bin 0 -> 28392 bytes 7 files changed, 280 insertions(+) create mode 100644 apps/android/app/src/main/java/com/codedeck/plus/ui/screens/TerminalScreen.kt create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_machine_terminals.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_ended.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_opening.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_refused.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_watching.png diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/TerminalScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/TerminalScreen.kt new file mode 100644 index 00000000..06d4ae2a --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/TerminalScreen.kt @@ -0,0 +1,227 @@ +package com.codedeck.plus.ui.screens + +import android.content.ClipData +import androidx.compose.foundation.background +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import com.codedeck.plus.ui.components.EmptyState +import com.codedeck.plus.ui.components.Group +import com.codedeck.plus.ui.components.IconAction +import com.codedeck.plus.ui.components.NavRow +import com.codedeck.plus.ui.components.Page +import com.codedeck.plus.ui.components.QuietButton +import com.codedeck.plus.ui.components.ValueRow +import com.codedeck.plus.ui.theme.Tokens +import kotlinx.coroutines.launch +import uniffi.client_ffi.UniffiAttachedTerminal +import uniffi.client_ffi.UniffiMachineSummary +import uniffi.client_ffi.UniffiTerminalEnded +import uniffi.client_ffi.UniffiTerminalInfo +import uniffi.client_ffi.UniffiTerminalRefusal + +/** The capability a bridge advertises only when its owner turned terminals on. */ +internal const val TERMINAL_CAPABILITY = "terminal" + +/** `bash` for `/bin/bash`; a configured command (`tmux new -A -s phone`) as it is. */ +internal fun shellName(shell: String?): String = + shell?.takeIf { it.isNotBlank() }?.let { if (' ' in it) it else it.substringAfterLast('/') } ?: "Terminal" + +/** + * The "Terminal" group on a machine's page: open one, or the ones running + * there — each with what runs in it and the device that opened it. A bridge + * that does not advertise terminals says where they are switched on; the + * phone never switches them on itself. + */ +@Composable +internal fun MachineTerminalsGroup( + machine: UniffiMachineSummary, + terminals: List, + now: Long, + onOpen: () -> Unit, + onAttach: (term: String) -> Unit, +) { + if (TERMINAL_CAPABILITY !in machine.capabilities) { + Group(title = "Terminal", footer = "Off on this machine. Its owner turns terminals on in the bridge's config (terminal.enabled).") { + ValueRow("Terminal", subtitle = "Off") {} + } + return + } + Group(title = "Terminal", footer = "A shell on the machine. Only approved phones open one, and every terminal is recorded on the machine.") { + terminals.forEach { t -> + NavRow( + shellName(t.shell), + onClick = { onAttach(t.term) }, + subtitle = listOfNotNull(t.name?.let { "Opened by $it" }, openedAgo(t.opened.toLong(), now)).joinToString(" · "), + ) + Divider() + } + NavRow("Open a terminal", onClick = onOpen) + } +} + +private fun openedAgo(openedMs: Long, now: Long): String { + val minutes = (now - openedMs) / 60_000 + return when { + minutes < 1 -> "just now" + minutes < 60 -> "$minutes min ago" + else -> "${minutes / 60} h ago" + } +} + +/** What to say about a refused open, and what the owner can do about it. */ +internal fun refusalText(refusal: UniffiTerminalRefusal): Pair = when (refusal.reason) { + "not-allowed" -> "This phone is not approved" to + "A terminal is a shell on the machine, so each phone is approved there first. Run this on the machine, then open it again." + "limit" -> "Too many terminals" to "The machine runs as many terminals as it allows. Close one, then open it again." + "disabled" -> "Terminals are off" to "The machine's owner turns them on in the bridge's config (terminal.enabled)." + "transport" -> "The terminal did not start" to "The machine could not start a shell, or this phone reached it a way terminals are not allowed (over a relay)." + else -> "The terminal did not open" to "The machine refused it (${refusal.reason})." +} + +/** Why a terminal is over, in a line. */ +internal fun endedText(ended: UniffiTerminalEnded): String = when (ended.reason) { + "exit" -> ended.code?.let { "The shell exited with code $it." } ?: "The shell exited." + "closed" -> "The terminal was closed." + "idle" -> "It ended after a long time with no phone attached." + "killed" -> "The machine ended it." + "restart" -> "The bridge restarted, and the shell with it." + "transport" -> "The machine lost the shell." + "not-allowed" -> "This phone was withdrawn from the machine's approved devices." + else -> "The terminal ended (${ended.reason})." +} + +/** + * One terminal: while it opens, the shell itself ([emulator], which fills + * the screen), why it was refused, or why it is over. Only the writer closes + * it; a device that does not write watches, with Take over to become the + * writer. Pure — the caller supplies the terminal and the emulator — so it + * renders in a snapshot. + */ +@Composable +fun TerminalContent( + attached: UniffiAttachedTerminal?, + onBack: () -> Unit, + onTakeOver: () -> Unit, + onClose: () -> Unit, + emulator: @Composable (UniffiAttachedTerminal) -> Unit, +) { + val refusal = attached?.refusal + val ended = attached?.ended + if (attached != null && attached.term != null && refusal == null && ended == null) { + RunningTerminal(attached, onBack, onTakeOver, onClose, emulator) + return + } + Page(title = shellName(attached?.shell), onBack = onBack) { + when { + refusal != null -> Refused(refusal, onBack) + ended != null -> EmptyState( + icon = Icons.Outlined.PowerSettingsNew, + title = "Terminal ended", + body = endedText(ended), + action = "Done", + onAction = onBack, + modifier = Modifier.fillMaxWidth().padding(top = Tokens.Space7), + ) + else -> Column( + Modifier.fillMaxWidth().padding(top = Tokens.Space7), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Tokens.Space3), + ) { + CircularProgressIndicator(color = Tokens.Text, strokeWidth = 2.dp) + Text("Opening a terminal…", color = Tokens.TextMuted, fontSize = Tokens.TextMd) + } + } + } +} + +/** + * A running terminal: one slim bar (back, what runs, and Take over or close), + * the rest of the screen the shell's — a terminal needs its rows more than a + * page title. + */ +@Composable +private fun RunningTerminal( + attached: UniffiAttachedTerminal, + onBack: () -> Unit, + onTakeOver: () -> Unit, + onClose: () -> Unit, + emulator: @Composable (UniffiAttachedTerminal) -> Unit, +) { + Column(Modifier.fillMaxSize().background(Tokens.Bg)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = Tokens.Space1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Tokens.Space1), + ) { + IconAction(Icons.AutoMirrored.Outlined.ArrowBack, "Back", onBack) + Text( + shellName(attached.shell), + color = Tokens.Text, + fontSize = Tokens.TextMd, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (attached.writer) { + IconAction(Icons.Outlined.PowerSettingsNew, "Close the terminal", onClick = onClose, tint = Tokens.TextMuted) + } else { + // Another device types into it: this one watches until it + // takes the pen, which every device of the identity is told. + Text("Watching", color = Tokens.TextMuted, fontSize = Tokens.TextSm) + QuietButton("Take over", onClick = onTakeOver) + } + } + Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = Tokens.Space2)) { emulator(attached) } + } +} + +@Composable +private fun Refused(refusal: UniffiTerminalRefusal, onBack: () -> Unit) { + val (title, body) = refusalText(refusal) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + EmptyState( + icon = Icons.Outlined.Lock, + title = title, + body = body, + action = "Done", + onAction = onBack, + modifier = Modifier.fillMaxWidth().padding(top = Tokens.Space6), + ) + val device = refusal.device + if (refusal.reason == "not-allowed" && device != null) { + val command = "codedeck-bridge terminal allow $device" + Group(title = "On the machine", footer = refusal.name?.let { "This phone is \"$it\" there; codedeck-bridge terminal pending lists it." }) { + ValueRow("Approve this phone", subtitle = command, mono = true) { + IconAction( + Icons.Outlined.ContentCopy, + "Copy the command", + onClick = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("command", command))) } }, + tint = Tokens.TextMuted, + ) + } + } + } +} diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 99ba9edf..31ab5544 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -18,6 +18,11 @@ import com.codedeck.plus.ui.DesignFixtures.workstation import com.codedeck.plus.ui.screens.AddView import com.codedeck.plus.ui.screens.AppearancePage import com.codedeck.plus.ui.screens.SessionsContent +import com.codedeck.plus.ui.screens.MachineTerminalsGroup +import com.codedeck.plus.ui.screens.TERMINAL_CAPABILITY +import com.codedeck.plus.ui.screens.TerminalContent +import com.codedeck.plus.ui.components.Page +import androidx.compose.material3.Text import com.codedeck.plus.ui.screens.McpContent import com.codedeck.plus.ui.screens.PermissionRulesContent import com.codedeck.plus.ui.screens.ConnectionPage @@ -38,6 +43,10 @@ import com.codedeck.plus.ui.screens.AccountPage import com.codedeck.plus.ui.screens.BackupPage import com.codedeck.plus.ui.screens.RestoreContent import uniffi.client_ffi.UniffiAgentPermissionRules +import uniffi.client_ffi.UniffiAttachedTerminal +import uniffi.client_ffi.UniffiTerminalEnded +import uniffi.client_ffi.UniffiTerminalInfo +import uniffi.client_ffi.UniffiTerminalRefusal import uniffi.client_ffi.UniffiUsageData import uniffi.client_ffi.UniffiBackupStatus import uniffi.client_ffi.UniffiBackupView @@ -291,6 +300,22 @@ private val pages: Map Unit> = linkedMapOf( credentialsStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenAgent = {}, ) }, + "terminal_opening" to { Terminal(attached(term = null)) }, + "terminal_refused" to { + Terminal(attached(term = null, refusal = UniffiTerminalRefusal("not-allowed", "3f9a1c0e7b2d4a58", "Pixel 8a"))) + }, + "terminal_ended" to { Terminal(attached(ended = UniffiTerminalEnded("exit", 0))) }, + "terminal_watching" to { Terminal(attached(writer = false)) }, + "machine_terminals" to { + Page(title = "Workstation", onBack = {}) { + MachineTerminalsGroup( + workstation.copy(capabilities = listOf(TERMINAL_CAPABILITY)), + listOf(UniffiTerminalInfo("t1", "/bin/bash", (NOW - 12 * 60_000).toULong(), "3f9a1c0e", "Pixel 8a", "3f9a1c0e", 80u, 24u)), + NOW, onOpen = {}, onAttach = {}, + ) + MachineTerminalsGroup(workstation, emptyList(), NOW, onOpen = {}, onAttach = {}) + } + }, "settings_agent_opencode" to { AgentPage("opencode") }, "settings_agent_claude" to { AgentPage("claude-code") }, "settings_agent_not_installed" to { AgentPage("deepseek-harness") }, @@ -392,6 +417,29 @@ private val deepseekLook = AgentLook.of("DeepSeek Harness", DesignFixtures.deeps private val qwenLook = AgentLook.of("Qwen Code", DesignFixtures.qwenStyle, "plan") /** [content] drawn as a session of the agent [look] belongs to. */ +/** The terminal on the workstation, as the core shows it. */ +private fun attached( + term: String? = "t1", + writer: Boolean = true, + refusal: UniffiTerminalRefusal? = null, + ended: UniffiTerminalEnded? = null, +) = UniffiAttachedTerminal( + machine = workstation.pubkeyHex, term = term, openId = "o1", shell = "/bin/bash", writer = writer, + cols = 80u, rows = 24u, expectedOffset = 0u, waitingInput = ByteArray(0), refusal = refusal, ended = ended, +) + +/** A terminal page, the emulator standing in as a few lines of a shell. */ +@Composable +private fun Terminal(attached: UniffiAttachedTerminal) = TerminalContent( + attached = attached, onBack = {}, onTakeOver = {}, onClose = {}, + emulator = { + Text( + "me@workstation:~/codedeck-plus$ git status\nOn branch master\nnothing to commit, working tree clean\nme@workstation:~/codedeck-plus$ ", + color = Tokens.Text, fontFamily = Tokens.FontMono, fontSize = Tokens.TextSm, + ) + }, +) + /** One agent's page on the workstation. */ @Composable private fun AgentPage(agent: String) = AgentSettingsContent( @@ -460,6 +508,11 @@ class DesignSnapshotTest { @Test fun pairing() = paparazzi.page("pairing") @Test fun settings() = paparazzi.page("settings") @Test fun settings_machine_update_needed() = paparazzi.page("settings_machine_update_needed") + @Test fun terminal_opening() = paparazzi.page("terminal_opening") + @Test fun terminal_refused() = paparazzi.page("terminal_refused") + @Test fun terminal_ended() = paparazzi.page("terminal_ended") + @Test fun terminal_watching() = paparazzi.page("terminal_watching") + @Test fun machine_terminals() = paparazzi.page("machine_terminals") @Test fun settings_agent_opencode() = paparazzi.page("settings_agent_opencode") @Test fun settings_agent_claude() = paparazzi.page("settings_agent_claude") @Test fun settings_agent_not_installed() = paparazzi.page("settings_agent_not_installed") diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_machine_terminals.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_machine_terminals.png new file mode 100644 index 0000000000000000000000000000000000000000..4a86097c28177047763d13a578b01ee2dd75d9aa GIT binary patch literal 46039 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 QIR*UO)zpKPYgk463$`=gX8-^I literal 0 HcmV?d00001 diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_ended.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_ended.png new file mode 100644 index 0000000000000000000000000000000000000000..d50339e0107115d7996c93383ac5102e1977d34e GIT binary patch literal 21779 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 literal 0 HcmV?d00001 diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_refused.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_refused.png new file mode 100644 index 0000000000000000000000000000000000000000..5530242042017559db0f5a5463b57bac691f022b GIT binary patch literal 59093 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 literal 0 HcmV?d00001 From 6856f758a64d90de014a205291802102bc1755a7 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:03 +0200 Subject: [PATCH 30/50] fix(bridge-runtime): the terminal allowlist's file work stays off the runtime thread, and cannot lose a write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge runs relays, the engine, the direct links and the web page on one thread, so nothing there may block on a file. Three things about `terminal-allow.json` broke that, or let a change go missing: - `Effect::TerminalPending` called `merge_pending`, which read the whole file on the runtime thread. An unapproved device spamming `terminal-open` made that happen once per message. The read, the merge and the write now all happen on the allowlist's writer thread, which is fed the refused-device list and coalesces a burst: it drains its channel and writes only the newest list. The engine keeps its own pending list, so the immediate `Input::TerminalAllowlist` echo is gone; the write's new metadata reaches the engine through the existing per-message and periodic checks. - `Cache::changed` compared only the mtime, so two writes in one mtime tick — or a filesystem with a coarse clock — looked like no change at all, and `terminal disallow` right after `terminal allow` might never be seen. It now compares a stamp of the file's mtime, length and (on unix) inode, which an atomic rename always changes. Still one `metadata` call. - The CLI and the writer thread each did a read-modify-write of the whole file with no lock, so one could lose the other's change. Both now take `terminal-allow.json.lock` (created exclusively, retried every 20 ms for up to 2 s, a lock file older than 10 s treated as a crashed holder's and removed) around the whole read-modify-write. Only the CLI and the writer thread ever take it; the runtime thread never does. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/runtime.rs | 22 +- crates/bridge-runtime/src/terminal_allow.rs | 342 ++++++++++++++++---- 2 files changed, 295 insertions(+), 69 deletions(-) diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 24e9331b..f31e1c30 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -116,7 +116,7 @@ struct Runtime { /// The terminal audit's writer thread. audit: audit::Audit, /// `/terminal-allow.json`, shared with the periodic change check so - /// its mtime is read once between the two. + /// its metadata is read once between the two. allowlist: Rc>, /// Writes the allowlist file, on a thread of its own: the runtime's single /// thread never writes it. @@ -245,7 +245,7 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio /// Watch the terminal allowlist file for a change every few seconds, so a /// `terminal disallow` closes a device's terminals even when no terminal /// message arrives to carry the check. The check is one `metadata` call; the -/// file is read only when its mtime moved, and the same cache is shared with +/// file is read only when its stamp moved, and the same cache is shared with /// the per-message check, so the change is noticed and read once. fn watch_allowlist(cache: Rc>, inputs: mpsc::UnboundedSender) { tokio::task::spawn_local(async move { @@ -441,15 +441,15 @@ impl Runtime { } Effect::TerminalClose { term } => self.terminals.close(&term), Effect::TerminalPending(pending) => { - // Merge the refused devices with the approved ones as they are - // on disk now (the owner may have run `terminal allow` since - // this engine last read the file). The write goes to a thread - // of its own — never the runtime's — and the engine is handed - // the merged list, whose approved devices would otherwise be a - // file change behind. - let merged = self.allowlist.borrow().merge_pending(&pending); - self.allowlist_writer.write(merged.clone()); - let _ = self.inputs.send(Input::TerminalAllowlist(merged)); + // The refused devices go to the allowlist's writer thread, + // which reads the file, replaces `pending` with this list and + // writes it — never the runtime's thread, which an unapproved + // device spamming `terminal-open` would otherwise stall with a + // read per message. The engine already holds its own pending + // list, and the write's new mtime reaches it through the + // per-message and periodic allowlist checks, so nothing is + // sent back to it here. + self.allowlist_writer.set_pending(pending); } Effect::Audit(record) => self.audit.record(record), } diff --git a/crates/bridge-runtime/src/terminal_allow.rs b/crates/bridge-runtime/src/terminal_allow.rs index ba05ef73..9aacfd2c 100644 --- a/crates/bridge-runtime/src/terminal_allow.rs +++ b/crates/bridge-runtime/src/terminal_allow.rs @@ -15,22 +15,36 @@ //! It is owner-only (`0600`) and written atomically (a temporary file, then a //! rename), so a crash never leaves half a file, and the CLI //! (`codedeck-bridge terminal allow|disallow`) edits it while the bridge runs. -//! The bridge re-reads it, cached by modification time, so a change takes -//! effect without a restart: [`Cache::changed`] is one `metadata` call, and -//! the file is read only when its mtime moved. +//! Every read-modify-write of it — the CLI's and the bridge's writer thread's — +//! takes a small lock file (`terminal-allow.json.lock`) so two of them cannot +//! lose each other's change. The bridge re-reads it, cached by a stamp of its +//! metadata, so a change takes effect without a restart: [`Cache::changed`] is +//! one `metadata` call, and the file is read only when that stamp moved. use std::collections::BTreeSet; -use std::fs; +use std::fs::{self, OpenOptions}; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, Sender}; +use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::JoinHandle; -use std::time::SystemTime; +use std::time::{Duration, Instant, SystemTime}; use bridge_core::{PendingDevice, TerminalAllowlist}; /// The file's name in the bridge home. pub const ALLOWLIST_FILE: &str = "terminal-allow.json"; +/// How long a read-modify-write waits for the lock before giving up. The +/// holder is the CLI or the bridge's writer thread, and neither holds it for +/// more than a read and a write. +const LOCK_WAIT: Duration = Duration::from_secs(2); +/// How often to retry while another holder has the lock. +const LOCK_RETRY: Duration = Duration::from_millis(20); +/// A lock file older than this belonged to a process that died holding it, so +/// it is removed and the lock taken. Well above the time a real holder needs. +const LOCK_STALE: Duration = Duration::from_secs(10); +/// What the lock file is called: the allowlist's name with this appended. +const LOCK_SUFFIX: &str = ".lock"; + /// The allowlist file under `home`. pub fn path_in(home: &Path) -> PathBuf { home.join(ALLOWLIST_FILE) @@ -60,12 +74,38 @@ pub fn write(path: &Path, list: &TerminalAllowlist) -> Result<(), String> { crate::state::write_private(path, &json).map_err(|e| format!("cannot write {}: {e}", path.display())) } -/// The allowlist file and the modification time last seen. The runtime shares -/// one of these between the command path and its periodic check, so a change -/// is noticed once and read once. +/// What the cache compares to notice a change: the file's modification time, +/// its length, and (on unix) its inode. mtime alone is not enough — two writes +/// inside one mtime tick, or a filesystem with a coarse clock, report the same +/// mtime — so the length and inode are compared too. Every write here is an +/// atomic rename, so the inode is new on each one; the length also catches a +/// rewrite that kept the inode. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Stamp { + mtime: Option, + len: u64, + ino: u64, +} + +impl Stamp { + fn of(meta: &fs::Metadata) -> Stamp { + #[cfg(unix)] + let ino = { + use std::os::unix::fs::MetadataExt; + meta.ino() + }; + #[cfg(not(unix))] + let ino = 0; + Stamp { mtime: meta.modified().ok(), len: meta.len(), ino } + } +} + +/// The allowlist file and the stamp last seen. The runtime shares one of +/// these between the command path and its periodic check, so a change is +/// noticed once and read once. pub struct Cache { path: PathBuf, - seen: Option, + seen: Option, checked: bool, } @@ -82,58 +122,55 @@ impl Cache { /// first call always returns the file's content, so it doubles as the /// start-up read. pub fn changed(&mut self) -> Option { - let mtime = fs::metadata(&self.path).ok().and_then(|m| m.modified().ok()); - if self.checked && mtime == self.seen { + let seen = fs::metadata(&self.path).ok().map(|m| Stamp::of(&m)); + if self.checked && seen == self.seen { return None; } self.checked = true; - self.seen = mtime; + self.seen = seen; Some(read(&self.path)) } +} - /// The engine's refused-device list merged with the approved devices as - /// they are on disk now (the owner may have approved or withdrawn one in - /// the CLI while this bridge ran). Nothing is written here: the caller - /// hands the result to a [`Writer`] thread and to the engine, whose own - /// copy of the approved devices may otherwise be a file change behind. - /// Reading on the runtime thread is fine; writing the file is not. - pub fn merge_pending(&self, pending: &[PendingDevice]) -> TerminalAllowlist { - let mut list = read(&self.path); - list.pending = pending.to_vec(); - list - } +/// The engine's refused-device list merged with the approved devices as they +/// are on disk now (the owner may have approved or withdrawn one in the CLI +/// while this bridge ran). Only the allowlist's writer thread calls this, from +/// its own thread: reading the file is file work, and the bridge's runtime +/// thread must not do it. +fn merge_pending(path: &Path, pending: Vec) -> TerminalAllowlist { + let mut list = read(path); + list.pending = pending; + list } /// Writes the allowlist on a thread of its own, so the bridge's single runtime /// thread never touches the file (the same reason the audit has a writer -/// thread). Fed whole lists in order; each list is complete, so the last write -/// wins. +/// thread). It is fed the refused-device lists; each one is merged with the +/// approved devices as the file has them now and written whole, under the +/// allowlist lock. A burst of refused devices coalesces: the thread drains its +/// channel and writes only the newest list, so a device spamming +/// `terminal-open` costs the file one write, not one per message. pub struct Writer { - tx: Option>, + tx: Option>>, thread: Option>, } impl Writer { pub fn start(path: PathBuf) -> Writer { - let (tx, rx) = mpsc::channel::(); + let (tx, rx) = mpsc::channel::>(); let thread = std::thread::Builder::new() .name("terminal-allow".into()) - .spawn(move || { - while let Ok(list) = rx.recv() { - if let Err(err) = write(&path, &list) { - log::warn!("[Terminal] cannot write the allowlist: {err}"); - } - } - }) + .spawn(move || writer_loop(path, rx)) .map_err(|e| log::error!("[Terminal] cannot start the allowlist writer thread: {e}")) .ok(); Writer { tx: Some(tx), thread } } - /// Queue a list to write. Dropping the returned handle later flushes it. - pub fn write(&self, list: TerminalAllowlist) { + /// Hand the refused devices over to be written. Never blocks the caller on + /// the file, and only the newest list matters: a burst coalesces. + pub fn set_pending(&self, pending: Vec) { if let Some(tx) = &self.tx { - if tx.send(list).is_err() { + if tx.send(pending).is_err() { log::warn!("[Terminal] the allowlist writer thread is gone; a change was lost"); } } @@ -150,6 +187,96 @@ impl Drop for Writer { } } +/// The writer thread: take the newest refused-device list, merge it with the +/// file as it is now, and write it — all under the lock, so the CLI editing +/// the file at the same moment cannot lose this write (nor this one the CLI's). +fn writer_loop(path: PathBuf, rx: Receiver>) { + while let Ok(mut pending) = rx.recv() { + // Everything that arrived while this one waited is superseded by the + // newest: only the last list needs writing. + while let Ok(newer) = rx.try_recv() { + pending = newer; + } + let _lock = match Lock::acquire(&path) { + Ok(lock) => lock, + Err(err) => { + log::warn!("[Terminal] cannot write the allowlist: {err}"); + continue; + } + }; + let list = merge_pending(&path, pending); + if let Err(err) = write(&path, &list) { + log::warn!("[Terminal] cannot write the allowlist: {err}"); + } + } +} + +/// The lock that serializes the allowlist's read-modify-write. The CLI and the +/// bridge's writer thread each read the whole file and write it back, so +/// without this they can lose each other's change — `terminal allow` right +/// after a refusal, say. Taken by creating `.lock` exclusively, which +/// works across processes; released by removing it. Only the CLI and the +/// writer thread ever take it: the bridge's runtime thread never does, so a +/// lock can never stall a relay, a session or a terminal. +struct Lock { + path: PathBuf, +} + +impl Lock { + /// Take the lock for `file`, waiting up to [`LOCK_WAIT`] for a holder + /// (retrying every [`LOCK_RETRY`]). A lock file older than [`LOCK_STALE`] + /// belonged to a process that died holding it, so it is removed and the + /// lock taken; anything else means a real holder, and the wait ends with + /// an error rather than a lost write. + fn acquire(file: &Path) -> Result { + let path = lock_path(file); + let deadline = Instant::now() + LOCK_WAIT; + loop { + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(_) => return Ok(Lock { path }), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if stale(&path) { + let _ = fs::remove_file(&path); + continue; + } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for {} (another process is editing the allowlist)", + path.display() + )); + } + std::thread::sleep(LOCK_RETRY); + } + Err(e) => return Err(format!("cannot create {}: {e}", path.display())), + } + } + } +} + +impl Drop for Lock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +/// `file`'s lock file: `.lock` beside it. +fn lock_path(file: &Path) -> PathBuf { + let mut name = file.file_name().unwrap_or_default().to_os_string(); + name.push(LOCK_SUFFIX); + file.with_file_name(name) +} + +/// Whether the lock file is old enough that its holder must have died. A file +/// that cannot be read is treated as held: the caller retries and, on the +/// deadline, reports it rather than stealing a live lock. +fn stale(path: &Path) -> bool { + fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|mtime| SystemTime::now().duration_since(mtime).ok()) + .is_some_and(|age| age >= LOCK_STALE) +} + /// The refused devices as lines for a person: id, label, when last seen. pub fn pending_lines(list: &TerminalAllowlist) -> Vec { list.pending @@ -167,8 +294,11 @@ fn when(ms: u64) -> String { } /// Approve a device: move it from the refused list to the approved one. -/// `target` is the id, or an unambiguous prefix of one. +/// `target` is the id, or an unambiguous prefix of one. The whole +/// read-modify-write is under the allowlist lock, so it cannot race the +/// bridge's writer thread. pub fn allow(path: &Path, target: &str) -> Result { + let _lock = Lock::acquire(path)?; let mut list = read(path); if resolve_device(&list.devices, target)?.is_some() { return Ok(format!("\"{target}\" is already approved.")); @@ -187,7 +317,10 @@ pub fn allow(path: &Path, target: &str) -> Result { /// Withdraw a device's approval: remove it from the approved list. A running /// bridge closes that device's terminals the moment it next reads the file. +/// The whole read-modify-write is under the allowlist lock, so it cannot race +/// the bridge's writer thread. pub fn disallow(path: &Path, target: &str) -> Result { + let _lock = Lock::acquire(path)?; let mut list = read(path); let Some(id) = resolve_device(&list.devices, target)? else { return Err(format!( @@ -242,17 +375,18 @@ mod tests { use super::*; fn list_with(devices: &[&str], pending: &[(&str, &str)]) -> TerminalAllowlist { - TerminalAllowlist { - devices: devices.iter().map(|d| d.to_string()).collect(), - pending: pending - .iter() - .map(|(device, label)| PendingDevice { - device: device.to_string(), - label: Some(label.to_string()), - last_seen_ms: 1_700_000_000_000, - }) - .collect(), - } + TerminalAllowlist { devices: devices.iter().map(|d| d.to_string()).collect(), pending: pending_of(pending) } + } + + fn pending_of(pending: &[(&str, &str)]) -> Vec { + pending + .iter() + .map(|(device, label)| PendingDevice { + device: device.to_string(), + label: Some(label.to_string()), + last_seen_ms: 1_700_000_000_000, + }) + .collect() } #[test] @@ -279,7 +413,7 @@ mod tests { } #[test] - fn the_cache_re_reads_only_when_the_mtime_moved() { + fn the_cache_re_reads_only_when_the_file_changed() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(ALLOWLIST_FILE); let mut cache = Cache::new(dir.path()); @@ -290,13 +424,40 @@ mod tests { assert_eq!(cache.changed(), None); // A write is noticed once (the file went from absent to present, so - // its mtime moved), and not read again until it moves again. + // its stamp moved), and not read again until it moves again. write(&path, &list_with(&["aaaa"], &[])).unwrap(); let seen = cache.changed().expect("the changed file is read"); assert!(seen.devices.contains("aaaa")); assert_eq!(cache.changed(), None, "no second read without a new change"); } + /// Two writes in one mtime tick — what a coarse filesystem reports, and + /// what an mtime-only check misses — are both seen. `terminal disallow` + /// right after `terminal allow` is exactly this pair. + #[test] + fn two_writes_in_the_same_mtime_tick_are_both_seen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let mut cache = Cache::new(dir.path()); + assert_eq!(cache.changed(), Some(TerminalAllowlist::default())); + + write(&path, &list_with(&["aaaa"], &[])).unwrap(); + let before = fs::metadata(&path).unwrap().modified().unwrap(); + assert!(cache.changed().expect("the first write is seen").devices.contains("aaaa")); + + // A second write straight away, with a different list of a different + // length, its mtime forced back to the first's: the mtime alone says + // nothing changed. + write(&path, &list_with(&["bbbbbbbb"], &[])).unwrap(); + let file = OpenOptions::new().write(true).open(&path).unwrap(); + file.set_modified(before).unwrap(); + drop(file); + + let second = cache.changed().expect("the second write is seen even with the same mtime"); + assert!(second.devices.contains("bbbbbbbb"), "the newest list is the one read"); + assert_eq!(cache.changed(), None); + } + #[test] fn merge_pending_keeps_the_approved_devices_as_on_disk() { let dir = tempfile::tempdir().unwrap(); @@ -304,25 +465,90 @@ mod tests { // The owner approved one device in the CLI since the bridge last read. write(&path, &list_with(&["aaaa"], &[])).unwrap(); - let cache = Cache::new(dir.path()); let pending = vec![PendingDevice { device: "cccc".into(), label: Some("Pixel 8".into()), last_seen_ms: 5 }]; - let merged = cache.merge_pending(&pending); + let merged = merge_pending(&path, pending.clone()); assert!(merged.devices.contains("aaaa"), "the device on disk is kept"); assert_eq!(merged.pending, pending); - // Merging only reads: the file is unchanged until a Writer writes it. + // Merging only reads: the file is unchanged until a write follows. assert!(read(&path).pending.is_empty()); } #[test] - fn the_writer_writes_on_its_own_thread() { + fn the_writer_merges_and_writes_on_its_own_thread() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(ALLOWLIST_FILE); + // A device the owner already approved, which the merge must keep. + write(&path, &list_with(&["aaaa"], &[])).unwrap(); + let writer = Writer::start(path.clone()); - writer.write(list_with(&["aaaa"], &[("cccc", "Pixel 8")])); + writer.set_pending(pending_of(&[("cccc", "Pixel 8")])); drop(writer); // joins, so the write has landed let list = read(&path); - assert!(list.devices.contains("aaaa")); + assert!(list.devices.contains("aaaa"), "the approved device on disk is kept"); assert_eq!(list.pending.len(), 1); + assert_eq!(list.pending[0].device, "cccc"); + } + + /// A burst of refused devices coalesces: the file holds the last list, not + /// one write per message. + #[test] + fn a_burst_of_refused_devices_leaves_the_last_list() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + let writer = Writer::start(path.clone()); + writer.set_pending(pending_of(&[("d1", "one")])); + writer.set_pending(pending_of(&[("d2", "two")])); + writer.set_pending(pending_of(&[("d3", "three")])); + drop(writer); // joins, so the queue has drained + + let list = read(&path); + assert_eq!(list.pending.len(), 1, "only the newest list is kept: {:?}", list.pending); + assert_eq!(list.pending[0].device, "d3"); + } + + /// The lock serializes the read-modify-write: while another holder has it, + /// `allow` waits rather than racing it. + #[test] + fn a_held_lock_makes_allow_wait() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&[], &[("c3d4e5f6", "Pixel 8")])).unwrap(); + + // Hold the lock the way the writer thread would, then release it. + let held = lock_path(&path); + OpenOptions::new().write(true).create_new(true).open(&held).unwrap(); + let release = held.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(120)); + let _ = fs::remove_file(&release); + }); + + let started = Instant::now(); + let message = allow(&path, "c3d4e5f6").expect("the lock is released while we wait"); + assert!(message.contains("c3d4e5f6")); + assert!(started.elapsed() >= Duration::from_millis(80), "allow did not wait for the lock"); + assert!(!held.exists(), "the lock is released after the write"); + } + + /// A lock file left by a process that died is broken, not waited on: the + /// CLI must not be stuck for the full wait on a stale lock. + #[test] + fn a_stale_lock_is_broken() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&["aaaa"], &[])).unwrap(); + + let held = lock_path(&path); + let file = OpenOptions::new().write(true).create_new(true).open(&held).unwrap(); + // A holder that died eleven seconds ago. + file.set_modified(SystemTime::now() - Duration::from_secs(11)).unwrap(); + drop(file); + + let started = Instant::now(); + let message = disallow(&path, "aaaa").expect("the stale lock is broken"); + assert!(message.contains("aaaa")); + assert!(started.elapsed() < Duration::from_secs(1), "the stale lock was waited on"); + assert!(!held.exists(), "the lock is released after the write"); } #[test] From 3e8321a1d04ea291d658a5b863b245ebc463a70c Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:23 +0200 Subject: [PATCH 31/50] fix(bridge-runtime): open a terminal's recording off the runtime thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Terminals::spawn` opened the asciicast recording itself — create the audit directory, open the file, write and flush the header — and the `Effect::TerminalSpawn` arm called it straight from the bridge's one thread. That thread serves the relays, the engine, the direct links and the web page, so a slow disk there stalls every phone. The file work now runs in a `tokio::task::spawn_blocking` task: the arm computes the recording path, opens the recording there, and only then hands the opened `Cast` to `Terminals::spawn`, which starts the pty. The invariant is kept — no shell runs without its recording, and a recording that cannot be opened is answered as a refusal — because the open is awaited before the pty exists. `Cast` is `Send`, so it crosses the task boundary; opening the pty and forking stay on the runtime thread as before, being fast syscalls. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/runtime.rs | 32 ++++++--- crates/bridge-runtime/src/terminal/mod.rs | 80 ++++++++++++++++++----- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index f31e1c30..eee074f4 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -29,7 +29,7 @@ use crate::config::Config; use crate::host::{self, HostCommand, HostHandle}; use crate::relay::Relays; use crate::state::StateFile; -use crate::terminal::Terminals; +use crate::terminal::{Cast, Terminals}; use crate::terminal_allow::{self, Cache as AllowlistCache}; use crate::transcripts::FileTranscripts; use crate::workspace::FsWorkspace; @@ -323,7 +323,7 @@ impl Runtime { async fn dispatch(&mut self, input: Input) -> bool { let effects = self.engine.handle(input); for effect in effects { - if self.execute(effect) { + if self.execute(effect).await { self.relays.flush().await; self.host.stop().await; self.relays.shutdown(); @@ -339,8 +339,11 @@ impl Runtime { false } - /// Carry out one effect; true for [`Effect::Stopped`]. - fn execute(&mut self, effect: Effect) -> bool { + /// Carry out one effect; true for [`Effect::Stopped`]. Async because one + /// effect does blocking file work that must not run here: opening a + /// terminal's recording, which is handed to a blocking task before its + /// shell is started. + async fn execute(&mut self, effect: Effect) -> bool { match effect { Effect::Publish { to, message } => self.relays.publish(to, message), Effect::Host(frame) => self.host.send(&frame), @@ -414,10 +417,23 @@ impl Runtime { // state a configuration leaves them in — the engine emits none of // these effects, so a bridge without terminals starts no shell. Effect::TerminalSpawn { term, cwd, cols, rows } => { - // `None`: the owner has configured no `terminal.command`, so - // the shell runs. A failure is answered as a refusal, not - // left as a terminal that never appears. - let result = self.terminals.spawn(&term, &cwd, cols, rows, self.config.terminal.command.as_deref()); + // Opening the recording is blocking file work — create the + // audit directory, open the file, write and flush the header — + // and the bridge's one thread must not do it, so it goes to a + // blocking task. It is awaited before the pty is opened: no + // shell ever runs without its recording, and a recording that + // cannot be opened is answered as a refusal, not left as a + // terminal that never appears. `None` is the owner's + // configured `terminal.command`: with none, the shell runs. + let path = self.terminals.recording_path(&term); + let opened = tokio::task::spawn_blocking(move || Cast::open(&path, cols, rows)).await; + let result = match opened { + Ok(Ok(cast)) => { + self.terminals.spawn(&term, &cwd, cols, rows, self.config.terminal.command.as_deref(), cast) + } + Ok(Err(e)) => Err(format!("could not open the recording: {e}")), + Err(e) => Err(format!("could not open the recording: {e}")), + }; let _ = match result { Ok(shell) => self.inputs.send(Input::TerminalSpawned { term, shell }), Err(note) => self.inputs.send(Input::TerminalSpawnFailed { term, note }), diff --git a/crates/bridge-runtime/src/terminal/mod.rs b/crates/bridge-runtime/src/terminal/mod.rs index f44e5a80..37823d33 100644 --- a/crates/bridge-runtime/src/terminal/mod.rs +++ b/crates/bridge-runtime/src/terminal/mod.rs @@ -54,7 +54,7 @@ use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, Pt use protocol::common::TerminalEndReason; use tokio::sync::mpsc; -use cast::Cast; +pub use cast::Cast; use ring::Ring; /// How often a terminal's output is handed to the engine. The engine @@ -154,16 +154,25 @@ impl Terminals { Self { audit_dir, inputs, ptys: HashMap::new() } } + /// Where `term`'s recording goes (`/audit/term-.cast`). + pub fn recording_path(&self, term: &str) -> PathBuf { + self.audit_dir.join(format!("term-{term}.cast")) + } + /// Start a shell for `term` in `cwd`, `cols`×`rows`, and take it under /// this bridge's wing. `command` is the owner's configured /// `terminal.command` (a fixed program such as `tmux new -A -s phone`); /// `None` runs the shell. `Ok` is what runs, as the phone is told it; /// `Err` is why it could not be started, for the audit and the phone. /// - /// The recording is opened before any process exists, and a failure - /// after the shell has started kills it: a shell nothing can reconstruct - /// afterwards, or one left running with no terminal attached to it, is - /// worse than an open that fails. + /// `cast` is `term`'s already-opened recording, from [`Cast::open`] on + /// [`recording_path`](Self::recording_path). Opening it is blocking file + /// work (create the audit directory, open the file, write and flush the + /// header), so the caller does it — off the bridge's one thread — and + /// hands the result in: no process exists until its recording does, and a + /// failure after the shell has started kills it. A shell nothing can + /// reconstruct afterwards, or one left running with no terminal attached + /// to it, is worse than an open that fails. pub fn spawn( &mut self, term: &str, @@ -171,11 +180,10 @@ impl Terminals { cols: u16, rows: u16, command: Option<&[String]>, + cast: Cast, ) -> Result { let (program, args, shell) = shell_command(command); - let cast = Cast::open(&self.audit_dir.join(format!("term-{term}.cast")), cols, rows) - .map_err(|e| format!("could not open the recording: {e}"))?; let ring = Rc::new(RefCell::new(Ring::new())); let mut cmd = CommandBuilder::new(&program); @@ -637,6 +645,23 @@ mod tests { (Terminals::new(dir.path().join("audit"), inputs), rx, dir) } + /// [`Terminals::spawn`] with the recording opened first, the way the + /// runtime does it (there from a blocking task). A test is not on the + /// bridge's thread, so it can open the file here. + #[cfg(unix)] + fn spawn( + terms: &mut Terminals, + term: &str, + cwd: &str, + cols: u16, + rows: u16, + command: Option<&[String]>, + ) -> Result { + let cast = + Cast::open(&terms.recording_path(term), cols, rows).map_err(|e| format!("could not open the recording: {e}"))?; + terms.spawn(term, cwd, cols, rows, command, cast) + } + /// The next `Input::TerminalOutput` from a terminal, or a panic if none /// arrives in time. #[cfg(unix)] @@ -693,7 +718,7 @@ mod tests { .run_until(async { let (mut terms, mut rx, dir) = rig(); let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); - let shell = terms.spawn("t1", &cwd, 80, 24, None).expect("a shell"); + let shell = spawn(&mut terms, "t1", &cwd, 80, 24, None).expect("a shell"); assert!(shell.ends_with("sh"), "the shell is {shell}"); terms.write("t1", b"echo one\n"); @@ -714,6 +739,33 @@ mod tests { .await; } + /// The invariant the runtime relies on: by the time `spawn` returns (and + /// so by the time any shell could write anything), the recording exists + /// with its header already flushed. + #[cfg(unix)] + #[tokio::test] + async fn a_spawned_terminal_has_its_recording_with_a_header_before_any_output() { + let _guard = env_lock(); + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (mut terms, _rx, dir) = rig(); + let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); + spawn(&mut terms, "t7", &cwd, 80, 24, None).expect("a shell"); + + let cast = std::fs::read_to_string(dir.path().join("audit").join("term-t7.cast")) + .expect("the recording exists as soon as the shell does"); + let header: serde_json::Value = + serde_json::from_str(cast.lines().next().expect("a header line")).expect("a JSON header"); + assert_eq!(header["version"], 2); + assert_eq!(header["width"], 80); + assert_eq!(header["height"], 24); + + terms.close("t7"); + }) + .await; + } + #[cfg(unix)] #[tokio::test] async fn the_shell_gets_a_built_environment_and_nothing_of_the_bridges() { @@ -727,7 +779,7 @@ mod tests { // provider key the user exported for the agents. It must not // reach a shell that runs as the bridge's user. std::env::set_var("CODEDECK_TEST_SECRET", "must-not-leak"); - terms.spawn("t2", &cwd, 80, 24, Some(&["env".to_string()])).expect("env runs"); + spawn(&mut terms, "t2", &cwd, 80, 24, Some(&["env".to_string()])).expect("env runs"); let (_, out) = collect_until(&mut rx, "PATH=").await; std::env::remove_var("CODEDECK_TEST_SECRET"); @@ -757,7 +809,7 @@ mod tests { .run_until(async { let (mut terms, mut rx, dir) = rig(); let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); - terms.spawn("t3", &cwd, 80, 24, None).expect("a shell"); + spawn(&mut terms, "t3", &cwd, 80, 24, None).expect("a shell"); // The shell turns its own echo off, the way a password prompt // does, and says so once it has. The marker is split in the @@ -789,7 +841,7 @@ mod tests { .run_until(async { let (mut terms, mut rx, dir) = rig(); let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); - terms.spawn("t4", &cwd, 80, 24, None).expect("a shell"); + spawn(&mut terms, "t4", &cwd, 80, 24, None).expect("a shell"); terms.write("t4", b"echo bye; exit 7\n"); let deadline = tokio::time::Instant::now() + Duration::from_secs(20); @@ -828,9 +880,7 @@ mod tests { .run_until(async { let (mut terms, _rx, dir) = rig(); let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); - terms - .spawn("t5", &cwd, 80, 24, Some(&["sleep".to_string(), "30".to_string()])) - .expect("sleep runs"); + spawn(&mut terms, "t5", &cwd, 80, 24, Some(&["sleep".to_string(), "30".to_string()])).expect("sleep runs"); // Far more than any pty's input buffer: written to the pty // directly, this would block once the buffer filled. let big = vec![b'x'; 1024 * 1024]; @@ -855,7 +905,7 @@ mod tests { .run_until(async { let (mut terms, mut rx, dir) = rig(); let cwd = dir.path().to_str().expect("a utf-8 temp path").to_string(); - terms.spawn("t6", &cwd, 80, 24, None).expect("a shell"); + spawn(&mut terms, "t6", &cwd, 80, 24, None).expect("a shell"); let started = tokio::time::Instant::now(); terms.write("t6", b"yes | head -c 1000000\n"); From 466370ef509a87bd89a4a56826fd61c5f57c6b94 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:32 +0200 Subject: [PATCH 32/50] fix(bridge-runtime): audit verify prints the head hash, the chain's blind spot made checkable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash chain catches an edited or deleted line in the middle, because the line after it disagrees. It cannot catch an edit or a deletion of the *last* line, nor a truncation: nothing follows them, and a chain with no external anchor cannot say. The docs called this an honest limit but left it unusable — an owner had nothing to compare against. `verify` now returns the line count and the head, the SHA-256 of the last line, and `audit verify` prints it: an owner compares it with the syslog copy (`audit.syslog: true`) or with a value noted earlier, and a change shows. An empty log still prints "empty" and exits 0. The honest-limit sentence in TERMINAL.md and BRIDGE.md now states which tampering the chain catches and which needs the syslog copy or a noted head hash. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/audit.rs | 73 ++++++++++++++++++++++++------ crates/bridge-runtime/src/main.rs | 17 ++++--- docs/BRIDGE.md | 16 ++++--- docs/TERMINAL.md | 12 +++-- 4 files changed, 86 insertions(+), 32 deletions(-) diff --git a/crates/bridge-runtime/src/audit.rs b/crates/bridge-runtime/src/audit.rs index c3cf3600..aa652ccd 100644 --- a/crates/bridge-runtime/src/audit.rs +++ b/crates/bridge-runtime/src/audit.rs @@ -9,7 +9,9 @@ //! the next line, which `codedeck-bridge audit verify` finds. The honest //! limit, the same one [`docs/TERMINAL.md`](../../../docs/TERMINAL.md) states: //! anyone running as the bridge's user can rewrite the whole file, chain and -//! all; the chain catches tampering that is not that thorough, and +//! all; the chain catches an edit or a deletion in the middle, while the +//! *last* line and a truncation have no line after them to disagree, so they +//! need the syslog copy or a head hash the owner noted (`verify` prints it). //! `audit.syslog: true` also copies each line to the system log, which //! survives even that. //! @@ -202,14 +204,30 @@ fn last_line_hash(log: &Path) -> Result { Ok(prev) } -/// Walk the log and recompute the chain. `Ok(n)` is the number of lines; -/// `Err` names the first line whose `prev` does not match the line before it -/// (1-based) — the line after an edited or deleted one, which is where the -/// break shows. -pub fn verify(log: &Path) -> Result { +/// What [`verify`] found when the chain is intact: how many lines, and the +/// head — the SHA-256 of the last line, the chain's tip. The head is what +/// makes the chain's blind spot checkable: the chain alone cannot see an edit +/// or a deletion of the *last* line, nor a truncation, because nothing follows +/// them to disagree. Printing the head lets an owner compare it with the +/// syslog copy, or with a value noted earlier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verified { + /// The number of lines in the log. + pub lines: u64, + /// The hash of the last line, or `None` when the log is empty. + pub head: Option, +} + +/// Walk the log and recompute the chain. `Ok(Verified)` is the line count and +/// the head hash when the chain is intact; `Err` names the first line whose +/// `prev` does not match the line before it (1-based) — the line after an +/// edited or deleted one, which is where the break shows. The last line and a +/// truncation cannot be caught here: compare the returned [`Verified::head`] +/// with the syslog copy or a value noted earlier. +pub fn verify(log: &Path) -> Result { let file = match File::open(log) { Ok(file) => file, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Verified { lines: 0, head: None }), Err(e) => return Err(format!("cannot read {}: {e}", log.display())), }; let mut prev = GENESIS.to_string(); @@ -229,7 +247,8 @@ pub fn verify(log: &Path) -> Result { } prev = hash_hex(line.as_bytes()); } - Ok(n) + // `prev` is the last line's hash (or GENESIS with no lines at all). + Ok(Verified { lines: n, head: (n > 0).then_some(prev) }) } /// Remove the oldest whole recordings under `dir` that are older than @@ -319,8 +338,12 @@ mod tests { drop(audit); // joins the writer thread, so the file is complete let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); - assert_eq!(verify(&log), Ok(3)); + let verified = verify(&log).expect("an intact chain"); + assert_eq!(verified.lines, 3); let lines = lines(&log); + // The head is the hash of the last line: what an owner notes down to + // check against later, and what catches an edit of the last line. + assert_eq!(verified.head.as_deref(), Some(hash_hex(lines[2].as_bytes()).as_str())); // Each line's prev is the hash of the one before it; the first is GENESIS. let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap(); assert_eq!(value["prev"], GENESIS); @@ -346,7 +369,7 @@ mod tests { // If the second run had started from GENESIS instead of the last // line's hash, line 3's prev would not match and this would fail. - assert_eq!(verify(&log), Ok(3)); + assert_eq!(verify(&log).map(|v| v.lines), Ok(3)); } #[test] @@ -386,11 +409,33 @@ mod tests { } #[test] - fn an_empty_or_missing_log_verifies_as_zero() { + fn an_empty_or_missing_log_verifies_as_empty() { let dir = tempfile::tempdir().unwrap(); - assert_eq!(verify(&dir.path().join("nope.jsonl")), Ok(0)); + assert_eq!(verify(&dir.path().join("nope.jsonl")), Ok(Verified { lines: 0, head: None })); fs::write(dir.path().join("empty.jsonl"), "").unwrap(); - assert_eq!(verify(&dir.path().join("empty.jsonl")), Ok(0)); + assert_eq!(verify(&dir.path().join("empty.jsonl")), Ok(Verified { lines: 0, head: None })); + } + + /// The last line's own edit cannot be caught by the chain — nothing + /// follows it — but the head hash it changes can be compared with a noted + /// value. This is the honest limit the docs state. + #[test] + fn an_edited_last_line_leaves_the_chain_intact_but_moves_the_head() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join(AUDIT_DIR).join(LOG_FILE); + let audit = Audit::open(dir.path(), false, Retention { max_days: 90, max_bytes: 1 << 30 }); + audit.record(record(1, "t1")); + audit.record(record(2, "t2")); + drop(audit); + + let head = verify(&log).unwrap().head; + let mut lines = lines(&log); + lines[1] = lines[1].replace("\"t2\"", "\"tX\""); + fs::write(&log, lines.join("\n") + "\n").unwrap(); + + // Still "intact" — the point of printing the head. + assert_eq!(verify(&log).map(|v| v.lines), Ok(2)); + assert_ne!(verify(&log).unwrap().head, head, "the head hash moved, so a noted value would not match"); } #[test] @@ -401,7 +446,7 @@ mod tests { let audit = Audit::open(dir.path(), true, Retention { max_days: 90, max_bytes: 1 << 30 }); audit.record(record(1, "t1")); drop(audit); - assert_eq!(verify(&dir.path().join(AUDIT_DIR).join(LOG_FILE)), Ok(1)); + assert_eq!(verify(&dir.path().join(AUDIT_DIR).join(LOG_FILE)).map(|v| v.lines), Ok(1)); } fn cast(dir: &Path, name: &str, bytes: usize, age_days: u64) { diff --git a/crates/bridge-runtime/src/main.rs b/crates/bridge-runtime/src/main.rs index f4658fdf..2aad279b 100644 --- a/crates/bridge-runtime/src/main.rs +++ b/crates/bridge-runtime/src/main.rs @@ -123,7 +123,7 @@ enum TerminalCommand { #[derive(Subcommand)] enum AuditCommand { - /// Check the terminal audit's hash chain, printing the line count or the first break + /// Check the terminal audit's hash chain, printing the line count and head hash, or the first break Verify, } @@ -232,19 +232,24 @@ fn terminal(config: &Config, action: TerminalCommand) -> Result Result { match action { AuditCommand::Verify => { let path = config.home.join(audit::AUDIT_DIR).join(audit::LOG_FILE); match audit::verify(&path) { - Ok(0) => { + Ok(v) if v.lines == 0 => { println!("The terminal audit is empty ({}).", path.display()); Ok(ExitCode::SUCCESS) } - Ok(lines) => { - println!("OK: {lines} line(s), the chain is intact ({}).", path.display()); + Ok(v) => { + println!("OK: {} line(s), the chain is intact ({}).", v.lines, path.display()); + println!("Head: {} (the last line's SHA-256)", v.head.unwrap_or_default()); Ok(ExitCode::SUCCESS) } Err(err) => { diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 31530253..c45c2792 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -136,7 +136,7 @@ install runbook. | `agents install …` / `agents remove …` | Install agents at the version this build pins, or remove what CodeDeck installed of them. A running bridge sees the change once its agent host restarts; the phone's Install and Remove take effect at once. | | `terminal pending` | The devices that asked for a terminal and were refused, with their id, name and when they were last seen. | | `terminal allow ` / `terminal disallow ` | Approve a device for a terminal, or withdraw it (an unambiguous id prefix works). These edit the allowlist file directly, so they work while the bridge runs; a withdrawn device's terminals close at once. | -| `audit verify` | Recompute the terminal audit's hash chain; prints the line count, or the first broken line and exits non-zero. | +| `audit verify` | Recompute the terminal audit's hash chain; prints the line count and the head hash (compare it with the syslog copy or a value noted earlier), or the first broken line and exits non-zero. | | `version` | The bridge version and protocol version. | `--test-mode` (or `CODEDECK_TEST_MODE=1`) makes the agents answer canned @@ -258,12 +258,14 @@ lost phone. resized, closed, exited, refused), each with the device, identity, transport and reason. Every line carries `prev`, the SHA-256 of the line before it (the first line's is all zeros), so an edited or deleted line breaks the chain: - `codedeck-bridge audit verify` recomputes it and prints the line count, or - the first broken line, exiting non-zero. The honest limit: anyone running as - the bridge's user can rewrite the whole file, chain and all. The chain - catches tampering that is not that thorough; `audit.syslog: true` also - copies each line to the log target `audit` (journald / rsyslog, if a handler - forwards it), which survives even that. + `codedeck-bridge audit verify` recomputes it and prints the line count and + the head (the last line's SHA-256), or the first broken line, exiting + non-zero. The chain catches an edit or a deletion in the middle; the last + line and a truncation need the syslog copy or a noted head hash. The honest + limit: anyone running as the bridge's user can rewrite the whole file, chain + and all. The chain catches tampering that is not that thorough; + `audit.syslog: true` also copies each line to the log target `audit` + (journald / rsyslog, if a handler forwards it), which survives even that. - `term-.cast` — a full recording of each terminal in asciicast v2 (replayable with `asciinema play`). Input typed at a password prompt is recorded as a byte count, never the bytes. diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 34006e05..528e8b0f 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -210,11 +210,13 @@ state file), never sent anywhere: attached, takeover, resized, closed, exited (reason, code, duration), and refused (why: disabled, not-allowed, limit, transport, bad seq). Each line carries `prev`, the SHA-256 of the line before it, so an edit or a deleted - line breaks the chain (`codedeck-bridge audit verify` checks it). The honest - limit: anyone running as the bridge's user can rewrite the whole file; the - chain catches tampering that is not that thorough, and `audit.syslog: true` - also copies each line to the system log (journald / syslog), which survives - even that. + line breaks the chain (`codedeck-bridge audit verify` checks it and prints + the head — the last line's SHA-256). The chain catches an edit or a deletion + in the middle; the last line and a truncation need the syslog copy or a noted + head hash. The honest limit: anyone running as the bridge's user can rewrite + the whole file; the chain catches tampering that is not that thorough, and + `audit.syslog: true` also copies each line to the system log (journald / + syslog), which survives even that. - **`term-.cast`** — a full recording of each terminal in asciicast v2 (replayable with `asciinema play`): every output frame, and every input chunk with the device that typed it. **Input typed while the terminal has From 7ce5670eea65c34adb17b49e58f1293677672875 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:33 +0200 Subject: [PATCH 33/50] fix(bridge-runtime): the terminal config comment matches what the code does The comment claimed the flag and the environment variable "can only switch terminals on". The flag can, but `CODEDECK_TERMINAL` is parsed as a boolean and overrides the file in either direction, so `CODEDECK_TERMINAL=false` switches terminals off even with `terminal.enabled: true` in config.json. Say what the code does. Co-Authored-By: Claude Code --- crates/bridge-runtime/src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bridge-runtime/src/config.rs b/crates/bridge-runtime/src/config.rs index ed6ff654..301c8ddc 100644 --- a/crates/bridge-runtime/src/config.rs +++ b/crates/bridge-runtime/src/config.rs @@ -236,7 +236,8 @@ impl Default for AuditSettings { fn terminal_settings(flags: &Flags, file: Option) -> TerminalSettings { let file = file.unwrap_or_default(); // Flags win, then the environment, then the file, then the default. The - // flag and the environment variable can only switch terminals on. + // `--terminal` flag can only switch terminals on; `CODEDECK_TERMINAL` + // carries a boolean, so it overrides the file in either direction. let enabled = if flags.terminal { true } else if let Some(v) = env_bool("CODEDECK_TERMINAL") { From a2673ed27340c203aadb32898ff19b87396ef888 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:48:37 +0200 Subject: [PATCH 34/50] feat(client-runtime): send the phone's device name on every session-key grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grant's `label` is what the bridge shows a person in its terminal allowlist (`codedeck-bridge terminal pending`) and in a refusal to open a terminal. The phone always sent `None`, so the bridge could name a device only by its id. `CoreConfig` gains `device_label`, sanitized once at construction (control characters stripped, trimmed, capped at 64 characters, empty becomes `None`) and carried on `PhoneKeys`, so `grant_for` stays pure and every grant — the first, a rotation or a re-grant — sends the same name. The bridge-runtime end-to-end test follows the new `CoreConfig::new` argument. Co-Authored-By: Claude Code --- crates/bridge-runtime/tests/e2e.rs | 4 +- crates/client-runtime/src/dispatch.rs | 4 +- crates/client-runtime/src/intent.rs | 2 +- crates/client-runtime/src/runtime.rs | 26 +++++++-- crates/client-runtime/src/runtime/tests.rs | 52 +++++++++++++++++ crates/client-runtime/src/signer.rs | 65 ++++++++++++++++++++-- 6 files changed, 140 insertions(+), 13 deletions(-) diff --git a/crates/bridge-runtime/tests/e2e.rs b/crates/bridge-runtime/tests/e2e.rs index 0a95836b..fce6d835 100644 --- a/crates/bridge-runtime/tests/e2e.rs +++ b/crates/bridge-runtime/tests/e2e.rs @@ -214,7 +214,7 @@ async fn a_phone_drives_the_real_bridge_through_a_relay() { // --- the phone --- let store = Rc::new(MemoryTranscriptStore::new()); let core = Core::spawn( - CoreConfig::new(Rc::new(LocalSigner(generate_keypair())), None, false), + CoreConfig::new(Rc::new(LocalSigner(generate_keypair())), None, false, None), CorePorts { transcript_store: store.clone(), ..CorePorts::default() }, Rc::new(Observer), Rc::new(SystemClock), @@ -424,7 +424,7 @@ async fn a_phone_opens_a_terminal_and_types_through_the_bridge() { let store = Rc::new(MemoryTranscriptStore::new()); let observer = Rc::new(TerminalObserver::default()); let core = Core::spawn( - CoreConfig::new(Rc::new(LocalSigner(phone.clone())), None, false), + CoreConfig::new(Rc::new(LocalSigner(phone.clone())), None, false, None), CorePorts { kv: kv.clone(), transcript_store: store.clone(), ..CorePorts::default() }, observer.clone(), Rc::new(SystemClock), diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index 20104116..d5fcdd56 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -1042,7 +1042,7 @@ mod tests { let h = hydrate(&kv, &ts, &StoresConfig::default()).await; let identity = protocol::crypto::generate_keypair(); let (ring, _) = client_core::stores::session_key::SessionKeyRing::load(None, 1_000); - (h.stores, ts, PhoneKeys::new(&identity.pubkey_hex, &ring.current)) + (h.stores, ts, PhoneKeys::new(&identity.pubkey_hex, &ring.current, None)) } fn text_entry(content: &str) -> OutputEntry { @@ -1181,7 +1181,7 @@ mod tests { let ts = MemoryTranscriptStore::new(); let identity = protocol::crypto::generate_keypair(); let (ring, _) = client_core::stores::session_key::SessionKeyRing::load(None, 1_000); - let kp = PhoneKeys::new(&identity.pubkey_hex, &ring.current); + let kp = PhoneKeys::new(&identity.pubkey_hex, &ring.current, None); // Deleted (and acked: no rows left), then the app was killed. let mut before = hydrate(&kv, &ts, &StoresConfig::default()).await.stores; diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index bdd847e7..39cf43ed 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -1215,7 +1215,7 @@ mod tests { let h = hydrate(&kv, &ts, &StoresConfig::default()).await; let identity = protocol::crypto::generate_keypair(); let (ring, _) = client_core::stores::session_key::SessionKeyRing::load(None, 1_000); - (h.stores, PhoneKeys::new(&identity.pubkey_hex, &ring.current)) + (h.stores, PhoneKeys::new(&identity.pubkey_hex, &ring.current, None)) } fn ctx() -> IntentCtx { diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index 06d3f813..2c3284b7 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -49,7 +49,7 @@ use crate::nostr_client::{NostrClient, NostrClientHost, NostrEvent}; use crate::ports::{ Kv, KvSessionKeyStore, MemoryKv, MemoryTranscriptStore, NullNotifier, Notifier, SessionKeyStore, TranscriptStore, }; -use crate::signer::{Cipher, IdentityAuth, IdentitySigner, PhoneKeys, SignerError}; +use crate::signer::{sanitize_device_label, Cipher, IdentityAuth, IdentitySigner, PhoneKeys, SignerError}; use crate::stores::{hydrate, CoreStores, Persister, StoresConfig}; use client_core::stores::backup::ConfigBackup; use crate::transport::ws::{WsConfig, WsTransport, PING_EVERY, PUBLISH_CONFIRM_ATTEMPTS, PUBLISH_CONFIRM_BUDGET}; @@ -281,6 +281,14 @@ pub struct CoreConfig { /// regardless, so `Intent::SetTorEnabled` can toggle between `Some` and /// `None` without needing the phone to resend the address. pub tor: bool, + /// The phone's own name, sent on every session-key grant as + /// `SessionKeyGrant::label`: what a person reads in the bridge's terminal + /// allowlist (`codedeck-bridge terminal pending`) and in a refusal to open + /// a terminal. Sanitized by [`sanitize_device_label`] — at most + /// [`crate::signer::DEVICE_LABEL_MAX`] characters, control characters + /// stripped, trimmed, and empty becomes `None`. Display only: the bridge + /// trusts the device id derived from the first key, never this name. + pub device_label: Option, /// Backoff / stale-window timing. [`CoreConfig::new`] picks the Tor variant /// when `tor` is set; tests override directly. pub reconnect: ReconnectConfig, @@ -315,11 +323,20 @@ impl Default for CorePorts { } impl CoreConfig { - pub fn new(identity: Rc, proxy: Option, tor: bool) -> Self { + /// `device_label` is the phone's name for the bridge (see the field doc); + /// it is sanitized here, so an empty or whitespace-only name is stored as + /// `None` and a long one is capped before it ever reaches a grant. + pub fn new( + identity: Rc, + proxy: Option, + tor: bool, + device_label: Option, + ) -> Self { Self { identity, proxy, tor, + device_label: device_label.as_deref().and_then(sanitize_device_label), reconnect: if tor { TOR_RECONNECT_CONFIG } else { @@ -374,7 +391,7 @@ impl Core { if changed { key_store.save(&session.encode()).await; } - let keys = PhoneKeys::new(&config.identity.pubkey_hex(), &session.current); + let keys = PhoneKeys::new(&config.identity.pubkey_hex(), &session.current, config.device_label.clone()); // A machine paired in a PRIOR run is already in `hydrated.stores.machines` // — nothing about a plain (re)connect ever calls `refresh_authors()` for @@ -1645,7 +1662,8 @@ impl Loop { pub(super) async fn session_ring_changed(&mut self) { self.key_store.save(&self.session.encode()).await; if self.keys.session_pubkey_hex != self.session.current.pubkey_hex() { - self.keys = PhoneKeys::new(&self.keys.identity_pubkey_hex, &self.session.current); + self.keys = + PhoneKeys::new(&self.keys.identity_pubkey_hex, &self.session.current, self.keys.device_label.clone()); // The old key's grant attempts say nothing about the new one. self.grant_attempts.clear(); } diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index 0437ebdf..759aa167 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -79,6 +79,7 @@ async fn core_for_ports( identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, ports, @@ -218,6 +219,7 @@ async fn spawn_applies_the_boot_time_proxy_to_the_http_port_when_tor_is_on() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: Some("127.0.0.1:9050".to_string()), tor: true, + device_label: None, reconnect: fast_reconnect(), }, ports, @@ -254,6 +256,7 @@ async fn spawn_leaves_the_http_port_direct_when_tor_is_off() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: Some("127.0.0.1:9050".to_string()), tor: false, + device_label: None, reconnect: fast_reconnect(), }, ports, @@ -363,6 +366,7 @@ async fn a_relay_dying_while_another_survives_is_reported_at_once() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts::default(), @@ -1021,6 +1025,7 @@ async fn editing_a_machines_relays_repoints_the_transport() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, @@ -1638,6 +1643,7 @@ async fn a_phone_with_no_relays_pairs_over_the_relays_its_pairing_names() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts::default(), @@ -2141,6 +2147,7 @@ async fn restored_core(mock: &MockRelay, phone: &Keypair, machine: &Keypair, spy identity: Rc::new(DecryptCounter(crate::signer::LocalSigner(phone.clone()), Rc::clone(&decrypts))), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, @@ -2153,6 +2160,47 @@ async fn restored_core(mock: &MockRelay, phone: &Keypair, machine: &Keypair, spy (core, decrypts) } +/// Every grant carries the phone's configured device name — what the bridge +/// shows in its terminal allowlist and in a refusal — and none when the phone +/// has no name to give. `CoreConfig::new` sanitizes, so a whitespace-only +/// setting is stored (and granted) as no name rather than a blank one. +#[tokio::test] +async fn every_grant_carries_the_configured_device_label() { + LocalSet::new() + .run_until(async { + for (configured, expected) in [ + (Some("Pixel 8a".to_string()), Some("Pixel 8a")), + (Some(" ".to_string()), None), + (None, None), + ] { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let spy = Rc::new(Spy::default()); + let mut state = client_core::stores::machines::MachinesState::default(); + state.register_machine(&machine.pubkey_hex, "bridge", None, None, &[]); + let kv = MemoryKv::seeded([( + crate::stores::MACHINES_KEY, + client_core::stores::machines::serialize_machines(&state.machines), + )]); + let core = Core::spawn( + CoreConfig::new(Rc::new(crate::signer::LocalSigner(phone.clone())), None, false, configured.clone()), + CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, + spy, + Rc::new(FixedClock(RefCell::new(1_000_000))), + Rc::new(ZeroEntropy), + ) + .await; + core.set_relays(vec![mock.url.clone()]); + core.start(); + eose_all(&mut mock).await; + let grant = expect_grant(&mut mock, &phone, &machine).await; + assert_eq!(grant.label.as_deref(), expected, "configured {configured:?}"); + } + }) + .await; +} + /// A restored device costs the identity's signer no decrypt: what the /// bridge wrote to the identity before the grant is skipped, what it /// writes while the grant travels is dropped once the key is confirmed, @@ -2314,6 +2362,7 @@ async fn a_quiet_client_renews_its_presence_and_says_when_it_goes_away() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), session_keys: Some(Rc::new(store.clone())), ..CorePorts::default() }, @@ -2368,6 +2417,7 @@ async fn a_machine_settings_change_nobody_answers_stops_being_busy_at_the_deadli identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, @@ -2495,6 +2545,7 @@ async fn a_resume_refreshes_only_machines_not_heard_from_lately() { identity: Rc::new(crate::signer::LocalSigner(phone.clone())), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, @@ -2682,6 +2733,7 @@ async fn a_burst_of_sync_chunks_costs_one_signed_ack() { identity: Rc::new(CountingSigner(crate::signer::LocalSigner(phone.clone()), Rc::clone(&signs))), proxy: None, tor: false, + device_label: None, reconnect: fast_reconnect(), }, CorePorts { kv: Rc::new(kv), ..CorePorts::default() }, diff --git a/crates/client-runtime/src/signer.rs b/crates/client-runtime/src/signer.rs index b3d95e82..31b855c1 100644 --- a/crates/client-runtime/src/signer.rs +++ b/crates/client-runtime/src/signer.rs @@ -79,7 +79,27 @@ impl IdentitySigner for LocalSigner { } } -/// The public halves of the phone's keys — what the wire needs to name them. +/// The most characters a device label may carry. The bridge prints it in its +/// terminal allowlist and in a refusal, so a longer one would only crowd the +/// line it is shown on. +pub const DEVICE_LABEL_MAX: usize = 64; + +/// Clean a device name for the wire: control characters removed (the name is +/// shown on a single line, and it comes from the platform's own model string), +/// then trimmed, then capped at [`DEVICE_LABEL_MAX`] characters. A name that +/// is empty or whitespace-only becomes `None` — the bridge then shows the +/// device by its id alone rather than a blank name. +pub fn sanitize_device_label(raw: &str) -> Option { + let stripped: String = raw.chars().filter(|c| !c.is_control()).collect(); + let trimmed = stripped.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(DEVICE_LABEL_MAX).collect()) +} + +/// The public halves of the phone's keys — what the wire needs to name them — +/// and the device name every grant carries. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PhoneKeys { pub identity_pubkey_hex: String, @@ -88,29 +108,39 @@ pub struct PhoneKeys { pub session_pubkey_hex: String, /// When it, and every grant of it, lapses (seconds). pub session_expires_at: u64, + /// The device's name as the bridge shows it in its terminal allowlist and + /// in a refusal, sent on every grant. Sanitized by + /// [`sanitize_device_label`]; `None` when the phone has no name to give. + pub device_label: Option, } impl PhoneKeys { - pub fn new(identity_pubkey_hex: &str, session: &SessionKey) -> Self { + /// `device_label` is the phone's name for the bridge (see + /// [`sanitize_device_label`]); it is stored as given, so callers pass it + /// already sanitized. + pub fn new(identity_pubkey_hex: &str, session: &SessionKey, device_label: Option) -> Self { Self { identity_pubkey_hex: identity_pubkey_hex.to_string(), identity_npub: protocol::crypto::npub_from_hex(identity_pubkey_hex).unwrap_or_default(), session_pubkey_hex: session.pubkey_hex().to_string(), session_expires_at: session.expires_at, + device_label, } } /// A grant of the current session key to `bridge`, for the wire. /// `replaces` is the key the bridge confirmed before, if another: the /// bridge then moves this client to the new key rather than counting a - /// new client. + /// new client. Every grant carries the device's label, so the bridge can + /// name the device in its allowlist and in a refusal whether this is the + /// first grant, a rotation or a re-grant. pub fn grant_for(&self, bridge: &str, replaces: Option<&str>) -> SessionKeyGrant { SessionKeyGrant { pubkey_hex: self.session_pubkey_hex.clone(), bridge_pubkey_hex: bridge.to_string(), expires_at: self.session_expires_at, replaces: replaces.filter(|old| *old != self.session_pubkey_hex).map(str::to_string), - label: None, + label: self.device_label.clone(), } } @@ -270,6 +300,33 @@ mod tests { assert!(ev.tags.iter().any(|t| t[0] == "challenge" && t[1] == "chal")); } + #[test] + fn a_device_label_is_sanitized_for_the_wire() { + assert_eq!(sanitize_device_label("Pixel 8a").as_deref(), Some("Pixel 8a")); + // Trimmed, and control characters (a newline or a tab, say) dropped: + // the bridge prints the name on one line. + assert_eq!(sanitize_device_label(" Pixel 8a\n").as_deref(), Some("Pixel 8a")); + assert_eq!(sanitize_device_label("Pix\u{7}el 8a").as_deref(), Some("Pixel 8a")); + // Nothing left to name the device. + assert_eq!(sanitize_device_label(" "), None); + assert_eq!(sanitize_device_label(""), None); + // Capped, by characters, at the length the bridge's line allows. + let long = "x".repeat(DEVICE_LABEL_MAX + 16); + assert_eq!(sanitize_device_label(&long).unwrap().chars().count(), DEVICE_LABEL_MAX); + } + + #[test] + fn a_grant_carries_the_device_label() { + let session = SessionKey { keypair: generate_keypair(), expires_at: 1_800_000_000 }; + let named = PhoneKeys::new("ab", &session, Some("Pixel 8a".to_string())); + assert_eq!(named.grant_for("cd", None).label.as_deref(), Some("Pixel 8a")); + // The rotation and re-grant paths go through the same `grant_for`. + assert_eq!(named.grant_for("cd", Some("ee")).label.as_deref(), Some("Pixel 8a")); + // A phone with no name to give sends none, not an empty string. + let unnamed = PhoneKeys::new("ab", &session, None); + assert_eq!(unnamed.grant_for("cd", None).label, None); + } + #[test] fn signed_event_json_round_trips() { let k = generate_keypair(); From 4637f26ca47a10c747f9000266796940a703d62f Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:48:45 +0200 Subject: [PATCH 35/50] feat(client-ffi): take the device label on the core constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android app knows the phone's name at start-up; the core needs it to put it on every session-key grant. Passed alongside `proxy`/`tor`, an empty string meaning no name — the core sanitizes and caps it. The Kotlin bindings are regenerated for the new constructor argument. Co-Authored-By: Claude Code --- .../app/src/main/java/uniffi/client_ffi/client_ffi.kt | 9 +++++---- crates/client-ffi/src/lib.rs | 11 ++++++++++- crates/client-ffi/tests/real_core_over_ffi.rs | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 6cc562d1..55bd8691 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -967,7 +967,7 @@ internal object UniffiLib { ): Long external fun uniffi_client_ffi_fn_free_core(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit - external fun uniffi_client_ffi_fn_constructor_core_new(`identity`: Long,`sessionKeys`: RustBuffer.ByValue,`listener`: Long,`notifier`: Long,`http`: RustBuffer.ByValue,`dbPath`: RustBuffer.ByValue,`proxy`: RustBuffer.ByValue,`tor`: Byte,uniffi_out_err: UniffiRustCallStatus, + external fun uniffi_client_ffi_fn_constructor_core_new(`identity`: Long,`sessionKeys`: RustBuffer.ByValue,`listener`: Long,`notifier`: Long,`http`: RustBuffer.ByValue,`dbPath`: RustBuffer.ByValue,`proxy`: RustBuffer.ByValue,`tor`: Byte,`deviceLabel`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_client_ffi_fn_method_core_connection_view(`ptr`: Long, ): Long @@ -1333,7 +1333,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_client_ffi_checksum_method_uniffisessionkeystore_save() and 0xFFFF) != 1939) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_client_ffi_checksum_constructor_core_new() and 0xFFFF) != 39996) { + if ((lib.uniffi_client_ffi_checksum_constructor_core_new() and 0xFFFF) != 27654) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } } @@ -1997,7 +1997,7 @@ open class Core: Disposable, AutoCloseable, CoreInterface * Hydration reads the whole local database, so this can take seconds on * a slow device: never call it on a UI or service main thread. */ - constructor(`identity`: UniffiIdentitySigner, `sessionKeys`: UniffiSessionKeyStore?, `listener`: CoreListener, `notifier`: UniffiNotifier, `http`: UniffiHttpFetch?, `dbPath`: kotlin.String, `proxy`: kotlin.String?, `tor`: kotlin.Boolean) : + constructor(`identity`: UniffiIdentitySigner, `sessionKeys`: UniffiSessionKeyStore?, `listener`: CoreListener, `notifier`: UniffiNotifier, `http`: UniffiHttpFetch?, `dbPath`: kotlin.String, `proxy`: kotlin.String?, `tor`: kotlin.Boolean, `deviceLabel`: kotlin.String) : this(UniffiWithHandle, uniffiRustCallWithError(CoreInitException) { _status -> UniffiLib.uniffi_client_ffi_fn_constructor_core_new( @@ -2010,7 +2010,8 @@ open class Core: Disposable, AutoCloseable, CoreInterface FfiConverterOptionalTypeUniffiHttpFetch.lower(`http`), FfiConverterString.lower(`dbPath`), FfiConverterOptionalString.lower(`proxy`), - FfiConverterBoolean.lower(`tor`),_status) + FfiConverterBoolean.lower(`tor`), + FfiConverterString.lower(`deviceLabel`),_status) } ) diff --git a/crates/client-ffi/src/lib.rs b/crates/client-ffi/src/lib.rs index b085e2ec..155b21b2 100644 --- a/crates/client-ffi/src/lib.rs +++ b/crates/client-ffi/src/lib.rs @@ -287,6 +287,12 @@ impl Core { // before Settings has ever been touched. proxy: Option, tor: bool, + // This phone's name for the bridge (`Build.MODEL`, prefixed with the + // manufacturer when it needs one) — sent on every session-key grant, + // shown in the bridge's terminal allowlist and in a refusal. Empty + // becomes no name; the core sanitizes and caps it (see + // `CoreConfig::device_label`). + device_label: String, ) -> Result, CoreInitError> { android_log::install(); log::info!("Core::new: tor={tor} proxy={proxy:?} db_path={db_path}"); @@ -311,7 +317,7 @@ impl Core { let clock: Rc = Rc::new(SystemClock); let entropy: Rc = Rc::new(TimeEntropy); let signer = Rc::new(signer::SignerAdapter::new(identity, identity_pubkey)); - let config = CoreConfig::new(signer, proxy, tor); + let config = CoreConfig::new(signer, proxy, tor, Some(device_label)); let conn = match open_native_db(&db_path) { Ok(conn) => Rc::new(RefCell::new(conn)), @@ -554,6 +560,7 @@ mod tests { db_path, None, false, + String::new(), ) .expect("core spawns"); let expected = protocol::crypto::keypair_from_secret_hex(SEC_PHONE).unwrap(); @@ -581,6 +588,7 @@ mod tests { db_path, None, false, + String::new(), ) }; let core = spawn(db_path.clone()).expect("core spawns"); @@ -609,6 +617,7 @@ mod tests { db_path.clone(), None, false, + String::new(), ) .expect("core spawns"); assert!(!persisted_tor_proxy_enabled(db_path.clone())); diff --git a/crates/client-ffi/tests/real_core_over_ffi.rs b/crates/client-ffi/tests/real_core_over_ffi.rs index 0784209d..f1e2bd5d 100644 --- a/crates/client-ffi/tests/real_core_over_ffi.rs +++ b/crates/client-ffi/tests/real_core_over_ffi.rs @@ -70,6 +70,7 @@ fn spawn_dispatch_observe_and_shutdown_all_work_over_the_real_ffi_surface() { db_path, None, false, + String::new(), ) .expect("core spawns"); @@ -126,6 +127,7 @@ fn dropping_an_in_flight_dispatch_future_does_not_lose_the_intent() { db_path, None, false, + String::new(), ) .expect("core spawns"), ); From c5af51c322ca3867734612060ece4f38b7e85fd7 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:48:53 +0200 Subject: [PATCH 36/50] feat(android): name this phone on the session-key grant The core constructor now takes the phone's name; the app builds it from `Build.MODEL`, prefixed with the manufacturer only when the model does not already name it ("Pixel 8a" needs no "Google"; "SM-S918B" needs "Samsung"), so the bridge's terminal allowlist and its refusal name the phone rather than show a bare device id. The manufacturer is capitalized because `Build.MANUFACTURER` is often lowercase. Co-Authored-By: Claude Code --- .../java/com/codedeck/plus/core/CoreHost.kt | 34 ++++++++++++++++++- .../plus/platform/StayConnectedService.kt | 5 +++ .../com/codedeck/plus/core/DeviceLabelTest.kt | 33 ++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/android/app/src/test/java/com/codedeck/plus/core/DeviceLabelTest.kt diff --git a/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt b/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt index af6705d7..9dd646e8 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt @@ -68,6 +68,10 @@ class CoreHost( dbPath: String, proxy: String?, tor: Boolean, + /** This phone's name for the bridge, built by the top-level `deviceLabel` + * function. Sent on every session-key grant, shown in the bridge's + * terminal allowlist and in a refusal to open a terminal. */ + deviceLabel: String, ) : CoreListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -161,7 +165,7 @@ class CoreHost( // Constructed last: `Core` holds `this` as its listener and may call back // from its own thread straight away, so every field a callback touches // (the refreshers above) must already be initialized. - private val core: Core = Core(identity, sessionKeys, this, notifier, CoreHttpFetch(), dbPath, proxy, tor) + private val core: Core = Core(identity, sessionKeys, this, notifier, CoreHttpFetch(), dbPath, proxy, tor, deviceLabel) fun start() { core.start() @@ -326,3 +330,31 @@ class CoreHost( private fun refreshPairing() = pairingRefresher.request() } + +/** + * This phone's name for the bridge's terminal allowlist and its refusal + * message, built from the platform's own model string ([model]) and the + * manufacturer. The make is added only when the model does not already name + * it: a "Google Pixel 8a" says nothing "Pixel 8a" does not, while a bare + * model code like "SM-S918B" names no make on its own. The manufacturer is + * capitalized because `Build.MANUFACTURER` is often lowercase ("samsung"). + * + * The bridge sanitizes and caps the name before showing it (see + * `CoreConfig::device_label`), so a name that is empty here is simply no + * name. + */ +internal fun deviceLabel(manufacturer: String, model: String): String { + val make = capitalizeAscii(manufacturer.trim()) + val name = model.trim() + return when { + name.isEmpty() -> make + make.isEmpty() -> name + // Already names its make: either it starts with the make, or it reads + // as a product name (it carries lowercase) rather than a model code. + name.startsWith(make, ignoreCase = true) || name.any { it.isLowerCase() } -> name + else -> "$make $name" + }.ifBlank { "Android device" } +} + +private fun capitalizeAscii(value: String): String = + if (value.isEmpty()) "" else value.take(1).uppercase() + value.drop(1) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt index 87ac1a27..77c0082e 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import com.codedeck.plus.MainActivity import com.codedeck.plus.R import com.codedeck.plus.core.CoreHost +import com.codedeck.plus.core.deviceLabel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -245,6 +246,10 @@ class StayConnectedService : Service() { // live Tor toggle has an address to switch back to. proxy = "127.0.0.1:9050", tor = torProxyEnabled, + // This phone's name for the bridge (its terminal allowlist and a + // refusal to open a terminal) — the platform's model string, with + // the manufacturer added only when the model does not name it. + deviceLabel = deviceLabel(Build.MANUFACTURER, Build.MODEL), ) } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/core/DeviceLabelTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/core/DeviceLabelTest.kt new file mode 100644 index 00000000..174c641d --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/core/DeviceLabelTest.kt @@ -0,0 +1,33 @@ +package com.codedeck.plus.core + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The device name the phone sends with every session-key grant — what the + * bridge shows in its terminal allowlist and in a refusal to open a terminal. + */ +class DeviceLabelTest { + @Test + fun prefixesTheManufacturerOnlyWhenTheModelDoesNotNameIt() { + // "Pixel 8a" already names its make, so a make adds nothing. + assertEquals("Pixel 8a", deviceLabel("Google", "Pixel 8a")) + // A bare model code names no make on its own. + assertEquals("Samsung SM-S918B", deviceLabel("samsung", "SM-S918B")) + // Already prefixed: never doubled. + assertEquals("Samsung Galaxy S23", deviceLabel("Samsung", "Samsung Galaxy S23")) + } + + @Test + fun fallsBackWhenOneHalfIsMissing() { + assertEquals("Google", deviceLabel("Google", "")) + assertEquals("Pixel 8a", deviceLabel("", "Pixel 8a")) + assertEquals("Android device", deviceLabel("", "")) + } + + @Test + fun capitalizesALowercaseManufacturer() { + // Build.MANUFACTURER is often lowercase ("samsung"). + assertEquals("Samsung SM-S918B", deviceLabel("samsung", "SM-S918B")) + } +} From acd1f2a3ee19b5bf394af106cd4c340d005adfb7 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:34:53 +0200 Subject: [PATCH 37/50] build(android): vendor Termux's terminal emulator and view at v0.118.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy termux/termux-app's terminal-emulator and terminal-view modules (Apache-2.0; the rest of that repository is GPLv3) into two Gradle library modules, so a Compose screen can later host a terminal whose shell runs on a remote bridge. The sources under each module's src/main are a verbatim copy of tag v0.118.3, commit 5b657c6. Upstream built the emulator with the NDK around a local pseudoterminal; this copy carries only the Java sources — no jni/ C sources, no NDK or CMake config. The JNI class and the process-backed session are replaced in the next commit. The modules are shaped like apps/android/app/build.gradle.kts (the catalog's AGP, compileSdk and minSdk, Java 17) and the upstream manifests' package attribute moves to the Gradle namespace, which AGP 9 requires. Each module's lint.xml disables the exact checks upstream's untouched code trips (DefaultLocale; ObsoleteSdkInt and UseRequiresApi), so the app's lint bar stays green without editing third-party logic. apps/android/terminal-view/README.md records the source, tag and commit and says the copy is deliberate, never merged. Co-Authored-By: Claude Code --- apps/android/app/build.gradle.kts | 5 + apps/android/build.gradle.kts | 1 + apps/android/gradle/libs.versions.toml | 6 + apps/android/settings.gradle.kts | 4 + apps/android/terminal-emulator/LICENSE | 201 ++ .../terminal-emulator/build.gradle.kts | 46 + apps/android/terminal-emulator/lint.xml | 14 + .../src/main/AndroidManifest.xml | 5 + .../java/com/termux/terminal/ByteQueue.java | 108 + .../main/java/com/termux/terminal/JNI.java | 41 + .../java/com/termux/terminal/KeyHandler.java | 373 +++ .../main/java/com/termux/terminal/Logger.java | 80 + .../com/termux/terminal/TerminalBuffer.java | 497 ++++ .../termux/terminal/TerminalColorScheme.java | 126 + .../com/termux/terminal/TerminalColors.java | 96 + .../com/termux/terminal/TerminalEmulator.java | 2605 +++++++++++++++++ .../com/termux/terminal/TerminalOutput.java | 32 + .../java/com/termux/terminal/TerminalRow.java | 283 ++ .../com/termux/terminal/TerminalSession.java | 372 +++ .../terminal/TerminalSessionClient.java | 46 + .../java/com/termux/terminal/TextStyle.java | 90 + .../java/com/termux/terminal/WcWidth.java | 566 ++++ apps/android/terminal-view/LICENSE | 201 ++ apps/android/terminal-view/README.md | 22 + apps/android/terminal-view/build.gradle.kts | 39 + apps/android/terminal-view/lint.xml | 16 + .../src/main/AndroidManifest.xml | 5 + .../view/GestureAndScaleRecognizer.java | 112 + .../com/termux/view/TerminalRenderer.java | 249 ++ .../java/com/termux/view/TerminalView.java | 1476 ++++++++++ .../com/termux/view/TerminalViewClient.java | 83 + .../view/textselection/CursorController.java | 55 + .../TextSelectionCursorController.java | 402 +++ .../TextSelectionHandleView.java | 345 +++ .../text_select_handle_left_material.xml | 10 + .../text_select_handle_right_material.xml | 10 + .../src/main/res/values/strings.xml | 5 + 37 files changed, 8627 insertions(+) create mode 100644 apps/android/terminal-emulator/LICENSE create mode 100644 apps/android/terminal-emulator/build.gradle.kts create mode 100644 apps/android/terminal-emulator/lint.xml create mode 100644 apps/android/terminal-emulator/src/main/AndroidManifest.xml create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/Logger.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java create mode 100644 apps/android/terminal-view/LICENSE create mode 100644 apps/android/terminal-view/README.md create mode 100644 apps/android/terminal-view/build.gradle.kts create mode 100644 apps/android/terminal-view/lint.xml create mode 100644 apps/android/terminal-view/src/main/AndroidManifest.xml create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/TerminalView.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java create mode 100644 apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java create mode 100644 apps/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml create mode 100644 apps/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml create mode 100644 apps/android/terminal-view/src/main/res/values/strings.xml diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 0199a5f9..36811dbc 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -155,6 +155,11 @@ dependencies { implementation(libs.markdown.renderer.m3) implementation(libs.kotlinx.serialization.json) + // The vendored Termux terminal view and its emulator (Apache-2.0) — a + // Compose screen hosts the view; see terminal-view/README.md. + implementation(project(":terminal-view")) + implementation(project(":terminal-emulator")) + testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 39124e7c..b86d9956 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -5,6 +5,7 @@ // choices are what they are. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.paparazzi) apply false diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index dc071274..5e2dda79 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -45,6 +45,9 @@ tink = "1.23.0" # launch to read one QR code. Neither is covered by the Compose BOM. camerax = "1.6.2" zxing = "3.5.4" +# The vendored Termux terminal-view module annotates a few parameters and +# methods with androidx.annotation; nothing else in the app pulls it directly. +androidxAnnotation = "1.11.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -77,6 +80,7 @@ androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } +androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" } [bundles] compose = [ @@ -89,6 +93,8 @@ compose = [ [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +# The vendored terminal-emulator / terminal-view modules are Android libraries. +android-library = { id = "com.android.library", version.ref = "agp" } # No separate "org.jetbrains.kotlin.android" plugin: AGP 9+ has Kotlin support # built in (applying it is now a hard error — "no longer required since AGP # 9.0"). diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts index e74fb571..3c0e949e 100644 --- a/apps/android/settings.gradle.kts +++ b/apps/android/settings.gradle.kts @@ -17,3 +17,7 @@ dependencyResolutionManagement { } rootProject.name = "CodeDeckAndroid" include(":app") +// Vendored Termux terminal emulator + view (Apache-2.0) — see +// terminal-view/README.md. +include(":terminal-emulator") +include(":terminal-view") diff --git a/apps/android/terminal-emulator/LICENSE b/apps/android/terminal-emulator/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/apps/android/terminal-emulator/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/android/terminal-emulator/build.gradle.kts b/apps/android/terminal-emulator/build.gradle.kts new file mode 100644 index 00000000..c9762039 --- /dev/null +++ b/apps/android/terminal-emulator/build.gradle.kts @@ -0,0 +1,46 @@ +// Vendored Termux terminal emulator (Apache-2.0). The sources under +// src/main/java are a verbatim copy of termux/termux-app's terminal-emulator +// module except where terminal-view/README.md lists a local change; the +// module shape follows apps/android/app/build.gradle.kts so it shares the +// version catalog's AGP/compileSdk/minSdk. Upstream built it with the NDK +// for a local pseudoterminal; this copy has no native code and no local +// process. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.termux.terminal" + compileSdk { + version = release(37) + } + + defaultConfig { + minSdk { + version = release(26) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + // Same reason as the app module: a JVM test that reaches + // android.util.Log (Logger falls back to it with a null client) + // would otherwise throw "Method ... not mocked". + isReturnDefaultValues = true + } + } + + lint { + abortOnError = true + checkReleaseBuilds = true + } +} + +dependencies { + testImplementation(libs.junit) +} diff --git a/apps/android/terminal-emulator/lint.xml b/apps/android/terminal-emulator/lint.xml new file mode 100644 index 00000000..840919a3 --- /dev/null +++ b/apps/android/terminal-emulator/lint.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/apps/android/terminal-emulator/src/main/AndroidManifest.xml b/apps/android/terminal-emulator/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cec37532 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java new file mode 100644 index 00000000..adfdfa86 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java @@ -0,0 +1,108 @@ +package com.termux.terminal; + +/** A circular byte buffer allowing one producer and one consumer thread. */ +final class ByteQueue { + + private final byte[] mBuffer; + private int mHead; + private int mStoredBytes; + private boolean mOpen = true; + + public ByteQueue(int size) { + mBuffer = new byte[size]; + } + + public synchronized void close() { + mOpen = false; + notify(); + } + + public synchronized int read(byte[] buffer, boolean block) { + while (mStoredBytes == 0 && mOpen) { + if (block) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } else { + return 0; + } + } + if (!mOpen) return -1; + + int totalRead = 0; + int bufferLength = mBuffer.length; + boolean wasFull = bufferLength == mStoredBytes; + int length = buffer.length; + int offset = 0; + while (length > 0 && mStoredBytes > 0) { + int oneRun = Math.min(bufferLength - mHead, mStoredBytes); + int bytesToCopy = Math.min(length, oneRun); + System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy); + mHead += bytesToCopy; + if (mHead >= bufferLength) mHead = 0; + mStoredBytes -= bytesToCopy; + length -= bytesToCopy; + offset += bytesToCopy; + totalRead += bytesToCopy; + } + if (wasFull) notify(); + return totalRead; + } + + /** + * Attempt to write the specified portion of the provided buffer to the queue. + *

+ * Returns whether the output was totally written, false if it was closed before. + */ + public boolean write(byte[] buffer, int offset, int lengthToWrite) { + if (lengthToWrite + offset > buffer.length) { + throw new IllegalArgumentException("length + offset > buffer.length"); + } else if (lengthToWrite <= 0) { + throw new IllegalArgumentException("length <= 0"); + } + + final int bufferLength = mBuffer.length; + + synchronized (this) { + while (lengthToWrite > 0) { + while (bufferLength == mStoredBytes && mOpen) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } + if (!mOpen) return false; + final boolean wasEmpty = mStoredBytes == 0; + int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes); + lengthToWrite -= bytesToWriteBeforeWaiting; + + while (bytesToWriteBeforeWaiting > 0) { + int tail = mHead + mStoredBytes; + int oneRun; + if (tail >= bufferLength) { + // Buffer: [.............] + // ________________H_______T + // => + // Buffer: [.............] + // ___________T____H + // onRun= _____----_ + tail = tail - bufferLength; + oneRun = mHead - tail; + } else { + oneRun = bufferLength - tail; + } + int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting); + System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy); + offset += bytesToCopy; + bytesToWriteBeforeWaiting -= bytesToCopy; + mStoredBytes += bytesToCopy; + } + if (wasEmpty) notify(); + } + } + return true; + } +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java new file mode 100644 index 00000000..e2fc477b --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java @@ -0,0 +1,41 @@ +package com.termux.terminal; + +/** + * Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c. + */ +final class JNI { + + static { + System.loadLibrary("termux"); + } + + /** + * Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the + * subprocess. + *

+ * Callers are responsible for calling {@link #close(int)} on the returned file descriptor. + * + * @param cmd The command to execute + * @param cwd The current working directory for the executed command + * @param args An array of arguments to the command + * @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process + * @param processId A one-element array to which the process ID of the started process will be written. + * @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the + * slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr. + */ + public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns, int cellWidth, int cellHeight); + + /** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */ + public static native void setPtyWindowSize(int fd, int rows, int cols, int cellWidth, int cellHeight); + + /** + * Causes the calling thread to wait for the process associated with the receiver to finish executing. + * + * @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated. + */ + public static native int waitFor(int processId); + + /** Close a file descriptor through the close(2) system call. */ + public static native void close(int fileDescriptor); + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java new file mode 100644 index 00000000..254f5e3a --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -0,0 +1,373 @@ +package com.termux.terminal; + +import java.util.HashMap; +import java.util.Map; + +import static android.view.KeyEvent.KEYCODE_BACK; +import static android.view.KeyEvent.KEYCODE_BREAK; +import static android.view.KeyEvent.KEYCODE_DEL; +import static android.view.KeyEvent.KEYCODE_DPAD_CENTER; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_F1; +import static android.view.KeyEvent.KEYCODE_F10; +import static android.view.KeyEvent.KEYCODE_F11; +import static android.view.KeyEvent.KEYCODE_F12; +import static android.view.KeyEvent.KEYCODE_F2; +import static android.view.KeyEvent.KEYCODE_F3; +import static android.view.KeyEvent.KEYCODE_F4; +import static android.view.KeyEvent.KEYCODE_F5; +import static android.view.KeyEvent.KEYCODE_F6; +import static android.view.KeyEvent.KEYCODE_F7; +import static android.view.KeyEvent.KEYCODE_F8; +import static android.view.KeyEvent.KEYCODE_F9; +import static android.view.KeyEvent.KEYCODE_FORWARD_DEL; +import static android.view.KeyEvent.KEYCODE_INSERT; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_NUMPAD_0; +import static android.view.KeyEvent.KEYCODE_NUMPAD_1; +import static android.view.KeyEvent.KEYCODE_NUMPAD_2; +import static android.view.KeyEvent.KEYCODE_NUMPAD_3; +import static android.view.KeyEvent.KEYCODE_NUMPAD_4; +import static android.view.KeyEvent.KEYCODE_NUMPAD_5; +import static android.view.KeyEvent.KEYCODE_NUMPAD_6; +import static android.view.KeyEvent.KEYCODE_NUMPAD_7; +import static android.view.KeyEvent.KEYCODE_NUMPAD_8; +import static android.view.KeyEvent.KEYCODE_NUMPAD_9; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD; +import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER; +import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS; +import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY; +import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT; +import static android.view.KeyEvent.KEYCODE_NUM_LOCK; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_SPACE; +import static android.view.KeyEvent.KEYCODE_SYSRQ; +import static android.view.KeyEvent.KEYCODE_TAB; + +public final class KeyHandler { + + public static final int KEYMOD_ALT = 0x80000000; + public static final int KEYMOD_CTRL = 0x40000000; + public static final int KEYMOD_SHIFT = 0x20000000; + public static final int KEYMOD_NUM_LOCK = 0x10000000; + + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); + + static { + // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html + // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html + TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT); + TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home + TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key + + TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12); + TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12); + + TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key + + TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key + TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT); + + // K1=Upper left of keypad: + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN); + + TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP); + + TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab + TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key + TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down + TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key + TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT); + TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN); + TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key + TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up + + TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER); + } + + static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) { + Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap); + if (keyCodeAndMod == null) return null; + int keyCode = keyCodeAndMod; + int keyMod = 0; + if ((keyCode & KEYMOD_SHIFT) != 0) { + keyMod |= KEYMOD_SHIFT; + keyCode &= ~KEYMOD_SHIFT; + } + if ((keyCode & KEYMOD_CTRL) != 0) { + keyMod |= KEYMOD_CTRL; + keyCode &= ~KEYMOD_CTRL; + } + if ((keyCode & KEYMOD_ALT) != 0) { + keyMod |= KEYMOD_ALT; + keyCode &= ~KEYMOD_ALT; + } + if ((keyCode & KEYMOD_NUM_LOCK) != 0) { + keyMod |= KEYMOD_NUM_LOCK; + keyCode &= ~KEYMOD_NUM_LOCK; + } + return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication); + } + + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) { + boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0; + keyMode &= ~KEYMOD_NUM_LOCK; + switch (keyCode) { + case KEYCODE_DPAD_CENTER: + return "\015"; + + case KEYCODE_DPAD_UP: + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + case KEYCODE_DPAD_DOWN: + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + case KEYCODE_DPAD_RIGHT: + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + case KEYCODE_DPAD_LEFT: + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + + case KEYCODE_MOVE_HOME: + // Note that KEYCODE_HOME is handled by the system and never delivered to applications. + // On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow. + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + case KEYCODE_MOVE_END: + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + + // An xterm can send function keys F1 to F4 in two modes: vt100 compatible or + // not. Because Vim may not know what the xterm is sending, both types of keys + // are recognized. The same happens for the and keys. + // normal vt100 ~ + // t_k1 [11~ OP *-xterm* + // t_k2 [12~ OQ *-xterm* + // t_k3 [13~ OR *-xterm* + // t_k4 [14~ OS *-xterm* + // t_kh [7~ OH *-xterm* + // t_@7 [4~ OF *-xterm* + case KEYCODE_F1: + return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P'); + case KEYCODE_F2: + return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q'); + case KEYCODE_F3: + return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R'); + case KEYCODE_F4: + return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S'); + case KEYCODE_F5: + return transformForModifiers("\033[15", keyMode, '~'); + case KEYCODE_F6: + return transformForModifiers("\033[17", keyMode, '~'); + case KEYCODE_F7: + return transformForModifiers("\033[18", keyMode, '~'); + case KEYCODE_F8: + return transformForModifiers("\033[19", keyMode, '~'); + case KEYCODE_F9: + return transformForModifiers("\033[20", keyMode, '~'); + case KEYCODE_F10: + return transformForModifiers("\033[21", keyMode, '~'); + case KEYCODE_F11: + return transformForModifiers("\033[23", keyMode, '~'); + case KEYCODE_F12: + return transformForModifiers("\033[24", keyMode, '~'); + + case KEYCODE_SYSRQ: + return "\033[32~"; // Sys Request / Print + // Is this Scroll lock? case Cancel: return "\033[33~"; + case KEYCODE_BREAK: + return "\033[34~"; // Pause/Break + + case KEYCODE_ESCAPE: + case KEYCODE_BACK: + return "\033"; + + case KEYCODE_INSERT: + return transformForModifiers("\033[2", keyMode, '~'); + case KEYCODE_FORWARD_DEL: + return transformForModifiers("\033[3", keyMode, '~'); + + case KEYCODE_PAGE_UP: + return "\033[5~"; + case KEYCODE_PAGE_DOWN: + return "\033[6~"; + case KEYCODE_DEL: + String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033"; + // Just do what xterm and gnome-terminal does: + return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008"); + case KEYCODE_NUM_LOCK: + if (keypadApplication) { + return "\033OP"; + } else { + return null; + } + case KEYCODE_SPACE: + // If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a + // combining accent to be written): + return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0"; + case KEYCODE_TAB: + // This is back-tab when shifted: + return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z"; + case KEYCODE_ENTER: + return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r"; + + case KEYCODE_NUMPAD_ENTER: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n"; + case KEYCODE_NUMPAD_MULTIPLY: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*"; + case KEYCODE_NUMPAD_ADD: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+"; + case KEYCODE_NUMPAD_COMMA: + return ","; + case KEYCODE_NUMPAD_DOT: + if (numLockOn) { + return keypadApplication ? "\033On" : "."; + } else { + // DELETE + return transformForModifiers("\033[3", keyMode, '~'); + } + case KEYCODE_NUMPAD_SUBTRACT: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-"; + case KEYCODE_NUMPAD_DIVIDE: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/"; + case KEYCODE_NUMPAD_0: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0"; + } else { + // INSERT + return transformForModifiers("\033[2", keyMode, '~'); + } + case KEYCODE_NUMPAD_1: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1"; + } else { + // END + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + } + case KEYCODE_NUMPAD_2: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2"; + } else { + // DOWN + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + } + case KEYCODE_NUMPAD_3: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3"; + } else { + // PGDN + return "\033[6~"; + } + case KEYCODE_NUMPAD_4: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4"; + } else { + // LEFT + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + } + case KEYCODE_NUMPAD_5: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5"; + case KEYCODE_NUMPAD_6: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6"; + } else { + // RIGHT + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + } + case KEYCODE_NUMPAD_7: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7"; + } else { + // HOME + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + } + case KEYCODE_NUMPAD_8: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8"; + } else { + // UP + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + } + case KEYCODE_NUMPAD_9: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9"; + } else { + // PGUP + return "\033[5~"; + } + case KEYCODE_NUMPAD_EQUALS: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "="; + } + + return null; + } + + private static String transformForModifiers(String start, int keymod, char lastChar) { + int modifier; + switch (keymod) { + case KEYMOD_SHIFT: + modifier = 2; + break; + case KEYMOD_ALT: + modifier = 3; + break; + case (KEYMOD_SHIFT | KEYMOD_ALT): + modifier = 4; + break; + case KEYMOD_CTRL: + modifier = 5; + break; + case KEYMOD_SHIFT | KEYMOD_CTRL: + modifier = 6; + break; + case KEYMOD_ALT | KEYMOD_CTRL: + modifier = 7; + break; + case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL: + modifier = 8; + break; + default: + return start + lastChar; + } + return start + (";" + modifier) + lastChar; + } +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/Logger.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/Logger.java new file mode 100644 index 00000000..d4d502e0 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/Logger.java @@ -0,0 +1,80 @@ +package com.termux.terminal; + +import android.util.Log; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +public class Logger { + + public static void logError(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logError(logTag, message); + else + Log.e(logTag, message); + } + + public static void logWarn(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logWarn(logTag, message); + else + Log.w(logTag, message); + } + + public static void logInfo(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logInfo(logTag, message); + else + Log.i(logTag, message); + } + + public static void logDebug(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logDebug(logTag, message); + else + Log.d(logTag, message); + } + + public static void logVerbose(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logVerbose(logTag, message); + else + Log.v(logTag, message); + } + + public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) { + logError(client, tag, getMessageAndStackTraceString(message, throwable)); + } + + public static String getMessageAndStackTraceString(String message, Throwable throwable) { + if (message == null && throwable == null) + return null; + else if (message != null && throwable != null) + return message + ":\n" + getStackTraceString(throwable); + else if (throwable == null) + return message; + else + return getStackTraceString(throwable); + } + + public static String getStackTraceString(Throwable throwable) { + if (throwable == null) return null; + + String stackTraceString = null; + + try { + StringWriter errors = new StringWriter(); + PrintWriter pw = new PrintWriter(errors); + throwable.printStackTrace(pw); + pw.close(); + stackTraceString = errors.toString(); + errors.close(); + } catch (IOException e) { + e.printStackTrace(); + } + + return stackTraceString; + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java new file mode 100644 index 00000000..21d65187 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -0,0 +1,497 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll + * history. + *

+ * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public final class TerminalBuffer { + + TerminalRow[] mLines; + /** The length of {@link #mLines}. */ + int mTotalRows; + /** The number of rows and columns visible on the screen. */ + int mScreenRows, mColumns; + /** The number of rows kept in history. */ + private int mActiveTranscriptRows = 0; + /** The index in the circular buffer where the visible screen starts. */ + private int mScreenFirstRow = 0; + + /** + * Create a transcript screen. + * + * @param columns the width of the screen in characters. + * @param totalRows the height of the entire text area, in rows of text. + * @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off + * the top of the screen. + */ + public TerminalBuffer(int columns, int totalRows, int screenRows) { + mColumns = columns; + mTotalRows = totalRows; + mScreenRows = screenRows; + mLines = new TerminalRow[totalRows]; + + blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + } + + public String getTranscriptText() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); + } + + public String getTranscriptTextWithoutJoinedLines() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim(); + } + + public String getTranscriptTextWithFullLinesJoined() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim(); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2) { + return getSelectedText(selX1, selY1, selX2, selY2, true); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) { + return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) { + final StringBuilder builder = new StringBuilder(); + final int columns = mColumns; + + if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows(); + if (selY2 >= mScreenRows) selY2 = mScreenRows - 1; + + for (int row = selY1; row <= selY2; row++) { + int x1 = (row == selY1) ? selX1 : 0; + int x2; + if (row == selY2) { + x2 = selX2 + 1; + if (x2 > columns) x2 = columns; + } else { + x2 = columns; + } + TerminalRow lineObject = mLines[externalToInternalRow(row)]; + int x1Index = lineObject.findStartOfColumn(x1); + int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed(); + if (x2Index == x1Index) { + // Selected the start of a wide character. + x2Index = lineObject.findStartOfColumn(x2 + 1); + } + char[] line = lineObject.mText; + int lastPrintingCharIndex = -1; + int i; + boolean rowLineWrap = getLineWrap(row); + if (rowLineWrap && x2 == columns) { + // If the line was wrapped, we shouldn't lose trailing space: + lastPrintingCharIndex = x2Index - 1; + } else { + for (i = x1Index; i < x2Index; ++i) { + char c = line[i]; + if (c != ' ') lastPrintingCharIndex = i; + } + } + + int len = lastPrintingCharIndex - x1Index + 1; + if (lastPrintingCharIndex != -1 && len > 0) + builder.append(line, x1Index, len); + + boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1; + if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth) + && row < selY2 && row < mScreenRows - 1) builder.append('\n'); + } + return builder.toString(); + } + + public String getWordAtLocation(int x, int y) { + // Set y1 and y2 to the lines where the wrapped line starts and ends. + // I.e. if a line that is wrapped to 3 lines starts at line 4, and this + // is called with y=5, then y1 would be set to 4 and y2 would be set to 6. + int y1 = y; + int y2 = y; + while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) { + y1--; + } + while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) { + y2++; + } + + // Get the text for the whole wrapped line + String text = getSelectedText(0, y1, mColumns, y2, true, true); + // The index of x in text + int textOffset = (y - y1) * mColumns + x; + + if (textOffset >= text.length()) { + // The click was to the right of the last word on the line, so + // there's no word to return + return ""; + } + + // Set x1 and x2 to the indices of the last space before x and the + // first space after x in text respectively + int x1 = text.lastIndexOf(' ', textOffset); + int x2 = text.indexOf(' ', textOffset); + if (x2 == -1) { + x2 = text.length(); + } + + if (x1 == x2) { + // The click was on a space, so there's no word to return + return ""; + } + return text.substring(x1 + 1, x2); + } + + public int getActiveTranscriptRows() { + return mActiveTranscriptRows; + } + + public int getActiveRows() { + return mActiveTranscriptRows + mScreenRows; + } + + /** + * Convert a row value from the public external coordinate system to our internal private coordinate system. + * + *

+     * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+     * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+     *   mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+     *
+     * External ↔ Internal:
+     *
+     * [ ...                            ]     [ ...                                     ]
+     * [ -mActiveTranscriptRows         ]     [ mScreenFirstRow - mActiveTranscriptRows ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ 0 (visible screen starts here) ]  ↔  [ mScreenFirstRow                         ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ mScreenRows-1                  ]     [ mScreenFirstRow + mScreenRows-1         ]
+     * 
+ * + * @param externalRow a row in the external coordinate system. + * @return The row corresponding to the input argument in the private coordinate system. + */ + public int externalToInternalRow(int externalRow) { + if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows) + throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows); + final int internalRow = mScreenFirstRow + externalRow; + return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows); + } + + public void setLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = true; + } + + public boolean getLineWrap(int row) { + return mLines[externalToInternalRow(row)].mLineWrap; + } + + public void clearLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = false; + } + + /** + * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not + * change or the rows expand (that is, it only works when shrinking the number of rows). + * + * @param newColumns The number of columns the screen should have. + * @param newRows The number of rows the screen should have. + * @param cursor An int[2] containing the (column, row) cursor location. + */ + public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) { + // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000): + if (newColumns == mColumns && newRows <= mTotalRows) { + // Fast resize where just the rows changed. + int shiftDownOfTopRow = mScreenRows - newRows; + if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) { + // Shrinking. Check if we can skip blank rows at bottom below cursor. + for (int i = mScreenRows - 1; i > 0; i--) { + if (cursor[1] >= i) break; + int r = externalToInternalRow(i); + if (mLines[r] == null || mLines[r].isBlank()) { + if (--shiftDownOfTopRow == 0) break; + } + } + } else if (shiftDownOfTopRow < 0) { + // Negative shift down = expanding. Only move screen up if there is transcript to show: + int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows); + if (shiftDownOfTopRow != actualShift) { + // The new lines revealed by the resizing are not all from the transcript. Blank the below ones. + for (int i = 0; i < actualShift - shiftDownOfTopRow; i++) + allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle); + shiftDownOfTopRow = actualShift; + } + } + mScreenFirstRow += shiftDownOfTopRow; + mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows); + mTotalRows = newTotalRows; + mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow); + cursor[1] -= shiftDownOfTopRow; + mScreenRows = newRows; + } else { + // Copy away old state and update new: + TerminalRow[] oldLines = mLines; + mLines = new TerminalRow[newTotalRows]; + for (int i = 0; i < newTotalRows; i++) + mLines[i] = new TerminalRow(newColumns, currentStyle); + + final int oldActiveTranscriptRows = mActiveTranscriptRows; + final int oldScreenFirstRow = mScreenFirstRow; + final int oldScreenRows = mScreenRows; + final int oldTotalRows = mTotalRows; + mTotalRows = newTotalRows; + mScreenRows = newRows; + mActiveTranscriptRows = mScreenFirstRow = 0; + mColumns = newColumns; + + int newCursorRow = -1; + int newCursorColumn = -1; + int oldCursorRow = cursor[1]; + int oldCursorColumn = cursor[0]; + boolean newCursorPlaced = false; + + int currentOutputExternalRow = 0; + int currentOutputExternalColumn = 0; + + // Loop over every character in the initial state. + // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we + // keep track how many blank lines we have skipped if we later on find a non-blank line. + int skippedBlankLines = 0; + for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) { + // Do what externalToInternalRow() does but for the old state: + int internalOldRow = oldScreenFirstRow + externalOldRow; + internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows); + + TerminalRow oldLine = oldLines[internalOldRow]; + boolean cursorAtThisRow = externalOldRow == oldCursorRow; + // The cursor may only be on a non-null line, which we should not skip: + if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) { + skippedBlankLines++; + continue; + } else if (skippedBlankLines > 0) { + // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines. + for (int i = 0; i < skippedBlankLines; i++) { + if (currentOutputExternalRow == mScreenRows - 1) { + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + skippedBlankLines = 0; + } + + int lastNonSpaceIndex = 0; + boolean justToCursor = false; + if (cursorAtThisRow || oldLine.mLineWrap) { + // Take the whole line, either because of cursor on it, or if line wrapping. + lastNonSpaceIndex = oldLine.getSpaceUsed(); + if (cursorAtThisRow) justToCursor = true; + } else { + for (int i = 0; i < oldLine.getSpaceUsed(); i++) + // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices + if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) + lastNonSpaceIndex = i + 1; + } + + int currentOldCol = 0; + long styleAtCol = 0; + for (int i = 0; i < lastNonSpaceIndex; i++) { + // Note that looping over java character, not cells. + char c = oldLine.mText[i]; + int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; + int displayWidth = WcWidth.width(codePoint); + // Use the last style if this is a zero-width character: + if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + + // Line wrap as necessary: + if (currentOutputExternalColumn + displayWidth > mColumns) { + setLineWrap(currentOutputExternalRow); + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); + int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + + if (displayWidth > 0) { + if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { + newCursorColumn = currentOutputExternalColumn; + newCursorRow = currentOutputExternalRow; + newCursorPlaced = true; + } + currentOldCol += displayWidth; + currentOutputExternalColumn += displayWidth; + if (justToCursor && newCursorPlaced) break; + } + } + // Old row has been copied. Check if we need to insert newline if old line was not wrapping: + if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) { + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + } + + cursor[0] = newCursorColumn; + cursor[1] = newCursorRow; + } + + // Handle cursor scrolling off screen: + if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0; + } + + /** + * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound + * into account. + * + * @param srcInternal The first line to be copied. + * @param len The number of lines to be copied. + */ + private void blockCopyLinesDown(int srcInternal, int len) { + if (len == 0) return; + int totalRows = mTotalRows; + + int start = len - 1; + // Save away line to be overwritten: + TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]; + // Do the copy from bottom to top. + for (int i = start; i >= 0; --i) + mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]; + // Put back overwritten line, now above the block: + mLines[(srcInternal) % totalRows] = lineToBeOverWritten; + } + + /** + * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24). + * + * @param topMargin First line that is scrolled. + * @param bottomMargin One line after the last line that is scrolled. + * @param style the style for the newly exposed line. + */ + public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { + if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows) + throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows); + + // Copy the fixed topMargin lines one line down so that they remain on screen in same position: + blockCopyLinesDown(mScreenFirstRow, topMargin); + // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same + // position: + blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin); + + // Update the screen location in the ring buffer: + mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows; + // Note that the history has grown if not already full: + if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++; + + // Blank the newly revealed line above the bottom margin: + int blankRow = externalToInternalRow(bottomMargin - 1); + if (mLines[blankRow] == null) { + mLines[blankRow] = new TerminalRow(mColumns, style); + } else { + mLines[blankRow].clear(style); + } + } + + /** + * Block copy characters from one position in the screen to another. The two positions can overlap. All characters + * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will + * be thrown. + * + * @param sx source X coordinate + * @param sy source Y coordinate + * @param w width + * @param h height + * @param dx destination X coordinate + * @param dy destination Y coordinate + */ + public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) { + if (w == 0) return; + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows) + throw new IllegalArgumentException(); + boolean copyingUp = sy > dy; + for (int y = 0; y < h; y++) { + int y2 = copyingUp ? y : (h - (y + 1)); + TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2)); + allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx); + } + } + + /** + * Block set characters. All characters must be within the bounds of the screen, or else and + * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block + * of characters. + */ + public void blockSet(int sx, int sy, int w, int h, int val, long style) { + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) { + throw new IllegalArgumentException( + "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); + } + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + setChar(sx + x, sy + y, val, style); + } + + public TerminalRow allocateFullLineIfNecessary(int row) { + return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row]; + } + + public void setChar(int column, int row, int codePoint, long style) { + if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) + throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + } + + public long getStyleAt(int externalRow, int column) { + return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); + } + + /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */ + public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left, + int bottom, int right) { + for (int y = top; y < bottom; y++) { + TerminalRow line = mLines[externalToInternalRow(y)]; + int startOfLine = (rectangular || y == top) ? left : leftMargin; + int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin; + for (int x = startOfLine; x < endOfLine; x++) { + long currentStyle = line.getStyle(x); + int foreColor = TextStyle.decodeForeColor(currentStyle); + int backColor = TextStyle.decodeBackColor(currentStyle); + int effect = TextStyle.decodeEffect(currentStyle); + if (reverse) { + // Clear out the bits to reverse and add them back in reversed: + effect = (effect & ~bits) | (bits & ~effect); + } else if (setOrClear) { + effect |= bits; + } else { + effect &= ~bits; + } + line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect); + } + } + } + + public void clearTranscript() { + if (mScreenFirstRow < mActiveTranscriptRows) { + Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null); + Arrays.fill(mLines, 0, mScreenFirstRow, null); + } else { + Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); + } + mActiveTranscriptRows = 0; + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java new file mode 100644 index 00000000..40887818 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java @@ -0,0 +1,126 @@ +package com.termux.terminal; + +import java.util.Map; +import java.util.Properties; + +/** + * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using + * Operating System Control (OSC) sequences. + * + * @see TerminalColors + */ +public final class TerminalColorScheme { + + /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */ + private static final int[] DEFAULT_COLORSCHEME = { + // 16 original colors. First 8 are dim. + 0xff000000, // black + 0xffcd0000, // dim red + 0xff00cd00, // dim green + 0xffcdcd00, // dim yellow + 0xff6495ed, // dim blue + 0xffcd00cd, // dim magenta + 0xff00cdcd, // dim cyan + 0xffe5e5e5, // dim white + // Second 8 are bright: + 0xff7f7f7f, // medium grey + 0xffff0000, // bright red + 0xff00ff00, // bright green + 0xffffff00, // bright yellow + 0xff5c5cff, // light blue + 0xffff00ff, // bright magenta + 0xff00ffff, // bright cyan + 0xffffffff, // bright white + + // 216 color cube, six shades of each color: + 0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff, + 0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff, + 0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff, + 0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff, + 0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff, + 0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff, + 0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff, + 0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff, + 0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff, + 0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff, + 0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff, + 0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff, + 0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff, + 0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff, + 0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff, + 0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff, + 0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff, + 0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff, + + // 24 grey scale ramp: + 0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676, + 0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee, + + // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR: + 0xffffffff, 0xff000000, 0xffffffff}; + + public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + public TerminalColorScheme() { + reset(); + } + + private void reset() { + System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + public void updateWith(Properties props) { + reset(); + boolean cursorPropExists = false; + for (Map.Entry entries : props.entrySet()) { + String key = (String) entries.getKey(); + String value = (String) entries.getValue(); + int colorIndex; + + if (key.equals("foreground")) { + colorIndex = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (key.equals("background")) { + colorIndex = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (key.equals("cursor")) { + colorIndex = TextStyle.COLOR_INDEX_CURSOR; + cursorPropExists = true; + } else if (key.startsWith("color")) { + try { + colorIndex = Integer.parseInt(key.substring(5)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + } else { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + + int colorValue = TerminalColors.parse(value); + if (colorValue == 0) + throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'"); + + mDefaultColors[colorIndex] = colorValue; + } + + if (!cursorPropExists) + setCursorColorForBackground(); + } + + /** + * If the "cursor" color is not set by user, we need to decide on the appropriate color that will + * be visible on the current terminal background. White will not be visible on light backgrounds + * and black won't be visible on dark backgrounds. So we find the perceived brightness of the + * background color and if its below the threshold (too dark), we use white cursor and if its + * above (too bright), we use black cursor. + */ + public void setCursorColorForBackground() { + int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND]; + int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor); + if (brightness > 0) { + if (brightness < 130) + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff; + else + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000; + } + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java new file mode 100644 index 00000000..25135a2f --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java @@ -0,0 +1,96 @@ +package com.termux.terminal; + +import android.graphics.Color; + +/** Current terminal colors (if different from default). */ +public final class TerminalColors { + + /** Static data - a bit ugly but ok for now. */ + public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme(); + + /** + * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC + * 4 control sequence. + */ + public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + /** Create a new instance with default colors from the theme. */ + public TerminalColors() { + reset(); + } + + /** Reset a particular indexed color with the default color from the color theme. */ + public void reset(int index) { + mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]; + } + + /** Reset all indexed colors with the default color from the color theme. */ + public void reset() { + System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + /** + * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html + *

+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed. + */ + static int parse(String c) { + try { + int skipInitial, skipBetween; + if (c.charAt(0) == '#') { + // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits. + skipInitial = 1; + skipBetween = 0; + } else if (c.startsWith("rgb:")) { + // rgb:// where , , := h | hh | hhh | hhhh. Scaled. + skipInitial = 4; + skipBetween = 1; + } else { + return 0; + } + int charsForColors = c.length() - skipInitial - 2 * skipBetween; + if (charsForColors % 3 != 0) return 0; // Unequal lengths. + int componentLength = charsForColors / 3; + double mult = 255 / (Math.pow(2, componentLength * 4) - 1); + + int currentPosition = skipInitial; + String rString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String gString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String bString = c.substring(currentPosition, currentPosition + componentLength); + + int r = (int) (Integer.parseInt(rString, 16) * mult); + int g = (int) (Integer.parseInt(gString, 16) * mult); + int b = (int) (Integer.parseInt(bString, 16) * mult); + return 0xFF << 24 | r << 16 | g << 8 | b; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return 0; + } + } + + /** Try parse a color from a text parameter and into a specified index. */ + public void tryParseColor(int intoIndex, String textParameter) { + int c = parse(textParameter); + if (c != 0) mCurrentColors[intoIndex] = c; + } + + /** + * Get the perceived brightness of the color based on its RGB components. + * + * https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx + * http://alienryderflex.com/hsp.html + * + * @param color The color code int. + * @return Returns value between 0-255. + */ + public static int getPerceivedBrightnessOfColor(int color) { + return (int) + Math.floor(Math.sqrt( + Math.pow(Color.red(color), 2) * 0.241 + + Math.pow(Color.green(color), 2) * 0.691 + + Math.pow(Color.blue(color), 2) * 0.068 + )); + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java new file mode 100644 index 00000000..8d7eaa8c --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -0,0 +1,2605 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.Stack; + +/** + * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window + * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal. + *

+ * References: + *

    + *
  • http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
  • + *
  • http://en.wikipedia.org/wiki/ANSI_escape_code
  • + *
  • http://man.he.net/man4/console_codes
  • + *
  • http://bazaar.launchpad.net/~leonerd/libvterm/trunk/view/head:/src/state.c
  • + *
  • http://www.columbia.edu/~kermit/k95manual/iso2022.html
  • + *
  • http://www.vt100.net/docs/vt510-rm/chapter4
  • + *
  • http://en.wikipedia.org/wiki/ISO/IEC_2022 - for 7-bit and 8-bit GL GR explanation
  • + *
  • http://bjh21.me.uk/all-escapes/all-escapes.txt - extensive!
  • + *
  • http://woldlab.caltech.edu/~diane/kde4.10/workingdir/kubuntu/konsole/doc/developer/old-documents/VT100/techref. + * html - document for konsole - accessible!
  • + *
+ */ +public final class TerminalEmulator { + + /** Log unknown or unimplemented escape sequences received from the shell process. */ + private static final boolean LOG_ESCAPE_SEQUENCES = false; + + public static final int MOUSE_LEFT_BUTTON = 0; + + /** Mouse moving while having left mouse button pressed. */ + public static final int MOUSE_LEFT_BUTTON_MOVED = 32; + public static final int MOUSE_WHEELUP_BUTTON = 64; + public static final int MOUSE_WHEELDOWN_BUTTON = 65; + + /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ + public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + + /** Escape processing: Not currently in an escape sequence. */ + private static final int ESC_NONE = 0; + /** Escape processing: Have seen an ESC character - proceed to {@link #doEsc(int)} */ + private static final int ESC = 1; + /** Escape processing: Have seen ESC POUND */ + private static final int ESC_POUND = 2; + /** Escape processing: Have seen ESC and a character-set-select ( char */ + private static final int ESC_SELECT_LEFT_PAREN = 3; + /** Escape processing: Have seen ESC and a character-set-select ) char */ + private static final int ESC_SELECT_RIGHT_PAREN = 4; + /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */ + private static final int ESC_CSI = 6; + /** Escape processing: ESC [ ? */ + private static final int ESC_CSI_QUESTIONMARK = 7; + /** Escape processing: ESC [ $ */ + private static final int ESC_CSI_DOLLAR = 8; + /** Escape processing: ESC % */ + private static final int ESC_PERCENT = 9; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */ + private static final int ESC_OSC = 10; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */ + private static final int ESC_OSC_ESC = 11; + /** Escape processing: ESC [ > */ + private static final int ESC_CSI_BIGGERTHAN = 12; + /** Escape procession: "ESC P" or Device Control String (DCS) */ + private static final int ESC_P = 13; + /** Escape processing: CSI > */ + private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; + /** Escape processing: CSI $ARGS ' ' */ + private static final int ESC_CSI_ARGS_SPACE = 15; + /** Escape processing: CSI $ARGS '*' */ + private static final int ESC_CSI_ARGS_ASTERIX = 16; + /** Escape processing: CSI " */ + private static final int ESC_CSI_DOUBLE_QUOTE = 17; + /** Escape processing: CSI ' */ + private static final int ESC_CSI_SINGLE_QUOTE = 18; + /** Escape processing: CSI ! */ + private static final int ESC_CSI_EXCLAMATION = 19; + /** Escape processing: "ESC _" or Application Program Command (APC). */ + private static final int ESC_APC = 20; + /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */ + private static final int ESC_APC_ESCAPE = 21; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23; + + /** The number of parameter arguments including colon separated sub-parameters. */ + private static final int MAX_ESCAPE_PARAMETERS = 32; + + /** Needs to be large enough to contain reasonable OSC 52 pastes. */ + private static final int MAX_OSC_STRING_LENGTH = 8192; + + /** DECSET 1 - application cursor keys. */ + private static final int DECSET_BIT_APPLICATION_CURSOR_KEYS = 1; + private static final int DECSET_BIT_REVERSE_VIDEO = 1 << 1; + /** + * http://www.vt100.net/docs/vt510-rm/DECOM: "When DECOM is set, the home cursor position is at the upper-left + * corner of the screen, within the margins. The starting point for line numbers depends on the current top margin + * setting. The cursor cannot move outside of the margins. When DECOM is reset, the home cursor position is at the + * upper-left corner of the screen. The starting point for line numbers is independent of the margins. The cursor + * can move outside of the margins." + */ + private static final int DECSET_BIT_ORIGIN_MODE = 1 << 2; + /** + * http://www.vt100.net/docs/vt510-rm/DECAWM: "If the DECAWM function is set, then graphic characters received when + * the cursor is at the right border of the page appear at the beginning of the next line. Any text on the page + * scrolls up if the cursor is at the end of the scrolling region. If the DECAWM function is reset, then graphic + * characters received when the cursor is at the right border of the page replace characters already on the page." + */ + private static final int DECSET_BIT_AUTOWRAP = 1 << 3; + /** DECSET 25 - if the cursor should be enabled, {@link #isCursorEnabled()}. */ + private static final int DECSET_BIT_CURSOR_ENABLED = 1 << 4; + private static final int DECSET_BIT_APPLICATION_KEYPAD = 1 << 5; + /** DECSET 1000 - if to report mouse press&release events. */ + private static final int DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 << 6; + /** DECSET 1002 - like 1000, but report moving mouse while pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 << 7; + /** DECSET 1004 - NOT implemented. */ + private static final int DECSET_BIT_SEND_FOCUS_EVENTS = 1 << 8; + /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */ + private static final int DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 << 9; + /** DECSET 2004 - see {@link #paste(String)} */ + private static final int DECSET_BIT_BRACKETED_PASTE_MODE = 1 << 10; + /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */ + private static final int DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 << 11; + /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */ + private static final int DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 << 12; + + + private String mTitle; + private final Stack mTitleStack = new Stack<>(); + + /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ + private int mCursorRow, mCursorCol; + + /** The number of character rows and columns in the terminal screen. */ + public int mRows, mColumns; + + /** Size of a terminal cell in pixels. */ + private int mCellWidthPixels, mCellHeightPixels; + + /** The number of terminal transcript rows that can be scrolled back to. */ + public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; + public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; + public static final int DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000; + + + /* The supported terminal cursor styles. */ + + public static final int TERMINAL_CURSOR_STYLE_BLOCK = 0; + public static final int TERMINAL_CURSOR_STYLE_UNDERLINE = 1; + public static final int TERMINAL_CURSOR_STYLE_BAR = 2; + public static final int DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK; + public static final Integer[] TERMINAL_CURSOR_STYLES_LIST = new Integer[]{TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR}; + + /** The terminal cursor styles. */ + private int mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + + + /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */ + private final TerminalBuffer mMainBuffer; + /** + * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when + * the alternate screen buffer is active, you cannot scroll back to view saved lines). + *

+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer + */ + final TerminalBuffer mAltBuffer; + /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */ + private TerminalBuffer mScreen; + + /** The terminal session this emulator is bound to. */ + private final TerminalOutput mSession; + + TerminalSessionClient mClient; + + /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */ + private int mArgIndex; + /** Holds the arguments of the current escape sequence. */ + private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */ + private int mArgsSubParamsBitSet = 0; + + /** Holds OSC and device control arguments, which can be strings. */ + private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + + /** + * True if the current escape sequence should continue, false if the current escape sequence should be terminated. + * Used when parsing a single character. + */ + private boolean mContinueSequence; + + /** The current state of the escape sequence state machine. One of the ESC_* constants. */ + private int mEscapeState; + + private final SavedScreenState mSavedStateMain = new SavedScreenState(); + private final SavedScreenState mSavedStateAlt = new SavedScreenState(); + + /** http://www.vt100.net/docs/vt102-ug/table5-15.html */ + private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + + /** + * @see TerminalEmulator#mapDecSetBitToInternalBit(int) + */ + private int mCurrentDecSetFlags, mSavedDecSetFlags; + + /** + * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing + * existing text to the right. Characters moved past the right margin are lost. + */ + private boolean mInsertMode; + + /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */ + private boolean[] mTabStop; + + /** + * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows + * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns]. + */ + private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin; + + /** + * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case + * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be + * output in the last column, the cursor not moving but this flag will be set. When outputting another character + * this will move to the next line. + */ + private boolean mAboutToAutoWrap; + + /** + * If the cursor blinking is enabled. It requires cursor itself to be enabled, which is controlled + * byt whether {@link #DECSET_BIT_CURSOR_ENABLED} bit is set or not. + */ + private boolean mCursorBlinkingEnabled; + + /** + * If currently cursor should be in a visible state or not if {@link #mCursorBlinkingEnabled} + * is {@code true}. + */ + private boolean mCursorBlinkState; + + /** + * Current foreground, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * For a 24-bit value the top byte (0xff000000) is set. + * + *

Note that the underline color is currently parsed but not yet used during rendering. + * + * @see TextStyle + */ + int mForeColor, mBackColor, mUnderlineColor; + + /** Current {@link TextStyle} effect. */ + int mEffect; + + /** + * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along + * with the scrolling text. + */ + private int mScrollCounter = 0; + + private byte mUtf8ToFollow, mUtf8Index; + private final byte[] mUtf8InputBuffer = new byte[4]; + private int mLastEmittedCodePoint = -1; + + public final TerminalColors mColors = new TerminalColors(); + + private static final String LOG_TAG = "TerminalEmulator"; + + private boolean isDecsetInternalBitSet(int bit) { + return (mCurrentDecSetFlags & bit) != 0; + } + + private void setDecsetinternalBit(int internalBit, boolean set) { + if (set) { + // The mouse modes are mutually exclusive. + if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false); + } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false); + } + } + if (set) { + mCurrentDecSetFlags |= internalBit; + } else { + mCurrentDecSetFlags &= ~internalBit; + } + } + + static int mapDecSetBitToInternalBit(int decsetBit) { + switch (decsetBit) { + case 1: + return DECSET_BIT_APPLICATION_CURSOR_KEYS; + case 5: + return DECSET_BIT_REVERSE_VIDEO; + case 6: + return DECSET_BIT_ORIGIN_MODE; + case 7: + return DECSET_BIT_AUTOWRAP; + case 25: + return DECSET_BIT_CURSOR_ENABLED; + case 66: + return DECSET_BIT_APPLICATION_KEYPAD; + case 69: + return DECSET_BIT_LEFTRIGHT_MARGIN_MODE; + case 1000: + return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE; + case 1002: + return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT; + case 1004: + return DECSET_BIT_SEND_FOCUS_EVENTS; + case 1006: + return DECSET_BIT_MOUSE_PROTOCOL_SGR; + case 2004: + return DECSET_BIT_BRACKETED_PASTE_MODE; + default: + return -1; + // throw new IllegalArgumentException("Unsupported decset: " + decsetBit); + } + } + + public TerminalEmulator(TerminalOutput session, int columns, int rows, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) { + mSession = session; + mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); + mAltBuffer = new TerminalBuffer(columns, rows, rows); + mClient = client; + mRows = rows; + mColumns = columns; + mCellWidthPixels = cellWidthPixels; + mCellHeightPixels = cellHeightPixels; + mTabStop = new boolean[mColumns]; + reset(); + } + + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + setCursorStyle(); + setCursorBlinkState(true); + } + + public TerminalBuffer getScreen() { + return mScreen; + } + + public boolean isAlternateBufferActive() { + return mScreen == mAltBuffer; + } + + private int getTerminalTranscriptRows(Integer transcriptRows) { + if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX) + return DEFAULT_TERMINAL_TRANSCRIPT_ROWS; + else + return transcriptRows; + } + + /** + * @param mouseButton one of the MOUSE_* constants of this class. + */ + public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) { + if (column < 1) column = 1; + if (column > mColumns) column = mColumns; + if (row < 1) row = 1; + if (row > mRows) row = mRows; + + if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) { + // Do not send tracking. + } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) { + mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row)); + } else { + mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons. + // Clip to screen, and clip to the limits of 8-bit data. + boolean out_of_bounds = column > 255 - 32 || row > 255 - 32; + if (!out_of_bounds) { + byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)}; + mSession.write(data, 0, data.length); + } + } + } + + public void resize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + this.mCellWidthPixels = cellWidthPixels; + this.mCellHeightPixels = cellHeightPixels; + + if (mRows == rows && mColumns == columns) { + return; + } else if (columns < 2 || rows < 2) { + throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns); + } + + if (mRows != rows) { + mRows = rows; + mTopMargin = 0; + mBottomMargin = mRows; + } + if (mColumns != columns) { + int oldColumns = mColumns; + mColumns = columns; + boolean[] oldTabStop = mTabStop; + mTabStop = new boolean[mColumns]; + setDefaultTabStops(); + int toTransfer = Math.min(oldColumns, columns); + System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer); + mLeftMargin = 0; + mRightMargin = mColumns; + } + + resizeScreen(); + } + + private void resizeScreen() { + final int[] cursor = {mCursorCol, mCursorRow}; + int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows; + mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive()); + mCursorCol = cursor[0]; + mCursorRow = cursor[1]; + } + + public int getCursorRow() { + return mCursorRow; + } + + public int getCursorCol() { + return mCursorCol; + } + + /** Get the terminal cursor style. It will be one of {@link #TERMINAL_CURSOR_STYLES_LIST} */ + public int getCursorStyle() { + return mCursorStyle; + } + + /** Set the terminal cursor style. */ + public void setCursorStyle() { + Integer cursorStyle = null; + + if (mClient != null) + cursorStyle = mClient.getTerminalCursorStyle(); + + if (cursorStyle == null || !Arrays.asList(TERMINAL_CURSOR_STYLES_LIST).contains(cursorStyle)) + mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + else + mCursorStyle = cursorStyle; + } + + public boolean isReverseVideo() { + return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO); + } + + + + public boolean isCursorEnabled() { + return isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED); + } + public boolean shouldCursorBeVisible() { + if (!isCursorEnabled()) + return false; + else + return mCursorBlinkingEnabled ? mCursorBlinkState : true; + } + + public void setCursorBlinkingEnabled(boolean cursorBlinkingEnabled) { + this.mCursorBlinkingEnabled = cursorBlinkingEnabled; + } + + public void setCursorBlinkState(boolean cursorBlinkState) { + this.mCursorBlinkState = cursorBlinkState; + } + + + + public boolean isKeypadApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD); + } + + public boolean isCursorKeysApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS); + } + + /** If mouse events are being sent as escape codes to the terminal. */ + public boolean isMouseTrackingActive() { + return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT); + } + + private void setDefaultTabStops() { + for (int i = 0; i < mColumns; i++) + mTabStop[i] = (i & 7) == 0 && i != 0; + } + + /** + * Accept bytes (typically from the pseudo-teletype) and process them. + * + * @param buffer a byte array containing the bytes to be processed + * @param length the number of bytes in the array to process + */ + public void append(byte[] buffer, int length) { + for (int i = 0; i < length; i++) + processByte(buffer[i]); + } + + private void processByte(byte byteToProcess) { + if (mUtf8ToFollow > 0) { + if ((byteToProcess & 0b11000000) == 0b10000000) { + // 10xxxxxx, a continuation byte. + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + if (--mUtf8ToFollow == 0) { + byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111)); + int codePoint = (mUtf8InputBuffer[0] & firstByteMask); + for (int i = 1; i < mUtf8Index; i++) + codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111)); + if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2) + || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) { + // Overlong encoding. + codePoint = UNICODE_REPLACEMENT_CHAR; + } + + mUtf8Index = mUtf8ToFollow = 0; + + if (codePoint >= 0x80 && codePoint <= 0x9F) { + // Sequence decoded to a C1 control character which we ignore. They are + // not used nowadays and increases the risk of messing up the terminal state + // on binary input. XTerm does not allow them in utf-8: + // "It is not possible to use a C1 control obtained from decoding the + // UTF-8 text" - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + } else { + switch (Character.getType(codePoint)) { + case Character.UNASSIGNED: + case Character.SURROGATE: + codePoint = UNICODE_REPLACEMENT_CHAR; + } + processCodePoint(codePoint); + } + } + } else { + // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char: + mUtf8Index = mUtf8ToFollow = 0; + emitCodePoint(UNICODE_REPLACEMENT_CHAR); + // The Unicode Standard Version 6.2 – Core Specification + // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first + // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the + // successor bytes as part of the ill-formed subsequence + // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit + // subsequence." + processByte(byteToProcess); + } + } else { + if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character. + processCodePoint(byteToProcess); + return; + } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence. + mUtf8ToFollow = 1; + } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence. + mUtf8ToFollow = 2; + } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence. + mUtf8ToFollow = 3; + } else { + // Not a valid UTF-8 sequence start, signal invalid data: + processCodePoint(UNICODE_REPLACEMENT_CHAR); + return; + } + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + } + } + + public void processCodePoint(int b) { + // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. + if (mEscapeState == ESC_APC) { + doApc(b); + return; + } else if (mEscapeState == ESC_APC_ESCAPE) { + doApcEscape(b); + return; + } + + switch (b) { + case 0: // Null character (NUL, ^@). Do nothing. + break; + case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell. + if (mEscapeState == ESC_OSC) + doOsc(b); + else + mSession.onBell(); + break; + case 8: // Backspace (BS, ^H). + if (mLeftMargin == mCursorCol) { + // Jump to previous line if it was auto-wrapped. + int previousRow = mCursorRow - 1; + if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) { + mScreen.clearLineWrap(previousRow); + setCursorRowCol(previousRow, mRightMargin - 1); + } + } else { + setCursorCol(mCursorCol - 1); + } + break; + case 9: // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen + // XXX: Should perhaps use color if writing to new cells. Try with + // printf "\033[41m\tXX\033[0m\n" + // The OSX Terminal.app colors the spaces from the tab red, but xterm does not. + // Note that Terminal.app only colors on new cells, in e.g. + // printf "\033[41m\t\r\033[42m\tXX\033[0m\n" + // the first cells are created with a red background, but when tabbing over + // them again with a green background they are not overwritten. + mCursorCol = nextTabStop(1); + break; + case 10: // Line feed (LF, \n). + case 11: // Vertical tab (VT, \v). + case 12: // Form feed (FF, \f). + doLinefeed(); + break; + case 13: // Carriage return (CR, \r). + setCursorCol(mLeftMargin); + break; + case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. + mUseLineDrawingUsesG0 = false; + break; + case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set. + mUseLineDrawingUsesG0 = true; + break; + case 24: // CAN. + case 26: // SUB. + if (mEscapeState != ESC_NONE) { + // FIXME: What is this?? + mEscapeState = ESC_NONE; + emitCodePoint(127); + } + break; + case 27: // ESC + // Starts an escape sequence unless we're parsing a string + if (mEscapeState == ESC_P) { + // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + return; + } else if (mEscapeState != ESC_OSC) { + startEscapeSequence(); + } else { + doOsc(b); + } + break; + default: + mContinueSequence = false; + switch (mEscapeState) { + case ESC_NONE: + if (b >= 32) emitCodePoint(b); + break; + case ESC: + doEsc(b); + break; + case ESC_POUND: + doEscPound(b); + break; + case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100). + mUseLineDrawingG0 = (b == '0'); + break; + case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100). + mUseLineDrawingG1 = (b == '0'); + break; + case ESC_CSI: + doCsi(b); + break; + case ESC_CSI_UNSUPPORTED_PARAMETER_BYTE: + case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE: + doCsiUnsupportedParameterOrIntermediateByte(b); + break; + case ESC_CSI_EXCLAMATION: + if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). + reset(); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_QUESTIONMARK: + doCsiQuestionMark(b); + break; + case ESC_CSI_BIGGERTHAN: + doCsiBiggerThan(b); + break; + case ESC_CSI_DOLLAR: + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + switch (b) { + case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v" + // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA): + // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA. + // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM). + // DECCRA is not affected by the page margins. + // The copied text takes on the line attributes of the destination area. + // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value + // is treated as the width or height of that page. + // If the destination area is partially off the page, then DECCRA clips the off-page data. + // DECCRA does not change the active cursor position." + int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows); + int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns); + // Inclusive, so do not subtract one: + int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows); + int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns); + // int sourcePage = getArg(4, 1, true); + int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows); + int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns); + // int destinationPage = getArg(7, 1, true); + int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource); + int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource); + mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop); + break; + case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${" + // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA). + case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x" + // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA). + case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z" + // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA). + boolean erase = b != 'x'; + boolean selective = b == '{'; + // Only DECSERA keeps visual attributes, DECERA does not: + boolean keepVisualAttributes = erase && selective; + int argIndex = 0; + int fillChar = erase ? ' ' : getArg(argIndex++, -1, true); + // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the + // terminal ignores the DECFRA command": + if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) { + // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value + // is treated as the width or height of that page." + int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1); + int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1); + int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin); + int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin); + long style = getStyle(); + for (int row = top - 1; row < bottom; row++) + for (int col = left - 1; col < right; col++) + if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style); + } + break; + case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r" + // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA). + case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t" + // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA). + boolean reverse = b == 't'; + // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)". + int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin; + int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin; + int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin; + int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin; + if (mArgIndex >= 4) { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 4; i <= mArgIndex; i++) { + int bits = 0; + boolean setOrClear = true; // True if setting, false if clearing. + switch (getArg(i, 0, false)) { + case 0: // Attributes off (no bold, no underline, no blink, positive image). + bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK + | TextStyle.CHARACTER_ATTRIBUTE_INVERSE); + if (!reverse) setOrClear = false; + break; + case 1: // Bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + break; + case 4: // Underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + break; + case 5: // Blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + break; + case 7: // Negative image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + break; + case 22: // No bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + setOrClear = false; + break; + case 24: // No underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + setOrClear = false; + break; + case 25: // No blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + setOrClear = false; + break; + case 27: // Positive image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + setOrClear = false; + break; + } + if (reverse && !setOrClear) { + // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits. + } else { + mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE), + effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right); + } + } + } else { + // Do nothing. + } + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_DOUBLE_QUOTE: + if (b == 'q') { + // http://www.vt100.net/docs/vt510-rm/DECSCA + int arg = getArg0(0); + if (arg == 0 || arg == 2) { + // DECSED and DECSEL can erase characters. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else if (arg == 1) { + // DECSED and DECSEL cannot erase characters. + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else { + unknownSequence(b); + } + } else { + unknownSequence(b); + } + break; + case ESC_CSI_SINGLE_QUOTE: + if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToInsert; + mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0); + blockClear(mCursorCol, 0, columnsToInsert, mRows); + } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToDelete; + mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0); + } else { + unknownSequence(b); + } + break; + case ESC_PERCENT: + break; + case ESC_OSC: + doOsc(b); + break; + case ESC_OSC_ESC: + doOscEsc(b); + break; + case ESC_P: + doDeviceControl(b); + break; + case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: + if (b == 'p') { + // Request DEC private mode (DECRQM). + int mode = getArg0(0); + int value; + if (mode == 47 || mode == 1047 || mode == 1049) { + // This state is carried by mScreen pointer. + value = (mScreen == mAltBuffer) ? 1 : 2; + } else { + int internalBit = mapDecSetBitToInternalBit(mode); + if (internalBit != -1) { + value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset. + } else { + Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode); + value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset + } + } + mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value)); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_SPACE: + int arg = getArg0(0); + switch (b) { + case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR). + switch (arg) { + case 0: // Blinking block. + case 1: // Blinking block. + case 2: // Steady block. + mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK; + break; + case 3: // Blinking underline. + case 4: // Steady underline. + mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE; + break; + case 5: // Blinking bar (xterm addition). + case 6: // Steady bar (xterm addition). + mCursorStyle = TERMINAL_CURSOR_STYLE_BAR; + break; + } + break; + case 't': + case 'u': + // Set margin-bell volume - ignore. + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_ASTERIX: + int attributeChangeExtent = getArg0(0); + if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) { + // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE). + setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2); + } else { + unknownSequence(b); + } + break; + default: + unknownSequence(b); + break; + } + if (!mContinueSequence) mEscapeState = ESC_NONE; + break; + } + } + + /** When in {@link #ESC_P} ("device control") sequence. */ + private void doDeviceControl(int b) { + switch (b) { + case (byte) '\\': // End of ESC \ string Terminator + { + String dcs = mOSCOrDeviceControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) + if (dcs.startsWith("$q")) { + if (dcs.equals("$q\"p")) { + // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: + String csiString = "64;1\"p"; + mSession.write("\033P1$r" + csiString + "\033\\"); + } else { + finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'"); + } + } else if (dcs.startsWith("+q")) { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + // Example: + // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\ + // where + // kd=down-arrow key + // kl=left-arrow key + // kr=right-arrow key + // ku=up-arrow key + // #2=key_shome, "shifted home" + // #4=key_sleft, "shift arrow left" + // %i=key_sright, "shift arrow right" + // *7=key_send, "shifted end" + // k1=F1 function key + + // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal. + // Xterm response in normal cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A + // Xterm response in application cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A + + // #4 is "shift arrow left": + // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \' + // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \ + // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D + // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D"); + + // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to + // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for + // the meaning of e.g. "ku", "kd", "kr", "kl" + + for (String part : dcs.substring(2).split(";")) { + if (part.length() % 2 == 0) { + StringBuilder transBuffer = new StringBuilder(); + char c; + for (int i = 0; i < part.length(); i += 2) { + try { + c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue(); + } catch (NumberFormatException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"" + part + "\"", e); + continue; + } + transBuffer.append(c); + } + + String trans = transBuffer.toString(); + String responseValue; + switch (trans) { + case "Co": + case "colors": + responseValue = "256"; // Number of colors. + break; + case "TN": + case "name": + responseValue = "xterm"; + break; + default: + responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS), + isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)); + break; + } + if (responseValue == null) { + switch (trans) { + case "%1": // Help key - ignore + case "&8": // Undo key - ignore. + break; + default: + Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'"); + } + // Respond with invalid request: + mSession.write("\033P0+r" + part + "\033\\"); + } else { + StringBuilder hexEncoded = new StringBuilder(); + for (int j = 0; j < responseValue.length(); j++) { + hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j))); + } + mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\"); + } + } else { + Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); + } + } + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); + } + finishSequence(); + } + break; + default: + if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { + // Too long. + mOSCOrDeviceControlArgs.setLength(0); + finishSequence(); + } else { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } + } + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApc(int b) { + if (b == 27) { + continueSequence(ESC_APC_ESCAPE); + } + // Eat APC sequences silently for now. + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApcEscape(int b) { + if (b == '\\') { + // A String Terminator (ST), ending the APC escape sequence. + finishSequence(); + } else { + // The Escape character was not the start of a String Terminator (ST), + // but instead just data inside of the APC escape sequence. + continueSequence(ESC_APC); + } + } + + private int nextTabStop(int numTabs) { + for (int i = mCursorCol + 1; i < mColumns; i++) + if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); + return mRightMargin - 1; + } + + /** + * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or + * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state. + * + * Parse unsupported parameter, intermediate and final bytes but ignore them. + * + * > For Control Sequence Introducer, ... the ESC [ is followed by + * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?), + * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), + * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~). + * + * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands + * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4 + */ + private void doCsiUnsupportedParameterOrIntermediateByte(int b) { + if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) { + // Supported `0–9:;>?` or unsupported `<=` parameter byte after an + // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte. + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + } else if (b >= 0x20 && b <= 0x2F) { + // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte. + continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); + } else if (b >= 0x40 && b <= 0x7E) { + // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte. + // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now. + finishSequence(); + } else { + unknownSequence(b); + } + } + + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ + private void doCsiQuestionMark(int b) { + switch (b) { + case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. + case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. + mAboutToAutoWrap = false; + int fillChar = ' '; + int startCol = -1; + int startRow = -1; + int endCol = -1; + int endRow = -1; + boolean justRow = (b == 'K'); + switch (getArg0(0)) { + case 0: // Erase from the active position to the end, inclusive (default). + startCol = mCursorCol; + startRow = mCursorRow; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + case 1: // Erase from start to the active position, inclusive. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mCursorCol + 1; + endRow = mCursorRow + 1; + break; + case 2: // Erase all of the display/line. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + default: + unknownSequence(b); + break; + } + long style = getStyle(); + for (int row = startRow; row < endRow; row++) { + for (int col = startCol; col < endCol; col++) { + if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, style); + } + } + break; + case 'h': + case 'l': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) + doDecSetOrReset(b == 'h', mArgs[i]); + break; + case 'n': // Device Status Report (DSR, DEC-specific). + switch (getArg0(-1)) { + case 6: + // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1. + mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1)); + break; + default: + finishSequence(); + return; + } + break; + case 'r': + case 's': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int externalBit = mArgs[i]; + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit == -1) { + Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit); + } else { + if (b == 's') { + mSavedDecSetFlags |= internalBit; + } else { + doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit); + } + } + } + break; + case '$': + continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR); + return; + default: + parseArg(b); + } + } + + public void doDecSetOrReset(boolean setting, int externalBit) { + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit != -1) { + setDecsetinternalBit(internalBit, setting); + } + switch (externalBit) { + case 1: // Application Cursor Keys (DECCKM). + break; + case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM. + // We don't actually set/reset 132 cols, but we do want the side effects + // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?): + // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for + // the "reset" utility to really reset the terminal: + mLeftMargin = mTopMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable": + setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false); + // "Erases all data in page memory": + blockClear(0, 0, mColumns, mRows); + setCursorRowCol(0, 0); + break; + case 4: // DECSCLM-Scrolling Mode. Ignore. + break; + case 5: // Reverse video. No action. + break; + case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM. + if (setting) setCursorPosition(0, 0); + break; + case 7: // Wrap-around bit, not specific action. + case 8: // Auto-repeat Keys (DECARM). Do not implement. + case 9: // X10 mouse reporting - outdated. Do not implement. + case 12: // Control cursor blinking - ignore. + case 25: // Hide/show cursor - no action needed, renderer will check with shouldCursorBeVisible(). + if (mClient != null) + mClient.onTerminalCursorStateChange(setting); + break; + case 40: // Allow 80 => 132 Mode, ignore. + case 45: // TODO: Reverse wrap-around. Implement??? + case 66: // Application keypad (DECNKM). + break; + case 69: // Left and right margin mode (DECLRMM). + if (!setting) { + mLeftMargin = 0; + mRightMargin = mColumns; + } + break; + case 1000: + case 1001: + case 1002: + case 1003: + case 1004: + case 1005: // UTF-8 mouse mode, ignore. + case 1006: // SGR Mouse Mode + case 1015: + case 1034: // Interpret "meta" key, sets eighth bit. + break; + case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC. + if (setting) + saveCursor(); + else + restoreCursor(); + break; + case 47: + case 1047: + case 1049: { + // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + // Reset: Use Normal Screen Buffer and restore cursor as in DECRC. + TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer; + if (newScreen != mScreen) { + boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); + if (setting) saveCursor(); + mScreen = newScreen; + if (!setting) { + int col = mSavedStateMain.mSavedCursorCol; + int row = mSavedStateMain.mSavedCursorRow; + restoreCursor(); + if (resized) { + // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that): + mCursorCol = col; + mCursorRow = row; + } + } + // Check if buffer size needs to be updated: + if (resized) resizeScreen(); + // Clear new screen if alt buffer: + if (newScreen == mAltBuffer) + newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle()); + } + break; + } + case 2004: + // Bracketed paste mode - setting bit is enough. + break; + default: + unknownParameter(externalBit); + break; + } + } + + private void doCsiBiggerThan(int b) { + switch (b) { + case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). + // Originally this was used for the terminal to respond with "identification code, firmware version level, + // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 + // terminal type. This is not used anymore, but the second version level field has been changed by xterm + // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html), + // and some applications use it as a feature check: + // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check, + // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used. + // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR + // mouse report. + // The third number is a keyboard identifier not used nowadays. + mSession.write("\033[>41;320;0c"); + break; + case 'm': + // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25 + // Depending on the first number parameter, this can set one of the xterm resources + // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys. + // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES + + // * modifyKeyboard (parameter=1): + // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard + // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related + // terminals that implement user-defined keys (UDK). + // The bits of the resource value selectively enable modification of the given category when these keyboards + // are selected. The default is "0": + // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered + // function-keys. Other special keys are not modified. + // (1) allows modification of the numeric keypad + // (2) allows modification of the editing keypad + // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK. + // (8) allows modification of other special keys + + // * modifyCursorKeys (parameter=2): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a cursor-key. The default is "2". + // - Set it to -1 to disable it. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + + // * modifyFunctionKeys (parameter=3): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a (numbered) function- + // key. The default is "2". The resource values are similar to modifyCursorKeys: + // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings + // using the normal encoding scheme. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct + // numbered function-keys beyond the set provided by the keyboard: + // (Control) adds the value given by the ctrlFKeys resource. + // (Shift) adds twice the value given by the ctrlFKeys resource. + // (Control/Shift) adds three times the value given by the ctrlFKeys resource. + // + // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true) + // keyboards interpret only the Control-modifier when constructing numbered function-keys. + // This is done to provide compatible keyboards for DEC VT220 and related terminals that + // implement user-defined keys (UDK). + + // * modifyOtherKeys (parameter=4): + // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when + // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and + // well-defined keys such as ESC or the control keys. The default is "0". + // (0) disables this feature. + // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and + // some special control character cases, e.g., Control-Space to make a NUL. + // (2) enables this feature for keys including the exceptions listed. + Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1)); + break; + default: + parseArg(b); + break; + } + } + + private void startEscapeSequence() { + mEscapeState = ESC; + mArgIndex = 0; + Arrays.fill(mArgs, -1); + mArgsSubParamsBitSet = 0; + } + + private void doLinefeed() { + boolean belowScrollingRegion = mCursorRow >= mBottomMargin; + int newCursorRow = mCursorRow + 1; + if (belowScrollingRegion) { + // Move down (but not scroll) as long as we are above the last row. + if (mCursorRow != mRows - 1) { + setCursorRow(newCursorRow); + } + } else { + if (newCursorRow == mBottomMargin) { + scrollDownOneLine(); + newCursorRow = mBottomMargin - 1; + } + setCursorRow(newCursorRow); + } + } + + private void continueSequence(int state) { + mEscapeState = state; + mContinueSequence = true; + } + + private void doEscPound(int b) { + switch (b) { + case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's. + mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle()); + break; + default: + unknownSequence(b); + break; + } + } + + /** Encountering a character in the {@link #ESC} state. */ + private void doEsc(int b) { + switch (b) { + case '#': + continueSequence(ESC_POUND); + break; + case '(': + continueSequence(ESC_SELECT_LEFT_PAREN); + break; + case ')': + continueSequence(ESC_SELECT_RIGHT_PAREN); + break; + case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start. + if (mCursorCol > mLeftMargin) { + mCursorCol--; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin); + mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC + saveCursor(); + break; + case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC + restoreCursor(); + break; + case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end. + if (mCursorCol < mRightMargin - 1) { + mCursorCol++; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin); + mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS). + reset(); + mMainBuffer.clearTranscript(); + blockClear(0, 0, mColumns, mRows); + setCursorPosition(0, 0); + break; + case 'D': // INDEX + doLinefeed(); + break; + case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL). + setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0); + doLinefeed(); + break; + case 'F': // Cursor to lower-left corner of screen + setCursorRowCol(0, mBottomMargin - 1); + break; + case 'H': // Tab set + mTabStop[mCursorCol] = true; + break; + case 'M': // "${ESC}M" - reverse index (RI). + // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal + // position on the preceding line. If the active position is at the top margin, a scroll down is performed". + if (mCursorRow <= mTopMargin) { + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin); + } else { + mCursorRow--; + } + break; + case 'N': // SS2, ignore. + case '0': // SS3, ignore. + break; + case 'P': // Device control string + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_P); + break; + case '[': + continueSequence(ESC_CSI); + break; + case '=': // DECKPAM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); + break; + case ']': // OSC + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_OSC); + break; + case '>': // DECKPNM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); + break; + case '_': // APC - Application Program Command. + continueSequence(ESC_APC); + break; + default: + unknownSequence(b); + break; + } + } + + /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */ + private void saveCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + state.mSavedCursorRow = mCursorRow; + state.mSavedCursorCol = mCursorCol; + state.mSavedEffect = mEffect; + state.mSavedForeColor = mForeColor; + state.mSavedBackColor = mBackColor; + state.mSavedDecFlags = mCurrentDecSetFlags; + state.mUseLineDrawingG0 = mUseLineDrawingG0; + state.mUseLineDrawingG1 = mUseLineDrawingG1; + state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0; + } + + /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */ + private void restoreCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol); + mEffect = state.mSavedEffect; + mForeColor = state.mSavedForeColor; + mBackColor = state.mSavedBackColor; + int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE); + mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask); + mUseLineDrawingG0 = state.mUseLineDrawingG0; + mUseLineDrawingG1 = state.mUseLineDrawingG1; + mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0; + } + + /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */ + private void doCsi(int b) { + switch (b) { + case '!': + continueSequence(ESC_CSI_EXCLAMATION); + break; + case '"': + continueSequence(ESC_CSI_DOUBLE_QUOTE); + break; + case '\'': + continueSequence(ESC_CSI_SINGLE_QUOTE); + break; + case '$': + continueSequence(ESC_CSI_DOLLAR); + break; + case '*': + continueSequence(ESC_CSI_ARGS_ASTERIX); + break; + case '@': { + // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH. + mAboutToAutoWrap = false; + int columnsAfterCursor = mColumns - mCursorCol; + int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor); + int charsToMove = columnsAfterCursor - spacesToInsert; + mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow); + blockClear(mCursorCol, mCursorRow, spacesToInsert); + } + break; + case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows. + setCursorRow(Math.max(0, mCursorRow - getArg0(1))); + break; + case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows. + setCursorRow(Math.min(mRows - 1, mCursorRow + getArg0(1))); + break; + case 'C': // "CSI${n}C" - Cursor forward (CUF). + case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48. + setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1))); + break; + case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns. + setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1))); + break; + case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow + getArg0(1)); + break; + case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow - getArg0(1)); + break; + case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}. + setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1); + break; + case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP). + case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). + setCursorPosition(getArg1(1) - 1, getArg0(1) - 1); + break; + case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward. + setCursorCol(nextTabStop(getArg0(1))); + break; + case 'J': // "${CSI}${0,1,2,3}J" - Erase in Display (ED) + // ED ignores the scrolling margins. + switch (getArg0(0)) { + case 0: // Erase from the active position to the end of the screen, inclusive (default). + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1)); + break; + case 1: // Erase from start of the screen to the active position, inclusive. + blockClear(0, 0, mColumns, mCursorRow); + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not + // move.. + blockClear(0, 0, mColumns, mRows); + break; + case 3: // Delete all lines saved in the scrollback buffer (xterm etc) + mMainBuffer.clearTranscript(); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'K': // "CSI{n}K" - Erase in line (EL). + switch (getArg0(0)) { + case 0: // Erase from the cursor to the end of the line, inclusive (default) + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + break; + case 1: // Erase from the start of the screen to the cursor, inclusive. + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the line. + blockClear(0, mCursorRow, mColumns); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'L': // "${CSI}{N}L" - insert ${N} lines (IL). + { + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToInsert = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToInsert; + mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert); + blockClear(0, mCursorRow, mColumns, linesToInsert); + } + break; + case 'M': // "${CSI}${N}M" - delete N lines (DL). + { + mAboutToAutoWrap = false; + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToDelete = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToDelete; + mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow); + blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete); + } + break; + case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH). + { + // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the + // cursor and the right margin, then DCH only deletes the remaining characters. + // As characters are deleted, the remaining characters between the cursor and right margin move to the left. + // Character attributes move with the characters. The terminal adds blank spaces with no visual character + // attributes at the right margin. DCH has no effect outside the scrolling margins." + mAboutToAutoWrap = false; + int cellsAfterCursor = mColumns - mCursorCol; + int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor); + int cellsToMove = cellsAfterCursor - cellsToDelete; + mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow); + blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete); + } + break; + case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU). + final int linesToScroll = getArg0(1); + for (int i = 0; i < linesToScroll; i++) + scrollDownOneLine(); + break; + } + case 'T': + if (mArgIndex == 0) { + // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD). + // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page + // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the + // display. You cannot pan past the top margin of the current page". + final int linesToScrollArg = getArg0(1); + final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; + final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll); + } else { + // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. + unimplementedSequence(b); + } + break; + case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes? + mAboutToAutoWrap = false; + mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle()); + break; + case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward. + int numberOfTabs = getArg0(1); + int newCol = mLeftMargin; + for (int i = mCursorCol - 1; i >= 0; i--) + if (mTabStop[i]) { + if (--numberOfTabs == 0) { + newCol = Math.max(i, mLeftMargin); + break; + } + } + mCursorCol = newCol; + break; + case '?': // Esc [ ? -- start of a private parameter byte + continueSequence(ESC_CSI_QUESTIONMARK); + break; + case '>': // "Esc [ >" -- start of a private parameter byte + continueSequence(ESC_CSI_BIGGERTHAN); + break; + case '<': // "Esc [ <" -- start of a private parameter byte + case '=': // "Esc [ =" -- start of a private parameter byte + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + break; + case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). + setCursorColRespectingOriginMode(getArg0(1) - 1); + break; + case 'b': // Repeat the preceding graphic character Ps times (REP). + if (mLastEmittedCodePoint == -1) break; + final int numRepeat = getArg0(1); + for (int i = 0; i < numRepeat; i++) emitCodePoint(mLastEmittedCodePoint); + break; + case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero. + // The important part that may still be used by some (tmux stores this value but does not currently use it) + // is the first response parameter identifying the terminal service class, where we send 64 for "vt420". + // This is followed by a list of attributes which is probably unused by applications. Send like xterm. + if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c"); + break; + case 'd': // ESC [ Pn d - Vert Position Absolute + setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); + break; + case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48). + setCursorPosition(mCursorCol, mCursorRow + getArg0(1)); + break; + // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'. + case 'g': // Clear tab stop + switch (getArg0(0)) { + case 0: + mTabStop[mCursorCol] = false; + break; + case 3: + for (int i = 0; i < mColumns; i++) { + mTabStop[i] = false; + } + break; + default: + // Specified to have no effect. + break; + } + break; + case 'h': // Set Mode + doSetMode(true); + break; + case 'l': // Reset Mode + doSetMode(false); + break; + case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments) + selectGraphicRendition(); + break; + case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands + // sendDeviceAttributes() + switch (getArg0(0)) { + case 5: // Device status report (DSR): + // Answer is ESC [ 0 n (Terminal OK). + byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'}; + mSession.write(dsr, 0, dsr.length); + break; + case 6: // Cursor position report (CPR): + // Answer is ESC [ y ; x R, where x,y is + // the cursor location. + mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1)); + break; + default: + break; + } + break; + case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM). + { + // https://vt100.net/docs/vt510-rm/DECSTBM.html + // The top margin defaults to 1, the bottom margin defaults to mRows. + // The escape sequence numbers top 1..23, but we number top 0..22. + // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering + // scheme, but we store the first line below the bottom-most scrolling line. + // As a result, we adjust the top line by -1, but we leave the bottom line alone. + // Also require that top + 2 <= bottom. + mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2)); + mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows)); + + // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode. + setCursorPosition(0, 0); + } + break; + case 's': + if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) { + // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM). + mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2); + mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns)); + // DECSLRM moves the cursor to column 1, line 1 of the page. + setCursorPosition(0, 0); + } else { + // Save cursor (ANSI.SYS), available only when DECLRMM is disabled. + saveCursor(); + } + break; + case 't': // Window manipulation (from dtterm, as well as extensions) + switch (getArg0(0)) { + case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + mSession.write("\033[1t"); + break; + case 13: // Report xterm window position. Result is CSI 3 ; x ; y t + mSession.write("\033[3;0;0t"); + break; + case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels)); + break; + case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t + mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels)); + break; + case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t + mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); + break; + case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t + // We report the same size as the view, since it's the view really isn't resizable from the shell. + mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns)); + break; + case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns: + mSession.write("\033]LIconLabel\033\\"); + break; + case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns: + mSession.write("\033]l\033\\"); + break; + case 22: + // 22;0 -> Save xterm icon and window title on stack. + // 22;1 -> Save xterm icon title on stack. + // 22;2 -> Save xterm window title on stack. + mTitleStack.push(mTitle); + if (mTitleStack.size() > 20) { + // Limit size + mTitleStack.remove(0); + } + break; + case 23: // Like 22 above but restore from stack. + if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop()); + break; + default: + // Ignore window manipulation. + break; + } + break; + case 'u': // Restore cursor (ANSI.SYS). + restoreCursor(); + break; + case ' ': + continueSequence(ESC_CSI_ARGS_SPACE); + break; + default: + parseArg(b); + break; + } + } + + /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */ + private void selectGraphicRendition() { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + // Skip leading sub parameters: + if ((mArgsSubParamsBitSet & (1 << i)) != 0) { + continue; + } + + int code = getArg(i, 0, false); + if (code < 0) { + if (mArgIndex > 0) { + continue; + } else { + code = 0; + } + } + if (code == 0) { // reset + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + mEffect = 0; + } else if (code == 1) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD; + } else if (code == 2) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM; + } else if (code == 3) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 4) { + if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) { + // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/ + i++; + if (mArgs[i] == 0) { + // No underline. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else { + // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/ + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else if (code == 5) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 7) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 8) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 9) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code == 10) { + // Exit alt charset (TERM=linux) - ignore. + } else if (code == 11) { + // Enter alt charset (TERM=linux) - ignore. + } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint. + mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM); + } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 24) { // underline: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 25) { // blink: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 27) { // image: positive + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 28) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 29) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code >= 30 && code <= 37) { + mForeColor = code - 30; + } else if (code == 38 || code == 48 || code == 58) { + // Extended set foreground(38)/background(48)/underline(58) color. + // This is followed by either "2;$R;$G;$B" to set a 24-bit color or + // "5;$INDEX" to set an indexed color. + if (i + 2 > mArgIndex) continue; + int firstArg = mArgs[i + 1]; + if (firstArg == 2) { + if (i + 4 > mArgIndex) { + Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); + } else { + int red = getArg(i + 2, 0, false); + int green = getArg(i + 3, 0, false); + int blue = getArg(i + 4, 0, false); + + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { + finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); + } else { + int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue; + switch (code) { + case 38: mForeColor = argbColor; break; + case 48: mBackColor = argbColor; break; + case 58: mUnderlineColor = argbColor; break; + } + } + i += 4; // "2;P_r;P_g;P_r" + } + } else if (firstArg == 5) { + int color = getArg(i + 2, 0, false); + i += 2; // "5;P_s" + if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { + switch (code) { + case 38: mForeColor = color; break; + case 48: mBackColor = color; break; + case 58: mUnderlineColor = color; break; + } + } else { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); + } + } else { + finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg); + } + } else if (code == 39) { // Set default foreground color. + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 40 && code <= 47) { // Set background color. + mBackColor = code - 40; + } else if (code == 49) { // Set default background color. + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code == 59) { // Set default underline color. + mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). + mForeColor = code - 90 + 8; + } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). + mBackColor = code - 100 + 8; + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code)); + } + } + } + + private void doOsc(int b) { + switch (b) { + case 7: // Bell. + doOscSetTextParameters("\007"); + break; + case 27: // Escape. + continueSequence(ESC_OSC_ESC); + break; + default: + collectOSCArgs(b); + break; + } + } + + private void doOscEsc(int b) { + switch (b) { + case '\\': + doOscSetTextParameters("\033\\"); + break; + default: + // The ESC character was not followed by a \, so insert the ESC and + // the current character in arg buffer. + collectOSCArgs(27); + collectOSCArgs(b); + continueSequence(ESC_OSC); + break; + } + } + + /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ + private void doOscSetTextParameters(String bellOrStringTerminator) { + int value = -1; + String textParameter = ""; + // Extract initial $value from initial "$value;..." string. + for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { + char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + if (b == ';') { + textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + } + + switch (value) { + case 0: // Change icon name and window title to T. + case 1: // Change icon name to T. + case 2: // Change window title to T. + setTitle(textParameter); + break; + case 4: + // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB + // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond + // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or + // 256-color table. + // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the + // same form which can be used to set the corresponding color. Because more than one pair of color number + // and specification can be given in one control sequence, xterm can make more than one reply. + int colorIndex = -1; + int parsingPairStart = -1; + for (int i = 0; ; i++) { + boolean endOfInput = i == textParameter.length(); + char b = endOfInput ? ';' : textParameter.charAt(i); + if (b == ';') { + if (parsingPairStart < 0) { + parsingPairStart = i + 1; + } else { + if (colorIndex < 0 || colorIndex > 255) { + unknownSequence(b); + return; + } else { + mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i)); + mSession.onColorsChanged(); + colorIndex = -1; + parsingPairStart = -1; + } + } + } else if (parsingPairStart >= 0) { + // We have passed a color index and are now going through color spec. + } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) { + colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + if (endOfInput) break; + } + break; + case 10: // Set foreground color. + case 11: // Set background color. + case 12: // Set cursor color. + int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10); + int lastSemiIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + String colorSpec = textParameter.substring(lastSemiIndex, charIndex); + if ("?".equals(colorSpec)) { + // Report current color in the same format xterm and gnome-terminal does. + int rgb = mColors.mCurrentColors[specialIndex]; + int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255; + int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255; + int b = (65535 * ((rgb & 0x000000FF))) / 255; + mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/" + + String.format(Locale.US, "%04x", b) + bellOrStringTerminator); + } else { + mColors.tryParseColor(specialIndex, colorSpec); + mSession.onColorsChanged(); + } + specialIndex++; + if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length()) + break; + lastSemiIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + break; + case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s). + int startIndex = textParameter.indexOf(";") + 1; + try { + String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8); + mSession.onCopyTextToClipboard(clipboardText); + } catch (Exception e) { + Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + } + break; + case 104: + // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X + // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, + // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no + // parameters are given, the entire table will be reset. + if (textParameter.isEmpty()) { + mColors.reset(); + mSession.onColorsChanged(); + } else { + int lastIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex)); + mColors.reset(colorToReset); + mSession.onColorsChanged(); + if (endOfInput) break; + charIndex++; + lastIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + } + break; + case 110: // Reset foreground color. + case 111: // Reset background color. + case 112: // Reset cursor color. + mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110)); + mSession.onColorsChanged(); + break; + case 119: // Reset highlight color. + break; + default: + unknownParameter(value); + break; + } + finishSequence(); + } + + private void blockClear(int sx, int sy, int w) { + blockClear(sx, sy, w, 1); + } + + private void blockClear(int sx, int sy, int w, int h) { + mScreen.blockSet(sx, sy, w, h, ' ', getStyle()); + } + + private long getStyle() { + return TextStyle.encode(mForeColor, mBackColor, mEffect); + } + + /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */ + private void doSetMode(boolean newValue) { + int modeBit = getArg0(0); + switch (modeBit) { + case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM). + mInsertMode = newValue; + break; + case 20: // Normal Linefeed (LNM). + unknownParameter(modeBit); + // http://www.vt100.net/docs/vt510-rm/LNM + break; + case 34: + // Normal cursor visibility - when using TERM=screen, see + // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html + break; + default: + unknownParameter(modeBit); + break; + } + } + + /** + * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use + * {@link #setCursorRowCol(int, int)} for absolute pos. + */ + private void setCursorPosition(int x, int y) { + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1)); + int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1)); + setCursorRowCol(newRow, newCol); + } + + private void scrollDownOneLine() { + mScrollCounter++; + long currentStyle = getStyle(); + if (mLeftMargin != 0 || mRightMargin != mColumns) { + // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. + mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); + // .. and blank bottom row between margins: + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle); + } else { + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle); + } + } + + /** + * Process the next ASCII character of a parameter. + * + *

You must use the ; character to separate parameters and : to separate sub-parameters. + * + *

Parameter characters modify the action or interpretation of the sequence. Originally + * you can use up to 16 parameters per sequence, but following at least xterm and alacritty + * we use a common space for parameters and sub-parameters, allowing 32 in total. + * + *

All parameters are unsigned, positive decimal integers, with the most significant + * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 + * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value + * or omitted parameter indicates a default value for the sequence. For most + * sequences, the default value is 1. + * + *

References: + * VT510 Video Terminal Programmer Information: Control Sequences + * alacritty/vte: Implement colon separated CSI parameters + * */ + private void parseArg(int b) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; + } + if (value > 9999) + value = 9999; + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';' || b == ':') { + if (mArgIndex + 1 < mArgs.length) { + mArgIndex++; + if (b == ':') { + mArgsSubParamsBitSet |= 1 << mArgIndex; + } + } else { + logError("Too many parameters when in state: " + mEscapeState); + } + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private int getArg0(int defaultValue) { + return getArg(0, defaultValue, true); + } + + private int getArg1(int defaultValue) { + return getArg(1, defaultValue, true); + } + + private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { + int result = mArgs[index]; + if (result < 0 || (result == 0 && treatZeroAsDefault)) { + result = defaultValue; + } + return result; + } + + private void collectOSCArgs(int b) { + if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private void unimplementedSequence(int b) { + logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); + finishSequence(); + } + + private void unknownSequence(int b) { + logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")"); + finishSequence(); + } + + private void unknownParameter(int parameter) { + logError("Unknown parameter: " + parameter); + finishSequence(); + } + + private void logError(String errorType) { + if (LOG_ESCAPE_SEQUENCES) { + StringBuilder buf = new StringBuilder(); + buf.append(errorType); + buf.append(", escapeState="); + buf.append(mEscapeState); + boolean firstArg = true; + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int value = mArgs[i]; + if (value >= 0) { + if (firstArg) { + firstArg = false; + buf.append(", args={"); + } else { + buf.append(','); + } + buf.append(value); + } + } + if (!firstArg) buf.append('}'); + finishSequenceAndLogError(buf.toString()); + } + } + + private void finishSequenceAndLogError(String error) { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error); + finishSequence(); + } + + private void finishSequence() { + mEscapeState = ESC_NONE; + } + + /** + * Send a Unicode code point to the screen. + * + * @param codePoint The code point of the character to display + */ + private void emitCodePoint(int codePoint) { + mLastEmittedCodePoint = codePoint; + if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) { + // http://www.vt100.net/docs/vt102-ug/table5-15.html. + switch (codePoint) { + case '_': + codePoint = ' '; // Blank. + break; + case '`': + codePoint = '◆'; // Diamond. + break; + case '0': + codePoint = '█'; // Solid block; + break; + case 'a': + codePoint = '▒'; // Checker board. + break; + case 'b': + codePoint = '␉'; // Horizontal tab. + break; + case 'c': + codePoint = '␌'; // Form feed. + break; + case 'd': + codePoint = '\r'; // Carriage return. + break; + case 'e': + codePoint = '␊'; // Linefeed. + break; + case 'f': + codePoint = '°'; // Degree. + break; + case 'g': + codePoint = '±'; // Plus-minus. + break; + case 'h': + codePoint = '\n'; // Newline. + break; + case 'i': + codePoint = '␋'; // Vertical tab. + break; + case 'j': + codePoint = '┘'; // Lower right corner. + break; + case 'k': + codePoint = '┐'; // Upper right corner. + break; + case 'l': + codePoint = '┌'; // Upper left corner. + break; + case 'm': + codePoint = '└'; // Left left corner. + break; + case 'n': + codePoint = '┼'; // Crossing lines. + break; + case 'o': + codePoint = '⎺'; // Horizontal line - scan 1. + break; + case 'p': + codePoint = '⎻'; // Horizontal line - scan 3. + break; + case 'q': + codePoint = '─'; // Horizontal line - scan 5. + break; + case 'r': + codePoint = '⎼'; // Horizontal line - scan 7. + break; + case 's': + codePoint = '⎽'; // Horizontal line - scan 9. + break; + case 't': + codePoint = '├'; // T facing rightwards. + break; + case 'u': + codePoint = '┤'; // T facing leftwards. + break; + case 'v': + codePoint = '┴'; // T facing upwards. + break; + case 'w': + codePoint = '┬'; // T facing downwards. + break; + case 'x': + codePoint = '│'; // Vertical line. + break; + case 'y': + codePoint = '≤'; // Less than or equal to. + break; + case 'z': + codePoint = '≥'; // Greater than or equal to. + break; + case '{': + codePoint = 'π'; // Pi. + break; + case '|': + codePoint = '≠'; // Not equal to. + break; + case '}': + codePoint = '£'; // UK pound. + break; + case '~': + codePoint = '·'; // Centered dot. + break; + } + } + + final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP); + final int displayWidth = WcWidth.width(codePoint); + final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1; + + if (autoWrap) { + if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) { + mScreen.setLineWrap(mCursorRow); + mCursorCol = mLeftMargin; + if (mCursorRow + 1 < mBottomMargin) { + mCursorRow++; + } else { + scrollDownOneLine(); + } + } + } else if (cursorInLastColumn && displayWidth == 2) { + // The behaviour when a wide character is output with cursor in the last column when + // autowrap is disabled is not obvious - it's ignored here. + return; + } + + if (mInsertMode && displayWidth > 0) { + // Move character to right one space. + int destCol = mCursorCol + displayWidth; + if (destCol < mRightMargin) + mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow); + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0); + int column = mCursorCol - offsetDueToCombiningChar; + + // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported + // The offsetDueToCombiningChar would never be 1 if mCursorCol was 0 to get column/index=-1, + // so was mCursorCol changed after the offsetDueToCombiningChar conditional by another thread? + // TODO: Check if there are thread synchronization issues with mCursorCol and mCursorRow, possibly causing others bugs too. + if (column < 0) column = 0; + mScreen.setChar(column, mCursorRow, codePoint, getStyle()); + + if (autoWrap && displayWidth > 0) + mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); + + mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1); + } + + private void setCursorRow(int row) { + mCursorRow = row; + mAboutToAutoWrap = false; + } + + private void setCursorCol(int col) { + mCursorCol = col; + mAboutToAutoWrap = false; + } + + /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */ + private void setCursorColRespectingOriginMode(int col) { + setCursorPosition(col, mCursorRow); + } + + /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */ + private void setCursorRowCol(int row, int col) { + mCursorRow = Math.max(0, Math.min(row, mRows - 1)); + mCursorCol = Math.max(0, Math.min(col, mColumns - 1)); + mAboutToAutoWrap = false; + } + + public int getScrollCounter() { + return mScrollCounter; + } + + public void clearScrollCounter() { + mScrollCounter = 0; + } + + /** Reset terminal state so user can interact with it regardless of present state. */ + public void reset() { + setCursorStyle(); + mArgIndex = 0; + mContinueSequence = false; + mEscapeState = ESC_NONE; + mInsertMode = false; + mTopMargin = mLeftMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + mAboutToAutoWrap = false; + mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + setDefaultTabStops(); + + mUseLineDrawingG0 = mUseLineDrawingG1 = false; + mUseLineDrawingUsesG0 = true; + + mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0; + mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0; + mCurrentDecSetFlags = 0; + // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen: + setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true); + setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true); + mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags; + + // XXX: Should we set terminal driver back to IUTF8 with termios? + mUtf8Index = mUtf8ToFollow = 0; + + mColors.reset(); + mSession.onColorsChanged(); + } + + public String getSelectedText(int x1, int y1, int x2, int y2) { + return mScreen.getSelectedText(x1, y1, x2, y2); + } + + /** Get the terminal session's title (null if not set). */ + public String getTitle() { + return mTitle; + } + + /** Change the terminal session's title. */ + private void setTitle(String newTitle) { + String oldTitle = mTitle; + mTitle = newTitle; + if (!Objects.equals(oldTitle, newTitle)) { + mSession.titleChanged(oldTitle, newTitle); + } + } + + /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */ + public void paste(String text) { + // First: Always remove escape key and C1 control characters [0x80,0x9F]: + text = text.replaceAll("(\u001B|[\u0080-\u009F])", ""); + // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r). + text = text.replaceAll("\r?\n", "\r"); + + // Then: Implement bracketed paste mode if enabled: + boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE); + if (bracketed) mSession.write("\033[200~"); + mSession.write(text); + if (bracketed) mSession.write("\033[201~"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSC */ + static final class SavedScreenState { + /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */ + int mSavedCursorRow, mSavedCursorCol; + int mSavedEffect, mSavedForeColor, mSavedBackColor; + int mSavedDecFlags; + boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + } + + @Override + public String toString() { + return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin + + "," + mLeftMargin + "}]"; + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java new file mode 100644 index 00000000..305082a5 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java @@ -0,0 +1,32 @@ +package com.termux.terminal; + +import java.nio.charset.StandardCharsets; + +/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */ +public abstract class TerminalOutput { + + /** Write a string using the UTF-8 encoding to the terminal client. */ + public final void write(String data) { + if (data == null) return; + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + write(bytes, 0, bytes.length); + } + + /** Write bytes to the terminal client. */ + public abstract void write(byte[] data, int offset, int count); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void titleChanged(String oldTitle, String newTitle); + + /** Notify the terminal client that text should be copied to clipboard. */ + public abstract void onCopyTextToClipboard(String text); + + /** Notify the terminal client that text should be pasted from clipboard. */ + public abstract void onPasteTextFromClipboard(); + + /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */ + public abstract void onBell(); + + public abstract void onColorsChanged(); + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java new file mode 100644 index 00000000..d68dc326 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -0,0 +1,283 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A row in a terminal, composed of a fixed number of cells. + *

+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering. + */ +public final class TerminalRow { + + private static final float SPARE_CAPACITY_FACTOR = 1.5f; + + /** + * Max combining characters that can exist in a column, that are separate from the base character + * itself. Any additional combining characters will be ignored and not added to the column. + * + * There does not seem to be limit in unicode standard for max number of combination characters + * that can be combined but such characters are primarily under 10. + * + * "Section 3.6 Combination" of unicode standard contains combining characters info. + * - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf + * - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges + * - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to + * + * UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters. + * > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage. + * > While it would have been feasible to chose a smaller number, this value provides a very wide margin, + * > yet is well within the buffer size limits of practical implementations. + * - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format + * - https://stackoverflow.com/a/11983435/14686958 + * + * We choose the value 15 because it should be enough for terminal based applications and keep + * the memory usage low for a terminal row, won't affect performance or cause terminal to + * lag or hang, and will keep malicious applications from causing harm. The value can be + * increased if ever needed for legitimate applications. + */ + private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15; + + /** The number of columns in this terminal row. */ + private final int mColumns; + /** The text filling this terminal row. */ + public char[] mText; + /** The number of java chars used in {@link #mText}. */ + private short mSpaceUsed; + /** If this row has been line wrapped due to text output at the end of line. */ + boolean mLineWrap; + /** The style bits of each cell in the row. See {@link TextStyle}. */ + final long[] mStyle; + /** If this row might contain chars with width != 1, used for deactivating fast path */ + boolean mHasNonOneWidthOrSurrogateChars; + + /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ + public TerminalRow(int columns, long style) { + mColumns = columns; + mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)]; + mStyle = new long[columns]; + clear(style); + } + + /** NOTE: The sourceX2 is exclusive. */ + public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) { + mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars; + final int x1 = line.findStartOfColumn(sourceX1); + final int x2 = line.findStartOfColumn(sourceX2); + boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); + final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + int latestNonCombiningWidth = 0; + for (int i = x1; i < x2; i++) { + char sourceChar = sourceChars[i]; + int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar; + if (startingFromSecondHalfOfWideChar) { + // Just treat copying second half of wide char as copying whitespace. + codePoint = ' '; + startingFromSecondHalfOfWideChar = false; + } + int w = WcWidth.width(codePoint); + if (w > 0) { + destinationX += latestNonCombiningWidth; + sourceX1 += latestNonCombiningWidth; + latestNonCombiningWidth = w; + } + setChar(destinationX, codePoint, line.getStyle(sourceX1)); + } + } + + public int getSpaceUsed() { + return mSpaceUsed; + } + + /** Note that the column may end of second half of wide character. */ + public int findStartOfColumn(int column) { + if (column == mColumns) return getSpaceUsed(); + + int currentColumn = 0; + int currentCharIndex = 0; + while (true) { // 0<2 1 < 2 + int newCharIndex = currentCharIndex; + char c = mText[newCharIndex++]; // cci=1, cci=2 + boolean isHigh = Character.isHighSurrogate(c); + int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); // 1, 2 + if (wcwidth > 0) { + currentColumn += wcwidth; + if (currentColumn == column) { + while (newCharIndex < mSpaceUsed) { + // Skip combining chars. + if (Character.isHighSurrogate(mText[newCharIndex])) { + if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) { + newCharIndex += 2; + } else { + break; + } + } else if (WcWidth.width(mText[newCharIndex]) <= 0) { + newCharIndex++; + } else { + break; + } + } + return newCharIndex; + } else if (currentColumn > column) { + // Wide column going past end. + return currentCharIndex; + } + } + currentCharIndex = newCharIndex; + } + } + + private boolean wideDisplayCharacterStartingAt(int column) { + for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) { + char c = mText[currentCharIndex++]; + int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); + if (wcwidth > 0) { + if (currentColumn == column && wcwidth == 2) return true; + currentColumn += wcwidth; + if (currentColumn > column) return false; + } + } + return false; + } + + public void clear(long style) { + Arrays.fill(mText, ' '); + Arrays.fill(mStyle, style); + mSpaceUsed = (short) mColumns; + mHasNonOneWidthOrSurrogateChars = false; + } + + // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 + public void setChar(int columnToSet, int codePoint, long style) { + if (columnToSet < 0 || columnToSet >= mStyle.length) + throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style); + + mStyle[columnToSet] = style; + + final int newCodePointDisplayWidth = WcWidth.width(codePoint); + + // Fast path when we don't have any chars with width != 1 + if (!mHasNonOneWidthOrSurrogateChars) { + if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) { + mHasNonOneWidthOrSurrogateChars = true; + } else { + mText[columnToSet] = (char) codePoint; + return; + } + } + + final boolean newIsCombining = newCodePointDisplayWidth <= 0; + + boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1); + + if (newIsCombining) { + // When standing at second half of wide character and inserting combining: + if (wasExtraColForWideChar) columnToSet--; + } else { + // Check if we are overwriting the second half of a wide character starting at the previous column: + if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); + // Check if we are overwriting the first half of a wide character starting at the next column: + boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1); + if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style); + } + + char[] text = mText; + final int oldStartOfColumnIndex = findStartOfColumn(columnToSet); + final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex); + + // Get the number of elements in the mText array this column uses now + int oldCharactersUsedForColumn; + if (columnToSet + oldCodePointDisplayWidth < mColumns) { + int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth); + oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex; + } else { + // Last character. + oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; + } + + // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters. + if (newIsCombining) { + int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn); + if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN) + return; + } + + // Find how many chars this column will need + int newCharactersUsedForColumn = Character.charCount(codePoint); + if (newIsCombining) { + // Combining characters are added to the contents of the column instead of overwriting them, so that they + // modify the existing contents. + // FIXME: Unassigned characters also get width=0. + newCharactersUsedForColumn += oldCharactersUsedForColumn; + } + + int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn; + int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn; + + final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn; + if (javaCharDifference > 0) { + // Shift the rest of the line right. + int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex; + if (mSpaceUsed + javaCharDifference > text.length) { + // We need to grow the array + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, oldNextColumnIndex); + System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn); + mText = text = newText; + } else { + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn); + } + } else if (javaCharDifference < 0) { + // Shift the rest of the line left. + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex); + } + mSpaceUsed += javaCharDifference; + + // Store char. A combining character is stored at the end of the existing contents so that it modifies them: + //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used. + Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0)); + + if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) { + // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character. + if (mSpaceUsed + 1 > text.length) { + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, newNextColumnIndex); + System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + mText = text = newText; + } else { + System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + } + text[newNextColumnIndex] = ' '; + + ++mSpaceUsed; + } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) { + if (columnToSet == mColumns - 1) { + throw new IllegalArgumentException("Cannot put wide character in last column"); + } else if (columnToSet == mColumns - 2) { + // Truncate the line to the second part of this wide char: + mSpaceUsed = (short) newNextColumnIndex; + } else { + // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the + // check at the beginning of this method we know that we are not overwriting a wide char. + int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1); + int nextLen = newNextNextColumnIndex - newNextColumnIndex; + + // Shift the array leftwards. + System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex); + mSpaceUsed -= nextLen; + } + } + } + + boolean isBlank() { + for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) + if (mText[charIndex] != ' ') return false; + return true; + } + + public final long getStyle(int column) { + return mStyle[column]; + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java new file mode 100644 index 00000000..324e0bc0 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -0,0 +1,372 @@ +package com.termux.terminal; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Message; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * A terminal session, consisting of a process coupled to a terminal interface. + *

+ * The subprocess will be executed by the constructor, and when the size is made known by a call to + * {@link #updateSize(int, int, int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O. + * All terminal emulation and callback methods will be performed on the main thread. + *

+ * The child process may be exited forcefully by using the {@link #finishIfRunning()} method. + *

+ * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks! + */ +public final class TerminalSession extends TerminalOutput { + + private static final int MSG_NEW_INPUT = 1; + private static final int MSG_PROCESS_EXITED = 4; + + public final String mHandle = UUID.randomUUID().toString(); + + TerminalEmulator mEmulator; + + /** + * A queue written to from a separate thread when the process outputs, and read by main thread to process by + * terminal emulator. + */ + final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096); + /** + * A queue written to from the main thread due to user interaction, and read by another thread which forwards by + * writing to the {@link #mTerminalFileDescriptor}. + */ + final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096); + /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */ + private final byte[] mUtf8InputBuffer = new byte[5]; + + /** Callback which gets notified when a session finishes or changes title. */ + TerminalSessionClient mClient; + + /** The pid of the shell process. 0 if not started and -1 if finished running. */ + int mShellPid; + + /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */ + int mShellExitStatus; + + /** + * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling + * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int, int, int)}. + */ + private int mTerminalFileDescriptor; + + /** Set by the application for user identification of session, not by terminal. */ + public String mSessionName; + + final Handler mMainThreadHandler = new MainThreadHandler(); + + private final String mShellPath; + private final String mCwd; + private final String[] mArgs; + private final String[] mEnv; + private final Integer mTranscriptRows; + + + private static final String LOG_TAG = "TerminalSession"; + + public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) { + this.mShellPath = shellPath; + this.mCwd = cwd; + this.mArgs = args; + this.mEnv = env; + this.mTranscriptRows = transcriptRows; + this.mClient = client; + } + + /** + * @param client The {@link TerminalSessionClient} interface implementation to allow + * for communication between {@link TerminalSession} and its client. + */ + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + + if (mEmulator != null) + mEmulator.updateTerminalSessionClient(client); + } + + /** Inform the attached pty of the new size and reflow or initialize the emulator. */ + public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + if (mEmulator == null) { + initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); + } else { + JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels); + mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); + } + } + + /** The terminal title as set through escape sequences or null if none set. */ + public String getTitle() { + return (mEmulator == null) ? null : mEmulator.getTitle(); + } + + /** + * Set the terminal emulator's window size and start terminal emulation. + * + * @param columns The number of columns in the terminal window. + * @param rows The number of rows in the terminal window. + */ + public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); + + int[] processId = new int[1]; + mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels); + mShellPid = processId[0]; + + final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient); + + new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { + @Override + public void run() { + try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) { + final byte[] buffer = new byte[4096]; + while (true) { + int read = termIn.read(buffer); + if (read == -1) return; + if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return; + mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); + } + } catch (Exception e) { + // Ignore, just shutting down. + } + } + }.start(); + + new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") { + @Override + public void run() { + final byte[] buffer = new byte[4096]; + try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) { + while (true) { + int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true); + if (bytesToWrite == -1) return; + termOut.write(buffer, 0, bytesToWrite); + } + } catch (IOException e) { + // Ignore. + } + } + }.start(); + + new Thread("TermSessionWaiter[pid=" + mShellPid + "]") { + @Override + public void run() { + int processExitCode = JNI.waitFor(mShellPid); + mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode)); + } + }.start(); + + } + + /** Write data to the shell process. */ + @Override + public void write(byte[] data, int offset, int count) { + if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count); + } + + /** Write the Unicode code point to the terminal encoded in UTF-8. */ + public void writeCodePoint(boolean prependEscape, int codePoint) { + if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + // 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range. + throw new IllegalArgumentException("Invalid code point: " + codePoint); + } + + int bufferPosition = 0; + if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27; + + if (codePoint <= /* 7 bits */0b1111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) codePoint; + } else if (codePoint <= /* 11 bits */0b11111111111) { + /* 110xxxxx leading byte with leading 5 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else if (codePoint <= /* 16 bits */0b1111111111111111) { + /* 1110xxxx leading byte with leading 4 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */ + /* 11110xxx leading byte with leading 3 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } + write(mUtf8InputBuffer, 0, bufferPosition); + } + + public TerminalEmulator getEmulator() { + return mEmulator; + } + + /** Notify the {@link #mClient} that the screen has changed. */ + protected void notifyScreenUpdate() { + mClient.onTextChanged(this); + } + + /** Reset state for terminal emulator state. */ + public void reset() { + mEmulator.reset(); + notifyScreenUpdate(); + } + + /** Finish this terminal session by sending SIGKILL to the shell. */ + public void finishIfRunning() { + if (isRunning()) { + try { + Os.kill(mShellPid, OsConstants.SIGKILL); + } catch (ErrnoException e) { + Logger.logWarn(mClient, LOG_TAG, "Failed sending SIGKILL: " + e.getMessage()); + } + } + } + + /** Cleanup resources when the process exits. */ + void cleanupResources(int exitStatus) { + synchronized (this) { + mShellPid = -1; + mShellExitStatus = exitStatus; + } + + // Stop the reader and writer threads, and close the I/O streams + mTerminalToProcessIOQueue.close(); + mProcessToTerminalIOQueue.close(); + JNI.close(mTerminalFileDescriptor); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + mClient.onTitleChanged(this); + } + + public synchronized boolean isRunning() { + return mShellPid != -1; + } + + /** Only valid if not {@link #isRunning()}. */ + public synchronized int getExitStatus() { + return mShellExitStatus; + } + + @Override + public void onCopyTextToClipboard(String text) { + mClient.onCopyTextToClipboard(this, text); + } + + @Override + public void onPasteTextFromClipboard() { + mClient.onPasteTextFromClipboard(this); + } + + @Override + public void onBell() { + mClient.onBell(this); + } + + @Override + public void onColorsChanged() { + mClient.onColorsChanged(this); + } + + public int getPid() { + return mShellPid; + } + + /** Returns the shell's working directory or null if it was unavailable. */ + public String getCwd() { + if (mShellPid < 1) { + return null; + } + try { + final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid); + String outputPath = new File(cwdSymlink).getCanonicalPath(); + String outputPathWithTrailingSlash = outputPath; + if (!outputPath.endsWith("/")) { + outputPathWithTrailingSlash += '/'; + } + if (!cwdSymlink.equals(outputPathWithTrailingSlash)) { + return outputPath; + } + } catch (IOException | SecurityException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e); + } + return null; + } + + private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) { + FileDescriptor result = new FileDescriptor(); + try { + Field descriptorField; + try { + descriptorField = FileDescriptor.class.getDeclaredField("descriptor"); + } catch (NoSuchFieldException e) { + // For desktop java: + descriptorField = FileDescriptor.class.getDeclaredField("fd"); + } + descriptorField.setAccessible(true); + descriptorField.set(result, fileDescriptor); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { + Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e); + System.exit(1); + } + return result; + } + + @SuppressLint("HandlerLeak") + class MainThreadHandler extends Handler { + + final byte[] mReceiveBuffer = new byte[4 * 1024]; + + @Override + public void handleMessage(Message msg) { + int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false); + if (bytesRead > 0) { + mEmulator.append(mReceiveBuffer, bytesRead); + notifyScreenUpdate(); + } + + if (msg.what == MSG_PROCESS_EXITED) { + int exitCode = (Integer) msg.obj; + cleanupResources(exitCode); + + String exitDescription = "\r\n[Process completed"; + if (exitCode > 0) { + // Non-zero process exit. + exitDescription += " (code " + exitCode + ")"; + } else if (exitCode < 0) { + // Negated signal. + exitDescription += " (signal " + (-exitCode) + ")"; + } + exitDescription += " - press Enter]"; + + byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8); + mEmulator.append(bytesToWrite, bytesToWrite.length); + notifyScreenUpdate(); + + mClient.onSessionFinished(TerminalSession.this); + } + } + + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java new file mode 100644 index 00000000..0ffe8e43 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java @@ -0,0 +1,46 @@ +package com.termux.terminal; + +/** + * The interface for communication between {@link TerminalSession} and its client. It is used to + * send callbacks to the client when {@link TerminalSession} changes or for sending other + * back data to the client like logs. + */ +public interface TerminalSessionClient { + + void onTextChanged(TerminalSession changedSession); + + void onTitleChanged(TerminalSession changedSession); + + void onSessionFinished(TerminalSession finishedSession); + + void onCopyTextToClipboard(TerminalSession session, String text); + + void onPasteTextFromClipboard(TerminalSession session); + + void onBell(TerminalSession session); + + void onColorsChanged(TerminalSession session); + + void onTerminalCursorStateChange(boolean state); + + + + Integer getTerminalCursorStyle(); + + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java new file mode 100644 index 00000000..173d6ae9 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java @@ -0,0 +1,90 @@ +package com.termux.terminal; + +/** + *

+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal + * row in {@link TerminalRow#mStyle}. + *

+ *

+ * The bit layout is: + *

+ * - 16 flags (11 currently used). + * - 24 for foreground color (only 9 first bits if a color index). + * - 24 for background color (only 9 first bits if a color index). + */ +public final class TextStyle { + + public final static int CHARACTER_ATTRIBUTE_BOLD = 1; + public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1; + public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2; + public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3; + public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4; + public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5; + public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6; + /** + * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable. + *

+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that + * come after it as erasable from the screen. + *

+ */ + public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7; + /** Dim colors. Also known as faint or half intensity. */ + public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10; + + public final static int COLOR_INDEX_FOREGROUND = 256; + public final static int COLOR_INDEX_BACKGROUND = 257; + public final static int COLOR_INDEX_CURSOR = 258; + + /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */ + public final static int NUM_INDEXED_COLORS = 259; + + /** Normal foreground and background colors and no effects. */ + final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0); + + static long encode(int foreColor, int backColor, int effect) { + long result = effect & 0b111111111; + if ((0xff000000 & foreColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L); + } else { + // Indexed color. + result |= (foreColor & 0b111111111L) << 40; + } + if ((0xff000000 & backColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L); + } else { + // Indexed color. + result |= (backColor & 0b111111111L) << 16L; + } + + return result; + } + + public static int decodeForeColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) { + return (int) ((style >>> 40) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL); + } + + } + + public static int decodeBackColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) { + return (int) ((style >>> 16) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL); + } + } + + public static int decodeEffect(long style) { + return (int) (style & 0b11111111111); + } + +} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java new file mode 100644 index 00000000..d71cc277 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java @@ -0,0 +1,566 @@ +package com.termux.terminal; + +/** + * Implementation of wcwidth(3) for Unicode 15. + * + * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters. + * + * IMPORTANT: + * Must be kept in sync with the following: + * https://github.com/termux/wcwidth + * https://github.com/termux/libandroid-support + * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support + */ +public final class WcWidth { + + // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] ZERO_WIDTH = { + {0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le + {0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli + {0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg + {0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe + {0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot + {0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot + {0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata + {0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra + {0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below + {0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip + {0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen + {0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda + {0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon + {0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem + {0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip + {0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh + {0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun + {0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot + {0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan + {0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh + {0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A + {0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U + {0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa + {0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark + {0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M + {0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S + {0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara + {0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe + {0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta + {0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai + {0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama + {0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu + {0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo + {0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu + {0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta + {0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal + {0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama + {0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal + {0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark + {0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi + {0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta + {0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu + {0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai + {0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama + {0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat + {0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak + {0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash + {0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara + {0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta + {0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand + {0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai + {0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama + {0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca + {0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle + {0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu + {0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta + {0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I + {0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic + {0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama + {0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark + {0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic + {0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara + {0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii + {0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama + {0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca + {0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An + {0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta + {0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii + {0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai + {0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama + {0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark + {0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali + {0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu + {0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta + {0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I + {0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E + {0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama + {0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal + {0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin + {0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular + {0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc + {0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama + {0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc + {0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu + {0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna + {0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti + {0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga + {0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a + {0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu + {0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan + {0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan + {0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo + {0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil) + {0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig + {0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru + {0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga + {0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta + {0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags + {0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter + {0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter + {0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda + {0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu + {0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below + {0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat + {0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal + {0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah + {0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S + {0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan + {0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci + {0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton + {0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin + {0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama + {0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U + {0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U + {0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U + {0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa + {0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua + {0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit + {0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat + {0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan + {0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation + {0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation + {0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U + {0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O + {0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv + {0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i + {0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U + {0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae + {0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign + {0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign + {0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot + {0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai + {0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B + {0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue + {0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt + {0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le + {0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang + {0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan + {0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R + {0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L + {0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe + {0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol + {0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar + {0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan + {0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan + {0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign + {0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi + {0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee + {0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O + {0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H + {0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T + {0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta + {0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha + {0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash + {0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda + {0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak + {0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above + {0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A + {0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea + {0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above + {0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu + {0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine + {0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton + {0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag + {0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous + {0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer + {0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk + {0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva + {0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant + {0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva + {0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign + {0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern + {0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi + {0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig + {0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay + {0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop + {0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R + {0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar + {0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu + {0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku + {0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign + {0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw + {0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe + {0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue + {0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa + {0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T + {0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang + {0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U + {0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia + {0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek + {0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho + {0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama + {0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek + {0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani + {0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16 + {0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo + {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi + {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M + {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let + {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo + {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O + {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga + {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo + {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama + {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation + {0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas + {0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M + {0x10efd, 0x10eff}, // (nil) ..(nil) + {0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke + {0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two + {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara + {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama + {0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi + {0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta + {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara + {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai + {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta + {0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali + {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga + {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu + {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa + {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta + {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara + {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O + {0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe + {0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca + {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai + {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara + {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda + {0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun + {0x11241, 0x11241}, // (nil) ..(nil) + {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara + {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama + {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu + {0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta + {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii + {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit + {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter + {0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai + {0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara + {0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta + {0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark + {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal + {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short + {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara + {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta + {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal + {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara + {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta + {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter + {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai + {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara + {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra + {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara + {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa + {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au + {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta + {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi + {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu + {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer + {0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara + {0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta + {0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab + {0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama + {0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta + {0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V + {0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A + {0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama + {0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L + {0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An + {0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster + {0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin + {0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe + {0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar + {0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara + {0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner + {0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc + {0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara + {0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama + {0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter + {0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa + {0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E + {0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu + {0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama + {0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara + {0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign + {0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv + {0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama + {0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U + {0x11f00, 0x11f01}, // (nil) ..(nil) + {0x11f36, 0x11f3a}, // (nil) ..(nil) + {0x11f40, 0x11f40}, // (nil) ..(nil) + {0x11f42, 0x11f42}, // (nil) ..(nil) + {0x13440, 0x13440}, // (nil) ..(nil) + {0x13447, 0x13455}, // (nil) ..(nil) + {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High + {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta + {0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi + {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below + {0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill + {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark + {0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark + {0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie + {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical + {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking + {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement + {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T + {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea + {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie + {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod + {0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e08f, 0x1e08f}, // (nil) ..(nil) + {0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T + {0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone + {0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini + {0x1e4ec, 0x1e4ef}, // (nil) ..(nil) + {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining + {0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta + {0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256 + }; + + // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] WIDE_EASTASIAN = { + {0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler + {0x0231a, 0x0231b}, // Watch ..Hourglass + {0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra + {0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub + {0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock + {0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S + {0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar + {0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage + {0x02648, 0x02653}, // Aries ..Pisces + {0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol + {0x02693, 0x02693}, // Anchor ..Anchor + {0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign + {0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle + {0x026bd, 0x026be}, // Soccer Ball ..Baseball + {0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud + {0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus + {0x026d4, 0x026d4}, // No Entry ..No Entry + {0x026ea, 0x026ea}, // Church ..Church + {0x026f2, 0x026f3}, // Fountain ..Flag In Hole + {0x026f5, 0x026f5}, // Sailboat ..Sailboat + {0x026fa, 0x026fa}, // Tent ..Tent + {0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump + {0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark + {0x0270a, 0x0270b}, // Raised Fist ..Raised Hand + {0x02728, 0x02728}, // Sparkles ..Sparkles + {0x0274c, 0x0274c}, // Cross Mark ..Cross Mark + {0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M + {0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O + {0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S + {0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign + {0x027b0, 0x027b0}, // Curly Loop ..Curly Loop + {0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop + {0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square + {0x02b50, 0x02b50}, // White Medium Star ..White Medium Star + {0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle + {0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap + {0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified + {0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute + {0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description + {0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In + {0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke + {0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto + {0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn + {0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae + {0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q + {0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha + {0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto + {0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d + {0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr + {0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke + {0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo + {0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih + {0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil) + {0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve + {0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop + {0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign + {0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At + {0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa + {0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign + {0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill + {0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea + {0x17000, 0x187f7}, // (nil) ..(nil) + {0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char + {0x18d00, 0x18d08}, // (nil) ..(nil) + {0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T + {0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N + {0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N + {0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic + {0x1b132, 0x1b132}, // (nil) ..(nil) + {0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo + {0x1b155, 0x1b155}, // (nil) ..(nil) + {0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N + {0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb + {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon + {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker + {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab + {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs + {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa + {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo + {0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed + {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept + {0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai + {0x1f300, 0x1f320}, // Cyclone ..Shooting Star + {0x1f32d, 0x1f335}, // Hot Dog ..Cactus + {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle + {0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap + {0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer + {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And + {0x1f3e0, 0x1f3f0}, // House Building ..European Castle + {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag + {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints + {0x1f440, 0x1f440}, // Eyes ..Eyes + {0x1f442, 0x1f4fc}, // Ear ..Videocassette + {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red + {0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch + {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty + {0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing + {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be + {0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart + {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands + {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage + {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation + {0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley + {0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator + {0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy + {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving + {0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate + {0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square + {0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign + {0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer + {0x1f93c, 0x1f945}, // Wrestlers ..Goal Net + {0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet + {0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch + {0x1fa80, 0x1fa88}, // Yo-yo ..(nil) + {0x1fa90, 0x1fabd}, // Ringed Planet ..(nil) + {0x1fabf, 0x1fac5}, // (nil) ..Person With Crown + {0x1face, 0x1fadb}, // (nil) ..(nil) + {0x1fae0, 0x1fae8}, // Melting Face ..(nil) + {0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil) + {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil) + {0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil) + }; + + + private static boolean intable(int[][] table, int c) { + // First quick check f|| Latin1 etc. characters. + if (c < table[0][0]) return false; + + // Binary search in table. + int bot = 0; + int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1); + while (top >= bot) { + int mid = (bot + top) / 2; + if (table[mid][1] < c) { + bot = mid + 1; + } else if (table[mid][0] > c) { + top = mid - 1; + } else { + return true; + } + } + return false; + } + + /** Return the terminal display width of a code point: 0, 1 || 2. */ + public static int width(int ucs) { + if (ucs == 0 || + ucs == 0x034F || + (0x200B <= ucs && ucs <= 0x200F) || + ucs == 0x2028 || + ucs == 0x2029 || + (0x202A <= ucs && ucs <= 0x202E) || + (0x2060 <= ucs && ucs <= 0x2063)) { + return 0; + } + + // C0/C1 control characters + // Termux change: Return 0 instead of -1. + if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0; + + // combining characters with zero width + if (intable(ZERO_WIDTH, ucs)) return 0; + + return intable(WIDE_EASTASIAN, ucs) ? 2 : 1; + } + + /** The width at an index position in a java char array. */ + public static int width(char[] chars, int index) { + char c = chars[index]; + return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c); + } + + /** + * The zero width characters count like combining characters in the `chars` array from start + * index to end index (exclusive). + */ + public static int zeroWidthCharsCount(char[] chars, int start, int end) { + if (start < 0 || start >= chars.length) + return 0; + + int count = 0; + for (int i = start; i < end && i < chars.length;) { + if (Character.isHighSurrogate(chars[i])) { + if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) { + count++; + } + i += 2; + } else { + if (width(chars[i]) <= 0) { + count++; + } + i++; + } + } + return count; + } + +} diff --git a/apps/android/terminal-view/LICENSE b/apps/android/terminal-view/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/apps/android/terminal-view/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/android/terminal-view/README.md b/apps/android/terminal-view/README.md new file mode 100644 index 00000000..68f67ce1 --- /dev/null +++ b/apps/android/terminal-view/README.md @@ -0,0 +1,22 @@ +# Vendored Termux terminal emulator and view + +`apps/android/terminal-emulator` and `apps/android/terminal-view` are copies of +Termux's terminal emulator and view, taken so the app can host a terminal whose +shell runs on a remote bridge. Both are Apache-2.0 (the rest of +`termux/termux-app` is GPLv3); the license text is each module's `LICENSE`. + +- Source: +- Tag: `v0.118.3` +- Commit: `5b657c6adf4304e5198951ce815fe0205dcac29c` +- Copied: `terminal-emulator/src/main/java/**`, `terminal-view/src/main/java/**` + and `terminal-view/src/main/res/**` + +Upstream is not tracked. There is no submodule, no Gradle include of the +upstream tree and no JitPack dependency: the sources are here, and the modules' +Gradle files are written in this repo's own style. To update, re-copy the paths +above from a newer tag deliberately and re-apply the local changes listed +below; never merge upstream mechanically. + +## Local changes + +(To be listed.) diff --git a/apps/android/terminal-view/build.gradle.kts b/apps/android/terminal-view/build.gradle.kts new file mode 100644 index 00000000..7cd113e6 --- /dev/null +++ b/apps/android/terminal-view/build.gradle.kts @@ -0,0 +1,39 @@ +// Vendored Termux terminal view (Apache-2.0). The sources under src/main are +// a verbatim copy of termux/termux-app's terminal-view module except where +// terminal-view/README.md lists a local change; the module shape follows +// apps/android/app/build.gradle.kts so it shares the version catalog's +// AGP/compileSdk/minSdk. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.termux.view" + compileSdk { + version = release(37) + } + + defaultConfig { + minSdk { + version = release(26) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint { + abortOnError = true + checkReleaseBuilds = true + } +} + +dependencies { + // The view draws an emulator buffer and drives a session, both from the + // emulator module, so callers of this module see those types too (api, + // not implementation, as upstream had it). + api(project(":terminal-emulator")) + implementation(libs.androidx.annotation) +} diff --git a/apps/android/terminal-view/lint.xml b/apps/android/terminal-view/lint.xml new file mode 100644 index 00000000..988e91a2 --- /dev/null +++ b/apps/android/terminal-view/lint.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/apps/android/terminal-view/src/main/AndroidManifest.xml b/apps/android/terminal-view/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cec37532 --- /dev/null +++ b/apps/android/terminal-view/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java b/apps/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java new file mode 100644 index 00000000..f7fc9d23 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java @@ -0,0 +1,112 @@ +package com.termux.view; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */ +final class GestureAndScaleRecognizer { + + public interface Listener { + boolean onSingleTapUp(MotionEvent e); + + boolean onDoubleTap(MotionEvent e); + + boolean onScroll(MotionEvent e2, float dx, float dy); + + boolean onFling(MotionEvent e, float velocityX, float velocityY); + + boolean onScale(float focusX, float focusY, float scale); + + boolean onDown(float x, float y); + + boolean onUp(MotionEvent e); + + void onLongPress(MotionEvent e); + } + + private final GestureDetector mGestureDetector; + private final ScaleGestureDetector mScaleDetector; + final Listener mListener; + boolean isAfterLongPress; + + public GestureAndScaleRecognizer(Context context, Listener listener) { + mListener = listener; + + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { + return mListener.onScroll(e2, dx, dy); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return mListener.onFling(e2, velocityX, velocityY); + } + + @Override + public boolean onDown(MotionEvent e) { + return mListener.onDown(e.getX(), e.getY()); + } + + @Override + public void onLongPress(MotionEvent e) { + mListener.onLongPress(e); + isAfterLongPress = true; + } + }, null, true /* ignoreMultitouch */); + + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return mListener.onSingleTapUp(e); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mListener.onDoubleTap(e); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return true; + } + }); + + mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor()); + } + }); + mScaleDetector.setQuickScaleEnabled(false); + } + + public void onTouchEvent(MotionEvent event) { + mGestureDetector.onTouchEvent(event); + mScaleDetector.onTouchEvent(event); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isAfterLongPress = false; + break; + case MotionEvent.ACTION_UP: + if (!isAfterLongPress) { + // This behaviour is desired when in e.g. vim with mouse events, where we do not + // want to move the cursor when lifting finger after a long press. + mListener.onUp(event); + } + break; + } + } + + public boolean isInProgress() { + return mScaleDetector.isInProgress(); + } + +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java new file mode 100644 index 00000000..a4bef7d3 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -0,0 +1,249 @@ +package com.termux.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Typeface; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalRow; +import com.termux.terminal.TextStyle; +import com.termux.terminal.WcWidth; + +/** + * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. + *

+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + */ +public final class TerminalRenderer { + + final int mTextSize; + final Typeface mTypeface; + private final Paint mTextPaint = new Paint(); + + /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + final float mFontWidth; + /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + final int mFontLineSpacing; + /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + private final int mFontAscent; + /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + final int mFontLineSpacingAndAscent; + + private final float[] asciiMeasures = new float[127]; + + public TerminalRenderer(int textSize, Typeface typeface) { + mTextSize = textSize; + mTypeface = typeface; + + mTextPaint.setTypeface(typeface); + mTextPaint.setAntiAlias(true); + mTextPaint.setTextSize(textSize); + + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontAscent = (int) Math.ceil(mTextPaint.ascent()); + mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; + mFontWidth = mTextPaint.measureText("X"); + + StringBuilder sb = new StringBuilder(" "); + for (int i = 0; i < asciiMeasures.length; i++) { + sb.setCharAt(0, (char) i); + asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1); + } + } + + /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, + int selectionY1, int selectionY2, int selectionX1, int selectionX2) { + final boolean reverseVideo = mEmulator.isReverseVideo(); + final int endRow = topRow + mEmulator.mRows; + final int columns = mEmulator.mColumns; + final int cursorCol = mEmulator.getCursorCol(); + final int cursorRow = mEmulator.getCursorRow(); + final boolean cursorVisible = mEmulator.shouldCursorBeVisible(); + final TerminalBuffer screen = mEmulator.getScreen(); + final int[] palette = mEmulator.mColors.mCurrentColors; + final int cursorShape = mEmulator.getCursorStyle(); + + if (reverseVideo) + canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); + + float heightOffset = mFontLineSpacingAndAscent; + for (int row = topRow; row < endRow; row++) { + heightOffset += mFontLineSpacing; + + final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; + int selx1 = -1, selx2 = -1; + if (row >= selectionY1 && row <= selectionY2) { + if (row == selectionY1) selx1 = selectionX1; + selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; + } + + TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + final char[] line = lineObject.mText; + final int charsUsedInLine = lineObject.getSpaceUsed(); + + long lastRunStyle = 0; + boolean lastRunInsideCursor = false; + boolean lastRunInsideSelection = false; + int lastRunStartColumn = -1; + int lastRunStartIndex = 0; + boolean lastRunFontWidthMismatch = false; + int currentCharIndex = 0; + float measuredWidthForRun = 0.f; + + for (int column = 0; column < columns; ) { + final char charAtIndex = line[currentCharIndex]; + final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); + final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; + final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + final int codePointWcWidth = WcWidth.width(codePoint); + final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); + final boolean insideSelection = column >= selx1 && column <= selx2; + final long style = lineObject.getStyle(column); + + // Check if the measured text width for this code point is not the same as that expected by wcwidth(). + // This could happen for some fonts which are not truly monospace, or for more exotic characters such as + // smileys which android font renders as wide. + // If this is detected, we draw this code point scaled to match what wcwidth() expects. + final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, + currentCharIndex, charsForCodePoint); + final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { + if (column == 0) { + // Skip first column as there is nothing to draw, just record the current style. + } else { + final int columnWidthSinceLastRun = column - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, + lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, + cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + measuredWidthForRun = 0.f; + lastRunStyle = style; + lastRunInsideCursor = insideCursor; + lastRunInsideSelection = insideSelection; + lastRunStartColumn = column; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = fontWidthMismatch; + } + measuredWidthForRun += measuredCodePointWidth; + column += codePointWcWidth; + currentCharIndex += charsForCodePoint; + while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { + // Eat combining chars so that they are treated as part of the last non-combining code point, + // instead of e.g. being considered inside the cursor in the next run. + currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + } + } + + final int columnWidthSinceLastRun = columns - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + } + + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo) { + int foreColor = TextStyle.decodeForeColor(textStyle); + final int effect = TextStyle.decodeEffect(textStyle); + int backColor = TextStyle.decodeBackColor(textStyle); + final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; + final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; + final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; + final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; + + if ((foreColor & 0xff000000) != 0xff000000) { + // Let bold have bright colors if applicable (one of the first 8): + if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; + foreColor = palette[foreColor]; + } + + if ((backColor & 0xff000000) != 0xff000000) { + backColor = palette[backColor]; + } + + // Reverse video here if _one and only one_ of the reverse flags are set: + final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + if (reverseVideoHere) { + int tmp = foreColor; + foreColor = backColor; + backColor = tmp; + } + + float left = startColumn * mFontWidth; + float right = left + runWidthColumns * mFontWidth; + + mes = mes / mFontWidth; + boolean savedMatrix = false; + if (Math.abs(mes - runWidthColumns) > 0.01) { + canvas.save(); + canvas.scale(runWidthColumns / mes, 1.f); + left *= mes / runWidthColumns; + right *= mes / runWidthColumns; + savedMatrix = true; + } + + if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { + // Only draw non-default background. + mTextPaint.setColor(backColor); + canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); + } + + if (cursor != 0) { + mTextPaint.setColor(cursor); + float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; + if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; + else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); + } + + if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { + if (dim) { + int red = (0xFF & (foreColor >> 16)); + int green = (0xFF & (foreColor >> 8)); + int blue = (0xFF & foreColor); + // Dim color handling used by libvte which in turn took it from xterm + // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): + red = red * 2 / 3; + green = green * 2 / 3; + blue = blue * 2 / 3; + foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + } + + mTextPaint.setFakeBoldText(bold); + mTextPaint.setUnderlineText(underline); + mTextPaint.setTextSkewX(italic ? -0.35f : 0.f); + mTextPaint.setStrikeThruText(strikeThrough); + mTextPaint.setColor(foreColor); + + // The text alignment is the default Paint.Align.LEFT. + canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); + } + + if (savedMatrix) canvas.restore(); + } + + public float getFontWidth() { + return mFontWidth; + } + + public int getFontLineSpacing() { + return mFontLineSpacing; + } +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/TerminalView.java b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalView.java new file mode 100644 index 00000000..351f47a4 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -0,0 +1,1476 @@ +package com.termux.view; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.HapticFeedbackConstants; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Scroller; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.view.textselection.TextSelectionCursorController; + +/** View displaying and interacting with a {@link TerminalSession}. */ +public final class TerminalView extends View { + + /** Log terminal view key and IME events. */ + private static boolean TERMINAL_VIEW_KEY_LOGGING_ENABLED = false; + + /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */ + public TerminalSession mTermSession; + /** Our terminal emulator whose session is {@link #mTermSession}. */ + public TerminalEmulator mEmulator; + + public TerminalRenderer mRenderer; + + public TerminalViewClient mClient; + + private TextSelectionCursorController mTextSelectionCursorController; + + private Handler mTerminalCursorBlinkerHandler; + private TerminalCursorBlinkerRunnable mTerminalCursorBlinkerRunnable; + private int mTerminalCursorBlinkerRate; + private boolean mCursorInvisibleIgnoreOnce; + public static final int TERMINAL_CURSOR_BLINK_RATE_MIN = 100; + public static final int TERMINAL_CURSOR_BLINK_RATE_MAX = 2000; + + /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */ + int mTopRow; + int[] mDefaultSelectors = new int[]{-1,-1,-1,-1}; + + float mScaleFactor = 1.f; + final GestureAndScaleRecognizer mGestureRecognizer; + + /** Keep track of where mouse touch event started which we report as mouse scroll. */ + private int mMouseScrollStartX = -1, mMouseScrollStartY = -1; + /** Keep track of the time when a touch event leading to sending mouse scroll events started. */ + private long mMouseStartDownTime = -1; + + final Scroller mScroller; + + /** What was left in from scrolling movement. */ + float mScrollRemainder; + + /** If non-zero, this is the last unicode code point received if that was a combining character. */ + int mCombiningAccent; + + /** + * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}. + * + * The default is {@link #AUTOFILL_TYPE_NONE} so that AutoFill UI, like toolbar above keyboard + * is not shown automatically, like on Activity starts/View create. This value should be updated + * to required value, like {@link #AUTOFILL_TYPE_TEXT} before calling + * {@link AutofillManager#requestAutofill(View)} so that AutoFill UI shows. The updated value + * set will automatically be restored to {@link #AUTOFILL_TYPE_NONE} in + * {@link #autofill(AutofillValue)} so that AutoFill UI isn't shown anymore by calling + * {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillType = AUTOFILL_TYPE_NONE; + + /** + * The current AutoFill type returned for {@link View#getImportantForAutofill()} by + * {@link #getImportantForAutofill()}. + * + * The default is {@link #IMPORTANT_FOR_AUTOFILL_NO} so that view is not considered important + * for AutoFill. This value should be updated to required value, like + * {@link #IMPORTANT_FOR_AUTOFILL_YES} before calling {@link AutofillManager#requestAutofill(View)} + * so that Android and apps consider the view as important for AutoFill to process the request. + * The updated value set will automatically be restored to {@link #IMPORTANT_FOR_AUTOFILL_NO} in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + + /** + * The current AutoFill hints returned for {@link View#getAutofillHints()} ()} by {@link #getAutofillHints()} ()}. + * + * The default is an empty `string[]`. This value should be updated to required value. The + * updated value set will automatically be restored an empty `string[]` in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + private String[] mAutoFillHints = new String[0]; + + private final boolean mAccessibilityEnabled; + + private static final String LOG_TAG = "TerminalView"; + + public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code) + super(context, attributes); + mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() { + + boolean scrolledWithFinger; + + @Override + public boolean onUp(MotionEvent event) { + mScrollRemainder = 0.0f; + if (mEmulator != null && mEmulator.isMouseTrackingActive() && !event.isFromSource(InputDevice.SOURCE_MOUSE) && !isSelectingText() && !scrolledWithFinger) { + // Quick event processing when mouse tracking is active - do not wait for check of double tapping + // for zooming. + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true); + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false); + return true; + } + scrolledWithFinger = false; + return false; + } + + @Override + public boolean onSingleTapUp(MotionEvent event) { + if (mEmulator == null) return true; + + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } + requestFocus(); + mClient.onSingleTapUp(event); + return true; + } + + @Override + public boolean onScroll(MotionEvent e, float distanceX, float distanceY) { + if (mEmulator == null) return true; + if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) { + // If moving with mouse pointer while pressing button, report that instead of scroll. + // This means that we never report moving with button press-events for touch input, + // since we cannot just start sending these events without a starting press event, + // which we do not do for touch input, only mouse in onTouchEvent(). + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + } else { + scrolledWithFinger = true; + distanceY += mScrollRemainder; + int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing); + mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing; + doScroll(e, deltaRows); + } + return true; + } + + @Override + public boolean onScale(float focusX, float focusY, float scale) { + if (mEmulator == null || isSelectingText()) return true; + mScaleFactor *= scale; + mScaleFactor = mClient.onScale(mScaleFactor); + return true; + } + + @Override + public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) { + if (mEmulator == null) return true; + // Do not start scrolling until last fling has been taken care of: + if (!mScroller.isFinished()) return true; + + final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive(); + float SCALE = 0.25f; + if (mouseTrackingAtStartOfFling) { + mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2); + } else { + mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0); + } + + post(new Runnable() { + private int mLastY = 0; + + @Override + public void run() { + if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) { + mScroller.abortAnimation(); + return; + } + if (mScroller.isFinished()) return; + boolean more = mScroller.computeScrollOffset(); + int newY = mScroller.getCurrY(); + int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow); + doScroll(e2, diff); + mLastY = newY; + if (more) post(this); + } + }); + + return true; + } + + @Override + public boolean onDown(float x, float y) { + // Why is true not returned here? + // https://developer.android.com/training/gestures/detector.html#detect-a-subset-of-supported-gestures + // Although setting this to true still does not solve the following errors when long pressing in terminal view text area + // ViewDragHelper: Ignoring pointerId=0 because ACTION_DOWN was not received for this pointer before ACTION_MOVE + // Commenting out the call to mGestureDetector.onTouchEvent(event) in GestureAndScaleRecognizer#onTouchEvent() removes + // the error logging, so issue is related to GestureDetector + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + // Do not treat is as a single confirmed tap - it may be followed by zoom. + return false; + } + + @Override + public void onLongPress(MotionEvent event) { + if (mGestureRecognizer.isInProgress()) return; + if (mClient.onLongPress(event)) return; + if (!isSelectingText()) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + startTextSelectionMode(event); + } + } + }); + mScroller = new Scroller(context); + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + mAccessibilityEnabled = am.isEnabled(); + } + + + + /** + * @param client The {@link TerminalViewClient} interface implementation to allow + * for communication between {@link TerminalView} and its client. + */ + public void setTerminalViewClient(TerminalViewClient client) { + this.mClient = client; + } + + /** + * Sets whether terminal view key logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public void setIsTerminalViewKeyLoggingEnabled(boolean value) { + TERMINAL_VIEW_KEY_LOGGING_ENABLED = value; + } + + + + /** + * Attach a {@link TerminalSession} to this view. + * + * @param session The {@link TerminalSession} this view will be displaying. + */ + public boolean attachSession(TerminalSession session) { + if (session == mTermSession) return false; + mTopRow = 0; + + mTermSession = session; + mEmulator = null; + mCombiningAccent = 0; + + updateSize(); + + // Wait with enabling the scrollbar until we have a terminal to get scroll position from. + setVerticalScrollBarEnabled(true); + + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Ensure that inputType is only set if TerminalView is selected view with the keyboard and + // an alternate view is not selected, like an EditText. This is necessary if an activity is + // initially started with the alternate view or if activity is returned to from another app + // and the alternate view was the one selected the last time. + if (mClient.isTerminalViewSelected()) { + if (mClient.shouldEnforceCharBasedInput()) { + // Some keyboards seems do not reset the internal state on TYPE_NULL. + // Affects mostly Samsung stock keyboards. + // https://github.com/termux/termux-app/issues/686 + // However, this is not a valid value as per AOSP since `InputType.TYPE_CLASS_*` is + // not set and it logs a warning: + // W/InputAttributes: Unexpected input class: inputType=0x00080090 imeOptions=0x02000000 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/inputmethods/LatinIME/java/src/com/android/inputmethod/latin/InputAttributes.java;l=79 + outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } else { + // Using InputType.NULL is the most correct input type and avoids issues with other hacks. + // + // Previous keyboard issues: + // https://github.com/termux/termux-packages/issues/25 + // https://github.com/termux/termux-app/issues/87. + // https://github.com/termux/termux-app/issues/126. + // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL). + outAttrs.inputType = InputType.TYPE_NULL; + } + } else { + // Corresponds to android:inputType="text" + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; + } + + // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen + // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + + return new BaseInputConnection(this, true) { + + @Override + public boolean finishComposingText() { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()"); + super.finishComposingText(); + + sendTextToTerminal(getEditable()); + getEditable().clear(); + return true; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")"); + } + super.commitText(text, newCursorPosition); + + if (mEmulator == null) return true; + + Editable content = getEditable(); + sendTextToTerminal(content); + content.clear(); + return true; + } + + @Override + public boolean deleteSurroundingText(int leftLength, int rightLength) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")"); + } + // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1. + KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey); + return super.deleteSurroundingText(leftLength, rightLength); + } + + void sendTextToTerminal(CharSequence text) { + stopTextSelectionMode(); + final int textLengthInChars = text.length(); + for (int i = 0; i < textLengthInChars; i++) { + char firstChar = text.charAt(i); + int codePoint; + if (Character.isHighSurrogate(firstChar)) { + if (++i < textLengthInChars) { + codePoint = Character.toCodePoint(firstChar, text.charAt(i)); + } else { + // At end of string, with no low surrogate following the high: + codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR; + } + } else { + codePoint = firstChar; + } + + // Check onKeyDown() for details. + if (mClient.readShiftKey()) + codePoint = Character.toUpperCase(codePoint); + + boolean ctrlHeld = false; + if (codePoint <= 31 && codePoint != 27) { + if (codePoint == '\n') { + // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed, + // instead of a key event like most other keyboard apps. A terminal expects \r for the enter + // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to + // check the behaviour). + codePoint = '\r'; + } + + // E.g. penti keyboard for ctrl input. + ctrlHeld = true; + switch (codePoint) { + case 31: + codePoint = '_'; + break; + case 30: + codePoint = '^'; + break; + case 29: + codePoint = ']'; + break; + case 28: + codePoint = '\\'; + break; + default: + codePoint += 96; + break; + } + } + + inputCodePoint(codePoint, ctrlHeld, false); + } + } + + }; + } + + @Override + protected int computeVerticalScrollRange() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows(); + } + + @Override + protected int computeVerticalScrollExtent() { + return mEmulator == null ? 1 : mEmulator.mRows; + } + + @Override + protected int computeVerticalScrollOffset() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows; + } + + public void onScreenUpdated() { + if (mEmulator == null) return; + + int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows(); + if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory; + + boolean skipScrolling = false; + if (isSelectingText()) { + // Do not scroll when selecting text. + int rowShift = mEmulator.getScrollCounter(); + if (-mTopRow + rowShift > rowsInHistory) { + // .. unless we're hitting the end of history transcript, in which + // case we abort text selection and scroll to end. + stopTextSelectionMode(); + } else { + skipScrolling = true; + mTopRow -= rowShift; + decrementYTextSelectionCursors(rowShift); + } + } + + if (!skipScrolling && mTopRow != 0) { + // Scroll down if not already there. + if (mTopRow < -3) { + // Awaken scroll bars only if scrolling a noticeable amount + // - we do not want visible scroll bars during normal typing + // of one row at a time. + awakenScrollBars(); + } + mTopRow = 0; + } + + mEmulator.clearScrollCounter(); + + invalidate(); + if (mAccessibilityEnabled) setContentDescription(getText()); + } + + /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} + * when context menu for the {@link TerminalView} is started by + * {@link TextSelectionCursorController#ACTION_MORE} is closed. */ + public void onContextMenuClosed(Menu menu) { + // Unset the stored text since it shouldn't be used anymore and should be cleared from memory + unsetStoredSelectedText(); + } + + /** + * Sets the text size, which in turn sets the number of rows and columns. + * + * @param textSize the new font size, in density-independent pixels. + */ + public void setTextSize(int textSize) { + mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface); + updateSize(); + } + + public void setTypeface(Typeface newTypeface) { + mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface); + updateSize(); + invalidate(); + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isOpaque() { + return true; + } + + /** + * Get the zero indexed column and row of the terminal view for the + * position of the event. + * + * @param event The event with the position to get the column and row for. + * @param relativeToScroll If true the column number will take the scroll + * position into account. E.g. if scrolled 3 lines up and the event + * position is in the top left, column will be -3 if relativeToScroll is + * true and 0 if relativeToScroll is false. + * @return Array with the column and row. + */ + public int[] getColumnAndRow(MotionEvent event, boolean relativeToScroll) { + int column = (int) (event.getX() / mRenderer.mFontWidth); + int row = (int) ((event.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + if (relativeToScroll) { + row += mTopRow; + } + return new int[] { column, row }; + } + + /** Send a single mouse event code to the terminal. */ + void sendMouseEventCode(MotionEvent e, int button, boolean pressed) { + int[] columnAndRow = getColumnAndRow(e, false); + int x = columnAndRow[0] + 1; + int y = columnAndRow[1] + 1; + if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) { + if (mMouseStartDownTime == e.getDownTime()) { + x = mMouseScrollStartX; + y = mMouseScrollStartY; + } else { + mMouseStartDownTime = e.getDownTime(); + mMouseScrollStartX = x; + mMouseScrollStartY = y; + } + } + mEmulator.sendMouseEvent(button, x, y, pressed); + } + + /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */ + void doScroll(MotionEvent event, int rowsDown) { + boolean up = rowsDown < 0; + int amount = Math.abs(rowsDown); + for (int i = 0; i < amount; i++) { + if (mEmulator.isMouseTrackingActive()) { + sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true); + } else if (mEmulator.isAlternateBufferActive()) { + // Send up and down key events for scrolling, which is what some terminals do to make scroll work in + // e.g. less, which shifts to the alt screen without mouse handling. + handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0); + } else { + mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1))); + if (!awakenScrollBars()) invalidate(); + } + } + } + + /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */ + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) { + // Handle mouse wheel scrolling. + boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f; + doScroll(event, up ? -3 : 3); + return true; + } + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + @TargetApi(23) + public boolean onTouchEvent(MotionEvent event) { + if (mEmulator == null) return true; + final int action = event.getAction(); + + if (isSelectingText()) { + updateFloatingToolbarVisibility(event); + mGestureRecognizer.onTouchEvent(event); + return true; + } else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) { + if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) { + if (action == MotionEvent.ACTION_DOWN) showContextMenu(); + return true; + } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { + ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData != null) { + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem != null) { + CharSequence text = clipItem.coerceToText(getContext()); + if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); + } + } + } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.getAction() == MotionEvent.ACTION_DOWN); + break; + case MotionEvent.ACTION_MOVE: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + break; + } + } + } + + mGestureRecognizer.onTouchEvent(event); + return true; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")"); + if (keyCode == KeyEvent.KEYCODE_BACK) { + cancelRequestAutoFill(); + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } else if (mClient.shouldBackButtonBeMappedToEscape()) { + // Intercept back button to treat it as escape: + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + return onKeyDown(keyCode, event); + case KeyEvent.ACTION_UP: + return onKeyUp(keyCode, event); + } + } + } else if (mClient.shouldUseCtrlSpaceWorkaround() && + keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed()) { + /* ctrl+space does not work on some ROMs without this workaround. + However, this breaks it on devices where it works out of the box. */ + return onKeyDown(keyCode, event); + } + return super.onKeyPreIme(keyCode, event); + } + + /** + * Key presses in software keyboards will generally NOT trigger this listener, although some + * may elect to do so in some situations. Do not rely on this to catch software key presses. + * Gboard calls this when shouldEnforceCharBasedInput() is disabled (InputType.TYPE_NULL) instead + * of calling commitText(), with deviceId=-1. However, Hacker's Keyboard, OpenBoard, LG Keyboard + * call commitText(). + * + * This function may also be called directly without android calling it, like by + * `TerminalExtraKeys` which generates a KeyEvent manually which uses {@link KeyCharacterMap#VIRTUAL_KEYBOARD} + * as the device (deviceId=-1), as does Gboard. That would normally use mappings defined in + * `/system/usr/keychars/Virtual.kcm`. You can run `dumpsys input` to find the `KeyCharacterMapFile` + * used by virtual keyboard or hardware keyboard. Note that virtual keyboard device is not the + * same as software keyboard, like Gboard, etc. Its a fake device used for generating events and + * for testing. + * + * We handle shift key in `commitText()` to convert codepoint to uppercase case there with a + * call to {@link Character#toUpperCase(int)}, but here we instead rely on getUnicodeChar() for + * conversion of keyCode, for both hardware keyboard shift key (via effectiveMetaState) and + * `mClient.readShiftKey()`, based on value in kcm files. + * This may result in different behaviour depending on keyboard and android kcm files set for the + * InputDevice for the event passed to this function. This will likely be an issue for non-english + * languages since `Virtual.kcm` in english only by default or at least in AOSP. For both hardware + * shift key (via effectiveMetaState) and `mClient.readShiftKey()`, `getUnicodeChar()` is used + * for shift specific behaviour which usually is to uppercase. + * + * For fn key on hardware keyboard, android checks kcm files for hardware keyboards, which is + * `Generic.kcm` by default, unless a vendor specific one is defined. The event passed will have + * {@link KeyEvent#META_FUNCTION_ON} set. If the kcm file only defines a single character or unicode + * code point `\\uxxxx`, then only one event is passed with that value. However, if kcm defines + * a `fallback` key for fn or others, like `key DPAD_UP { ... fn: fallback PAGE_UP }`, then + * android will first pass an event with original key `DPAD_UP` and {@link KeyEvent#META_FUNCTION_ON} + * set. But this function will not consume it and android will pass another event with `PAGE_UP` + * and {@link KeyEvent#META_FUNCTION_ON} not set, which will be consumed. + * + * Now there are some other issues as well, firstly ctrl and alt flags are not passed to + * `getUnicodeChar()`, so modified key values in kcm are not used. Secondly, if the kcm file + * for other modifiers like shift or fn define a non-alphabet, like { fn: '\u0015' } to act as + * DPAD_LEFT, the `getUnicodeChar()` will correctly return `21` as the code point but action will + * not happen because the `handleKeyCode()` function that transforms DPAD_LEFT to `\033[D` + * escape sequence for the terminal to perform the left action would not be called since its + * called before `getUnicodeChar()` and terminal will instead get `21 0x15 Negative Acknowledgement`. + * The solution to such issues is calling `getUnicodeChar()` before the call to `handleKeyCode()` + * if user has defined a custom kcm file, like done in POC mentioned in #2237. Note that + * Hacker's Keyboard calls `commitText()` so don't test fn/shift with it for this function. + * https://github.com/termux/termux-app/pull/2237 + * https://github.com/agnostic-apollo/termux-app/blob/terminal-code-point-custom-mapping/terminal-view/src/main/java/com/termux/view/TerminalView.java + * + * Key Character Map (kcm) and Key Layout (kl) files info: + * https://source.android.com/devices/input/key-character-map-files + * https://source.android.com/devices/input/key-layout-files + * https://source.android.com/devices/input/keyboard-devices + * AOSP kcm and kl files: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/data/keyboards + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/packages/InputDevices/res/raw + * + * KeyCodes: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java + * https://cs.android.com/android/platform/superproject/+/master:frameworks/native/include/android/keycodes.h + * + * `dumpsys input`: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1917 + * + * Loading of keymap: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1644 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/Keyboard.cpp;l=41 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/InputDevice.cpp + * OVERLAY keymaps for hardware keyboards may be combined as well: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=165 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=831 + * + * Parse kcm file: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=727 + * Parse key value: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=981 + * + * `KeyEvent.getUnicodeChar()` + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java;l=2716 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/KeyCharacterMap.java;l=368 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/jni/android_view_KeyCharacterMap.cpp;l=117 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=231 + * + * Keyboard layouts advertised by applications, like for hardware keyboards via #ACTION_QUERY_KEYBOARD_LAYOUTS + * Config is stored in `/data/system/input-manager-state.xml` + * https://github.com/ris58h/custom-keyboard-layout + * Loading from apps: + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1221 + * Set: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=89 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=543 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/apps/Settings/src/com/android/settings/inputmethod/KeyboardLayoutDialogFragment.java;l=167 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1385 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/PersistentDataStore.java + * Get overlay keyboard layout + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=2158 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp;l=616 + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")"); + if (mEmulator == null) return true; + if (isSelectingText()) { + stopTextSelectionMode(); + } + + if (mClient.onKeyDown(keyCode, event, mTermSession)) { + invalidate(); + return true; + } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { + return super.onKeyDown(keyCode, event); + } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) { + mTermSession.write(event.getCharacters()); + return true; + } + + final int metaState = event.getMetaState(); + final boolean controlDown = event.isCtrlPressed() || mClient.readControlKey(); + final boolean leftAltDown = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0 || mClient.readAltKey(); + final boolean shiftDown = event.isShiftPressed() || mClient.readShiftKey(); + final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0; + + int keyMod = 0; + if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL; + if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT; + if (shiftDown) keyMod |= KeyHandler.KEYMOD_SHIFT; + if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK; + // https://github.com/termux/termux-app/issues/731 + if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event"); + return true; + } + + // Clear Ctrl since we handle that ourselves: + int bitsToClear = KeyEvent.META_CTRL_MASK; + if (rightAltDownFromEvent) { + // Let right Alt/Alt Gr be used to compose characters. + } else { + // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove: + bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + } + int effectiveMetaState = event.getMetaState() & ~bitsToClear; + + if (shiftDown) effectiveMetaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; + if (mClient.readFnKey()) effectiveMetaState |= KeyEvent.META_FUNCTION_ON; + + int result = event.getUnicodeChar(effectiveMetaState); + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result); + if (result == 0) { + return false; + } + + int oldCombiningAccent = mCombiningAccent; + if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) { + // If entered combining accent previously, write it out: + if (mCombiningAccent != 0) + inputCodePoint(mCombiningAccent, controlDown, leftAltDown); + mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK; + } else { + if (mCombiningAccent != 0) { + int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result); + if (combinedChar > 0) result = combinedChar; + mCombiningAccent = 0; + } + inputCodePoint(result, controlDown, leftAltDown); + } + + if (mCombiningAccent != oldCombiningAccent) invalidate(); + + return true; + } + + public void inputCodePoint(int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "inputCodePoint(codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + + leftAltDownFromEvent + ")"); + } + + if (mTermSession == null) return; + + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + final boolean controlDown = controlDownFromEvent || mClient.readControlKey(); + final boolean altDown = leftAltDownFromEvent || mClient.readAltKey(); + + if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + + if (controlDown) { + if (codePoint >= 'a' && codePoint <= 'z') { + codePoint = codePoint - 'a' + 1; + } else if (codePoint >= 'A' && codePoint <= 'Z') { + codePoint = codePoint - 'A' + 1; + } else if (codePoint == ' ' || codePoint == '2') { + codePoint = 0; + } else if (codePoint == '[' || codePoint == '3') { + codePoint = 27; // ^[ (Esc) + } else if (codePoint == '\\' || codePoint == '4') { + codePoint = 28; + } else if (codePoint == ']' || codePoint == '5') { + codePoint = 29; + } else if (codePoint == '^' || codePoint == '6') { + codePoint = 30; // control-^ + } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') { + // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102" + // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal + codePoint = 31; + } else if (codePoint == '8') { + codePoint = 127; // DEL + } + } + + if (codePoint > -1) { + // Work around bluetooth keyboards sending funny unicode characters instead + // of the more normal ones from ASCII that terminal programs expect - the + // desire to input the original characters should be low. + switch (codePoint) { + case 0x02DC: // SMALL TILDE. + codePoint = 0x007E; // TILDE (~). + break; + case 0x02CB: // MODIFIER LETTER GRAVE ACCENT. + codePoint = 0x0060; // GRAVE ACCENT (`). + break; + case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT. + codePoint = 0x005E; // CIRCUMFLEX ACCENT (^). + break; + } + + // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline: + mTermSession.writeCodePoint(altDown, codePoint); + } + } + + /** Input the specified keyCode if applicable and return if the input was consumed. */ + public boolean handleKeyCode(int keyCode, int keyMod) { + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + if (handleKeyCodeAction(keyCode, keyMod)) + return true; + + TerminalEmulator term = mTermSession.getEmulator(); + String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); + if (code == null) return false; + mTermSession.write(code); + return true; + } + + public boolean handleKeyCodeAction(int keyCode, int keyMod) { + boolean shiftDown = (keyMod & KeyHandler.KEYMOD_SHIFT) != 0; + + switch (keyCode) { + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PAGE_DOWN: + // shift+page_up and shift+page_down should scroll scrollback history instead of + // scrolling command history or changing pages + if (shiftDown) { + long time = SystemClock.uptimeMillis(); + MotionEvent motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0); + doScroll(motionEvent, keyCode == KeyEvent.KEYCODE_PAGE_UP ? -mEmulator.mRows : mEmulator.mRows); + motionEvent.recycle(); + return true; + } + } + + return false; + } + + /** + * Called when a key is released in the view. + * + * @param keyCode The keycode of the key which was released. + * @param event A {@link KeyEvent} describing the event. + * @return Whether the event was handled. + */ + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")"); + + // Do not return for KEYCODE_BACK and send it to the client since user may be trying + // to exit the activity. + if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true; + + if (mClient.onKeyUp(keyCode, event)) { + invalidate(); + return true; + } else if (event.isSystem()) { + // Let system key events through. + return super.onKeyUp(keyCode, event); + } + + return true; + } + + /** + * This is called during layout when the size of this view has changed. If you were just added to the view + * hierarchy, you're called with the old values of 0. + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateSize(); + } + + /** Check if the terminal size in rows and columns should be updated. */ + public void updateSize() { + int viewWidth = getWidth(); + int viewHeight = getHeight(); + if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return; + + // Set to 80 and 24 if you want to enable vttest. + int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth)); + int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + + if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) { + mTermSession.updateSize(newColumns, newRows, (int) mRenderer.getFontWidth(), mRenderer.getFontLineSpacing()); + mEmulator = mTermSession.getEmulator(); + mClient.onEmulatorSet(); + + // Update mTerminalCursorBlinkerRunnable inner class mEmulator on session change + if (mTerminalCursorBlinkerRunnable != null) + mTerminalCursorBlinkerRunnable.setEmulator(mEmulator); + + mTopRow = 0; + scrollTo(0, 0); + invalidate(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mEmulator == null) { + canvas.drawColor(0XFF000000); + } else { + // render the terminal view and highlight any selected text + int[] sel = mDefaultSelectors; + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.getSelectors(sel); + } + + mRenderer.render(mEmulator, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3]); + + // render the text selection handles + renderTextSelection(); + } + } + + public TerminalSession getCurrentSession() { + return mTermSession; + } + + private CharSequence getText() { + return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows); + } + + public int getCursorX(float x) { + return (int) (x / mRenderer.mFontWidth); + } + + public int getCursorY(float y) { + return (int) (((y - 40) / mRenderer.mFontLineSpacing) + mTopRow); + } + + public int getPointX(int cx) { + if (cx > mEmulator.mColumns) { + cx = mEmulator.mColumns; + } + return Math.round(cx * mRenderer.mFontWidth); + } + + public int getPointY(int cy) { + return Math.round((cy - mTopRow) * mRenderer.mFontLineSpacing); + } + + public int getTopRow() { + return mTopRow; + } + + public void setTopRow(int mTopRow) { + this.mTopRow = mTopRow; + } + + + + /** + * Define functions required for AutoFill API + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void autofill(AutofillValue value) { + if (value.isText()) { + mTermSession.write(value.getTextValue().toString()); + } + + resetAutoFill(); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getAutofillType() { + return mAutoFillType; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public String[] getAutofillHints() { + return mAutoFillHints; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public AutofillValue getAutofillValue() { + return AutofillValue.forText(""); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getImportantForAutofill() { + return mAutoFillImportance; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private synchronized void resetAutoFill() { + // Restore none type so that AutoFill UI isn't shown anymore. + mAutoFillType = AUTOFILL_TYPE_NONE; + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + mAutoFillHints = new String[0]; + } + + public AutofillManager getAutoFillManagerService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; + + try { + Context context = getContext(); + if (context == null) return null; + return context.getSystemService(AutofillManager.class); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e); + return null; + } + } + + public boolean isAutoFillEnabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + return autofillManager != null && autofillManager.isEnabled(); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e); + return false; + } + } + + public synchronized void requestAutoFillUsername() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_USERNAME} : + null); + } + + public synchronized void requestAutoFillPassword() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_PASSWORD} : + null); + } + + public synchronized void requestAutoFill(String[] autoFillHints) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (autoFillHints == null || autoFillHints.length < 1) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown. + mAutoFillType = AUTOFILL_TYPE_TEXT; + // Update importance that will be returned by `getImportantForAutofill()` so that + // AutoFill considers the view as important. + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES; + // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI. + mAutoFillHints = autoFillHints; + autofillManager.requestAutofill(this); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e); + } + } + + public synchronized void cancelRequestAutoFill() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (mAutoFillType == AUTOFILL_TYPE_NONE) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + resetAutoFill(); + autofillManager.cancel(); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e); + } + } + + + + + + /** + * Set terminal cursor blinker rate. It must be between {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} + * and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}, otherwise it will be disabled. + * + * The {@link #setTerminalCursorBlinkerState(boolean, boolean)} must be called after this + * for changes to take effect if not disabling. + * + * @param blinkRate The value to set. + * @return Returns {@code true} if setting blinker rate was successfully set, otherwise [@code false}. + */ + public synchronized boolean setTerminalCursorBlinkerRate(int blinkRate) { + boolean result; + + // If cursor blinking rate is not valid + if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) { + mClient.logError(LOG_TAG, "The cursor blink rate must be in between " + TERMINAL_CURSOR_BLINK_RATE_MIN + "-" + TERMINAL_CURSOR_BLINK_RATE_MAX + ": " + blinkRate); + mTerminalCursorBlinkerRate = 0; + result = false; + } else { + mClient.logVerbose(LOG_TAG, "Setting cursor blinker rate to " + blinkRate); + mTerminalCursorBlinkerRate = blinkRate; + result = true; + } + + if (mTerminalCursorBlinkerRate == 0) { + mClient.logVerbose(LOG_TAG, "Cursor blinker disabled"); + stopTerminalCursorBlinker(); + } + + return result; + } + + /** + * Sets whether cursor blinker should be started or stopped. Cursor blinker will only be + * started if {@link #mTerminalCursorBlinkerRate} does not equal 0 and is between + * {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}. + * + * This should be called when the view holding this activity is resumed or stopped so that + * cursor blinker does not run when activity is not visible. If you call this on onResume() + * to start cursor blinking, then ensure that {@link #mEmulator} is set, otherwise wait for the + * {@link TerminalViewClient#onEmulatorSet()} event after calling {@link #attachSession(TerminalSession)} + * for the first session added in the activity since blinking will not start if {@link #mEmulator} + * is not set, like if activity is started again after exiting it with double back press. Do not + * call this directly after {@link #attachSession(TerminalSession)} since {@link #updateSize()} + * may return without setting {@link #mEmulator} since width/height may be 0. Its called again in + * {@link #onSizeChanged(int, int, int, int)}. Calling on onResume() if emulator is already set + * is necessary, since onEmulatorSet() may not be called after activity is started after device + * display timeout with double tap and not power button. + * + * It should also be called on the + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)} + * callback when cursor is enabled or disabled so that blinker is disabled if cursor is not + * to be shown. It should also be checked if activity is visible if blinker is to be started + * before calling this. + * + * It should also be called after terminal is reset with {@link TerminalSession#reset()} in case + * cursor blinker was disabled before reset due to call to + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}. + * + * How cursor blinker starting works is by registering a {@link Runnable} with the looper of + * the main thread of the app which when run, toggles the cursor blinking state and re-registers + * itself to be called with the delay set by {@link #mTerminalCursorBlinkerRate}. When cursor + * blinking needs to be disabled, we just cancel any callbacks registered. We don't run our own + * "thread" and let the thread for the main looper do the work for us, whose usage is also + * required to update the UI, since it also handles other calls to update the UI as well based + * on a queue. + * + * Note that when moving cursor in text editors like nano, the cursor state is quickly + * toggled `-> off -> on`, which would call this very quickly sequentially. So that if cursor + * is moved 2 or more times quickly, like long hold on arrow keys, it would trigger + * `-> off -> on -> off -> on -> ...`, and the "on" callback at index 2 is automatically + * cancelled by next "off" callback at index 3 before getting a chance to be run. For this case + * we log only if {@link #TERMINAL_VIEW_KEY_LOGGING_ENABLED} is enabled, otherwise would clutter + * the log. We don't start the blinking with a delay to immediately show cursor in case it was + * previously not visible. + * + * @param start If cursor blinker should be started or stopped. + * @param startOnlyIfCursorEnabled If set to {@code true}, then it will also be checked if the + * cursor is even enabled by {@link TerminalEmulator} before + * starting the cursor blinker. + */ + public synchronized void setTerminalCursorBlinkerState(boolean start, boolean startOnlyIfCursorEnabled) { + // Stop any existing cursor blinker callbacks + stopTerminalCursorBlinker(); + + if (mEmulator == null) return; + + mEmulator.setCursorBlinkingEnabled(false); + + if (start) { + // If cursor blinker is not enabled or is not valid + if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX) + return; + // If cursor blinder is to be started only if cursor is enabled + else if (startOnlyIfCursorEnabled && ! mEmulator.isCursorEnabled()) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled"); + return; + } + + // Start cursor blinker runnable + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate " + mTerminalCursorBlinkerRate); + if (mTerminalCursorBlinkerHandler == null) + mTerminalCursorBlinkerHandler = new Handler(Looper.getMainLooper()); + mTerminalCursorBlinkerRunnable = new TerminalCursorBlinkerRunnable(mEmulator, mTerminalCursorBlinkerRate); + mEmulator.setCursorBlinkingEnabled(true); + mTerminalCursorBlinkerRunnable.run(); + } + } + + /** + * Cancel the terminal cursor blinker callbacks + */ + private void stopTerminalCursorBlinker() { + if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Stopping cursor blinker"); + mTerminalCursorBlinkerHandler.removeCallbacks(mTerminalCursorBlinkerRunnable); + } + } + + private class TerminalCursorBlinkerRunnable implements Runnable { + + private TerminalEmulator mEmulator; + private final int mBlinkRate; + + // Initialize with false so that initial blink state is visible after toggling + boolean mCursorVisible = false; + + public TerminalCursorBlinkerRunnable(TerminalEmulator emulator, int blinkRate) { + mEmulator = emulator; + mBlinkRate = blinkRate; + } + + public void setEmulator(TerminalEmulator emulator) { + mEmulator = emulator; + } + + public void run() { + try { + if (mEmulator != null) { + // Toggle the blink state and then invalidate() the view so + // that onDraw() is called, which then calls TerminalRenderer.render() + // which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether + // to draw the cursor or not + mCursorVisible = !mCursorVisible; + //mClient.logVerbose(LOG_TAG, "Toggling cursor blink state to " + mCursorVisible); + mEmulator.setCursorBlinkState(mCursorVisible); + invalidate(); + } + } finally { + // Recall the Runnable after mBlinkRate milliseconds to toggle the blink state + mTerminalCursorBlinkerHandler.postDelayed(this, mBlinkRate); + } + } + } + + + + /** + * Define functions required for text selection and its handles. + */ + TextSelectionCursorController getTextSelectionCursorController() { + if (mTextSelectionCursorController == null) { + mTextSelectionCursorController = new TextSelectionCursorController(this); + + final ViewTreeObserver observer = getViewTreeObserver(); + if (observer != null) { + observer.addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + return mTextSelectionCursorController; + } + + private void showTextSelectionCursors(MotionEvent event) { + getTextSelectionCursorController().show(event); + } + + private boolean hideTextSelectionCursors() { + return getTextSelectionCursorController().hide(); + } + + private void renderTextSelection() { + if (mTextSelectionCursorController != null) + mTextSelectionCursorController.render(); + } + + public boolean isSelectingText() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.isActive(); + } else { + return false; + } + } + + /** Get the currently selected text if selecting. */ + public String getSelectedText() { + if (isSelectingText() && mTextSelectionCursorController != null) + return mTextSelectionCursorController.getSelectedText(); + else + return null; + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mTextSelectionCursorController != null ? mTextSelectionCursorController.getStoredSelectedText() : null; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + if (mTextSelectionCursorController != null) mTextSelectionCursorController.unsetStoredSelectedText(); + } + + private ActionMode getTextSelectionActionMode() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.getActionMode(); + } else { + return null; + } + } + + public void startTextSelectionMode(MotionEvent event) { + if (!requestFocus()) { + return; + } + + showTextSelectionCursors(event); + mClient.copyModeChanged(isSelectingText()); + + invalidate(); + } + + public void stopTextSelectionMode() { + if (hideTextSelectionCursors()) { + mClient.copyModeChanged(isSelectingText()); + invalidate(); + } + } + + private void decrementYTextSelectionCursors(int decrement) { + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.decrementYTextSelectionCursors(decrement); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (mTextSelectionCursorController != null) { + getViewTreeObserver().addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (mTextSelectionCursorController != null) { + // Might solve the following exception + // android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow + stopTextSelectionMode(); + + getViewTreeObserver().removeOnTouchModeChangeListener(mTextSelectionCursorController); + mTextSelectionCursorController.onDetached(); + } + } + + + + /** + * Define functions required for long hold toolbar. + */ + private final Runnable mShowFloatingToolbar = new Runnable() { + @Override + public void run() { + if (getTextSelectionActionMode() != null) { + getTextSelectionActionMode().hide(0); // hide off. + } + } + }; + + private void showFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + int delay = ViewConfiguration.getDoubleTapTimeout(); + postDelayed(mShowFloatingToolbar, delay); + } + } + + void hideFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + removeCallbacks(mShowFloatingToolbar); + getTextSelectionActionMode().hide(-1); + } + } + + public void updateFloatingToolbarVisibility(MotionEvent event) { + if (getTextSelectionActionMode() != null) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_MOVE: + hideFloatingToolbar(); + break; + case MotionEvent.ACTION_UP: // fall through + case MotionEvent.ACTION_CANCEL: + showFloatingToolbar(); + } + } + } + +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java new file mode 100644 index 00000000..d6b49b89 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java @@ -0,0 +1,83 @@ +package com.termux.view; + +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +import com.termux.terminal.TerminalSession; + +/** + * The interface for communication between {@link TerminalView} and its client. It allows for getting + * various configuration options from the client and for sending back data to the client like logs, + * key events, both hardware and IME (which makes it different from that available with + * {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the + * {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}. + */ +public interface TerminalViewClient { + + /** + * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}. + */ + float onScale(float scale); + + + + /** + * On a single tap on the terminal if terminal mouse reporting not enabled. + */ + void onSingleTapUp(MotionEvent e); + + boolean shouldBackButtonBeMappedToEscape(); + + boolean shouldEnforceCharBasedInput(); + + boolean shouldUseCtrlSpaceWorkaround(); + + boolean isTerminalViewSelected(); + + + + void copyModeChanged(boolean copyMode); + + + + boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session); + + boolean onKeyUp(int keyCode, KeyEvent e); + + boolean onLongPress(MotionEvent event); + + + + boolean readControlKey(); + + boolean readAltKey(); + + boolean readShiftKey(); + + boolean readFnKey(); + + + + boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session); + + + void onEmulatorSet(); + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java new file mode 100644 index 00000000..f0e1cc5e --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java @@ -0,0 +1,55 @@ +package com.termux.view.textselection; + +import android.view.MotionEvent; +import android.view.ViewTreeObserver; + +import com.termux.view.TerminalView; + +/** + * A CursorController instance can be used to control cursors in the text. + * It is not used outside of {@link TerminalView}. + */ +public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener { + /** + * Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw. + * See also {@link #hide()}. + */ + void show(MotionEvent event); + + /** + * Hide the cursors from screen. + * See also {@link #show(MotionEvent event)}. + */ + boolean hide(); + + /** + * Render the cursors. + */ + void render(); + + /** + * Update the cursor positions. + */ + void updatePosition(TextSelectionHandleView handle, int x, int y); + + /** + * This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors + * a chance to become active and/or visible. + * + * @param event The touch event + */ + boolean onTouchEvent(MotionEvent event); + + /** + * Called when the view is detached from window. Perform house keeping task, such as + * stopping Runnable thread that would otherwise keep a reference on the context, thus + * preventing the activity to be recycled. + */ + void onDetached(); + + /** + * @return true if the cursors are currently active. + */ + boolean isActive(); + +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java new file mode 100644 index 00000000..714d56e4 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java @@ -0,0 +1,402 @@ +package com.termux.view.textselection; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Rect; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.InputDevice; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.WcWidth; +import com.termux.view.R; +import com.termux.view.TerminalView; + +public class TextSelectionCursorController implements CursorController { + + private final TerminalView terminalView; + private final TextSelectionHandleView mStartHandle, mEndHandle; + private String mStoredSelectedText; + private boolean mIsSelectingText = false; + private long mShowStartTime = System.currentTimeMillis(); + + private final int mHandleHeight; + private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; + + private ActionMode mActionMode; + public final int ACTION_COPY = 1; + public final int ACTION_PASTE = 2; + public final int ACTION_MORE = 3; + + public TextSelectionCursorController(TerminalView terminalView) { + this.terminalView = terminalView; + mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT); + mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT); + + mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight()); + } + + @Override + public void show(MotionEvent event) { + setInitialTextSelectionPosition(event); + mStartHandle.positionAtCursor(mSelX1, mSelY1, true); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true); + + setActionModeCallBacks(); + mShowStartTime = System.currentTimeMillis(); + mIsSelectingText = true; + } + + @Override + public boolean hide() { + if (!isActive()) return false; + + // prevent hide calls right after a show call, like long pressing the down key + // 300ms seems long enough that it wouldn't cause hide problems if action button + // is quickly clicked after the show, otherwise decrease it + if (System.currentTimeMillis() - mShowStartTime < 300) { + return false; + } + + mStartHandle.hide(); + mEndHandle.hide(); + + if (mActionMode != null) { + // This will hide the TextSelectionCursorController + mActionMode.finish(); + } + + mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1; + mIsSelectingText = false; + + return true; + } + + @Override + public void render() { + if (!isActive()) return; + + mStartHandle.positionAtCursor(mSelX1, mSelY1, false); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false); + + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + public void setInitialTextSelectionPosition(MotionEvent event) { + int[] columnAndRow = terminalView.getColumnAndRow(event, true); + mSelX1 = mSelX2 = columnAndRow[0]; + mSelY1 = mSelY2 = columnAndRow[1]; + + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) { + // Selecting something other than whitespace. Expand to word. + while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) { + mSelX1--; + } + while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) { + mSelX2++; + } + } + } + + public void setActionModeCallBacks() { + final ActionMode.Callback callback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT; + + ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (!isActive()) { + // Fix issue where the dialog is pressed while being dismissed. + return true; + } + + switch (item.getItemId()) { + case ACTION_COPY: + String selectedText = getSelectedText(); + terminalView.mTermSession.onCopyTextToClipboard(selectedText); + terminalView.stopTextSelectionMode(); + break; + case ACTION_PASTE: + terminalView.stopTextSelectionMode(); + terminalView.mTermSession.onPasteTextFromClipboard(); + break; + case ACTION_MORE: + // We first store the selected text in case TerminalViewClient needs the + // selected text before MORE button was pressed since we are going to + // stop selection mode + mStoredSelectedText = getSelectedText(); + // The text selection needs to be stopped before showing context menu, + // otherwise handles will show above popup + terminalView.stopTextSelectionMode(); + terminalView.showContextMenu(); + break; + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + }; + + mActionMode = terminalView.startActionMode(new ActionMode.Callback2() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return callback.onCreateActionMode(mode, menu); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return callback.onActionItemClicked(mode, item); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + // Ignore. + } + + @Override + public void onGetContentRect(ActionMode mode, View view, Rect outRect) { + int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth()); + int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth()); + int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + + if (x1 > x2) { + int tmp = x1; + x1 = x2; + x2 = tmp; + } + + int terminalBottom = terminalView.getBottom(); + int top = y1 + mHandleHeight; + int bottom = y2 + mHandleHeight; + if (top > terminalBottom) top = terminalBottom; + if (bottom > terminalBottom) bottom = terminalBottom; + + outRect.set(x1, top, x2, bottom); + } + }, ActionMode.TYPE_FLOATING); + } + + @Override + public void updatePosition(TextSelectionHandleView handle, int x, int y) { + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows; + if (handle == mStartHandle) { + mSelX1 = terminalView.getCursorX(x); + mSelY1 = terminalView.getCursorY(y); + if (mSelX1 < 0) { + mSelX1 = 0; + } + + if (mSelY1 < -scrollRows) { + mSelY1 = -scrollRows; + + } else if (mSelY1 > terminalView.mEmulator.mRows - 1) { + mSelY1 = terminalView.mEmulator.mRows - 1; + + } + + if (mSelY1 > mSelY2) { + mSelY1 = mSelY2; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX1 = mSelX2; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY1 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX1 = getValidCurX(screen, mSelY1, mSelX1); + + } else { + mSelX2 = terminalView.getCursorX(x); + mSelY2 = terminalView.getCursorY(y); + if (mSelX2 < 0) { + mSelX2 = 0; + } + + if (mSelY2 < -scrollRows) { + mSelY2 = -scrollRows; + } else if (mSelY2 > terminalView.mEmulator.mRows - 1) { + mSelY2 = terminalView.mEmulator.mRows - 1; + } + + if (mSelY1 > mSelY2) { + mSelY2 = mSelY1; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX2 = mSelX1; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY2 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX2 = getValidCurX(screen, mSelY2, mSelX2); + } + + terminalView.invalidate(); + } + + private int getValidCurX(TerminalBuffer screen, int cy, int cx) { + String line = screen.getSelectedText(0, cy, cx, cy); + if (!TextUtils.isEmpty(line)) { + int col = 0; + for (int i = 0, len = line.length(); i < len; i++) { + char ch1 = line.charAt(i); + if (ch1 == 0) { + break; + } + + int wc; + if (Character.isHighSurrogate(ch1) && i + 1 < len) { + char ch2 = line.charAt(++i); + wc = WcWidth.width(Character.toCodePoint(ch1, ch2)); + } else { + wc = WcWidth.width(ch1); + } + + final int cend = col + wc; + if (cx > col && cx < cend) { + return cend; + } + if (cend == col) { + return col; + } + col = cend; + } + } + return cx; + } + + public void decrementYTextSelectionCursors(int decrement) { + mSelY1 -= decrement; + mSelY2 -= decrement; + } + + public boolean onTouchEvent(MotionEvent event) { + return false; + } + + public void onTouchModeChanged(boolean isInTouchMode) { + if (!isInTouchMode) { + terminalView.stopTextSelectionMode(); + } + } + + @Override + public void onDetached() { + } + + @Override + public boolean isActive() { + return mIsSelectingText; + } + + public void getSelectors(int[] sel) { + if (sel == null || sel.length != 4) { + return; + } + + sel[0] = mSelY1; + sel[1] = mSelY2; + sel[2] = mSelX1; + sel[3] = mSelX2; + } + + /** Get the currently selected text. */ + public String getSelectedText() { + return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mStoredSelectedText; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + mStoredSelectedText = null; + } + + public ActionMode getActionMode() { + return mActionMode; + } + + /** + * @return true if this controller is currently used to move the start selection. + */ + public boolean isSelectionStartDragged() { + return mStartHandle.isDragging(); + } + + /** + * @return true if this controller is currently used to move the end selection. + */ + public boolean isSelectionEndDragged() { + return mEndHandle.isDragging(); + } + +} diff --git a/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java new file mode 100644 index 00000000..4f39e723 --- /dev/null +++ b/apps/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java @@ -0,0 +1,345 @@ +package com.termux.view.textselection; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; +import android.widget.PopupWindow; + +import com.termux.view.R; +import com.termux.view.TerminalView; + +@SuppressLint("ViewConstructor") +public class TextSelectionHandleView extends View { + private final TerminalView terminalView; + private PopupWindow mHandle; + private final CursorController mCursorController; + + private final Drawable mHandleLeftDrawable; + private final Drawable mHandleRightDrawable; + private Drawable mHandleDrawable; + + private boolean mIsDragging; + + final int[] mTempCoords = new int[2]; + Rect mTempRect; + + private int mPointX; + private int mPointY; + private float mTouchToWindowOffsetX; + private float mTouchToWindowOffsetY; + private float mHotspotX; + private float mHotspotY; + private float mTouchOffsetY; + private int mLastParentX; + private int mLastParentY; + + private int mHandleHeight; + private int mHandleWidth; + + private final int mInitialOrientation; + private int mOrientation; + + public static final int LEFT = 0; + public static final int RIGHT = 2; + + private long mLastTime; + + public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) { + super(terminalView.getContext()); + this.terminalView = terminalView; + mCursorController = cursorController; + mInitialOrientation = initialOrientation; + + mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material); + mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material); + + setOrientation(mInitialOrientation); + } + + private void initHandle() { + mHandle = new PopupWindow(terminalView.getContext(), null, + android.R.attr.textSelectHandleWindowStyle); + mHandle.setSplitTouchEnabled(true); + mHandle.setClippingEnabled(false); + mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL); + mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setBackgroundDrawable(null); + mHandle.setAnimationStyle(0); + mHandle.setEnterTransition(null); + mHandle.setExitTransition(null); + mHandle.setContentView(this); + } + + public void setOrientation(int orientation) { + mOrientation = orientation; + int handleWidth = 0; + switch (orientation) { + case LEFT: { + mHandleDrawable = mHandleLeftDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = (handleWidth * 3) / (float) 4; + break; + } + + case RIGHT: { + mHandleDrawable = mHandleRightDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = handleWidth / (float) 4; + break; + } + } + + mHandleHeight = mHandleDrawable.getIntrinsicHeight(); + + mHandleWidth = handleWidth; + mTouchOffsetY = -mHandleHeight * 0.3f; + mHotspotY = 0; + invalidate(); + } + + public void show() { + if (!isPositionVisible()) { + hide(); + return; + } + + // We remove handle from its parent first otherwise the following exception may be thrown + // java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first. + removeFromParent(); + + initHandle(); // init the handle + invalidate(); // invalidate to make sure onDraw is called + + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + coords[0] += mPointX; + coords[1] += mPointY; + + if (mHandle != null) + mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]); + } + + public void hide() { + mIsDragging = false; + + if (mHandle != null) { + mHandle.dismiss(); + + // We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call + removeFromParent(); + mHandle = null; // garbage collect the handle + } + invalidate(); + } + + public void removeFromParent() { + if (!isParentNull()) { + ((ViewGroup)this.getParent()).removeView(this); + } + } + + public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) { + int x = terminalView.getPointX(cx); + int y = terminalView.getPointY(cy + 1); + moveTo(x, y, forceOrientationCheck); + } + + private void moveTo(int x, int y, boolean forceOrientationCheck) { + float oldHotspotX = mHotspotX; + checkChangedOrientation(x, forceOrientationCheck); + mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX)); + mPointY = y; + + if (isPositionVisible()) { + int[] coords = null; + + if (isShowing()) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + int x1 = coords[0] + mPointX; + int y1 = coords[1] + mPointY; + if (mHandle != null) + mHandle.update(x1, y1, getWidth(), getHeight()); + } else { + show(); + } + + if (mIsDragging) { + if (coords == null) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + } + if (coords[0] != mLastParentX || coords[1] != mLastParentY) { + mTouchToWindowOffsetX += coords[0] - mLastParentX; + mTouchToWindowOffsetY += coords[1] - mLastParentY; + mLastParentX = coords[0]; + mLastParentY = coords[1]; + } + } + } else { + hide(); + } + } + + public void changeOrientation(int orientation) { + if (mOrientation != orientation) { + setOrientation(orientation); + } + } + + private void checkChangedOrientation(int posX, boolean force) { + if (!mIsDragging && !force) { + return; + } + long millis = SystemClock.currentThreadTimeMillis(); + if (millis - mLastTime < 50 && !force) { + return; + } + mLastTime = millis; + + final TerminalView hostView = terminalView; + final int left = hostView.getLeft(); + final int right = hostView.getWidth(); + final int top = hostView.getTop(); + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return; + } + + if (posX - mHandleWidth < clip.left) { + changeOrientation(RIGHT); + } else if (posX + mHandleWidth > clip.right) { + changeOrientation(LEFT); + } else { + changeOrientation(mInitialOrientation); + } + } + + private boolean isPositionVisible() { + // Always show a dragging handle. + if (mIsDragging) { + return true; + } + + final TerminalView hostView = terminalView; + final int left = 0; + final int right = hostView.getWidth(); + final int top = 0; + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return false; + } + + final int[] coords = mTempCoords; + hostView.getLocationInWindow(coords); + final int posX = coords[0] + mPointX + (int) mHotspotX; + final int posY = coords[1] + mPointY + (int) mHotspotY; + + return posX >= clip.left && posX <= clip.right && + posY >= clip.top && posY <= clip.bottom; + } + + @Override + public void onDraw(Canvas c) { + final int width = mHandleDrawable.getIntrinsicWidth(); + int height = mHandleDrawable.getIntrinsicHeight(); + mHandleDrawable.setBounds(0, 0, width, height); + mHandleDrawable.draw(c); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + terminalView.updateFloatingToolbarVisibility(event); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + mTouchToWindowOffsetX = rawX - mPointX; + mTouchToWindowOffsetY = rawY - mPointY; + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + mLastParentX = coords[0]; + mLastParentY = coords[1]; + mIsDragging = true; + break; + } + + case MotionEvent.ACTION_MOVE: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + + final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX; + final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY; + + mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY)); + break; + } + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsDragging = false; + } + return true; + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(), + mHandleDrawable.getIntrinsicHeight()); + } + + public int getHandleHeight() { + return mHandleHeight; + } + + public int getHandleWidth() { + return mHandleWidth; + } + + public boolean isShowing() { + if (mHandle != null) + return mHandle.isShowing(); + else + return false; + } + + public boolean isParentNull() { + return this.getParent() == null; + } + + public boolean isDragging() { + return mIsDragging; + } + +} diff --git a/apps/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml b/apps/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml new file mode 100644 index 00000000..576ff4a9 --- /dev/null +++ b/apps/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml b/apps/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml new file mode 100644 index 00000000..d049d3a8 --- /dev/null +++ b/apps/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/android/terminal-view/src/main/res/values/strings.xml b/apps/android/terminal-view/src/main/res/values/strings.xml new file mode 100644 index 00000000..cd03c618 --- /dev/null +++ b/apps/android/terminal-view/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Paste + Copy + More… + From 89b1df38247659534a4c7a54f36cec7244c3f612 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:43:17 +0200 Subject: [PATCH 38/50] feat(android): a terminal session the remote shell drives Replace the vendored TerminalSession's local pseudoterminal process with a session a remote shell drives, and drop the JNI class that spawned it. TerminalSession keeps its name and package, so TerminalView needs no change. Its constructor is now (transcriptRows, TerminalSessionClient, TerminalSessionCallbacks) and it starts nothing: - feed(bytes) appends bytes from the bridge to the emulator on the main thread, then asks the view's client to redraw; - the write(...) paths the view uses for keys, IME text and paste call TerminalSessionCallbacks.onInput instead of writing to a file descriptor; - updateSize(...) resizes the emulator and reports the new size through TerminalSessionCallbacks.onResize; - isAlternateBufferActive() exposes the alternate-screen flag, so a host can batch input differently there. The pid, exit-status, finishIfRunning, cwd and process-cleanup machinery is gone. The TerminalOutput callbacks now null-check the client, because the emulator calls onColorsChanged from its own constructor's reset() and a session built without one would otherwise throw. A JVM test in terminal-emulator covers the three directions: bytes fed in reach the screen and move the cursor, a resize reaches onResize, written input reaches onInput, and the alternate-screen escape sequence toggles the flag. Co-Authored-By: Claude Code --- .../main/java/com/termux/terminal/JNI.java | 41 --- .../com/termux/terminal/TerminalSession.java | 303 +++++------------- .../terminal/TerminalSessionCallbacks.java | 23 ++ .../termux/terminal/TerminalSessionTest.java | 91 ++++++ apps/android/terminal-view/README.md | 33 +- 5 files changed, 220 insertions(+), 271 deletions(-) delete mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java create mode 100644 apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionCallbacks.java create mode 100644 apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java deleted file mode 100644 index e2fc477b..00000000 --- a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/JNI.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.termux.terminal; - -/** - * Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c. - */ -final class JNI { - - static { - System.loadLibrary("termux"); - } - - /** - * Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the - * subprocess. - *

- * Callers are responsible for calling {@link #close(int)} on the returned file descriptor. - * - * @param cmd The command to execute - * @param cwd The current working directory for the executed command - * @param args An array of arguments to the command - * @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process - * @param processId A one-element array to which the process ID of the started process will be written. - * @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the - * slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr. - */ - public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns, int cellWidth, int cellHeight); - - /** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */ - public static native void setPtyWindowSize(int fd, int rows, int cols, int cellWidth, int cellHeight); - - /** - * Causes the calling thread to wait for the process associated with the receiver to finish executing. - * - * @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated. - */ - public static native int waitFor(int processId); - - /** Close a file descriptor through the close(2) system call. */ - public static native void close(int fileDescriptor); - -} diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java index 324e0bc0..bd9a06de 100644 --- a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -1,91 +1,61 @@ package com.termux.terminal; -import android.annotation.SuppressLint; import android.os.Handler; -import android.os.Message; -import android.system.ErrnoException; -import android.system.Os; -import android.system.OsConstants; - -import java.io.File; -import java.io.FileDescriptor; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; +import android.os.Looper; + +import java.util.Arrays; import java.util.UUID; /** - * A terminal session, consisting of a process coupled to a terminal interface. - *

- * The subprocess will be executed by the constructor, and when the size is made known by a call to - * {@link #updateSize(int, int, int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O. - * All terminal emulation and callback methods will be performed on the main thread. + * A terminal session: a {@link TerminalEmulator} fed by a shell that runs + * elsewhere, plus the callbacks that carry the user's input and the terminal's + * size back to that shell. *

- * The child process may be exited forcefully by using the {@link #finishIfRunning()} method. + * Upstream spawned a local pseudoterminal process through JNI and pumped bytes + * to and from it. This session has no process: the shell lives on a remote + * bridge, so {@link #feed(byte[])} appends the bytes it sends, the + * {@link TerminalOutput#write(byte[], int, int)} paths hand what the user types + * to {@link TerminalSessionCallbacks#onInput(byte[])}, and + * {@link #updateSize(int, int, int, int)} reports the size through + * {@link TerminalSessionCallbacks#onResize(int, int)}. All terminal emulation + * and callback methods are performed on the main thread. *

* NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks! */ public final class TerminalSession extends TerminalOutput { - private static final int MSG_NEW_INPUT = 1; - private static final int MSG_PROCESS_EXITED = 4; - public final String mHandle = UUID.randomUUID().toString(); TerminalEmulator mEmulator; - /** - * A queue written to from a separate thread when the process outputs, and read by main thread to process by - * terminal emulator. - */ - final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096); - /** - * A queue written to from the main thread due to user interaction, and read by another thread which forwards by - * writing to the {@link #mTerminalFileDescriptor}. - */ - final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096); - /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */ - private final byte[] mUtf8InputBuffer = new byte[5]; - - /** Callback which gets notified when a session finishes or changes title. */ + /** Callback which gets notified when a session changes title or state. */ TerminalSessionClient mClient; - /** The pid of the shell process. 0 if not started and -1 if finished running. */ - int mShellPid; + /** Callback which forwards the user's input and size changes to the remote shell. */ + private final TerminalSessionCallbacks mCallbacks; - /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */ - int mShellExitStatus; - - /** - * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling - * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int, int, int)}. - */ - private int mTerminalFileDescriptor; + /** Buffer to write translate code points into utf8 before writing to the remote shell. */ + private final byte[] mUtf8InputBuffer = new byte[5]; /** Set by the application for user identification of session, not by terminal. */ public String mSessionName; - final Handler mMainThreadHandler = new MainThreadHandler(); - - private final String mShellPath; - private final String mCwd; - private final String[] mArgs; - private final String[] mEnv; private final Integer mTranscriptRows; + /** Created on first use, only when a feed arrives off the main thread. */ + private Handler mMainThreadHandler; - private static final String LOG_TAG = "TerminalSession"; - - public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) { - this.mShellPath = shellPath; - this.mCwd = cwd; - this.mArgs = args; - this.mEnv = env; + /** + * @param transcriptRows how many lines of scrollback the emulator keeps. + * @param client the {@link TerminalSessionClient} implementation the emulator + * reports titles, bells and clipboard events to. + * @param callbacks the {@link TerminalSessionCallbacks} implementation that + * carries input and resizes to the remote shell. + */ + public TerminalSession(Integer transcriptRows, TerminalSessionClient client, TerminalSessionCallbacks callbacks) { this.mTranscriptRows = transcriptRows; this.mClient = client; + this.mCallbacks = callbacks; } /** @@ -99,14 +69,29 @@ public void updateTerminalSessionClient(TerminalSessionClient client) { mEmulator.updateTerminalSessionClient(client); } - /** Inform the attached pty of the new size and reflow or initialize the emulator. */ + /** + * Feed bytes received from the remote shell into the emulator. Safe to call + * from any thread: the bytes are appended on the main thread, then the + * client is told to redraw. + */ + public void feed(byte[] data) { + if (data == null || data.length == 0) return; + final byte[] bytes = Arrays.copyOf(data, data.length); + runOnMainThread(() -> { + if (mEmulator == null) return; + mEmulator.append(bytes, bytes.length); + notifyScreenUpdate(); + }); + } + + /** Tell the remote shell the new size and reflow or initialize the emulator. */ public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { if (mEmulator == null) { initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); } else { - JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels); mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); } + if (mCallbacks != null) mCallbacks.onResize(columns, rows); } /** The terminal title as set through escape sequences or null if none set. */ @@ -116,66 +101,26 @@ public String getTitle() { /** * Set the terminal emulator's window size and start terminal emulation. + * Upstream started a local subprocess here; the shell this session is + * attached to is remote, so there is nothing to start. * * @param columns The number of columns in the terminal window. * @param rows The number of rows in the terminal window. */ public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); + } - int[] processId = new int[1]; - mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels); - mShellPid = processId[0]; - - final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient); - - new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { - @Override - public void run() { - try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) { - final byte[] buffer = new byte[4096]; - while (true) { - int read = termIn.read(buffer); - if (read == -1) return; - if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return; - mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); - } - } catch (Exception e) { - // Ignore, just shutting down. - } - } - }.start(); - - new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") { - @Override - public void run() { - final byte[] buffer = new byte[4096]; - try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) { - while (true) { - int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true); - if (bytesToWrite == -1) return; - termOut.write(buffer, 0, bytesToWrite); - } - } catch (IOException e) { - // Ignore. - } - } - }.start(); - - new Thread("TermSessionWaiter[pid=" + mShellPid + "]") { - @Override - public void run() { - int processExitCode = JNI.waitFor(mShellPid); - mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode)); - } - }.start(); - + /** Whether the emulator is currently showing its alternate screen buffer. */ + public boolean isAlternateBufferActive() { + return mEmulator != null && mEmulator.isAlternateBufferActive(); } - /** Write data to the shell process. */ + /** Write data the user typed to the remote shell. */ @Override public void write(byte[] data, int offset, int count) { - if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count); + if (count <= 0) return; + if (mCallbacks != null) mCallbacks.onInput(Arrays.copyOfRange(data, offset, offset + count)); } /** Write the Unicode code point to the terminal encoded in UTF-8. */ @@ -221,152 +166,52 @@ public TerminalEmulator getEmulator() { /** Notify the {@link #mClient} that the screen has changed. */ protected void notifyScreenUpdate() { - mClient.onTextChanged(this); + if (mClient != null) mClient.onTextChanged(this); } /** Reset state for terminal emulator state. */ public void reset() { - mEmulator.reset(); + if (mEmulator != null) mEmulator.reset(); notifyScreenUpdate(); } - /** Finish this terminal session by sending SIGKILL to the shell. */ - public void finishIfRunning() { - if (isRunning()) { - try { - Os.kill(mShellPid, OsConstants.SIGKILL); - } catch (ErrnoException e) { - Logger.logWarn(mClient, LOG_TAG, "Failed sending SIGKILL: " + e.getMessage()); - } - } - } - - /** Cleanup resources when the process exits. */ - void cleanupResources(int exitStatus) { - synchronized (this) { - mShellPid = -1; - mShellExitStatus = exitStatus; - } - - // Stop the reader and writer threads, and close the I/O streams - mTerminalToProcessIOQueue.close(); - mProcessToTerminalIOQueue.close(); - JNI.close(mTerminalFileDescriptor); - } - @Override public void titleChanged(String oldTitle, String newTitle) { - mClient.onTitleChanged(this); - } - - public synchronized boolean isRunning() { - return mShellPid != -1; - } - - /** Only valid if not {@link #isRunning()}. */ - public synchronized int getExitStatus() { - return mShellExitStatus; + if (mClient != null) mClient.onTitleChanged(this); } @Override public void onCopyTextToClipboard(String text) { - mClient.onCopyTextToClipboard(this, text); + if (mClient != null) mClient.onCopyTextToClipboard(this, text); } @Override public void onPasteTextFromClipboard() { - mClient.onPasteTextFromClipboard(this); + if (mClient != null) mClient.onPasteTextFromClipboard(this); } @Override public void onBell() { - mClient.onBell(this); + if (mClient != null) mClient.onBell(this); } @Override public void onColorsChanged() { - mClient.onColorsChanged(this); + if (mClient != null) mClient.onColorsChanged(this); } - public int getPid() { - return mShellPid; - } - - /** Returns the shell's working directory or null if it was unavailable. */ - public String getCwd() { - if (mShellPid < 1) { - return null; - } - try { - final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid); - String outputPath = new File(cwdSymlink).getCanonicalPath(); - String outputPathWithTrailingSlash = outputPath; - if (!outputPath.endsWith("/")) { - outputPathWithTrailingSlash += '/'; - } - if (!cwdSymlink.equals(outputPathWithTrailingSlash)) { - return outputPath; - } - } catch (IOException | SecurityException e) { - Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e); - } - return null; - } - - private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) { - FileDescriptor result = new FileDescriptor(); - try { - Field descriptorField; - try { - descriptorField = FileDescriptor.class.getDeclaredField("descriptor"); - } catch (NoSuchFieldException e) { - // For desktop java: - descriptorField = FileDescriptor.class.getDeclaredField("fd"); - } - descriptorField.setAccessible(true); - descriptorField.set(result, fileDescriptor); - } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { - Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e); - System.exit(1); - } - return result; - } - - @SuppressLint("HandlerLeak") - class MainThreadHandler extends Handler { - - final byte[] mReceiveBuffer = new byte[4 * 1024]; - - @Override - public void handleMessage(Message msg) { - int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false); - if (bytesRead > 0) { - mEmulator.append(mReceiveBuffer, bytesRead); - notifyScreenUpdate(); - } - - if (msg.what == MSG_PROCESS_EXITED) { - int exitCode = (Integer) msg.obj; - cleanupResources(exitCode); - - String exitDescription = "\r\n[Process completed"; - if (exitCode > 0) { - // Non-zero process exit. - exitDescription += " (code " + exitCode + ")"; - } else if (exitCode < 0) { - // Negated signal. - exitDescription += " (signal " + (-exitCode) + ")"; - } - exitDescription += " - press Enter]"; - - byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8); - mEmulator.append(bytesToWrite, bytesToWrite.length); - notifyScreenUpdate(); - - mClient.onSessionFinished(TerminalSession.this); - } + /** + * Run the action on the main thread. Where there is no main looper (a plain + * JVM test) or the caller is already on it, the action runs inline. + */ + private void runOnMainThread(Runnable action) { + Looper mainLooper = Looper.getMainLooper(); + if (mainLooper == null || Looper.myLooper() == mainLooper) { + action.run(); + return; } - + if (mMainThreadHandler == null) mMainThreadHandler = new Handler(mainLooper); + mMainThreadHandler.post(action); } } diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionCallbacks.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionCallbacks.java new file mode 100644 index 00000000..494266a1 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionCallbacks.java @@ -0,0 +1,23 @@ +package com.termux.terminal; + +/** + * The callbacks a {@link TerminalSession} uses to reach the shell it is + * attached to. Upstream wired these to a local pseudoterminal process through + * JNI; here the shell runs on a remote bridge, so these are how it receives + * the user's input and learns the terminal's size. + */ +public interface TerminalSessionCallbacks { + + /** + * Called with the bytes the user produced: key events, IME text and + * pastes, already encoded as UTF-8 where they came from a code point. + */ + void onInput(byte[] data); + + /** + * Called when the terminal's size changes, so the remote shell can be told + * its new dimensions (and, on the first call, before any output arrives). + */ + void onResize(int columns, int rows); + +} diff --git a/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java b/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java new file mode 100644 index 00000000..1f53b76e --- /dev/null +++ b/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java @@ -0,0 +1,91 @@ +package com.termux.terminal; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * The remote session is the only place the emulator meets the shell, so these + * pin the three directions it carries: bytes in from the shell, the user's + * input out, and the terminal size out. A plain JVM test — the emulator is + * pure Java, and with no main looper the session appends inline instead of + * posting to a handler. + */ +public class TerminalSessionTest { + + private static final int COLUMNS = 20; + private static final int ROWS = 5; + + private static final class RecordingCallbacks implements TerminalSessionCallbacks { + final List inputs = new ArrayList<>(); + int lastColumns = -1; + int lastRows = -1; + + @Override + public void onInput(byte[] data) { + inputs.add(data); + } + + @Override + public void onResize(int columns, int rows) { + lastColumns = columns; + lastRows = rows; + } + } + + private static TerminalSession sizedSession(RecordingCallbacks callbacks) { + TerminalSession session = new TerminalSession(ROWS * 2, null, callbacks); + session.updateSize(COLUMNS, ROWS, 13, 15); + return session; + } + + @Test + public void feedAppendsToTheEmulatorAndMovesTheCursor() { + TerminalSession session = sizedSession(new RecordingCallbacks()); + + session.feed("hi\r\n\u001b[1mbold".getBytes(StandardCharsets.UTF_8)); + + TerminalEmulator emulator = session.getEmulator(); + assertEquals("hi", emulator.getScreen().getSelectedText(0, 0, COLUMNS, 0)); + assertEquals(1, emulator.getCursorRow()); + assertEquals(4, emulator.getCursorCol()); + } + + @Test + public void resizeReachesTheCallbacks() { + RecordingCallbacks callbacks = new RecordingCallbacks(); + + sizedSession(callbacks); + + assertEquals(COLUMNS, callbacks.lastColumns); + assertEquals(ROWS, callbacks.lastRows); + } + + @Test + public void writtenInputReachesTheCallbacks() { + RecordingCallbacks callbacks = new RecordingCallbacks(); + TerminalSession session = sizedSession(callbacks); + + session.write("ls\n"); + + assertEquals(1, callbacks.inputs.size()); + assertArrayEquals("ls\n".getBytes(StandardCharsets.UTF_8), callbacks.inputs.get(0)); + } + + @Test + public void alternateScreenEscapeSequenceTogglesTheFlag() { + TerminalSession session = sizedSession(new RecordingCallbacks()); + assertFalse(session.isAlternateBufferActive()); + + session.feed("\u001b[?1049h".getBytes(StandardCharsets.UTF_8)); + + assertTrue(session.isAlternateBufferActive()); + } +} diff --git a/apps/android/terminal-view/README.md b/apps/android/terminal-view/README.md index 68f67ce1..82268fef 100644 --- a/apps/android/terminal-view/README.md +++ b/apps/android/terminal-view/README.md @@ -19,4 +19,35 @@ below; never merge upstream mechanically. ## Local changes -(To be listed.) +- **No native code.** Upstream built the emulator with the NDK + (`src/main/jni/`, `Android.mk`, an `externalNativeBuild` block) around a + local pseudoterminal. None of it is copied, and the `JNI` class is deleted. +- **`TerminalSession` is remote, not process-backed.** The class keeps its + name and package so `TerminalView` needs no change, but its constructor is + `(Integer transcriptRows, TerminalSessionClient client, + TerminalSessionCallbacks callbacks)` and it spawns nothing. `feed(byte[])` + appends bytes from the bridge to the emulator on the main thread and asks the + view's client to redraw; `updateSize(...)` resizes the emulator and reports + the new size; `isAlternateBufferActive()` exposes the alternate-screen flag + so a host can batch input differently there. The `write(...)` paths the view + uses (keys, IME text, paste, code points) now call + `TerminalSessionCallbacks.onInput` instead of writing to a file descriptor. + The pid, exit-status, `finishIfRunning`, cwd and process-cleanup machinery is + gone. +- **`TerminalSessionCallbacks` added** (`onInput`, `onResize`) — the small + interface the bridge-facing host implements; upstream had no equivalent + because its input and resize went straight to the pty. +- **`TerminalSession`'s `TerminalOutput` callbacks null-check `mClient`.** The + emulator calls `onColorsChanged` from its own constructor's `reset()`, so a + session built without a client (as the unit test builds one) would otherwise + throw. +- **Manifests emptied.** The upstream `package` attribute moved to the Gradle + `namespace` (`com.termux.terminal`, `com.termux.view`), which AGP 9 requires. +- **Gradle files written fresh** in `apps/android/app/build.gradle.kts`'s + style (the catalog's AGP, compileSdk and minSdk, Java 17), and the app + depends on both modules. +- **`terminal-emulator/src/test/.../TerminalSessionTest.java` added** — a JVM + test of the three directions above. + +The upstream `proguard-rules.pro` files are not copied: they were empty +templates and neither module minifies. From ce9ce9e25c86a961bad3cf97f6d0e9e71f951e60 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:51:09 +0200 Subject: [PATCH 39/50] fix(android): hold terminal output that arrives before the view is sized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session is attached before its view is laid out, so the shell's replay of its screen so far arrives while there is still no emulator to append it to — and feed(...) dropped it. Buffer that output inside TerminalSession, in the order it arrives and bounded to 512 KiB (the oldest bytes past the cap are dropped, since the newest output is what the screen should end up showing), and append it when the first updateSize(...) creates the emulator, before any later feed. A JVM test feeds before the first updateSize and asserts the screen shows the fed text. Co-Authored-By: Claude Code --- .../com/termux/terminal/TerminalSession.java | 48 ++++++++++++++++++- .../termux/terminal/TerminalSessionTest.java | 15 ++++++ apps/android/terminal-view/README.md | 8 +++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java index bd9a06de..1e8d8f76 100644 --- a/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -3,6 +3,7 @@ import android.os.Handler; import android.os.Looper; +import java.io.ByteArrayOutputStream; import java.util.Arrays; import java.util.UUID; @@ -45,6 +46,19 @@ public final class TerminalSession extends TerminalOutput { /** Created on first use, only when a feed arrives off the main thread. */ private Handler mMainThreadHandler; + /** + * Output that arrives before the emulator exists (the view sizes the + * session only once it is laid out, but the shell's replay of its screen so + * far arrives as soon as the session is attached). Held in order and + * appended when the emulator is created; bounded so a shell that floods the + * session before the view is ready cannot grow it without limit — the + * oldest bytes past {@link #MAX_PENDING_OUTPUT_BYTES} are dropped, since + * the newest output is what the screen should end up showing. + */ + private final ByteArrayOutputStream mPendingOutput = new ByteArrayOutputStream(); + + private static final int MAX_PENDING_OUTPUT_BYTES = 512 * 1024; + /** * @param transcriptRows how many lines of scrollback the emulator keeps. * @param client the {@link TerminalSessionClient} implementation the emulator @@ -72,13 +86,18 @@ public void updateTerminalSessionClient(TerminalSessionClient client) { /** * Feed bytes received from the remote shell into the emulator. Safe to call * from any thread: the bytes are appended on the main thread, then the - * client is told to redraw. + * client is told to redraw. Bytes that arrive before the emulator exists + * are buffered and replayed in order when it is created. */ public void feed(byte[] data) { if (data == null || data.length == 0) return; final byte[] bytes = Arrays.copyOf(data, data.length); runOnMainThread(() -> { - if (mEmulator == null) return; + if (mEmulator == null) { + mPendingOutput.write(bytes, 0, bytes.length); + trimPendingOutput(); + return; + } mEmulator.append(bytes, bytes.length); notifyScreenUpdate(); }); @@ -109,6 +128,7 @@ public String getTitle() { */ public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); + flushPendingOutput(); } /** Whether the emulator is currently showing its alternate screen buffer. */ @@ -200,6 +220,30 @@ public void onColorsChanged() { if (mClient != null) mClient.onColorsChanged(this); } + /** + * Drop the oldest buffered output past {@link #MAX_PENDING_OUTPUT_BYTES}, + * so a flood before the view is ready cannot grow the buffer without limit. + */ + private void trimPendingOutput() { + int excess = mPendingOutput.size() - MAX_PENDING_OUTPUT_BYTES; + if (excess <= 0) return; + byte[] all = mPendingOutput.toByteArray(); + mPendingOutput.reset(); + mPendingOutput.write(all, excess, all.length - excess); + } + + /** + * Append output buffered before the emulator existed, in the order it + * arrived. Called once the emulator is created, before any later feed. + */ + private void flushPendingOutput() { + if (mPendingOutput.size() == 0) return; + byte[] pending = mPendingOutput.toByteArray(); + mPendingOutput.reset(); + mEmulator.append(pending, pending.length); + notifyScreenUpdate(); + } + /** * Run the action on the main thread. Where there is no main looper (a plain * JVM test) or the caller is already on it, the action runs inline. diff --git a/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java b/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java index 1f53b76e..9fa06e7c 100644 --- a/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java +++ b/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java @@ -3,6 +3,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -58,6 +59,20 @@ public void feedAppendsToTheEmulatorAndMovesTheCursor() { assertEquals(4, emulator.getCursorCol()); } + @Test + public void feedBeforeTheFirstSizeIsReplayedOnceTheEmulatorExists() { + TerminalSession session = new TerminalSession(ROWS * 2, null, new RecordingCallbacks()); + + // The shell's replay of its screen arrives right after attach, before + // the view has been laid out and called updateSize. + session.feed("hi".getBytes(StandardCharsets.UTF_8)); + assertNull(session.getEmulator()); + + session.updateSize(COLUMNS, ROWS, 13, 15); + + assertEquals("hi", session.getEmulator().getScreen().getSelectedText(0, 0, COLUMNS, 0)); + } + @Test public void resizeReachesTheCallbacks() { RecordingCallbacks callbacks = new RecordingCallbacks(); diff --git a/apps/android/terminal-view/README.md b/apps/android/terminal-view/README.md index 82268fef..153c6ba3 100644 --- a/apps/android/terminal-view/README.md +++ b/apps/android/terminal-view/README.md @@ -34,6 +34,12 @@ below; never merge upstream mechanically. `TerminalSessionCallbacks.onInput` instead of writing to a file descriptor. The pid, exit-status, `finishIfRunning`, cwd and process-cleanup machinery is gone. +- **`TerminalSession` buffers output that arrives before the emulator exists.** + A session is attached before the view is laid out, so the shell's replay of + its screen so far arrives while there is nothing to append it to. `feed(...)` + holds those bytes in order (bounded to 512 KiB, dropping the oldest past it) + and appends them when the first `updateSize(...)` creates the emulator, + before any later feed. - **`TerminalSessionCallbacks` added** (`onInput`, `onResize`) — the small interface the bridge-facing host implements; upstream had no equivalent because its input and resize went straight to the pty. @@ -47,7 +53,7 @@ below; never merge upstream mechanically. style (the catalog's AGP, compileSdk and minSdk, Java 17), and the app depends on both modules. - **`terminal-emulator/src/test/.../TerminalSessionTest.java` added** — a JVM - test of the three directions above. + test of the three directions above plus the pre-size replay buffer. The upstream `proguard-rules.pro` files are not copied: they were empty templates and neither module minifies. From dde1e20cb1067787c511b6778777486a61bd0bbe Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:03:47 +0200 Subject: [PATCH 40/50] feat(protocol): mark a terminal replay answer so a clamped one is taken `terminal-output` gains an optional `replay` flag, set only on the bridge's answer to `terminal-replay`. A replay reads the bridge's 256 KiB ring, which starts past the requested `from` once a terminal has written more than that; the flag is what tells the client the answer's `offset` has advanced and it should adopt it rather than treat it as another gap. The paced terminal lane carries the flag through its merge, so a replay answer keeps its marking when it is coalesced before it is sent. Co-Authored-By: Claude Code --- crates/bridge-core/src/engine/terminal.rs | 5 ++- crates/bridge-runtime/src/relay.rs | 38 ++++++++++++++++++---- crates/bridge-runtime/src/runtime.rs | 5 +++ crates/client-core/src/stores/terminals.rs | 7 ++-- crates/client-runtime/src/runtime/tests.rs | 1 + crates/protocol/fixtures/corpus.json | 1 + crates/protocol/src/events.rs | 5 +++ docs/PROTOCOL.md | 13 +++++--- docs/TERMINAL.md | 7 ++-- 9 files changed, 65 insertions(+), 17 deletions(-) diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs index 106fd55a..b4b4d7e8 100644 --- a/crates/bridge-core/src/engine/terminal.rs +++ b/crates/bridge-core/src/engine/terminal.rs @@ -844,7 +844,10 @@ impl Engine { /// asked for a replay reads from. pub(super) fn on_terminal_output(&mut self, term: String, offset: u64, data: String) { let Some(identity) = self.terminals.open.get(&term).map(|t| t.identity.clone()) else { return }; - self.out.publish(vec![identity], BridgeToPhone::TerminalOutput(TerminalOutputMsg { term, offset, data })); + self.out.publish( + vec![identity], + BridgeToPhone::TerminalOutput(TerminalOutputMsg { term, offset, data, replay: None }), + ); } /// The shell is gone. Whoever ended it — the shell itself, its pipe, the diff --git a/crates/bridge-runtime/src/relay.rs b/crates/bridge-runtime/src/relay.rs index 02b0b4b0..139ace88 100644 --- a/crates/bridge-runtime/src/relay.rs +++ b/crates/bridge-runtime/src/relay.rs @@ -401,15 +401,24 @@ struct PendingTerminal { offset: u64, /// The merged bytes, contiguous from `offset`. data: Vec, + /// Whether this range is the answer to a `terminal-replay`, and so may + /// start past what the phone asked for; kept through the merge so the + /// paced frame still says so. + replay: Option, /// When this terminal's window is up. due: tokio::time::Instant, } impl PendingTerminal { /// Whether `msg` continues this range: the same terminal, the same - /// clients, and the byte straight after what is held. + /// clients, the same replay marking, and the byte straight after what is + /// held. A replay answer never folds into live output, or the other way + /// round: the marking describes where the range starts. fn continues(&self, to: &[Addressee], msg: &TerminalOutputMsg) -> bool { - self.term == msg.term && self.to.as_slice() == to && self.offset + self.data.len() as u64 == msg.offset + self.term == msg.term + && self.to.as_slice() == to + && self.replay == msg.replay + && self.offset + self.data.len() as u64 == msg.offset } } @@ -431,7 +440,14 @@ impl TerminalPace { } return; } - self.pending.push(PendingTerminal { to, term: msg.term.clone(), offset: msg.offset, data: bytes, due: now + TERMINAL_FLUSH }); + self.pending.push(PendingTerminal { + to, + term: msg.term.clone(), + offset: msg.offset, + data: bytes, + replay: msg.replay, + due: now + TERMINAL_FLUSH, + }); } /// Take the terminal-output jobs queued right behind the front into the @@ -493,7 +509,12 @@ async fn flush_terminals( ) { let ready = if all { terminal.take_all() } else { terminal.take_due(tokio::time::Instant::now()) }; for p in ready { - let message = BridgeToPhone::TerminalOutput(TerminalOutputMsg { term: p.term, offset: p.offset, data: STANDARD.encode(&p.data) }); + let message = BridgeToPhone::TerminalOutput(TerminalOutputMsg { + term: p.term, + offset: p.offset, + data: STANDARD.encode(&p.data), + replay: p.replay, + }); let lane = lane_of(&message); match &lane { Some(lane) => flight.room_in(lane).await, @@ -882,7 +903,12 @@ mod tests { // Terminal output rides the live kind and is never stored: the bytes // a shell printed may hold a secret, and a stored frame would outlive // the terminal. - let out = BridgeToPhone::TerminalOutput(TerminalOutputMsg { term: "t".into(), offset: 0, data: "aGk=".into() }); + let out = BridgeToPhone::TerminalOutput(TerminalOutputMsg { + term: "t".into(), + offset: 0, + data: "aGk=".into(), + replay: None, + }); let ev = f.events(&out, &to(&phone.pubkey_hex), 1000).unwrap().remove(0); assert_eq!((ev.kind, tag(&ev, "expiration")), (LIVE_KIND, None)); // The small, state-like terminal messages are stored responses: a @@ -1056,7 +1082,7 @@ mod tests { } fn output_msg(term: &str, offset: u64, data: &[u8]) -> TerminalOutputMsg { - TerminalOutputMsg { term: term.into(), offset, data: STANDARD.encode(data) } + TerminalOutputMsg { term: term.into(), offset, data: STANDARD.encode(data), replay: None } } fn term_job(to: &[Addressee], msg: TerminalOutputMsg) -> Job { diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index eee074f4..ada72916 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -447,10 +447,15 @@ impl Runtime { // ring it keeps. An oversize replay is split into `chunk` // fragments by the transport, like any other message. if let Some((offset, data)) = self.terminals.replay(&term, from) { + // `replay` marks this as the answer to `terminal-replay`: + // `offset` is the ring's start, which may be past the + // requested `from`, and the phone takes it from there + // instead of asking again. let message = BridgeToPhone::TerminalOutput(TerminalOutputMsg { term, offset, data: STANDARD.encode(data), + replay: Some(true), }); self.relays.publish(vec![client], message); } diff --git a/crates/client-core/src/stores/terminals.rs b/crates/client-core/src/stores/terminals.rs index 87ed9a80..8345e803 100644 --- a/crates/client-core/src/stores/terminals.rs +++ b/crates/client-core/src/stores/terminals.rs @@ -1048,7 +1048,7 @@ mod tests { // --- output ------------------------------------------------------------- fn out(offset: u64, text: &str) -> TerminalOutputMsg { - TerminalOutputMsg { term: T.into(), offset, data: STANDARD.encode(text.as_bytes()) } + TerminalOutputMsg { term: T.into(), offset, data: STANDARD.encode(text.as_bytes()), replay: None } } #[test] @@ -1095,7 +1095,10 @@ mod tests { other.term = "t9".into(); assert_eq!(s.output(M, &other), None); assert_eq!( - s.output(M, &TerminalOutputMsg { term: T.into(), offset: 0, data: "not base64!".into() }), + s.output( + M, + &TerminalOutputMsg { term: T.into(), offset: 0, data: "not base64!".into(), replay: None }, + ), None ); assert_eq!(s.attached().unwrap().expected, 0); diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index 759aa167..c310ecbc 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -3320,6 +3320,7 @@ async fn terminal_output_reaches_the_host_on_its_own_event() { term: "t1".into(), offset: 0, data: "aGk=".into(), + replay: None, }), LIVE_KIND, None, diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 8c59f15b..4d00c697 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -209,6 +209,7 @@ { "v": 12, "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false }, { "v": 12, "type": "terminal-opened", "openId": "o1", "term": "t1", "shell": "/bin/bash" }, { "v": 12, "type": "terminal-output", "term": "t1", "offset": 0, "data": "bHMgLWxhCg==" }, + { "v": 12, "type": "terminal-output", "term": "t1", "offset": 300000, "data": "cmVwbGF5", "replay": true }, { "v": 12, "type": "terminal-input-from", "term": "t1", "seq": 4 }, { "v": 12, "type": "terminal-exited", "term": "t1", "code": 0, "reason": "exit" }, { "v": 12, "type": "terminal-exited", "term": "t1", "reason": "idle" }, diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 5fd9c469..58c22df8 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -561,6 +561,11 @@ pub struct TerminalOutputMsg { #[specta(type = specta_typescript::Number)] pub offset: u64, pub data: String, + /// Set (true) only on the bridge's answer to `terminal-replay`. Its + /// `offset` may be past what the client asked for when the bridge no + /// longer holds those bytes, and the client takes it from there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replay: Option, } /// The input the bridge is still waiting for: `seq` is the first one that diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 08e02196..ab8b420e 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -311,12 +311,15 @@ opens a real shell on the bridge's machine: says resend from `seq`: the first one that never arrived, or — to a device that has just taken the pen — the seq its own count resumes at, which the phone adopts. -- `terminal-output {term, offset, data}` is what the shell wrote, `data` - base64 and `offset` the byte offset of its first byte in everything this - terminal has output. Also LIVE_KIND, and like `partial` it goes ONLY to the - present clients watching that terminal (`presence.watchingTerminal`) — a +- `terminal-output {term, offset, data, replay?}` is what the shell wrote, + `data` base64 and `offset` the byte offset of its first byte in everything + this terminal has output. Also LIVE_KIND, and like `partial` it goes ONLY to + the present clients watching that terminal (`presence.watchingTerminal`) — a client that missed a run reattaches with `terminal-replay {term, from}`, - which resends the output from `from` and what follows, read-only. + which resends the output from `from` and what follows, read-only. `replay` + is set (true) only on the bridge's answer to that request: its `offset` may + be past `from` when the bridge no longer holds those bytes, and the client + takes it from there. - `terminal-resize {term, cols, rows}`, `terminal-takeover {term}` (become the writer) and `terminal-close {term}` (hang up). - `terminal-exited {term?, openId?, code?, reason, device?}` says a terminal diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 528e8b0f..061c20ab 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -164,8 +164,9 @@ When relays are allowed, a terminal falls back to them like any message. - Loss is tolerated: every output frame carries the terminal's byte offset, and a phone that sees a gap (or reconnects) asks for a replay from the last offset it drew. The bridge keeps the last 256 KiB of each terminal's - output for that; older than that, the phone resets the screen and redraws - from what is kept (the program's next repaint fixes the rest). + output for that; older than that, the answer starts at the ring's first + byte — marked `replay: true`, with an `offset` past the one asked for — and + the phone takes it from there (the program's next repaint fixes the rest). ## Messages @@ -177,7 +178,7 @@ All carry `v` like every message. `term` is a terminal id the bridge assigns. | `terminal-opened` | bridge → phone | `openId`, `term`, `shell` (what runs, e.g. `/bin/bash`) | | `terminal-input` | phone → bridge | `term`, `seq`, `data` (base64 of the bytes — a key press need not be valid UTF-8) | | `terminal-input-from` | bridge → phone | `term`, `seq` (resend from here; on a takeover, the seq this device's own count resumes at) | -| `terminal-output` | bridge → phone | `term`, `offset`, `data` (base64) | +| `terminal-output` | bridge → phone | `term`, `offset`, `data` (base64), `replay?` (true only on the answer to `terminal-replay`; `offset` may be past what was asked when the ring no longer holds those bytes) | | `terminal-resize` | phone → bridge | `term`, `cols`, `rows` (writer only) | | `terminal-replay` | phone → bridge | `term`, `from` (byte offset; attaches read-only) | | `terminal-takeover` | phone → bridge | `term` (become the writer) | From d5c5269e1620118422aa289f45c0e773c15e78e4 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:15 +0200 Subject: [PATCH 41/50] fix(client-core): an attach or a gap older than the ring no longer replays forever An attach asks for a replay from 0, and a gap asks from the last offset drawn. The bridge answers from its 256 KiB ring, whose start has moved past that offset once the terminal has written more than the ring holds. The answer's own offset therefore being ahead is not loss: when a replay is pending and the frame is marked as its answer, the store adopts that offset and draws the frame. Live frames ahead of a pending replay are still dropped, and the next frame at the expected offset still clears the wait when the bridge has nothing to replay. Co-Authored-By: Claude Code --- crates/client-core/src/stores/terminals.rs | 95 +++++++++++++++++++--- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/crates/client-core/src/stores/terminals.rs b/crates/client-core/src/stores/terminals.rs index 8345e803..aa114a26 100644 --- a/crates/client-core/src/stores/terminals.rs +++ b/crates/client-core/src/stores/terminals.rs @@ -112,8 +112,9 @@ pub struct Attached { pub rows: u16, /// The next output byte offset expected: frames are drawn from here. pub expected: u64, - /// A frame was lost and a replay asked for; frames are dropped until one - /// arrives at `expected`. + /// A replay was asked for (an attach asks from the start of the ring); + /// live frames are dropped until its answer arrives or a frame at + /// `expected` fills the gap. pub replay_pending: bool, pub refusal: Option, pub ended: Option, @@ -383,7 +384,8 @@ impl TerminalsState { } /// Attach to a terminal the heartbeat lists, read-only, from the start of - /// what the bridge still holds. Returns the replay command to send. + /// what the bridge still holds. Returns the replay command to send, and + /// waits for its answer before taking live frames. pub fn begin_attach(&mut self, machine: &str, term: &str) -> TerminalReplayMsg { let info = self.lists.get(machine).and_then(|m| m.get(term)).cloned(); let writer = info @@ -398,7 +400,7 @@ impl TerminalsState { cols: info.as_ref().map_or(80, |t| t.cols), rows: info.as_ref().map_or(24, |t| t.rows), expected: 0, - replay_pending: false, + replay_pending: true, refusal: None, ended: None, }); @@ -551,7 +553,7 @@ impl TerminalsState { /// `terminal-output` for the terminal on screen. `None` when it is for /// another terminal or the frame is dropped (an overlap already drawn, or - /// a frame ahead of a replay that was asked for). + /// a live frame ahead of a replay that was asked for). pub fn output(&mut self, machine: &str, msg: &TerminalOutputMsg) -> Option { let a = self.attached.as_mut()?; if a.machine != machine || a.term.as_deref() != Some(msg.term.as_str()) { @@ -559,13 +561,25 @@ impl TerminalsState { } let bytes = STANDARD.decode(&msg.data).ok()?; if msg.offset > a.expected { - // A frame was lost. Ask for the bytes from where the screen is - // once per gap, and drop what arrives until the replay does. - if a.replay_pending { - return None; + if a.replay_pending && msg.replay == Some(true) { + // The answer to the replay starts at the oldest byte the + // bridge still holds, which is past what was asked for once + // that output has left the ring. Take it from there: asking + // again would only earn the same clamped answer. + a.expected = msg.offset; + } else { + // A frame was lost. Ask for the bytes from where the screen is + // once per gap, and drop what arrives until the replay does. + if a.replay_pending { + return None; + } + a.replay_pending = true; + return Some(OutputFrame { + offset: a.expected, + data: Vec::new(), + replay_from: Some(a.expected), + }); } - a.replay_pending = true; - return Some(OutputFrame { offset: a.expected, data: Vec::new(), replay_from: Some(a.expected) }); } let skip = (a.expected - msg.offset) as usize; if skip >= bytes.len() { @@ -1070,10 +1084,67 @@ mod tests { assert_eq!(f.replay_from, Some(4), "asked from where the screen is"); assert!(f.data.is_empty()); assert_eq!(s.output(M, &out(20, "still lost")), None, "one request per gap"); - let f = s.output(M, &out(4, "efgh")).unwrap(); + let mut answer = out(4, "efgh"); + answer.replay = Some(true); + let f = s.output(M, &answer).unwrap(); assert_eq!((f.offset, f.data.as_slice()), (4, b"efgh".as_slice())); assert_eq!(s.attached().unwrap().expected, 8); assert!(!s.attached().unwrap().replay_pending); + // The marking alone does not excuse a gap nobody asked about: with no + // replay pending this is an ordinary lost frame. + let mut ahead = out(20, "ahead"); + ahead.replay = Some(true); + let f = s.output(M, &ahead).unwrap(); + assert_eq!(f.replay_from, Some(8), "asked from where the screen is"); + assert!(f.data.is_empty()); + } + + #[test] + fn an_attach_takes_a_replay_clamped_to_the_ring() { + // An attach asks from 0, so its answer is accepted from wherever the + // ring now starts. + let mut s = attached(); + assert!(s.attached().unwrap().replay_pending); + let mut answer = out(300_000, "clamped"); + answer.replay = Some(true); + let f = s.output(M, &answer).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (300_000, b"clamped".as_slice())); + assert_eq!(f.replay_from, None, "a clamped answer is not a new gap"); + assert_eq!(s.attached().unwrap().expected, 300_007); + assert!(!s.attached().unwrap().replay_pending); + } + + #[test] + fn a_gap_older_than_the_ring_resumes_from_the_replay() { + let mut s = attached(); + s.output(M, &out(0, "abcd")); + let f = s.output(M, &out(10, "lost")).unwrap(); + assert_eq!(f.replay_from, Some(4)); + // The requested bytes left the ring, so the answer starts later. + let mut answer = out(300_000, "kept"); + answer.replay = Some(true); + let f = s.output(M, &answer).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (300_000, b"kept".as_slice())); + assert_eq!(s.attached().unwrap().expected, 300_004); + assert!(!s.attached().unwrap().replay_pending); + // Live output continues from the answer's end. + let f = s.output(M, &out(300_004, "!")).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (300_004, b"!".as_slice())); + assert_eq!(s.attached().unwrap().expected, 300_005); + } + + #[test] + fn a_live_frame_while_a_replay_is_pending_does_not_advance_the_screen() { + let mut s = attached(); + assert_eq!(s.output(M, &out(500, "ahead")), None); + assert!(s.attached().unwrap().replay_pending); + assert_eq!(s.attached().unwrap().expected, 0); + // The bridge may answer nothing (an empty ring, or nothing left after + // `from`); the next live frame at the expected offset still lands. + let f = s.output(M, &out(0, "live")).unwrap(); + assert_eq!((f.offset, f.data.as_slice()), (0, b"live".as_slice())); + assert_eq!(s.attached().unwrap().expected, 4); + assert!(!s.attached().unwrap().replay_pending); } #[test] From 8f0b6b49203daf1518f31f8e2af5200fd0a32151 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:17:58 +0200 Subject: [PATCH 42/50] build: the compose file passes CODEDECK_TERMINAL through to the bridge The bridge already reads CODEDECK_TERMINAL, but the compose service only forwarded the variables it lists, so terminals could be switched on from .env only by editing config.json. Listed (empty = the file decides) and documented in .env.example with the approval step. Co-Authored-By: Claude Code --- .env.example | 4 ++++ docker-compose.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 86016003..23055981 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,7 @@ GIT_REPO=https://github.com/your-username/your-repo.git # set them here (or add them on the phone). # CODEDECK_DIRECT_LISTEN=0.0.0.0:7447 # CODEDECK_DIRECT_ENDPOINTS=wss://192.168.1.20:7447 +# Terminals: 1 lets an approved phone open a shell in the container (off by +# default). Approve each phone with `docker compose exec codedeck-bridge +# codedeck-bridge terminal allow `; docs/TERMINAL.md has the rest. +# CODEDECK_TERMINAL=1 diff --git a/docker-compose.yml b/docker-compose.yml index ef498c8f..e6a10549 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,9 @@ services: - CODEDECK_DEEPSEEK_PATH=${CODEDECK_DEEPSEEK_PATH:-} - CODEDECK_DIRECT_LISTEN=${CODEDECK_DIRECT_LISTEN:-} - CODEDECK_DIRECT_ENDPOINTS=${CODEDECK_DIRECT_ENDPOINTS:-} + # 1 runs terminals (docs/TERMINAL.md): a shell an approved phone + # drives. Off unless set; each phone is still approved on the machine. + - CODEDECK_TERMINAL=${CODEDECK_TERMINAL:-} # The direct link: phones on your network (or VPN) reach the bridge here # without a relay. Only paired phones get past its handshake. ports: From aca3424c2ec2b9969e549ac50ce3f3b056ac1b3c Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:17:58 +0200 Subject: [PATCH 43/50] feat(android): open, watch and type into a machine's terminal The terminal screens are wired: a machine's page lists its terminals and opens one (from Settings as well as from the sessions list), and a session's options sheet offers "Terminal here" in its directory. The Termux emulator draws the shell. Its output reaches the screen on a sink of its own rather than the shared event flow, whose small buffer a shell sending a frame every 16 ms would otherwise empty of every other event. Typing, the extra-keys row (ESC, CTRL, ALT, TAB, arrows, | ~ /) and pastes go through one ordered queue to the core; a paste is sent as one event, bracketed when the program asked for it. Only the writer sends input and its size, a device that takes over sends its own size, and the open carries a size estimated from the window so the shell starts close to right. Keys typed but not yet sent are drawn dimmed at the cursor on the normal screen. A pinch changes the text size, kept per phone. "Lock terminals" (Account) asks for the fingerprint or screen lock before a terminal opens. It needs Android 11 to offer both in one prompt; an unlock survives a rotation but not a new process. Leaving the page detaches; a rotation does not, and the recreated page replays the same terminal. Co-Authored-By: Claude Code --- apps/android/app/src/main/AndroidManifest.xml | 2 + .../java/com/codedeck/plus/core/CoreHost.kt | 25 + .../codedeck/plus/platform/DeviceUnlock.kt | 56 ++ .../codedeck/plus/platform/TerminalPrefs.kt | 34 ++ .../main/java/com/codedeck/plus/ui/Shell.kt | 27 +- .../plus/ui/screens/MachineSettings.kt | 10 +- .../plus/ui/screens/SettingsScreen.kt | 33 +- .../codedeck/plus/ui/screens/TerminalHost.kt | 524 ++++++++++++++++++ .../plus/ui/session/SessionOptions.kt | 12 +- .../codedeck/plus/ui/session/SessionScreen.kt | 8 + .../codedeck/plus/ui/DesignSnapshotTest.kt | 61 +- ...nFullPageSnapshotTest_settings_machine.png | Bin 56723 -> 53998 bytes ...nSnapshotTest_session_options_terminal.png | Bin 0 -> 35386 bytes ...esignSnapshotTest_settings_account_key.png | Bin 49472 -> 67291 bytes ...hotTest_settings_machine_update_needed.png | Bin 65133 -> 64552 bytes ....ui_DesignSnapshotTest_terminal_locked.png | Bin 0 -> 29390 bytes ...apshotTest_terminal_locked_unavailable.png | Bin 0 -> 35932 bytes ...signSnapshotTest_terminal_running_ctrl.png | Bin 0 -> 30250 bytes 18 files changed, 769 insertions(+), 23 deletions(-) create mode 100644 apps/android/app/src/main/java/com/codedeck/plus/platform/DeviceUnlock.kt create mode 100644 apps/android/app/src/main/java/com/codedeck/plus/platform/TerminalPrefs.kt create mode 100644 apps/android/app/src/main/java/com/codedeck/plus/ui/screens/TerminalHost.kt create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_options_terminal.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_locked.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_locked_unavailable.png create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_terminal_running_ctrl.png diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index ec2ad8db..061283ca 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + +