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/Cargo.lock b/Cargo.lock index 7299deb1..04b226c4 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", @@ -389,8 +390,10 @@ dependencies = [ "futures-util", "hex", "log", + "nix", "nostr", "nostr-transport", + "portable-pty", "protocol", "qrcode", "rand 0.8.8", @@ -484,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" @@ -742,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" @@ -815,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" @@ -1327,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" @@ -1410,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" @@ -1578,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" @@ -1639,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", @@ -1680,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", @@ -2082,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" @@ -2104,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" @@ -2912,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" @@ -3009,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/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/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 @@ + + + + + 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/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..1e8d8f76 --- /dev/null +++ b/apps/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -0,0 +1,261 @@ +package com.termux.terminal; + +import android.os.Handler; +import android.os.Looper; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.UUID; + +/** + * 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. + *

+ * 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 { + + public final String mHandle = UUID.randomUUID().toString(); + + TerminalEmulator mEmulator; + + /** Callback which gets notified when a session changes title or state. */ + TerminalSessionClient mClient; + + /** Callback which forwards the user's input and size changes to the remote shell. */ + private final TerminalSessionCallbacks mCallbacks; + + /** 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; + + private final Integer mTranscriptRows; + + /** 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 + * 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; + } + + /** + * @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); + } + + /** + * 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. 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) { + mPendingOutput.write(bytes, 0, bytes.length); + trimPendingOutput(); + 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 { + 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. */ + public String getTitle() { + return (mEmulator == null) ? null : mEmulator.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); + flushPendingOutput(); + } + + /** Whether the emulator is currently showing its alternate screen buffer. */ + public boolean isAlternateBufferActive() { + return mEmulator != null && mEmulator.isAlternateBufferActive(); + } + + /** Write data the user typed to the remote shell. */ + @Override + public void write(byte[] data, int offset, int 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. */ + 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() { + if (mClient != null) mClient.onTextChanged(this); + } + + /** Reset state for terminal emulator state. */ + public void reset() { + if (mEmulator != null) mEmulator.reset(); + notifyScreenUpdate(); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + if (mClient != null) mClient.onTitleChanged(this); + } + + @Override + public void onCopyTextToClipboard(String text) { + if (mClient != null) mClient.onCopyTextToClipboard(this, text); + } + + @Override + public void onPasteTextFromClipboard() { + if (mClient != null) mClient.onPasteTextFromClipboard(this); + } + + @Override + public void onBell() { + if (mClient != null) mClient.onBell(this); + } + + @Override + 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. + */ + 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/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-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..9fa06e7c --- /dev/null +++ b/apps/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalSessionTest.java @@ -0,0 +1,106 @@ +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.assertNull; +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 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(); + + 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/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..153c6ba3 --- /dev/null +++ b/apps/android/terminal-view/README.md @@ -0,0 +1,59 @@ +# 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 + +- **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. +- **`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. +- **`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 plus the pre-size replay buffer. + +The upstream `proguard-rules.pro` files are not copied: they were empty +templates and neither module minifies. 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… + 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-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 d7be0851..4258765f 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -28,13 +28,15 @@ 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}; +use protocol::events::{BridgeToPhone, OutputMsg, SessionListMsg, TerminalInfo}; use crate::catalog::Catalog; use crate::ingest::{since_for_connect, CommandsFilter, Ingest}; @@ -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; @@ -303,6 +320,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(); @@ -345,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); @@ -395,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), } } @@ -585,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() { @@ -601,23 +638,67 @@ impl Engine { let credentials = self.bridge_credentials(); let removed = self.tombstones.ids(); let rev = self.next_list_rev(); - let message = BridgeToPhone::Sessions(SessionListMsg { + // 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()); + } + // 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, 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, 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/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 abf7bad8..1a750498 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 { @@ -59,6 +58,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", } } @@ -75,18 +80,36 @@ 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; } 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 - // 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()); } @@ -96,14 +119,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), @@ -163,6 +186,14 @@ impl Engine { let msg = self.provider_profiles_msg(); self.publish_to(phone, msg); } + // 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 784c1135..07a355b6 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); } @@ -288,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(); @@ -296,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 { @@ -330,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..b4b4d7e8 --- /dev/null +++ b/crates/bridge-core/src/engine/terminal.rs @@ -0,0 +1,1257 @@ +//! 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; +//! - 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. + +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; +/// 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. +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. +/// +/// 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, + /// 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. + #[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`. 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, + /// 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, 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>, + /// 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, +} + +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. +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, 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 }; + let writer = t.writer.clone(); + t.last_seq.insert(writer, 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. + /// + /// 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 + /// 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 }; + 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 }; + 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 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(), + 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: BTreeMap::new(), + 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, replay: None }), + ); + } + + /// 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 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() { + 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 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); + } + } + + /// 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 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.pending.clear(); + 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} 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 }], + 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(why); + 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`, and tell it the seq its own count resumes + /// at. + /// + /// 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.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 }), + ); + } + 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/ingest.rs b/crates/bridge-core/src/ingest.rs index baaac708..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. @@ -175,6 +227,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 { @@ -239,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/src/io.rs b/crates/bridge-core/src/io.rs index 45f57ccf..33df0041 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -8,10 +8,13 @@ use agent_protocol::{BridgeFrame, HostFrame}; use protocol::commands::UploadFileMsg; +use protocol::common::TerminalEndReason; use protocol::events::BridgeToPhone; use serde::{Deserialize, Serialize}; -/// A command event from the relays, signature already verified. `content` is +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)] pub struct InboundEvent { @@ -21,13 +24,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 +80,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 +103,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 @@ -94,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. @@ -164,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, @@ -203,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/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..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 @@ -221,8 +241,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 +265,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 +274,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-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs new file mode 100644 index 00000000..67fc1284 --- /dev/null +++ b/crates/bridge-core/tests/terminal.rs @@ -0,0 +1,1333 @@ +//! 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, 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(); + 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() +} + +/// 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] +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_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(); + 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 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"); + 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"); +} + +#[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] +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: 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())]); + + // 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; +} + +// --- 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"); +} diff --git a/crates/bridge-runtime/Cargo.toml b/crates/bridge-runtime/Cargo.toml index 5f9c9cae..1dc298b6 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", "user"] } [dev-dependencies] tempfile = "3" diff --git a/crates/bridge-runtime/src/audit.rs b/crates/bridge-runtime/src/audit.rs new file mode 100644 index 00000000..aa652ccd --- /dev/null +++ b/crates/bridge-runtime/src/audit.rs @@ -0,0 +1,500 @@ +//! 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 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. +//! +//! 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) +} + +/// 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(Verified { lines: 0, head: None }), + 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()); + } + // `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 +/// `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); + 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); + 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).map(|v| v.lines), 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_empty() { + let dir = tempfile::tempdir().unwrap(); + 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(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] + 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)).map(|v| v.lines), 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..301c8ddc 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,90 @@ 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 + // `--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") { + 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 +301,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 +511,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 +540,8 @@ pub fn load(flags: &Flags) -> Result { test_mode, host_env, direct, + terminal, + audit, home, }) } @@ -456,6 +589,8 @@ mod tests { agent_host_path, node_path, direct, + terminal, + audit, unknown: _, } = file; let set = [ @@ -485,6 +620,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/direct.rs b/crates/bridge-runtime/src/direct.rs index 335cde66..2f61e96f 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,45 @@ 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() + } + + /// 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 { @@ -139,8 +184,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 +198,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 +218,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 +237,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 { @@ -203,8 +256,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(()) } } @@ -384,14 +437,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 => { @@ -419,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); @@ -524,9 +603,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 +629,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"); } @@ -566,7 +649,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); @@ -577,6 +660,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/lib.rs b/crates/bridge-runtime/src/lib.rs index 4a1ed520..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; @@ -14,6 +15,8 @@ pub mod qr; 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..2aad279b 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 and head hash, 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,69 @@ 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 in the middle) is reported with its line +/// number and the command exits non-zero. On success it prints the line count +/// and the head hash — the last line's SHA-256 — because the chain alone +/// cannot see an edit or a deletion of the last line, nor a truncation: there +/// is nothing after them to disagree, so the head is what an owner compares +/// with the syslog copy or a value noted earlier. +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(v) if v.lines == 0 => { + println!("The terminal audit is empty ({}).", path.display()); + Ok(ExitCode::SUCCESS) + } + 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) => { + 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/relay.rs b/crates/bridge-runtime/src/relay.rs index dc8a3be1..139ace88 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,205 @@ 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, + /// 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, 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.replay == msg.replay + && 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, + replay: msg.replay, + 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), + replay: p.replay, + }); + 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 +607,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 +626,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 +676,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; @@ -505,7 +715,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 +730,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 }); }), @@ -530,23 +744,31 @@ 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![], 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. @@ -602,6 +824,7 @@ mod tests { removed_sessions: None, machine_offline: None, direct: None, + terminals: None, rev: None, }) } @@ -676,6 +899,27 @@ 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(), + 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 + // 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 { @@ -830,6 +1074,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), replay: None } + } + + 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 0adfa313..ada72916 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -10,11 +10,18 @@ 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}; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -22,10 +29,12 @@ use crate::config::Config; use crate::host::{self, HostCommand, HostHandle}; use crate::relay::Relays; use crate::state::StateFile; +use crate::terminal::{Cast, 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; @@ -96,6 +105,22 @@ 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. + terminals: Terminals, + /// The terminal audit's writer thread. + audit: audit::Audit, + /// `/terminal-allow.json`, shared with the periodic change check so + /// 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. + allowlist_writer: terminal_allow::Writer, } fn say(line: &str) { @@ -112,6 +137,23 @@ 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, + }; + // 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; @@ -149,6 +191,15 @@ 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, @@ -163,7 +214,18 @@ 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)), + 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. + // 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() { @@ -180,6 +242,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 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 { + 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)] @@ -208,9 +292,38 @@ 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); + } + // 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) { + if self.execute(effect).await { self.relays.flush().await; self.host.stop().await; self.relays.shutdown(); @@ -226,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), @@ -249,7 +365,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(), @@ -295,6 +411,68 @@ impl Runtime { }); } Effect::Stopped => return true, + // The engine decides terminals and the runtime serves them: the + // 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 } => { + // 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 }), + }; + } + 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) { + // `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); + } + } + Effect::TerminalClose { term } => self.terminals.close(&term), + Effect::TerminalPending(pending) => { + // 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), } false } diff --git a/crates/bridge-runtime/src/terminal/cast.rs b/crates/bridge-runtime/src/terminal/cast.rs new file mode 100644 index 00000000..c5781679 --- /dev/null +++ b/crates/bridge-runtime/src/terminal/cast.rs @@ -0,0 +1,182 @@ +//! 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. + 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. + 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. + 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..9d6c5264 --- /dev/null +++ b/crates/bridge-runtime/src/terminal/mod.rs @@ -0,0 +1,986 @@ +//! 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. +//! +//! 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 +//! 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 +//! 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; + +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::mpsc::{sync_channel, Receiver as StdReceiver, Sender as StdSender, SyncSender, TrySendError}; +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; + +pub use cast::Cast; +use ring::Ring; + +/// 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); +/// 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, 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, + /// 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>, + /// 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 { + 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() } + } + + /// 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. + /// + /// `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, + cwd: &str, + cols: u16, + rows: u16, + command: Option<&[String]>, + cast: Cast, + ) -> Result { + let (program, args, shell) = shell_command(command); + + let ring = Rc::new(RefCell::new(Ring::new())); + + 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 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 || { + 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) => { + 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; + } + } + 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), self.inputs.clone())); + + 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(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(_)) => {} + } + } + + /// 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}"); + } + let _ = pty.recording.send(CastEvent::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); + } + } + + /// 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 { + 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); + } + } +} + +/// 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. 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()), + } +} + +#[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. +/// +/// 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::Receiver>, + ring: Rc>, + inputs: mpsc::UnboundedSender, +) { + let mut pending: Vec = Vec::new(); + let mut start: u64 = 0; + loop { + // 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; + } + Err(_) => break, + } + } + if pending.len() >= FLUSH_BYTES { + // 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; + } + if !pending.is_empty() { + let take = pending.len().min(FLUSH_BYTES); + emit(&term, start, &pending[..take], &inputs); + pending.drain(..take); + start += take as u64; + } + } +} + +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. On unix: `$SHELL`, +/// else the account's login shell (unless it is `nologin` or `false`, a +/// service account's), else `/bin/sh` — a service or a container often runs +/// with no `$SHELL`, and `/bin/sh` may be a shell with no line editing or +/// completion (Debian's dash) while the account's is bash. 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()) + .or_else(|| { + let user = nix::unistd::User::from_uid(nix::unistd::getuid()).ok().flatten()?; + let shell = user.shell.to_str()?.to_string(); + // A service account's "shell" refuses logins; /bin/sh is better. + let refuses = ["nologin", "false"].contains(&shell.rsplit('/').next().unwrap_or_default()); + (!shell.is_empty() && !refuses).then_some(shell) + }) + .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) + } + + /// [`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)] + 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); + } + } + } + + /// 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() { + 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 = 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"); + 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:?}"); + assert_contiguous(&frames); + assert!(frames[0].0 < frames[1].0); + + terms.close("t1"); + }) + .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() { + 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"); + 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"); + + 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(); + 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 + // 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"); + + 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(); + 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); + 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 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; + } + + /// 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(); + 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]; + 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(); + 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"); + 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"), + } + } + 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; + } + + /// `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]); + } +} diff --git a/crates/bridge-runtime/src/terminal_allow.rs b/crates/bridge-runtime/src/terminal_allow.rs new file mode 100644 index 00000000..9aacfd2c --- /dev/null +++ b/crates/bridge-runtime/src/terminal_allow.rs @@ -0,0 +1,605 @@ +//! `/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. +//! 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::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::JoinHandle; +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) +} + +/// 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())) +} + +/// 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, + 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 seen = fs::metadata(&self.path).ok().map(|m| Stamp::of(&m)); + if self.checked && seen == self.seen { + return None; + } + self.checked = true; + 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). 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). 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>>, + 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 || writer_loop(path, rx)) + .map_err(|e| log::error!("[Terminal] cannot start the allowlist writer thread: {e}")) + .ok(); + Writer { tx: Some(tx), thread } + } + + /// 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(pending).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 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 + .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. 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.")); + } + 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. +/// 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!( + "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_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] + 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_file_changed() { + 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 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(); + 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 pending = vec![PendingDevice { device: "cccc".into(), label: Some("Pixel 8".into()), last_seen_ms: 5 }]; + 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 write follows. + assert!(read(&path).pending.is_empty()); + } + + #[test] + 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.set_pending(pending_of(&[("cccc", "Pixel 8")])); + drop(writer); // joins, so the write has landed + let list = read(&path); + 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] + 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]); + } +} diff --git a/crates/bridge-runtime/tests/direct.rs b/crates/bridge-runtime/tests/direct.rs index 18a2400d..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. @@ -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. @@ -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); diff --git a/crates/bridge-runtime/tests/e2e.rs b/crates/bridge-runtime/tests/e2e.rs index f44fe7c5..fce6d835 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(); @@ -138,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), @@ -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::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)); + + 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, None), + 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; +} 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..aec66c4d --- /dev/null +++ b/crates/client-core/src/stores/terminals.rs @@ -0,0 +1,1236 @@ +//! 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 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, +} + +/// 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. Empty on + /// the alternate screen: there every key is a command whose effect the + /// program decides, so nothing is predicted, and the bytes it holds for + /// a few ms are no reason to redraw the app. + 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())) + .filter(|s| !s.alternate_screen) + .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, 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 + .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: true, + 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 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()) { + return None; + } + let bytes = STANDARD.decode(&msg.data).ok()?; + if msg.offset > a.expected { + 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), + }); + } + } + 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()), replay: None } + } + + #[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 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] + 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(), replay: None }, + ), + 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()); + // On the alternate screen nothing is predicted, held or not. + typed(&mut s, b"j", true, 2); + 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); + } +} 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..155b21b2 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, @@ -286,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}"); @@ -310,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)), @@ -553,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(); @@ -580,6 +588,7 @@ mod tests { db_path, None, false, + String::new(), ) }; let core = spawn(db_path.clone()).expect("core spawns"); @@ -608,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/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, + }), + }), + }, } } 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"), ); diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index fbd478d5..d5fcdd56 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,6 +529,55 @@ impl<'a> Router<'a> { ); r.ui_changed = true; } + // --- 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 } @@ -611,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)); @@ -934,6 +1031,7 @@ mod tests { removed_sessions: None, machine_offline: None, direct: None, + terminals: None, rev: None, } } @@ -944,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 { @@ -1083,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 d20b7d80..81dc671c 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,65 @@ 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 waiting = stores.terminals.waiting_input(); + let step = stores + .terminals + .input(&machine, &term, &bytes, alternate_screen, ctx.terminal_transport, ctx.now); + r.terminal_step(&machine, step); + // The app draws the bytes held back as the predicted echo; without + // a redraw a key shows only once the shell echoes it, a pause and + // a round trip later. + r.ui_changed |= stores.terminals.waiting_input() != waiting; + } + Intent::TerminalPaste { machine, term, bytes } => { + let waiting = stores.terminals.waiting_input(); + let step = stores + .terminals + .paste(&machine, &term, &bytes, ctx.terminal_transport, ctx.now); + r.terminal_step(&machine, step); + r.ui_changed |= stores.terminals.waiting_input() != waiting; + } + 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 } @@ -1077,13 +1222,14 @@ 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 { IntentCtx { now: 1_000, visible: true, + terminal_transport: InputTransport::Direct, } } @@ -1567,7 +1713,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 c707a341..54b6e413 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}; @@ -48,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}; @@ -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 { @@ -254,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, @@ -288,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 { @@ -347,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 @@ -417,6 +461,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 +495,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 +525,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 +711,7 @@ impl Core { credentials_status: Default::default(), provider_profile_status: Default::default(), undo_toast: None, + terminals: Default::default(), }) } @@ -688,6 +740,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 +1045,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 +1233,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 +1397,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 }); } @@ -1361,7 +1420,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); } @@ -1603,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(); } @@ -1673,6 +1733,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 +1921,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 +1938,70 @@ 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 waiting = self.stores.terminals.waiting_input(); + let step = self.stores.terminals.flush_due(transport, self.clock.now_ms()); + // What was held back is sent: the app's predicted echo of it goes. + if self.stores.terminals.waiting_input() != waiting { + self.state_changed(SliceId::Ui); + } + 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 +2080,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 +2356,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 }), 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 }), 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 c14a3956..7b03ebe1 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, @@ -2296,7 +2344,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 { @@ -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() }, @@ -2335,7 +2384,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; @@ -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() }, @@ -2421,7 +2471,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 { @@ -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() }, @@ -2978,7 +3030,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 +3169,263 @@ 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; +} + +/// A key held back for the next batch redraws the app, whose predicted echo +/// shows it at once; the send that empties the batch redraws it again. +#[tokio::test] +async fn keys_held_back_redraw_the_predicted_echo() { + LocalSet::new() + .run_until(async { + let 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(); + + core.dispatch(Intent::AttachTerminal { machine: machine.pubkey_hex.clone(), term: "t1".into() }) + .await; + settle().await; + let ui_changes = || { + spy.events + .lock() + .unwrap() + .iter() + .filter(|e| matches!(e, CoreEvent::StateChanged { slice: SliceId::Ui })) + .count() + }; + let before = ui_changes(); + core.dispatch(Intent::TerminalInput { + machine: machine.pubkey_hex.clone(), + term: "t1".into(), + bytes: b"l".to_vec(), + alternate_screen: false, + }) + .await; + settle().await; + assert_eq!(core.ui_view().await.terminals.attached.unwrap().waiting_input, b"l".to_vec()); + assert!(ui_changes() > before, "a held key must redraw the app"); + + let before = ui_changes(); + core.dispatch(Intent::TerminalInput { + machine: machine.pubkey_hex.clone(), + term: "t1".into(), + bytes: b"\r".to_vec(), + alternate_screen: false, + }) + .await; + settle().await; + assert!(core.ui_view().await.terminals.attached.unwrap().waiting_input.is_empty()); + assert!(ui_changes() > before, "the send must clear the prediction"); + }) + .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(), + replay: None, + }), + 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/signer.rs b/crates/client-runtime/src/signer.rs index 49bf7e91..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,28 +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: self.device_label.clone(), } } @@ -269,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(); 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), } } } 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..4d00c697 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,17 @@ { "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-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" }, + { "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 +278,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..58c22df8 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,13 @@ 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 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 /// 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 +509,114 @@ pub struct ProviderProfileAckMsg { pub error: Option, } +// --- terminals --- + +/// 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 { + /// 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, + /// 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 +/// 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 { + 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 +653,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 +799,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 +829,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..f4c17249 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 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()]) +} + /// 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() { 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: diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 1d42c9bc..c45c2792 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 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 @@ -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,63 @@ 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 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. + +**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 +289,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 diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 3ad3c9c8..4967bfff 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -308,6 +308,75 @@ 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 live frames until the replay arrives. The answer is marked `replay`; +it may start past the offset asked for (the bridge keeps only the last 256 +KiB), and the core takes it from there instead of asking again. An attach +asks from 0 the same way. 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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 339ef154..ab8b420e 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 | @@ -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. @@ -285,6 +288,64 @@ 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 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, 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. `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 + 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. 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` + 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 +686,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 @@ -699,8 +763,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 @@ -717,8 +782,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 +794,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`) diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md new file mode 100644 index 00000000..790d3b35 --- /dev/null +++ b/docs/TERMINAL.md @@ -0,0 +1,308 @@ +# 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. The new + writer's `seq` resumes where its own last one ended — a takeover never + restarts a device's count. + +## 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 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 + 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, per-device `seq` is its dedup, and + the 300-second age limit still applies. +- **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 + (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) and + replaced by the real output when it arrives, so batching never feels like + lag. After a control key (Enter, Tab, Ctrl-C) nothing is predicted until + the shell has echoed a key of the new line: a password prompt echoes + nothing, so a password is never drawn. 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. +- **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 — 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 + 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 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 + +All carry `v` like every message. `term` is a terminal id the bridge assigns. + +| Message | Direction | Fields | +| --- | --- | --- | +| `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; on a takeover, the seq this device's own count resumes at) | +| `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) | +| `terminal-close` | phone → bridge | `term` (hang up: SIGHUP, then kill after 5 s) | +| `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 +phone that comes back (or a second phone) sees them and can attach with +`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 +("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 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 + 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 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 + +- 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, `|`, + `~`, `/`) — a phone keyboard has none of these. Ctrl and Alt modify the + next key. Only the writer gets it; a device that watches gets no keyboard. +- Output reaches the emulator on its own sink in the app, never the shared + event flow: a frame every 16 ms would push every other event out of that + flow's small buffer. Input, extra keys and pastes go through one ordered + queue; a paste is one event, bracketed when the program asked for it. +- 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 (kept per phone); the emulator's size follows the + view and the writer's changes send `terminal-resize`. An open carries a + size estimated from the window, so the shell starts close to right. +- Leaving the page detaches (the shell keeps running); a rotation does not, + and the recreated page replays the same terminal. +- Settings, Account: "Lock terminals" (off by default) asks for the + fingerprint or screen lock before a terminal opens. It needs Android 11, + the first to offer both in one prompt; older phones do not offer it. + +## 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.