From 4fcf95e2cf20fe3963fce7bd38b5863cf4696f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:03:51 +0000 Subject: [PATCH 1/3] feat(ipad): a browser terminal for this machine, driven from an iPad `npm run ipad:lan` prints a URL; Safari on the tablet opens a real shell - vim, top, tab completion, colours - on the computer pixelpets runs on. It deliberately does not do the thing that cannot be done: iPadOS sandboxes every app and exposes no API for cross-app control, so nothing running on the tablet can drive other iPad apps. Shortcuts is the only sanctioned path there, and only for apps publishing App Intents. This puts the shell where a shell is worth having and lets the iPad be the screen and the keyboard. Built for a tablet specifically: - Safari suspends a backgrounded tab, so the shell outlives its connection (--idle, default 120s) and the client counts rendered bytes to ask for exactly the gap on reconnect - missed output replayed, read output not repeated. - A key bar supplies the Esc/Tab/Ctrl/arrows the soft keyboard lacks, with a sticky Ctrl. Taps use pointerdown rather than click, because moving focus to a button on iPadOS dismisses the keyboard mid-command. - visualViewport drives the layout, since the soft keyboard covers the window rather than resizing it. Runs on plain node with nothing installed: output over SSE, input over POST, rather than hand-rolled RFC 6455 framing. `npm install` inside the tool adds a real pty and vendors xterm.js locally; without it the server drops to line mode and the page pulls xterm from a pinned CDN. The tool is its own package on purpose - node-pty is native and xterm is browser-side, so neither belongs in the Electron bundle. Loopback unless --lan. Every request needs a token, ten bad ones lock the caller out for a minute, and the Host header is checked so a rebound DNS name cannot probe the port. The page strips the token from the address bar on load and authenticates its own stylesheet and script with a SameSite=Strict cookie that the API itself refuses. Two bugs found while testing and fixed here: a session created but never streamed to held a live shell open forever, because only a disconnecting client armed the cleanup; and the vendored xterm files 401'd in a real browser because a + + diff --git a/tools/ipad-terminal/server.js b/tools/ipad-terminal/server.js new file mode 100644 index 0000000..4c5e76c --- /dev/null +++ b/tools/ipad-terminal/server.js @@ -0,0 +1,444 @@ +// A browser terminal you can drive from an iPad. +// +// iPadOS sandboxes every app, so nothing running *on* the tablet can reach into other +// iPad apps. What the iPad can do is reach this machine over the network. So the shell +// lives here - on the computer pixelpets actually runs on - and Safari is only the +// screen and the keyboard. Point the iPad at the URL this prints and you get a real +// terminal, on the machine where a terminal is worth having. +// +// node tools/ipad-terminal/server.js # loopback only (this machine) +// node tools/ipad-terminal/server.js --lan # reachable from the iPad on your Wi-Fi +// +// Downstream is Server-Sent Events, upstream is POST, rather than one WebSocket: node +// ships no WebSocket *server*, and hand-rolling RFC 6455 framing is a lot of surface +// area for something whose whole job is shuttling a few hundred bytes per keypress +// across a LAN. This way the tool stays dependency-free and starts with plain `node`. +const http = require('node:http'); +const crypto = require('node:crypto'); +const os = require('node:os'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const PUBLIC = path.join(__dirname, 'public'); +// Enough scrollback that backgrounding Safari and coming back re-paints a full screen +// of context instead of dropping you into a blank buffer mid-command. +const SCROLLBACK = 256 * 1024; +const MAX_SESSIONS = 4; +const MAX_BODY = 64 * 1024; +const HEARTBEAT_MS = 15000; + +// ---------------------------------------------------------------- options + +function parseArgs(argv) { + const opt = { + host: '127.0.0.1', port: 7681, lan: false, token: '', + shell: '', idle: 120, allowHost: [], + }; + for (const arg of argv) { + const eq = arg.indexOf('='); + const key = eq === -1 ? arg : arg.slice(0, eq); + const val = eq === -1 ? '' : arg.slice(eq + 1); + switch (key) { + case '--lan': opt.lan = true; break; + case '--host': opt.host = val; break; + case '--port': opt.port = Number(val) || opt.port; break; + case '--token': opt.token = val; break; + case '--shell': opt.shell = val; break; + // Seconds a disconnected session's shell survives. iPad Safari suspends + // background tabs aggressively, so "you tabbed away" must not mean "your + // build got killed" - reconnecting inside this window resumes the same shell. + case '--idle': opt.idle = Number(val) || opt.idle; break; + // Only needed when you reach the box by a DNS name (a tunnel, an mDNS alias). + case '--allow-host': if (val) opt.allowHost.push(val.toLowerCase()); break; + case '--help': case '-h': opt.help = true; break; + default: if (arg.startsWith('-')) { opt.bad = arg; } + } + } + if (opt.lan && opt.host === '127.0.0.1') opt.host = '0.0.0.0'; + return opt; +} + +const opt = parseArgs(process.argv.slice(2)); + +const TOKEN = opt.token || crypto.randomBytes(16).toString('hex'); +const IS_WIN = process.platform === 'win32'; +const SHELL = opt.shell || process.env.SHELL || (IS_WIN ? 'powershell.exe' : '/bin/bash'); + +// node-pty is optional and native. With it you get a true terminal - vim, top, tab +// completion, job control. Without it we fall back to piping a shell, which can still +// run commands but has no tty, so the browser does the line editing instead. +let pty = null; +try { pty = require('node-pty'); } catch { /* line mode */ } +const MODE = pty ? 'pty' : 'line'; + +// ---------------------------------------------------------------- auth + +const TOKEN_BUF = Buffer.from(TOKEN); +const failures = new Map(); // ip -> { count, until } + +const COOKIE = 'ppterm'; + +function cookieToken(req) { + const jar = String(req.headers.cookie || ''); + const hit = jar.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${COOKIE}=`)); + return hit ? decodeURIComponent(hit.slice(COOKIE.length + 1)) : ''; +} + +// `allowCookie` is deliberately not the default. The page and its stylesheet/script +// tags have no way to send a header - a