diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c3315a8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +assets/fonts/**/*.bdf.gz binary linguist-vendored=true +app/font.generated.ts linguist-generated=true diff --git a/.github/workflows/terminal.yml b/.github/workflows/terminal.yml new file mode 100644 index 0000000..88dc657 --- /dev/null +++ b/.github/workflows/terminal.yml @@ -0,0 +1,40 @@ +name: Terminal contracts +on: + pull_request: + paths: + - "app/**" + - "assets/**" + - "host/**" + - "mirror/**" + - "shared/**" + - "scripts/**" + - "test/**" + - "vendor/pocketjs" + - "package.json" + - "pocket*.json" + - "tsconfig.json" + - ".github/workflows/terminal.yml" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: terminal-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + terminal: + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + submodules: true + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: bun run setup + - run: bun run check + - run: bun scripts/font.ts --check + - run: bun run test:pty diff --git a/AGENTS.md b/AGENTS.md index cb3170c..e835798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,18 +18,16 @@ first, and this repository moves its pin. ## The loop ```sh -bun run check # typecheck + host tests -bun run push --host # rebuild the guest, hot-push it (~20 s) -bun run probe --host # status, stats, tree, screenshot -bun run 3ds # the full .3dsx — needed for a reflash -bun run daemon --unicast +bun run check +bun run test:pty +bun run 3ds +bun run daemon --device ``` -`app/` and `mirror/` changes are hot pushes. A change under -`vendor/pocketjs/hosts/3ds` is native: rebuild the `.3dsx`, copy it to the SD -card, relaunch. **ftpd cannot run while Pocket Term does** — one homebrew -application at a time — so a reflash needs the console back at the Homebrew -Launcher. +The pinned offload host reads its embedded guest and does not start the +legacy development server. Rebuild and copy the `.3dsx` through ftpd for +updates. `push` and `probe` apply only to the previous svc launcher. +**ftpd cannot run while Pocket Term does**; return to HBL to transfer files. ## Things that have cost time @@ -56,8 +54,13 @@ Launcher. - **A hot push restarts QuickJS but not the transport.** The daemon sees the same connection, so a `hello` resets per-replica delivery state — without that the fresh guest draws blanks where the old one drew CJK. -- **TCP 8622 is usually taken** by another companion on this machine; the - daemon falls back to an ephemeral port and the beacon advertises it. -- **Broadcast is filtered on many networks.** `--unicast ` beacons - straight at the console; the device connects to the datagram's source - address at the advertised port. +- **Terminal access uses paired io.offload.** `--device ` (and + the legacy `--unicast` alias) selects the provider destination. PKNT is + loopback-only for Mac mirrors. `bun run pair` provisions both the existing + dev key and the app-specific offload key. +- **Provider workers do not own PTYs.** They are destroyed on disconnect; + `host/session.ts` runs in the durable Node terminal process. Keep the shared + protocol in `shared/`, and preserve command ids across uncertain replies. +- **The terminal atlas has a 5px advance.** Regenerate `app/font.generated.ts` + with `bun scripts/font.ts`; do not obtain 80 columns through negative + tracking of the old 12px font. Status belongs on the auxiliary screen. diff --git a/README.md b/README.md index 379e024..5afa910 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,226 @@ # Pocket Term -A remote terminal multiplexer for the **Nintendo 3DS**. Your shells run on the -Mac; the console is a replica of one of them, drawn at 57×17 on the top screen -with a touch keyboard on the bottom. +Pocket Term is a **Nintendo 3DS terminal client for macOS**. Open independent +shell sessions, switch between them, edit in Vim or Nano, and read scrollback +with the Circle Pad or a touchpad. Each session can also open a Mac mirror +window connected to the same PTY. + +The top screen uses its full **400×240 pixels for 80 columns × 24 rows**. +The lower screen contains session tabs, a scroll touchpad and a keyboard; +font, scroll speed and typing preview controls live in settings. + +Current Pocket Term interface: an 80-column shell above session tabs, a touchpad and a keyboard with a settings key + +Native 3DS rendering captured in Azahar with deterministic example terminal +data. The two screens retain their native pixel dimensions. Capture commands +and provenance are in [screenshots](docs/screenshots/README.md). + +## What it supports + +- **Multiple real shells.** Up to 32 macOS PTYs, paged session tabs, ANSI color, + alternate screens, terminal cursor keys and explicit bracketed paste. +- **Local inertial scrollback.** Pixel movement continues while history loads. + A bounded 3DS cache retains fetched rows; missing rows show placeholders. + The Mac retains up to 2,000 history rows per session. +- **Hardware cursor control.** The D-pad and right nub send repeating terminal + arrows, including application cursor mode for editors such as Vim and Nano. +- **Selectable bitmap fonts.** Spleentt 5×8, Spleen 5×8 and Fusion Pixel 10px + preserve their original pixels. ASCII occupies 5px; wide characters occupy + two columns. The Mac supplies glyphs missing from the baked atlas. +- **Provisional input preview.** After observing matching application echoes, + the client can show pending text or cursor movement before its reply arrives. + The preview never changes the actual terminal grid or executes a command. + +## Build and connect + +Use a Mac and a homebrew-capable 3DS on the same local network. The console +needs Homebrew Launcher and ftpd. Building requires Bun, Node, Rust through +rustup, and a running Docker engine. CI uses **Bun 1.3.14 and Node 24**; +`bun run daemon` starts the Node supervisor and its Bun offload provider. + +PocketJS is pinned as the `vendor/pocketjs` submodule. Its +[Rust toolchain file](vendor/pocketjs/hosts/3ds/core/rust-toolchain.toml) selects +the nightly used by the 3DS build; the build script drives the devkitARM +container. Install that pinned toolchain with its `rust-src` component if +rustup has not already provisioned it. -Pocket Term on a Nintendo 3DS: a colourized directory listing and a CJK line on the top screen, tab bar and touch keyboard on the bottom - -The handheld runs no shell and owns no terminal state. A companion daemon on -the Mac owns the PTYs and one authoritative -[libghostty](https://github.com/ghostty-org/ghostty) core per session, resolves -every SGR sequence to concrete RGB, and sends the console rows of coloured -cells. The console sends keys back. That split is what lets a handheld from 2011 -sit in front of a modern terminal program — including one whose UI is mostly -box drawing and CJK. - -Built on [PocketJS](https://github.com/pocket-stack/pocketjs): the guest is a -Solid application compiled to a native package, and the same guest code draws -the mirror windows on macOS and Linux. - -## How it works - -```text -Mac (host/serve.ts, Node) 3DS (app/, PocketJS) -├─ node-pty one PTY per session ├─ Solid UI at 60 Hz, 57×17 cells -├─ libghostty WASM the authoritative ├─ passive replica: applies row -│ screen + scrollback │ diffs, never interprets escapes -├─ resolves SGR → 0xRRGGBB runs ├─ touch keyboard, one contact -├─ bakes glyphs the device lacks into ├─ tabs: SELECT new, L/R switch, -│ spare font slots, streams them │ long-press-and-slide to close -└─ opens one mirror window per session └─ hold ZL for Ctrl (New 3DS) - │ │ - └────────── SVC WIRE (PKNT) over TCP ──────┘ - UDP 8621 beacon carries the TCP port +```sh +git clone --recursive https://github.com/pocket-stack/pocket-term +cd pocket-term +bun run setup +bun run 3ds +bun run mirror ``` -**The console is a replica, not a client.** On attach it gets a full snapshot; -after that, ordered row diffs behind a `gen`/`seq` fence. A replica that misses -a sequence asks for a resync rather than guessing, so a dropped frame can never -leave the screen subtly wrong — the failure mode of a terminal that reconnects -into the middle of a stream. +Keep `~/.cargo/bin` on `PATH`. `mirror` builds the optional Mac window; use +`--no-mirror` when starting the daemon if you do not want desktop windows. -**Glyphs arrive at runtime.** The app ships baked Latin and the full -U+2500–U+259F box and block ranges. Anything else a session prints — CJK, -`⏺`, `⎿` — is rasterized on the Mac into a font atlas for one of the device's -spare slots and streamed over the same wire, advances rewritten so a -double-width character lands on two columns. +Start ftpd on the console, then replace the example IP with the address +shown by ftpd: -**One window per session on the Mac.** Each is the mirror guest (`mirror/`) -running on the stock PocketJS desktop host, attached to the same daemon over -the same protocol as the console. It is a second replica, not a second -implementation: `app/grid.tsx` draws both. Typing into it goes to the same PTY. +```sh +bun run deploy --host 192.168.8.102 +``` -The Pocket Term mirror window on macOS +The deploy command installs `dist/3ds/pocketterm-main.3dsx` at +`/3DS/pocketterm-main.3dsx`, provisions this app's offload key, backs up the +previous launcher, and downloads the installed files to verify their bytes. +The default FTP port is 5000; use `--ftp-port` if yours differs. -## The keyboard +**Exit ftpd and launch Pocket Term from Homebrew Launcher.** Start the Mac +companion with the same console address: -The Pocket Term touch keyboard: key caps set into dark sockets +```sh +bun run daemon --device 192.168.8.102 +``` -The bottom screen is a resistive panel that reports one contact, so the -keyboard is built around the phone conventions that work with a single finger: -one-shot Shift and Ctrl, a layer key for symbols, and an action strip for the -keys a terminal needs (Esc, Tab, ^C, arrows, paging). Keys are caps set into -sockets; pressing sinks the cap and turns its light around. +Keep the companion running while using the terminal. It starts a shell for +the first device connection and opens a mirror for each session when the +mirror build is available. Useful options are: + +| Option | Purpose | +| --- | --- | +| `--cwd /path/to/project` | Working directory for new shells | +| `--shell /bin/zsh` | Shell executable | +| `--no-login` | Start the shell without login mode | +| `--no-mirror` | Use the handheld without opening Mac windows | +| `--name name` | Companion name | +| `--key /path/to/key` | Pairing key; defaults to `.pocket/offload.key` | +| `--trace` | Log command kinds and session changes, without typed text | + +`--unicast` is an alias for `--device`. The pairing key belongs to this app; +keep the Mac's `.pocket/offload.key` when updating an existing installation. + +**A Wi-Fi or provider reconnect preserves PTYs while the Node worker remains +alive. Stopping the companion ends its sessions.** Reconnect persistence is +in memory and does not restore shells after a Mac or daemon restart. + +## Controls and settings + +| Control | Action | +| --- | --- | +| A / B / X / Y | Enter / Backspace / Tab / Space | +| D-pad | Terminal cursor arrows, with repeat | +| Circle Pad / touchpad drag and flick | Scroll local history with inertia | +| Right nub, where supported | Repeating editor cursor arrows | +| START | Ctrl-C | +| SELECT / touch `+` | Open a session | +| L / R | Previous / next session | +| Touch a tab | Attach to that session | +| Hold a tab, slide down, release | Close the selected session | +| Touch page arrows | Page through session tabs | +| Hold ZL, where supported | Ctrl modifier | +| `?123` → `#{~` → `F1+` | Symbols, function keys and navigation keys | +| Keyboard `settings` key | Choose font, typing preview and scroll speed | +| L + R + START | Return to Homebrew Launcher | + +The touch keyboard provides Shift, Ctrl and Alt. The hardware arrows replace +touch arrow keys. Mac mirrors accept keyboard input and paste into their +assigned session; session creation, closing and switching belong to the 3DS. + +Current lower-screen settings: Spleentt, Spleen and Fusion Pixel fonts, Auto typing preview and Fast scroll speed + +Spleentt is the default face, padded to a 5×10 cell. Settings apply during the +current app run. Preview can be disabled; it does not predict wrapping, wide +glyphs, completion or control-key actions. Its 350ms expiry and two-second +confidence window use **PocketJS virtual time**, so pausing frame progression +also pauses these display deadlines. See [prediction and input delivery](docs/RESPONSIVENESS.md) +for the confirmation rules and measured queue behavior. + +## Architecture -Hardware buttons cover what you reach for most: **A** Enter, **B** Backspace, -**X** Tab, **Y** Space, **START** ^C, **SELECT** a new session, **L/R** switch -sessions, the d-pad scrolls history, and **ZL held** is Ctrl (ZL comes from -`ir:rst`, so it needs a New 3DS or a Circle Pad Pro). +```text +3DS UI and bounded row cache + │ paired PocketJS io.offload + ▼ +Bun provider worker ── authenticated loopback ── Node terminal worker + ├─ PTYs + libghostty + ├─ session and history registry + ├─ dynamic glyph rasterization + └─ session-specific Mac mirrors +``` -## Requirements +**The Node worker owns terminal state; the 3DS owns presentation and input.** +The handheld does not spawn processes, parse terminal escape sequences or +rasterize outline fonts. PocketJS delivers asynchronous results at frame +boundaries. Complete grid generations commit together; historical rows are +published into a bounded cache with a separate per-frame work limit. -- A 3DS running the Homebrew Launcher, on the same network as the Mac -- [Bun](https://bun.sh) and **Node ≥ 23.6** (the daemon runs under Node: Bun's - `node-pty` spawn helper hangs on macOS 26) -- Docker, for the devkitARM half of the 3DS toolchain (fetched on first build) -- The PocketJS checkout comes with the repository as `vendor/pocketjs` +Three independent request paths keep input responsive: -## Quick start +| Request | Contract | +| --- | --- | +| `term.input` | Ordered input batches with command ids; lost replies retry the same ids | +| `term.exchange` | Live grid and glyph fragments retained until acknowledged | +| `term.history.batch` | Up to 16 historical rows per request, with two requests in flight | -```sh -git clone --recursive https://github.com/pocket-stack/pocket-term -cd pocket-term -bun run setup # vendor install, runtime links, daemon deps +An output reply cannot hold the input ticket. Cache keys include the session, +history epoch and absolute row; reset, pruning and alternate-screen transitions +fence stale results. A changed replica epoch discards uncertain input and +requests a fresh screen. Already cached history remains readable while offline. -bun run 3ds # → dist/3ds/pocketterm-main.3dsx -# copy it to the SD card under /3DS/ and launch it from the Homebrew Launcher +The guest retains up to **192 historical rows** and **64 queued commands**. +Paste is limited to 8,192 UTF-16 code units. These bounds and the pinned +runtime's one-record-per-frame delivery limit are documented in +[cache synchronization](docs/SCROLLBACK.md) and +[history throughput](docs/HISTORY-THROUGHPUT.md). They do not imply a physical +Wi-Fi throughput or a guaranteed frame rate. -bun run daemon # the Mac side: PTYs, terminal cores, beacon -``` +The provider ownership follows [Pocket Doc](https://github.com/pocket-stack/pocket-doc). +Public PocketJS APIs own runtime, input and host operations; Solid owns +reactivity. Product protocol and budgets live in `shared/`, the handheld in +`app/`, and terminal capabilities in `host/`. Mac mirror listeners bind only +to loopback; LAN terminal access uses paired offload. -The console discovers the daemon by its UDP beacon and connects. The daemon -takes `--port`, `--beacon-port`, `--name`, `--shell`, `--no-mirror` and -`--unicast ` — the last beacons directly at a console on a network that -eats broadcast. +## Updating and troubleshooting -To iterate on the app without reflashing, pair once while the console is -running ftpd, then hot-push the guest package: +Pull the latest main, update the submodule, run setup and rebuild: ```sh -bun run pair --host 192.168.8.152 # once, with ftpd open on the console -bun run push --host 192.168.8.152 # rebuild + push; the app keeps running -bun run probe --host 192.168.8.152 # status, stats, tree, screenshot +git pull --ff-only +git submodule update --init +bun run setup +bun run 3ds +bun run mirror ``` -A change under `vendor/pocketjs/hosts/3ds` is native and needs `bun run 3ds` -plus a reflash; everything in `app/` and `mirror/` is a hot push. +Then exit Pocket Term, start ftpd, and run `deploy` again. **The offload +launcher boots its embedded package**, so updates require replacing the +`.3dsx`. The old `push` and `probe` commands apply to the earlier svc launcher. +Upgrade the guest and companion together when the protocol changes; current +builds use **protocol 6**. The old svc key does not replace the offload key. -## Layout +If the device stays disconnected, check that ftpd has exited, the companion +uses the console's current IP, and `.pocket/offload.key` matches the deployed +key. Return to HBL with L + R + START before transferring another build. +The app's runtime storage is isolated under +`/pocketjs/runtime/apps/22a222ca7b6bddb1/`. -```text -app/ the console guest — grid, tabs, touch keyboard, replica store -mirror/ the desktop guest — the same grid, one window per session -host/ the Mac daemon — PTYs, libghostty cores, glyph baking, the wire -test/ protocol, run-building and glyph-routing tests (bun run check) -scripts/ build and device commands over the vendored PocketJS toolchain -``` - -`app/protocol.ts` is the contract both sides read: message shapes, the run -encoding, and which codepoints the device can already draw. +## Development and validation -## Status +```sh +bun run check # guest/host types and terminal unit tests +bun run test:pty # actual providers, macOS PTYs and VT behavior +bun scripts/font.ts --check # reproduce all three shipped bitmap atlases +bun run visual --showcase # build the documentation capture fixture +bun run visual --showcase --settings +bun run visual --history --motion +bun run visual --preview +``` -`vendor/pocketjs` pins merged PocketJS main at `10aee589`. It includes the -3DS companion transport, per-app recovery storage and the current HBL icon. -**The build embeds independently drawn 24×24 and 48×48 icons**, using the -same assets as Pocket Doc. Its runtime state lives under -`/pocketjs/runtime/apps/22a222ca7b6bddb1/`; hold **L+R+START** to return to HBL. +Visual builds use a separate app id and `pocketterm-qa.3dsx` launcher. The +[screen captures](docs/screenshots/README.md) use the native renderer with +controlled data; `test:pty` separately exercises real providers, shells, +reconnects, history and saved Vim/Nano edits. -See [runtime upgrade validation](docs/RUNTIME-UPGRADE.md) for the checks and -deployment receipt for this pin. +The protocol-6 implementation has been accepted for merge. Reproducible +checks, deployment hashes and the revision history are recorded in +[validation](docs/OFFLOAD-UPGRADE.md). Simulated latency and emulator captures +remain distinct from physical timing measurements. ## License -MIT. PocketJS is a separate project under its own license, and libghostty -arrives through [`@wterm/ghostty`](https://www.npmjs.com/package/@wterm/ghostty) -under its own. +MIT. PocketJS, libghostty and the fonts retain their own licenses. +[Font sources](assets/fonts/README.md) records pinned upstream versions, +licenses and original-byte hashes. BDF sources are stored as lossless gzip +archives and decoded by build tools and the Mac host. diff --git a/app/app.tsx b/app/app.tsx index 75b60a1..3f91d10 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,47 +1,19 @@ -// app/app.tsx — a remote terminal multiplexer for the 3ds-dev host. -// -// The Mac companion (host/serve.ts) owns the PTYs and an authoritative -// terminal state machine per session; this app is a passive replica in the -// zhongduan sense: attach delivers a full cell-grid snapshot, then ordered -// row diffs. The two screens split the terminal the way the contacts demo -// split the phone app: the top screen is the grid (grid.tsx, shared with the -// desktop mirror window) and the touch screen holds the session tabs and the -// keyboard. -// -// Physical controls: D-pad = arrow keys (with repeat), A = Enter, -// B = Backspace, X = Tab, Y = Space, START = Ctrl-C, SELECT = new session, -// circle pad = scrollback. -// -// ZL is Ctrl: hold it and every key that follows carries the control -// modifier, with the on-screen keyboard lighting its Ctrl cap so the state is -// visible where the operator is already looking. ZL is New-3DS-only and -// reaches the guest through ir:rst rather than the ordinary HID pad -// (hosts/3ds/src/input.c); a console without it uses the keyboard's own Ctrl -// cap, which arms for one key. -// -// L and R step between sessions, and that is all they do. They briefly -// doubled as the modifier, which meant discriminating a tap from a hold — -// and a shoulder that only switches when released is a shoulder that feels -// broken. One button, one job. +// 3DS terminal: the full primary surface is a grid; the auxiliary surface +// owns session navigation, connection status and incremental keyboard input. -import { createSignal, For, Show } from "solid-js"; +import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"; import { AuxiliarySurface, Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { createGesture } from "@pocketjs/framework/gesture"; -import { analogY, onFrame } from "@pocketjs/framework/lifecycle"; +import { analogY, rightAnalogX, rightAnalogY, onFrame } from "@pocketjs/framework/lifecycle"; import { BTN } from "@pocketjs/framework/input"; -import { getOps } from "@pocketjs/framework"; import { TermGrid } from "./grid.tsx"; import { KB_H, Keyboard } from "./keyboard.tsx"; -import { connectSvc } from "./svc.ts"; -import { createTermStore } from "./store.ts"; - -/* Grid geometry. The 12 px mono atlas is slot 16 (spec MONO_FONT_PX); - * `font-mono text-xs` in grid.tsx is what makes the build bake it. The - * natural advance (~7.2 px) is snapped down to a 7 px integer cell with - * negative tracking so every column lands on a pixel. */ -const MONO_SLOT = 16; -const STATUS_H = 14; -const CELL_H = 13; +import { connectTermOffload } from "./offload.ts"; +import { FONT_NAMES, loadTerminalFont } from "./font.ts"; +import { TERM_LAYOUT, TABS_PER_PAGE, tabPage } from "../shared/layout.ts"; +import { createTermStore, type TermStore } from "./store.ts"; +import { createCursorStick } from "./stick.ts"; +import { TermSettings } from "./settings.tsx"; const TAB_H = 26; const TAB_W = 72; @@ -50,13 +22,6 @@ const TAB_W = 72; const TAB_NEW_W = 30; const KB_TOP = 240 - KB_H; -/** The strip between the tabs and the keyboard: two 12 px lines, centred. */ -const HINT_LINE = 14; -const HINT_TOP = Math.round((KB_TOP - TAB_H - (HINT_LINE + 12)) / 2); -const HINT_BUTTONS = "SELECT new · L/R switch · hold ZL = ctrl"; -/** 12 px regular — slot 0 of the pinned table the compiler bakes - * (framework/compiler/tailwind.ts), which is what `text-xs` draws in. */ -const HINT_SLOT = 0; /* Closing a session is a hold, then a slide, then a release — not a tap on a * small ×. The panel is resistive and single-contact: an 18 px target inside @@ -78,18 +43,22 @@ const DPAD_KEYS: readonly [number, "Up" | "Down" | "Left" | "Right"][] = [ const DPAD_DELAY = 18; const DPAD_REPEAT = 4; -export default function TermApp() { - const ops = getOps(); - const advance = ops.measureText("M", MONO_SLOT); - // What the hint occupies is what the host name cannot have. - const hintWidth = Math.ceil(ops.measureText(HINT_BUTTONS, HINT_SLOT)); - const CELL_W = advance > 0 ? Math.max(6, Math.round(advance)) : 7; - const TRACK = advance > 0 ? CELL_W - advance : 0; - const COLS = Math.floor(400 / CELL_W); - const ROWS = Math.floor((240 - STATUS_H) / CELL_H); - - const svc = connectSvc(); - const store = createTermStore({ cols: COLS, rows: ROWS, cell: [CELL_W, CELL_H] }, svc); +export default function TermApp(props: { store?: TermStore; initialSettings?: boolean } = {}) { + loadTerminalFont(); + const { cols: COLS, rows: ROWS, cellW: CELL_W, cellH: CELL_H, track: TRACK, statusH: STATUS_H } = TERM_LAYOUT; + const store = props.store ?? createTermStore({ cols: COLS, rows: ROWS, cell: [CELL_W, CELL_H] }, connectTermOffload()); + onCleanup(() => store.dispose()); + const [page, setPage] = createSignal(0); + const [fontIndex, setFontIndex] = createSignal(0); + const [settingsOpen, setSettingsOpen] = createSignal(props.initialSettings ?? false); + const [scrollSpeed, setScrollSpeed] = createSignal(1); + const [preview, setPreview] = createSignal(true); + const pageCount = () => Math.max(1, Math.ceil(store.sessions().length / TABS_PER_PAGE)); + const visibleSessions = () => store.sessions().slice(page() * TABS_PER_PAGE, (page() + 1) * TABS_PER_PAGE); + createEffect(() => { + const at = store.sessions().findIndex(s => s.sid === store.activeSid()); + setPage(tabPage(at)); + }); /** The touch keyboard's one-shot Ctrl: armed by its cap, spent by the next * key. Holding L is the other way in, and the cap lights for both. */ const [ctrlArmed, setCtrlArmed] = createSignal(false); @@ -97,15 +66,31 @@ export default function TermApp() { const ctrlActive = () => ctrlArmed() || ctrlHeld(); const dpadHeld = new Map(); - let scrollCarry = 0; + const cursorStick = createCursorStick(); - let prevButtons = 0; + let prevButtons = 0, stickVelocity = 0; onFrame((buttons) => { + // Preserve the stick's velocity on release instead of easing through + // only the final chase gap. Both touch and stick use the same fling. + const pad = analogY(); + if (!settingsOpen() && Math.abs(pad) > 0.1) { + const step = Math.sign(pad) * (Math.abs(pad) - 0.1) / 0.9 * 18 * scrollSpeed(); + if (step * stickVelocity < 0) store.history?.stop(); + stickVelocity = step * 60; store.history?.nudge(step); + } else if (stickVelocity) { + if (!settingsOpen()) { store.history?.beginDrag(); store.history?.endDrag(stickVelocity); } + stickVelocity = 0; + } store.frame(); const pressed = buttons & ~prevButtons; prevButtons = buttons; + if (settingsOpen()) { + cursorStick.reset(); dpadHeld.clear(); + if (pressed & BTN.CROSS) setSettingsOpen(false); + return; + } setCtrlHeld((buttons & BTN.ZL) !== 0); let sentThisFrame = false; @@ -134,35 +119,31 @@ export default function TermApp() { if (pressed & BTN.START) send("c", true); if (pressed & BTN.SELECT) store.newSession(); + const cursorKey = cursorStick.step(rightAnalogX(), rightAnalogY()); + if (cursorKey) send(cursorKey, ctrlActive()); + if (sentThisFrame && ctrlArmed()) setCtrlArmed(false); if (pressed & BTN.LTRIGGER) store.attachSibling(-1); if (pressed & BTN.RTRIGGER) store.attachSibling(1); - // Circle pad Y scrubs scrollback: up = into history (positive delta). - const pad = analogY(); - if (Math.abs(pad) > 0.25) { - scrollCarry += -pad * 0.5; - const lines = Math.trunc(scrollCarry); - if (lines !== 0) { - scrollCarry -= lines; - store.scroll(lines); - } - } else { - scrollCarry = 0; - } }); // Session tab strip on the touch screen: tap a tab to attach, hold one to // arm closing it, the trailing + to open one. let tabsNode: NodeMirror | undefined; + let pagesNode: NodeMirror | undefined; + createGesture({ surface: "auxiliary", region: { node: () => pagesNode }, onTap: contact => { + const count = pageCount(); + setPage(p => (p + (contact.y < 68 ? -1 : 1) + count) % count); + } }); /** The session the close bar is armed for, and how far the bar has slid. */ const [closingSid, setClosingSid] = createSignal(-1); const [closeAnim, setCloseAnim] = createSignal(0); const [overClose, setOverClose] = createSignal(false); const inCloseBar = (y: number) => y >= TAB_H && y < TAB_H + CLOSE_BAR_H; const sessionAt = (x: number) => { - const list = store.sessions(); + const list = visibleSessions(); const index = Math.floor(x / TAB_W); return index >= 0 && index < list.length ? list[index] : undefined; }; @@ -197,7 +178,7 @@ export default function TermApp() { return; } // A plain tap: the tabs, then the trailing cell that opens one. - const list = store.sessions(); + const list = visibleSessions(); const session = sessionAt(contact.x); if (session) { store.attach(session.sid); @@ -215,13 +196,23 @@ export default function TermApp() { const closingTitle = () => store.sessions().find((s) => s.sid === closingSid())?.title ?? ""; + let touchpad: NodeMirror | undefined; + const [touching, setTouching] = createSignal(false); + createGesture({ surface: "auxiliary", region: { node: () => touchpad }, axis: "y", panSlop: 2, + onDown() { if (closingSid() < 0) { setTouching(true); store.history?.beginDrag(); } }, + onPanMove(c) { if (closingSid() < 0) store.history?.drag(-c.fdy * 3 * scrollSpeed()); }, + onPanEnd(c) { setTouching(false); store.history?.endDrag(-c.vy * 3 * scrollSpeed()); }, + onTap() { setTouching(false); store.history?.endDrag(0); }, + onCancel() { setTouching(false); store.history?.stop(); }, + }); + return ( <> @@ -235,7 +226,7 @@ export default function TermApp() { class="absolute left-0 right-0 top-0 flex-row bg-[#141a24]" style={{ height: TAB_H }} > - + {(session) => ( + 1 ? 260 : 312, bgColor: touching() ? 0xff30251b : 0xff1b1611 }}> + + + + + 1}> + pagesNode = node} class="absolute left-[270] top-[30] w-[46] h-[76] rounded-[4] bg-[#141b24]"> + + + + + + { store.history?.stop(); setSettingsOpen(true); }} + onChar={(ch) => { + store.sendText(ch); + setCtrlArmed(false); + }} + onKey={(name, ctrl, alt, shift) => store.sendKey(name, ctrl || ctrlHeld(), alt, shift)} + ctrlArmed={ctrlActive} + setCtrlArmed={setCtrlArmed} + /> {/* Slides out from under the strip while a tab is held. */} 0}> - {/* Both columns are anchored to their own edge instead of sharing a - flex row. A companion's name is whatever its machine is called — - the macOS default is "evandeMacBook-Pro" — and in a row that long - name widened the left column until it pushed the hints off the - 320 px panel. Anchored, the hints cannot move, and the name is - given the width that is actually left over: measured, because the - hint beside it is the thing that decides it. */} - - - {store.conn() === "live" ? "connected" : store.conn()} - - - {store.hostName() || "—"} - - - {HINT_BUTTONS} - - - {store.dynamicGlyphs() > 0 - ? `pad scrolls · ${store.dynamicGlyphs()} runtime glyphs` - : "pad scrolls history · A ⏎ · B ⌫"} - - - - { - store.sendText(ch); - setCtrlArmed(false); - }} - onKey={(name, ctrl) => store.sendKey(name, ctrl || ctrlHeld())} - ctrlArmed={ctrlActive} - setCtrlArmed={setCtrlArmed} - /> + + { setFontIndex(n); loadTerminalFont(FONT_NAMES[n]); }} + onSpeed={setScrollSpeed} onPreview={on => { setPreview(on); store.setPreview(on); }} onClose={() => setSettingsOpen(false)} /> + diff --git a/app/channel.ts b/app/channel.ts new file mode 100644 index 0000000..2cac6b5 --- /dev/null +++ b/app/channel.ts @@ -0,0 +1,17 @@ +import type { ClientLine, HostInputLine, HostLine } from "../shared/protocol.ts"; + +export interface TermChannel { + historyIO?: import("./history.ts").HistoryIO; + /** Non-blocking transport probe — call once per frame; false while the + * companion is still being discovered (the app supplies the cadence). */ + open(): boolean; + /** Drain and parse this frame's lines (call once per frame). The queue + * carries the companion's terminal state and, on a desktop host, the + * window's own input — see HostInputLine. */ + poll(): (HostLine | HostInputLine)[]; + send(line: ClientLine): number | void; + inputPending?(): boolean; + sendBatch?(lines: ClientLine[]): boolean; + status?(): string; + dispose?(): void; +} diff --git a/app/font.generated.ts b/app/font.generated.ts new file mode 100644 index 0000000..711a376 --- /dev/null +++ b/app/font.generated.ts @@ -0,0 +1,3 @@ +// Generated by bun scripts/font.ts. See assets/fonts/README.md. +export const TERM_FONTS = {"spleen":"RENGQQMALQEFCgcKEAABACAAAAABAAUAIQAAAAIABQAiAAAAAwAFACMAAAAEAAUAJAAAAAUABQAlAAAABgAFACYAAAAHAAUAJwAAAAgABQAoAAAACQAFACkAAAAKAAUAKgAAAAsABQArAAAADAAFACwAAAANAAUALQAAAA4ABQAuAAAADwAFAC8AAAAQAAUAMAAAABEABQAxAAAAEgAFADIAAAATAAUAMwAAABQABQA0AAAAFQAFADUAAAAWAAUANgAAABcABQA3AAAAGAAFADgAAAAZAAUAOQAAABoABQA6AAAAGwAFADsAAAAcAAUAPAAAAB0ABQA9AAAAHgAFAD4AAAAfAAUAPwAAACAABQBAAAAAIQAFAEEAAAAiAAUAQgAAACMABQBDAAAAJAAFAEQAAAAlAAUARQAAACYABQBGAAAAJwAFAEcAAAAoAAUASAAAACkABQBJAAAAKgAFAEoAAAArAAUASwAAACwABQBMAAAALQAFAE0AAAAuAAUATgAAAC8ABQBPAAAAMAAFAFAAAAAxAAUAUQAAADIABQBSAAAAMwAFAFMAAAA0AAUAVAAAADUABQBVAAAANgAFAFYAAAA3AAUAVwAAADgABQBYAAAAOQAFAFkAAAA6AAUAWgAAADsABQBbAAAAPAAFAFwAAAA9AAUAXQAAAD4ABQBeAAAAPwAFAF8AAABAAAUAYAAAAEEABQBhAAAAQgAFAGIAAABDAAUAYwAAAEQABQBkAAAARQAFAGUAAABGAAUAZgAAAEcABQBnAAAASAAFAGgAAABJAAUAaQAAAEoABQBqAAAASwAFAGsAAABMAAUAbAAAAE0ABQBtAAAATgAFAG4AAABPAAUAbwAAAFAABQBwAAAAUQAFAHEAAABSAAUAcgAAAFMABQBzAAAAVAAFAHQAAABVAAUAdQAAAFYABQB2AAAAVwAFAHcAAABYAAUAeAAAAFkABQB5AAAAWgAFAHoAAABbAAUAewAAAFwABQB8AAAAXQAFAH0AAABeAAUAfgAAAF8ABQCrAAAAYAAFAKwAAABhAAUAsQAAAGIABQC3AAAAYwAFALsAAABkAAUAvAAAAGUABQC9AAAAZgAFANcAAABnAAUA9wAAAGgABQC7AwAAaQAFABMgAABqAAUAFCAAAGsABQAYIAAAbAAFABkgAABtAAUAHCAAAG4ABQAdIAAAbwAFACIgAABwAAUAJiAAAHEABQA+IAAAcgAFAJAhAABzAAUAkSEAAHQABQCSIQAAdQAFAJMhAAB2AAUAlCEAAHcABQAZIgAAeAAFAEgiAAB5AAUAYCIAAHoABQBkIgAAewAFAGUiAAB8AAUA+yMAAH0ABQAAJQAAfgAFAAElAAB/AAUAAiUAAIAABQADJQAAgQAFAAQlAACCAAUABSUAAIMABQAGJQAAhAAFAAclAACFAAUACCUAAIYABQAJJQAAhwAFAAolAACIAAUACyUAAIkABQAMJQAAigAFAA0lAACLAAUADiUAAIwABQAPJQAAjQAFABAlAACOAAUAESUAAI8ABQASJQAAkAAFABMlAACRAAUAFCUAAJIABQAVJQAAkwAFABYlAACUAAUAFyUAAJUABQAYJQAAlgAFABklAACXAAUAGiUAAJgABQAbJQAAmQAFABwlAACaAAUAHSUAAJsABQAeJQAAnAAFAB8lAACdAAUAICUAAJ4ABQAhJQAAnwAFACIlAACgAAUAIyUAAKEABQAkJQAAogAFACUlAACjAAUAJiUAAKQABQAnJQAApQAFACglAACmAAUAKSUAAKcABQAqJQAAqAAFACslAACpAAUALCUAAKoABQAtJQAAqwAFAC4lAACsAAUALyUAAK0ABQAwJQAArgAFADElAACvAAUAMiUAALAABQAzJQAAsQAFADQlAACyAAUANSUAALMABQA2JQAAtAAFADclAAC1AAUAOCUAALYABQA5JQAAtwAFADolAAC4AAUAOyUAALkABQA8JQAAugAFAD0lAAC7AAUAPiUAALwABQA/JQAAvQAFAEAlAAC+AAUAQSUAAL8ABQBCJQAAwAAFAEMlAADBAAUARCUAAMIABQBFJQAAwwAFAEYlAADEAAUARyUAAMUABQBIJQAAxgAFAEklAADHAAUASiUAAMgABQBLJQAAyQAFAEwlAADKAAUATSUAAMsABQBOJQAAzAAFAE8lAADNAAUAUCUAAM4ABQBRJQAAzwAFAFIlAADQAAUAUyUAANEABQBUJQAA0gAFAFUlAADTAAUAViUAANQABQBXJQAA1QAFAFglAADWAAUAWSUAANcABQBaJQAA2AAFAFslAADZAAUAXCUAANoABQBdJQAA2wAFAF4lAADcAAUAXyUAAN0ABQBgJQAA3gAFAGElAADfAAUAYiUAAOAABQBjJQAA4QAFAGQlAADiAAUAZSUAAOMABQBmJQAA5AAFAGclAADlAAUAaCUAAOYABQBpJQAA5wAFAGolAADoAAUAayUAAOkABQBsJQAA6gAFAG0lAADrAAUAbiUAAOwABQBvJQAA7QAFAHAlAADuAAUAcSUAAO8ABQByJQAA8AAFAHMlAADxAAUAdCUAAPIABQB1JQAA8wAFAHYlAAD0AAUAdyUAAPUABQB4JQAA9gAFAHklAAD3AAUAeiUAAPgABQB7JQAA+QAFAHwlAAD6AAUAfSUAAPsABQB+JQAA/AAFAH8lAAD9AAUAgCUAAP4ABQCBJQAA/wAFAIIlAAAAAQUAgyUAAAEBBQCEJQAAAgEFAIUlAAADAQUAhiUAAAQBBQCHJQAABQEFAIglAAAGAQUAiSUAAAcBBQCKJQAACAEFAIslAAAJAQUAjCUAAAoBBQCNJQAACwEFAI4lAAAMAQUAjyUAAA0BBQCQJQAADgEFAJElAAAPAQUAkiUAABABBQCTJQAAEQEFAJQlAAASAQUAlSUAABMBBQCWJQAAFAEFAJclAAAVAQUAmCUAABYBBQCZJQAAFwEFAJolAAAYAQUAmyUAABkBBQCcJQAAGgEFAJ0lAAAbAQUAniUAABwBBQCfJQAAHQEFAKAlAAAeAQUAoSUAAB8BBQCqJQAAIAEFAKslAAAhAQUAsiUAACIBBQC6JQAAIwEFALwlAAAkAQUAxCUAACUBBQDGJQAAJgEFAMslAAAnAQUAzyUAACgBBQAXJwAAKQEFAG4nAAAqAQUAbycAACsBBQCcJwAALAEFAP3/AAAAAAUAAAAAAAD/////AP8AAP8A/wAA/wD/AAD/AP8AAP8A/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD//////wD/AP8AAP8A/wD//////wD/AP8AAAAAAAAAAAAAAAAAAAAAAAD/AAAA////AP8A/wAAAP//AAAAAP//AAAA//8A////AAAAAP8AAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wD/AAAAAP8AAAD/AAAAAP8A/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA/wAAAP8A/wAA/wD/AAD//wAA/wD/AP//AAD/AAD//wD/AAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAP8AAAAAAP8AAAAAAAAAAAAAAAD/AAAAAAD/AAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP//AAD/////AAD//wAA/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAP//////AAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP8AAAAA/wAAAP8AAAAA/wAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wD//wD//wD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP//AAAAAP8AAAAA/wAAAAD/AAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAAAAP8AAP//AAD/AAAAAP////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8AAAD/AAAAAAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wD/AAD/AP8AAP////8AAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/AAAAAP///wAAAAAA/wAAAAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAAAA////AAD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8A/wAA/wAAAAD/AAAA/wAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAD//wAA/wAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wAA////AAAAAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAP8AAAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAAA/wAAAAAA/wAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAP8AAAAAAP8AAAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAAAAP8AAAD/AAAA/wAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AP//AP8A//8A/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8A/////wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP8AAP8A////AAD/AAD/AP8AAP8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAD/AAAAAP8AAAAA/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP8AAP8A/wAA/wD/AAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAAAA////AAD/AAAAAP8AAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAD/AAAAAP///wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wD/AAAAAP8A//8A/wAA/wD/AAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/////wD/AAD/AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAAD/AAAAAP8AAAAA/wAAAAD/AAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAP8AAAAA/wAAAAD/AAAAAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A////AAD/AAD/AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/////AP////8A/wAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP//AP8A//8A/wD/AP//AP8A//8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AAD/AP8AAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP8AAP8A////AAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wD/AAD/AP8AAP8AAP//AAAAAP//AAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP///wAA/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wD/AAAAAAD//wAAAAAA/wAAAAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8A/wAA/wAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wAA/wD/////AP////8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wAA//8AAAD//wAA/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8AAP///wAAAAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAP8AAAD/AAAA/wAAAP8AAAAA/////wAAAAAAAAAAAAAAAAAAAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD///8AAAAAAAAAAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAAAAAAAAAAAP///wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAA////AAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8A/wD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAP8AAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAAD/AAD///8A/wAA/wAA////AAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD///8AAP8AAP8A/wAA/wD/AAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAD/AAAAAP8AAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAP///wD/AAD/AP8AAP8A/wAA/wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAP8A/////wD/AAAAAAD///8AAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAAAAD/AAAA////AAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wD/AAD/AP8AAP8AAP//AAAAAAD/AP///wAAAAAAAAAAAAAAAP8AAAAA/wAAAAD///8AAP8AAP8A/wAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAP//AAAAAP8AAAAA/wAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAP//AAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAD/AP8A/wAA//8AAAD/AP8AAP8AAP8AAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/////AP////8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP8AAP8A/wAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP8AAP8A////AAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAP8A/wAA/wAA////AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAA/wD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wD/AAAAAAD//wAAAAAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAD///8AAAD/AAAAAP8AAAAA/wAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AP8AAP8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8AAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/////wD/////AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP//AAAA//8AAP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8AAP///wAAAAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAP8AAAD/AAAA/wAAAP////8AAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAAAAD/AAAA//8AAAD//wAAAAD/AAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAP//AAAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP8AAAAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP//AP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wAAAAD/AAAA////AAD/AP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP//////AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA////AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wD//////wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAP///wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD/AAAA//////8A/wAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAA/wD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAD///8AAAD/AAAA////AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD//wAAAP8AAAAAAP//AAAAAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAD/AAD//wAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAD/AP8AAP8A/wAA/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP8AAAAA/wAAAAD/AAAAAP8AAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD//////wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAP//////AAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAP///wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAAAA////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAAAAD///8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD///8AAAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP///wAAAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA////AAAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAAAP//AAAA//8AAAD/AP8A/wAA/wD/AAAA/wAAAAD/AAAA/wD/AAD/AP8A/wAAAP//AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////////////////wAAAAAAAAAAAAD/////////////////////////////////////////////////////AAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAAA/wAA/wAA/wAAAP8AAP8AAP8AAAD/AAD/AAD/AAAA/wAA/wAA/wAAAP8AAP8AAP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8AAP///wD//wD//wD///8A//8A//8A////AP//AP//AP///wD//wD//wD///8A//8A/////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//////wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP///wAA////AAD///8AAP////////////////////////////////////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////////8AAP///wAA////AAD///8AAP///wAA/////////////////////////////////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////8AAAAAAAAAAAAA/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA//8AAAD///8A/////wD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAP///wAA////AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP///wD//////wD///8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAAAAAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AP////8A/////wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP///wAA//8AAP//AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD/AAAA//8AAAD//wAAAAD/AAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAP8AAAAA/wAAAAD//wAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD//////wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAA=","spleentt":"RENGQQMALQEFCgcKEAABACAAAAABAAUAIQAAAAIABQAiAAAAAwAFACMAAAAEAAUAJAAAAAUABQAlAAAABgAFACYAAAAHAAUAJwAAAAgABQAoAAAACQAFACkAAAAKAAUAKgAAAAsABQArAAAADAAFACwAAAANAAUALQAAAA4ABQAuAAAADwAFAC8AAAAQAAUAMAAAABEABQAxAAAAEgAFADIAAAATAAUAMwAAABQABQA0AAAAFQAFADUAAAAWAAUANgAAABcABQA3AAAAGAAFADgAAAAZAAUAOQAAABoABQA6AAAAGwAFADsAAAAcAAUAPAAAAB0ABQA9AAAAHgAFAD4AAAAfAAUAPwAAACAABQBAAAAAIQAFAEEAAAAiAAUAQgAAACMABQBDAAAAJAAFAEQAAAAlAAUARQAAACYABQBGAAAAJwAFAEcAAAAoAAUASAAAACkABQBJAAAAKgAFAEoAAAArAAUASwAAACwABQBMAAAALQAFAE0AAAAuAAUATgAAAC8ABQBPAAAAMAAFAFAAAAAxAAUAUQAAADIABQBSAAAAMwAFAFMAAAA0AAUAVAAAADUABQBVAAAANgAFAFYAAAA3AAUAVwAAADgABQBYAAAAOQAFAFkAAAA6AAUAWgAAADsABQBbAAAAPAAFAFwAAAA9AAUAXQAAAD4ABQBeAAAAPwAFAF8AAABAAAUAYAAAAEEABQBhAAAAQgAFAGIAAABDAAUAYwAAAEQABQBkAAAARQAFAGUAAABGAAUAZgAAAEcABQBnAAAASAAFAGgAAABJAAUAaQAAAEoABQBqAAAASwAFAGsAAABMAAUAbAAAAE0ABQBtAAAATgAFAG4AAABPAAUAbwAAAFAABQBwAAAAUQAFAHEAAABSAAUAcgAAAFMABQBzAAAAVAAFAHQAAABVAAUAdQAAAFYABQB2AAAAVwAFAHcAAABYAAUAeAAAAFkABQB5AAAAWgAFAHoAAABbAAUAewAAAFwABQB8AAAAXQAFAH0AAABeAAUAfgAAAF8ABQCrAAAAYAAFAKwAAABhAAUAsQAAAGIABQC3AAAAYwAFALsAAABkAAUAvAAAAGUABQC9AAAAZgAFANcAAABnAAUA9wAAAGgABQC7AwAAaQAFABMgAABqAAUAFCAAAGsABQAYIAAAbAAFABkgAABtAAUAHCAAAG4ABQAdIAAAbwAFACIgAABwAAUAJiAAAHEABQA+IAAAcgAFAJAhAABzAAUAkSEAAHQABQCSIQAAdQAFAJMhAAB2AAUAlCEAAHcABQAZIgAAeAAFAEgiAAB5AAUAYCIAAHoABQBkIgAAewAFAGUiAAB8AAUA+yMAAH0ABQAAJQAAfgAFAAElAAB/AAUAAiUAAIAABQADJQAAgQAFAAQlAACCAAUABSUAAIMABQAGJQAAhAAFAAclAACFAAUACCUAAIYABQAJJQAAhwAFAAolAACIAAUACyUAAIkABQAMJQAAigAFAA0lAACLAAUADiUAAIwABQAPJQAAjQAFABAlAACOAAUAESUAAI8ABQASJQAAkAAFABMlAACRAAUAFCUAAJIABQAVJQAAkwAFABYlAACUAAUAFyUAAJUABQAYJQAAlgAFABklAACXAAUAGiUAAJgABQAbJQAAmQAFABwlAACaAAUAHSUAAJsABQAeJQAAnAAFAB8lAACdAAUAICUAAJ4ABQAhJQAAnwAFACIlAACgAAUAIyUAAKEABQAkJQAAogAFACUlAACjAAUAJiUAAKQABQAnJQAApQAFACglAACmAAUAKSUAAKcABQAqJQAAqAAFACslAACpAAUALCUAAKoABQAtJQAAqwAFAC4lAACsAAUALyUAAK0ABQAwJQAArgAFADElAACvAAUAMiUAALAABQAzJQAAsQAFADQlAACyAAUANSUAALMABQA2JQAAtAAFADclAAC1AAUAOCUAALYABQA5JQAAtwAFADolAAC4AAUAOyUAALkABQA8JQAAugAFAD0lAAC7AAUAPiUAALwABQA/JQAAvQAFAEAlAAC+AAUAQSUAAL8ABQBCJQAAwAAFAEMlAADBAAUARCUAAMIABQBFJQAAwwAFAEYlAADEAAUARyUAAMUABQBIJQAAxgAFAEklAADHAAUASiUAAMgABQBLJQAAyQAFAEwlAADKAAUATSUAAMsABQBOJQAAzAAFAE8lAADNAAUAUCUAAM4ABQBRJQAAzwAFAFIlAADQAAUAUyUAANEABQBUJQAA0gAFAFUlAADTAAUAViUAANQABQBXJQAA1QAFAFglAADWAAUAWSUAANcABQBaJQAA2AAFAFslAADZAAUAXCUAANoABQBdJQAA2wAFAF4lAADcAAUAXyUAAN0ABQBgJQAA3gAFAGElAADfAAUAYiUAAOAABQBjJQAA4QAFAGQlAADiAAUAZSUAAOMABQBmJQAA5AAFAGclAADlAAUAaCUAAOYABQBpJQAA5wAFAGolAADoAAUAayUAAOkABQBsJQAA6gAFAG0lAADrAAUAbiUAAOwABQBvJQAA7QAFAHAlAADuAAUAcSUAAO8ABQByJQAA8AAFAHMlAADxAAUAdCUAAPIABQB1JQAA8wAFAHYlAAD0AAUAdyUAAPUABQB4JQAA9gAFAHklAAD3AAUAeiUAAPgABQB7JQAA+QAFAHwlAAD6AAUAfSUAAPsABQB+JQAA/AAFAH8lAAD9AAUAgCUAAP4ABQCBJQAA/wAFAIIlAAAAAQUAgyUAAAEBBQCEJQAAAgEFAIUlAAADAQUAhiUAAAQBBQCHJQAABQEFAIglAAAGAQUAiSUAAAcBBQCKJQAACAEFAIslAAAJAQUAjCUAAAoBBQCNJQAACwEFAI4lAAAMAQUAjyUAAA0BBQCQJQAADgEFAJElAAAPAQUAkiUAABABBQCTJQAAEQEFAJQlAAASAQUAlSUAABMBBQCWJQAAFAEFAJclAAAVAQUAmCUAABYBBQCZJQAAFwEFAJolAAAYAQUAmyUAABkBBQCcJQAAGgEFAJ0lAAAbAQUAniUAABwBBQCfJQAAHQEFAKAlAAAeAQUAoSUAAB8BBQCqJQAAIAEFAKslAAAhAQUAsiUAACIBBQC6JQAAIwEFALwlAAAkAQUAxCUAACUBBQDGJQAAJgEFAMslAAAnAQUAzyUAACgBBQAXJwAAKQEFAG4nAAAqAQUAbycAACsBBQCcJwAALAEFAP3/AAAAAAUAAAAAAAD/////AP8AAP8A/wAA/wD/AAD/AP8AAP8A/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8A//////8A/wD/AAD/AP8A//////8A/wD/AAAAAAAAAAAAAAAAAAAAAAAA/wAAAP///wD/AAAAAAD//wAAAAAA/wD///8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AP8AAAD/AAAA/wAAAP8A//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wD/AAAA/wAAAP8A/wAA/wD/AAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAAA/wAAAAD/AAAAAAD/AAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wAAAAAA/wAAAAD/AAAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AAD//wAA/////wAA//8AAP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAD//////wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP8AAAAA/wAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8A//8A//8A/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD//wAAAAD/AAAAAP8AAAAA/wAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wAAAAD/AAD//wAA/wAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAAA/wAAAAAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8A/wAA/wD/AAD/////AAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8A/wAAAAD///8AAAAAAP8AAAAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAAAAP///wAA/wAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wAA//8AAP8AAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8AAP///wAAAAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAAAA/wAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAP////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAP8AAAAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAAA/wAAAP8AAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wD//wD/AP//AP8AAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AAD/AP////8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP///wAA/wAA/wD/AAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAAAA/wAAAAD/AAAAAP8AAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP8AAP8A/wAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/AAAAAP///wAA/wAAAAD/AAAAAP////8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AP8AAAAA////AAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAD/AP//AP8AAP8A/wAA/wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP////8A/wAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAA/wAAAAD/AAAAAP8AAAAA/wAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAAD/AAAAAP8AAAAA/wAAAAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AP8AAP//AAAA/wD/AAD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/////wD/////AP8AAP8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD//wD/AP//AP8A/wD//wD/AP//AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP///wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8A/wAA/wD/AAD/AAD//wAAAAD//wAAAAAAAAAAAAAAAAAAAAD///8AAP8AAP8A/wAA/wD///8AAP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAAA//8AAAAAAP8AAAAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AP8AAP8AAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8A/////wD/////AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8AAP//AAAA//8AAP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAD/AAAA/wAAAP8AAAD/AAAAAP////8AAAAAAAAAAAAAAAAAAAAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAD//wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAAAAAAAAAAAAAAD/AAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAAAA/wAA////AP8AAP8AAP///wAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA////AAD/AAD/AP8AAP8A/wAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAAAAD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAD///8A/wAA/wD/AAD/AP8AAP8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP////8A/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAAAA/wAAAP///wAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAA/wD/AAD/AAD///8AAAAA/wD///8AAAAAAAAAAAAAAAD/AAAAAP8AAAAA////AAD/AAD/AP8AAP8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAAAD//wAAAAD/AAAAAP8AAAAA/wAAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAAAD//wAAAAD/AAAAAP8AAAAA/wAAAAD/AAD//wAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAA/wD/AP8AAP//AAAA/wD/AAD/AAD/AAAAAAAAAAAAAAAAAAAAAAD//wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/////wD/////AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD//wD/AP8AAP8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP///wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wD/AAD/AP8AAP8AAP///wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP//AP//AAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAAAAAA//8AAAAAAP8A////AAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAA////AAAA/wAAAAD/AAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wAA/wD/AAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP////8A/////wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AAD//wAAAP//AAD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AAD///8AAAAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAD/AAD//wAA/wAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAAA/wAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAA/wAAAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8A/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wAAAAD/AAAA////AAD/AP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP//////AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA////AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wD//////wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAP///wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD/AAAA//////8A/wAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAA/wD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAD///8AAAD/AAAA////AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD//wAAAP8AAAAAAP//AAAAAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAD/AAD//wAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAD/AP8AAP8A/wAA/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP8AAAAA/wAAAAD/AAAAAP8AAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD//////wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAP//////AAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAP///wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAAAA////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAAAAD///8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD///8AAAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP///wAAAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA////AAAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAAAP//AAAA//8AAAD/AP8A/wAA/wD/AAAA/wAAAAD/AAAA/wD/AAD/AP8A/wAAAP//AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////////////////wAAAAAAAAAAAAD/////////////////////////////////////////////////////AAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAAA/wAA/wAA/wAAAP8AAP8AAP8AAAD/AAD/AAD/AAAA/wAA/wAA/wAAAP8AAP8AAP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8AAP///wD//wD//wD///8A//8A//8A////AP//AP//AP///wD//wD//wD///8A//8A/////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//////wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP///wAA////AAD///8AAP////////////////////////////////////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////////8AAP///wAA////AAD///8AAP///wAA/////////////////////////////////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////8AAAAAAAAAAAAA/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA//8AAAD///8A/////wD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAP///wAA////AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD///8A//////8A////AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAAAAAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AP////8A/////wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP///wAA//8AAP//AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD/AAAA//8AAAD//wAAAAD/AAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAP8AAAAA/wAAAAD//wAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD//////wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAA=","fusion":"RENGQQMALQEFCgcKEAABACAAAAABAAUAIQAAAAIABQAiAAAAAwAFACMAAAAEAAUAJAAAAAUABQAlAAAABgAFACYAAAAHAAUAJwAAAAgABQAoAAAACQAFACkAAAAKAAUAKgAAAAsABQArAAAADAAFACwAAAANAAUALQAAAA4ABQAuAAAADwAFAC8AAAAQAAUAMAAAABEABQAxAAAAEgAFADIAAAATAAUAMwAAABQABQA0AAAAFQAFADUAAAAWAAUANgAAABcABQA3AAAAGAAFADgAAAAZAAUAOQAAABoABQA6AAAAGwAFADsAAAAcAAUAPAAAAB0ABQA9AAAAHgAFAD4AAAAfAAUAPwAAACAABQBAAAAAIQAFAEEAAAAiAAUAQgAAACMABQBDAAAAJAAFAEQAAAAlAAUARQAAACYABQBGAAAAJwAFAEcAAAAoAAUASAAAACkABQBJAAAAKgAFAEoAAAArAAUASwAAACwABQBMAAAALQAFAE0AAAAuAAUATgAAAC8ABQBPAAAAMAAFAFAAAAAxAAUAUQAAADIABQBSAAAAMwAFAFMAAAA0AAUAVAAAADUABQBVAAAANgAFAFYAAAA3AAUAVwAAADgABQBYAAAAOQAFAFkAAAA6AAUAWgAAADsABQBbAAAAPAAFAFwAAAA9AAUAXQAAAD4ABQBeAAAAPwAFAF8AAABAAAUAYAAAAEEABQBhAAAAQgAFAGIAAABDAAUAYwAAAEQABQBkAAAARQAFAGUAAABGAAUAZgAAAEcABQBnAAAASAAFAGgAAABJAAUAaQAAAEoABQBqAAAASwAFAGsAAABMAAUAbAAAAE0ABQBtAAAATgAFAG4AAABPAAUAbwAAAFAABQBwAAAAUQAFAHEAAABSAAUAcgAAAFMABQBzAAAAVAAFAHQAAABVAAUAdQAAAFYABQB2AAAAVwAFAHcAAABYAAUAeAAAAFkABQB5AAAAWgAFAHoAAABbAAUAewAAAFwABQB8AAAAXQAFAH0AAABeAAUAfgAAAF8ABQCrAAAAYAAFAKwAAABhAAUAsQAAAGIABQC3AAAAYwAFALsAAABkAAUAvAAAAGUABQC9AAAAZgAFANcAAABnAAUA9wAAAGgABQC7AwAAaQAFABMgAABqAAUAFCAAAGsABQAYIAAAbAAFABkgAABtAAUAHCAAAG4ABQAdIAAAbwAFACIgAABwAAUAJiAAAHEABQA+IAAAcgAFAJAhAABzAAUAkSEAAHQABQCSIQAAdQAFAJMhAAB2AAUAlCEAAHcABQAZIgAAeAAFAEgiAAB5AAUAYCIAAHoABQBkIgAAewAFAGUiAAB8AAUA+yMAAH0ABQAAJQAAfgAFAAElAAB/AAUAAiUAAIAABQADJQAAgQAFAAQlAACCAAUABSUAAIMABQAGJQAAhAAFAAclAACFAAUACCUAAIYABQAJJQAAhwAFAAolAACIAAUACyUAAIkABQAMJQAAigAFAA0lAACLAAUADiUAAIwABQAPJQAAjQAFABAlAACOAAUAESUAAI8ABQASJQAAkAAFABMlAACRAAUAFCUAAJIABQAVJQAAkwAFABYlAACUAAUAFyUAAJUABQAYJQAAlgAFABklAACXAAUAGiUAAJgABQAbJQAAmQAFABwlAACaAAUAHSUAAJsABQAeJQAAnAAFAB8lAACdAAUAICUAAJ4ABQAhJQAAnwAFACIlAACgAAUAIyUAAKEABQAkJQAAogAFACUlAACjAAUAJiUAAKQABQAnJQAApQAFACglAACmAAUAKSUAAKcABQAqJQAAqAAFACslAACpAAUALCUAAKoABQAtJQAAqwAFAC4lAACsAAUALyUAAK0ABQAwJQAArgAFADElAACvAAUAMiUAALAABQAzJQAAsQAFADQlAACyAAUANSUAALMABQA2JQAAtAAFADclAAC1AAUAOCUAALYABQA5JQAAtwAFADolAAC4AAUAOyUAALkABQA8JQAAugAFAD0lAAC7AAUAPiUAALwABQA/JQAAvQAFAEAlAAC+AAUAQSUAAL8ABQBCJQAAwAAFAEMlAADBAAUARCUAAMIABQBFJQAAwwAFAEYlAADEAAUARyUAAMUABQBIJQAAxgAFAEklAADHAAUASiUAAMgABQBLJQAAyQAFAEwlAADKAAUATSUAAMsABQBOJQAAzAAFAE8lAADNAAUAUCUAAM4ABQBRJQAAzwAFAFIlAADQAAUAUyUAANEABQBUJQAA0gAFAFUlAADTAAUAViUAANQABQBXJQAA1QAFAFglAADWAAUAWSUAANcABQBaJQAA2AAFAFslAADZAAUAXCUAANoABQBdJQAA2wAFAF4lAADcAAUAXyUAAN0ABQBgJQAA3gAFAGElAADfAAUAYiUAAOAABQBjJQAA4QAFAGQlAADiAAUAZSUAAOMABQBmJQAA5AAFAGclAADlAAUAaCUAAOYABQBpJQAA5wAFAGolAADoAAUAayUAAOkABQBsJQAA6gAFAG0lAADrAAUAbiUAAOwABQBvJQAA7QAFAHAlAADuAAUAcSUAAO8ABQByJQAA8AAFAHMlAADxAAUAdCUAAPIABQB1JQAA8wAFAHYlAAD0AAUAdyUAAPUABQB4JQAA9gAFAHklAAD3AAUAeiUAAPgABQB7JQAA+QAFAHwlAAD6AAUAfSUAAPsABQB+JQAA/AAFAH8lAAD9AAUAgCUAAP4ABQCBJQAA/wAFAIIlAAAAAQUAgyUAAAEBBQCEJQAAAgEFAIUlAAADAQUAhiUAAAQBBQCHJQAABQEFAIglAAAGAQUAiSUAAAcBBQCKJQAACAEFAIslAAAJAQUAjCUAAAoBBQCNJQAACwEFAI4lAAAMAQUAjyUAAA0BBQCQJQAADgEFAJElAAAPAQUAkiUAABABBQCTJQAAEQEFAJQlAAASAQUAlSUAABMBBQCWJQAAFAEFAJclAAAVAQUAmCUAABYBBQCZJQAAFwEFAJolAAAYAQUAmyUAABkBBQCcJQAAGgEFAJ0lAAAbAQUAniUAABwBBQCfJQAAHQEFAKAlAAAeAQUAoSUAAB8BBQCqJQAAIAEFAKslAAAhAQUAsiUAACIBBQC6JQAAIwEFALwlAAAkAQUAxCUAACUBBQDGJQAAJgEFAMslAAAnAQUAzyUAACgBBQAXJwAAKQEFAG4nAAAqAQUAbycAACsBBQCcJwAALAEFAP3/AAAAAAUAAAAAAAD/////AP8AAP8A/wAA/wD/AAD/AP8AAP8A/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD/////AAD/AP8A/wAA/wD/AP8AAP////8A/wD/AAAAAAAAAAAAAAAAAAD/AAAA////AP8A/wAA/wD/AAAA//8AAAAA//8AAAD//wD///8AAAAA/wAAAAAAAAAAAAAAAP8AAP8AAAAA/wAAAP8AAAAA/wAAAP8AAAD/AAAAAP8AAP8AAAAAAAAAAAAAAAAAAAAAAP8AAAD/AP8AAP8A/wAAAP8AAAD/AP//AP8A/wAAAP8A/wAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAAD/AAAA/wAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAAAP8AAAD///8AAAD/AAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAA/////wAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP8AAAAA/wAAAP8AAAAA/wAAAAD/AAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wD//wD//wD/AP8AAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP//AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAD///8AAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AAAAAP8AAAD/AAAA/wAAAP8AAAAA/////wAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8AAAAA/wAAAP8AAAAAAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8A/wAA/wD/AP8AAP8A/wAA/wD/////AAAAAP8AAAAAAAAAAAAAAAAAAAAA/////wD/AAAAAP8AAAAA////AAAAAAD/AAAAAP8A////AAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAAAAD///8AAP8AAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAP////8AAAAA/wAAAAD/AAAA/wAAAAD/AAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8AAP//AAD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wAA////AAAAAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAAAAD/AAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAAAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAD/AAAAAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8AAAAA/wAAAP8AAAAA/wAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AP//AP8A//8A/wD//wD/AAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8A/wAA/wD/////AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAD///8AAP8AAP8A/wAA/wD///8AAP8AAP8A/wAA/wD///8AAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AAAAAP8AAAAA/wAAAAD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A////AAAAAAAAAAAAAAAAAAAAAAD/////AP8AAAAA/wAAAAD///8AAP8AAAAA/wAAAAD/////AAAAAAAAAAAAAAAAAAAAAP////8A/wAAAAD/AAAAAP///wAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAAAA/wAAAAD/AP//AP8AAP8AAP///wAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wAA/wD/////AP8AAP8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAP////8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAP////8AAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wD/AAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wD/AAD//wAAAP8A/wAA/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP////8AAAAAAAAAAAAAAAAAAAAA/wAA/wD/////AP////8A/wAA/wD/AAD/AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAD/AAD/AP//AP8A//8A/wD/AP//AP8A//8A/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAA////AAD/AAD/AP8AAP8A////AAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD/AAAA/wD/AAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP///wAA/wD/AAD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAAAAAP//AAAAAAD/AP8AAP8AAP//AAAAAAAAAAAAAAAAAAAAAAD/////AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AAD//wAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8AAP8A/wAA/wD/AAD/AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8A/wAA/wD/AAD/AP////8A/////wD/AAD/AAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/AAD/AAD//wAA/wAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AAD/AP8AAP8A/wAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAD/////AAAAAP8AAAD/AAAA/wAAAAD/AAAA/wAAAAD/////AAAAAAAAAAAAAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP///wAAAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAAA////AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAP///wAAAAAAAAAAAAAAAAAA/wAAAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAA/wAAAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAAAAP8AAP///wD/AAD/AAD///8AAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP///wAA/wAA/wD/AAD/AP8AAP8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAAAA/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAA////AP8AAP8A/wAA/wD/AAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA/wAA/wD/////AP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAAA/////wAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAP8A/wAA/wAA////AAAAAP8AAP//AAAAAAAAAAAAAAAA/wAAAAD/AAAAAP///wAA/wAA/wD/AAD/AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAA////AAAAAP8AAAAA/wAAAAD/AAD/////AAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAD///8AAAAA/wAAAAD/AAAAAP8AAAAA/wAA//8AAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAP8A/wD/AAD//wAAAP8A/wAA/wAA/wAAAAAAAAAAAAAAAAAAAAD//wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP////8A/////wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAA/wD/AAD/AP8AAP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD/AAD/AP8AAP8A/wAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP8AAP8A/wAA/wD///8AAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A/wAA/wD/AAD/AAD///8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD//wD//wAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP8AAAAAAP//AAAAAAD/AP///wAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP////8AAP8AAAAA/wAAAAD/AAAAAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AP8AAP8A/wAA/wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8AAP8A/wAA/wD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wAA/wD/////AP////8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AAD/AAD//wAA/wAA/wD/AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AP8AAP8AAP8A/wAA/wD/AAAA/wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAAD/AAAA/wAAAP8AAAAA/////wAAAAAAAAAAAAAAAAD//wAA/wAAAAD/AAAAAP8AAAD/AAAAAAD/AAAAAP8AAAAA/wAAAAAA//8AAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAP//AAAAAAD/AAAAAP8AAAAA/wAAAAAA/wAAAP8AAAAA/wAAAAD/AAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD/AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8A/wD/AAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAP////8AAAD/AAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAAA/wD/AP8A/wAAAAAAAAAAAAAAAAAAAAAAAP8AAP8A/wD/AAD//wAAAAD/AAAA/wAAAP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA/wD/AP8AAP//AAAAAP//AAD/AAD//wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP8A/wAAAP8AAAD/AP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAAP////8AAAAAAAAAAP8AAAAAAAAAAAAAAAAA/wAAAAAA/wAAAAD/AAAA////AAD/AP8A/wAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAP//////AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA////AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAA/wD//////wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAP///wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wD/AAAA//////8A/wAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAA/wD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAD///8AAAD/AAAA////AAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD//wAAAP8AAAAAAP//AAAAAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAD/AAD//wAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAD/AP8AAP8A/wAA/wAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP///wAA////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAD///8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////wD/////AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////AP////8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////8A/////wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP8AAAAA/wAAAAD/AAAAAP8AAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAP//AAAA//8AAAD//wD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8A/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAD//////wAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////8AAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8A/////////////wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wD/////////////AAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAP//////AAAAAAD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAP///wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAAAA////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAAAAD///8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD///8AAAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP///wAAAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA////AAAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wAA/////wD/AP8AAP////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/////AP8A/wAA/////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP////8A/wD/AAD/////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD/////AAD/AP8A/////wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP////8AAP8A/wD/////AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A/////wAA/wD/AP////8AAP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAAAAAAAAAAAAAAAAAAAAP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAAAAAAAAAAAAAAAAAAAA//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAD/AP8AAP8A/wD//////wD/AP8A//////8A/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AP//////AP8A/wD//////wD/AP8AAP8A/wAA/wD/AAD/AP8AAP8A/wAA/wD/AAD/AP8A//////8A/wD/AP//////AP8A/wAA/wD/AAD/AP8AAP8A/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAD/AAAAAP8AAAAAAP8AAAAA/wAAAAAA/wAAAAD/AAAAAAD/AAAAAP//AAAA//8AAAD/AP8A/wAA/wD/AAAA/wAAAAD/AAAA/wD/AAD/AP8A/wAAAP//AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8AAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////////////////wAAAAAAAAAAAAD/////////////////////////////////////////////////////AAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD/////AP////8A/////wD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA////AAD///8AAP///wAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA////AAAA/wAA/wAA/wAAAP8AAP8AAP8AAAD/AAD/AAD/AAAA/wAA/wAA/wAAAP8AAP8AAP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8AAP///wD//wD//wD///8A//8A//8A////AP//AP//AP///wD//wD//wD///8A//8A/////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD//wAAAP//AAAA//8AAAD//////wAA////AAD///8AAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD///8AAP///wAA////AAD///8AAP////////////////////////////////////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////////8AAP///wAA////AAD///8AAP///wAA/////////////////////////////////wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAA//8AAAD//wAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAA//8AAAD//wAAAP//AAAA//////8AAP///wAA////AAD///8AAP///wAAAAAA//8AAAD//wAAAP//AAAA//8AAAD///////////////////////////////////8AAAAAAAAAAAAA/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAP8AAAAA/wAAAAD/AAAAAP//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA//8AAAD///8A/////wD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8AAP///wAA////AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAP////8A/////wAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAA/wAAAAAAAAAAAP8AAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AP////8A/////wAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAP8AAP///wAA//8AAP//AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAD/AAAA//8AAAD//wAAAAD/AAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAP8AAAAA/wAAAAD//wAAAP8AAAD//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wD//////wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAA="}; +export const TERM_FONT = TERM_FONTS.spleentt; diff --git a/app/font.ts b/app/font.ts new file mode 100644 index 0000000..d2a5e6b --- /dev/null +++ b/app/font.ts @@ -0,0 +1,18 @@ +import { getOps } from "@pocketjs/framework/host"; +import { TERM_FONTS } from "./font.generated.ts"; +export type TerminalFontName = keyof typeof TERM_FONTS; +export const FONT_NAMES: TerminalFontName[] = ["spleentt", "spleen", "fusion"]; +export const FONT_LABELS = { spleentt: "Spleentt", spleen: "Spleen", fusion: "Fusion Pixel" }; +export function loadTerminalFont(name: TerminalFontName = "spleentt") { + const TERM_FONT = TERM_FONTS[name]; + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const bytes = new Uint8Array(Math.floor(TERM_FONT.replace(/=+$/, "").length * 3 / 4)); + let bits = 0, count = 0, at = 0; + for (let i = 0; i < TERM_FONT.length; i++) { + const value = alphabet.indexOf(TERM_FONT[i]); + if (value < 0) break; + bits = (bits << 6) | value; count += 6; + if (count >= 8) { count -= 8; bytes[at++] = (bits >>> count) & 255; } + } + getOps().loadFontAtlas?.(bytes); +} diff --git a/app/grid.tsx b/app/grid.tsx index aebc2d5..9bf3808 100644 --- a/app/grid.tsx +++ b/app/grid.tsx @@ -10,10 +10,13 @@ // advances the companion pinned to the grid, so mixed CJK and ASCII lines // stay on their columns without the app measuring anything. -import { For, Show } from "solid-js"; -import { Text, View } from "@pocketjs/framework/components"; -import { getOps } from "@pocketjs/framework"; -import { THEME_CURSOR, THEME_FG, isDynamicSlot, runColumns, type Run } from "./protocol.ts"; +import { createMemo, For, Show } from "solid-js"; +import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; +import { onFrame } from "@pocketjs/framework/lifecycle"; +import * as hot from "@pocketjs/framework/hot"; +import { slotRow } from "../shared/history.ts"; +import { getOps } from "@pocketjs/framework/host"; +import { THEME_CURSOR, THEME_FG, isDynamicSlot, runColumns, type Run } from "../shared/protocol.ts"; import { rgbToAbgr, type TermStore } from "./store.ts"; export interface GridMetrics { @@ -51,7 +54,7 @@ function connectionLabel(store: TermStore): string { case "no-svc": return "this host has no companion channel"; case "search": - return "searching for the companion beacon (UDP 8621)…"; + return "waiting for the paired Mac…"; case "link": return "companion linked — waiting for a session…"; case "live": @@ -78,11 +81,80 @@ export function TermGrid(props: GridProps) { const hostLeft = STATUS_PAD + Math.ceil(getOps().measureText(props.title, STATUS_BOLD_SLOT)) + STATUS_GAP; const cursorLeft = () => (store.cursor()?.[0] ?? 0) * m.cellW; const cursorTop = () => m.statusH + (store.cursor()?.[1] ?? 0) * m.cellH; - const cursorOn = () => store.cursor()?.[2] === 1 && store.conn() === "live"; + const cursorOn = () => store.cursor()?.[2] === 1 && store.conn() === "live" && store.scrollback() === 0; + const first = () => store.history?.first() ?? 0; + // Rebase before large absolute row ids lose pixel precision in native floats. + const origin = createMemo(() => Math.floor(first() / 512) * 512); + let canvas: NodeMirror | undefined, previewNode: NodeMirror | undefined, previewText: NodeMirror | undefined, previewLine: NodeMirror | undefined; + const rowNodes: (NodeMirror | undefined)[] = []; + let placedFirst = -1; + onFrame(() => { + hot.prop(canvas, "translateY", store.history?.translation(origin()) ?? 0); + if (placedFirst !== first()) { + placedFirst = first(); + for (let slot = 0; slot < m.rows + 2; slot++) hot.prop(rowNodes[slot], "translateY", m.statusH + ((store.history ? slotRow(slot, first(), m.rows + 2) : slot) - origin()) * m.cellH); + } + const p = store.scrollback() === 0 ? store.preview()?.text : undefined; + hot.prop(previewNode, "opacity", p ? 1 : 0); + if (p) { + hot.prop(previewNode, "translateX", p.x * m.cellW); hot.prop(previewNode, "translateY", m.statusH + p.y * m.cellH); + hot.text(previewText, p.value); hot.prop(previewLine, "scaleX", p.value.length * m.cellW); + } + }); + + const rowCanvas = + {Array.from({ length: m.rows + 2 }, (_, slot) => { + const row = createMemo(() => store.history ? slotRow(slot, first(), m.rows + 2) : slot); + const runs = () => store.history ? store.history.row(row()) : slot < m.rows ? store.row(slot)() : []; + return rowNodes[slot] = node} debugName="TerminalRow" class="absolute left-0 right-0 top-0" style={{ height: m.cellH }}> + }> + + {(run: Run) => ( + <> + = 0}> + + + = 0 ? run[2] : THEME_FG), + } + : { + insetL: run[0] * m.cellW, + lineHeight: m.cellH, + tracking: m.track, + textColor: rgbToAbgr(run[2] >= 0 ? run[2] : THEME_FG), + } + } + > + {run[1]} + + + )} + + + ; + })} + ; return ( - 0}> ● - + - {Array.from({ length: m.rows }, (_, y) => ( - - - {(run: Run) => ( - <> - = 0}> - - - = 0 ? run[2] : THEME_FG), - } - : { - insetL: run[0] * m.cellW, - lineHeight: m.cellH, - tracking: m.track, - textColor: rgbToAbgr(run[2] >= 0 ? run[2] : THEME_FG), - } - } - > - {run[1]} - - - )} - - - ))} + {rowCanvas} + + + + - + pocket term {connectionLabel(store)} diff --git a/app/history-batch.ts b/app/history-batch.ts new file mode 100644 index 0000000..f2997df --- /dev/null +++ b/app/history-batch.ts @@ -0,0 +1,94 @@ +import type { ResourceLoad, ResourceResult } from "@pocketjs/framework/resource-cache"; +import { HISTORY, HISTORY_BATCH, type HistoryBatchReply, type HistoryInput } from "../shared/history.ts"; +import type { HistoryIO } from "./history.ts"; + +interface RowTask { input: HistoryInput; complete(result: ResourceResult): void; live: boolean; raw: string; batch?: RowBatch } +interface RowBatch { tasks: RowTask[]; id: number } + +/** The scheduler retains per-row ownership. Only the wire groups reads, so + * cancellation, pruning and partial tails never alias a page identity. */ +export function createHistoryBatchLoader(io: HistoryIO) { + let queued: RowTask[] = []; + let completed: RowTask[] = []; + const batches = new Set(); + const counters = { requests: 0, replies: 0, replyChars: 0, completedRows: 0, cancelledRows: 0 }; + const retire = (b: RowBatch) => { if (b.id) io.cancel(b.id); b.id = 0; batches.delete(b); }; + const fail = (b: RowBatch, error: unknown) => { + retire(b); + for (const t of b.tasks) if (t.live) { t.live = false; t.complete({ ok: false, error }); } + }; + const request = (b: RowBatch): boolean => { + b.tasks = b.tasks.filter(t => t.live); + if (!b.tasks.length) { retire(b); return true; } + const first = b.tasks[0]; + b.id = io.request("term.history.batch", JSON.stringify({ sid: first.input.sid, epoch: first.input.epoch, + rows: b.tasks.map(t => t.input.row), offset: first.raw.length }), result => { + b.id = 0; + if (!batches.has(b)) return; + if (!result.ok) { fail(b, result.error); return; } + try { + const p = JSON.parse(result.value) as HistoryBatchReply; + if (p.epoch !== first.input.epoch || !Array.isArray(p.chunks) || !p.chunks.length || p.chunks.length > b.tasks.length) throw new Error("Invalid history batch reply"); + // Validate the whole envelope before publishing any row completion. + for (let i = 0; i < p.chunks.length; i++) { + const c = p.chunks[i], t = b.tasks[i]; + if (!Array.isArray(c) || c.length !== 4 || c[0] !== t.input.row || c[1] !== t.raw.length || + typeof c[2] !== "string" || !c[2].length || t.raw.length + c[2].length > HISTORY.rowChars || + typeof c[3] !== "boolean" || c[3] && i !== p.chunks.length - 1) throw new Error("Invalid history batch chunk"); + } + counters.replies++; counters.replyChars += result.value.length; + const finished = new Set(); + for (let i = 0; i < p.chunks.length; i++) { + const [,,data,more] = p.chunks[i], t = b.tasks[i]; + if (t.live) { + t.raw += data; + if (!more) { completed.push(t); finished.add(t); } + } else finished.add(t); + } + b.tasks = b.tasks.filter(t => !finished.has(t) && t.live); + if (!b.tasks.length) retire(b); + // Continuations wait for step(), after demand cancellation and the + // input-priority check. No transport callback starts hidden work. + } catch (error) { fail(b, error); } + }); + if (b.id) counters.requests++; + return b.id !== 0; + }; + const load: ResourceLoad = (input, complete) => { + const task: RowTask = { input, complete, live: true, raw: "" }; queued.push(task); + return { cancel() { if (!task.live) return; task.live = false; counters.cancelledRows++; + const b = task.batch; if (b && b.tasks.every(t => !t.live)) retire(b); + } }; + }; + return { + load, + /** Feed the public materialization scheduler by both row and text cost. + * Dense ANSI rows do not turn one network reply into 640 new text nodes + * in one frame. One oversized row is allowed so progress cannot stall. */ + publish() { + completed = completed.filter(t => t.live); + let rows = 0, chars = 0; + while (completed.length && rows < HISTORY_BATCH.materializeRows) { + const t = completed[0]; + if (rows && chars + t.raw.length > HISTORY_BATCH.materializeChars) break; + completed.shift(); rows++; chars += t.raw.length; t.live = false; + counters.completedRows++; t.complete({ ok: true, value: t.raw }); + } + }, + step(available: boolean) { + queued = queued.filter(t => t.live); + if (!available) return; + for (const b of batches) if (!b.id && !request(b)) return; + while (queued.length && batches.size < HISTORY_BATCH.concurrent) { + const first = queued[0], selected: RowTask[] = []; + queued = queued.filter(t => { + if (selected.length < HISTORY_BATCH.rows && t.input.sid === first.input.sid && t.input.epoch === first.input.epoch) { selected.push(t); return false; } + return true; + }); + const b: RowBatch = { tasks: selected, id: 0 }; for (const t of selected) t.batch = b; + batches.add(b); if (!request(b)) return; + } + }, + stats: () => ({ ...counters, batches: batches.size, queuedRows: queued.filter(t => t.live).length, completedRowsPending: completed.filter(t => t.live).length }), + }; +} diff --git a/app/history.ts b/app/history.ts new file mode 100644 index 0000000..de6f5a2 --- /dev/null +++ b/app/history.ts @@ -0,0 +1,170 @@ +import { batch, createSignal, onCleanup, type Accessor } from "solid-js"; +import { createScroller } from "@pocketjs/framework/kinetics"; +import { createResourceScheduler } from "@pocketjs/framework/resource-cache"; +import type { ResourceDemand, ResourceLoad } from "@pocketjs/framework/resource-cache"; +import type { createOffloadClient } from "@pocketjs/framework/offload"; +import { HISTORY, HISTORY_BATCH, decodeHistoryRow, historyKey, validManifest, type HistoryInput, type HistoryManifest, type HistoryReply } from "../shared/history.ts"; +import type { Run } from "../shared/protocol.ts"; +import { createHistoryBatchLoader } from "./history-batch.ts"; + +export type HistoryIO = Pick, "request" | "cancel">; + +/** Reads have their own cancellable tickets; they never wait behind queued + * terminal input and can never execute a PTY mutation on retry. */ +export function historyLoader(io: HistoryIO): ResourceLoad { + return (input, complete) => { + let id = 0, closed = false, part = 0, parts = 0, raw = ""; + const next = (): boolean => { + id = io.request("term.history", JSON.stringify({ ...input, part }), result => { + id = 0; + if (closed) return; + if (!result.ok) { closed = true; complete(result); return; } + try { + const p = JSON.parse(result.value) as HistoryReply; + if (p.epoch !== input.epoch || p.row !== input.row || p.part !== part || + !Number.isInteger(p.parts) || p.parts < 1 || p.parts > Math.ceil(HISTORY.rowChars / HISTORY.fragmentChars) || + parts && p.parts !== parts || typeof p.data !== "string" || p.data.length > HISTORY.fragmentChars || + raw.length + p.data.length > HISTORY.rowChars) throw new Error("Invalid history fragment"); + raw += p.data; parts = p.parts; part++; + if (part === parts) { closed = true; complete({ ok: true, value: raw }); } + else if (!next()) throw new Error("History transport busy"); + } catch (cause) { closed = true; complete({ ok: false, error: String(cause).slice(0, 120) }); } + }); + return id !== 0; + }; + if (!next()) return false; + return { cancel() { closed = true; if (id) io.cancel(id); raw = ""; } }; + }; +} + +export function historyDemand(sid: number, m: HistoryManifest, first: number, direction: number, visible = 26): ResourceDemand[] { + if (m.alternate) return []; + const demand: ResourceDemand[] = [], seen = new Set(); + const add = (row: number, pin: boolean) => { + if (row < m.first || row >= m.end || seen.has(row) || demand.length >= HISTORY.demand) return; + seen.add(row); demand.push({ input: { sid, epoch: m.epoch, row }, priority: demand.length + (pin ? 0 : 30), pin }); + }; + for (let n = 0; n < visible; n++) add(first + n, true); + // A live view warms its newest history; movement biases future addresses + // without removing the useful rows immediately behind the viewport. + for (let n = 1; n <= HISTORY.demand; n++) { + if (direction <= 0) { add(first - n, false); if (n % 3 === 0) add(first + visible + n / 3 - 1, false); } + else { add(first + visible + n - 1, false); if (n % 3 === 0) add(first - n / 3, false); } + } + return demand; +} + +export function createTermHistory(io: HistoryIO, liveRow: (y: number) => Accessor, rows: number, cellH: number, + glyphs?: { ready(runs: Run[]): boolean; demand(rows: Run[][]): boolean }, canLoad: () => boolean = () => true) { + const [manifest, setManifest] = createSignal(undefined, { equals: (a, b) => + a?.epoch === b?.epoch && a?.first === b?.first && a?.end === b?.end && a?.alternate === b?.alternate }); + const [sid, setSid] = createSignal(-1), [first, setFirst] = createSignal(0); + let online = false, following = true, direction = -1; + const maximum = () => { const m = manifest(); return m ? (m.end - m.first) * cellH : 0; }; + const scroller = createScroller({ max: maximum, extent: () => rows * cellH, overscroll: 0 }); + const revisions = Array.from({ length: rows + 2 }, () => createSignal(0)); + const dirty = new Set(); + let planned = "", plans = 0, demands = 0; + const available = () => online && !!manifest() && canLoad(); + const loader = createHistoryBatchLoader(io); + const runtime = createResourceScheduler({ maxCollections: 1, maxConcurrent: HISTORY_BATCH.pendingRows, + startsPerFrame: HISTORY_BATCH.rows, completionsPerFrame: HISTORY_BATCH.materializeRows, available }); + onCleanup(runtime.dispose); + const cache = runtime.createCache({ key: historyKey, maxEntries: HISTORY.entries, + maxCost: HISTORY.entries * 65536, cost: () => 65536, maxResponseBytes: HISTORY.rowChars * 2, + retry: { attempts: 3, delayFrames: 45, maxDelayFrames: 180 }, load: loader.load, materialize: decodeHistoryRow, + changed(input) { if (input.sid === sid() && input.epoch === manifest()?.epoch) dirty.add(input.row); } }); + // Fixed geometry means demand changes only at an eight-row boundary, + // direction change or authoritative manifest update. The generic view + // adapter replans every frame; use its public scheduler with an explicit + // product planner so idle frames do not allocate/reconcile 144 identities. + const plan = () => { + const m = manifest(), bucket = Math.floor(first() / 8) * 8; + const key = m ? `${sid()}/${m.epoch}/${m.first}/${m.end}/${bucket}/${direction}` : "empty"; + if (key === planned) return; + planned = key; plans++; + const wanted = m ? historyDemand(sid(), m, bucket, direction, rows + 9) : []; + demands = wanted.length; cache.reconcile(wanted); + }; + const value = (row: number): Run[] | undefined => { + const m = manifest(); if (!m) return; + const state = cache.state({ sid: sid(), epoch: m.epoch, row }); + return state.status === "ready" ? state.value : undefined; + }; + const updateFirst = () => { const m = manifest(); setFirst((m?.first ?? 0) + Math.floor(Math.round(scroller.offset()) / cellH)); }; + const usable = () => !!manifest() && !manifest()!.alternate; + const goLive = () => { following = true; scroller.scrollTo(maximum(), { immediate: true }); updateFirst(); }; + let lastGlyphRows: (Run[] | undefined)[] = [], glyphCooldown = 0; + return { + manifest, first, scroller, + select(next: number) { + if (sid() === next) return; + runtime.cancel(); planned = ""; setSid(next); setManifest(undefined); following = true; + scroller.scrollTo(0, { immediate: true }); updateFirst(); + }, + adopt(next: number, m: HistoryManifest) { + if (!validManifest(m)) throw new Error("Invalid terminal history manifest"); + const old = manifest(), changed = next !== sid() || !old || old.epoch !== m.epoch; + if (changed) { + runtime.cancel(); + cache.invalidate(i => i.sid === next && i.epoch !== m.epoch, true); + following = true; + } + setSid(next); setManifest(m); + if (following || m.alternate) goLive(); + else if (old && old.first !== m.first) { + scroller.rebase((old.first - m.first) * cellH); + if (scroller.offset() < 0) scroller.scrollTo(0, { immediate: true }); + } + updateFirst(); + + }, + setOnline(value: boolean) { if (online && !value) runtime.cancel(); online = value; }, + reset() { runtime.cancel(); cache.clear(); planned = ""; setManifest(undefined); following = true; scroller.scrollTo(0, { immediate: true }); updateFirst(); }, + frame() { + const before = scroller.offset(); scroller.step(); + if (scroller.offset() < 0 || scroller.offset() > maximum()) scroller.scrollTo(Math.max(0, Math.min(maximum(), scroller.offset())), { immediate: true }); + const motion = scroller.offset() - before; + if (motion !== 0) direction = Math.sign(motion); + if (scroller.state() === "idle" && scroller.offset() >= maximum() - 0.5) following = true; + updateFirst(); + batch(() => { + plan(); loader.publish(); runtime.step(); loader.step(available()); + for (const row of dirty) if (row >= first() && row < first() + rows + 2) revisions[row % revisions.length][1](n => n + 1); + dirty.clear(); + }); + const m = manifest(), current: (Run[] | undefined)[] = []; + if (m && scroller.offset() < maximum() - 0.5) for (let y = first(); y < first() + rows + 1 && y < m.end; y++) { + current.push(value(y)); + } + if (glyphCooldown > 0) glyphCooldown--; + if (online && !glyphCooldown && (current.length !== lastGlyphRows.length || current.some((row, i) => row !== lastGlyphRows[i]))) { + glyphCooldown = 8; + if (!glyphs || glyphs.demand(current.filter((row): row is Run[] => row !== undefined))) lastGlyphRows = current; + } + }, + nudge(px: number) { if (usable() && px) { following = false; direction = Math.sign(px); scroller.nudge(px); } }, + scroll(lines: number) { if (usable() && lines) { following = false; direction = -Math.sign(lines); scroller.scrollBy(-lines * cellH); } }, + beginDrag() { if (usable()) { following = false; scroller.beginDrag(); } }, + drag(px: number) { if (usable()) { following = false; direction = Math.sign(px) || direction; scroller.drag(px); updateFirst(); } }, + endDrag(velocity: number) { if (usable()) scroller.endDrag(Math.max(-4800, Math.min(4800, velocity))); }, + stop() { scroller.stop(); }, goLive, + back: () => Math.max(0, Math.ceil((maximum() - scroller.offset()) / cellH)), + translation(origin: number) { return -((manifest()?.first ?? 0) - origin) * cellH - Math.round(scroller.offset()); }, + row(row: number): Run[] | undefined { + const m = manifest(); + if (!m) return row >= 0 && row < rows ? liveRow(row)() : []; + if (row >= m.end) return row < m.end + rows ? liveRow(row - m.end)() : []; + if (row < m.first) return []; + revisions[row % revisions.length][0](); + const runs = value(row); + return runs && (!glyphs || glyphs.ready(runs)) ? runs : undefined; + }, + rowError(row: number) { + const m = manifest(); return !!m && row >= m.first && row < m.end && cache.state({ sid: sid(), epoch: m.epoch, row }).status === "error"; + }, + stats: () => ({ ...cache.stats(), plans, demands, transport: loader.stats() }), + dispose: runtime.dispose, + }; +} +export type TermHistory = ReturnType; diff --git a/app/keyboard.tsx b/app/keyboard.tsx index 6baa403..da6fcf0 100644 --- a/app/keyboard.tsx +++ b/app/keyboard.tsx @@ -4,16 +4,16 @@ // a fixed grid of 26 px rows on a 32 px column unit (10 units = the 320 px // panel), hit by one auxiliary-surface gesture on the keyboard root. // -// Row 0 is the terminal action strip (Esc/Tab/Ctrl/^C/arrows/paging); rows +// Row 0 is the terminal action strip (Esc/Tab/Ctrl/Alt/paging/settings); rows // 1..4 are the character layers. Shift and Ctrl are one-shot: they arm, the // next key consumes them — the classic touch-phone convention, and the only // one that works with a single resistive contact. -import { createSignal, For } from "solid-js"; +import { createMemo, createSignal, For } from "solid-js"; import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { createGesture } from "@pocketjs/framework/gesture"; import { onFrame } from "@pocketjs/framework/lifecycle"; -import type { KeyName } from "./protocol.ts"; +import type { KeyName } from "../shared/protocol.ts"; export const KEY_H = 26; export const KB_ROWS = 5; @@ -25,9 +25,10 @@ export type KeyAction = | { ch: string; ctrl?: boolean } | { key: KeyName } | { layer: LayerName } - | { mod: "shift" | "ctrl" }; + | { mod: "shift" | "ctrl" | "alt" } + | { settings: true }; -export type LayerName = "lower" | "upper" | "sym" | "sym2"; +export type LayerName = "lower" | "upper" | "sym" | "sym2" | "fn"; interface KeyDef { label: string; @@ -54,16 +55,11 @@ const layer = (label: string, target: LayerName, w = 1): KeyDef => ({ /** The action strip is layer-independent. ^C rides the key path with the * ctrl flag so the daemon encodes the control byte. */ const ACTION_ROW: KeyDef[] = [ - key("esc", "Escape"), - key("tab", "Tab"), - { label: "ctl", w: 1, act: { mod: "ctrl" }, dark: true }, - { label: "^C", w: 1, act: { ch: "c", ctrl: true }, dark: true }, - key("←", "Left"), - key("↓", "Down"), - key("↑", "Up"), - key("→", "Right"), - key("pgu", "PageUp"), - key("pgd", "PageDown"), + key("esc", "Escape", 1.25), key("tab", "Tab", 1.25), + { label: "ctl", w: 1.25, act: { mod: "ctrl" }, dark: true }, + { label: "alt", w: 1.25, act: { mod: "alt" }, dark: true }, + key("pgu", "PageUp", 1.25), key("pgd", "PageDown", 1.25), + { label: "settings", w: 2.5, act: { settings: true }, dark: true }, ]; function charRow(chars: string): KeyDef[] { @@ -71,6 +67,12 @@ function charRow(chars: string): KeyDef[] { } const LAYERS: Record = { + fn: [ + [key("F1", "F1", 2), key("F2", "F2", 2), key("F3", "F3", 2), key("F4", "F4", 2), key("F5", "F5", 2)], + [key("F6", "F6", 2), key("F7", "F7", 2), key("F8", "F8", 2), key("F9", "F9", 2), key("F10", "F10", 2)], + [key("F11", "F11", 2), key("F12", "F12", 2), key("ins", "Insert", 2), key("del", "Delete", 2), key("home", "Home", 2)], + [layer("abc", "lower", 2), layer("?123", "sym", 2), key("end", "End", 2), key("tab", "Tab", 2), key("enter", "Enter", 2)], + ], lower: [ charRow("qwertyuiop"), charRow("asdfghjkl'"), @@ -91,7 +93,7 @@ const LAYERS: Record = { ], sym2: [ charRow("~`|\\{}<>\"'"), - charRow("/?*+-=%$#@"), + [layer("F1+", "fn", 2), ...charRow("*+-=%$#@")], [layer("?123", "sym", 1.5), ...charRow("&^!.,;"), k(":"), key("⌫", "Backspace", 1.5)], [layer("abc", "lower", 1.5), k("("), { label: "space", w: 4, act: { ch: " " } }, k(")"), k("."), key("⏎", "Enter", 1.5)], ], @@ -124,9 +126,10 @@ export function keyAt(layerName: LayerName, x: number, y: number): KeyHit | null export interface KeyboardProps { top: number; + onSettings: () => void; onChar: (ch: string) => void; /** `name` is a KeyName, or a single character when ctrl is held. */ - onKey: (name: string, ctrl: boolean) => void; + onKey: (name: string, ctrl: boolean, alt: boolean, shift: boolean) => void; /** One-shot Ctrl arms here; the next character key consumes it. */ ctrlArmed: () => boolean; setCtrlArmed: (on: boolean) => void; @@ -134,6 +137,7 @@ export interface KeyboardProps { export function Keyboard(props: KeyboardProps) { const [layerName, setLayerName] = createSignal("lower"); + const [altArmed, setAltArmed] = createSignal(false); const [pressed, setPressed] = createSignal(null); let rootNode: NodeMirror | undefined; let releaseTimer = 0; @@ -142,21 +146,26 @@ export function Keyboard(props: KeyboardProps) { setPressed(`${hit.row}:${hit.index}`); releaseTimer = 4; const act = hit.def.act; - if ("ch" in act) { - if (act.ctrl || props.ctrlArmed()) { - props.onKey(act.ch, true); + if ("settings" in act) { props.onSettings(); + } else if ("ch" in act) { + if (act.ctrl || props.ctrlArmed() || altArmed()) { + props.onKey(act.ch, !!act.ctrl || props.ctrlArmed(), altArmed(), false); + setAltArmed(false); props.setCtrlArmed(false); } else { props.onChar(act.ch); } if (layerName() === "upper") setLayerName("lower"); // one-shot shift } else if ("key" in act) { - props.onKey(act.key, props.ctrlArmed()); + props.onKey(act.key, props.ctrlArmed(), altArmed(), layerName() === "upper"); + setAltArmed(false); + if (layerName() === "upper") setLayerName("lower"); props.setCtrlArmed(false); } else if ("layer" in act) { setLayerName(act.layer); } else if ("mod" in act) { if (act.mod === "shift") setLayerName(layerName() === "upper" ? "lower" : "upper"); + else if (act.mod === "alt") setAltArmed(!altArmed()); else props.setCtrlArmed(!props.ctrlArmed()); } }; @@ -197,6 +206,7 @@ export function Keyboard(props: KeyboardProps) { layer={layerName()} pressed={pressed()} ctrlArmed={props.ctrlArmed()} + altArmed={altArmed()} /> )} @@ -239,6 +249,7 @@ function KeyboardRow(props: { layer: LayerName; pressed: string | null; ctrlArmed: boolean; + altArmed: boolean; }) { const defs = () => rowsFor(props.layer)[props.row]; return ( @@ -248,9 +259,9 @@ function KeyboardRow(props: { defs() .slice(0, index()) .reduce((x, d) => x + d.w * UNIT, 0); - const isPressed = () => props.pressed === `${props.row}:${index()}`; - const isArmedCtrl = () => "mod" in def.act && def.act.mod === "ctrl" && props.ctrlArmed; - const down = () => isPressed() || isArmedCtrl(); + const isPressed = createMemo(() => props.pressed === `${props.row}:${index()}`); + const isArmedCtrl = () => "mod" in def.act && (def.act.mod === "ctrl" && props.ctrlArmed || def.act.mod === "alt" && props.altArmed); + const down = createMemo(() => isPressed() || isArmedCtrl()); return ( // The socket: a dark recess the cap sits in. Unpressed, the cap // covers all but the bottom lip, and that sliver of shadow is what diff --git a/app/offload.ts b/app/offload.ts new file mode 100644 index 0000000..c99be11 --- /dev/null +++ b/app/offload.ts @@ -0,0 +1,90 @@ +import { offload } from "@pocketjs/framework/offload"; +import { LIMITS, fitsRecord, type ExchangeReply, type ExchangeRequest, type InputCommand, type InputReply } from "../shared/exchange.ts"; +import type { ClientLine, HostInputLine, HostLine } from "../shared/protocol.ts"; +import type { TermChannel } from "./channel.ts"; + +type IO = Pick, "connected" | "session" | "request" | "cancel">; + +/** Input and output have independent tickets. One ordered input batch stays + * in flight; its ids survive uncertain replies. Output keeps its own cursor + * and cannot hold a key behind a large grid or atlas fragment. */ +export function createTermChannel(io: IO, replica = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`): TermChannel { + let epoch: string | undefined, received = 0, nextId = 1, output = 0, input = 0, generation = 0; + let partial = "", outputRetry = 0, inputRetry = 0, lastHello: ClientLine | undefined, error = ""; + const commands: InputCommand[] = [], incoming: (HostLine | HostInputLine)[] = []; + const enqueue = (lines: ClientLine[]) => { + if (commands.length + lines.length > LIMITS.commands) { error = "Input queue full; wait for the Mac"; return false; } + for (const line of lines) { if (line.t === "hello") lastHello = line; commands.push({ id: nextId++, line }); } + return true; + }; + function adopt(reply: InputReply): boolean { + if (typeof reply.epoch !== "string" || !Number.isSafeInteger(reply.ack) || reply.ack < 0) throw new Error("Invalid terminal reply"); + if (epoch && reply.epoch !== epoch) { + generation++; + if (input) io.cancel(input); if (output) io.cancel(output); input = output = 0; + commands.length = 0; nextId = 1; received = 0; partial = ""; incoming.length = 0; + incoming.push({ t: "transport-reset" }); + error = "Mac restarted; pending input discarded"; + epoch = reply.epoch; + if (lastHello) enqueue([lastHello]); + return false; + } + epoch = reply.epoch; error = reply.error ?? ""; + while (commands[0] && commands[0].id <= reply.ack) commands.shift(); + return true; + } + function pumpInput() { + if (input || inputRetry || !epoch || !io.connected() || !commands.length) return; + let payload = ""; + for (let n = 1; n <= Math.min(LIMITS.inputBatch, commands.length); n++) { + const candidate = JSON.stringify({ replica, epoch, commands: commands.slice(0, n) }); + if (!fitsRecord(candidate, "term.input")) break; + payload = candidate; + } + if (!payload) { error = "Input record exceeds budget"; return; } + const ticketGeneration = generation; + input = io.request("term.input", payload, result => { + if (ticketGeneration !== generation) return; + input = 0; + if (!result.ok) { error = result.error; inputRetry = 2; return; } + try { adopt(JSON.parse(result.value)); } + catch (cause) { error = String(cause).slice(0, 120); inputRetry = 2; } + }); + } + return { + historyIO: io, + open: () => io.connected(), status: () => error, + inputPending: () => commands.length > 0 || input !== 0, + send(line) { const id = nextId; if (!enqueue([line])) return 0; pumpInput(); return id; }, + sendBatch(lines) { const accepted = enqueue(lines); if (accepted) pumpInput(); return accepted; }, + dispose() { generation++; if (input) io.cancel(input); if (output) io.cancel(output); commands.length = incoming.length = 0; partial = ""; }, + poll() { + if (outputRetry > 0) outputRetry--; if (inputRetry > 0) inputRetry--; + pumpInput(); + if (!output && !outputRetry && io.connected()) { + const request: ExchangeRequest = { replica, epoch, received }, ticketGeneration = generation; + output = io.request("term.exchange", JSON.stringify(request), result => { + if (ticketGeneration !== generation) return; + output = 0; + if (!result.ok) { error = result.error; outputRetry = 2; return; } + try { + const reply = JSON.parse(result.value) as ExchangeReply; + if (!adopt(reply)) return; + if (!Number.isSafeInteger(reply.sequence) || reply.sequence < 0) throw new Error("Invalid terminal delivery"); + if (reply.data !== undefined && reply.sequence > received) { + if (reply.sequence !== received + 1 || typeof reply.data !== "string" || partial.length + reply.data.length > LIMITS.lineChars) throw new Error("Invalid terminal delivery"); + partial += reply.data; received = reply.sequence; + if (!reply.more) { incoming.push(JSON.parse(partial)); partial = ""; } + } + } catch (cause) { error = String(cause).slice(0, 120); outputRetry = 2; } + }); + } + return incoming.splice(0); + }, + }; +} + +export function connectTermOffload(): TermChannel | null { + if (!(globalThis as { offload?: unknown }).offload) return null; + return createTermChannel(offload()); +} diff --git a/app/prediction.ts b/app/prediction.ts new file mode 100644 index 0000000..0c93689 --- /dev/null +++ b/app/prediction.ts @@ -0,0 +1,79 @@ +import type { ClientLine, Cursor, Run } from "../shared/protocol.ts"; + +export interface TypingPreview { cursor: Cursor; text?: { x: number; y: number; value: string } } +interface Picture { cursor: Cursor; rows: Map } +interface Guess { id: number; kind: string; at: number; picture: Picture; ch?: { x: number; y: number; value: string } } +const sameCursor = (a: Cursor | null, b: Cursor) => !!a && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; + +/** A display-only hypothesis. It never edits the authoritative grid or + * history, never invents an input command, and requires two observed echoes + * before showing that kind of action. Unknown input resets that confidence. + * All timestamps are virtual milliseconds supplied by the frame transaction. */ +export function createTypingPrediction(readRow: (y: number) => Run[], readCursor: () => Cursor | null, + publish: (preview: TypingPreview | undefined) => void, cols = 80, rows = 24) { + const confidence = new Map(); + let enabled = true, base: Picture | undefined, guesses: Guess[] = [], confirmedAt = 0; + const rowText = (y: number) => { + const cells = Array(cols).fill(" "); + for (const run of readRow(y)) { + if (run[4] !== undefined || (run[5] ?? run[1].length) !== run[1].length || /[^\x20-\x7e]/.test(run[1])) return; + for (let x = 0; x < run[1].length && run[0] + x < cols; x++) cells[run[0] + x] = run[1][x]; + } + return cells.join(""); + }; + const matches = (picture: Picture) => sameCursor(readCursor(), picture.cursor) && [...picture.rows].every(([y, text]) => rowText(y) === text); + const reset = () => { base = undefined; guesses = []; confidence.clear(); publish(undefined); }; + const show = (now: number) => { + if (!enabled || !guesses.length || now - confirmedAt > 2000 || guesses.some(g => (confidence.get(g.kind) ?? 0) < 2)) { publish(undefined); return; } + const chars = guesses.filter(g => g.ch).map(g => g.ch!); + publish({ cursor: guesses.at(-1)!.picture.cursor, ...(chars.length ? { text: { x: chars[0].x, y: chars[0].y, value: chars.map(c => c.value).join("") } } : {}) }); + }; + return { + reset, + enabled(on: boolean) { enabled = on; reset(); }, + input(line: ClientLine, id: number, now: number) { + if (!enabled || !id || guesses.length >= 16) { reset(); return; } + const cursor = guesses.at(-1)?.picture.cursor ?? readCursor(); + if (!cursor || !cursor[2]) { reset(); return; } + const [x, y] = cursor; + const previous = guesses.at(-1)?.picture ?? { cursor, rows: new Map() }; + const picture: Picture = { cursor: [...cursor], rows: new Map(previous.rows) }; + const read = (row: number) => { const text = picture.rows.get(row) ?? rowText(row); if (text !== undefined) picture.rows.set(row, text); return text; }; + const text = read(y); if (text === undefined) { reset(); return; } + let kind: string, ch: Guess["ch"]; + if (line.t === "ch" && /^[\x20-\x7e]$/.test(line.s) && x < cols - 1 && text.slice(x).trim() === "") { + kind = "text"; ch = { x, y, value: line.s }; + picture.rows.set(y, text.slice(0, x) + line.s + text.slice(x + 1)); picture.cursor = [x + 1, y, 1]; + } else if (line.t === "key" && !line.ctrl && !line.alt && !line.shift && ["Left", "Right", "Up", "Down"].includes(line.k)) { + kind = line.k; + const nx = x + (kind === "Right" ? 1 : kind === "Left" ? -1 : 0), ny = y + (kind === "Down" ? 1 : kind === "Up" ? -1 : 0); + if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) { reset(); return; } + const target = read(ny); + if (target === undefined || kind !== "Left" && target[nx] === " " && (ny !== y || text[x] === " ")) { reset(); return; } + picture.cursor = [nx, ny, 1]; + } else { reset(); return; } + if (guesses.length && guesses.at(-1)!.kind !== kind) { reset(); return; } + if (!base) base = { cursor: [...cursor], rows: new Map(picture.rows) }; + // The base contains pre-input cells, including any target row inspected. + for (const row of picture.rows.keys()) if (!base.rows.has(row) || !guesses.length) { + const before = rowText(row); if (before !== undefined) base.rows.set(row, before); + } + guesses.push({ id, kind, at: now, picture, ch }); show(now); + }, + authoritative(ack: number | undefined, now: number) { + if (!guesses.length) return; + if (ack === undefined) { reset(); return; } + let match = -1; + for (let i = 0; i < guesses.length; i++) if (guesses[i].id <= ack && matches(guesses[i].picture)) match = i; + if (match >= 0) { + for (let i = 0; i <= match; i++) confidence.set(guesses[i].kind, Math.min(2, (confidence.get(guesses[i].kind) ?? 0) + 1)); + confirmedAt = now; base = guesses[match].picture; guesses.splice(0, match + 1); + if (!guesses.length) base = undefined; + show(now); + } else if (base && !matches(base)) reset(); + // An accepted write is not proof that the PTY application has echoed + // it. An unchanged base waits briefly; it never confirms a hypothesis. + }, + frame(now: number) { if (guesses.length && now - guesses[0].at > 350) reset(); }, + }; +} diff --git a/app/settings.tsx b/app/settings.tsx new file mode 100644 index 0000000..6aace8e --- /dev/null +++ b/app/settings.tsx @@ -0,0 +1,29 @@ +import { For } from "solid-js"; +import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; +import { createGesture } from "@pocketjs/framework/gesture"; +import { FONT_LABELS, FONT_NAMES } from "./font.ts"; + +export function TermSettings(props: { font: number; speed: number; preview: boolean; status: string; + onFont(n: number): void; onSpeed(n: number): void; onPreview(on: boolean): void; onClose(): void }) { + let panel: NodeMirror | undefined; + createGesture({ surface: "auxiliary", region: { node: () => panel }, onTap(c) { + if (c.y < 34 && c.x > 250) props.onClose(); + else if (c.y >= 45 && c.y < 129) props.onFont(Math.floor((c.y - 45) / 28)); + else if (c.y >= 139 && c.y < 174) props.onPreview(!props.preview); + else if (c.y >= 177 && c.y < 208) props.onSpeed(props.speed === 1 ? 1.5 : 1); + } }); + return + Settings + × + Font + {(name, n) => + {FONT_LABELS[name]} + {props.font === n() ? "●" : "○"} + } + Typing preview + {props.preview ? "Auto" : "Off"} + Scroll speed + {props.speed === 1 ? "Fast" : "Faster"} + {props.status} + ; +} diff --git a/app/stick.ts b/app/stick.ts new file mode 100644 index 0000000..b43c1b1 --- /dev/null +++ b/app/stick.ts @@ -0,0 +1,20 @@ +/** Cardinal cursor motion with a repeat delay, velocity-sensitive repeat and + * hysteresis around diagonals. The public right-stick axes use down-positive Y. */ +export function createCursorStick() { + let direction = "", held = 0; + return { + step(x: number, y: number): "Up" | "Down" | "Left" | "Right" | undefined { + const ax = Math.abs(x), ay = Math.abs(y), strength = Math.max(ax, ay); + if (strength < 0.22) { direction = ""; held = 0; return; } + let horizontal = ax > ay; + if (direction === "Left" || direction === "Right") horizontal = ay < ax * 1.3; + if (direction === "Up" || direction === "Down") horizontal = ax > ay * 1.3; + const next = horizontal ? x < 0 ? "Left" : "Right" : y < 0 ? "Up" : "Down"; + if (next !== direction) { direction = next; held = 0; } + held++; + const repeat = strength > 0.75 ? 3 : strength > 0.45 ? 5 : 8; + if (held === 1 || held > 16 && (held - 17) % repeat === 0) return next; + }, + reset() { direction = ""; held = 0; }, + }; +} diff --git a/app/store.ts b/app/store.ts index 48627d2..dc1d787 100644 --- a/app/store.ts +++ b/app/store.ts @@ -1,13 +1,13 @@ -// app/store.ts — the device-side replica state. The companion daemon -// owns terminal truth; this store only applies ordered grid updates onto -// per-row signals and turns UI intents into protocol lines. gen/seq guard the -// one lossy hop (the svc line queue drops oldest under backlog): a gap asks -// the host for a fresh snapshot instead of rendering from a hole. +// Visible terminal replica. Completed grid generations commit atomically; +// sequence gaps request a snapshot. PTYs and scrollback remain on the Mac. -import { createSignal, type Accessor } from "solid-js"; -import { getOps } from "@pocketjs/framework"; +import { batch, createMemo, createSignal, type Accessor } from "solid-js"; +import { virtualNow } from "@pocketjs/framework/clock"; +import { getOps } from "@pocketjs/framework/host"; import { + DYNAMIC_SLOTS, TERM_PROTO, + type ClientLine, type Cursor, type HostInputLine, type HostLine, @@ -15,8 +15,11 @@ import { type Role, type Run, type SessionInfo, -} from "./protocol.ts"; -import type { Svc } from "./svc.ts"; + isDynamicSlot, +} from "../shared/protocol.ts"; +import type { TermChannel } from "./channel.ts"; +import { createTermHistory, type TermHistory } from "./history.ts"; +import { createTypingPrediction, type TypingPreview } from "./prediction.ts"; export type ConnState = "no-svc" | "search" | "link" | "live"; @@ -59,6 +62,9 @@ function base64ToBytes(text: string): Uint8Array { } export interface TermStore { + history?: TermHistory; + preview: Accessor; + setPreview(on: boolean): void; conn: Accessor; hostName: Accessor; sessions: Accessor; @@ -70,10 +76,13 @@ export interface TermStore { /** Glyphs delivered at runtime for codepoints the build never baked; the * count is what the status bar reports. */ dynamicGlyphs: Accessor; + status(): string; + dispose(): void; + paste(s: string): void; /** Pump the channel — call exactly once per frame. */ frame(): void; sendText(s: string): void; - sendKey(k: KeyName | string, ctrl?: boolean, alt?: boolean): void; + sendKey(k: KeyName | string, ctrl?: boolean, alt?: boolean, shift?: boolean): void; scroll(lines: number): void; newSession(): void; kill(sid: number): void; @@ -90,7 +99,7 @@ export interface TermStoreOptions { role?: Role; } -export function createTermStore(options: TermStoreOptions, svc: Svc | null): TermStore { +export function createTermStore(options: TermStoreOptions, svc: TermChannel | null): TermStore { const { cols, rows, cell } = options; const role: Role = options.role ?? "device"; const [conn, setConn] = createSignal(svc === null ? "no-svc" : "search"); @@ -101,12 +110,40 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter const [scrollback, setScrollback] = createSignal(0); const [bell, setBell] = createSignal(false); const [dynamicGlyphs, setDynamicGlyphs] = createSignal(0); + const [status, setStatus] = createSignal(""); const rowSignals = Array.from({ length: rows }, () => createSignal([])); + const [preview, setPreview] = createSignal(); + const prediction = createTypingPrediction(y => rowSignals[y][0](), cursor, setPreview, cols, rows); + const displayedCursor = createMemo(() => preview()?.cursor ?? cursor()); + const coverage = new Map>(); + const [atlasVersion, setAtlasVersion] = createSignal(0); + const history = svc?.historyIO ? createTermHistory(svc.historyIO, y => rowSignals[y][0], rows, cell[1], { + ready(runs) { + atlasVersion(); + return runs.every(run => !isDynamicSlot(run[4]) || [...run[1]].every(ch => coverage.get(run[4]!)?.has(ch.codePointAt(0)!))); + }, + demand(rows) { + const wanted = new Map(); + for (const row of rows) for (const run of row) if (isDynamicSlot(run[4])) { + const chars = [...run[1]], wide = (run[5] ?? run[1].length) > chars.length; + for (const ch of chars) if (wanted.size < 1024) wanted.set(ch, wide ? 2 : 1); + } + const entries = [...wanted], lines: ClientLine[] = []; + for (let at = 0; at < Math.max(1, entries.length); at += 224) { + const chunk = entries.slice(at, at + 224); + lines.push({ t: "glyphs", one: chunk.filter(e => e[1] === 1).map(e => e[0]).join(""), two: chunk.filter(e => e[1] === 2).map(e => e[0]).join(""), + ...(at === 0 ? { reset: 1 as const } : {}), ...(at + 224 < entries.length ? { more: 1 as const } : {}) }); + } + if (svc.sendBatch) return svc.sendBatch(lines); + lines.forEach(line => svc.send(line)); return true; + }, + }, () => !svc.inputPending?.()) : undefined; let wasOpen = false; let gen = -1; let seq = -1; let sawGrid = false; + let staged: Map | undefined; let resyncCooldown = 0; let bellFrames = 0; /** Survives a reconnect so the console comes back to the session it was @@ -120,13 +157,14 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter gen: number; seq: number; parts: string[]; + chars: number; } const atlasRx = new Map(); const glyphCounts = new Map(); const clearGrid = () => { for (const [, set] of rowSignals) set([]); - setCursor(null); + prediction.reset(); setCursor(null); setScrollback(0); }; @@ -137,24 +175,32 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter }; const applyAtlas = (line: Extract) => { + if (!(DYNAMIC_SLOTS as readonly number[]).includes(line.slot) || typeof line.b64 !== "string" || line.b64.length > 6144) return; let rx = atlasRx.get(line.slot); if (rx === undefined || line.gen !== rx.gen) { if (line.seq !== 0) return; // joined mid-bake; the next one starts clean - rx = { gen: line.gen, seq: -1, parts: [] }; + rx = { gen: line.gen, seq: -1, parts: [], chars: 0 }; atlasRx.set(line.slot, rx); } if (line.seq !== rx.seq + 1) { - atlasRx.delete(line.slot); // a gap; drop the bake and wait for the next + atlasRx.delete(line.slot); + requestResync(); return; } rx.seq = line.seq; - rx.parts.push(line.b64); + if (rx.chars + line.b64.length > 262144) { atlasRx.delete(line.slot); requestResync(); return; } + rx.parts.push(line.b64); rx.chars += line.b64.length; if (line.more === 1) return; const blob = base64ToBytes(rx.parts.join("")); - rx.parts = []; + rx.parts = []; rx.chars = 0; const load = getOps().loadFontAtlas; if (!load || blob.length < 16) return; load(blob); + const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength), count = view.getUint16(6, true); + if (16 + count * 8 <= blob.length) { + coverage.set(line.slot, new Set(Array.from({ length: count }, (_, n) => view.getUint32(16 + n * 8, true)))); + setAtlasVersion(n => n + 1); + } // Each blob's header carries its own glyph count (spec FONT ATLAS v3); // the status line reports the whole chain's. glyphCounts.set(line.slot, blob[6] | (blob[7] << 8)); @@ -176,7 +222,7 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter } gen = line.gen; seq = -1; - clearGrid(); + staged = new Map(); } if (line.seq !== seq + 1) { if (line.seq <= seq) return; // duplicate; TCP makes this a host bug @@ -184,33 +230,75 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter return; } seq = line.seq; + if (!staged) staged = new Map(); for (const update of line.rows) { const [y, ...runs] = update; - if (y >= 0 && y < rows) rowSignals[y][1](runs); + if (y >= 0 && y < rows) staged.set(y, runs); } - if (line.cur) setCursor(line.cur); - if (line.sb !== undefined) setScrollback(line.sb); - sawGrid = true; - setConn("live"); + if (line.more) return; + batch(() => { + if (line.full || line.history && line.history.epoch !== history?.manifest()?.epoch) prediction.reset(); + for (const [y, runs] of staged!) rowSignals[y][1](runs); + staged = undefined; + if (line.cur) setCursor(line.cur); + if (line.sb !== undefined) setScrollback(line.sb); + if (line.history) history?.adopt(line.sid, line.history); + sawGrid = true; + prediction.authoritative(line.ack, virtualNow() * 1000); + setConn("live"); + }); }; const sendText = (s: string) => { - if (s.length > 0) svc?.send({ t: "ch", s }); + if (!wasOpen || activeSid() < 0) return; + if (s.length > 0 && s.length <= 256) { + history?.goLive(); const line: ClientLine = { t: "ch", s }, id = svc?.send(line); + if (id && conn() === "live") prediction.input(line, id, virtualNow() * 1000); else prediction.reset(); + } }; - const sendKey = (k: KeyName | string, ctrl?: boolean, alt?: boolean) => { - svc?.send({ + const sendKey = (k: KeyName | string, ctrl?: boolean, alt?: boolean, shift?: boolean) => { + if (!wasOpen || activeSid() < 0) return; + history?.goLive(); + const line: ClientLine = { t: "key", k, ...(ctrl ? { ctrl: 1 as const } : {}), ...(alt ? { alt: 1 as const } : {}), - }); + ...(shift ? { shift: 1 as const } : {}), + }; + const id = svc?.send(line); + if (id && conn() === "live") prediction.input(line, id, virtualNow() * 1000); else prediction.reset(); + }; + const paste = (text: string) => { + if (!wasOpen || activeSid() < 0 || !text) return; + prediction.reset(); history?.goLive(); + if (text.length > 8192) { setStatus("Paste exceeds 8192 characters"); return; } + const chunks: string[] = []; + for (let at = 0; at < text.length;) { + let end = Math.min(text.length, at + 128); + const last = text.charCodeAt(end - 1); + if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--; + chunks.push(text.slice(at, end)); at = end; + } + const lines: ClientLine[] = chunks.map((s, i) => ({ t: "paste", s, phase: chunks.length === 1 ? "single" : i === 0 ? "start" : i === chunks.length - 1 ? "end" : "more" })); + if (svc?.sendBatch) svc.sendBatch(lines); + else lines.forEach(line => svc?.send(line)); }; const scroll = (lines: number) => { - if (lines !== 0) svc?.send({ t: "scroll", d: lines }); + prediction.reset(); + if (history) history.scroll(lines); + else if (lines !== 0) svc?.send({ t: "scroll", d: lines }); }; const apply = (line: HostLine | HostInputLine) => { switch (line.t) { + case "transport-reset": + gen = -1; seq = -1; staged = undefined; sawGrid = false; + clearGrid(); atlasRx.clear(); glyphCounts.clear(); setDynamicGlyphs(0); + history?.reset(); + coverage.clear(); setAtlasVersion(n => n + 1); + setConn("link"); + break; case "hello": // The local host says hello too, with a viewport and no proto. Only // the companion's introduces the session. @@ -229,10 +317,10 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter sendText(line.s); break; case "key": - sendKey(line.k, line.ctl === true, line.alt === true); + sendKey(line.k, line.ctl === true, line.alt === true, line.sh === true); break; case "paste": - sendText(line.text); + paste(line.text); break; case "scroll": // A wheel notch is worth a line of history. @@ -248,7 +336,9 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter case "sessions": setSessions(line.list); if (line.active !== activeSid()) { + clearGrid(); sawGrid = false; staged = undefined; setConn("link"); setActiveSid(line.active); + history?.select(line.active); if (line.active >= 0) lastWanted = line.active; } if (line.list.length === 0) { @@ -259,6 +349,7 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter gen = -1; seq = -1; sawGrid = false; + staged = undefined; lastWanted = -1; } break; @@ -280,22 +371,29 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter const attach = (sid: number) => { if (sid === activeSid()) return; - lastWanted = sid; + prediction.reset(); lastWanted = sid; svc?.send({ t: "attach", sid }); }; return { + history, preview, setPreview: prediction.enabled, conn, hostName, sessions, activeSid, row: (y) => rowSignals[y][0], - cursor, - scrollback, + cursor: displayedCursor, + scrollback: () => history?.manifest() ? history.back() : scrollback(), bell, dynamicGlyphs, + status, + dispose() { history?.dispose(); svc?.dispose?.(); atlasRx.clear(); }, + paste, frame() { + prediction.frame(virtualNow() * 1000); if (svc === null) return; + const transportStatus = svc.status?.() ?? ""; + if (transportStatus) setStatus(transportStatus); if (resyncCooldown > 0) resyncCooldown -= 1; if (bellFrames > 0 && --bellFrames === 0) setBell(false); const open = svc.open(); @@ -305,6 +403,7 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter gen = -1; seq = -1; sawGrid = false; + staged = undefined; atlasRx.clear(); glyphCounts.clear(); svc.send({ @@ -314,25 +413,28 @@ export function createTermStore(options: TermStoreOptions, svc: Svc | null): Ter rows, cell, role, + ...(history ? { history: 1 as const } : {}), ...(lastWanted >= 0 ? { want: lastWanted } : {}), }); } wasOpen = open; if (!open) { - setConn("search"); + prediction.reset(); setConn("search"); + history?.setOnline(false); history?.frame(); return; } if (conn() !== "live" || !sawGrid) setConn(sawGrid ? "live" : "link"); for (const line of svc.poll()) apply(line); + history?.setOnline(sawGrid && conn() === "live"); history?.frame(); }, sendText, sendKey, scroll, newSession() { - svc?.send({ t: "new" }); + prediction.reset(); svc?.send({ t: "new" }); }, kill(sid) { - if (sid >= 0) svc?.send({ t: "kill", sid }); + prediction.reset(); if (sid >= 0) svc?.send({ t: "kill", sid }); }, attach, attachSibling(step) { diff --git a/app/svc.ts b/app/svc.ts index fa25049..530f3b2 100644 --- a/app/svc.ts +++ b/app/svc.ts @@ -2,22 +2,13 @@ // apps/note/svc.ts shape). Hosts without the channel feature-detect to null // and the app stays on its connect screen. -import { getOps } from "@pocketjs/framework"; -import { TERM_APP, type ClientLine, type HostInputLine, type HostLine } from "./protocol.ts"; +import { getOps } from "@pocketjs/framework/host"; +import { TERM_APP, type ClientLine, type HostInputLine, type HostLine } from "../shared/protocol.ts"; -export interface Svc { - /** Non-blocking transport probe — call once per frame; false while the - * companion is still being discovered (the app supplies the cadence). */ - open(): boolean; - /** Drain and parse this frame's lines (call once per frame). The queue - * carries the companion's terminal state and, on a desktop host, the - * window's own input — see HostInputLine. */ - poll(): (HostLine | HostInputLine)[]; - send(line: ClientLine): void; -} +import type { TermChannel } from "./channel.ts"; /** Null = this host has no svc channel at all (goldens, hosts/sim). */ -export function connectSvc(): Svc | null { +export function connectSvc(): TermChannel | null { const ops = getOps(); if (!ops.svcOpen || !ops.svcPoll || !ops.svcSend) return null; const open = ops.svcOpen.bind(ops); diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 0000000..4164dfc --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,43 @@ +# Terminal bitmap fonts + +**The upstream BDF bytes are preserved in gzip archives.** Compression uses +level 9 with no filename or timestamp in the header (`gzip -n -9`). The three +sources occupy 353,333 bytes compressed instead of 3,303,564 bytes of text. +The build tools and Mac rasterizer decompress them through +`shared/font-sources.ts`; the 3DS receives baked atlases. Regenerate the app's +three terminal atlases with `bun scripts/font.ts` and verify reproduction +with `bun scripts/font.ts --check`. + +| Face | Pinned source | License | +| --- | --- | --- | +| Spleen 5×8 | [fcambus/spleen](https://github.com/fcambus/spleen/tree/57f9219328c9f5873085320fe8bc8f7dd34b8791), version 2.2.0 | BSD-2-Clause, `spleen/LICENSE` | +| Spleentt 5×8 | [tommythorn/spleentt-5x8-font](https://github.com/tommythorn/spleentt-5x8-font/tree/54f98cef28e7e5c656414e8854d9986a8e7cb89d) | BSD-2-Clause, `spleentt/LICENSE` | +| Fusion Pixel 10px Monospaced, Simplified Chinese | [TakWolf/fusion-pixel-font release 2026.09.01](https://github.com/TakWolf/fusion-pixel-font/releases/tag/2026.09.01), BDF release archive | SIL OFL 1.1, `fusion-pixel/OFL.txt` and upstream component notices in `fusion-pixel/LICENSES/` | + +The hashes below identify the **decompressed, unmodified source bytes**. +For example, `gzip -dc assets/fonts/spleen/spleen-5x8.bdf.gz | shasum -a 256` +checks the Spleen source. The adjacent license files remain readable text. + +| Source | SHA-256 | +| --- | --- | +| Spleen | `40488184d075d0c752cdd239b441c5ece51e50b353156f2496c756c384ab01cb` | +| Spleentt | `8503def0ec91e7aa54f6a067acb181b843904c6bf37207d80fa6d095adc46304` | +| Fusion Pixel | `fb35066c7ade4b9674abb3e528d9adc82f6a2c359902e567ef8bead3621c5d4e` | + +**ASCII advances 5px in all three terminal atlases.** Spleen and Spleentt +retain their original 5×8 pixels with padding inside a 5×10 cell. Fusion +uses its original 10px height; its ASCII advances 5px and CJK advances 10px. +There is no resampling or antialiasing of these bitmap glyphs. Spleentt is +the default; open settings on the lower screen to compare the faces. + +The generated atlas is a product composition: ASCII comes from the selected +face, other narrow symbols prefer Fusion, and terminal box/block glyphs are +drawn procedurally to meet the 5×10 cell edges. A symbol absent from both +bitmap sources uses a thresholded JetBrains Mono outline. JetBrains Mono's +SIL OFL notice is retained in +`vendor/pocketjs/assets/fonts/LICENSE-JetBrainsMono.txt`. + +**Dynamic CJK is baked on the Mac from Fusion at 1:1 pixels.** Each glyph +keeps its terminal column width even when narrow and wide characters share +an atlas. Characters outside Fusion's coverage use the existing system-font +fallback chain; those outline fallbacks can still use antialiasing. diff --git a/assets/fonts/fusion-pixel/LICENSES/ark-pixel/OFL.txt b/assets/fonts/fusion-pixel/LICENSES/ark-pixel/OFL.txt new file mode 100644 index 0000000..264873a --- /dev/null +++ b/assets/fonts/fusion-pixel/LICENSES/ark-pixel/OFL.txt @@ -0,0 +1,96 @@ +Ark Pixel Font +https://github.com/TakWolf/ark-pixel-font + +Copyright (c) 2021, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/fusion-pixel/LICENSES/boutique-bitmap-9x9/OFL.txt b/assets/fonts/fusion-pixel/LICENSES/boutique-bitmap-9x9/OFL.txt new file mode 100644 index 0000000..8de4e1d --- /dev/null +++ b/assets/fonts/fusion-pixel/LICENSES/boutique-bitmap-9x9/OFL.txt @@ -0,0 +1,122 @@ +[BoutiqueBitmap9x9] +Copyright © 2026 字言字型 版權所有。 +Copyright © 2026 fancy type foundry. All rights reserved. +2020-2026《字言字語》Cen-cyun, Liu. Luke Liu. +https://fontspeech.blogspot.com/ +These fonts are free software. +Unlimited permission is granted to use, copy, and distribute them, with or without modification, either commercially or noncommercially. +THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY. +此字型是免費的。 +無論您是否進行對本字型進行商業或非商業性修改,均可無限制地使用,複製和分發它們。 +本字型的衍生品之授權必須與此字型相同,且不作任何擔保。 +此字体是免费的。 +无论您是否进行对本字型进行商业或非商业性修改,均可无限制地使用,复制和分发它们。 +本字型的衍生品之授权必须与此字型相同,且不作任何担保。 +[M+ BITMAP FONTS] +Copyright (C) 2002-2004 COZ +These fonts are free software. +Unlimited permission is granted to use, copy, and distribute it, with or without modification, either commercially and noncommercially. +THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY. +これらのフォントはフリー(自由な)ソフトウエアです。 +あらゆる改変の有無に関わらず、また商業的な利用であっても、自由にご利用、複製、再配布することができますが、全て無保証とさせていただきます。 +[ベストテン(BestTen-DOT)] +フリーフォントのベストテンは無料でダウンロードできるドットフォント。商用・非商用問わず使用可能なので、安心してダウンロードしてください。 +作成した印刷物およびデジタル・コンテンツにつき、その商用・非商用にかかわらず印刷、放送、通信、各種記録メディアなどの媒体の形式も問わず、使用をすることができます。プログラムへの埋め込みが可能です。 +このフォントのライセンスは、 +M+のライセンスに準じます。 +M+ FONT LICENSEについては、配布物に含まれる +mplus_bitmap_fonts をご覧ください。 +[Fusion Pixel] +Copyright (c) 2022, TakWolf (https://takwolf.com), with Reserved Font Name 'Fusion Pixel'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/fusion-pixel/LICENSES/galmuri/LICENSE.txt b/assets/fonts/fusion-pixel/LICENSES/galmuri/LICENSE.txt new file mode 100644 index 0000000..ae34182 --- /dev/null +++ b/assets/fonts/fusion-pixel/LICENSES/galmuri/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright (c) 2019–2025 Lee Minseo (quiple@quiple.dev) + +This font software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/fusion-pixel/OFL.txt b/assets/fonts/fusion-pixel/OFL.txt new file mode 100644 index 0000000..0427d1d --- /dev/null +++ b/assets/fonts/fusion-pixel/OFL.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/fusion-pixel/fusion-pixel-10px-monospaced-zh_hans.bdf.gz b/assets/fonts/fusion-pixel/fusion-pixel-10px-monospaced-zh_hans.bdf.gz new file mode 100644 index 0000000..3ae8149 Binary files /dev/null and b/assets/fonts/fusion-pixel/fusion-pixel-10px-monospaced-zh_hans.bdf.gz differ diff --git a/assets/fonts/spleen/LICENSE b/assets/fonts/spleen/LICENSE new file mode 100644 index 0000000..6928cd0 --- /dev/null +++ b/assets/fonts/spleen/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2018-2026, Frederic Cambus +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/assets/fonts/spleen/spleen-5x8.bdf.gz b/assets/fonts/spleen/spleen-5x8.bdf.gz new file mode 100644 index 0000000..2516c71 Binary files /dev/null and b/assets/fonts/spleen/spleen-5x8.bdf.gz differ diff --git a/assets/fonts/spleentt/LICENSE b/assets/fonts/spleentt/LICENSE new file mode 100644 index 0000000..6fab106 --- /dev/null +++ b/assets/fonts/spleentt/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2018-2022, Frederic Cambus +Copyright (c) 2023, Tommy Thorn +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/assets/fonts/spleentt/spleentt-5x8.bdf.gz b/assets/fonts/spleentt/spleentt-5x8.bdf.gz new file mode 100644 index 0000000..185e573 Binary files /dev/null and b/assets/fonts/spleentt/spleentt-5x8.bdf.gz differ diff --git a/docs/HISTORY-THROUGHPUT.md b/docs/HISTORY-THROUGHPUT.md new file mode 100644 index 0000000..c18f49f --- /dev/null +++ b/docs/HISTORY-THROUGHPUT.md @@ -0,0 +1,94 @@ +# History throughput + +**The previous cache requested and published one row at a time.** Its two +in-flight requests could fetch roughly 20 short rows per second at 100ms +round-trip latency. A normal 80-column row left most of each reply unused. +Rows over 600 UTF-16 code units required additional serial requests. The +resource scheduler then materialized at most one row per frame. Faster +Wi-Fi cannot remove those application-level waits. + +**The pinned offload host delivers one record per UI frame.** Both +`framework/src/offload.ts` and `hosts/3ds/src/offload.c` enforce this limit. +The record cap is 4,096 bytes and the payload cap is 2,500 code units. At +60fps the record ceiling is 245,760 bytes/s (240 KiB/s), before JSON overhead +and competing screen/input replies; at 20fps it is 80 KiB/s. The current API +therefore cannot expose a 1 MB/s link to guest JavaScript. This is a bound +derived from the checked-in code, not a measurement of the radio. + +The old `/history` handler also scheduled a live-screen scan after each +read. Reading immutable history does not change the terminal. The new batch +handler omits that scan. + +## Grouped transport, individual cache ownership + +Protocol 6 adds `term.history.batch`. The scheduler admits up to 32 pending +row loads, and the app groups up to 16 compatible row addresses into each of +two network requests. The Mac fills the actual nested JSON record budget +with complete rows. A single oversized row uses exact-offset fragments; +ordinary rows do not incur a continuation just to fill the last bytes of a +packet. Unicode surrogate pairs stay together. + +**Cache identities remain session, history epoch and absolute row number.** +Transport batches do not create page identities. The Mac validates every +requested address before reading cells, so a pruned or expired range cannot +silently supply replacement rows. The guest validates every returned chunk +before completing any row in that envelope. Cancelling one row leaves its +peers usable; cancelling every row cancels the shared ticket. Late callbacks +cannot publish into a retired session or epoch. Appending history preserves +the meaning of already requested addresses, including a short newest tail. + +Input retains its independent ordered ticket. History starts and +continuations pause while input is pending. The public resource scheduler +still owns retry, cancellation, eviction and materialization. The product +loader feeds it **at most eight rows and 4,096 serialized code units per +frame**. A single larger row may progress on its own. This text budget stops +a dense ANSI reply from creating hundreds of text nodes in one frame. +Resident entries remain capped at 192; demand remains capped at 144. + +## Measurements + +The replay uses the actual guest offload pump, two submissions and one +delivery per frame, 100ms simulated round trips, continuous idle screen +polls and a cold 26-row viewport. Plain rows contain 80 characters. The dense +case changes color every character. The baseline is `d12e905`. + +| Cold viewport | Previous | Batched | +| --- | ---: | ---: | +| Plain, 60fps: all 26 rows ready | 1,550ms | 167ms | +| Plain, 20fps: all 26 rows ready | 2,050ms | 300ms | +| Dense ANSI, 60fps: all 26 rows ready | 4,583ms | 1,867ms | +| Plain: maximum rows in one reply | 1 | 16 | +| Plain: maximum rows published in one frame | 1 | 8 | + +The first plain row still arrives after 117ms at 60fps: batching reduces the +wait for the remaining rows, not the first network round trip. The trace +counts 27 history requests before the old viewport is complete and four for +the new one, including speculative lookahead. Consequently total bytes at +that point can increase: the new cache has fetched more future rows. + +The actual Bun provider, Node broker and libghostty integration read the +same 26 `row-NNN` PTY history rows with 26 calls in 17.21ms, versus two calls +in 1.79ms. This is a local loopback sample, not Wi-Fi latency. The test also +checks identical rows after appends, lost replies/provider reconnection and +epoch expiration. The timing itself is not a flaky test assertion. + +```sh +git show d12e905:app/history.ts > .pocket/before-batch-history.ts +bun --conditions=browser scripts/history-bench.ts --baseline=.pocket/before-batch-history.ts +bun run check +bun run test:pty +bun run visual --history --motion +``` + +The same native motion fixture at frame 182 previously showed two cached +rows among skeletons. The batch version shows consecutive rows across the +visible viewport. Compare [previous frame](history-single-row.png) with +[batched frame](history-batched.png); [six native frames](history-batched.gif) +retain pixel motion. These are deterministic emulator captures with delayed +mock replies, not physical frame-rate measurements. + +**Physical bandwidth saturation is not claimed.** macOS denied access to +the BPF capture device, so no packet-level radio trace was obtained. A higher +bulk-transfer ceiling would require an upstream PocketJS change with bounded +byte/time budgets and UI-frame measurements. This revision removes the +terminal's small-row request bottleneck within the existing host contract. diff --git a/docs/OFFLOAD-UPGRADE.md b/docs/OFFLOAD-UPGRADE.md new file mode 100644 index 0000000..b79c54c --- /dev/null +++ b/docs/OFFLOAD-UPGRADE.md @@ -0,0 +1,235 @@ +# Paired terminal upgrade validation + +## Current protocol-6 validation + +**The user accepted the current changes and authorized merging PR #2.** The +launcher containing the framework-clock prediction fix (`5434243`) was +installed on 2026-09-07 at 01:20 UTC. Both the launcher and the app-specific +pairing key were downloaded again and matched their local originals. +The source-font compression in `d787d41` reproduced the same launcher and +package bytes. It retained the unmodified upstream glyphs and licenses. + +| Check | Result | +| --- | --- | +| Guest and host types; unit suite | Passed: 69 tests, 1,494 assertions | +| Actual provider and macOS PTYs | Passed: 2 integrations, including saved Vim/Nano edits | +| Prediction clock | Production and fixtures use the framework virtual clock; 20Hz/60Hz store tests prohibit wall-clock reads | +| Font sources and generated atlases | Lossless source archives and byte-identical atlas reproduction | +| Native preview | Frame 93 retained the unconfirmed third character and its five-pixel underline | +| Physical deployment | Launcher and pairing key read back byte-identically | +| User decision | Changes accepted; merge authorized | + +| Deployed artifact | Bytes | SHA-256 | +| --- | ---: | --- | +| pocketterm-main.3dsx | 2,027,680 | `1c369ee2d00a6cee742a80bbc6125f1b1ac702e7a643ab62e9d0e7bf97751c56` | +| pocketterm-main.pocket | 725,224 | `839fc37ca38ddc16ec4f528bbc91b32cf8f1ae411be528b24675ad942e1694ed` | + +The launcher is installed at `/3DS/pocketterm-main.3dsx`. Its previous version +was backed up at +`/pocketjs/runtime/native-backups/pocketterm-main-21a4a120c9ef2978.3dsx`. +Existing Mac PTYs were preserved during installation. This acceptance does +not establish numerical physical latency, sustained Wi-Fi throughput or a +guaranteed frame rate. + +The [current interface captures](screenshots/README.md) show the terminal and +settings. The sections below retain **historical revision evidence**; their +screenshots, hashes and pending checks describe those earlier revisions. + +## Batched history revision + +**Protocol 6 groups up to 16 history rows per network request.** Cache keys +remain individual absolute rows. At most two network requests and 32 logical +row loads are pending; materialization is bounded by eight rows and 4,096 +serialized code units per frame, with one larger row allowed to progress. +[HISTORY-THROUGHPUT.md](HISTORY-THROUGHPUT.md) records the diagnosis, wire ceiling, +measurements and native comparison. + +| Check | Result | +| --- | --- | +| Guest and host types; unit suite | Passed: 67 tests, 1,476 assertions | +| Actual provider and macOS PTYs | Passed: 2 integrations; bulk rows match single reads across append, reconnect and expiration | +| Cold-cache replay | 26 plain rows at 100ms RTT/60fps: 1,550ms before, 167ms after | +| Native motion | Same delayed fixture now has consecutive cached rows throughout frame 182; six frames inspected | +| Production launcher and Mac mirror | Built; app key path present, capture marker absent | +| Physical FTP deployment | Launcher and pairing key read back byte-identically on 2026-09-07 at 00:31 UTC | +| Physical revision acceptance | Pending fresh launch and fast-scroll testing | + +| Artifact | Bytes | SHA-256 | +| --- | ---: | --- | +| pocketterm-main.3dsx | 2,027,600 | `21a4a120c9ef297856082cbac6d102dfdc021d08a3e5e1b8ed39f9940914d55b` | +| pocketterm-main.pocket | 725,144 | `b080e6f334108671de30fcfd95196fe6974bd18b3a1aea6fb0797ac90f3ed2ea` | + +That launcher was installed at `/3DS/pocketterm-main.3dsx`; the previous +version was backed up and verified at +`/pocketjs/runtime/native-backups/pocketterm-main-b349e7e97590e4d9.3dsx`. +The receipt is `.pocket/last-deploy.json`. The protocol-6 companion is running; +its trace is `.pocket/history-batch-daemon.log`. The previous worker's two +PTYs and two existing Mac mirrors remain alive in their original process, +not migrated into protocol 6. `.pocket/retired-protocol5-daemon.json` records +the process preservation. + +## Responsive input and scrolling baseline + +**Protocol 5 sends input independently of output delivery and batches ordered +keys.** Local scroll demand is planned in eight-row buckets, row movement uses +pixel translations, and releasing the stick carries its velocity into a fling. +The quiet lower screen has a settings key for fonts, preview and scroll speed. +Conservative provisional echo learns from confirmed application output. +The mechanism and simulated baseline comparison are in +[RESPONSIVENESS.md](RESPONSIVENESS.md). + +| Check | Result | +| --- | --- | +| Guest and host types; unit suite | Passed: 60 tests, 1,441 assertions | +| Actual provider and macOS PTYs | Passed: 2 integrations, including batched Vim/Nano edits and lost-reply retries | +| All three font atlases | Reproduction matched | +| Production launcher and Mac mirror | Built; app key path present, capture marker absent | +| Native settings | Inspected; all three fonts and preview/speed controls fit | +| Native provisional echo | Inspected; pending third character and underline appear before authoritative output | +| Native inertial motion | Six consecutive frames inspected; unchanged pixels move 12, 11 and 10px in successive frames | +| Physical FTP deployment | Passed: launcher and pairing key read back byte-identically on 2026-09-06 at 23:23 UTC | +| Physical revision acceptance | Pending fresh launch and input/scrolling tests | + +| Artifact | Bytes | SHA-256 | +| --- | ---: | --- | +| pocketterm-main.3dsx | 2,024,192 | `b349e7e97590e4d97bc43e53da9f1735fd979f65ce8dd5e7970bbda7b244355d` | +| pocketterm-main.pocket | 721,736 | `a486a182e912b9513b914ecaecf2e5c69b1c1d889c08db3de272e8a69aa47494` | + +**The protocol-5 launcher was installed at `/3DS/pocketterm-main.3dsx`.** +The previous launcher was backed up and verified at +`/pocketjs/runtime/native-backups/pocketterm-main-6f65c3a374681c11.3dsx`. +That deployment's readback matched the artifact above. The latest receipt +now records protocol 6. Its then-new Mac worker and provider started +successfully and the user later reported slow fallback-row filling. + +The protocol-4 supervisor and provider were stopped while the old durable +worker and its existing PTY were preserved. That session is not migrated +into the protocol-5 worker. Preservation details are in +`.pocket/retired-protocol4-daemon.json`; the new trace is +`.pocket/responsive-daemon.log`. +The native fixtures establish rendering behavior, not physical frame rate. + +## Cached scrollback baseline + +**This preceding revision added local inertial scrollback, a touchpad, right-nub +cursor arrows and three pixel fonts.** The user confirmed the preceding +paired-terminal build was usable, then identified slow scrollback and blurry +text. The results below distinguish the new revision from that physical +baseline. The synchronization mechanism is documented in +[SCROLLBACK.md](SCROLLBACK.md). + +| Baseline check | Result | +| --- | --- | +| Guest and host types; unit suite | Passed: 50 tests, 1,432 assertions | +| Native Ghostty history and real provider/PTYS | Passed: 2 integrations, including pruning, same-chunk clear/refill, palette invalidation, reconnect and saved Vim/Nano cursor edits | +| All three generated atlases | Reproduction matched; ASCII is 1:1 source bitmap coverage with 5px advance | +| Production 3DS launcher/package | Built; expected app key path present and capture marker absent | +| macOS mirror | Guest and release desktop host built | +| Native font/layout frame | Inspected at 80×24; Spleentt, full-width last column and bottom touchpad | +| Native delayed-history frame 180 | Inspected: translated rows retain identity, missing rows show skeletons | +| Native settled-history frame 360 | Inspected: missing rows filled, consecutive row identities and the reading anchor preserved | +| Physical FTP deployment | Passed: production launcher and pairing key read back byte-identically on 2026-09-06 at 21:46 UTC | +| Physical revision acceptance | User confirmed Nano nub control worked; reported high latency, slow motion and noisy lower-screen controls | + +[Native font/layout](terminal-80x24.png), [history while loading](history-loading.png), +[history after filling](history-settled.png), and +[font comparison at 1:1](font-comparison.png) are retained as review artifacts. +The font study compares the previous antialiased face with Spleen, Spleentt and +Fusion; the native fixture verifies the selected face through the 3DS renderer. +These deterministic emulator frames do not establish physical frame rate. + +| Baseline artifact | Bytes | SHA-256 | +| --- | ---: | --- | +| pocketterm-main.3dsx | 1,937,872 | `6f65c3a374681c1151175fc088e00ea8727df70c060e46a315f3a7d70f377c8f` | +| pocketterm-main.pocket | 635,416 | `fc3180add555cc7a5cf7136162a49f21007807650d2c411a36853cc82c1502c5` | + +**The protocol-4 launcher was installed at `/3DS/pocketterm-main.3dsx`.** +The previous launcher was backed up and verified at +`/pocketjs/runtime/native-backups/pocketterm-main-9f4a08ce175b4af4.3dsx`. +Its FTP readback matched the baseline artifact above. The latest +`.pocket/last-deploy.json` now records the protocol-5 deployment. + +After this baseline deployment, the Mac companion waited for the console +to leave ftpd. The previous provider was stopped while its durable terminal worker +and both existing PTYs were preserved for their original desktop mirrors. +Those sessions are not migrated into the new worker. Its new sessions use +terminal protocol 4. The baseline trace is `.pocket/cache-daemon.log`; +preservation details are in `.pocket/retired-protocol3-daemon.json`. + +## Initial paired-terminal baseline + + +Validated on 2026-09-06 with PocketJS main `1c0735fa` and Pocket Doc's +`edd774b` provider/resource architecture as the reference. + +**The top screen is an 80×24 terminal.** Every cell advances 5px and every +row occupies 10px. The generated terminal font replaces slot 16 when the +guest mounts. Box and block coverage reaches the cell boundaries. Status, +tabs and the keyboard occupy the auxiliary 320×240 display. + +**Terminal sessions survive provider disconnects.** A durable Node process +owns node-pty, libghostty and scrollback. PocketJS's connection worker +forwards `term.exchange` to its authenticated loopback endpoint. Command +ids prevent duplicate execution after lost replies; acknowledged output +fragments retain delivery order. A changed epoch discards uncertain input +and resets the view. + +| Check | Result | +| --- | --- | +| Guest and host TypeScript | Passed | +| Explicit unit suite | 42 tests, 1,021 assertions passed | +| Generated atlas | Reproduction matched; every advance is 5px | +| Provider and PTY integration | Passed with actual Bun workers, Node PTYs and libghostty | +| Production .3dsx and .pocket | Built using the pinned nightly and devkitARM container | +| macOS mirror | Guest bundle and release desktop host built | +| Native capture | Azahar dual-screen pixels inspected at 80×24, with paged tabs and keyboard | +| Physical FTP deployment | Launcher and app-specific offload key uploaded; both read back byte-identically | +| Physical paired connection | Mac provider connected to the 3DS at TCP port 8741; the device sent `hello` and created an 80×24 zsh PTY | +| Physical input and multiplexing | Fresh character/key, new, kill, attach and scroll requests observed; three 80×24 PTYs created and repeated session switching recorded | + +The integration test force-closes the connection after commands, reconnects +the actual PocketJS provider, and resends the same command ids. A temporary +file proves one execution. It checks independent sessions, explicit +reattachment, 80×24 sizing, alternate screens, ANSI color, the last cell, +CJK atlas delivery, close/new-session behavior, terminal cursor-position +query replies, and exact bracketed paste bytes received by a foreground +program in raw mode. + +The visual fixture uses its own app id and `pocketterm-qa.3dsx` output. Its +mock transport supplies deterministic rows; it is separate from the actual +provider integration test. The decoded native capture is +[terminal-80x24.png](terminal-80x24.png). + +| Production artifact | Bytes | SHA-256 | +| --- | ---: | --- | +| pocketterm-main.3dsx | 1,844,656 | `9f4a08ce175b4af4e6f9c2a40de0710cc39bb5d6de44465b323209f727fc5580` | +| pocketterm-main.pocket | 542,200 | `0d3fbec4f0fb72b05ade5d528dda861ded96a7f33da1d28964a252a4fe999a15` | + +**Physical FTP deployment passed on 2026-09-06 at 17:49 UTC.** The launcher +above was installed at `/3DS/pocketterm-main.3dsx` through ftpd on +`192.168.8.102:5000`. Its app-specific key was installed at +`/pocketjs/offload/22a222ca7b6bddb1.key`. Both files were downloaded again +and compared byte-for-byte with their local originals. The previous launcher +was backed up and verified at +`/pocketjs/runtime/native-backups/pocketterm-main-1452e2723b5edc0f.3dsx`. +The local receipt is `.pocket/last-deploy.json`; it contains no key material. + +**Physical launch, paired requests and fresh input passed.** The Mac provider +has an established connection from `192.168.8.159` to the console at +`192.168.8.102:8741`. Its trace records a device `hello`, an 80×24 zsh PTY, +character/key requests followed by shell prompt updates, two `new` requests, +one `kill`, 19 `attach` and 151 `scroll` requests by 17:54 UTC. Three 80×24 +PTYs were created and the device attached to each. Desktop mirrors attach +through their session-specific loopback listeners. The local trace is +`.pocket/offload-daemon.log`; the acceptance snapshot is +`.pocket/last-hardware-run.json` and contains command kinds, not typed text. + +The user subsequently confirmed that this build was usable and reported +scrolling and font quality as the main problems. HBL return was not separately +confirmed in that report. Device telemetry recorded a cumulative maximum CPU frame of +316,584 microseconds and 137 frames over 16ms in the first 4,827 frames; +the connection/input evidence does not establish consistently smooth frames. +The pinned offload host starts its embedded package without the legacy dev +server; hot push and dev-server screenshots are unavailable in this mode. +The previous FTP receipt in `RUNTIME-UPGRADE.md` belongs to the older +57×17 svc build. diff --git a/docs/RESPONSIVENESS.md b/docs/RESPONSIVENESS.md new file mode 100644 index 0000000..ad39925 --- /dev/null +++ b/docs/RESPONSIVENESS.md @@ -0,0 +1,137 @@ +# Input and scrolling responsiveness + +**Input is separate from screen delivery since protocol 5.** Protocol 6 adds +[batched history reads](HISTORY-THROUGHPUT.md). `term.input` has one +request in flight, containing up to eight ordered commands that fit the +PocketJS offload record budget. `term.exchange` independently pulls screen +fragments. A held output reply cannot prevent a new key from reaching the +Mac. Retried commands retain their ids; a restarted delivery epoch discards +uncertain input and cancels old tickets. A late callback from an older epoch +cannot restore it. + +The 64-command queue remains bounded. A batch is admitted before sending any +of it, and ids are validated before executing new commands. The Mac consumes +rejected commands once and retains an error across subsequent commands in +the same batch. **Key repeat does not drop or merge logical keystrokes.** + +Output fragments fit their actual nested JSON encoding, up to 1,800 UTF-16 +code units instead of a fixed 600. Both the payload and UTF-8 record limits +are checked, including escaped quotes and surrogate pairs. Held output +reserves room for acknowledgement growth and a later input error. Atlas pieces +are 1,536 base64 characters; changed screen rows take precedence over the +next atlas piece. History starts pause while input is pending. These changes +use the existing bounded offload queues and worker, with no socket work on +the 3DS UI thread. + +## Local motion + +**The history planner runs on viewport demand changes, not every frame.** +It uses the public PocketJS `createResourceScheduler`, preserving bounded +requests, materialization, cancellation and eviction. The app plans in +eight-row buckets with enough pinned rows to cover the viewport through the +bucket. Manifest and direction changes also replan. Per-slot signals publish +only relevant cached-row changes; the scheduler still advances once per +frame so completion and retry timing remain deterministic. + +The canvas and recycled row positions use paint translations. Text and row +geometry are not rounded to ten-pixel steps. Pixel rounding preserves bitmap +font sharpness while allowing one-pixel motion. A normal full stick deflection +advances the target by 18px per frame; release carries its velocity into the +same kinetic fling used by the touchpad. Opposite motion cancels the old +chase target. The viewport clamps at terminal history boundaries. Settings +also offers a 1.5× speed option. Touch drag gain is 3× and release velocity is +bounded to 4,800 logical pixels per virtual second. + +Keyboard pressed-state memos stop a changed key from rewriting every other +key's style. The four touch arrow keys are removed; the hardware D-pad and +right nub retain that function. Font, preview and speed controls live in a +settings panel. The normal lower screen has session tabs, a touchpad, a small +connection indicator and the keyboard. + +## Provisional echo + +**Prediction changes only a display overlay.** The authoritative grid, +scrollback and PTY input remain unchanged. Pending text is underlined until +confirmed. Matching real output replaces it; a mismatch clears it. + +The implementation learns each kind of action from two matching echoes +before displaying predictions. It supports single printable ASCII characters +appended in blank row space and learned cardinal cursor movements within +known ASCII rows. It does not predict a row wrap, wide glyph, control key, +paste, completion or command execution. Empty right-hand space is required +for text; it does not guess an editor's insertion or overwrite mode. + +A grid carries the input acknowledgement at snapshot creation. **An accepted +PTY write alone is not proof of application echo.** Confirmation additionally +requires matching cells and cursor. An unchanged pre-input screen waits until +the first frame past 350ms of virtual time; unknown or mismatched output +revokes the prediction. At most 16 hypotheses are retained, and learned +confidence stops enabling previews after two virtual seconds without +confirmation. Session/screen transitions, disconnects, resyncs and +unmodeled keys reset confidence. A silent password-like input path never +trains echo. Settings can disable the preview. + +**Prediction reads the framework's `virtualNow()` clock in production and +tests.** The framework advances this clock once at the start of each frame; +input, authoritative confirmation and expiry in that transaction observe +the same time. The store converts virtual seconds to milliseconds for the +prediction model. Host-selected simulation rates retain the same time units. +`Date.now()` would introduce unrecorded UTC time into replica state; +`performance.now()` would still introduce unrecorded real elapsed time. + +These deadlines bound display hypotheses in simulation time. Pausing the +frame pump pauses them, and running below the simulation rate extends their +real duration. A requirement to expire after real elapsed time needs a host +monotonic deadline delivered as a recorded event at a frame boundary. Network +deadlines remain transport concerns; this display policy does not extend them. + +This follows the observed-echo approach described by +[Mosh](https://mosh.org/#techinfo). Warp instead describes +[a command editor owned by the terminal](https://www.warp.dev/blog/why-is-the-terminal-input-so-weird), +which submits its completed buffer to the shell. Pocket Term retains the +PTY's incremental input contract so Nano, Vim and other foreground programs +continue to decide what keys mean. + +## Validation and limits + +The deterministic latency fixture simulates a 60Hz native frame boundary, +100ms round trips, two submissions and one reply delivery per frame. It sends +60 keys at 20Hz, measures arrival at the PTY capability, and checks order and +uniqueness. The baseline is the previously deployed `c2b7d0e` guest transport. + +| Simulated input arrival | Previous transport | Protocol 5 | +| --- | ---: | ---: | +| P50 | 2,083ms | 133ms | +| P95 | 3,883ms | 183ms | +| Maximum queued keys | 35 | 4 | +| Received keys | 60, in order | 60, in order | + +This measures queue behavior under the stated conditions, not physical +3DS network latency. `test/latency.test.ts` pins the new transport's queue +bound and 200ms P95 ceiling for this fixture. `test:pty` exercises the actual +Bun provider and Node broker, including batched right-nub keys, lost-reply +retries and saved files in Vim and the system Nano/Pico. + +A desktop Bun sample of 1,000 idle history frames with 144 loaded rows took +61.15ms before and 3.11ms after. The repeatable invariant is **zero demand +replans while idle**, tested directly; those desktop times do not predict +3DS frame rate. Prediction tests cover confirmed prefixes, mismatches, +timeouts, control transitions, non-echoing input and cell preservation. Store +tests drive the framework clock at 20Hz and 60Hz with wall-clock reads forbidden, +checking preview expiry, confidence age and preservation of authoritative cells. + +Native fixtures are built with `bun run visual --history --motion`, +`--settings` and `--preview`. They use the actual 3DS renderer, delayed mock +replies and the same framework clock as production, with no fixture-specific +prediction clock. The emulator's wall-clock speed does not consume the +preview deadline. + +[Settings](responsive-settings.png), [provisional echo](responsive-preview.png) +and [six consecutive motion frames](responsive-motion.gif) retain the pixels. +The echo frame shows the third `a` and its five-pixel underline before its +mock reply, while the authoritative grid still contains `$ aa`. Motion +frames 181–184 translate unchanged row pixels by 12, 11 and 10px respectively; +other frames also fill delayed rows. The GIF plays slowly for inspection. +The user authorized merging the protocol-6 implementation after the +virtual-clock update. Numerical physical latency and frame-rate measurements remain +separate from these simulated and native-fixture results. diff --git a/docs/RUNTIME-UPGRADE.md b/docs/RUNTIME-UPGRADE.md index 77da006..59ddad3 100644 --- a/docs/RUNTIME-UPGRADE.md +++ b/docs/RUNTIME-UPGRADE.md @@ -1,5 +1,9 @@ # Runtime upgrade validation +**Historical record for the 57×17 svc launcher.** Current installation and +controls are in the [README](../README.md); the accepted paired offload build +is documented in [OFFLOAD-UPGRADE.md](OFFLOAD-UPGRADE.md). + Validated on 2026-09-06 against PocketJS main `10aee589d95aca34802ea4f8c79023e929434a92`. diff --git a/docs/SCROLLBACK.md b/docs/SCROLLBACK.md new file mode 100644 index 0000000..922ed03 --- /dev/null +++ b/docs/SCROLLBACK.md @@ -0,0 +1,121 @@ +# Local terminal history + +**The Mac owns terminal state; the 3DS owns its reading position.** +libghostty parses PTY output in the durable Node session process. The live +80×24 screen still arrives as an ordered, atomically committed grid. The +device does not replay terminal escape sequences or mutate cached history. + +Pocket Doc's bounded resource views and Pocket Map's local scrolling inform +the device implementation: `createScroller` advances the viewport, while a +resource scheduler fetches only demanded rows. Both APIs come from the +pinned PocketJS main. No unmerged framework code is copied into this app. + +## Row identity and synchronization + +Each committed live grid includes `{epoch, first, end, alternate}`. Historical +rows occupy the half-open range `[first, end)`; live row `y` follows at +`end + y`. A cache key contains the session id, history epoch and absolute +row number. **Appending output never changes an existing historical key.** + +Ghostty's history accessor is newest-first. Its discarded-row counter plus +retained count establishes absolute addresses; the Mac reads row `r` at +`end - 1 - r`. Tests use the pinned native core, including its page-based +pruning, rather than assuming its configured limit is an exact row count. + +| Change | Device behavior | +| --- | --- | +| Output appended while following live | Move to the new live screen | +| Output appended while reading history | Preserve the absolute reading position; new output remains below | +| History pruned | Retain surviving row identities, rebase the scroll origin, clamp an expired position to the oldest available row | +| Clear history, terminal reset, resize or alternate-screen transition | Change epoch, cancel stale loads and return to the current live screen | +| Palette/default-color write or reset | Change epoch because existing cells can resolve to new colors; ordinary color queries retain the epoch | +| Provider disconnected | Keep cached rows and the last live screen, mark the view offline, stop new reads | +| Same session reconnects | Adopt the current manifest; preserve valid history and the reading anchor | +| Terminal process/replica restarts | Discard uncertain input and cached delivery state; accept a fresh manifest | +| Session switched | Fence pending reads and hide the previous session's live grid | + +**An epoch fence also covers clear-and-refill in one PTY chunk.** A small +observer recognizes destructive VT boundaries across split chunks and +ignores escape-looking data in OSC/DCS strings. It does not interpret cells; +Ghostty remains the parser. Checking counts only after parsing would miss a +clear followed by enough output to reuse old indices with a larger count. +The observer also retains a bounded OSC prefix to distinguish color changes +from queries and titles. The native test confirms that a palette write +recolors an existing historical cell even though its row count is unchanged. + +`term.history.batch` groups reproducible row reads separately from the ordered +`term.input` stream and `term.exchange` screen delivery. Every fragment repeats the epoch and row +identity. The server rejects expired ranges; the client checks fragment +order, identity and size and fences late completions after cancellation. +A missing row is a skeleton; an empty decoded row is a known blank. + +## Frame and memory bounds + +The top screen has **26 reusable row slots** for 24 visible rows and two +edge rows. Crossing one row reassigns one slot. Sub-row motion writes a +rounded pixel translation through PocketJS's hot property API, so neither +network round trips nor rebuilding all rows drives motion. The render +origin rebases every 512 absolute rows to keep native float coordinates +small. Demand replans only at eight-row bucket, manifest or direction changes. +Left-stick release and lower-screen drag/flick share this scroller. +Typing and cursor keys return to live; alternate screens disable history. + +| Budget | Limit | +| --- | ---: | +| Exposed Mac history | At most 2,000 rows per session | +| Device resident cache | 192 entries, each conservatively charged 64 KiB | +| Current demand | 144 rows, visible rows pinned first | +| Directional lookahead | Approximately 3:1 ahead versus behind | +| History requests in flight | 2 | +| Logical pending row loads | 32 | +| Rows per network request | Up to 16, with replies filled to the encoded record budget | +| Starts / materializations | Up to 16 starts and 8 row materializations per frame | +| Materialization text budget | 4,096 code units per frame; one larger row may progress alone | +| Offload response | At most 2,500 code units in a 4,096-byte record | +| Serialized row | 16,384 UTF-16 code units | +| Read retries | 3 attempts, 45–180 frame backoff | + +The cache is in RAM and is not persisted to the SD card. Revisiting an +evicted row needs the Mac; disconnected cache misses remain skeletons. +Failed reads use a distinct skeleton color and bounded retries. + +## Glyph residency + +**A cached Unicode row does not imply its bitmap is still resident.** +The device tracks the codepoints in each loaded atlas. It draws a historical +row only when all its dynamic glyphs are available; otherwise it shows the +skeleton while requesting visible glyph residency. Prefetch reads classify +glyphs without placing them in the atlas, so distant rows cannot evict the +current viewport's glyphs merely by downloading. + +Visible interest is sent as an ordered, bounded batch and published on the +Mac only when complete. A face retains up to 1,024 dynamic glyphs. Idle +replicas stop requesting residency after 15 seconds. Several simultaneously +active replicas can still contend for that finite atlas; this is not an +unbounded Unicode cache. ASCII and terminal furniture live in the shipped +static atlas and need no residency requests. + +## Validation + +`bun run check` exercises the actual PocketJS resource scheduler with delayed +replies, cancellation, offline reading, append/prune anchors and slot reuse. +`bun run test:pty` covers native Ghostty history identities and reads through +the actual Bun provider/Node PTY path, including reconnect and expiration. +It also feeds right-stick-generated arrow keys into `/usr/bin/vim` and +`/usr/bin/nano` and checks the saved text. On this Mac, `nano` identifies as +Pico. This proves escape/input behavior, not the physical nub's feel. + +`bun run visual --history` builds a separate native capture fixture. It +injects analog input while history replies arrive with variable frame +delays. Add `--frame=360` to capture after the visible rows have filled. +`bun run visual` builds the static 80-column font/layout fixture. +Neither fixture is a production launcher or evidence of physical frame rate. +[Input and scrolling responsiveness](RESPONSIVENESS.md) documents the separate +input path, planner changes, provisional echo and latency measurements. +[History throughput](HISTORY-THROUGHPUT.md) records the row batching mechanism, +transport limits and before/after cold-cache measurements. + +[Superlogical's public material](https://www.superlogical.com/) describes durable sessions, native history +and reconnecting from other devices. It does not specify a reusable public +cache protocol; the identities and invalidation rules above follow the +terminal core's concrete behavior. diff --git a/docs/console.png b/docs/console.png deleted file mode 100644 index 6136539..0000000 Binary files a/docs/console.png and /dev/null differ diff --git a/docs/font-comparison.png b/docs/font-comparison.png new file mode 100644 index 0000000..0233dda Binary files /dev/null and b/docs/font-comparison.png differ diff --git a/docs/history-batched.gif b/docs/history-batched.gif new file mode 100644 index 0000000..33b8c4b Binary files /dev/null and b/docs/history-batched.gif differ diff --git a/docs/history-batched.png b/docs/history-batched.png new file mode 100644 index 0000000..0e1257f Binary files /dev/null and b/docs/history-batched.png differ diff --git a/docs/history-loading.png b/docs/history-loading.png new file mode 100644 index 0000000..30227db Binary files /dev/null and b/docs/history-loading.png differ diff --git a/docs/history-settled.png b/docs/history-settled.png new file mode 100644 index 0000000..dd4f8d4 Binary files /dev/null and b/docs/history-settled.png differ diff --git a/docs/history-single-row.png b/docs/history-single-row.png new file mode 100644 index 0000000..6c4bcce Binary files /dev/null and b/docs/history-single-row.png differ diff --git a/docs/keyboard.png b/docs/keyboard.png deleted file mode 100644 index b135332..0000000 Binary files a/docs/keyboard.png and /dev/null differ diff --git a/docs/mirror.png b/docs/mirror.png deleted file mode 100644 index 2435949..0000000 Binary files a/docs/mirror.png and /dev/null differ diff --git a/docs/responsive-motion.gif b/docs/responsive-motion.gif new file mode 100644 index 0000000..28dcb38 Binary files /dev/null and b/docs/responsive-motion.gif differ diff --git a/docs/responsive-preview.png b/docs/responsive-preview.png new file mode 100644 index 0000000..baeb47e Binary files /dev/null and b/docs/responsive-preview.png differ diff --git a/docs/responsive-settings.png b/docs/responsive-settings.png new file mode 100644 index 0000000..89dc58d Binary files /dev/null and b/docs/responsive-settings.png differ diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..fa0986f --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,29 @@ +# Current interface captures + +**These PNGs come from Pocket Term's native 3DS renderer in Azahar.** They use +the production UI and framework clock with deterministic example shell data +from `test/fixtures/screen.tsx`. They are not photographs or live shell traces. + +[captures.json](captures.json) records the runtime pin, fixture hash, commands, +dimensions and image hashes used for this capture set. + +| File | Content | Native dimensions | Build command | +| --- | --- | --- | --- | +| [terminal.png](terminal.png) | Shell, session tabs, touchpad and keyboard | 400×240 top screen; centered 320×240 lower screen | `bun run visual --showcase` | +| [settings.png](settings.png) | Font, preview and scroll-speed settings | 320×240 lower screen | `bun run visual --showcase --settings` | + +Both builds capture frame 40 using the separate `pocketterm-qa.3dsx` launcher. +Run that launcher in Azahar with a writable SD-card directory. The capture +appears under `pocketjs-captures/` as `f0040.raw` and `aux-f0040.raw`; `done` +indicates completion and `error.txt` reports a capture failure. + +Decode the column-major, vertically inverted ABGR capture at its native size. +The combined terminal PNG centers the lower screen beneath the top screen +with 40px side margins. Pixels are not filtered or rescaled. The settings PNG +contains only the lower-screen capture. + +These captures replace the earlier README interface images. The diagnostic +screenshots linked from [upgrade history](../OFFLOAD-UPGRADE.md) retain their +original revision context. [Batched history motion](../history-batched.gif) +and [provisional input](../responsive-preview.png) document those mechanisms +using their dedicated fixtures. diff --git a/docs/screenshots/captures.json b/docs/screenshots/captures.json new file mode 100644 index 0000000..57e1690 --- /dev/null +++ b/docs/screenshots/captures.json @@ -0,0 +1,29 @@ +{ + "renderer": "PocketJS native 3DS host in Azahar", + "data": "deterministic showcase fixture", + "capture_frame": 40, + "runtime": "1c0735faef54ce96484cf4dd31113b9d919fc23c", + "fixture_sha256": "a6a98e070c46ecf1ede9265e09244ef0a1177877111021de10f646f7221e3ae3", + "commands": [ + "bun run visual --showcase", + "bun run visual --showcase --settings" + ], + "images": [ + { + "file": "terminal.png", + "dimensions": [ + 400, + 480 + ], + "sha256": "e8d6a5f68d1bf349f1ae83f365d5e48a08cd0f26ea83c274c36dd30361589572" + }, + { + "file": "settings.png", + "dimensions": [ + 320, + 240 + ], + "sha256": "e804b1d303c08381d98703c660057142a2b5d80be91adce6d549624414f480c6" + } + ] +} diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..6948043 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/terminal.png b/docs/screenshots/terminal.png new file mode 100644 index 0000000..c3e7b4f Binary files /dev/null and b/docs/screenshots/terminal.png differ diff --git a/docs/terminal-80x24.png b/docs/terminal-80x24.png new file mode 100644 index 0000000..0c2fe95 Binary files /dev/null and b/docs/terminal-80x24.png differ diff --git a/host/exchange.ts b/host/exchange.ts new file mode 100644 index 0000000..10dcd9d --- /dev/null +++ b/host/exchange.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { LIMITS, fitsRecord, type ExchangeReply, type ExchangeRequest, type InputRequest, type InputReply } from "../shared/exchange.ts"; +import type { ClientLine, HostLine } from "../shared/protocol.ts"; + +// A held fragment can be retried after input advances the ack or reports +// an error. Reserve their largest encoding without changing held data. +const ERROR_RESERVE = "\u0000".repeat(120); + +/** A connection-independent replica. Replies remain until acknowledged; + * command ids are consumed once, including rejected commands. PTYs belong + * to the terminal worker, never to the reconnecting provider worker. */ +export class Mailbox { + identity = randomUUID(); + ack = 0; + sequence = 0; + touched = Date.now(); + private queue: string[] = []; + private chars = 0; + private head = 0; + private held?: { data: string; more: boolean }; + private error?: string; + private overflowed = false; + get busy() { return this.queue.length > 0 || this.held !== undefined; } + + push(line: HostLine) { + if (this.overflowed) return; + const text = JSON.stringify(line); + if (text.length > LIMITS.lineChars || this.chars + text.length > LIMITS.outputChars) { + // Retire only this delivery epoch. An inactive replica cannot exhaust + // the terminal process or terminate other users' PTYs. + this.identity = randomUUID(); this.ack = 0; this.sequence = 0; + this.queue = []; this.chars = 0; this.head = 0; this.held = undefined; + this.overflowed = true; return; + } + this.queue.push(text); this.chars += text.length; + } + + input(request: InputRequest, epoch: string, apply: (line: ClientLine) => void): InputReply { + this.touched = Date.now(); + if (request.epoch !== epoch) return { epoch, ack: this.ack }; + if (!Array.isArray(request.commands) || request.commands.length > LIMITS.inputBatch) throw new Error("Invalid input batch"); + let previous = 0, next = this.ack + 1; + for (const command of request.commands) { + if (!Number.isSafeInteger(command.id) || command.id < 1 || command.id <= previous || command.id > next) throw new Error("Command sequence gap"); + previous = command.id; if (command.id === next) next++; + } + let applying = false; + for (const command of request.commands) if (command.id === this.ack + 1) { + if (this.overflowed && command.line.t !== "hello") throw new Error("Replica hello required after overflow"); + if (!applying) { this.error = undefined; applying = true; } + this.overflowed = false; this.ack = command.id; + try { apply(command.line); } catch (error) { this.error ??= String(error).slice(0, 120); } + } + return { epoch, ack: this.ack, ...(this.error ? { error: this.error } : {}) }; + } + + exchange(request: ExchangeRequest, epoch: string, apply: (line: ClientLine) => void): ExchangeReply { + this.touched = Date.now(); + // A fresh handshake learns the daemon epoch before sending any input. + if (request.epoch !== epoch) return { epoch, ack: this.ack, sequence: this.sequence }; + if (!Number.isSafeInteger(request.received) || request.received < 0 || request.received > this.sequence) throw new Error("Invalid delivery acknowledgement"); + if (this.held && request.received === this.sequence) this.held = undefined; + if (request.command) this.input({ replica: request.replica, epoch: request.epoch, commands: [request.command] }, epoch, apply); + if (!this.held && this.queue.length) { + const text = this.queue[0]; + let low = 1, high = Math.min(text.length - this.head, LIMITS.fragmentChars), length = 1; + while (low <= high) { + const n = (low + high) >> 1; + if (fitsRecord(JSON.stringify({ epoch, ack: Number.MAX_SAFE_INTEGER, sequence: Number.MAX_SAFE_INTEGER, + data: text.slice(this.head, this.head + n), more: false, error: ERROR_RESERVE }), "term.exchange", true)) { length = n; low = n + 1; } + else high = n - 1; + } + let end = this.head + length; + // Do not cut a Unicode surrogate pair across JSON records. + const last = text.charCodeAt(end - 1); + if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--; + this.held = { data: text.slice(this.head, end), more: end < text.length }; + this.sequence++; + this.head = end; + if (end === text.length) { this.queue.shift(); this.chars -= text.length; this.head = 0; } + } + return { epoch, ack: this.ack, sequence: this.sequence, ...this.held, ...(this.error ? { error: this.error } : {}) }; + } +} diff --git a/host/glyphs.ts b/host/glyphs.ts index 2c508a7..e87495a 100644 --- a/host/glyphs.ts +++ b/host/glyphs.ts @@ -34,10 +34,12 @@ import { fileURLToPath } from "node:url"; // bundler-only `module` field (see framework/compiler/bake-font.ts). import opentype, { type Font } from "opentype.js"; import { bakeSlot } from "../vendor/pocketjs/framework/compiler/bake-font.ts"; -import { FONT_CMAP_ENTRY_SIZE, FONT_HEADER_SIZE } from "../vendor/pocketjs/contracts/spec/spec.ts"; -import { DYNAMIC_SLOTS, TERM_GLYPHS } from "../app/protocol.ts"; +import { FONT_CMAP_ENTRY_SIZE, FONT_HEADER_SIZE, FONT_MAGIC, FONT_VERSION } from "../vendor/pocketjs/contracts/spec/spec.ts"; +import { DYNAMIC_SLOTS, TERM_GLYPHS } from "../shared/protocol.ts"; +import { bitmapCell, type BitmapFont } from "../shared/bitmap-font.ts"; +import { bitmapFontSource, loadBitmapFont } from "../shared/font-sources.ts"; -const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../.."); +const ROOT = fileURLToPath(new URL("../vendor/pocketjs/", import.meta.url)); /** * The fallback chain, best face first. Each entry takes one atlas slot, so @@ -163,10 +165,10 @@ function loadFont(paths: readonly string[]): { font: Font; path: string } | null /** Glyphs kept per face. This bounds the bake, the transfer, and device * memory — the PICA200 expands atlas coverage to RGBA8, so the glyph grid's * power-of-two envelope costs 4 bytes per sample. */ -const MAX_GLYPHS = 224; +const MAX_GLYPHS = 1024; /** Nothing below this is legible; a too-small glyph still beats one that * paints over the column beside it. */ -const MIN_PX = 7; +const MIN_PX = 4; export interface BakedDynamicAtlas { slot: number; @@ -179,7 +181,7 @@ export interface BakedDynamicAtlas { class FaceAtlas { readonly slot: number; readonly label: string; - readonly font: Font; + readonly font: Font | BitmapFont; readonly path: string; /** codepoint -> cell columns, in last-seen order (the eviction order). */ #wanted = new Map(); @@ -187,7 +189,7 @@ class FaceAtlas { #gen = 0; #baked: BakedDynamicAtlas | null = null; - constructor(slot: number, label: string, font: Font, path: string) { + constructor(slot: number, label: string, font: Font | BitmapFont, path: string) { this.slot = slot; this.label = label; this.font = font; @@ -202,8 +204,10 @@ class FaceAtlas { return this.#baked; } - covers(cp: number): boolean { - return this.font.charToGlyphIndex(String.fromCodePoint(cp)) !== 0; + covers(cp: number, columns: number): boolean { + if ("ascent" in this.font) { const glyph = this.font.glyphs.get(cp); return !!glyph && glyph.advance <= columns * 5 && glyph.pixels.some(p => p !== 0); } + const text = String.fromCodePoint(cp); + return this.font.charToGlyphIndex(text) !== 0 && this.font.charToGlyph(text).getPath(0, 0, 16).commands.length > 0; } want(cp: number, columns: number): void { @@ -221,6 +225,11 @@ class FaceAtlas { this.#wanted.delete(oldest.value); } const chars = [...this.#wanted.keys()].sort((a, b) => a - b); + if ("ascent" in this.font) { + const bytes = bakeBitmapAtlas(this.font, this.slot, this.#wanted, cellW, cellH); + this.#dirty = false; + return this.#baked = { slot: this.slot, gen: ++this.#gen, bytes, glyphCount: chars.length + 1, px: 10 }; + } // The cell has to fit the narrowest span in this atlas: one atlas has one // cell size, and a two-column glyph's box would clip a one-column one's // neighbour. @@ -255,11 +264,12 @@ class FaceAtlas { export class DynamicAtlasSet { #faces: FaceAtlas[] | null = null; /** Resolved routing, so a repeat codepoint costs a map read. */ - #route = new Map(); + #route = new Map(); #ensure(): FaceAtlas[] { if (this.#faces !== null) return this.#faces; - const faces: FaceAtlas[] = []; + const path = fileURLToPath(bitmapFontSource("fusion")); + const faces: FaceAtlas[] = [new FaceAtlas(DYNAMIC_SLOTS[0], "fusion-pixel", loadBitmapFont("fusion"), path)]; for (const rung of FONT_CHAIN) { if (faces.length >= DYNAMIC_SLOTS.length) break; const found = loadFont(rung.paths); @@ -279,21 +289,19 @@ export class DynamicAtlasSet { /** The slot that can draw this codepoint, or -1 for nobody. Unbaked and * uncovered means the caller must keep the grid honest itself. */ - slotFor(cp: number): number { + slotFor(cp: number, columns = 2): number { if (isBakedCodepoint(cp)) return -1; - const known = this.#route.get(cp); + const key = `${cp}/${columns}`, known = this.#route.get(key); if (known !== undefined) return known; - const text = String.fromCodePoint(cp); for (const face of this.#ensure()) { - if (face.font.charToGlyphIndex(text) === 0) continue; // A cmap hit with no outline (a colour-bitmap emoji face) would bake a // blank cell, which reads as a dropped character rather than a missing // one. - if (face.font.charToGlyph(text).getPath(0, 0, 16).commands.length === 0) continue; - this.#route.set(cp, face.slot); + if (!face.covers(cp, columns)) continue; + this.#route.set(key, face.slot); return face.slot; } - this.#route.set(cp, -1); + this.#route.set(key, -1); return -1; } @@ -333,6 +341,23 @@ export class DynamicAtlasSet { } } +export function bakeBitmapAtlas(font: BitmapFont, slot: number, wanted: ReadonlyMap, cellW: number, cellH: number): Uint8Array { + const chars = [...new Set([...wanted.keys(), 0xfffd])].sort((a, b) => a - b); + const width = cellW * 2, size = width * cellH, start = 16 + chars.length * 8; + const bytes = new Uint8Array(start + chars.length * size), view = new DataView(bytes.buffer); + view.setUint32(0, FONT_MAGIC, true); view.setUint16(4, FONT_VERSION, true); view.setUint16(6, chars.length, true); + bytes.set([width, cellH, 9, cellH, slot, 0, 1, 0], 8); + let next = 1; + for (let i = 0; i < chars.length; i++) { + const cp = chars[i], gid = cp === 0xfffd ? 0 : next++, at = 16 + i * 8; + view.setUint32(at, cp, true); view.setUint16(at + 4, gid, true); bytes[at + 6] = (wanted.get(cp) ?? 1) * cellW; + const pixels = bitmapCell(font, cp, width, cellH); + if (cp !== 0xfffd && pixels) bytes.set(pixels, start + gid * size); + } + for (let y = 1; y < cellH - 1; y++) for (let x = 0; x < cellW - 1; x++) if (x === 0 || x === cellW - 2 || y === 1 || y === cellH - 2) bytes[start + y * width + x] = 255; + return bytes; +} + /** Rewrite every cmap entry's advance to `columns * cellW` (spec.ts FONT * ATLAS v3 cmap: u32 codepoint, u16 gid, u8 advance, u8 xoff). */ export function forceAdvances( diff --git a/host/grid.ts b/host/grid.ts index f3cc40e..1bdc25f 100644 --- a/host/grid.ts +++ b/host/grid.ts @@ -1,10 +1,7 @@ -// host/grid.ts — cell resolution, run building and row chunking: -// how the authoritative xterm buffer becomes the wire's Run/RowUpdate shapes. -// Dependency-free on purpose — serve.ts feeds it real IBufferCells through -// the structural XtermCellLike interface, tests feed it fakes, and the repo -// typecheck gate never needs the daemon's native modules. +// libghostty cells to bounded row runs; terminal colors and widths remain +// authoritative. The renderer applies the resolved colors and cell geometry. -import { LINE_BUDGET, THEME_BG, THEME_FG, type Run, type RowUpdate } from "../app/protocol.ts"; +import { LINE_BUDGET, THEME_BG, THEME_FG, type Run, type RowUpdate } from "../shared/protocol.ts"; /** The slice of libghostty's CellData this module reads (@wterm/core). The * core resolves colour itself — palette lookup, bright-bold, the configured @@ -86,7 +83,7 @@ function halve(rgb: number): number { */ export function resolveCell(cell: TerminalCell): Cell { const width = (cell.width ?? 1) as 0 | 1 | 2; - const raw = width === 0 ? "" : (cell.chars ?? String.fromCodePoint(cell.char || 32)); + const raw = width === 0 ? "" : (cell.chars ?? String.fromCodePoint(cell.char || 32)).normalize("NFC"); // Normalize the other spaces to the ordinary one here, so everything // downstream — run merging, glyph routing — sees a blank. const first = raw.codePointAt(0); @@ -111,7 +108,7 @@ export function resolveCell(cell: TerminalCell): Cell { export function rowRuns(cells: readonly Cell[]): Run[] { const runs: Run[] = []; let run: - | { col: number; text: string; fg: number; bg: number; slot?: number; span: number } + | { col: number; text: string; fg: number; bg: number; slot?: number; span: number; columns: number } | null = null; let pendingBlanks = 0; const close = () => { @@ -146,6 +143,7 @@ export function rowRuns(cells: readonly Cell[]): Run[] { run.fg === cell.fg && run.bg === cell.bg && run.slot === cell.slot && + (cell.slot === undefined || run.columns === columns) && (pendingBlanks === 0 || (cell.slot === undefined && pendingBlanks <= 4)) ) { run.text += " ".repeat(pendingBlanks) + ch; @@ -154,7 +152,7 @@ export function rowRuns(cells: readonly Cell[]): Run[] { continue; } close(); - run = { col, text: ch, fg: cell.fg, bg: cell.bg, slot: cell.slot, span: columns }; + run = { col, text: ch, fg: cell.fg, bg: cell.bg, slot: cell.slot, span: columns, columns }; } close(); return runs; diff --git a/host/history-batch.ts b/host/history-batch.ts new file mode 100644 index 0000000..e0ac690 --- /dev/null +++ b/host/history-batch.ts @@ -0,0 +1,40 @@ +import { fitsRecord } from "../shared/exchange.ts"; +import { HISTORY, HISTORY_BATCH, type HistoryBatchReply, type HistoryBatchRequest } from "../shared/history.ts"; + +export function validateHistoryBatch(input: HistoryBatchRequest) { + if (!input || !Number.isSafeInteger(input.sid) || input.sid < 1 || typeof input.epoch !== "string" || + !input.epoch.length || input.epoch.length > 96 || !Array.isArray(input.rows) || !input.rows.length || + input.rows.length > HISTORY_BATCH.rows || new Set(input.rows).size !== input.rows.length || + input.rows.some(row => !Number.isSafeInteger(row) || row < 0) || + !Number.isSafeInteger(input.offset) || input.offset < 0 || input.offset >= HISTORY.rowChars) throw new Error("Invalid history batch"); +} + +/** Fill the actual nested JSON budget. Rows remain individually addressed; + * neither a short tail nor pruning can change the meaning of a cached row. */ +export function historyBatchReply(input: HistoryBatchRequest, read: (row: number) => string): HistoryBatchReply { + validateHistoryBatch(input); + const reply: HistoryBatchReply = { epoch: input.epoch, chunks: [] }; + for (let i = 0; i < input.rows.length; i++) { + const row = input.rows[i], offset = i ? 0 : input.offset, raw = read(row); + if (!raw.length || raw.length > HISTORY.rowChars || offset >= raw.length) throw new Error("Invalid history row offset"); + const chunk: HistoryBatchReply["chunks"][number] = [row, offset, raw.slice(offset), false]; + reply.chunks.push(chunk); + if (fitsRecord(JSON.stringify(reply), "term.history.batch", true)) continue; + // Avoid making a small row wait for another RTT merely to use the last + // few bytes of this reply. Large rows alone use exact-offset fragments. + if (reply.chunks.length > 1) { reply.chunks.pop(); break; } + let low = 1, high = Math.min(raw.length - offset, HISTORY_BATCH.fragmentChars), length = 0; + while (low <= high) { + const n = (low + high) >> 1; + // Reserve the longer boolean spelling even if this becomes a tail. + chunk[2] = raw.slice(offset, offset + n); chunk[3] = false; + if (fitsRecord(JSON.stringify(reply), "term.history.batch", true)) { length = n; low = n + 1; } + else high = n - 1; + } + if (length && /[\uD800-\uDBFF]/.test(raw[offset + length - 1]) && offset + length < raw.length) length--; + if (!length) throw new Error("History fragment exceeds budget"); + chunk[2] = raw.slice(offset, offset + length); chunk[3] = offset + length < raw.length; + break; + } + return reply; +} diff --git a/host/history.ts b/host/history.ts new file mode 100644 index 0000000..f6332df --- /dev/null +++ b/host/history.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { HISTORY, type HistoryManifest } from "../shared/history.ts"; + +/** Only observes boundaries that may replace old rows. Ghostty still parses + * every byte and owns all terminal state. This survives split sequences and + * ignores escape-looking text in OSC/DCS strings. A reset followed by enough + * output in one PTY chunk must not silently reuse earlier row addresses. */ +export class HistoryBoundary { + private state: "ground" | "escape" | "csi" | "string" | "string-escape" = "ground"; + private csi = ""; + private osc: string | undefined; + private oscOverflow = false; + private finishString(): boolean { + const [code, ...params] = (this.osc ?? "").split(";"); + this.osc = undefined; + // Palette/default-color writes reinterpret already stored cells. Color + // queries and titles must not repeatedly invalidate an editor's history. + return ["104", "110", "111"].includes(code) || + code === "4" && (this.oscOverflow || params.some((value, n) => n % 2 === 1 && value !== "?")) || + ["10", "11"].includes(code) && (this.oscOverflow || params.some(value => value !== "?" && value !== "")); + } + feed(text: string): boolean { + let reset = false; + for (const ch of text) { + if (this.state === "string" || this.state === "string-escape") { + if (ch === "\x07" || ch === "\x9c" || this.state === "string-escape" && ch === "\\") { + if (this.finishString()) reset = true; + this.state = "ground"; + } else { + if (ch !== "\x1b" && this.osc !== undefined) { + if (this.osc.length < 256) this.osc += ch; + else this.oscOverflow = true; + } + this.state = ch === "\x1b" ? "string-escape" : "string"; + } + continue; + } + if (ch === "\x18" || ch === "\x1a") { this.state = "ground"; continue; } + if (ch === "\x1b") { this.state = "escape"; continue; } + if (ch === "\x9b") { this.state = "csi"; this.csi = ""; continue; } + if (ch === "\x9d") { this.state = "string"; this.osc = ""; this.oscOverflow = false; continue; } + if (this.state === "escape") { + if (ch === "[") { this.state = "csi"; this.csi = ""; } + else if ("]PX^_".includes(ch)) { this.state = "string"; this.osc = ch === "]" ? "" : undefined; this.oscOverflow = false; } + else { if (ch === "c") reset = true; this.state = "ground"; } + } else if (this.state === "csi") { + if (ch >= "@" && ch <= "~") { + if (ch === "J" && this.csi.split(";").includes("3") || + ch === "p" && this.csi === "!" || + (ch === "h" || ch === "l") && /^\?/.test(this.csi) && this.csi.slice(1).split(";").some(p => ["3", "47", "1047", "1049"].includes(p))) reset = true; + this.state = "ground"; + } else if (this.csi.length < 128) this.csi += ch; + else { reset = true; this.state = "ground"; } + } + } + return reset; + } +} + +export interface HistoryCore { getScrollbackCount(): number; getScrollbackDiscardedCount(): number; usingAltScreen(): boolean } + +export class SessionHistory { + private readonly identity = randomUUID(); + private generation = 0; + private count = 0; + private discarded = 0; + private alternate = false; + private readonly boundary = new HistoryBoundary(); + private reset = false; + observe(text: string) { if (this.boundary.feed(text)) this.reset = true; } + invalidate() { this.reset = true; } + update(core: HistoryCore): HistoryManifest { + const count = core.getScrollbackCount(), discarded = core.getScrollbackDiscardedCount(), alternate = core.usingAltScreen(); + if (this.reset || alternate !== this.alternate || discarded < this.discarded || count + discarded < this.count + this.discarded) this.generation++; + this.reset = false; this.count = count; this.discarded = discarded; this.alternate = alternate; + return this.manifest(); + } + manifest(): HistoryManifest { + const end = this.discarded + this.count; + return { epoch: `${this.identity}:${this.generation}`, first: this.alternate ? end : Math.max(this.discarded, end - HISTORY.rows), end, alternate: this.alternate }; + } + /** Ghostty indexes history newest-first; the wire addresses oldest-first. */ + offset(row: number, epoch: string): number { + const m = this.manifest(); + if (m.alternate || epoch !== m.epoch || !Number.isSafeInteger(row) || row < m.first || row >= m.end) throw new Error("History range expired"); + return m.end - 1 - row; + } +} diff --git a/host/keys.ts b/host/keys.ts index 6ccf6fa..cf0f418 100644 --- a/host/keys.ts +++ b/host/keys.ts @@ -1,6 +1,5 @@ // host/keys.ts — semantic input encoding, on the authority side -// (the zhongduan discipline: the client sends key names, the host turns them -// into PTY bytes in the same actor order as everything else it writes). +// The guest sends key names; encoding runs beside the PTY terminal modes. const NAMED: Record = { Enter: { normal: "\r" }, @@ -27,25 +26,16 @@ function controlByte(ch: string): string { /** A named key, or a single character carrying ctrl/alt, to PTY bytes. * `appCursor` = DECCKM (vim and friends flip the arrow encoding). */ -export function encodeKey(k: string, ctrl: boolean, alt: boolean, appCursor: boolean): string { - let bytes: string; - const named = NAMED[k]; - if (named !== undefined) { - bytes = appCursor && named.app ? named.app : named.normal; - if (ctrl) { - if (bytes.length === 1) { - // Space, Enter and Tab already ARE C0 bytes or map onto one, which is - // how ctrl-space reaches a shell as NUL. - bytes = controlByte(bytes); - } else if (bytes.startsWith("\x1b[") && bytes.length === 3) { - // Modified CSI arrows: ESC [ 1 ; 5 . - bytes = `\x1b[1;5${bytes[2]}`; - } - } - } else if (k.length === 1) { - bytes = ctrl ? controlByte(k) : k; - } else { - return ""; - } - return alt ? `\x1b${bytes}` : bytes; +export function encodeKey(k: string, ctrl: boolean, alt: boolean, appCursor: boolean, shift = false): string { + const modifier = 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0); + const finals: Record = { Up: "A", Down: "B", Right: "C", Left: "D", Home: "H", End: "F" }; + if (finals[k]) return modifier > 1 ? `\x1b[1;${modifier}${finals[k]}` : `${appCursor ? "\x1bO" : "\x1b["}${finals[k]}`; + const tilde: Record = { Insert: 2, Delete: 3, PageUp: 5, PageDown: 6, F5: 15, F6: 17, F7: 18, F8: 19, F9: 20, F10: 21, F11: 23, F12: 24 }; + if (tilde[k]) return `\x1b[${tilde[k]}${modifier > 1 ? `;${modifier}` : ""}~`; + const fn: Record = { F1: "P", F2: "Q", F3: "R", F4: "S" }; + if (fn[k]) return modifier > 1 ? `\x1b[1;${modifier}${fn[k]}` : `\x1bO${fn[k]}`; + if (k === "Tab" && shift) return "\x1b[Z"; + const bytes = NAMED[k]?.normal ?? ([...k].length === 1 ? k : ""); + if (!bytes) return ""; + return (alt ? "\x1b" : "") + (ctrl ? controlByte(bytes) : bytes); } diff --git a/host/package.json b/host/package.json index 964b5aa..eef5436 100644 --- a/host/package.json +++ b/host/package.json @@ -1,7 +1,7 @@ { "name": "pocketjs-term-host", "private": true, - "description": "Mac companion daemon for Pocket Term: PTY sessions + authoritative libghostty cores served to PocketJS devices over the SVC WIRE (PKNT) transport.", + "description": "Paired offload provider and durable Node PTY sessions for Pocket Term, with libghostty and loopback desktop mirrors.", "type": "module", "scripts": { "serve": "node serve.ts" diff --git a/host/provider.ts b/host/provider.ts new file mode 100644 index 0000000..f7944bf --- /dev/null +++ b/host/provider.ts @@ -0,0 +1,7 @@ +import { connectOffloadProvider } from "@pocketjs/framework/offload/provider"; +const config = JSON.parse(process.env.POCKET_TERM_PROVIDER!); +const provider = connectOffloadProvider({ + address: config.address, port: config.port, key: config.key, worker: new URL("./worker.ts", import.meta.url), + data: { endpoint: config.endpoint, token: config.token }, log: console.log, +}); +process.on("SIGTERM", () => { provider.close(); process.exit(0); }); diff --git a/host/serve.ts b/host/serve.ts index 94331b8..ae0a08f 100644 --- a/host/serve.ts +++ b/host/serve.ts @@ -1,923 +1,36 @@ -// host/serve.ts — the Mac companion daemon for Pocket Term. -// -// bun install (once, in this directory — node-pty + @wterm/ghostty) -// node serve.ts [--port 8622] [--beacon-port 8621] [--name ] -// [--shell /bin/zsh] [--unicast ] [--no-beacon] -// -// Node (>= 23.6, for native type stripping), NOT bun: under Bun this -// machine's node-pty never execs the child — its spawn-helper blocks forever -// in the slave-reattach open() — while the same call under Node works. The -// PTY is the one dependency that dictates the runtime; everything else here -// is runtime-neutral. -// -// Architecture (the zhongduan shape, collapsed to one process): this daemon -// holds the real PTYs and one authoritative libghostty core per session -// — the role Ghostty WASM plays there — and 3DS clients attach as passive -// replicas over the SVC WIRE (PKNT) TCP transport. A UDP beacon on the LAN -// makes discovery zero-config; grid state flows as a full snapshot on attach -// plus ordered row diffs after, all inside gen/seq fences so a lossy device -// queue degrades into a resync, never into a wrong screen. - -import { createServer, type Socket } from "node:net"; -import { createSocket } from "node:dgram"; -import { hostname } from "node:os"; -import { chmodSync, existsSync, readFileSync, statSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; +/** Supervisor: native PTYs run under Node; PocketJS's provider uses Bun + * workers. The durable terminal process outlives every device connection. */ +import { fork, spawn, type ChildProcess } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn, type ChildProcess } from "node:child_process"; -import pty from "node-pty"; -import { GhosttyCore } from "@wterm/ghostty"; -import { - LINE_BUDGET, - TERM_APP, - TERM_PROTO, - type ClientLine, - type Cursor, - type HostLine, - type Role, - type RowUpdate, - type Run, - type SessionInfo, -} from "../app/protocol.ts"; -import { chunkRows, resolveCell, rowKey, rowRuns, type Cell } from "./grid.ts"; -import { DynamicAtlasSet, isBakedCodepoint } from "./glyphs.ts"; -import { encodeKey } from "./keys.ts"; -import { - FrameParser, - WIRE_BEACON_PORT, - WIRE_MSG, - WIRE_PORT, - encodeBeacon, - encodeCtrl, - encodeFrame, - encodeHelloAck, - parseHello, -} from "./wire.ts"; - -// --------------------------------------------------------------------------- -// options -// --------------------------------------------------------------------------- - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(HERE, ".."); -/** The desktop host binary, which is also the `linux-app` one. */ -const MIRROR_BIN = join(ROOT, "vendor/pocketjs/hosts/desktop/target/release/pocket-desktop-host"); -const MIRROR_APP = "pocketterm-mirror-main"; - -const options = { - port: WIRE_PORT, - beaconPort: WIRE_BEACON_PORT, - name: hostname().replace(/\.local$/, ""), - shell: process.env.SHELL ?? "/bin/zsh", - cwd: process.env.HOME ?? process.cwd(), - unicast: [] as string[], - beacon: true, - /** Open a read-only desktop window per session. */ - mirror: true, -}; - -{ - const argv = process.argv.slice(2); - for (let i = 0; i < argv.length; i += 1) { - const a = argv[i]; - if (a === "--port") options.port = Number(argv[++i]); - else if (a === "--beacon-port") options.beaconPort = Number(argv[++i]); - else if (a === "--name") options.name = argv[++i]; - else if (a === "--shell") options.shell = argv[++i]; - else if (a === "--cwd") options.cwd = argv[++i]; - else if (a === "--unicast") options.unicast.push(argv[++i]); - else if (a === "--no-beacon") options.beacon = false; - else if (a === "--no-mirror") options.mirror = false; - else { - console.error(`unknown argument: ${a}`); - process.exit(2); - } - } -} - -// Bun's installer drops the executable bit on prebuilt binaries; node-pty's -// posix_spawn of its helper then fails with a bare "posix_spawnp failed". -for (const helper of [ - join( - dirname(fileURLToPath(import.meta.url)), - "node_modules/node-pty/prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", - ), -]) { - if (existsSync(helper) && (statSync(helper).mode & 0o111) === 0) chmodSync(helper, 0o755); -} - -// --------------------------------------------------------------------------- -// sessions — PTY + authoritative terminal core -// --------------------------------------------------------------------------- - -const SCROLLBACK = 2000; - -/** The core loads its WASM by fetching a URL, and Node's fetch has no `file:` - * scheme — so the module is read once at startup and handed over as a data - * URL. Reading it here also fails loudly at boot rather than on the first - * session. */ -const ghosttyWasmUrl = (() => { - const path = createRequire(import.meta.url).resolve("@wterm/ghostty/ghostty-vt.wasm"); - return `data:application/wasm;base64,${readFileSync(path).toString("base64")}`; -})(); -class Session { - readonly sid: number; - title: string; - readonly pty: pty.IPty; - /** The authority. Null only for the moment between spawning the shell and - * the core's WASM finishing instantiation; bytes wait in `backlog`. */ - core: GhosttyCore | null = null; - #backlog: string[] = []; - cols: number; - rows: number; - disposed = false; - - constructor(sid: number, cols: number, rows: number) { - this.sid = sid; - this.cols = cols; - this.rows = rows; - this.title = `${options.shell.split("/").pop()} #${sid}`; - this.pty = pty.spawn(options.shell, ["-il"], { - name: "xterm-256color", - cols, - rows, - cwd: options.cwd, - env: { ...process.env, TERM: "xterm-256color" } as Record, +const args = process.argv.slice(2), terminalArgs: string[] = []; +let address = "", keyPath = ".pocket/offload.key"; +for (let i = 0; i < args.length; i++) { + if (args[i] === "--device" || args[i] === "--unicast") address = args[++i]; + else if (args[i] === "--key") keyPath = args[++i]; + else terminalArgs.push(args[i]); +} +const key = address ? readFileSync(resolve(keyPath), "utf8").trim() : ""; +if (address && !/^[0-9a-f]{64}$/.test(key)) throw new Error("Expected a paired offload key; run bun run pair --host "); +const worker = fork(fileURLToPath(new URL("./terminal-worker.ts", import.meta.url)), terminalArgs, { stdio: ["inherit", "inherit", "inherit", "ipc"] }); +let provider: ChildProcess | undefined; +let stopping = false; +worker.on("message", (message: any) => { + if (message.ready && address) { + provider = spawn("bun", [fileURLToPath(new URL("./provider.ts", import.meta.url))], { + stdio: "inherit", env: { ...process.env, POCKET_TERM_PROVIDER: JSON.stringify({ address, key, ...message }) }, }); - this.pty.onData((data) => this.#parse(data)); - this.pty.onExit(() => hub.sessionExited(this.sid)); - - void GhosttyCore.load({ wasmPath: ghosttyWasmUrl, scrollbackLimit: SCROLLBACK }) - .then((core) => { - if (this.disposed) return; - core.init(this.cols, this.rows); - this.core = core; - const waiting = this.#backlog; - this.#backlog = []; - for (const chunk of waiting) this.#parse(chunk); - scheduleFlush(); - }) - .catch((error: unknown) => { - console.error(`[term] session #${sid}: terminal core failed to load: ${String(error)}`); - }); - } - - /** Feed the core, then let it answer. libghostty parses synchronously, so - * the buffer this serializes reflects the bytes the moment write returns. */ - #parse(data: string): void { - const core = this.core; - if (core === null) { - this.#backlog.push(data); - return; - } - core.writeString(data); - // Programs ask the terminal questions — what are you, where is the - // cursor, what colours do you use — and wait for the answer. The core - // composes the replies; nobody but us can put them back on the PTY. - for (let guard = 0; guard < 16; guard += 1) { - const response = core.getResponse(); - if (response === null || response === "") break; - this.pty.write(response); - } - this.refreshTitle(); - scheduleFlush(); + provider.on("error", error => { console.error(error.message); stop(1); }); + provider.on("exit", code => { if (!stopping) stop(code || 1); }); } - - /** - * What the tab says. A program that sets a window title means it, so that - * wins; otherwise the tab shows what is actually running in the session — - * the thing a multiplexer's tabs are for, and the answer the PTY's - * foreground process group gives directly. - * - * The core deliberately ignores OSC 1, the icon name, which is all many - * prompts set. Reading the process is both more accurate and independent - * of whether the shell announces anything at all. - */ - refreshTitle(): void { - const explicit = this.core?.getTitle(); - const label = - explicit !== null && explicit !== undefined && explicit.trim() !== "" - ? explicit.trim() - : (this.pty.process || options.shell.split("/").pop() || "shell"); - const next = `${label.slice(0, 24)} #${this.sid}`; - if (next === this.title) return; - this.title = next; - hub.sessionsChanged(); - } - - resize(cols: number, rows: number) { - if (this.cols === cols && this.rows === rows) return; - this.cols = cols; - this.rows = rows; - this.core?.resize(cols, rows); - this.pty.resize(cols, rows); - scheduleFlush(); - } - - write(data: string) { - if (data.length > 0) this.pty.write(data); - } - - /** Paste as one bracketed block when the program asked for it, so an editor - * inserts text instead of interpreting every character as a command. */ - paste(text: string) { - if (text.length === 0) return; - if (this.core?.bracketedPaste()) this.pty.write(`\x1b[200~${text}\x1b[201~`); - else this.pty.write(text); - } - - appCursor(): boolean { - return this.core?.cursorKeysApp() ?? false; - } - - cursorHidden(): boolean { - return this.core ? !this.core.getCursor().visible : false; - } - - dispose() { - if (this.disposed) return; - this.disposed = true; - try { - this.pty.kill(); - } catch { - /* already gone */ - } - } -} - -// --------------------------------------------------------------------------- -// connections — one attached replica each -// --------------------------------------------------------------------------- - -const PING_INTERVAL_MS = 2000; -const SILENCE_TIMEOUT_MS = 10_000; -/** Coalescing delay after a session produces output. A terminal's echo is a - * round trip through here, so this sits directly in the gap between a - * keystroke and the character appearing: it is a batching window, not a - * polling rate. A byte-at-a-time echo pays 2 ms; a screenful of output - * still collapses into one pass. */ -const FLUSH_COALESCE_MS = 2; -/** Backstop for the changes no PTY byte announces — a scrollback scrub, a - * cursor that moved because a replica attached. */ -const FLUSH_IDLE_MS = 100; - -/** Base64 per atlas chunk. The device's svc transport discards any ctrl - * frame over SVC_POLL_BUF (8192) bytes outright, so a chunk plus its JSON - * wrapper has to stay well inside the line budget. */ -const ATLAS_CHUNK = 5600; - -class Conn { - readonly socket: Socket; - readonly parser = new FrameParser(); - hello: Uint8Array | null = new Uint8Array(0); - role: Role = "device"; - cols = 80; - rows = 24; - cell: [number, number] = [7, 13]; - attachedSid = -1; - /** Atlas generation this replica holds per slot, and the chunks still owed - * to it for the slot in flight. One chunk goes out per flush tick: the - * device's line queue is 32 KiB and drops its oldest entries when full, - * so a whole atlas dumped at once would take the grid updates with it. */ - atlasSent = new Map(); - atlasSlot = 0; - atlasGen = -1; - atlasQueue: string[] = []; - atlasSeq = 0; - gen = 0; - seq = 0; - scrollback = 0; - rowCache: string[] = []; - lastCursor = ""; - lastRx = Date.now(); - sawClientHello = false; - - constructor(socket: Socket) { - this.socket = socket; - } - - sendLine(line: HostLine) { - this.socket.write(encodeCtrl(JSON.stringify(line))); - } - - /** Emit rows (+cursor/scrollback trailer) as ordered, chunked grid lines. */ - sendGrid(updates: RowUpdate[], cursor: Cursor, full: boolean) { - const chunks = chunkRows(updates, LINE_BUDGET); - for (let i = 0; i < chunks.length; i += 1) { - const last = i === chunks.length - 1; - this.sendLine({ - t: "grid", - sid: this.attachedSid, - gen: this.gen, - seq: this.seq++, - ...(full ? { full: 1 as const } : {}), - ...(last ? {} : { more: 1 as const }), - rows: chunks[i], - ...(last ? { cur: cursor, sb: this.scrollback } : {}), - }); - } - } -} - -// --------------------------------------------------------------------------- -// the hub -// --------------------------------------------------------------------------- - -class Hub { - readonly sessions = new Map(); - readonly conns = new Set(); - private nextSid = 1; - /** Sessions whose mirror window has been opened but has not connected back - * yet. A mirror binds to the head of this queue, so windows and sessions - * pair up in the order they were created. */ - private pendingMirrors: number[] = []; - /** Set by the mirror supervisor; called once per new session. */ - onSessionCreated: ((sid: number) => void) | null = null; - - create(cols: number, rows: number): Session { - const session = new Session(this.nextSid++, cols, rows); - this.sessions.set(session.sid, session); - this.sessionsChanged(); - // A replica left with nothing attached — the empty state after the last - // session closed — shows the first session that appears, whoever opened - // it. Without this it would sit at the empty state while sessions exist, - // which is the empty state lying. Mirrors are bound through the pending - // queue instead and are left alone here. - for (const conn of this.conns) { - if (conn.role === "device" && conn.sawClientHello && conn.attachedSid < 0) { - attach(conn, session.sid); - } - } - if (this.onSessionCreated) { - this.pendingMirrors.push(session.sid); - this.onSessionCreated(session.sid); - } - return session; - } - - /** Claim the session a connecting mirror belongs to: the one it names if - * it still exists, else the oldest window still waiting for its session, - * else the newest session. - * - * The last fallback matters for a window that outlived the companion — - * its own reconnect loop brings it back, and refusing it would leave it - * reconnecting forever against a window showing nothing. */ - takePendingMirror(want?: number): number | undefined { - if (want !== undefined && this.sessions.has(want)) { - const at = this.pendingMirrors.indexOf(want); - if (at >= 0) this.pendingMirrors.splice(at, 1); - return want; - } - while (this.pendingMirrors.length > 0) { - const sid = this.pendingMirrors.shift(); - if (sid !== undefined && this.sessions.has(sid)) return sid; - } - return [...this.sessions.keys()].at(-1); - } - - kill(sid: number) { - const session = this.sessions.get(sid); - if (!session) return; - session.dispose(); - this.sessions.delete(sid); - closeMirror(sid); - this.reattachOrphans(sid); - this.sessionsChanged(); - } - - sessionExited(sid: number) { - const session = this.sessions.get(sid); - if (!session) return; - session.dispose(); - this.sessions.delete(sid); - closeMirror(sid); - for (const conn of this.conns) { - if (conn.sawClientHello) conn.sendLine({ t: "exit", sid }); - } - this.reattachOrphans(sid); - this.sessionsChanged(); - } - - private reattachOrphans(gone: number) { - for (const conn of this.conns) { - if (conn.attachedSid !== gone) continue; - if (conn.role === "mirror") { - // A mirror exists to show one session. When that session is over so - // is the window: it is closed rather than pointed at someone else's - // shell. - conn.socket.destroy(); - this.conns.delete(conn); - continue; - } - const fallback = [...this.sessions.values()].at(-1); - if (fallback === undefined) { - // Closing the last session leaves the replica with nothing attached, - // not with a shell it did not ask for. Opening one is a decision, - // and the operator just made the opposite one. - conn.attachedSid = -1; - conn.rowCache = []; - conn.lastCursor = ""; - continue; - } - attach(conn, fallback.sid); - } - } - - list(): SessionInfo[] { - return [...this.sessions.values()].map((s) => ({ sid: s.sid, title: s.title })); - } - - sessionsChanged() { - for (const conn of this.conns) { - if (conn.sawClientHello) { - conn.sendLine({ t: "sessions", list: this.list(), active: conn.attachedSid }); - } - } - } -} - -const hub = new Hub(); - -// --------------------------------------------------------------------------- -// the replica view: serialize, diff, flush -// --------------------------------------------------------------------------- - -/** Codepoints the device cannot draw from its baked atlas, noted as the grid - * is serialized so the next bake covers exactly what is on screen. */ -const atlas = new DynamicAtlasSet(); - -/** Classify a resolved cell against the device's glyph coverage: baked text - * passes through, a character some face in the chain can draw is routed to - * that face's slot, and one nobody can draw becomes a placeholder of the - * same column width — an unrenderable glyph must not also shift the rest of - * the row, which is what dropping it or leaving it to a missing atlas would - * do. */ -const uncovered = new Set(); - -function classify(cell: Cell): void { - if (cell.width === 0 || cell.ch === "" || cell.ch === " ") return; - const cp = cell.ch.codePointAt(0); - if (cp === undefined || isBakedCodepoint(cp)) return; - const columns = cell.width === 2 ? 2 : 1; - const slot = atlas.slotFor(cp); - if (slot >= 0) { - atlas.want(cp, columns, slot); - cell.slot = slot; - return; - } - // A placeholder is the last resort, and the operator should be able to - // find out which character it stood for rather than guess from a "?". - if (!uncovered.has(cp)) { - uncovered.add(cp); - console.log( - `[term] no face draws U+${cp.toString(16).toUpperCase().padStart(4, "0")} ` + - `(${cell.ch}) — showing a placeholder`, - ); - } - cell.ch = "?".repeat(columns); -} - -/** Resolve the connection's visible window of the session buffer to runs. */ -function viewRows(session: Session, conn: Conn): Run[][] { - const core = session.core; - const rows: Run[][] = []; - if (core === null) { - for (let y = 0; y < conn.rows; y += 1) rows.push([]); - return rows; - } - // Scrolled back, the top of the view comes out of history: the core - // indexes that directly, oldest line first, so the offset is arithmetic on - // its own count rather than on a viewport's base row. - const history = core.getScrollbackCount(); - const back = Math.max(0, Math.min(history, conn.scrollback)); - for (let y = 0; y < conn.rows; y += 1) { - const fromHistory = y < back; - const offset = history - back + y; - const cells: Cell[] = []; - for (let x = 0; x < conn.cols; x += 1) { - const cell = fromHistory ? core.getScrollbackCell(offset, x) : core.getCell(y - back, x); - const resolved = resolveCell(cell); - classify(resolved); - cells.push(resolved); - } - rows.push(rowRuns(cells)); - } - return rows; -} - -/** Re-bake when the screen has shown codepoints the current atlas lacks. The - * bake is debounced: a session that dumps a page of Chinese should cost one - * bake, not one per row. */ -let atlasDebounce = 0; -function pumpAtlasBake(): void { - if (!atlas.dirty) { - atlasDebounce = 0; - return; - } - atlasDebounce += 1; - if (atlasDebounce < 8) return; // ~250 ms at the flush interval - atlasDebounce = 0; - const conn = [...hub.conns].find((c) => c.sawClientHello); - const [cellW, cellH] = conn?.cell ?? [7, 13]; - for (const baked of atlas.bake(cellW, cellH)) { - console.log( - `[term] baked ${baked.glyphCount} glyphs at ${baked.px}px into slot ${baked.slot} ` + - `(${(baked.bytes.length / 1024).toFixed(1)} KiB)`, - ); - } -} - -/** Hand one atlas chunk to a replica per tick. Every face in the chain is - * delivered, one slot at a time. */ -function pumpAtlasSend(conn: Conn): void { - if (!conn.sawClientHello) return; - if (conn.atlasQueue.length === 0) { - for (const baked of atlas.current()) { - if (conn.atlasSent.get(baked.slot) === baked.gen) continue; - conn.atlasSent.set(baked.slot, baked.gen); - conn.atlasSlot = baked.slot; - conn.atlasGen = baked.gen; - conn.atlasSeq = 0; - const b64 = Buffer.from(baked.bytes).toString("base64"); - for (let at = 0; at < b64.length; at += ATLAS_CHUNK) { - conn.atlasQueue.push(b64.slice(at, at + ATLAS_CHUNK)); - } - break; // one atlas at a time; the next tick starts the next - } - } - const chunk = conn.atlasQueue.shift(); - if (chunk === undefined) return; - conn.sendLine({ - t: "atlas", - slot: conn.atlasSlot, - gen: conn.atlasGen, - seq: conn.atlasSeq++, - ...(conn.atlasQueue.length > 0 ? { more: 1 as const } : {}), - b64: chunk, - }); -} - -function cursorFor(session: Session, conn: Conn): Cursor { - const cursor = session.core?.getCursor(); - if (cursor === undefined) return [0, 0, 0]; - const visible = conn.scrollback === 0 && cursor.visible; - return [cursor.col, cursor.row, visible ? 1 : 0]; -} - -/** Full snapshot: a new gen, every row sent (blank rows included). */ -function snapshot(conn: Conn) { - const session = hub.sessions.get(conn.attachedSid); - if (!session) return; - conn.gen += 1; - conn.seq = 0; - const rows = viewRows(session, conn); - conn.rowCache = rows.map(rowKey); - const cursor = cursorFor(session, conn); - conn.lastCursor = JSON.stringify([cursor, conn.scrollback]); - const updates: RowUpdate[] = rows.map((runs, y) => [y, ...runs]); - conn.sendGrid(updates, cursor, true); -} - -function flush(conn: Conn) { - pumpAtlasSend(conn); - const session = hub.sessions.get(conn.attachedSid); - if (!session || !conn.sawClientHello) return; - const rows = viewRows(session, conn); - const updates: RowUpdate[] = []; - for (let y = 0; y < rows.length; y += 1) { - const key = rowKey(rows[y]); - if (conn.rowCache[y] !== key) { - conn.rowCache[y] = key; - updates.push([y, ...rows[y]]); - } - } - const cursor = cursorFor(session, conn); - const cursorKey = JSON.stringify([cursor, conn.scrollback]); - if (updates.length === 0 && cursorKey === conn.lastCursor) return; - conn.lastCursor = cursorKey; - conn.sendGrid(updates, cursor, false); -} - -function attach(conn: Conn, sid: number) { - if (!hub.sessions.has(sid)) return; - conn.attachedSid = sid; - conn.scrollback = 0; - conn.sendLine({ t: "sessions", list: hub.list(), active: sid }); - snapshot(conn); -} - -// --------------------------------------------------------------------------- -// client line handling -// --------------------------------------------------------------------------- - -function handleLine(conn: Conn, line: ClientLine) { - // A mirror types into its own session, and nothing else: the window exists - // for one session, so it may not re-point itself at another, open one, or - // close one. That binding is enforced here rather than trusted to the - // window's guest. - if (conn.role === "mirror" && (line.t === "attach" || line.t === "new" || line.t === "kill")) { - return; - } - switch (line.t) { - case "hello": { - if (line.proto !== TERM_PROTO) return; - conn.role = line.role ?? "device"; - conn.cols = Math.max(20, Math.min(200, line.cols)); - conn.rows = Math.max(5, Math.min(80, line.rows)); - if (line.cell) conn.cell = [Math.max(1, line.cell[0]), Math.max(1, line.cell[1])]; - conn.sawClientHello = true; - // A hello means a replica that has loaded nothing yet, which is not the - // same as a new socket: the console's transport is native and survives - // a guest reload, so the fresh guest arrives on the connection that - // already had an atlas. Re-send it or its CJK renders as blanks. - conn.atlasSent.clear(); - conn.atlasGen = -1; - conn.atlasQueue = []; - conn.atlasSeq = 0; - - if (conn.role === "mirror") { - // A mirror shows one session and changes nothing about it: it takes - // the session it was opened for, renders whatever size that session - // already is, and never resizes a PTY. - const sid = hub.takePendingMirror(line.want); - const session = sid === undefined ? undefined : hub.sessions.get(sid); - if (!session) { - conn.socket.destroy(); - hub.conns.delete(conn); - return; - } - conn.cols = session.cols; - conn.rows = session.rows; - conn.sendLine({ t: "hello", proto: TERM_PROTO, name: options.name, sid: session.sid }); - attach(conn, session.sid); - break; - } - - conn.sendLine({ t: "hello", proto: TERM_PROTO, name: options.name }); - // The demo convention: every session tracks the driving replica's grid - // (the tmux attach model, one window size at a time). Other replicas - // that were sized differently get a fresh snapshot at the new size. - for (const session of hub.sessions.values()) session.resize(conn.cols, conn.rows); - for (const other of hub.conns) { - if (other === conn || !other.sawClientHello) continue; - if (other.cols !== conn.cols || other.rows !== conn.rows) { - other.cols = conn.cols; - other.rows = conn.rows; - snapshot(other); - } - } - // A reconnecting console names the session it was on, so the window it - // comes back to is the one it left rather than whichever is newest. - const wanted = line.want !== undefined ? hub.sessions.get(line.want) : undefined; - const target = wanted ?? [...hub.sessions.values()].at(-1) ?? hub.create(conn.cols, conn.rows); - attach(conn, target.sid); - break; - } - case "new": { - const session = hub.create(conn.cols, conn.rows); - attach(conn, session.sid); - break; - } - case "kill": - hub.kill(line.sid); - break; - case "attach": - attach(conn, line.sid); - break; - case "ch": { - const session = hub.sessions.get(conn.attachedSid); - if (session && line.s.length <= 1024) { - if (conn.scrollback !== 0) conn.scrollback = 0; // typing snaps to live - // More than one character at a time is a paste, not typing, and a - // program that asked for bracketed paste wants to be told which. - if (line.s.length > 1) session.paste(line.s); - else session.write(line.s); - } - break; - } - case "key": { - const session = hub.sessions.get(conn.attachedSid); - if (!session) break; - if (conn.scrollback !== 0) conn.scrollback = 0; - session.write(encodeKey(line.k, line.ctrl === 1, line.alt === 1, session.appCursor())); - break; - } - case "scroll": { - const session = hub.sessions.get(conn.attachedSid); - if (!session) break; - const max = session.core?.getScrollbackCount() ?? 0; - conn.scrollback = Math.max(0, Math.min(max, conn.scrollback + line.d)); - // Scrolling changes this replica's view without any PTY byte to - // announce it. - scheduleFlush(); - break; - } - case "resync": - snapshot(conn); - break; - } -} - -// --------------------------------------------------------------------------- -// wire server + beacon -// --------------------------------------------------------------------------- - -const server = createServer((socket) => { - socket.setNoDelay(true); - const conn = new Conn(socket); - - socket.on("data", (chunk: Buffer) => { - conn.lastRx = Date.now(); - let bytes = new Uint8Array(chunk); - try { - if (conn.hello !== null) { - const merged = new Uint8Array(conn.hello.length + bytes.length); - merged.set(conn.hello); - merged.set(bytes, conn.hello.length); - const hello = parseHello(merged); - if (hello === null) { - conn.hello = merged; - return; - } - if (hello.app !== TERM_APP) throw new Error(`unknown app "${hello.app}"`); - conn.hello = null; - socket.write(encodeHelloAck()); - hub.conns.add(conn); - console.log(`[term] device connected (${socket.remoteAddress ?? "?"})`); - bytes = merged.slice(hello.consumed); - if (bytes.length === 0) return; - } - for (const frame of conn.parser.push(bytes)) { - if (frame.type === WIRE_MSG.pong) continue; - if (frame.type !== WIRE_MSG.ctrl) continue; // forward compatibility - const text = new TextDecoder().decode(frame.payload); - try { - handleLine(conn, JSON.parse(text) as ClientLine); - } catch { - // A malformed device line is a device bug; skip it rather than drop. - } - } - } catch (error) { - console.log(`[term] dropping device: ${(error as Error).message}`); - socket.destroy(); - } - }); - - const cleanup = () => { - hub.conns.delete(conn); - }; - socket.on("close", cleanup); - socket.on("error", cleanup); }); - -let pingToken = 1; -setInterval(() => { - const now = Date.now(); - for (const conn of hub.conns) { - if (now - conn.lastRx > SILENCE_TIMEOUT_MS) { - console.log("[term] device silent, dropping"); - conn.socket.destroy(); - hub.conns.delete(conn); - continue; - } - const token = new Uint8Array(4); - new DataView(token.buffer).setUint32(0, pingToken++ >>> 0, true); - conn.socket.write(encodeFrame(WIRE_MSG.ping, token)); - } -}, PING_INTERVAL_MS); - -let flushTimer: ReturnType | null = null; - -function flushAll(): void { - if (flushTimer !== null) { - clearTimeout(flushTimer); - flushTimer = null; - } - // What a session is running changes without any output to announce it — - // launching an editor, or leaving one. - for (const session of hub.sessions.values()) session.refreshTitle(); - for (const conn of hub.conns) flush(conn); - pumpAtlasBake(); -} - -/** Ask for a pass shortly. Output is what makes a session worth serializing, - * so a session that just parsed some announces itself rather than waiting - * for a poll to come round. */ -function scheduleFlush(): void { - if (flushTimer !== null) return; - flushTimer = setTimeout(flushAll, FLUSH_COALESCE_MS); -} - -setInterval(flushAll, FLUSH_IDLE_MS); - -// --------------------------------------------------------------------------- -// mirror windows -// --------------------------------------------------------------------------- - -/** One desktop window per session, tracked so it closes with its session. */ -const mirrors = new Map(); - -/** Open the read-only window for a session. The window is an ordinary - * PocketJS app (mirror/) on the stock desktop host, pointed at this - * daemon with --svc-connect; it binds to the session through the hello - * queue. Nothing here is macOS-specific — the same binary is the linux-app - * host. */ -function openMirror(sid: number, tcpPort: number): void { - if (!options.mirror) return; - if (!existsSync(MIRROR_BIN)) { - console.log( - `[term] no mirror window: ${MIRROR_BIN} is not built ` + - `(bun run macos term-mirror --build-only), continuing without one`, - ); - options.mirror = false; - return; - } - const child = spawn( - MIRROR_BIN, - [ - "--app", MIRROR_APP, - "--title", `Pocket Term #${sid}`, - "--viewport", "400x240", - "--fixed", - "--density", "2", - "--companions", TERM_APP, - "--svc-connect", `127.0.0.1:${tcpPort}`, - ], - { - env: { ...process.env, POCKETJS_DIST: join(ROOT, "dist"), RUST_LOG: "warn" }, - stdio: "ignore", - detached: false, - }, - ); - mirrors.set(sid, child); - child.on("exit", () => { - if (mirrors.get(sid) === child) mirrors.delete(sid); - }); - child.on("error", (error) => { - console.log(`[term] mirror window for #${sid} failed: ${error.message}`); - mirrors.delete(sid); - }); -} - -function closeMirror(sid: number): void { - const child = mirrors.get(sid); - if (!child) return; - mirrors.delete(sid); - child.kill(); -} - -function closeAllMirrors(): void { - for (const [, child] of mirrors) child.kill(); - mirrors.clear(); -} - -for (const signal of ["exit", "SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - closeAllMirrors(); - if (signal !== "exit") process.exit(0); - }); -} - -function startBeacon(tcpPort: number) { - const beacon = createSocket("udp4"); - beacon.bind(() => { - beacon.setBroadcast(true); - const datagram = Buffer.from(encodeBeacon(TERM_APP, options.name, tcpPort)); - const targets = ["255.255.255.255", "127.0.0.1", ...options.unicast]; - setInterval(() => { - for (const target of targets) { - beacon.send(datagram, options.beaconPort, target, () => {}); - } - }, 1000); - console.log(`[term] beacon on udp/${options.beaconPort} -> ${targets.join(", ")}`); - }); -} - -const started = () => { - const bound = (server.address() as { port: number }).port; - console.log(`[term] PKNT listener on tcp/${bound} (app "${TERM_APP}")`); - if (options.beacon) startBeacon(bound); - if (options.mirror) { - // Every session gets a window, including ones that already exist when - // the mirror feature comes up. - hub.onSessionCreated = (sid) => openMirror(sid, bound); - for (const sid of hub.sessions.keys()) openMirror(sid, bound); - } -}; - -// Several companions can share one machine (the pocket-youtube host also -// speaks PKNT): the beacon advertises the actual TCP port, so when the -// default is taken we fall back to an ephemeral one. -server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code !== "EADDRINUSE") throw error; - console.log(`[term] tcp/${options.port} is taken — falling back to an ephemeral port`); - server.listen(0); -}); -server.once("listening", started); -server.listen(options.port); - -console.log(`[term] shell ${options.shell}, host name "${options.name}"`); +function stop(code: number) { + if (stopping) return; + stopping = true; provider?.kill(); worker.kill(); + process.exitCode = code; +} +worker.on("error", error => { console.error(error.message); stop(1); }); +worker.on("exit", code => stop(code ?? 1)); +for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => stop(0)); diff --git a/host/session.ts b/host/session.ts new file mode 100644 index 0000000..f674311 --- /dev/null +++ b/host/session.ts @@ -0,0 +1,156 @@ +/** Durable PTY and terminal state. No device transport or UI objects. */ +import pty from "node-pty"; +import { GhosttyCore } from "@wterm/ghostty"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { SessionHistory } from "./history.ts"; +export interface SessionOptions { shell: string; cwd: string; login: boolean } +export interface SessionEvents { output(): void; titles(): void; exit(sid: number): void } + +const SCROLLBACK = 2000; + +/** The core loads its WASM by fetching a URL, and Node's fetch has no `file:` + * scheme — so the module is read once at startup and handed over as a data + * URL. Reading it here also fails loudly at boot rather than on the first + * session. */ +const ghosttyWasmUrl = (() => { + const path = createRequire(import.meta.url).resolve("@wterm/ghostty/ghostty-vt.wasm"); + return `data:application/wasm;base64,${readFileSync(path).toString("base64")}`; +})(); + +export class Session { + readonly sid: number; + private readonly options: SessionOptions; + private readonly events: SessionEvents; + title: string; + readonly pty: pty.IPty; + /** The authority. Null only for the moment between spawning the shell and + * the core's WASM finishing instantiation; bytes wait in `backlog`. */ + core: GhosttyCore | null = null; + #backlog: string[] = []; + cols: number; + rows: number; + disposed = false; + readonly history = new SessionHistory(); + + constructor(sid: number, cols: number, rows: number, options: SessionOptions, events: SessionEvents) { + this.options = options; this.events = events; + this.sid = sid; + this.cols = cols; + this.rows = rows; + this.title = `${this.options.shell.split("/").pop()} #${sid}`; + this.pty = pty.spawn(options.shell, [options.login ? "-il" : "-i"], { + name: "xterm-256color", + cols, + rows, + cwd: options.cwd, + env: { ...process.env, TERM: "xterm-256color" } as Record, + }); + this.pty.onData((data) => this.#parse(data)); + this.pty.onExit(() => events.exit(this.sid)); + + void GhosttyCore.load({ wasmPath: ghosttyWasmUrl, scrollbackLimit: SCROLLBACK }) + .then((core) => { + if (this.disposed) return; + core.init(this.cols, this.rows); + this.core = core; + const waiting = this.#backlog; + this.#backlog = []; + for (const chunk of waiting) this.#parse(chunk); + this.events.output(); + }) + .catch((error: unknown) => { + console.error(`[term] session #${sid}: terminal core failed to load: ${String(error)}`); + events.exit(sid); + }); + } + + /** Feed the core, then let it answer. libghostty parses synchronously, so + * the buffer this serializes reflects the bytes the moment write returns. */ + #parse(data: string): void { + if (this.disposed) return; + const core = this.core; + if (core === null) { + if (this.#backlog.reduce((n, part) => n + part.length, 0) + data.length > 1024 * 1024) { this.events.exit(this.sid); return; } + this.#backlog.push(data); + return; + } + this.history.observe(data); + core.writeString(data); + this.history.update(core); + // Programs ask the terminal questions — what are you, where is the + // cursor, what colours do you use — and wait for the answer. The core + // composes the replies; nobody but us can put them back on the PTY. + for (let guard = 0; guard < 16; guard += 1) { + const response = core.getResponse(); + if (response === null || response === "") break; + this.pty.write(response); + } + this.refreshTitle(); + this.events.output(); + } + + /** + * What the tab says. A program that sets a window title means it, so that + * wins; otherwise the tab shows what is actually running in the session — + * the thing a multiplexer's tabs are for, and the answer the PTY's + * foreground process group gives directly. + * + * The core deliberately ignores OSC 1, the icon name, which is all many + * prompts set. Reading the process is both more accurate and independent + * of whether the shell announces anything at all. + */ + refreshTitle(): void { + const explicit = this.core?.getTitle(); + const label = + explicit !== null && explicit !== undefined && explicit.trim() !== "" + ? explicit.trim() + : (this.pty.process || this.options.shell.split("/").pop() || "shell"); + const next = `${label.slice(0, 24)} #${this.sid}`; + if (next === this.title) return; + this.title = next; + this.events.titles(); + } + + resize(cols: number, rows: number) { + if (this.cols === cols && this.rows === rows) return; + this.cols = cols; + this.rows = rows; + this.history.invalidate(); + this.core?.resize(cols, rows); + if (this.core) this.history.update(this.core); + this.pty.resize(cols, rows); + this.events.output(); + } + + write(data: string) { + if (data.length > 0) this.pty.write(data); + } + + /** Paste as one bracketed block when the program asked for it, so an editor + * inserts text instead of interpreting every character as a command. */ + paste(text: string) { + if (text.length === 0) return; + if (this.core?.bracketedPaste()) this.pty.write(`\x1b[200~${text}\x1b[201~`); + else this.pty.write(text); + } + + appCursor(): boolean { + return this.core?.cursorKeysApp() ?? false; + } + + cursorHidden(): boolean { + return this.core ? !this.core.getCursor().visible : false; + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.#backlog = []; this.core = null; + try { + this.pty.kill(); + } catch { + /* already gone */ + } + } +} diff --git a/host/terminal-worker.ts b/host/terminal-worker.ts new file mode 100644 index 0000000..0595945 --- /dev/null +++ b/host/terminal-worker.ts @@ -0,0 +1,909 @@ +/** Terminal worker: persistent session registry, bounded replica views, + * authenticated local capabilities and loopback desktop mirror transport. + * The supervisor owns process lifetime; device connections own no PTYs. */ + +import { createServer, type Socket } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { randomBytes, randomUUID } from "node:crypto"; +import { Mailbox } from "./exchange.ts"; +import { LIMITS, type ExchangeRequest } from "../shared/exchange.ts"; +import { HISTORY, type HistoryRequest, type HistoryBatchRequest } from "../shared/history.ts"; +import { historyBatchReply, validateHistoryBatch } from "./history-batch.ts"; +import { hostname } from "node:os"; +import { chmodSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, type ChildProcess } from "node:child_process"; +import { Session } from "./session.ts"; +import { + LINE_BUDGET, + TERM_APP, + TERM_PROTO, + type ClientLine, + type Cursor, + type HostLine, + type Role, + type RowUpdate, + type Run, + type SessionInfo, +} from "../shared/protocol.ts"; +import { chunkRows, resolveCell, rowKey, rowRuns, type Cell } from "./grid.ts"; +import { DynamicAtlasSet, isBakedCodepoint } from "./glyphs.ts"; +import { encodeKey } from "./keys.ts"; +import { + FrameParser, + WIRE_MSG, + WIRE_PORT, + encodeCtrl, + encodeFrame, + encodeHelloAck, + parseHello, +} from "./wire.ts"; + +// --------------------------------------------------------------------------- +// options +// --------------------------------------------------------------------------- + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +/** The desktop host binary, which is also the `linux-app` one. */ +const MIRROR_BIN = join(ROOT, "vendor/pocketjs/hosts/desktop/target/release/pocket-desktop-host"); +const MIRROR_APP = "pocketterm-mirror-main"; + +const options = { + port: WIRE_PORT, + name: hostname().replace(/\.local$/, ""), + shell: process.env.SHELL ?? "/bin/zsh", + cwd: process.env.HOME ?? process.cwd(), + login: true, + trace: false, + /** Open a desktop window per session. */ + mirror: true, +}; + +{ + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--port") options.port = Number(argv[++i]); + else if (a === "--name") options.name = argv[++i]; + else if (a === "--shell") options.shell = argv[++i]; + else if (a === "--cwd") options.cwd = argv[++i]; + else if (a === "--trace") options.trace = true; + else if (a === "--no-login") options.login = false; + else if (a === "--no-beacon") {} // accepted by older launch scripts + else if (a === "--no-mirror") options.mirror = false; + else { + console.error(`unknown argument: ${a}`); + process.exit(2); + } + } +} + +// Bun's installer drops the executable bit on prebuilt binaries; node-pty's +// posix_spawn of its helper then fails with a bare "posix_spawnp failed". +for (const helper of [ + join( + dirname(fileURLToPath(import.meta.url)), + "node_modules/node-pty/prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ), +]) { + if (existsSync(helper) && (statSync(helper).mode & 0o111) === 0) chmodSync(helper, 0o755); +} + +// --------------------------------------------------------------------------- +// sessions — PTY + authoritative terminal core +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// connections — one attached replica each +// --------------------------------------------------------------------------- + +const PING_INTERVAL_MS = 2000; +const SILENCE_TIMEOUT_MS = 10_000; +/** Coalescing delay after a session produces output. A terminal's echo is a + * round trip through here, so this sits directly in the gap between a + * keystroke and the character appearing: it is a batching window, not a + * polling rate. A byte-at-a-time echo pays 2 ms; a screenful of output + * still collapses into one pass. */ +const FLUSH_COALESCE_MS = 2; +/** Backstop for the changes no PTY byte announces — a scrollback scrub, a + * cursor that moved because a replica attached. */ +const FLUSH_IDLE_MS = 100; + +/** Keep one atlas piece within a typical offload reply so changed rows can + * take the next output turn instead of waiting through a large atlas line. */ +const ATLAS_CHUNK = 1536; + +class Conn { + readonly socket: Socket; + mailbox?: Mailbox; + readonly parser = new FrameParser(); + hello: Uint8Array | null = new Uint8Array(0); + role: Role = "device"; + mirrorSid?: number; + cols = 80; + rows = 24; + cell: [number, number] = [5, 10]; + attachedSid = -1; + /** Atlas generation this replica holds per slot, and the chunks still owed + * to it for the slot in flight. One chunk goes out per flush tick: the + * device's line queue is 32 KiB and drops its oldest entries when full, + * so a whole atlas dumped at once would take the grid updates with it. */ + atlasSent = new Map(); + atlasSlot = 0; + atlasGen = -1; + atlasQueue: string[] = []; + atlasSeq = 0; + gen = 0; + seq = 0; + scrollback = 0; + localHistory = false; + historyGlyphs: [number, number][] = []; + pendingGlyphs?: [number, number][]; + rowCache: string[] = []; + lastCursor = ""; + lastRx = Date.now(); + sawClientHello = false; + paste?: { sid: number; text: string; at: number }; + + constructor(socket: Socket) { + this.socket = socket; + } + + sendLine(line: HostLine) { + if (this.mailbox) { this.mailbox.push(line); return; } + if (this.socket.writableLength > LIMITS.outputChars) { this.socket.destroy(); return; } + this.socket.write(encodeCtrl(JSON.stringify(line))); + } + + /** Emit rows (+cursor/scrollback trailer) as ordered, chunked grid lines. */ + sendGrid(updates: RowUpdate[], cursor: Cursor, full: boolean) { + const chunks = chunkRows(updates, LINE_BUDGET); + for (let i = 0; i < chunks.length; i += 1) { + const last = i === chunks.length - 1; + this.sendLine({ + t: "grid", + sid: this.attachedSid, + gen: this.gen, + seq: this.seq++, + ...(full ? { full: 1 as const } : {}), + ...(last ? {} : { more: 1 as const }), + rows: chunks[i], + ...(last ? { cur: cursor, ack: this.mailbox?.ack, sb: this.scrollback, history: hub.sessions.get(this.attachedSid)?.history.manifest() } : {}), + }); + } + } +} + +// --------------------------------------------------------------------------- +// the hub +// --------------------------------------------------------------------------- + +class Hub { + readonly sessions = new Map(); + readonly conns = new Set(); + private nextSid = 1; + /** Set by the mirror supervisor; called once per new session. */ + onSessionCreated: ((sid: number) => void) | null = null; + + create(cols: number, rows: number): Session { + if (this.sessions.size >= LIMITS.sessions) throw new Error("Session limit reached (32)"); + const session = new Session(this.nextSid++, cols, rows, options, { + output: scheduleFlush, titles: () => this.sessionsChanged(), exit: sid => this.sessionExited(sid), + }); + this.sessions.set(session.sid, session); + console.log(`[term] session #${session.sid} started: pid ${session.pty.pid}, ${cols}x${rows}`); + this.sessionsChanged(); + // A replica left with nothing attached — the empty state after the last + // session closed — shows the first session that appears, whoever opened + // it. Without this it would sit at the empty state while sessions exist, + // which is the empty state lying. Mirrors are bound through the pending + // listener instead and are left alone here. + for (const conn of this.conns) { + if (conn.role === "device" && conn.sawClientHello && conn.attachedSid < 0) { + attach(conn, session.sid); + } + } + if (this.onSessionCreated) { + this.onSessionCreated(session.sid); + } + return session; + } + + kill(sid: number) { + const session = this.sessions.get(sid); + if (!session) return; + session.dispose(); + this.sessions.delete(sid); + closeMirror(sid); + this.reattachOrphans(sid); + this.sessionsChanged(); + } + + sessionExited(sid: number) { + const session = this.sessions.get(sid); + if (!session) return; + session.dispose(); + this.sessions.delete(sid); + closeMirror(sid); + for (const conn of this.conns) { + if (conn.sawClientHello) conn.sendLine({ t: "exit", sid }); + } + this.reattachOrphans(sid); + this.sessionsChanged(); + } + + private reattachOrphans(gone: number) { + for (const conn of this.conns) { + if (conn.attachedSid !== gone) continue; + if (conn.role === "mirror") { + // A mirror exists to show one session. When that session is over so + // is the window: it is closed rather than pointed at someone else's + // shell. + conn.socket.destroy(); + this.conns.delete(conn); + continue; + } + const fallback = [...this.sessions.values()].at(-1); + if (fallback === undefined) { + // Closing the last session leaves the replica with nothing attached, + // not with a shell it did not ask for. Opening one is a decision, + // and the operator just made the opposite one. + conn.attachedSid = -1; + conn.rowCache = []; + conn.lastCursor = ""; + continue; + } + attach(conn, fallback.sid); + } + } + + list(): SessionInfo[] { + return [...this.sessions.values()].map((s) => ({ sid: s.sid, title: s.title })); + } + + sessionsChanged() { + for (const conn of this.conns) { + if (conn.sawClientHello) { + conn.sendLine({ t: "sessions", list: this.list(), active: conn.attachedSid }); + } + } + } +} + +const hub = new Hub(); + +// --------------------------------------------------------------------------- +// the replica view: serialize, diff, flush +// --------------------------------------------------------------------------- + +/** Codepoints the device cannot draw from its baked atlas, noted as the grid + * is serialized so the next bake covers exactly what is on screen. */ +const atlas = new DynamicAtlasSet(); + +/** Classify a resolved cell against the device's glyph coverage: baked text + * passes through, a character some face in the chain can draw is routed to + * that face's slot, and one nobody can draw becomes a placeholder of the + * same column width — an unrenderable glyph must not also shift the rest of + * the row, which is what dropping it or leaving it to a missing atlas would + * do. */ +const uncovered = new Set(); + +function classify(cell: Cell, want = true): void { + if (cell.width === 0 || cell.ch === "" || cell.ch === " ") return; + const cp = cell.ch.codePointAt(0); + if (cp === undefined || isBakedCodepoint(cp)) return; + const columns = cell.width === 2 ? 2 : 1; + const slot = atlas.slotFor(cp, columns); + if (slot >= 0) { + if (want) atlas.want(cp, columns, slot); + cell.slot = slot; + return; + } + // A placeholder is the last resort, and the operator should be able to + // find out which character it stood for rather than guess from a "?". + if (!uncovered.has(cp)) { + uncovered.add(cp); + console.log( + `[term] no face draws U+${cp.toString(16).toUpperCase().padStart(4, "0")} ` + + `(${cell.ch}) — showing a placeholder`, + ); + } + cell.ch = "?".repeat(columns); +} + +/** Resolve the connection's visible window of the session buffer to runs. */ +function viewRows(session: Session, conn: Conn): Run[][] { + const core = session.core; + const rows: Run[][] = []; + if (core === null) { + for (let y = 0; y < conn.rows; y += 1) rows.push([]); + return rows; + } + // Scrolled back, the top of the view comes out of history: the core + // indexes that directly, oldest line first, so the offset is arithmetic on + // its own count rather than on a viewport's base row. + const history = core.getScrollbackCount(); + const back = Math.max(0, Math.min(history, conn.scrollback)); + for (let y = 0; y < conn.rows; y += 1) { + const fromHistory = y < back; + const offset = history - back + y; + const cells: Cell[] = []; + for (let x = 0; x < conn.cols; x += 1) { + const cell = fromHistory ? core.getScrollbackCell(history - 1 - offset, x) : core.getCell(y - back, x); + const resolved = resolveCell(cell); + classify(resolved); + cells.push(resolved); + } + rows.push(rowRuns(cells)); + } + return rows; +} + +/** Re-bake when the screen has shown codepoints the current atlas lacks. The + * bake is debounced: a session that dumps a page of Chinese should cost one + * bake, not one per row. */ +let atlasDebounce = 0; +function pumpAtlasBake(): void { + if (!atlas.dirty) { + atlasDebounce = 0; + return; + } + atlasDebounce += 1; + if (atlasDebounce < 8) return; // ~250 ms at the flush interval + atlasDebounce = 0; + const conn = [...hub.conns].find((c) => c.sawClientHello); + const [cellW, cellH] = conn?.cell ?? [5, 10]; + for (const baked of atlas.bake(cellW, cellH)) { + console.log( + `[term] baked ${baked.glyphCount} glyphs at ${baked.px}px into slot ${baked.slot} ` + + `(${(baked.bytes.length / 1024).toFixed(1)} KiB)`, + ); + } +} + +/** Hand one atlas chunk to a replica per tick. Every face in the chain is + * delivered, one slot at a time. */ +function pumpAtlasSend(conn: Conn): void { + if (!conn.sawClientHello) return; + if (conn.atlasQueue.length === 0) { + for (const baked of atlas.current()) { + if (conn.atlasSent.get(baked.slot) === baked.gen) continue; + conn.atlasSent.set(baked.slot, baked.gen); + conn.atlasSlot = baked.slot; + conn.atlasGen = baked.gen; + conn.atlasSeq = 0; + const b64 = Buffer.from(baked.bytes).toString("base64"); + for (let at = 0; at < b64.length; at += ATLAS_CHUNK) { + conn.atlasQueue.push(b64.slice(at, at + ATLAS_CHUNK)); + } + break; // one atlas at a time; the next tick starts the next + } + } + const chunk = conn.atlasQueue.shift(); + if (chunk === undefined) return; + conn.sendLine({ + t: "atlas", + slot: conn.atlasSlot, + gen: conn.atlasGen, + seq: conn.atlasSeq++, + ...(conn.atlasQueue.length > 0 ? { more: 1 as const } : {}), + b64: chunk, + }); +} + +function cursorFor(session: Session, conn: Conn): Cursor { + const cursor = session.core?.getCursor(); + if (cursor === undefined) return [0, 0, 0]; + const visible = conn.scrollback === 0 && cursor.visible; + return [cursor.col, cursor.row, visible ? 1 : 0]; +} + +/** Full snapshot: a new gen, every row sent (blank rows included). */ +function snapshot(conn: Conn) { + const session = hub.sessions.get(conn.attachedSid); + if (!session) return; + conn.gen += 1; + conn.seq = 0; + const rows = viewRows(session, conn); + conn.rowCache = rows.map(rowKey); + const cursor = cursorFor(session, conn); + conn.lastCursor = JSON.stringify([cursor, conn.scrollback, session.history.manifest()]); + const updates: RowUpdate[] = rows.map((runs, y) => [y, ...runs]); + conn.sendGrid(updates, cursor, true); +} + +function flush(conn: Conn) { + if (conn.mailbox?.busy) return; + const session = hub.sessions.get(conn.attachedSid); + if (!session || !conn.sawClientHello) return; + const rows = viewRows(session, conn); + const updates: RowUpdate[] = []; + for (let y = 0; y < rows.length; y += 1) { + const key = rowKey(rows[y]); + if (conn.rowCache[y] !== key) { + conn.rowCache[y] = key; + updates.push([y, ...rows[y]]); + } + } + const cursor = cursorFor(session, conn); + const cursorKey = JSON.stringify([cursor, conn.scrollback, session.history.manifest()]); + if (updates.length === 0 && cursorKey === conn.lastCursor) { pumpAtlasSend(conn); return; } + conn.lastCursor = cursorKey; + conn.sendGrid(updates, cursor, false); +} + +function attach(conn: Conn, sid: number) { + if (!hub.sessions.has(sid)) return; + conn.attachedSid = sid; + if (options.trace) console.log(`[term] ${conn.role} attached session #${sid}`); + conn.scrollback = 0; + conn.historyGlyphs = []; + conn.pendingGlyphs = undefined; + conn.sendLine({ t: "sessions", list: hub.list(), active: sid }); + snapshot(conn); +} + +// --------------------------------------------------------------------------- +// client line handling +// --------------------------------------------------------------------------- + +function handleLine(conn: Conn, line: ClientLine) { + // A mirror types into its own session, and nothing else: the window exists + // for one session, so it may not re-point itself at another, open one, or + // close one. That binding is enforced here rather than trusted to the + // window's guest. + if (conn.role === "mirror" && (line.t === "attach" || line.t === "new" || line.t === "kill")) { + return; + } + validateClientLine(line); + if (line.t !== "hello" && !conn.sawClientHello) throw new Error("Hello required"); + if (options.trace) console.log(`[term] command ${line.t}, session #${conn.attachedSid}`); + switch (line.t) { + case "hello": { + if (line.proto !== TERM_PROTO) throw new Error("Terminal protocol mismatch"); + conn.role = conn.mirrorSid !== undefined ? "mirror" : line.role ?? "device"; + conn.localHistory = conn.role === "device" && line.history === 1; + conn.cols = 80; conn.rows = 24; conn.cell = [5, 10]; + conn.sawClientHello = true; + // A hello means a replica that has loaded nothing yet, which is not the + // same as a new socket: the console's transport is native and survives + // a guest reload, so the fresh guest arrives on the connection that + // already had an atlas. Re-send it or its CJK renders as blanks. + conn.atlasSent.clear(); + conn.atlasGen = -1; + conn.atlasQueue = []; + conn.atlasSeq = 0; + + if (conn.role === "mirror") { + // A mirror shows one session and changes nothing about it: it takes + // the session it was opened for, renders whatever size that session + // already is, and never resizes a PTY. + const sid = conn.mirrorSid ?? line.want; + const session = sid === undefined ? undefined : hub.sessions.get(sid); + if (!session) { + conn.socket.destroy(); + hub.conns.delete(conn); + return; + } + conn.cols = session.cols; + conn.rows = session.rows; + conn.sendLine({ t: "hello", proto: TERM_PROTO, name: options.name, sid: session.sid }); + attach(conn, session.sid); + break; + } + + conn.sendLine({ t: "hello", proto: TERM_PROTO, name: options.name }); + // Every terminal uses the fixed primary geometry: every session tracks the driving replica's grid + // (the tmux attach model, one window size at a time). Other replicas + // that were sized differently get a fresh snapshot at the new size. + for (const session of hub.sessions.values()) session.resize(conn.cols, conn.rows); + for (const other of hub.conns) { + if (other === conn || !other.sawClientHello) continue; + if (other.cols !== conn.cols || other.rows !== conn.rows) { + other.cols = conn.cols; + other.rows = conn.rows; + snapshot(other); + } + } + // A reconnecting console names the session it was on, so the window it + // comes back to is the one it left rather than whichever is newest. + const wanted = line.want !== undefined ? hub.sessions.get(line.want) : undefined; + const target = wanted ?? [...hub.sessions.values()].at(-1) ?? hub.create(conn.cols, conn.rows); + attach(conn, target.sid); + break; + } + case "new": { + const session = hub.create(conn.cols, conn.rows); + attach(conn, session.sid); + break; + } + case "kill": + hub.kill(line.sid); + break; + case "attach": + attach(conn, line.sid); + break; + case "ch": { + const session = hub.sessions.get(conn.attachedSid); + if (session && line.s.length <= 256) { + if (conn.scrollback !== 0) conn.scrollback = 0; // typing snaps to live + // More than one character at a time is a paste, not typing, and a + // program that asked for bracketed paste wants to be told which. + session.write(line.s); + } + break; + } + case "paste": { + const session = hub.sessions.get(conn.attachedSid); + if (!session) break; + if (line.phase === "single") { session.paste(line.s); conn.paste = undefined; break; } + if (line.phase === "start") conn.paste = { sid: session.sid, text: "", at: Date.now() }; + const paste = conn.paste; + if (!paste || paste.sid !== session.sid || Date.now() - paste.at > 15000 || paste.text.length + line.s.length > 8192) { + conn.paste = undefined; throw new Error("Paste expired or exceeds budget"); + } + paste.text += line.s; paste.at = Date.now(); + if (line.phase === "end") { session.paste(paste.text); conn.paste = undefined; } + conn.scrollback = 0; + break; + } + case "key": { + const session = hub.sessions.get(conn.attachedSid); + if (!session) break; + if (conn.scrollback !== 0) conn.scrollback = 0; + session.write(encodeKey(line.k, line.ctrl === 1, line.alt === 1, session.appCursor(), line.shift === 1)); + break; + } + case "scroll": { + if (conn.localHistory) break; + const session = hub.sessions.get(conn.attachedSid); + if (!session) break; + const max = session.core?.getScrollbackCount() ?? 0; + conn.scrollback = Math.max(0, Math.min(max, conn.scrollback + line.d)); + // Scrolling changes this replica's view without any PTY byte to + // announce it. + scheduleFlush(); + break; + } + case "glyphs": + if (line.reset) conn.pendingGlyphs = []; + if (!conn.pendingGlyphs) throw new Error("Glyph demand needs an opening chunk"); + conn.pendingGlyphs.push(...[...line.one].map(ch => [ch.codePointAt(0)!, 1] as [number, number]), + ...[...line.two].map(ch => [ch.codePointAt(0)!, 2] as [number, number])); + if (conn.pendingGlyphs.length > 1024) { conn.pendingGlyphs = undefined; throw new Error("Visible glyph budget exceeded"); } + if (!line.more) { conn.historyGlyphs = conn.pendingGlyphs; conn.pendingGlyphs = undefined; } + scheduleFlush(); + break; + case "resync": + conn.atlasSent.clear(); conn.atlasQueue = []; + snapshot(conn); + break; + } +} + +// --------------------------------------------------------------------------- +// loopback mirror wire server +// --------------------------------------------------------------------------- + +function acceptSocket(socket: Socket, mirrorSid?: number) { + if (hub.conns.size >= 64) { socket.destroy(); return; } + socket.setTimeout(15000, () => socket.destroy()); + socket.setNoDelay(true); + const conn = new Conn(socket); + conn.mirrorSid = mirrorSid; + + socket.on("data", (chunk: Buffer) => { + conn.lastRx = Date.now(); + let bytes = new Uint8Array(chunk); + try { + if (conn.hello !== null) { + const merged = new Uint8Array(conn.hello.length + bytes.length); + merged.set(conn.hello); + merged.set(bytes, conn.hello.length); + const hello = parseHello(merged); + if (hello === null) { + conn.hello = merged; + return; + } + if (hello.app !== TERM_APP) throw new Error(`unknown app "${hello.app}"`); + conn.hello = null; + socket.write(encodeHelloAck()); + hub.conns.add(conn); + console.log(`[term] device connected (${socket.remoteAddress ?? "?"})`); + bytes = merged.slice(hello.consumed); + if (bytes.length === 0) return; + } + for (const frame of conn.parser.push(bytes)) { + if (frame.type === WIRE_MSG.pong) continue; + if (frame.type !== WIRE_MSG.ctrl) continue; // forward compatibility + const text = new TextDecoder().decode(frame.payload); + try { + handleLine(conn, JSON.parse(text) as ClientLine); + } catch { + // A malformed device line is a device bug; skip it rather than drop. + } + } + } catch (error) { + console.log(`[term] dropping device: ${(error as Error).message}`); + socket.destroy(); + } + }); + + const cleanup = () => { + hub.conns.delete(conn); + }; + socket.on("close", cleanup); + socket.on("error", cleanup); +} +const server = createServer(socket => acceptSocket(socket)); + +let pingToken = 1; +setInterval(() => { + const now = Date.now(); + for (const conn of hub.conns) { + if (conn.mailbox) continue; + if (now - conn.lastRx > SILENCE_TIMEOUT_MS) { + console.log("[term] device silent, dropping"); + conn.socket.destroy(); + hub.conns.delete(conn); + continue; + } + const token = new Uint8Array(4); + new DataView(token.buffer).setUint32(0, pingToken++ >>> 0, true); + conn.socket.write(encodeFrame(WIRE_MSG.ping, token)); + } +}, PING_INTERVAL_MS); + +let flushTimer: ReturnType | null = null; + +function flushAll(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + // What a session is running changes without any output to announce it — + // launching an editor, or leaving one. + for (const session of hub.sessions.values()) session.refreshTitle(); + for (const conn of hub.conns) flush(conn); + // Only visible history asks for glyph residency. Prefetching hundreds of + // other rows must not evict the glyphs the user is currently reading. + for (const conn of hub.conns) { + if (conn.mailbox && Date.now() - conn.mailbox.touched > 15000) continue; + for (const [cp, columns] of conn.historyGlyphs) { + const slot = atlas.slotFor(cp, columns); if (slot >= 0) atlas.want(cp, columns, slot); + } + } + pumpAtlasBake(); +} + +/** Ask for a pass shortly. Output is what makes a session worth serializing, + * so a session that just parsed some announces itself rather than waiting + * for a poll to come round. */ +function scheduleFlush(): void { + if (flushTimer !== null) return; + flushTimer = setTimeout(flushAll, FLUSH_COALESCE_MS); +} + +setInterval(flushAll, FLUSH_IDLE_MS); + +// --------------------------------------------------------------------------- +// mirror windows +// --------------------------------------------------------------------------- + +/** One desktop window per session, tracked so it closes with its session. */ +const mirrors = new Map(); +const mirrorListeners = new Map>(); + +/** Open the desktop window for a session. The window is an ordinary + * PocketJS app (mirror/) on the stock desktop host, pointed at this + * daemon with --svc-connect; the listener binds it to its session. Nothing here is macOS-specific — the same binary is the linux-app + * host. */ +function openMirror(sid: number, tcpPort: number): void { + if (!options.mirror) return; + if (!existsSync(MIRROR_BIN)) { + console.log( + `[term] no mirror window: ${MIRROR_BIN} is not built ` + + `(bun run macos term-mirror --build-only), continuing without one`, + ); + options.mirror = false; + return; + } + const listener = createServer(socket => acceptSocket(socket, sid)); + mirrorListeners.set(sid, listener); + listener.listen(0, "127.0.0.1", () => { + if (!hub.sessions.has(sid)) { listener.close(); return; } + const mirrorPort = (listener.address() as { port: number }).port; + const child = spawn( + MIRROR_BIN, + [ + "--app", MIRROR_APP, + "--title", `Pocket Term #${sid}`, + "--viewport", "400x240", + "--fixed", + "--density", "2", + "--companions", TERM_APP, + "--svc-connect", `127.0.0.1:${mirrorPort}`, + ], + { + env: { ...process.env, POCKETJS_DIST: join(ROOT, "dist"), RUST_LOG: "warn" }, + stdio: "ignore", + detached: false, + }, + ); + mirrors.set(sid, child); + child.on("exit", () => { + if (mirrors.get(sid) === child) { mirrors.delete(sid); listener.close(); mirrorListeners.delete(sid); } + }); + child.on("error", (error) => { + console.log(`[term] mirror window for #${sid} failed: ${error.message}`); + mirrors.delete(sid); listener.close(); mirrorListeners.delete(sid); + }); + }); +} + +function closeMirror(sid: number): void { + mirrorListeners.get(sid)?.close(); mirrorListeners.delete(sid); + const child = mirrors.get(sid); + if (!child) return; + mirrors.delete(sid); + child.kill(); +} + +function closeAllMirrors(): void { + for (const listener of mirrorListeners.values()) listener.close(); + mirrorListeners.clear(); + for (const [, child] of mirrors) child.kill(); + mirrors.clear(); +} + +for (const signal of ["exit", "SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + closeAllMirrors(); + for (const session of hub.sessions.values()) session.dispose(); + if (signal !== "exit") process.exit(0); + }); +} + +const started = () => { + const bound = (server.address() as { port: number }).port; + console.log(`[term] PKNT listener on tcp/${bound} (app "${TERM_APP}")`); + if (options.mirror) { + // Every session gets a window, including ones that already exist when + // the mirror feature comes up. + hub.onSessionCreated = (sid) => openMirror(sid, bound); + for (const sid of hub.sessions.keys()) openMirror(sid, bound); + } +}; + +// Local applications can already occupy the preferred port. Dedicated +// mirror listeners carry their actual ports, so the control listener may +// fall back to an ephemeral one. +server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EADDRINUSE") throw error; + console.log(`[term] tcp/${options.port} is taken — falling back to an ephemeral port`); + server.listen(0, "127.0.0.1"); +}); +server.once("listening", started); +server.listen(options.port, "127.0.0.1"); + +console.log(`[term] shell ${options.shell}, host name "${options.name}"`); + +// Authenticated local capability broker. The LAN transport only forwards to +// this process; reconnecting its worker never tears down a PTY. +const epoch = randomUUID(), token = randomBytes(32).toString("hex"); +const replicas = new Map(); +const broker = createHttpServer(async (request, response) => { + if (request.method !== "POST" || !["/exchange", "/history", "/history-batch", "/input"].includes(request.url ?? "") || request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(403).end(); return; + } + try { + let body = ""; + for await (const chunk of request) { + body += chunk; + if (Buffer.byteLength(body) > 4096) throw new Error("Request exceeds budget"); + } + if (request.url === "/history-batch") { + const input = JSON.parse(body) as HistoryBatchRequest; + validateHistoryBatch(input); + const session = hub.sessions.get(input.sid); + if (!session?.core) throw new Error("Terminal no longer exists"); + // Validate every address before reading any cells. Appends preserve + // absolute rows; pruning or a changed epoch rejects the batch. + for (const row of input.rows) session.history.offset(row, input.epoch); + const reply = historyBatchReply(input, row => { + const offset = session.history.offset(row, input.epoch), cells: Cell[] = []; + for (let x = 0; x < session.cols; x++) { + const cell = resolveCell(session.core!.getScrollbackCell(offset, x)); classify(cell, false); cells.push(cell); + } + return JSON.stringify(rowRuns(cells)); + }); + response.setHeader("content-type", "application/json"); response.end(JSON.stringify(reply)); + return; + } + if (request.url === "/history") { + const input = JSON.parse(body) as HistoryRequest; + if (!Number.isSafeInteger(input.sid) || !Number.isSafeInteger(input.part) || input.part < 0 || input.part > Math.ceil(HISTORY.rowChars / HISTORY.fragmentChars)) throw new Error("Invalid history request"); + const session = hub.sessions.get(input.sid); + if (!session?.core) throw new Error("Terminal no longer exists"); + const offset = session.history.offset(input.row, input.epoch); + const cells: Cell[] = []; + for (let x = 0; x < session.cols; x++) { + const cell = resolveCell(session.core.getScrollbackCell(offset, x)); classify(cell, false); cells.push(cell); + } + const raw = JSON.stringify(rowRuns(cells)); + if (raw.length > HISTORY.rowChars) throw new Error("History row exceeds budget"); + const parts = Math.ceil(raw.length / HISTORY.fragmentChars); + if (input.part >= parts) throw new Error("Invalid history fragment"); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ epoch: input.epoch, row: input.row, part: input.part, parts, + data: raw.slice(input.part * HISTORY.fragmentChars, (input.part + 1) * HISTORY.fragmentChars) })); + scheduleFlush(); + return; + } + const input = JSON.parse(body) as ExchangeRequest; + if (typeof input.replica !== "string" || !/^[a-zA-Z0-9-]{8,80}$/.test(input.replica)) throw new Error("Invalid replica"); + let conn = replicas.get(input.replica); + if (!conn) { + if (replicas.size >= LIMITS.replicas) { + const idle = [...replicas].sort((a, b) => a[1].mailbox!.touched - b[1].mailbox!.touched)[0]; + if (idle && Date.now() - idle[1].mailbox!.touched > 15000) { + replicas.delete(idle[0]); hub.conns.delete(idle[1]); + } + } + if (replicas.size >= LIMITS.replicas) throw new Error("Replica limit reached"); + // This adapter owns no socket; PTYs and views remain in the same hub + // as the loopback desktop mirrors. + conn = new Conn({ destroy() {}, writableLength: 0 } as unknown as Socket); + conn.mailbox = new Mailbox(); replicas.set(input.replica, conn); hub.conns.add(conn); + } + const reply = request.url === "/input" + ? conn.mailbox!.input(input as unknown as import("../shared/exchange.ts").InputRequest, `${epoch}-${conn.mailbox!.identity}`, line => handleLine(conn!, line)) + : conn.mailbox!.exchange(input, `${epoch}-${conn.mailbox!.identity}`, line => handleLine(conn!, line)); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(reply)); + if (!conn.mailbox!.busy) scheduleFlush(); + } catch (error) { response.writeHead(400).end(String(error).slice(0, 160)); } +}); +broker.requestTimeout = 5000; +broker.listen(0, "127.0.0.1", () => { + const endpoint = `http://127.0.0.1:${(broker.address() as { port: number }).port}/exchange`; + process.send?.({ ready: true, endpoint, token }); + console.log("[term] durable terminal worker ready (paired offload + loopback mirrors)"); +}); +setInterval(() => { + for (const [id, conn] of replicas) { + if (Date.now() - conn.mailbox!.touched > 30 * 60 * 1000) { + replicas.delete(id); hub.conns.delete(conn); + } + } +}, 60000).unref(); + +function validateClientLine(line: ClientLine) { + if (!line || typeof line !== "object") throw new Error("Invalid terminal command"); + const integer = (n: unknown, min: number, max: number) => typeof n === "number" && Number.isInteger(n) && n >= min && n <= max; + switch (line.t) { + case "hello": + if (!integer(line.cols, 20, 200) || !integer(line.rows, 5, 80) || + (line.role !== undefined && line.role !== "device" && line.role !== "mirror") || + (line.cell && (!integer(line.cell[0], 1, 32) || !integer(line.cell[1], 1, 40)))) throw new Error("Invalid terminal dimensions"); + return; + case "ch": case "paste": + if (typeof line.s !== "string" || line.s.length > 256) throw new Error("Input exceeds budget"); + if (line.t === "paste" && !["start", "more", "end", "single"].includes(line.phase)) throw new Error("Invalid paste phase"); + return; + case "key": + if (typeof line.k !== "string" || line.k.length > 16) throw new Error("Invalid key"); return; + case "glyphs": + if (typeof line.one !== "string" || typeof line.two !== "string" || line.one.length + line.two.length > 448 || [...line.one, ...line.two].length > 224) throw new Error("Glyph demand exceeds budget"); return; + case "kill": case "attach": + if (!integer(line.sid, 1, Number.MAX_SAFE_INTEGER)) throw new Error("Invalid session"); return; + case "scroll": + if (!integer(line.d, -2000, 2000)) throw new Error("Invalid scroll"); return; + case "new": case "resync": return; + default: throw new Error("Unknown terminal command"); + } +} diff --git a/host/worker.ts b/host/worker.ts new file mode 100644 index 0000000..31b4fbe --- /dev/null +++ b/host/worker.ts @@ -0,0 +1,20 @@ +import { dispatchOffload } from "@pocketjs/framework/offload/provider"; +declare const self: { onmessage: (event: MessageEvent) => void; postMessage(value: unknown): void }; +let config: { endpoint: string; token: string }; +self.onmessage = async event => { + if (event.data.init) { config = event.data.init; return; } + const forward = async (path: string, payload: string) => { + const response = await fetch(config.endpoint.replace(/\/exchange$/, path), { + method: "POST", headers: { authorization: `Bearer ${config.token}` }, body: payload, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error(`Terminal worker: ${await response.text()}`); + return response.text(); + }; + self.postMessage(await dispatchOffload({ + "term.exchange": payload => forward("/exchange", payload), + "term.history": payload => forward("/history", payload), + "term.history.batch": payload => forward("/history-batch", payload), + "term.input": payload => forward("/input", payload), + }, event.data)); +}; diff --git a/mirror/app.tsx b/mirror/app.tsx index abe6ff6..4344d5c 100644 --- a/mirror/app.tsx +++ b/mirror/app.tsx @@ -1,46 +1,24 @@ -// mirror/app.tsx — the read-only window the term companion opens -// on the desktop beside each session. -// -// It is the console's top screen and nothing else: the same grid component, -// the same store, the same protocol, drawn by PocketJS through the gpui -// backend instead of the PICA200. That is the whole point of the split in -// app/grid.tsx — a mirror is not a second implementation of the -// terminal, it is the same one on another machine, which is also why it -// follows the console to Linux (`hosts/desktop` is the stock host of both -// `macos-app` and `linux-app`). -// -// It types, like any replica. The keyboard arrives as svc lines from the -// host this window runs on (see HostInputLine) and the store relays them to -// the companion, which owns the PTY — so the console and the window are -// typing into the same shell and see the same echo. What `role: "mirror"` -// still forbids is changing what this window is: it cannot resize the PTY, -// nor re-point itself at another session. +// Desktop replica bound to one session. It shares the 80x24 grid, accepts +// window input through svc and never resizes or switches the underlying PTY. import { onFrame } from "@pocketjs/framework/lifecycle"; -import { getOps } from "@pocketjs/framework"; +import { onCleanup } from "solid-js"; +import { loadTerminalFont } from "../app/font.ts"; +import { TERM_LAYOUT } from "../shared/layout.ts"; import { TermGrid } from "../app/grid.tsx"; import { connectSvc } from "../app/svc.ts"; import { createTermStore } from "../app/store.ts"; -/** Matched to the console app: the same viewport, font slot and cell box, so the - * mirror's grid is column-for-column what the console shows. */ -const MONO_SLOT = 16; -const STATUS_H = 14; -const CELL_H = 13; - export default function TermMirror() { - const ops = getOps(); - const advance = ops.measureText("M", MONO_SLOT); - const CELL_W = advance > 0 ? Math.max(6, Math.round(advance)) : 7; - const TRACK = advance > 0 ? CELL_W - advance : 0; - const COLS = Math.floor(400 / CELL_W); - const ROWS = Math.floor((240 - STATUS_H) / CELL_H); + loadTerminalFont(); + const { cols: COLS, rows: ROWS, cellW: CELL_W, cellH: CELL_H, track: TRACK, statusH: STATUS_H } = TERM_LAYOUT; const store = createTermStore( { cols: COLS, rows: ROWS, cell: [CELL_W, CELL_H], role: "mirror" }, connectSvc(), ); onFrame(() => store.frame()); + onCleanup(() => store.dispose()); return ( existsSync(path)); for (const path of produced) { const destination = resolve(DIST_3DS, basename(path)); diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..479639b --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,47 @@ +/** Install the offload launcher while ftpd owns the console. */ +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { ROOT } from "./paths.ts"; +const argv = process.argv.slice(2); +const value = (name: string) => argv.includes(name) ? argv[argv.indexOf(name) + 1] : undefined; +const address = value("--host"), port = Number(value("--ftp-port") ?? 5000); +if (!address || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Usage: bun run deploy --host [--ftp-port 5000]"); +const binary = resolve(ROOT, "dist/3ds/pocketterm-main.3dsx"), bytes = readFileSync(binary); +if (bytes.includes(Buffer.from("pocketjs-captures"))) throw new Error("Rebuild the production launcher without --capture"); +const manifest = JSON.parse(readFileSync(resolve(ROOT, "pocket.json"), "utf8")); +const slot = createHash("sha256").update(manifest.id).digest("hex").slice(0, 16); +if (!bytes.includes(Buffer.from(`offload/${slot}.key`))) throw new Error("Launcher lacks this app's offload key path"); +mkdirSync(resolve(ROOT, ".pocket"), { recursive: true }); +const key = resolve(ROOT, ".pocket/offload.key"); +if (!existsSync(key)) writeFileSync(key, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" }); +chmodSync(key, 0o600); +if (!/^[0-9a-f]{64}$/.test(readFileSync(key, "utf8").trim())) throw new Error("Invalid local offload key"); +const program = `import ftplib,hashlib,io,json,pathlib,sys,datetime +ftp=ftplib.FTP(); ftp.connect(sys.argv[1],int(sys.argv[2]),timeout=20); ftp.login() +def mkdir(path): + try: ftp.mkd(path) + except ftplib.error_perm as e: + if not str(e).startswith('550'): raise +def read(path): + out=io.BytesIO(); ftp.retrbinary('RETR '+path,out.write); return out.getvalue() +def write(path,data): + ftp.storbinary('STOR '+path,io.BytesIO(data),blocksize=65536) + assert read(path)==data, 'Readback mismatch: '+path +for path in ['/3DS','/pocketjs','/pocketjs/offload','/pocketjs/runtime','/pocketjs/runtime/native-backups']: mkdir(path) +remote='/3DS/pocketterm-main.3dsx'; backup=None +try: previous=read(remote) +except (ftplib.error_perm,ftplib.error_temp) as e: + if not str(e).startswith('550') and str(e)!='450 No such file or directory': raise +else: + backup='/pocketjs/runtime/native-backups/pocketterm-main-'+hashlib.sha256(previous).hexdigest()[:16]+'.3dsx' + write(backup,previous) +write('/pocketjs/offload/'+sys.argv[5]+'.key',pathlib.Path(sys.argv[4]).read_bytes()) +data=pathlib.Path(sys.argv[3]).read_bytes(); write(remote,data) +ftp.quit() +print(json.dumps({'date':datetime.datetime.now(datetime.timezone.utc).isoformat(),'host':sys.argv[1],'port':int(sys.argv[2]),'remote':remote,'backup':backup,'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest(),'offloadKeyReadback':True,'readback':'byte-identical','freshHardwareInteraction':'pending'},indent=2)) +`; +const result = Bun.spawnSync(["python3", "-c", program, address, String(port), binary, key, slot], { stdout: "pipe", stderr: "pipe" }); +if (result.exitCode) throw new Error(result.stderr.toString()); +writeFileSync(resolve(ROOT, ".pocket/last-deploy.json"), result.stdout); +console.log(result.stdout.toString()); diff --git a/scripts/font.ts b/scripts/font.ts new file mode 100644 index 0000000..fb0a8fd --- /dev/null +++ b/scripts/font.ts @@ -0,0 +1,68 @@ +/** Product-owned terminal atlas. Runtime font slot policy stays unchanged. */ +import { readFileSync, writeFileSync } from "node:fs"; +import opentype from "opentype.js"; +import { bakeSlot } from "../vendor/pocketjs/framework/compiler/bake-font.ts"; +import { TERM_GLYPHS } from "../shared/protocol.ts"; +import { TERM_FONT_SLOT } from "../shared/layout.ts"; +import { bitmapCell } from "../shared/bitmap-font.ts"; +import { FONT_SOURCES, loadBitmapFont, type TerminalFontName } from "../shared/font-sources.ts"; + +// Unicode box-drawing arm masks (N/E/S/W) and stroke weights. +const BOX_SHAPES = [26, 42, 21, 37, 26, 42, 21, 37, 27, 43, 21, 37, 22, 38, 38, 38, 28, 44, 44, 44, 19, 35, 35, 35, 25, 41, 41, 41, 23, 39, 39, 39, 39, 39, 39, 39, 29, 45, 45, 45, 45, 45, 45, 45, 30, 46, 46, 46, 46, 46, 46, 46, 27, 43, 43, 43, 43, 43, 43, 43, 31, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 26, 42, 21, 37, 58, 53, 54, 54, 54, 60, 60, 60, 51, 51, 51, 57, 57, 57, 55, 55, 55, 61, 61, 61, 62, 62, 62, 59, 59, 59, 63, 63, 63, 22, 28, 25, 19, 27, 27, 16, 24, 17, 18, 20, 40, 33, 34, 36, 42, 37, 42, 37]; + +export function terminalFont(face: TerminalFontName = "spleentt"): Uint8Array { + const bitmap = loadBitmapFont(face); + const fallback = loadBitmapFont("fusion"); + const source = readFileSync(new URL("../vendor/pocketjs/assets/fonts/JetBrainsMono-Regular.ttf", import.meta.url)); + const font = opentype.parse(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength) as ArrayBuffer); + const chars = [...new Set([...Array.from({ length: 95 }, (_, n) => n + 32), ...[...TERM_GLYPHS].map(ch => ch.codePointAt(0)!)])].sort((a, b) => a - b); + const baked = bakeSlot(font, TERM_FONT_SLOT, 8, false, chars); + const n = baked.glyphCount, cmapEnd = 16 + n * 8; + const bytes = new Uint8Array(cmapEnd + n * 50); + bytes.set(baked.bytes.subarray(0, cmapEnd)); + bytes[8] = 5; bytes[9] = 10; bytes[10] = 7; bytes[11] = 10; + const view = new DataView(bytes.buffer); + for (let i = 0; i < n; i++) { + const at = 16 + i * 8, cp = view.getUint32(at, true), gid = view.getUint16(at + 4, true); + bytes[at + 6] = 5; bytes[at + 7] = 0; + const pixels = bitmapCell(bitmap, cp) ?? bitmapCell(fallback, cp); + for (let y = 0; y < 10; y++) for (let x = 0; x < 5; x++) { + const outline = baked.bytes[cmapEnd + gid * baked.cellW * baked.cellH + (y + 1) * baked.cellW + x] ?? 0; + bytes[cmapEnd + gid * 50 + y * 5 + x] = pixels ? pixels[y * 5 + x] : outline >= 96 ? 255 : 0; + } + // Terminal furniture joins at cell edges, including all four arms of + // a junction. Font line metrics cannot supply these 5 x 10 boundaries. + if (cp >= 0x2500 && cp <= 0x257f) { + const shape = BOX_SHAPES[cp - 0x2500], arms = shape & 15, weight = shape >> 4; + for (let y = 0; y < 10; y++) for (let x = 0; x < 5; x++) { + const vertical = weight === 3 ? x === 1 || x === 3 : x === 2 || weight === 2 && x === 3; + const horizontal = weight === 3 ? y === 3 || y === 5 : y === 4 || weight === 2 && y === 5; + let ink = !!((arms & 1) && y <= 4 && vertical || (arms & 4) && y >= 4 && vertical || + (arms & 8) && x <= 2 && horizontal || (arms & 2) && x >= 2 && horizontal); + if (cp >= 0x2571 && cp <= 0x2573) ink = (cp !== 0x2572 && x === 4 - Math.round(y * 4 / 9)) || (cp !== 0x2571 && x === Math.round(y * 4 / 9)); + bytes[cmapEnd + gid * 50 + y * 5 + x] = ink ? 255 : 0; + } + } + if (cp >= 0x2580 && cp <= 0x259f) { + const quadrants = [4, 8, 1, 13, 9, 7, 11, 2, 6, 14]; + for (let y = 0; y < 10; y++) for (let x = 0; x < 5; x++) { + let ink = cp === 0x2580 ? y < 5 : cp <= 0x2588 ? y >= 10 - Math.round((cp - 0x2580) * 10 / 8) : + cp <= 0x258f ? x < Math.round((0x2590 - cp) * 5 / 8) : cp === 0x2590 ? x >= 3 : + cp === 0x2591 ? (x + 2 * y) % 4 === 0 : cp === 0x2592 ? (x + y) % 2 === 0 : + cp === 0x2593 ? (x + 2 * y) % 4 !== 0 : cp === 0x2594 ? y === 0 : cp === 0x2595 ? x === 4 : + !!(quadrants[cp - 0x2596] & (y < 5 ? (x < 3 ? 1 : 2) : (x < 3 ? 4 : 8))); + bytes[cmapEnd + gid * 50 + y * 5 + x] = ink ? 255 : 0; + } + } + } + return bytes; +} + +if (import.meta.main) { + const faces = Object.fromEntries(Object.keys(FONT_SOURCES).map(name => [name, Buffer.from(terminalFont(name as TerminalFontName)).toString("base64")])); + const text = `// Generated by bun scripts/font.ts. See assets/fonts/README.md.\nexport const TERM_FONTS = ${JSON.stringify(faces)};\nexport const TERM_FONT = TERM_FONTS.spleentt;\n`; + const path = new URL("../app/font.generated.ts", import.meta.url); + if (process.argv.includes("--check")) { + if (readFileSync(path, "utf8") !== text) throw new Error("Terminal font is stale"); + } else writeFileSync(path, text); +} diff --git a/scripts/history-bench.ts b/scripts/history-bench.ts new file mode 100644 index 0000000..8e01aac --- /dev/null +++ b/scripts/history-bench.ts @@ -0,0 +1,10 @@ +/** Optional baseline: git show d12e905:app/history.ts > .pocket/before-batch-history.ts */ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { historyThroughput } from "../test/history-throughput.ts"; +const baselinePath = process.argv.find(arg => arg.startsWith("--baseline="))?.slice(11); +const baseline = baselinePath ? (await import(pathToFileURL(resolve(baselinePath)).href)).createTermHistory : undefined; +console.log(JSON.stringify({ baseline: baselinePath, samples: [[60, false], [20, false], [60, true]].map(([fps, colored]) => ({ + ...(baseline ? { before: historyThroughput(baseline, Number(fps), Boolean(colored)) } : {}), + current: historyThroughput(undefined, Number(fps), Boolean(colored)), +})) }, null, 2)); diff --git a/scripts/pair.ts b/scripts/pair.ts index 253ee4a..8f6aa2a 100644 --- a/scripts/pair.ts +++ b/scripts/pair.ts @@ -3,7 +3,8 @@ // inside the submodule; this brings that copy back into .pocket/devices/, // where it survives a re-clone of vendor/pocketjs. -import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, chmodSync } from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; import { resolve } from "node:path"; import { $ } from "bun"; import { passThrough } from "./run.ts"; @@ -20,3 +21,29 @@ if (existsSync(vendorKeys)) { console.log(`pocket-term: kept .pocket/devices/${name}`); } } + +// io.offload uses an app-scoped key, separate from the development service. +const argv = process.argv.slice(2); +const address = argv[argv.indexOf("--host") + 1]; +if (!argv.includes("--host") || !address) throw new Error("Expected --host "); +const offloadKey = resolve(ROOT, ".pocket/offload.key"); +if (!existsSync(offloadKey)) writeFileSync(offloadKey, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" }); +chmodSync(offloadKey, 0o600); +const manifest = JSON.parse(readFileSync(resolve(ROOT, "pocket.json"), "utf8")); +const slot = createHash("sha256").update(manifest.id).digest("hex").slice(0, 16); +const program = `import ftplib,io,pathlib,sys +ftp=ftplib.FTP(); ftp.connect(sys.argv[1],5000,timeout=20); ftp.login() +for directory in ['/pocketjs','/pocketjs/offload']: + try: ftp.mkd(directory) + except ftplib.error_perm as error: + if not str(error).startswith('550'): raise +key=pathlib.Path(sys.argv[2]).read_bytes() +remote='/pocketjs/offload/'+sys.argv[3]+'.key' +ftp.storbinary('STOR '+remote,io.BytesIO(key)) +result=io.BytesIO(); ftp.retrbinary('RETR '+remote,result.write) +assert result.getvalue()==key, 'Pair key readback mismatch' +ftp.quit() +print('Pocket Term offload key paired and read back successfully') +`; +const paired = Bun.spawnSync(["python3", "-c", program, address, offloadKey, slot], { stdout: "inherit", stderr: "inherit" }); +if (paired.exitCode) process.exit(paired.exitCode); diff --git a/scripts/visual.ts b/scripts/visual.ts new file mode 100644 index 0000000..a2e2e25 --- /dev/null +++ b/scripts/visual.ts @@ -0,0 +1,17 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { resolve3dsBuildPlan } from "../vendor/pocketjs/tools/3ds-profile.ts"; +import { build3ds } from "../vendor/pocketjs/tools/3ds.ts"; +import { ROOT } from "./paths.ts"; +const root = resolve(ROOT, ".pocket/visual"); mkdirSync(root, { recursive: true }); +const manifest = JSON.parse(readFileSync(resolve(ROOT, "pocket.json"), "utf8")); +manifest.id += ".qa"; manifest.app.output = "pocketterm-qa"; manifest.app.entry = "main.tsx"; +writeFileSync(resolve(root, "pocket.json"), JSON.stringify(manifest)); +const history = process.argv.includes("--history"), settings = process.argv.includes("--settings"), preview = process.argv.includes("--preview"); +const showcase = process.argv.includes("--showcase"); +writeFileSync(resolve(root, "main.tsx"), `import { mountFixture } from "../../test/fixtures/screen.tsx";\nmountFixture(${history}, ${settings}, ${preview}, ${showcase});\n`); +const planPath = resolve(root, "plan.json"); writeFileSync(planPath, JSON.stringify(resolve3dsBuildPlan(manifest))); +const captureFrame = Number(process.argv.find(arg => arg.startsWith("--frame="))?.slice(8) ?? (preview ? 93 : history ? 180 : 40)); +if (!Number.isInteger(captureFrame) || captureFrame < 0 || captureFrame > 10000) throw new Error("Invalid capture frame"); +process.env.POCKETJS_CAP_START = String(captureFrame); process.env.POCKETJS_CAP_N = process.argv.includes("--motion") ? "6" : "1"; +await build3ds([`--plan=${planPath}`, `--project-root=${root}`, "--capture"]); diff --git a/shared/bitmap-font.ts b/shared/bitmap-font.ts new file mode 100644 index 0000000..8ff0131 --- /dev/null +++ b/shared/bitmap-font.ts @@ -0,0 +1,38 @@ +/** BDF decoding used by the build and the Mac rasterizer, never by the guest. */ +export interface BitmapGlyph { advance: number; width: number; height: number; x: number; y: number; pixels: Uint8Array } +export interface BitmapFont { ascent: number; height: number; glyphs: Map } +export function parseBdf(text: string): BitmapFont { + const lines = text.split(/\r?\n/), glyphs = new Map(); + let ascent = 0, height = 0, cp = -1, advance = 0, width = 0, h = 0, x = 0, y = 0; + for (let at = 0; at < lines.length; at++) { + const line = lines[at], [name, ...parts] = line.split(" "); + if (name === "FONT_ASCENT") ascent = Number(parts[0]); + else if (name === "PIXEL_SIZE") height = Number(parts[0]); + else if (name === "ENCODING") cp = Number(parts[0]); + else if (name === "DWIDTH") advance = Number(parts[0]); + else if (name === "BBX") [width, h, x, y] = parts.map(Number); + else if (name === "BITMAP") { + if (width > 32 || h > 32 || advance > 32) { at += h; continue; } + const pixels = new Uint8Array(width * h); + for (let row = 0; row < h; row++) { + const bits = parseInt(lines[++at], 16), padded = Math.ceil(width / 8) * 8; + for (let col = 0; col < width; col++) pixels[row * width + col] = bits & (1 << (padded - col - 1)) ? 255 : 0; + } + if (cp >= 0) glyphs.set(cp, { advance, width, height: h, x, y, pixels }); + } + } + return { ascent, height, glyphs }; +} + +/** Preserve each source pixel at 1:1. The two spare rows of a 5x8 face + * are padding inside the terminal's 5x10 cell, not a vertical stretch. */ +export function bitmapCell(font: BitmapFont, cp: number, cellW = 5, cellH = 10): Uint8Array | undefined { + const g = font.glyphs.get(cp); + if (!g || g.advance > cellW) return; + const pixels = new Uint8Array(cellW * cellH), top = Math.floor((cellH - font.height) / 2) + font.ascent - g.height - g.y; + for (let y = 0; y < g.height; y++) for (let x = 0; x < g.width; x++) { + const dx = g.x + x, dy = top + y; + if (dx >= 0 && dx < cellW && dy >= 0 && dy < cellH) pixels[dy * cellW + dx] = g.pixels[y * g.width + x]; + } + return pixels; +} diff --git a/shared/exchange.ts b/shared/exchange.ts new file mode 100644 index 0000000..f619f0b --- /dev/null +++ b/shared/exchange.ts @@ -0,0 +1,36 @@ +import type { ClientLine } from "./protocol.ts"; +import { OFFLOAD } from "../vendor/pocketjs/contracts/spec/offload.ts"; + +/** Limits cover the guest, provider and durable terminal worker. */ +export const LIMITS = { commands: 64, inputBatch: 8, sessions: 32, replicas: 8, outputChars: 262144, lineChars: 65536, fragmentChars: 1800 } as const; +export interface InputCommand { id: number; line: ClientLine } +export interface InputRequest { replica: string; epoch?: string; commands: InputCommand[] } +export interface InputReply { epoch: string; ack: number; error?: string } +export interface ExchangeRequest { + replica: string; + epoch?: string; + received: number; + command?: InputCommand; +} + +/** The app uses the public offload record limits without allocating a + * TextEncoder buffer on the handheld. Count the fully nested wire JSON. */ +export function fitsRecord(payload: string, method = "term.exchange", reply = false): boolean { + if (payload.length > OFFLOAD.payloadChars) return false; + const record = JSON.stringify(reply ? { id: Number.MAX_SAFE_INTEGER, payload } : { v: 1, id: Number.MAX_SAFE_INTEGER, method, payload }); + let bytes = 0; + for (let i = 0; i < record.length; i++) { + const c = record.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff && i + 1 < record.length) { bytes += 4; i++; } + else bytes += c < 128 ? 1 : c < 2048 ? 2 : 3; + } + return bytes <= OFFLOAD.recordBytes; +} +export interface ExchangeReply { + epoch: string; + ack: number; + sequence: number; + data?: string; + more?: boolean; + error?: string; +} diff --git a/shared/font-sources.ts b/shared/font-sources.ts new file mode 100644 index 0000000..cd163dc --- /dev/null +++ b/shared/font-sources.ts @@ -0,0 +1,14 @@ +/** Compressed upstream sources, read only by build tools and the Mac host. */ +import { readFileSync } from "node:fs"; +import { gunzipSync } from "node:zlib"; +import { parseBdf } from "./bitmap-font.ts"; + +export const FONT_SOURCES = { + spleen: "spleen/spleen-5x8.bdf.gz", + spleentt: "spleentt/spleentt-5x8.bdf.gz", + fusion: "fusion-pixel/fusion-pixel-10px-monospaced-zh_hans.bdf.gz", +} as const; +export type TerminalFontName = keyof typeof FONT_SOURCES; + +export const bitmapFontSource = (face: TerminalFontName): URL => new URL(`../assets/fonts/${FONT_SOURCES[face]}`, import.meta.url); +export const loadBitmapFont = (face: TerminalFontName) => parseBdf(gunzipSync(readFileSync(bitmapFontSource(face))).toString("utf8")); diff --git a/shared/history.ts b/shared/history.ts new file mode 100644 index 0000000..26090df --- /dev/null +++ b/shared/history.ts @@ -0,0 +1,45 @@ +import type { Run } from "./protocol.ts"; + +/** Oldest-inclusive / newest-exclusive absolute row addresses. They are + * independent of the current viewport and transport connection. */ +export interface HistoryManifest { epoch: string; first: number; end: number; alternate: boolean } +export interface HistoryInput { sid: number; epoch: string; row: number } +export interface HistoryRequest extends HistoryInput { part: number } +export interface HistoryReply { epoch: string; row: number; part: number; parts: number; data: string } +export interface HistoryBatchRequest { sid: number; epoch: string; rows: number[]; offset: number } +/** Each chunk belongs to one absolute row. A reply can finish many rows; + * only an individually large row continues at a nonzero offset. */ +export interface HistoryBatchReply { epoch: string; chunks: [row: number, offset: number, data: string, more: boolean][] } +export const HISTORY = { rows: 2000, entries: 192, demand: 144, fragmentChars: 600, rowChars: 16384, concurrent: 2 } as const; +export const HISTORY_BATCH = { rows: 16, concurrent: 2, pendingRows: 32, materializeRows: 8, materializeChars: 4096, fragmentChars: 2200 } as const; +export const historyKey = (i: HistoryInput) => `${i.sid}/${i.epoch}/${i.row}`; + +export function validManifest(m: HistoryManifest): boolean { + return !!m && typeof m.epoch === "string" && m.epoch.length > 0 && m.epoch.length <= 96 && + Number.isSafeInteger(m.first) && Number.isSafeInteger(m.end) && m.first >= 0 && m.end >= m.first && + m.end - m.first <= HISTORY.rows && typeof m.alternate === "boolean"; +} + +/** Bound resident row shape before it reaches the renderer. Empty arrays + * mean a known blank row; missing values mean a cache miss. */ +export function decodeHistoryRow(raw: string): Run[] { + if (raw.length > HISTORY.rowChars) throw new Error("History row exceeds budget"); + const runs = JSON.parse(raw); + if (!Array.isArray(runs) || runs.length > 80) throw new Error("Invalid history row"); + let end = 0; + for (const r of runs) { + if (!Array.isArray(r) || r.length < 4 || r.length > 6 || !Number.isInteger(r[0]) || r[0] < end || + typeof r[1] !== "string" || !r[1].length || r[1].length > 1024 || + ![r[2], r[3]].every(c => Number.isInteger(c) && c >= -1 && c <= 0xffffff) || + (r[4] !== undefined && r[4] !== null && (!Number.isInteger(r[4]) || r[4] < 19 || r[4] > 23))) throw new Error("Invalid history run"); + const width = r[5] ?? r[1].length; + if (!Number.isInteger(width) || width < 1 || r[0] + width > 80) throw new Error("Invalid history span"); + end = r[0] + width; + } + return runs; +} + +/** A row keeps its physical rendering slot until it leaves the window. */ +export function slotRow(slot: number, first: number, slots: number): number { + return first + ((slot - first) % slots + slots) % slots; +} diff --git a/shared/layout.ts b/shared/layout.ts new file mode 100644 index 0000000..1b9ae73 --- /dev/null +++ b/shared/layout.ts @@ -0,0 +1,6 @@ +/** The entire 400 x 240 primary display is the standard 80 x 24 grid. + * Connection status and session controls belong on the touch display. */ +export const TERM_LAYOUT = { cols: 80, rows: 24, cellW: 5, cellH: 10, track: 0, statusH: 0 } as const; +export const TERM_FONT_SLOT = 16; +export const TABS_PER_PAGE = 4; +export function tabPage(index: number) { return Math.floor(Math.max(0, index) / TABS_PER_PAGE); } diff --git a/app/protocol.ts b/shared/protocol.ts similarity index 90% rename from app/protocol.ts rename to shared/protocol.ts index ca73c85..eef55ed 100644 --- a/app/protocol.ts +++ b/shared/protocol.ts @@ -1,8 +1,9 @@ -// app/protocol.ts — the terminal wire protocol, shared verbatim by the +// shared/protocol.ts — the terminal wire protocol, shared verbatim by the // guest app (this directory) and the Mac companion daemon (host/serve.ts). // -// The shape follows the svc mailbox contract (spec ops 30..32): JSON lines -// both ways over one ordered connection. The companion holds the PTYs and an +// The terminal worker emits bounded JSON lines +// carried by authenticated offload exchanges (3DS) or loopback svc mirrors. +// The companion holds the PTYs and an // authoritative terminal state machine per session; the device renders a // passive cell-grid replica. Attach delivers a full grid snapshot, everything // after arrives as ordered row diffs — reconnect/resync repeats the snapshot, @@ -14,7 +15,7 @@ /** The pocket-svc app id (manifest `companions`, PKNT handshake, beacon). */ export const TERM_APP = "term"; -export const TERM_PROTO = 2; +export const TERM_PROTO = 6; /** Keep every emitted line comfortably under SVC_POLL_BUF. */ export const LINE_BUDGET = 6144; @@ -121,13 +122,18 @@ export type ClientLine = * what it was looking at across a reconnect); mirrors pass the one * session they were opened for and follow nothing else. */ want?: number; + /** Scroll position stays on this replica; historical rows use a + * separate reproducible read capability. */ + history?: 1; } | { t: "new" } | { t: "kill"; sid: number } | { t: "attach"; sid: number } | { t: "ch"; s: string } - | { t: "key"; k: string; ctrl?: 1; alt?: 1 } + | { t: "paste"; s: string; phase: "start" | "more" | "end" | "single" } + | { t: "key"; k: string; ctrl?: 1; alt?: 1; shift?: 1 } | { t: "scroll"; d: number } + | { t: "glyphs"; one: string; two: string; reset?: 1; more?: 1 } | { t: "resync" }; /** host -> device */ @@ -164,8 +170,11 @@ export type HostLine = more?: 1; rows: RowUpdate[]; cur?: Cursor; + /** Input accepted before this snapshot; echo confirmation also requires matching cells. */ + ack?: number; /** Lines currently scrolled back into history (0 = live bottom). */ sb?: number; + history?: import("./history.ts").HistoryManifest; } | { t: "exit"; sid: number } | { t: "bell"; sid: number }; @@ -184,6 +193,7 @@ export type HostLine = * `proto`. Discriminating on `proto` is what keeps the two apart. */ export type HostInputLine = + | { t: "transport-reset" } | { t: "hello"; w: number; h: number; epoch?: number } | { t: "ch"; s: string } | { t: "key"; k: string; sh?: boolean; alt?: boolean; ctl?: boolean; cmd?: boolean } @@ -196,6 +206,8 @@ export type HostInputLine = /** Named keys the host encodes into PTY bytes (host/keys.ts). */ export type KeyName = + | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" + | "Insert" | "Enter" | "Backspace" | "Tab" diff --git a/test/exchange.test.ts b/test/exchange.test.ts new file mode 100644 index 0000000..137083f --- /dev/null +++ b/test/exchange.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { Mailbox } from "../host/exchange.ts"; +import { createTermChannel } from "../app/offload.ts"; +import { LIMITS, fitsRecord, type ExchangeRequest } from "../shared/exchange.ts"; +import { TERM_PROTO } from "../shared/protocol.ts"; +import { OFFLOAD } from "@pocketjs/framework/offload"; + +test("lost replies retain output and never re-execute input", () => { + const mailbox = new Mailbox(); let executions = 0; + const req: ExchangeRequest = { replica: "test-replica", epoch: "mac", received: 0, command: { id: 1, line: { t: "new" } } }; + mailbox.push({ t: "hello", proto: TERM_PROTO, name: "Mac" }); + const a = mailbox.exchange(req, "mac", () => executions++); + expect(mailbox.exchange(req, "mac", () => executions++)).toEqual(a); + expect(executions).toBe(1); + expect(mailbox.exchange({ ...req, received: a.sequence }, "mac", () => executions++).data).toBeUndefined(); + expect(executions).toBe(1); +}); + +test("nested wire records obey both UTF-8 and payload budgets", () => { + for (const text of ["\\\"\n".repeat(2000), "你好😀".repeat(2000), "A".repeat(6000)]) { + const mailbox = new Mailbox(); + const line = { t: "hello" as const, proto: TERM_PROTO, name: text }; + mailbox.push(line); + let received = 0, result = ""; + while (mailbox.busy) { + const reply = mailbox.exchange({ replica: "test-replica", epoch: "mac", received }, "mac", () => {}); + const payload = JSON.stringify(reply); + expect(payload.length).toBeLessThanOrEqual(OFFLOAD.payloadChars); + expect(Buffer.byteLength(JSON.stringify({ id: 1, payload }))).toBeLessThanOrEqual(OFFLOAD.recordBytes); + if (reply.data) { result += reply.data; received = reply.sequence; } + } + expect(JSON.parse(result)).toEqual(line); + } +}); + +test("an expired replica or restarted daemon does not replay mutations", () => { + const mailbox = new Mailbox(); let executed = false; + const reply = mailbox.exchange({ replica: "test-replica", epoch: "old", received: 9, command: { id: 7, line: { t: "new" } } }, "new", () => executed = true); + expect(reply.epoch).toBe("new"); expect(executed).toBe(false); +}); + +test("output overflow retires the replica epoch without crashing the terminal process", () => { + const mailbox = new Mailbox(), identity = mailbox.identity; + expect(() => mailbox.push({ t: "hello", proto: TERM_PROTO, name: "x".repeat(LIMITS.lineChars + 1) })).not.toThrow(); + expect(mailbox.identity).not.toBe(identity); expect(mailbox.busy).toBe(false); + let executed = false; + mailbox.exchange({ replica: "test-replica", epoch: identity, received: 0, command: { id: 1, line: { t: "new" } } }, mailbox.identity, () => executed = true); + expect(executed).toBe(false); +}); + +test("input bypasses held output, batches bursts, and retries identical ids", () => { + const requests: { method: string; input: any; done: any }[] = []; + const channel = createTermChannel({ connected: () => true, session: () => 1, + request(method, payload, done) { requests.push({ method, input: JSON.parse(payload), done }); return requests.length; }, cancel() {} }, "test-replica"); + channel.poll(); requests[0].done({ ok: true, value: JSON.stringify({ epoch: "mac", ack: 0, sequence: 0 }) }); + channel.poll(); // Hold this output request for the entire test. + const outputCount = requests.filter(r => r.method === "term.exchange").length; + channel.send({ t: "key", k: "Right" }); + const first = requests.at(-1)!; expect(first.method).toBe("term.input"); expect(first.input.commands[0].id).toBe(1); + for (let n = 0; n < 7; n++) channel.send({ t: "key", k: "Right" }); + first.done({ ok: false, error: "lost reply" }); channel.poll(); channel.poll(); + const retry = requests.at(-1)!; expect(retry.input.commands.map((c: any) => c.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + const mailbox = new Mailbox(); let count = 0; + mailbox.input(first.input, "mac", () => count++); const reply = mailbox.input(retry.input, "mac", () => count++); + expect(count).toBe(8); retry.done({ ok: true, value: JSON.stringify(reply) }); channel.poll(); + expect(channel.inputPending!()).toBe(false); + expect(requests.filter(r => r.method === "term.exchange")).toHaveLength(outputCount); + expect(channel.sendBatch!(Array.from({ length: LIMITS.commands + 1 }, () => ({ t: "new" })))).toBe(false); +}); + +test("a malformed input batch is rejected before any new command runs", () => { + const mailbox = new Mailbox(); let count = 0; + expect(() => mailbox.input({ replica: "test-replica", epoch: "mac", commands: [ + { id: 1, line: { t: "new" } }, { id: 3, line: { t: "new" } }, + ] }, "mac", () => count++)).toThrow("gap"); + expect(count).toBe(0); expect(mailbox.ack).toBe(0); +}); + +test("held output still fits when an independent input adds a maximally escaped error", () => { + const mailbox = new Mailbox(); + mailbox.push({ t: "hello", proto: TERM_PROTO, name: '"\\字'.repeat(1200) }); + const request = { replica: "test-replica", epoch: "mac", received: 0 }; + const first = mailbox.exchange(request, "mac", () => {}); + mailbox.input({ replica: request.replica, epoch: "mac", commands: [{ id: 1, line: { t: "new" } }] }, "mac", () => { throw "\u0000".repeat(120); }); + const retry = mailbox.exchange(request, "mac", () => {}); + expect(retry.data).toBe(first.data); expect(retry.sequence).toBe(first.sequence); + expect(retry.error).toHaveLength(120); expect(retry.ack).toBe(1); + expect(fitsRecord(JSON.stringify(retry), "term.exchange", true)).toBe(true); +}); + +test("a rejected command remains consumed and its error survives later commands in the same batch", () => { + const mailbox = new Mailbox(); let calls = 0; + const request = { replica: "test-replica", epoch: "mac", commands: [1, 2].map(id => ({ id, line: { t: "new" as const } })) }; + const reply = mailbox.input(request, "mac", () => { if (++calls === 1) throw new Error("session limit"); }); + expect(reply.ack).toBe(2); expect(reply.error).toContain("session limit"); + expect(mailbox.input(request, "mac", () => calls++)).toEqual(reply); expect(calls).toBe(2); +}); + +test("retired ticket callbacks cannot replace a newer transport epoch", () => { + const requests: any[] = []; + const channel = createTermChannel({ connected: () => true, session: () => 1, + request(method, payload, done) { requests.push({ method, input: JSON.parse(payload), done }); return requests.length; }, cancel() {} }, "test-replica"); + channel.send({ t: "hello", proto: TERM_PROTO, cols: 80, rows: 24 }); channel.poll(); + requests[0].done({ ok: true, value: JSON.stringify({ epoch: "a", ack: 0, sequence: 0 }) }); channel.poll(); + const old = requests.find(r => r.method === "term.input"); + requests.at(-1).done({ ok: true, value: JSON.stringify({ epoch: "b", ack: 0, sequence: 0 }) }); + old.done({ ok: true, value: JSON.stringify({ epoch: "a", ack: 1 }) }); + expect(channel.poll()).toContainEqual({ t: "transport-reset" }); + expect(requests.at(-1).input.epoch).toBe("b"); +}); diff --git a/test/fixtures/screen.tsx b/test/fixtures/screen.tsx new file mode 100644 index 0000000..405be03 --- /dev/null +++ b/test/fixtures/screen.tsx @@ -0,0 +1,101 @@ +import { onFrame } from "@pocketjs/framework/lifecycle"; +import { __setAnalog } from "../../vendor/pocketjs/framework/src/frame.ts"; +import { mount } from "@pocketjs/framework/solid"; +import { createTermStore } from "../../app/store.ts"; +import { connectTermOffload } from "../../app/offload.ts"; +import TermApp from "../../app/app.tsx"; +import { historyBatchReply } from "../../host/history-batch.ts"; +import { TERM_PROTO, type HostLine, type RowUpdate } from "../../shared/protocol.ts"; +const ruler = "1234567890".repeat(8); +const rows: RowUpdate[] = Array.from({ length: 24 }, (_, y) => [y, [0, y === 0 || y === 23 ? ruler : `${String(y + 1).padStart(2, "0")} ${[ + "Pocket Term | macOS shell | 80 columns x 24 rows", + "$ stty size 24 80", + "$ git status --short --branch", + "## feat/offload-terminal-multiplex", + " PID TTY TIME COMMAND", + " 1042 ttys003 0:00 /bin/zsh -il", + " 1043 ttys004 0:01 vim main.ts", + "printf, pipes |, redirects >, ctrl-c, alt-b, F1-F12", + "!@#$%^&*()_+-=[]{};:'\"\\|,.<>/? ~ `", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz", + "0O 1Il | 2Z 5S 6G 8B 9g -- pixel glyph distinction", + "┌──────────────────────┬───────────────────────────────┐", + "│ SESSION #5 │ restored after reconnect │", + "└──────────────────────┴───────────────────────────────┘", + "██████████████████████████ 100% no horizontal margin", + "request -> paired offload -> PTY -> libghostty -> rows", + "$ printf 'hello from another terminal'", + "hello from another terminal", + "vim alternate screen / ANSI colors / scrollback", + "L/R switches sessions; touch arrows page all 32 tabs", + "$ ", +][(y - 1) % 21]}`.padEnd(80, " ").slice(0, 80), y % 4 === 0 ? 0x81a2be : -1, y === 5 ? 0x243047 : -1]]); +const sessions = Array.from({ length: 12 }, (_, n) => ({ sid: n + 1, title: `zsh #${n + 1}` })); +// Documentation uses deterministic shell output with the production UI. +const showcaseRows: RowUpdate[] = [ + "$ cd ~/code/pocket-term", "$ ls", "app/ assets/ docs/ host/ mirror/", + "scripts/ shared/ test/ vendor/ README.md", "", + "$ git status --short --branch", "## main...origin/main", "", + "$ printf 'terminal size: '; stty size", "terminal size: 24 80", "", + "$ printf '\\033[32mready\\033[0m\\n'", "ready", "", + "$ ls docs/", "HISTORY-THROUGHPUT.md RESPONSIVENESS.md", + "SCROLLBACK.md OFFLOAD-UPGRADE.md", "", + "$ ps -o pid,tty,comm -p $$", " PID TTY COMM", " 1042 ttys003 /bin/zsh", "", "$ ", "", +].map((text, y) => [y, [0, text, y === 6 || y === 12 ? 0x9ccf9c : text.startsWith("$") ? 0x9fb6d8 : -1, -1]]); +export function mountFixture(historyMode = false, settingsMode = false, previewMode = false, showcase = false) { +const queue: string[] = []; let sequence = 0, ack = 0, held: any, gen = 0, ticks = 0, gridSeq = 0, typed = ""; +const fixtureRows = showcase ? showcaseRows : previewMode ? rows.map(r => r[0] === 12 ? [12, [0, "$ ", -1, -1]] as RowUpdate : r) : rows; +const fixtureSessions = showcase ? [{ sid: 5, title: "shell" }, { sid: 6, title: "vim" }, { sid: 7, title: "logs" }] : sessions; +const deferred: { at: number; line: HostLine }[] = []; +const responses: { at: number; value: string }[] = []; +function push(line: HostLine) { + const text = JSON.stringify(line); + for (let i = 0; i < text.length; i += 500) queue.push(JSON.stringify({ data: text.slice(i, i + 500), more: i + 500 < text.length })); +} +(globalThis as any).offload = { + session: () => 1, + submit(record: string) { + const request = JSON.parse(record), input = JSON.parse(request.payload); + if (request.method === "term.history.batch") { + const reply = historyBatchReply(input, row => JSON.stringify([[0, `${String(row).padStart(6, "0")} cached shell history / exact row identity / 80 columns`.padEnd(80, " "), row % 3 ? -1 : 0x81a2be, -1]])); + responses.push({ at: ticks + 6 + input.rows[0] % 9, value: JSON.stringify({ id: request.id, payload: JSON.stringify(reply) }) }); return true; + } + if (request.method === "term.history") { + const row = [[0, `${String(input.row).padStart(6, "0")} cached shell history / exact row identity / 80 columns`.padEnd(80, " "), input.row % 3 ? -1 : 0x81a2be, -1]]; + const raw = JSON.stringify(row); + responses.push({ at: ticks + 6 + input.row % 9, value: JSON.stringify({ id: request.id, payload: JSON.stringify({ epoch: input.epoch, row: input.row, part: input.part, parts: Math.ceil(raw.length / 600), data: raw.slice(input.part * 600, (input.part + 1) * 600) }) }) }); return true; + } + if (request.method === "term.input") { + for (const item of input.commands ?? []) if (item.id > ack) { + ack = item.id; const command = item.line; + if (command.t === "hello" || command.t === "attach") { + push({ t: "hello", proto: TERM_PROTO, name: "evandeMacBook-Pro" }); + push({ t: "sessions", list: fixtureSessions, active: command.sid ?? 5 }); + gridSeq = 0; + push({ t: "grid", sid: command.sid ?? 5, gen: ++gen, seq: gridSeq++, ack, full: 1, rows: fixtureRows, cur: showcase ? [2, 22, 1] : previewMode ? [2, 12, 1] : [79, 23, 1], ...(historyMode ? { history: { epoch: "fixture-history", first: 0, end: 2000, alternate: false } } : {}) }); + } else if (previewMode && command.t === "ch") { + typed += command.s; + deferred.push({ at: ticks + 8, line: { t: "grid", sid: 5, gen, seq: gridSeq++, ack, rows: [[12, [0, "$ " + typed, -1, -1]]], cur: [2 + typed.length, 12, 1] } }); + } + } + responses.push({ at: ticks, value: JSON.stringify({ id: request.id, payload: JSON.stringify({ epoch: "fixture", ack }) }) }); return true; + } + if (input.epoch) { + if (held && input.received === sequence) held = undefined; + if (!held && queue.length) { held = JSON.parse(queue.shift()!); sequence++; } + } + responses.push({ at: ticks, value: JSON.stringify({ id: request.id, payload: JSON.stringify({ epoch: "fixture", ack, sequence, ...held }) }) }); return true; + }, + take() { const at = responses.findIndex(r => r.at <= ticks); return at < 0 ? undefined : responses.splice(at, 1)[0].value; }, +}; +mount(() => { + const store = createTermStore({ cols: 80, rows: 24, cell: [5, 10] }, connectTermOffload()); + onFrame(() => { + ticks++; + while (deferred[0] && deferred[0].at <= ticks) push(deferred.shift()!.line); + if (historyMode) __setAnalog((128 << 8) | (ticks >= 120 && ticks < 175 ? 16 : 128)); + if (previewMode && [50, 70, 90].includes(ticks)) store.sendText("a"); + }); + return ; +}); +} diff --git a/test/ghostty-history.integration.ts b/test/ghostty-history.integration.ts new file mode 100644 index 0000000..cb6de9f --- /dev/null +++ b/test/ghostty-history.integration.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { GhosttyCore } from "../host/node_modules/@wterm/ghostty/dist/index.js"; +import { SessionHistory } from "../host/history.ts"; + +test("pinned libghostty history addresses remain correct across native page pruning and same-chunk resets", async () => { + const wasm = readFileSync(new URL("../host/node_modules/@wterm/ghostty/wasm/ghostty-vt.wasm", import.meta.url)); + const core = await GhosttyCore.load({ wasmPath: `data:application/wasm;base64,${wasm.toString("base64")}`, scrollbackLimit: 2000 }); + core.init(80, 24); const history = new SessionHistory(); + const write = (text: string) => { history.observe(text); core.writeString(text); return history.update(core); }; + const row = (id: number, epoch: string) => Array.from({ length: 12 }, (_, x) => String.fromCodePoint(core.getScrollbackCell(history.offset(id, epoch), x).char)).join("").trim(); + let m = history.update(core), epoch = m.epoch; + for (let chunk = 0; chunk < 10; chunk++) { + m = write(Array.from({ length: 1000 }, (_, n) => `line-${chunk * 1000 + n}\r\n`).join("")); + assert.equal(m.epoch, epoch); assert.equal(m.end, (chunk + 1) * 1000 - 23); + assert.equal(row(m.first, epoch), `line-${m.first}`); + assert.equal(row(m.end - 1, epoch), `line-${m.end - 1}`); + } + assert(m.first > 0); assert.throws(() => row(0, epoch), /expired/); + const pinned = m.end - 10, content = row(pinned, epoch); + m = write("tail-a\r\ntail-b\r\n"); assert.equal(row(pinned, epoch), content); + const reset = write("\x1b[3J" + "replacement\r\n".repeat(1500)); + assert.notEqual(reset.epoch, epoch); assert.throws(() => row(pinned, epoch), /expired/); + epoch = reset.epoch; const alternate = write("\x1b[?1049hvim\x1b[?1049l"); + assert.notEqual(alternate.epoch, epoch); assert.equal(alternate.alternate, false); + m = write("\x1b[3J\x1b[H\x1b[31mR\x1b[0m\r\n" + "palette-row\r\n".repeat(30)); + const colored = m.first, before = core.getScrollbackCell(history.offset(colored, m.epoch), 0).fgRgb; + const queried = write("\x1b]4;1;?\x07"); assert.equal(queried.epoch, m.epoch); + const recolored = write("\x1b]4;1;#123456\x07"); + assert.notEqual(recolored.epoch, m.epoch); + assert.equal(core.getScrollbackCell(history.offset(colored, recolored.epoch), 0).fgRgb, 0x123456); + assert.notEqual(before, 0x123456); +}); diff --git a/test/history-batch.test.ts b/test/history-batch.test.ts new file mode 100644 index 0000000..ce5b4c4 --- /dev/null +++ b/test/history-batch.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { createHistoryBatchLoader } from "../app/history-batch.ts"; +import { historyBatchReply } from "../host/history-batch.ts"; +import { fitsRecord } from "../shared/exchange.ts"; +import { decodeHistoryRow, HISTORY_BATCH, type HistoryBatchRequest } from "../shared/history.ts"; +import { historyThroughput } from "./history-throughput.ts"; + +function transport() { + let id = 0; + const requests: { id: number; input: HistoryBatchRequest; done: (r: any) => void; cancelled: boolean }[] = []; + const loader = createHistoryBatchLoader({ request(method, raw, done) { expect(method).toBe("term.history.batch"); requests.push({ id: ++id, input: JSON.parse(raw), done, cancelled: false }); return id; }, + cancel(id) { const r = requests.find(r => r.id === id); if (r) r.cancelled = true; } }); + return { loader, requests, answer(index: number, read = (row: number) => JSON.stringify([[0, `row-${row}`, -1, -1]])) { + const r = requests[index]; r.done({ ok: true, value: JSON.stringify(historyBatchReply(r.input, read)) }); + } }; +} + +test("sixteen independent row loads use one reply and retain exact identities", () => { + const t = transport(), values = new Map(); + for (let row = 10; row < 26; row++) t.loader.load({ sid: 1, epoch: "a", row }, r => { if (r.ok) values.set(row, r.value); }); + t.loader.step(true); expect(t.requests).toHaveLength(1); t.answer(0); + expect(values.size).toBe(0); t.loader.publish(); expect(values.size).toBe(8); t.loader.publish(); + expect(values.size).toBe(16); expect(decodeHistoryRow(values.get(25)!)[0][1]).toBe("row-25"); +}); + +test("wire filling handles dense colors, quotes, escapes and surrogate pairs exactly", () => { + const raw = JSON.stringify(Array.from({ length: 80 }, (_, x) => [x, x % 2 ? "字" : "\\\"😀", x % 2 ? 0xffffff : 0x123456, 0xabcdef, 19, 1])); + const input = { sid: 1, epoch: "a", rows: [10, 11], offset: 0 }, pieces = new Map(); + let requests = 0; + while (input.rows.length) { + const p = historyBatchReply(input, () => raw); requests++; + expect(fitsRecord(JSON.stringify(p), "term.history.batch", true)).toBe(true); + for (const [row, offset, data, more] of p.chunks) { + expect(offset).toBe((pieces.get(row) ?? "").length); pieces.set(row, (pieces.get(row) ?? "") + data); + expect(/[\uD800-\uDBFF]$/.test(data)).toBe(false); + if (!more) { input.rows.shift(); input.offset = 0; } else input.offset += data.length; + } + } + expect(requests).toBeGreaterThan(2); expect(pieces.get(10)).toBe(raw); expect(pieces.get(11)).toBe(raw); +}); + +test("record boundaries reserve the longer final-fragment flag", () => { + const raw = '"'.repeat(1003); + expect(fitsRecord(JSON.stringify({ epoch: "eee", chunks: [[0, 0, raw, false]] }), "term.history.batch", true)).toBe(false); + const p = historyBatchReply({ sid: 1, epoch: "eee", rows: [0], offset: 0 }, () => raw); + expect(p.chunks[0][3]).toBe(true); + expect(fitsRecord(JSON.stringify(p), "term.history.batch", true)).toBe(true); +}); + +test("cancelling one in-flight row preserves its peers; cancelling all fences late replies", () => { + const t = transport(), results: number[] = [], handles: ({ cancel(): void } | false)[] = []; + for (let row = 0; row < 4; row++) handles.push(t.loader.load({ sid: 1, epoch: "a", row }, () => results.push(row))); + t.loader.step(true); if (handles[0]) handles[0].cancel(); t.answer(0); t.loader.publish(); expect(results).toEqual([1, 2, 3]); + const last = t.loader.load({ sid: 2, epoch: "b", row: 9 }, () => results.push(9)); + t.loader.step(true); if (last) last.cancel(); expect(t.requests[1].cancelled).toBe(true); t.answer(1); t.loader.publish(); expect(results).toEqual([1, 2, 3]); +}); + +test("a malformed batch cannot publish a valid prefix and input pressure pauses starts", () => { + const t = transport(), successes: number[] = [], errors: unknown[] = []; + for (let row = 0; row < 2; row++) t.loader.load({ sid: 1, epoch: "a", row }, r => { if (r.ok) successes.push(row); else errors.push(r.error); }); + t.loader.step(false); expect(t.requests).toHaveLength(0); t.loader.step(true); + t.requests[0].done({ ok: true, value: JSON.stringify({ epoch: "a", chunks: [[0, 0, "[]", false], [99, 0, "[]", false]] }) }); + expect(successes).toEqual([]); expect(errors).toHaveLength(2); +}); + +test("dense rows obey a per-frame decode budget and completed rows remain cancellable", () => { + const t = transport(), delivered: number[] = []; + const raw = JSON.stringify(Array.from({ length: 80 }, (_, x) => [x, "X", 0xffffff, 0xabcdef])); + const handles = Array.from({ length: 4 }, (_, row) => t.loader.load({ sid: 1, epoch: "a", row }, r => { if (r.ok) delivered.push(row); })); + t.loader.step(true); + for (let n = 0; n < t.requests.length; n++) { t.answer(n, () => raw); t.loader.step(true); } + expect(delivered).toEqual([]); + const last = handles[3]; if (last) last.cancel(); + t.loader.publish(); expect(delivered.length).toBe(Math.min(3, Math.floor(HISTORY_BATCH.materializeChars / raw.length))); + for (let n = 0; n < 4; n++) t.loader.publish(); expect(delivered).toEqual([0, 1, 2]); +}); + +test("cold viewport fills in bounded batches under the actual one-reply-per-frame pump", () => { + for (const fps of [60, 20]) { + const r = historyThroughput(undefined, fps); + expect(r.readyRows).toBe(26); expect(r.fullMs).toBeLessThanOrEqual(650); + expect(r.maxRowsPerReply).toBeGreaterThan(8); expect(r.maxPublished).toBe(HISTORY_BATCH.materializeRows); + expect(r.historyRequests).toBeLessThanOrEqual(8); + } + const colored = historyThroughput(undefined, 60, true); + expect(colored.readyRows).toBe(26); expect(colored.fullMs).toBeLessThan(8000); +}); diff --git a/test/history-throughput.ts b/test/history-throughput.ts new file mode 100644 index 0000000..4351df9 --- /dev/null +++ b/test/history-throughput.ts @@ -0,0 +1,51 @@ +import { createRoot } from "solid-js"; +import { createOffloadClient } from "@pocketjs/framework/offload"; +import { HISTORY } from "../shared/history.ts"; +import { historyBatchReply } from "../host/history-batch.ts"; +import { createTermHistory } from "../app/history.ts"; +import { createTermChannel } from "../app/offload.ts"; + +/** Actual guest offload pump: two sends, one take per frame, fixed RTT, + * continuous idle output polls and a cold 26-row viewport. No wire-byte + * throughput cap is simulated: this isolates protocol/scheduler limits. */ +export function historyThroughput(factory: typeof createTermHistory = createTermHistory, fps = 60, colored = false) { + let frame = 0, nextReply = 0, submitted = 0, replyBytes = 0, historyRequests = 0, firstMs = -1, fullMs = -1, maxPublished = 0, previousReady = 0; + const perReplyRows: number[] = []; + const pending: { at: number; order: number; raw: string }[] = []; + const rowRaw = (row: number) => JSON.stringify(colored + ? Array.from({ length: 80 }, (_, x) => [x, String.fromCharCode(33 + (x + row) % 90), x % 2 ? 0x123456 : 0xffffff, -1]) + : [[0, `${String(row).padStart(6, "0")} ${"shell output ".repeat(7)}`.slice(0, 80), -1, -1]]); + const io = createOffloadClient({ session: () => 1, + submit(record) { + if (submitted >= 2) return false; submitted++; + const r = JSON.parse(record), p = JSON.parse(r.payload); let payload: unknown; + if (r.method === "term.history") { + historyRequests++; const raw = rowRaw(p.row); + payload = { epoch: p.epoch, row: p.row, part: p.part, parts: Math.ceil(raw.length / HISTORY.fragmentChars), data: raw.slice(p.part * HISTORY.fragmentChars, (p.part + 1) * HISTORY.fragmentChars) }; + perReplyRows.push(p.part + 1 === Math.ceil(raw.length / HISTORY.fragmentChars) ? 1 : 0); + } else if (r.method === "term.history.batch") { + historyRequests++; const reply = historyBatchReply(p, rowRaw); payload = reply; + perReplyRows.push(reply.chunks.filter(c => !c[3]).length); + } else payload = { epoch: "fixture", ack: 0, sequence: 0 }; + const raw = JSON.stringify({ id: r.id, payload: JSON.stringify(payload) }); + replyBytes += new TextEncoder().encode(raw).length; + pending.push({ at: frame + Math.ceil(fps * .1), order: nextReply++, raw }); return true; + }, + take() { const next = pending.findIndex(p => p.at <= frame); return next < 0 ? undefined : pending.splice(next, 1)[0].raw; }, + }); + let history!: ReturnType, dispose!: () => void; + createRoot(close => { dispose = close; history = factory(io, y => () => [[0, `live-${y}`, -1, -1]], 24, 10); }); + const output = createTermChannel(io, "benchmark-output"); + history.adopt(1, { epoch: "benchmark-epoch", first: 0, end: 2000, alternate: false }); history.setOnline(true); + history.beginDrag(); history.drag(-10000); history.endDrag(0); + for (; frame < fps * 30; frame++) { + submitted = 0; io.step(); history.frame(); output.poll(); + const ready = Array.from({ length: 26 }, (_, y) => history.row(1000 + y) !== undefined).filter(Boolean).length; + maxPublished = Math.max(maxPublished, ready - previousReady); previousReady = ready; + if (ready && firstMs < 0) firstMs = frame * 1000 / fps; + if (ready === 26) { fullMs = frame * 1000 / fps; break; } + } + const result = { fps, rttMs: 100, colored, firstMs, fullMs, historyRequests, replyBytes, maxPublished, + maxRowsPerReply: Math.max(...perReplyRows), readyRows: previousReady }; + dispose(); output.dispose?.(); io.dispose(); return result; +} diff --git a/test/history.test.ts b/test/history.test.ts new file mode 100644 index 0000000..232aeeb --- /dev/null +++ b/test/history.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { createRoot } from "solid-js"; +import { runFrameHooks, resetFrameHooks } from "../vendor/pocketjs/framework/src/frame.ts"; +import { createTermHistory, historyDemand, historyLoader, type HistoryIO, type TermHistory } from "../app/history.ts"; +import { createCursorStick } from "../app/stick.ts"; +import { HISTORY, decodeHistoryRow, slotRow, type HistoryManifest, type HistoryRequest, type HistoryBatchRequest } from "../shared/history.ts"; +import { historyBatchReply } from "../host/history-batch.ts"; +import { HistoryBoundary, SessionHistory } from "../host/history.ts"; +import type { Run } from "../shared/protocol.ts"; + +test("history identities survive append and trimming, but fence clear/reset/alternate screens", () => { + const h = new SessionHistory(); let count = 30, discarded = 10, alt = false; + const core = { getScrollbackCount: () => count, getScrollbackDiscardedCount: () => discarded, usingAltScreen: () => alt }; + const first = h.update(core); expect(first).toMatchObject({ first: 10, end: 40, alternate: false }); + expect(h.offset(10, first.epoch)).toBe(29); expect(h.offset(39, first.epoch)).toBe(0); + count += 5; const append = h.update(core); expect(append.epoch).toBe(first.epoch); expect(h.offset(39, first.epoch)).toBe(5); + discarded += 5; count -= 5; expect(h.update(core).first).toBe(15); expect(() => h.offset(10, first.epoch)).toThrow("expired"); + h.observe("\x1b[3"); h.observe("J"); count = 100; const clear = h.update(core); + expect(clear.epoch).not.toBe(first.epoch); expect(() => h.offset(39, first.epoch)).toThrow("expired"); + alt = true; const alternate = h.update(core); expect(alternate.epoch).not.toBe(clear.epoch); expect(alternate.first).toBe(alternate.end); + alt = false; expect(h.update(core).epoch).not.toBe(alternate.epoch); + h.invalidate(); expect(h.update(core).epoch).not.toBe(clear.epoch); +}); + +test("boundary observer handles split VT sequences without treating OSC/DCS data as terminal commands", () => { + const observer = new HistoryBoundary(); + expect(observer.feed("\x1b]2;literal \x1b[3J title\x07")).toBe(false); + expect(observer.feed("\x1bPignored \x1b[3J\x1b\\")).toBe(false); + expect(observer.feed("\x1b[2Jprogress\r50%")).toBe(false); + expect(observer.feed("\x1b[?104")).toBe(false); expect(observer.feed("9h\x1b[?1049l")).toBe(true); + expect(observer.feed("\x1b")).toBe(false); expect(observer.feed("c")).toBe(true); + expect(observer.feed("\x1b]4;1;?\x07\x1b]10;?\x1b\\")).toBe(false); + expect(observer.feed("\x1b]4;1;#123")).toBe(false); expect(observer.feed("456\x07")).toBe(true); + expect(observer.feed("\x1b]104\x1b\\")).toBe(true); + expect(observer.feed("\x1b]4;" + "1;?;".repeat(100) + "1;#abcdef\x07")).toBe(true); +}); + +test("viewport demand prioritizes visible rows, stays bounded, and recycles only one slot per row crossed", () => { + const m = { epoch: "a", first: 20, end: 2000, alternate: false }; + const demand = historyDemand(1, m, 500, -1); + expect(demand).toHaveLength(HISTORY.demand); + expect(demand.slice(0, 26).map(d => d.input.row)).toEqual(Array.from({ length: 26 }, (_, i) => 500 + i)); + expect(demand.slice(0, 26).every(d => d.pin)).toBe(true); + expect(historyDemand(1, m, m.end, -1)[0].input.row).toBe(1999); + expect(historyDemand(1, { ...m, alternate: true }, 500, -1)).toEqual([]); + for (const first of [0, 25, 26, 511, 2000000]) { + const before = Array.from({ length: 26 }, (_, slot) => slotRow(slot, first, 26)); + const after = Array.from({ length: 26 }, (_, slot) => slotRow(slot, first + 1, 26)); + expect(before.filter((row, i) => row !== after[i])).toHaveLength(1); + expect(new Set(before).size).toBe(26); + } +}); + +function transport() { + let nextId = 0; + const requests: { id: number; input: HistoryRequest & Partial; done: Parameters[2]; cancelled: boolean; answered: boolean }[] = []; + const io: HistoryIO = { + request(method, payload, done) { expect(["term.history", "term.history.batch"]).toContain(method); const id = ++nextId; requests.push({ id, input: JSON.parse(payload), done, cancelled: false, answered: false }); return id; }, + cancel(id) { const r = requests.find(r => r.id === id); if (r) r.cancelled = true; }, + }; + const answer = (r: typeof requests[number], supplied?: string) => { + r.answered = true; + const read = (row: number) => supplied ?? JSON.stringify([[0, `history-${r.input.epoch}-${row}`, -1, -1]]); + if (r.input.rows) { r.done({ ok: true, value: JSON.stringify(historyBatchReply(r.input as HistoryBatchRequest, read)) }); return; } + const raw = read(r.input.row); + r.done({ ok: true, value: JSON.stringify({ epoch: r.input.epoch, row: r.input.row, part: r.input.part, + parts: Math.ceil(raw.length / HISTORY.fragmentChars), data: raw.slice(r.input.part * HISTORY.fragmentChars, (r.input.part + 1) * HISTORY.fragmentChars) }) }); + }; + return { io, requests, answer }; +} + +test("fragmented history reads assemble exact rows and cancellation fences late data", () => { + const t = transport(), result: unknown[] = [], load = historyLoader(t.io); + const runs: Run[] = Array.from({ length: 80 }, (_, x) => [x, x % 2 ? "\\" : "字", x % 2 ? 0xffffff : 0x800000, -1]); + const raw = JSON.stringify(runs), handle = load({ sid: 1, epoch: "generation", row: 10 }, r => result.push(r)); + for (let n = 0; n < t.requests.length; n++) t.answer(t.requests[n], raw); + expect(result).toEqual([{ ok: true, value: raw }]); expect(t.requests.length).toBeGreaterThan(1); + expect(decodeHistoryRow(raw)).toEqual(runs); + const late = load({ sid: 1, epoch: "old", row: 20 }, r => result.push(r)); + if (late) late.cancel(); t.answer(t.requests.at(-1)!); expect(result).toHaveLength(1); + expect(() => decodeHistoryRow('[[79,"ab",-1,-1]]')).toThrow("span"); + expect(() => decodeHistoryRow('[[0,"x",-1,-1],[0,"y",-1,-1]]')).toThrow("run"); + if (handle) handle.cancel(); +}); + +test("local history glides while IO is delayed; cached reading survives disconnect and append preserves its anchor", () => { + resetFrameHooks(); const t = transport(); let h!: TermHistory, dispose!: () => void; + createRoot(close => { dispose = close; h = createTermHistory(t.io, y => () => [[0, `live-${y}`, -1, -1]], 24, 10); }); + const m: HistoryManifest = { epoch: "session-a", first: 0, end: 1000, alternate: false }; + h.adopt(1, m); h.setOnline(true); + const frame = (reply = true) => { h.frame(); runFrameHooks(0); if (reply) for (const r of [...t.requests]) if (!r.answered && !r.cancelled) t.answer(r); }; + for (let i = 0; i < 30; i++) frame(); + expect(h.stats().ready).toBeGreaterThan(20); + const plans = h.stats().plans; + for (let i = 0; i < 60; i++) frame(false); + expect(h.stats().plans).toBe(plans); // idle frames do not rebuild demand maps + h.beginDrag(); h.drag(-100); h.endDrag(-120); + const before = h.scroller.offset(); for (let i = 0; i < 10; i++) frame(false); + expect(h.scroller.offset()).toBeLessThan(before); // no network reply needed to move + const positions: number[] = []; + for (let i = 0; i < 8; i++) { frame(false); positions.push(h.translation(0)); } + expect(new Set(positions.map(p => Math.abs(p) % 10)).size).toBeGreaterThan(2); // sub-row paint positions + h.stop(); const anchored = h.first(); expect(h.row(anchored)).toBeDefined(); + h.adopt(1, { ...m, end: 1010 }); expect(h.first()).toBe(anchored); + h.adopt(1, { ...m, first: 10, end: 1010 }); expect(h.first()).toBe(anchored); + h.setOnline(false); const count = t.requests.length; for (let i = 0; i < 20; i++) frame(false); + expect(t.requests.length).toBe(count); expect(h.row(anchored)).toBeDefined(); + h.beginDrag(); h.drag(-2500); h.endDrag(0); frame(false); expect(h.row(h.first())).toBeUndefined(); // skeleton, not a fabricated blank row + const old = t.requests.find(r => !r.answered)!; + h.adopt(1, { ...m, epoch: "session-b" }); h.setOnline(true); + if (old) t.answer(old); frame(false); expect(h.first()).toBe(1000); expect(h.row(0)).toBeUndefined(); + h.adopt(1, { epoch: "alternate", first: 0, end: 0, alternate: true }); h.nudge(-100); h.drag(-100); frame(false); + expect(h.first()).toBe(0); expect(h.row(0)?.[0][1]).toBe("live-0"); + expect(h.stats().entries).toBeLessThanOrEqual(HISTORY.entries); dispose(); resetFrameHooks(); +}); + +test("right stick repeats cardinal cursor keys, rejects drift and stabilizes diagonal motion", () => { + const stick = createCursorStick(); + expect(stick.step(0.1, -0.1)).toBeUndefined(); expect(stick.step(0, -0.8)).toBe("Up"); + for (let i = 0; i < 15; i++) expect(stick.step(0, -0.8)).toBeUndefined(); + expect(stick.step(0, -0.8)).toBe("Up"); expect(stick.step(0.8, -0.75)).toBeUndefined(); + expect(stick.step(0.9, -0.3)).toBe("Right"); stick.step(0, 0); expect(stick.step(-0.4, 0)).toBe("Left"); +}); diff --git a/test/host.test.ts b/test/host.test.ts index 2bd9225..37a77e1 100644 --- a/test/host.test.ts +++ b/test/host.test.ts @@ -21,7 +21,7 @@ import { THEME_BG, THEME_FG, type RowUpdate, -} from "../app/protocol.ts"; +} from "../shared/protocol.ts"; import { FrameParser, encodeBeacon, @@ -45,7 +45,7 @@ import { forceAdvances, isBakedCodepoint, } from "../host/glyphs.ts"; -import { DYNAMIC_SLOTS } from "../app/protocol.ts"; +import { DYNAMIC_SLOTS } from "../shared/protocol.ts"; // --------------------------------------------------------------------------- // wire @@ -286,6 +286,14 @@ describe("key encoding", () => { expect(encodeKey("Space", true, false, false)).toBe("\x00"); expect(encodeKey("Space", false, false, false)).toBe(" "); }); + + test("function keys, shift-tab and combined navigation modifiers", () => { + expect(encodeKey("F1", false, false, false)).toBe("\x1bOP"); + expect(encodeKey("F12", true, true, false, true)).toBe("\x1b[24;8~"); + expect(encodeKey("Tab", false, false, false, true)).toBe("\x1b[Z"); + expect(encodeKey("Home", true, false, true)).toBe("\x1b[1;5H"); + expect(encodeKey("Right", true, true, true)).toBe("\x1b[1;7C"); + }); }); // --------------------------------------------------------------------------- diff --git a/test/latency.test.ts b/test/latency.test.ts new file mode 100644 index 0000000..a033476 --- /dev/null +++ b/test/latency.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { createTermChannel } from "../app/offload.ts"; +import { latencyScenario } from "./latency.ts"; + +test("20 Hz input stays bounded on a 100 ms link without dropping ordered keys", () => { + const result = latencyScenario(createTermChannel); + expect(result.inputs).toBe(60); expect(result.ordered).toBe(true); expect(result.unique).toBe(60); + expect(result.p95Ms).toBeLessThanOrEqual(200); expect(result.peakQueue).toBeLessThanOrEqual(4); +}); diff --git a/test/latency.ts b/test/latency.ts new file mode 100644 index 0000000..993c9db --- /dev/null +++ b/test/latency.ts @@ -0,0 +1,41 @@ +import { Mailbox } from "../host/exchange.ts"; +import type { TermChannel } from "../app/channel.ts"; + +/** Deterministic 60 Hz native boundary: 100 ms RTT, two submissions and one + * reply delivery per frame. Samples input arrival at the PTY capability, + * not a network callback or an optimistic pixel. */ +export function latencyScenario(factory: (io: any, replica: string) => TermChannel) { + const mailbox = new Mailbox(), sent = new Map(), latency: number[] = []; + let frame = 0, requestId = 0, generation = 0, submitted = 0, peakQueue = 0; + const pending: { id: number; method: string; request: any; sentAt: number; reply?: any; done: any; cancelled?: boolean }[] = []; + const inputKeys: number[] = []; + const channel = factory({ connected: () => true, session: () => 1, cancel(id: number) { const p = pending.find(p => p.id === id); if (p) p.cancelled = true; }, + request(method: string, payload: string, done: any) { + const id = ++requestId; pending.push({ id, method, request: JSON.parse(payload), sentAt: -1, done }); return id; + }, + }, "latency-replica"); + for (; frame < 700; frame++) { + submitted = 0; + for (const p of pending) { + if (p.cancelled) continue; + if (p.sentAt < 0 && submitted < 2) { p.sentAt = frame; submitted++; } + if (p.sentAt < 0 || frame !== p.sentAt + 3) continue; + const apply = (line: any) => { + if (line.t !== "key") return; + const n = Number(line.k); latency.push((frame - sent.get(n)!) * 1000 / 60); inputKeys.push(n); + mailbox.push({ t: "grid", sid: 1, gen: 1, seq: generation++, rows: [], cur: [n % 80, 0, 1] }); + }; + p.reply = p.method === "term.input" ? mailbox.input(p.request, "mac", apply) : mailbox.exchange(p.request, "mac", apply); + } + const ready = pending.find(p => !p.cancelled && p.reply && frame >= p.sentAt + 6); + if (ready) { ready.cancelled = true; ready.done({ ok: true, value: JSON.stringify(ready.reply) }); } + channel.poll(); + if (frame >= 20 && frame < 200 && (frame - 20) % 3 === 0) { + const n = (frame - 20) / 3; sent.set(n, frame); channel.send({ t: "key", k: String(n) }); + } + peakQueue = Math.max(peakQueue, sent.size - inputKeys.length); + } + channel.dispose?.(); + const sorted = latency.slice().sort((a, b) => a - b); + return { inputs: inputKeys.length, unique: new Set(inputKeys).size, ordered: inputKeys.every((n, i) => n === i), p50Ms: sorted[Math.floor(sorted.length * .5)], p95Ms: sorted[Math.floor(sorted.length * .95)], maxMs: sorted.at(-1), peakQueue }; +} diff --git a/test/layout.test.ts b/test/layout.test.ts new file mode 100644 index 0000000..2aa2690 --- /dev/null +++ b/test/layout.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { TERM_FONT, TERM_FONTS } from "../app/font.generated.ts"; +import { TERM_LAYOUT } from "../shared/layout.ts"; +import { keyAt, type LayerName } from "../app/keyboard.tsx"; +import { terminalFont } from "../scripts/font.ts"; +import { loadBitmapFont, type TerminalFontName } from "../shared/font-sources.ts"; +import { bitmapCell } from "../shared/bitmap-font.ts"; +import { bakeBitmapAtlas } from "../host/glyphs.ts"; + +test("the shipped atlas and 80 x 24 grid reach all four screen edges", () => { + const bytes = Buffer.from(TERM_FONT, "base64"), view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + expect(bytes).toEqual(Buffer.from(terminalFont())); + expect([bytes[8], bytes[9], bytes[11], bytes[12]]).toEqual([5, 10, 10, 16]); + expect(TERM_LAYOUT.cols * bytes[8]).toBe(400); expect(TERM_LAYOUT.rows * bytes[9]).toBe(240); + for (let i = 0; i < view.getUint16(6, true); i++) expect(bytes[16 + i * 8 + 6]).toBe(5); +}); + +test("all keyboard layers cover the touch panel, with reachable function and modifier keys", () => { + for (const layer of ["lower", "upper", "sym", "sym2", "fn"] as LayerName[]) { + for (let row = 0; row < 5; row++) for (let x = 0; x < 320; x += 16) expect(keyAt(layer, x + 1, row * 26 + 13)).not.toBeNull(); + } + expect(keyAt("fn", 1, 39)!.def.act).toEqual({ key: "F1" }); + expect(keyAt("fn", 65, 91)!.def.act).toEqual({ key: "F12" }); + expect(keyAt("lower", 137, 13)!.def.act).toEqual({ mod: "alt" }); + expect(keyAt("lower", 280, 13)!.def.act).toEqual({ settings: true }); + const strip = Array.from({ length: 320 }, (_, x) => keyAt("lower", x, 13)!.def.act); + expect(strip.some(act => "key" in act && ["Up", "Down", "Left", "Right"].includes(act.key))).toBe(false); +}); + +test("box and block ink reaches adjacent cells without font-metric gaps", () => { + const bytes = terminalFont(), view = new DataView(bytes.buffer), n = view.getUint16(6, true); + const glyph = (ch: string) => { + for (let i = 0; i < n; i++) if (view.getUint32(16 + i * 8, true) === ch.codePointAt(0)) { + const offset = 16 + n * 8 + view.getUint16(20 + i * 8, true) * 50; return bytes.slice(offset, offset + 50); + } + throw new Error(`Missing ${ch}`); + }; + expect([...glyph("█")].every(a => a === 255)).toBe(true); + expect([...glyph("─").slice(20, 25)]).toEqual([255, 255, 255, 255, 255]); + expect(Array.from({ length: 10 }, (_, y) => glyph("│")[y * 5 + 2])).toEqual(Array(10).fill(255)); +}); + +test("all three terminal faces preserve the source's ASCII bitmap and have no antialiasing samples", () => { + for (const name of Object.keys(TERM_FONTS) as TerminalFontName[]) { + const b = Buffer.from(TERM_FONTS[name], "base64"), v = new DataView(b.buffer, b.byteOffset, b.byteLength), n = v.getUint16(6, true); + const source = loadBitmapFont(name); + expect(b).toEqual(Buffer.from(terminalFont(name))); + expect([...b.subarray(16 + n * 8)].every(a => a === 0 || a === 255)).toBe(true); + for (let i = 0; i < n; i++) { + const cp = v.getUint32(16 + i * 8, true), gid = v.getUint16(20 + i * 8, true); + if (cp >= 32 && cp <= 126) expect(b.subarray(16 + n * 8 + gid * 50, 16 + n * 8 + (gid + 1) * 50)).toEqual(Buffer.from(bitmapCell(source, cp)!)); + } + } +}); + +test("Fusion Pixel CJK keeps its complete 10x10 cell next to a narrow dynamic glyph", () => { + const source = loadBitmapFont("fusion"); + const b = bakeBitmapAtlas(source, 19, new Map([["你".codePointAt(0)!, 2], ["é".codePointAt(0)!, 1]]), 5, 10); + const v = new DataView(b.buffer), n = v.getUint16(6, true); + expect([b[8], b[9]]).toEqual([10, 10]); + for (let i = 0; i < n; i++) { + const cp = v.getUint32(16 + i * 8, true), gid = v.getUint16(20 + i * 8, true); + if (cp === "你".codePointAt(0)) { expect(b[22 + i * 8]).toBe(10); expect([...b.slice(16 + n * 8 + gid * 100, 16 + n * 8 + (gid + 1) * 100)]).toEqual([...bitmapCell(source, cp, 10, 10)!]); } + } +}); diff --git a/test/prediction.test.ts b/test/prediction.test.ts new file mode 100644 index 0000000..c226db9 --- /dev/null +++ b/test/prediction.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { createTypingPrediction, type TypingPreview } from "../app/prediction.ts"; +import type { Cursor, Run } from "../shared/protocol.ts"; + +function fixture() { + let cursor: Cursor = [2, 0, 1], text = "$ ", preview: TypingPreview | undefined; + const p = createTypingPrediction(() => [[0, text, -1, -1]], () => cursor, next => preview = next); + return { p, view: () => preview, set(value: string, x: number) { text = value; cursor = [x, 0, 1]; } }; +} + +test("typing preview learns actual echoes, renders immediately and removes confirmed prefixes", () => { + const f = fixture(); + for (let n = 0; n < 2; n++) { + f.p.input({ t: "ch", s: "a" }, n + 1, n * 40); expect(f.view()).toBeUndefined(); + f.set("$ " + "a".repeat(n + 1), 3 + n); f.p.authoritative(n + 1, n * 40 + 20); + } + f.p.input({ t: "ch", s: "b" }, 3, 100); expect(f.view()?.text).toEqual({ x: 4, y: 0, value: "b" }); + f.p.input({ t: "ch", s: "c" }, 4, 120); expect(f.view()?.text?.value).toBe("bc"); + f.set("$ aab", 5); f.p.authoritative(4, 140); expect(f.view()?.text?.value).toBe("c"); + f.set("$ aabc", 6); f.p.authoritative(4, 160); expect(f.view()).toBeUndefined(); + f.p.input({ t: "ch", s: "d" }, 5, 180); expect(f.view()).toBeDefined(); + f.p.frame(540); expect(f.view()).toBeUndefined(); +}); + +test("write acknowledgement alone cannot enable echo; password-like silence and timeouts stay hidden", () => { + const f = fixture(); + f.p.input({ t: "ch", s: "s" }, 1, 0); f.p.authoritative(1, 100); + f.p.input({ t: "ch", s: "e" }, 2, 120); f.p.authoritative(2, 150); + expect(f.view()).toBeUndefined(); f.p.frame(500); expect(f.view()).toBeUndefined(); + f.p.input({ t: "key", k: "Enter" }, 3, 510); f.p.input({ t: "ch", s: "x" }, 4, 520); + expect(f.view()).toBeUndefined(); +}); + +test("control transitions, mismatched output and wrapped/wide rows revoke predictions", () => { + const f = fixture(); + f.p.input({ t: "ch", s: "a" }, 1, 0); f.set("$ a", 3); f.p.authoritative(1, 20); + f.p.input({ t: "ch", s: "b" }, 2, 40); f.set("$ ab", 4); f.p.authoritative(2, 60); + f.p.input({ t: "ch", s: "c" }, 3, 80); expect(f.view()).toBeDefined(); + f.set("unexpected", 0); f.p.authoritative(3, 100); expect(f.view()).toBeUndefined(); + f.p.input({ t: "ch", s: "x" }, 4, 120); expect(f.view()).toBeUndefined(); + f.p.input({ t: "key", k: "Escape" }, 5, 130); expect(f.view()).toBeUndefined(); + let result: TypingPreview | undefined; + const wide: Run[] = [[0, "字", -1, -1, 19, 2]]; + const p = createTypingPrediction(() => wide, () => [79, 0, 1], value => result = value); + p.input({ t: "ch", s: "x" }, 1, 0); expect(result).toBeUndefined(); +}); + +test("learned cursor motion is provisional and never writes cells", () => { + let cursor: Cursor = [0, 0, 1], preview: TypingPreview | undefined; + const row: Run[] = [[0, "alpha beta", -1, -1]]; + const p = createTypingPrediction(() => row, () => cursor, value => preview = value); + for (let n = 1; n <= 2; n++) { p.input({ t: "key", k: "Right" }, n, n * 40); cursor = [n, 0, 1]; p.authoritative(n, n * 40 + 20); } + p.input({ t: "key", k: "Right" }, 3, 130); expect(preview?.cursor).toEqual([3, 0, 1]); expect(cursor).toEqual([2, 0, 1]); + expect(row[0][1]).toBe("alpha beta"); p.enabled(false); expect(preview).toBeUndefined(); +}); + +test("a command submission revokes trained echo before a silent password prompt", () => { + const f = fixture(); + for (let n = 0; n < 2; n++) { + f.p.input({ t: "ch", s: "a" }, n + 1, n * 40); f.set("$ " + "a".repeat(n + 1), 3 + n); f.p.authoritative(n + 1, n * 40 + 20); + } + f.p.input({ t: "ch", s: "b" }, 3, 100); expect(f.view()).toBeDefined(); + f.p.input({ t: "key", k: "Enter" }, 4, 110); expect(f.view()).toBeUndefined(); f.set("Password: ", 10); + for (let n = 0; n < 5; n++) { f.p.input({ t: "ch", s: "s" }, 5 + n, 520 + n * 10); f.p.authoritative(5 + n, 525 + n * 10); expect(f.view()).toBeUndefined(); } +}); diff --git a/test/pty.integration.ts b/test/pty.integration.ts new file mode 100644 index 0000000..2e249a1 --- /dev/null +++ b/test/pty.integration.ts @@ -0,0 +1,223 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fork, spawn, type ChildProcess } from "node:child_process"; +import { createServer, type Socket } from "node:net"; +import { randomBytes } from "node:crypto"; +import { once } from "node:events"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { TERM_PROTO, type ClientLine, type HostLine, type Run } from "../shared/protocol.ts"; +import type { ExchangeReply, ExchangeRequest } from "../shared/exchange.ts"; +import { HISTORY_BATCH, type HistoryManifest, type HistoryReply, type HistoryBatchReply } from "../shared/history.ts"; +import { createCursorStick } from "../app/stick.ts"; + +test("real macOS PTYs: multiplex, resumable history, vim/nano cursor keys and VT modes", { timeout: 45000 }, async () => { + const directory = mkdtempSync(join(tmpdir(), "pocket-term-pty-")); + const worker = fork(fileURLToPath(new URL("../host/terminal-worker.ts", import.meta.url)), ["--port", "0", "--no-mirror", "--no-beacon", "--no-login", "--shell", "/bin/sh", "--cwd", directory], { + env: { ...process.env, HOME: directory }, stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + let log = ""; + let provider: ChildProcess | undefined, native: ReturnType | undefined, transport: Socket | undefined; + worker.stdout!.on("data", data => log += data); worker.stderr!.on("data", data => log += data); + try { + const config = await Promise.race([once(worker, "message").then(([message]) => message as { endpoint: string; token: string }), delay(5000).then(() => { throw new Error(`Worker failed to start: ${log}`); })]); + const unauthorized = await fetch(config.endpoint, { method: "POST", body: "{}" }); + assert.equal(unauthorized.status, 403); + // Emulate only the native socket boundary. The actual PocketJS provider, + // its capability worker, local broker, PTY and libghostty all run here. + const pairingKey = randomBytes(32).toString("hex"); + let nextRequest = 1; + const waiting = new Map void>(); + native = createServer(socket => { + let buffer = Buffer.alloc(0), paired = false; + socket.on("data", bytes => { + buffer = Buffer.concat([buffer, typeof bytes === "string" ? Buffer.from(bytes) : bytes]); + if (!paired) { + if (buffer.length < 64) return; + assert.equal(buffer.subarray(0, 64).toString(), pairingKey); + buffer = buffer.subarray(64); paired = true; transport = socket; + } + while (buffer.length >= 4 && buffer.length >= 4 + buffer.readUInt32BE(0)) { + const length = buffer.readUInt32BE(0); + const reply = JSON.parse(buffer.subarray(4, 4 + length).toString()); + buffer = buffer.subarray(4 + length); + waiting.get(reply.id)?.(reply); waiting.delete(reply.id); + } + }); + }); + native.listen(0, "127.0.0.1"); await once(native, "listening"); + provider = spawn("bun", [fileURLToPath(new URL("../host/provider.ts", import.meta.url))], { + env: { ...process.env, POCKET_TERM_PROVIDER: JSON.stringify({ address: "127.0.0.1", port: (native.address() as { port: number }).port, key: pairingKey, ...config }) }, stdio: ["ignore", "pipe", "pipe"], + }); + provider.stdout!.on("data", data => log += data); provider.stderr!.on("data", data => log += data); + const capability = async (method: string, request: unknown): Promise => { + const deadline = Date.now() + 4000; + while (!transport || transport.destroyed) { if (Date.now() > deadline) throw new Error(`Provider did not connect: ${log}`); await delay(5); } + const id = nextRequest++; + const record = Buffer.from(JSON.stringify({ v: 1, id, method, payload: JSON.stringify(request) })); + const frame = Buffer.alloc(4 + record.length); frame.writeUInt32BE(record.length); record.copy(frame, 4); + const reply = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiting.delete(id); reject(new Error(`Provider reply timeout: ${log}`)); }, 5000); + waiting.set(id, value => { clearTimeout(timer); resolve(value); }); transport!.write(frame); + }); + if (reply.error) throw new Error(reply.error); return JSON.parse(reply.payload); + }; + const post = (request: ExchangeRequest): Promise => capability("term.exchange", request); + const historyRow = async (sid: number, m: HistoryManifest, row: number): Promise => { + let raw = "", parts = 1; + for (let part = 0; part < parts; part++) { + const p = await capability("term.history", { sid, epoch: m.epoch, row, part }) as HistoryReply; + assert.equal(p.epoch, m.epoch); assert.equal(p.row, row); assert.equal(p.part, part); parts = p.parts; raw += p.data; + } + return JSON.parse(raw); + }; + const historyRows = async (sid: number, m: HistoryManifest, rows: number[], loseReply = false): Promise> => { + const result = new Map(); + for (let at = 0; at < rows.length; at += HISTORY_BATCH.rows) { + const remaining = rows.slice(at, at + HISTORY_BATCH.rows); let raw = ""; + while (remaining.length) { + const request = { sid, epoch: m.epoch, rows: remaining.slice(), offset: raw.length }; + const p = await capability("term.history.batch", request) as HistoryBatchReply; + if (loseReply) { loseReply = false; transport!.destroy(); assert.deepEqual(await capability("term.history.batch", request), p); } + assert.equal(p.epoch, m.epoch); + for (const [row, offset, data, more] of p.chunks) { + assert.equal(row, remaining[0]); assert.equal(offset, raw.length); raw += data; + if (!more) { result.set(row, JSON.parse(raw)); raw = ""; remaining.shift(); } + } + } + } + return result; + }; + class Replica { + id: string; epoch?: string; received = 0; command = 0; partial = ""; + active = -1; sessions: number[] = []; grid: Run[][] = []; lines: HostLine[] = []; + history?: HistoryManifest; + constructor(id: string) { this.id = id; } + async exchange(line?: ClientLine, loseReply = false) { + const request: ExchangeRequest = { replica: this.id, epoch: this.epoch, received: this.received, ...(line ? { command: { id: ++this.command, line } } : {}) }; + let reply = await post(request); + if (loseReply) { transport!.destroy(); const replay = await post(request); if (reply.data) assert.deepEqual(replay, reply); else { assert.equal(replay.ack, reply.ack); assert.equal(replay.epoch, reply.epoch); } reply = replay; } + assert.equal(reply.error, undefined); + this.epoch = reply.epoch; + if (reply.data && reply.sequence > this.received) { + assert.equal(reply.sequence, this.received + 1); this.received = reply.sequence; this.partial += reply.data; + if (!reply.more) { + const message = JSON.parse(this.partial) as HostLine; this.partial = ""; this.lines.push(message); + if (message.t === "sessions") { this.sessions = message.list.map(s => s.sid); this.active = message.active; } + if (message.t === "grid") { for (const [y, ...runs] of message.rows) this.grid[y] = runs; if (message.history) this.history = message.history; } + } + } + return reply; + } + async input(lines: ClientLine[], loseReply = false) { + const request = { replica: this.id, epoch: this.epoch, commands: lines.map(line => ({ id: ++this.command, line })) }; + const reply = await capability("term.input", request); + assert.equal(reply.error, undefined); assert.equal(reply.ack, this.command); + if (loseReply) { transport!.destroy(); assert.deepEqual(await capability("term.input", request), reply); } + } + text() { return this.grid.map(row => row.map(run => run[1]).join("")).join("\n"); } + async until(predicate: () => boolean) { + const deadline = Date.now() + 6000; + while (!predicate()) { if (Date.now() > deadline) throw new Error(`Terminal timeout: ${this.text()}\n${log}`); await this.exchange(); await delay(5); } + } + } + const first = new Replica("integration-device-one"); + await first.exchange(); + await first.exchange({ t: "hello", proto: TERM_PROTO, cols: 80, rows: 24, cell: [5, 10] }); + await first.until(() => first.active > 0); + const sid1 = first.active; + await first.exchange({ t: "ch", s: `PS1=''; cd '${directory}'; stty size\r` }); + await first.until(() => first.text().includes("24 80")); + await first.exchange({ t: "ch", s: `printf x >> '${directory}/once'; printf '\\033[2J\\033[H__FIRST__\\n'\r` }, true); + await first.until(() => first.text().includes("__FIRST__")); + await delay(100); assert.equal(readFileSync(join(directory, "once"), "utf8"), "x"); + await first.exchange({ t: "new" }, true); + await first.until(() => first.sessions.length === 2 && first.active !== sid1); + const sid2 = first.active; + await first.exchange({ t: "ch", s: "printf '\\033[2J\\033[H__SECOND__\\n'\r" }); + await first.until(() => first.text().includes("__SECOND__")); + // A new guest realm names its prior session; existing processes survive. + const reconnected = new Replica("integration-device-two"); await reconnected.exchange(); + await reconnected.exchange({ t: "hello", proto: TERM_PROTO, cols: 80, rows: 24, cell: [5, 10], want: sid1 }); + await reconnected.until(() => reconnected.active === sid1 && reconnected.text().includes("__FIRST__")); + assert.equal(first.active, sid2); + // Input is sent raw; a multi-character escape sequence is not a paste. + await reconnected.exchange({ t: "ch", s: "printf '\\033[?1049h\\033[H\\033[31mALT_SCREEN\\033[0m\\033[24;80HX'\r" }); + await reconnected.until(() => reconnected.grid[23]?.some(run => run[0] === 79 && run[1] === "X") ?? false); + assert(reconnected.grid[0].some(run => run[1].includes("ALT_SCREEN") && run[2] >= 0)); + await reconnected.exchange({ t: "ch", s: "printf '\\033[?1049l'\r" }); + await reconnected.until(() => reconnected.text().includes("__FIRST__")); + // Buffered paste commits once at the explicit end, even across retries. + await reconnected.exchange({ t: "paste", s: "printf PASTE_", phase: "start" }, true); + await reconnected.exchange({ t: "paste", s: "OK > pasted\n", phase: "end" }, true); + await delay(100); assert.equal(readFileSync(join(directory, "pasted"), "utf8"), "PASTE_OK"); + await reconnected.exchange({ t: "ch", s: "printf '你好 café\\n'\r" }); + await reconnected.until(() => reconnected.lines.some(line => line.t === "atlas")); + // An application in raw mode receives terminal query replies and one + // bracketed paste, rather than shell echo being mistaken for acceptance. + writeFileSync(join(directory, "query.py"), `import os,tty,termios,select\nold=termios.tcgetattr(0)\ntty.setraw(0)\nos.write(1,b'\\x1b[6n')\nreply=b''\nwhile not reply.endswith(b'R'):\n if not select.select([0],[],[],3)[0]: raise Exception('query timeout')\n reply+=os.read(0,100)\nopen('query-reply','wb').write(reply)\nos.write(1,b'\\x1b[?2004hPASTE_READY')\ndata=b''\nwhile not data.endswith(b'\\x1b[201~'):\n if not select.select([0],[],[],5)[0]: raise Exception('paste timeout')\n data+=os.read(0,4096)\nopen('paste-bytes','wb').write(data)\nos.write(1,b'\\x1b[?2004lPASTE_DONE')\ntermios.tcsetattr(0,termios.TCSANOW,old)\n`); + await reconnected.exchange({ t: "ch", s: "python3 query.py\r" }); + await reconnected.until(() => reconnected.text().includes("PASTE_READY")); + assert.match(readFileSync(join(directory, "query-reply"), "utf8"), /^\x1b\[\d+;\d+R$/); + await reconnected.exchange({ t: "paste", s: "hello\n", phase: "start" }); + await reconnected.exchange({ t: "paste", s: "world", phase: "end" }); + await reconnected.until(() => reconnected.text().includes("PASTE_DONE")); + assert.equal(readFileSync(join(directory, "paste-bytes"), "utf8"), "\x1b[200~hello\nworld\x1b[201~"); + // Real editor sessions receive the same named keys emitted by the right + // stick. File contents prove cursor movement, not just PTY echo. + for (const editor of ["vim", "nano"]) { + const path = join(directory, `${editor}.txt`); writeFileSync(path, "alpha\nbeta\n"); + await reconnected.exchange({ t: "ch", s: editor === "vim" ? "vim -Nu NONE -n vim.txt\r" : "nano nano.txt\r" }); + await reconnected.until(() => !!reconnected.history?.alternate && reconnected.text().includes("beta")); + const stick = createCursorStick(); + const down = stick.step(0, 0.9)!; stick.step(0, 0); + await reconnected.input([{ t: "key", k: down }, { t: "key", k: stick.step(0.9, 0)! }], true); + await reconnected.exchange({ t: "ch", s: editor === "vim" ? "iX" : "X" }); + if (editor === "vim") { + await reconnected.exchange({ t: "key", k: "Escape" }); await reconnected.exchange({ t: "ch", s: ":wq\r" }); + } else { + await reconnected.exchange({ t: "key", k: "o", ctrl: 1 }); await reconnected.exchange({ t: "key", k: "Enter" }); + await reconnected.exchange({ t: "key", k: "x", ctrl: 1 }); + } + await reconnected.until(() => reconnected.history?.alternate === false); + assert.equal(readFileSync(path, "utf8"), "alpha\nbXeta\n"); + } + await reconnected.exchange({ t: "ch", s: "printf '\\033[3J\\033[2J\\033[H'; i=0; while [ \"$i\" -lt 150 ]; do printf 'row-%03d\\n' \"$i\"; i=$((i+1)); done\r" }); + await reconnected.until(() => reconnected.text().includes("row-149") && (reconnected.history?.end ?? 0) >= 100); + const history = { ...reconnected.history! }, address = history.first + 3; + const original = await historyRow(sid1, history, address); + assert.equal(original.map(r => r[1]).join(""), "row-003"); + const addresses = Array.from({ length: 26 }, (_, y) => history.first + y); + let started = performance.now(), beforeRequests = nextRequest; + const individual = new Map(); + for (const row of addresses) individual.set(row, await historyRow(sid1, history, row)); + const single = { calls: nextRequest - beforeRequests, ms: performance.now() - started }; + started = performance.now(); beforeRequests = nextRequest; + const grouped = await historyRows(sid1, history, addresses); + const batch = { calls: nextRequest - beforeRequests, ms: performance.now() - started }; + assert.deepEqual(grouped, individual); assert.equal(batch.calls, 2); + console.log("History provider loopback profile:", JSON.stringify({ rows: 26, single, batch })); + assert.deepEqual(await historyRows(sid1, history, [address, address + 1], true), new Map([...individual].filter(([row]) => row === address || row === address + 1))); + const newest = await historyRow(sid1, history, history.end - 1); + assert(newest.map(r => r[1]).join("").startsWith("row-")); + await reconnected.exchange({ t: "ch", s: "printf 'more\\nmore\\nmore\\nmore\\n'\r" }); + await reconnected.until(() => (reconnected.history?.end ?? 0) > history.end); + assert.equal(reconnected.history!.epoch, history.epoch); assert.deepEqual(await historyRow(sid1, history, address), original); + assert.deepEqual((await historyRows(sid1, history, [address])).get(address), original); + transport!.destroy(); // historical addresses outlive the provider worker + assert.deepEqual(await historyRow(sid1, history, address), original); + await reconnected.exchange({ t: "ch", s: "printf '\\033[3JHISTORY_CLEARED\\n'\r" }); + await reconnected.until(() => reconnected.history?.epoch !== history.epoch); + await assert.rejects(historyRow(sid1, history, address), /expired/); + await assert.rejects(historyRows(sid1, history, [address, address + 1]), /expired/); + await first.exchange({ t: "kill", sid: sid2 }); await first.until(() => first.sessions.length === 1 && first.active === sid1); + await first.exchange({ t: "kill", sid: sid1 }); await first.until(() => first.sessions.length === 0 && first.active === -1); + await first.exchange({ t: "new" }); await first.until(() => first.sessions.length === 1 && first.active > sid2); + } finally { + provider?.kill(); if (provider && provider.exitCode === null && provider.signalCode === null) await once(provider, "exit"); transport?.destroy(); native?.close(); + worker.kill(); if (worker.exitCode === null && worker.signalCode === null) await once(worker, "exit"); rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/test/store.test.ts b/test/store.test.ts new file mode 100644 index 0000000..f9b1ec8 --- /dev/null +++ b/test/store.test.ts @@ -0,0 +1,85 @@ +import { expect, spyOn, test } from "bun:test"; +import { createRoot } from "solid-js"; +import { __advanceClock, resetClock } from "@pocketjs/framework/clock"; +import { createTermStore } from "../app/store.ts"; +import { TERM_PROTO, type ClientLine, type HostLine } from "../shared/protocol.ts"; + +test("chunked snapshots commit atomically, gaps resync, reconnect remembers the session", () => { + let open = true, incoming: HostLine[] = []; const sent: ClientLine[] = []; + const store = createTermStore({ cols: 80, rows: 24, cell: [5, 10] }, { + open: () => open, send: line => { sent.push(line); }, poll: () => incoming.splice(0), + }); + store.frame(); expect(sent[0]).toMatchObject({ t: "hello", proto: TERM_PROTO, cols: 80, rows: 24 }); + incoming.push({ t: "sessions", list: [{ sid: 1, title: "sh" }], active: 1 }, + { t: "grid", sid: 1, gen: 1, seq: 0, full: 1, more: 1, rows: [[0, [0, "first", -1, -1]]] }); + store.frame(); expect(store.row(0)()).toEqual([]); + incoming.push({ t: "grid", sid: 1, gen: 1, seq: 1, full: 1, rows: [[23, [79, "X", -1, -1]]], cur: [79, 23, 1] }); + store.frame(); expect(store.row(0)()[0][1]).toBe("first"); expect(store.cursor()).toEqual([79, 23, 1]); + incoming.push({ t: "grid", sid: 1, gen: 1, seq: 3, rows: [[0, [0, "wrong", -1, -1]]] }); + store.frame(); expect(sent.at(-1)).toEqual({ t: "resync" }); expect(store.row(0)()[0][1]).toBe("first"); + open = false; store.frame(); store.sendText("must not queue"); + open = true; store.frame(); expect(sent.at(-1)).toMatchObject({ t: "hello", want: 1 }); +}); + +test("paste preserves Unicode and explicit start/end instead of inferring typing", () => { + let incoming: HostLine[] = [{ t: "sessions", list: [{ sid: 1, title: "sh" }], active: 1 }]; + const sent: ClientLine[] = []; + const store = createTermStore({ cols: 80, rows: 24, cell: [5, 10] }, { open: () => true, send: line => { sent.push(line); }, poll: () => incoming.splice(0) }); + store.frame(); store.paste("😀".repeat(200)); + const chunks = sent.filter(line => line.t === "paste"); + expect(chunks.map(line => line.s).join("")).toBe("😀".repeat(200)); + expect(chunks[0].phase).toBe("start"); expect(chunks.at(-1)!.phase).toBe("end"); + expect(chunks.every(line => line.s.length <= 128)).toBe(true); +}); + +for (const hz of [20, 60]) test(`prediction uses frame time for expiry and confidence at ${hz}Hz without reading wall time`, () => { + const host = globalThis as { __simHz?: number }, previousHz = host.__simHz; + const wallClock = spyOn(Date, "now").mockImplementation(() => { throw new Error("Prediction must not read wall time"); }); + try { + host.__simHz = hz; resetClock(); + createRoot(dispose => { + let incoming: HostLine[] = [ + { t: "sessions", list: [{ sid: 1, title: "sh" }], active: 1 }, + { t: "grid", sid: 1, gen: 1, seq: 0, full: 1, rows: [[0, [0, "$ ", -1, -1]]], cur: [2, 0, 1] }, + ]; + let commandId = 0, seq = 0, text = "$ "; + const store = createTermStore({ cols: 80, rows: 24, cell: [5, 10] }, { + open: () => true, send: () => ++commandId, poll: () => incoming.splice(0), + }); + const frames = (count: number) => { for (let i = 0; i < count; i++) { __advanceClock(); store.frame(); } }; + const echo = (ch: string) => { + text += ch; + incoming.push({ t: "grid", sid: 1, gen: 1, seq: ++seq, ack: commandId, + rows: [[0, [0, text, -1, -1]]], cur: [text.length, 0, 1] }); + frames(1); + }; + try { + frames(1); + for (let i = 0; i < 2; i++) { store.sendText("a"); echo("a"); } + store.sendText("c"); + expect(store.preview()?.text?.value).toBe("c"); + frames(hz * 0.3); + expect(store.preview()?.text?.value).toBe("c"); + expect(store.row(0)()[0][1]).toBe("$ aa"); + frames(hz * 0.1); + expect(store.preview()).toBeUndefined(); + expect(store.cursor()).toEqual([4, 0, 1]); + expect(store.row(0)()[0][1]).toBe("$ aa"); + + // A late real echo remains authoritative; retrain and then age out + // confidence while no guess is pending, using the same frame clock. + echo("c"); + for (let i = 0; i < 2; i++) { store.sendText("a"); echo("a"); } + frames(hz * 2 + 1); + store.sendText("d"); + expect(store.preview()).toBeUndefined(); + expect(store.row(0)()[0][1]).toBe("$ aacaa"); + expect(wallClock).not.toHaveBeenCalled(); + } finally { store.dispose(); dispose(); } + }); + } finally { + wallClock.mockRestore(); + if (previousHz === undefined) delete host.__simHz; else host.__simHz = previousHz; + resetClock(); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 2a0eebc..231f2e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,13 +9,32 @@ "noEmit": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, - "types": ["bun-types"], + "types": [ + "bun-types" + ], "paths": { - "@pocketjs/framework": ["./vendor/pocketjs/framework/src/index.ts"], - "@pocketjs/framework/solid": ["./vendor/pocketjs/framework/src/index.ts"], - "@pocketjs/framework/input": ["./vendor/pocketjs/framework/src/input-api.ts"], - "@pocketjs/framework/*": ["./vendor/pocketjs/framework/src/*"] + "@pocketjs/framework": [ + "./vendor/pocketjs/framework/src/index.ts" + ], + "@pocketjs/framework/solid": [ + "./vendor/pocketjs/framework/src/index.ts" + ], + "@pocketjs/framework/input": [ + "./vendor/pocketjs/framework/src/input-api.ts" + ], + "@pocketjs/framework/*": [ + "./vendor/pocketjs/framework/src/*" + ], + "@pocketjs/framework/offload/provider": [ + "./vendor/pocketjs/tools/offload-provider.ts" + ] } }, - "include": ["app", "mirror", "scripts", "test"] + "include": [ + "app", + "mirror", + "scripts", + "test", + "shared" + ] } diff --git a/vendor/pocketjs b/vendor/pocketjs index 10aee58..1c0735f 160000 --- a/vendor/pocketjs +++ b/vendor/pocketjs @@ -1 +1 @@ -Subproject commit 10aee589d95aca34802ea4f8c79023e929434a92 +Subproject commit 1c0735faef54ce96484cf4dd31113b9d919fc23c