Skip to content

feat(ipad): a browser terminal for this machine, driven from an iPad - #29

Merged
JOhnsonKC201 merged 5 commits into
mainfrom
claude/ipad-terminal-app-access-m9izv8
Aug 28, 2026
Merged

feat(ipad): a browser terminal for this machine, driven from an iPad#29
JOhnsonKC201 merged 5 commits into
mainfrom
claude/ipad-terminal-app-access-m9izv8

Conversation

@JOhnsonKC201

Copy link
Copy Markdown
Owner

What does this change?

Adds tools/ipad-terminal/ — a terminal for this machine that you drive from an iPad.

npm run ipad:lan

It prints a URL. Open it in Safari on the iPad and you get a real shell — vim, top, tab completion, colours — on the computer pixelpets runs on. Share → Add to Home Screen gives it an icon and a full-screen window.

The limitation this is built around

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 gets around that — 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.

Built for a tablet specifically

  • Backgrounding Safari doesn't kill your work. Safari suspends a backgrounded tab, so the naive version loses the shell the moment you check a message. The shell outlives its connection (--idle, default 120s), and the client counts the bytes it has rendered so a reconnect asks for exactly the gap — missed output replayed, already-read output not repeated.
  • The keys a soft keyboard doesn't have. A key bar supplies Esc, Tab, Ctrl and arrows, with a sticky Ctrl. Taps use pointerdown rather than click, because moving focus to a button on iPadOS dismisses the keyboard mid-command.
  • Layout follows visualViewport, since the soft keyboard covers the window rather than resizing it.

Shape of it

Runs on plain node with nothing installed: output over SSE, input over POST, rather than hand-rolled RFC 6455 framing for something that shuttles a few hundred bytes per keypress. 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, and the boot banner says which you're getting.

It is its own package on purposenode-pty is native and xterm is browser-side, so neither belongs in the Electron bundle. The root package.json gains only two scripts; package-lock.json is untouched.

Security

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 hostname resolving to this box can't probe the port. The page strips the token out of the address bar on load and authenticates its own stylesheet and script with a SameSite=Strict cookie that the API itself refuses. It's plain HTTP — fine on your own Wi-Fi, and the README says to use a TLS tunnel rather than a port forward for anything else.

Two bugs found while testing, fixed here

  • A session created but never streamed to held a live shell open forever — only a disconnecting client armed the cleanup.
  • The locally vendored xterm files 401'd in a real browser: a <script src> can't carry an auth header. That's what the subresource cookie is for.

Verification

Beyond the test suite, this was driven in Chromium at an iPad viewport: opened vim, typed in insert mode, hit Esc from the key bar, :wq, and cat confirmed the file; tty reports /dev/pts/0 and tput cols matches the fitted terminal; colours and CJK render; no console errors. The offline path was checked too — with the CDN blocked the page shows the "run npm install" message and a retry button rather than a blank screen.

Checklist

  • npm test is green — 200 passing (193 existing + 7 new), with and without node-pty installed
  • npx eslint src/ tests/ is clean (npm run lint over the whole tree is clean too)
  • If this touches rendering: n/a — no sprite, palette or renderer code touched
  • If this touches src/cat-sprite.js: n/a — not touched
  • All art/sound in this PR is original or procedural — no art or sound added; the only third-party code is xterm.js, loaded from a pinned CDN or an opt-in local install, never vendored into the repo

Generated by Claude Code

`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 <script src> cannot carry an auth header.

Tests drive the reconnect-replay path, the reaper and the auth surface against
a real shell over real HTTP, and pass with and without node-pty installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaZc4CaHkaynMT3i5qoyLt
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pixelcat Ready Ready Preview Aug 28, 2026 3:12am

The shell outlives a dropped connection by --idle seconds and no longer,
which is right for a terminal and wrong for a six-hour training run: put
the iPad down and two minutes later the reaper takes the shell, and the
job with it.

Verified both halves rather than asserting them - a bare background job
is killed with its shell once the idle window passes, and the same job
inside tmux is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaZc4CaHkaynMT3i5qoyLt
@JOhnsonKC201
JOhnsonKC201 marked this pull request as ready for review August 28, 2026 03:08
@JOhnsonKC201
JOhnsonKC201 merged commit 5c093f6 into main Aug 28, 2026
5 checks passed
@JOhnsonKC201
JOhnsonKC201 deleted the claude/ipad-terminal-app-access-m9izv8 branch August 28, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants