diff --git a/CHANGELOG.md b/CHANGELOG.md index a15e881..1810af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no ## [Unreleased] +### iPad terminal +- **A terminal on the iPad, pointed at the machine that has one.** `npm run ipad:lan` prints a URL; Safari on the tablet opens a real shell - `vim`, `top`, tab completion, colours - on this computer. The thing it deliberately does *not* do is control other iPad apps: iPadOS sandboxes every app and exposes no API for it, so a tool that promised that would be lying. Shortcuts is the only sanctioned cross-app path on a tablet, and only for apps publishing App Intents. +- **Switching apps no longer kills your build.** Safari suspends a backgrounded tab, so the naive version of this loses the shell the moment you check a message. The shell now outlives its connection, and the client counts the bytes it has rendered so a reconnect asks for exactly the gap - output made while you were away is replayed, output you already read is not repeated. +- **A shell nobody is watching gets reaped.** The cleanup was armed only by a client *disconnecting*, so a session created and then never attached to - a page that loaded and got closed, a probe - held a live shell open forever. +- **The keys a soft keyboard does not have.** No Esc, no Tab, no Ctrl, no arrows is most of what a shell is driven with; a key bar supplies them, with a sticky Ctrl. Tapping one uses `pointerdown` rather than click, because moving focus to a button on iPadOS dismisses the keyboard mid-command. +- **Zero dependencies to start, better with two.** It runs on plain `node`: no install, output over SSE and input over POST rather than a hand-rolled WebSocket. `npm install` inside the tool adds a real pty and vendors xterm.js locally, and it is deliberately its own package - `node-pty` is native and xterm is browser-side, so neither belongs in the Electron bundle. +- **Loopback unless you say otherwise.** `--lan` is the only thing that opens the port. Every request needs a token, ten bad ones lock the caller out for a minute, and the `Host` header is checked so a hostname resolving to this box cannot be used to probe it. The page strips the token out of the address bar on load and authenticates its own stylesheet and script with a `SameSite=Strict` cookie the API itself refuses. + ### Sound - **The pet stopped making noise nobody asked for.** A cursor merely *resting* on the sprite counted as petting on every frame: the gate rejected a fast cursor, but a stationary one has `velEMA` 0, and main only forwards the cursor when it moves, so the last resting position stood forever. A pointer parked on the pet - easy to do by accident, and where the cursor often ends up after a pounce - purred without end and trilled every 1.5 seconds indefinitely. The *pose* is deliberate and stays, because a hand resting on the pet should squint its eyes; the voice now settles after eight seconds of a still hand. - **The butterfly stopped keeping time.** The paw-swat lasted 600ms and re-armed 100ms later, so it threw a whoosh roughly every 350ms for the whole 22-30 second visit, and visits recur every 14-24 seconds - better than half of all idle time spent audibly batting. The paw now rests between swats, jittered so it reads as a cat losing interest rather than a metronome. diff --git a/README.md b/README.md index 345fcd7..4bbb3a8 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,30 @@ fullscreen), clicks pass through except on the cat, the typing reaction works after granting Accessibility, the cat rests on the Dock edge (not the menu bar), the tray menu works in the menu bar, and login launch works. +## Drive it from an iPad + +``` +npm run ipad:lan +``` + +That prints a URL. Open it in Safari on the iPad and you have a real terminal - `vim`, +`top`, tab completion, colours - on **this** machine. Share → Add to Home Screen gives +it an icon and a full-screen window. + +The honest limitation first, because it is the reason the tool works this way: nothing +running on an iPad can control other iPad apps. iPadOS sandboxes every app, Apple +exposes no API for cross-app control, and no terminal on the App Store gets around it - +Shortcuts is the only sanctioned path, and only for apps that publish App Intents. So +this puts the shell on the computer, where a shell is worth having, and lets the iPad be +the screen and the keyboard. + +It is built for a tablet: a key bar supplies the Esc, Tab, Ctrl and arrows the soft +keyboard lacks, and because Safari suspends a backgrounded tab, the shell outlives the +connection and replays exactly the output you missed when you come back. + +It is a shell running as you, over plain HTTP. Fine on your own Wi-Fi, not fine anywhere +else - see [`tools/ipad-terminal/`](tools/ipad-terminal/) for the full notes. + ## How it works - The cat is one role-coded sprite (outline, coat, markings, white, patch, eye, @@ -565,6 +589,7 @@ pixelpets/ tests/ # node:test suites, no Electron and no GPU required scripts/ # the vm harness, icon and demo generators, notify.js, # install-hook.js, the boot check + tools/ipad-terminal/ # browser terminal for this machine, driven from an iPad integrations/ # ready-made agent hook configs (5 agents) assets/ # generated icons, showcase, hero + gallery clips site/ # the browser demo deployed to Vercel diff --git a/package.json b/package.json index 5e05ec7..cd64e88 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "test": "node --test", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "hook": "node scripts/install-hook.js", + "ipad": "node tools/ipad-terminal/server.js", + "ipad:lan": "node tools/ipad-terminal/server.js --lan", "test:boot": "node scripts/bootcheck.js", "demo": "node scripts/make-demo-gif.js mp4", "demo:hero": "node scripts/make-demo-gif.js hero mp4", diff --git a/tests/ipad-terminal.test.js b/tests/ipad-terminal.test.js new file mode 100644 index 0000000..02d448e --- /dev/null +++ b/tests/ipad-terminal.test.js @@ -0,0 +1,196 @@ +// The iPad terminal's job is to survive a tablet: Safari suspends the tab the moment +// you switch apps, and the shell on the other end must not die with it, lose the output +// it produced while you were away, or re-print what you already read. These drive the +// real server over real HTTP against a real shell - no mocks, so a pass means bytes +// actually made the round trip. +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const srv = require('../tools/ipad-terminal/server.js'); +const SERVER = path.join(__dirname, '..', 'tools', 'ipad-terminal', 'server.js'); +const WIN = process.platform === 'win32'; + +test('--lan is the one flag that opens the port to the network', () => { + assert.equal(srv.parseArgs([]).host, '127.0.0.1'); + assert.equal(srv.parseArgs(['--lan']).host, '0.0.0.0'); + // An explicit --host wins, so --lan can't silently widen a deliberate bind. + assert.equal(srv.parseArgs(['--lan', '--host=192.168.1.5']).host, '192.168.1.5'); + assert.equal(srv.parseArgs(['--port=9000']).port, 9000); + assert.equal(srv.parseArgs(['--idle=30']).idle, 30); + assert.deepEqual(srv.parseArgs(['--allow-host=a.ts.net', '--allow-host=b']).allowHost, ['a.ts.net', 'b']); + assert.equal(srv.parseArgs(['--nope']).bad, '--nope'); +}); + +test('the Host check lets the iPad in and keeps rebound DNS names out', () => { + const ok = (host) => srv.hostOk({ headers: { host } }); + assert.ok(ok('192.168.1.5:7681'), 'the LAN IP the iPad actually dials'); + assert.ok(ok('127.0.0.1:7681')); + assert.ok(ok('localhost:7681')); + assert.ok(ok('johnsons-mac.local:7681'), 'Bonjour name a Mac answers to'); + assert.ok(ok('[::1]:7681')); + // A name that resolves to this box is exactly the DNS-rebinding shape. + assert.ok(!ok('evil.example.com:7681')); + assert.ok(!ok('')); +}); + +test('scrollback keeps its offsets straight as it evicts', () => { + const sess = { chunks: [], bytes: 0, base: 0, offset: 0, clients: new Set() }; + const chunk = Buffer.alloc(64 * 1024, 'x'); + for (let i = 0; i < 6; i += 1) srv.pushOut(sess, chunk); + + const total = 6 * chunk.length; + assert.equal(sess.offset, total, 'offset counts every byte ever written'); + assert.ok(sess.bytes <= srv.SCROLLBACK, 'the buffer stays capped'); + assert.ok(sess.base > 0, 'the oldest output was dropped'); + assert.equal(sess.base + sess.bytes, sess.offset, 'base and length still meet the head'); + + // Replaying from the head is a no-op: a client that is caught up gets nothing. + let sent = ''; + srv.replay(sess, { write: (s) => { sent += s; } }, sess.offset); + assert.equal(sent, '', 'a caught-up reconnect replays nothing'); + + // Replaying from mid-buffer returns exactly the bytes past that point. + const from = sess.base + 1000; + let frame = ''; + srv.replay(sess, { write: (s) => { frame += s; } }, from); + const payload = Buffer.from(frame.replace(/^event: out\ndata: /, '').trim(), 'base64'); + assert.equal(payload.length, sess.offset - from, 'replay length matches the gap exactly'); +}); + +// ---- end to end, against a real shell over real HTTP + +const TOKEN = 'test-token-for-the-suite'; + +async function withServer(fn) { + const port = 7900 + Math.floor(Math.random() * 90); + const proc = spawn(process.execPath, [SERVER, `--port=${port}`, `--token=${TOKEN}`, '--idle=2'], { stdio: 'pipe' }); + const base = `http://127.0.0.1:${port}`; + try { + await waitFor(async () => (await fetch(`${base}/?t=${TOKEN}`)).ok); + await fn(base); + } finally { + proc.kill(); + } +} + +async function waitFor(check, tries = 60) { + for (let i = 0; i < tries; i += 1) { + try { if (await check()) return; } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('server never came up'); +} + +const auth = { 'x-term-token': TOKEN }; + +// Node's fetch silently drops a Host header, so the DNS-rebinding guard needs a raw +// request to be exercised at all. +function statusWithHost(base, host) { + const url = new URL(`${base}/?t=${TOKEN}`); + return new Promise((resolve, reject) => { + const req = require('node:http').request( + { host: url.hostname, port: url.port, path: url.pathname + url.search, headers: { host } }, + (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', reject); + req.end(); + }); +} + +// The browser client, in miniature: read SSE frames, decode the base64 payloads, and +// keep an exact count of rendered bytes so a reconnect can ask for precisely the gap. +async function readStream(base, id, from, ms) { + const stop = new AbortController(); + const timer = setTimeout(() => stop.abort(), ms); + const res = await fetch(`${base}/api/stream?id=${id}&from=${from}`, { headers: auth, signal: stop.signal }); + assert.ok(res.ok, `stream returned ${res.status}`); + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + let text = ''; + let rendered = from; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let cut; + while ((cut = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, cut); + buf = buf.slice(cut + 2); + if (!frame.startsWith('event: out')) continue; + const bytes = Buffer.from(frame.slice(frame.indexOf('data: ') + 6), 'base64'); + rendered += bytes.length; + text += bytes.toString('utf8'); + } + } + } catch { /* aborted on purpose */ } + clearTimeout(timer); + return { text, rendered }; +} + +const type = (base, id, s) => fetch(`${base}/api/input?id=${id}`, { method: 'POST', headers: auth, body: s }); + +test('an unauthenticated request never reaches a shell', { skip: WIN && 'posix shell' }, async () => { + await withServer(async (base) => { + assert.equal((await fetch(`${base}/`)).status, 401); + assert.equal((await fetch(`${base}/?t=wrong`)).status, 401); + // A token of the wrong length must be rejected, not throw inside the comparison. + assert.equal((await fetch(`${base}/?t=${'a'.repeat(200)}`)).status, 401); + assert.equal((await fetch(`${base}/api/session`, { method: 'POST' })).status, 401); + // fetch() refuses to set Host, so this one goes out over a raw request. + assert.equal(await statusWithHost(base, 'evil.example.com'), 421); + assert.equal(await statusWithHost(base, '127.0.0.1'), 200, 'the address the iPad dials still works'); + }); +}); + +test('a backgrounded iPad loses no output and re-reads none', { skip: WIN && 'posix shell' }, async () => { + await withServer(async (base) => { + const res = await fetch(`${base}/api/session`, { + method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ cols: 80, rows: 24 }), + }); + const { id, mode } = await res.json(); + assert.ok(id); + + // Connection one: run something, then let the socket drop mid-session. + await type(base, id, 'echo BEFORE-DROP\n'); + const first = await readStream(base, id, 0, 1200); + assert.match(first.text, /BEFORE-DROP/); + + // Safari is suspended here. The shell keeps working and keeps producing output. + await type(base, id, 'echo WHILE-AWAY\n'); + await new Promise((r) => setTimeout(r, 600)); + + // Connection two asks for exactly what it has not rendered. + await type(base, id, 'echo AFTER-RECONNECT\n'); + const second = await readStream(base, id, first.rendered, 1200); + assert.match(second.text, /WHILE-AWAY/, 'output produced while away was replayed'); + assert.match(second.text, /AFTER-RECONNECT/, 'the session is live again'); + assert.ok(!second.text.includes('BEFORE-DROP'), 'already-rendered output was not repeated'); + if (mode === 'pty') assert.equal((await fetch(`${base}/api/resize?id=${id}&cols=120&rows=40`, { method: 'POST', headers: auth })).status, 204); + }); +}); + +test('a shell nobody is watching is reaped, not left running', { skip: WIN && 'posix shell' }, async () => { + await withServer(async (base) => { + const res = await fetch(`${base}/api/session`, { + method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ cols: 80, rows: 24 }), + }); + const { id } = await res.json(); + // --idle=2 above: still there right after the drop, gone once the window passes. + assert.equal((await type(base, id, '')).status, 204); + await new Promise((r) => setTimeout(r, 3500)); + assert.equal((await type(base, id, 'echo late\n')).status, 404); + }); +}); + +test('a bogus session id is a 404, not a crash', { skip: WIN && 'posix shell' }, async () => { + await withServer(async (base) => { + assert.equal((await fetch(`${base}/api/input?id=nope`, { method: 'POST', headers: auth, body: 'x' })).status, 404); + assert.equal((await fetch(`${base}/api/stream?id=nope`, { headers: auth })).status, 404); + assert.equal((await fetch(`${base}/nope`, { headers: auth })).status, 404); + }); +}); diff --git a/tools/ipad-terminal/README.md b/tools/ipad-terminal/README.md new file mode 100644 index 0000000..424d65d --- /dev/null +++ b/tools/ipad-terminal/README.md @@ -0,0 +1,112 @@ +# ipad-terminal + +A terminal for **this machine**, driven from an iPad. + +``` +npm run ipad:lan # from the repo root +``` + +It prints a URL. Open it in Safari on the iPad. You get a real shell. + +--- + +## What this is not + +It is worth being blunt about the limit, because it is the whole reason this tool is +shaped the way it is. + +**Nothing running on an iPad can control other iPad apps.** iPadOS sandboxes every app: +it cannot read another app's data, send it input, or drive its UI. Apple exposes no API +for it, and no terminal - this one or any on the App Store - can get around that. The +only sanctioned cross-app automation on an iPad is the **Shortcuts** app, and it only +reaches apps that publish App Intents. + +So this does the thing that *is* possible: it puts a shell on the computer where a shell +is worth having, and lets the iPad be the screen and the keyboard. + +## Running it + +| | | +|---|---| +| `npm run ipad` | loopback only - this machine, for a smoke test | +| `npm run ipad:lan` | bound to `0.0.0.0`, so the iPad can reach it over your Wi-Fi | + +Options: `--port=N` (default 7681), `--token=STR`, `--shell=PATH`, `--idle=SECONDS`, +`--allow-host=NAME`, `--help`. + +A fresh random token is generated each run and baked into the printed URL. The page +strips it out of the address bar as soon as it loads, so it is not sitting in a +screenshot or a synced tab, and hands the tab a `SameSite=Strict` cookie for its own +stylesheet and script - the API itself never accepts that cookie. + +On the iPad, **Share → Add to Home Screen** gives it an icon and a full-screen window +with no Safari chrome. + +## Two modes + +Run `npm install` in this directory to get the good one. + +**Full tty** (with `node-pty` installed) - a real pty. `vim`, `top`, tab completion, +job control, Ctrl-C, colours, the works. The terminal's size is pushed to the shell, so +`tput cols` is honest. + +**Line mode** (nothing installed) - the shell is spawned over pipes, so there is no tty. +The browser does the line editing and ships whole lines. Commands run and output comes +back; interactive full-screen programs do not work. This exists so the tool starts with +plain `node` on a fresh clone. + +`npm install` here also vendors xterm.js locally. Without it the page pulls xterm from +jsdelivr with a pinned integrity hash - fine on a normal network, but install it if you +want the thing to work with no internet at all. + +## Surviving an iPad + +Safari suspends a backgrounded tab, which would ordinarily mean switching apps kills +whatever you were running. Two things prevent that: + +- The shell outlives the connection by `--idle` seconds (default 120). Coming back + inside that window resumes the *same* shell, not a new one. +- The client counts the bytes it has rendered and asks for exactly the gap on reconnect, + so output produced while you were away is replayed and output you already read is not. + +`tests/ipad-terminal.test.js` drives that path against a real shell over real HTTP. + +## Long-running jobs + +The shell outlives a dropped connection by `--idle` seconds and no longer. That is the +right behaviour for a terminal, and the wrong behaviour for a six-hour training run: put +the iPad down, and two minutes later the reaper kills the shell and the job goes with it. + +So start anything long inside `tmux` (or `screen`): + +``` +tmux new -s train # then run the job +# detach with ctrl-b d, close the iPad, come back later: +tmux attach -t train +``` + +A tmux session is not a child of the shell, so it survives the reaper, a Wi-Fi drop, and +restarting this server. Verified: a bare background job is killed with its shell once the +idle window passes; the same job inside tmux is untouched. + +`--idle=86400` also works and is worse - it leaves an abandoned shell alive for a day. + +## The key bar + +The iPad soft keyboard has no Esc, no Tab, no Ctrl and no arrows, which is most of what +a shell is driven with. The row along the bottom puts them back. `ctrl` is sticky: tap +it, then the next letter becomes a control code. + +## Security + +This is a shell running as you. Take it as seriously as that sounds. + +- The token is required on every request; ten bad ones and the caller is locked out for + a minute. +- The `Host` header is checked, so a hostname that resolves to this box cannot be used + to probe the port. +- `--lan` is the only thing that opens it to the network, and it is never the default. +- It is plain HTTP. That is fine on your own Wi-Fi and **not** fine anywhere else - to + reach it from outside, put it behind a tunnel that terminates TLS (Tailscale, + Cloudflare Tunnel, `ssh -L`) rather than forwarding a port on your router. +- Stop it with Ctrl-C when you are done. It is not a service; do not leave it running. diff --git a/tools/ipad-terminal/package-lock.json b/tools/ipad-terminal/package-lock.json new file mode 100644 index 0000000..5506c2a --- /dev/null +++ b/tools/ipad-terminal/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "pixelpets-ipad-terminal", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pixelpets-ipad-terminal", + "version": "1.0.0", + "license": "MIT", + "optionalDependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "node-pty": "^1.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "optional": true + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.1.0" + } + } + } +} diff --git a/tools/ipad-terminal/package.json b/tools/ipad-terminal/package.json new file mode 100644 index 0000000..cb6d487 --- /dev/null +++ b/tools/ipad-terminal/package.json @@ -0,0 +1,17 @@ +{ + "name": "pixelpets-ipad-terminal", + "version": "1.0.0", + "private": true, + "description": "Browser terminal for this machine, driven from an iPad", + "license": "MIT", + "scripts": { + "start": "node server.js", + "lan": "node server.js --lan" + }, + "//": "Deliberately a package of its own, not part of the app. node-pty is a native module and xterm is browser-side - neither belongs in the Electron bundle, and hoisting them into the root manifest would ship both to every installer. Everything here is optional: with nothing installed the server still runs in line mode and loads xterm from a CDN.", + "optionalDependencies": { + "node-pty": "^1.0.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0" + } +} diff --git a/tools/ipad-terminal/public/index.html b/tools/ipad-terminal/public/index.html new file mode 100644 index 0000000..b284d07 --- /dev/null +++ b/tools/ipad-terminal/public/index.html @@ -0,0 +1,409 @@ + + +
+ + + + + + + + + + +loading terminal…