diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index 488aa6844..c57bce200 100644 --- a/.github/workflows/3ds-runtime.yml +++ b/.github/workflows/3ds-runtime.yml @@ -1,10 +1,10 @@ name: 3DS runtime contracts on: pull_request: - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'engine/runtime/**', 'contracts/**', 'tools/3ds*.ts', 'tools/pocket-runtime-client.ts', 'tools/png.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/pocket-runtime-server.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', 'tests/fixtures/dev-*', '.github/workflows/3ds-runtime.yml'] push: branches: [main] - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'engine/runtime/**', 'contracts/**', 'tools/3ds*.ts', 'tools/pocket-runtime-client.ts', 'tools/png.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/pocket-runtime-server.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', 'tests/fixtures/dev-*', '.github/workflows/3ds-runtime.yml'] permissions: contents: read jobs: @@ -17,4 +17,4 @@ jobs: with: bun-version: 1.3.14 - run: bun install --frozen-lockfile - - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts + - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/pocket-runtime-server.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml index 61ac92795..2e868865b 100644 --- a/.github/workflows/native-c-harness.yml +++ b/.github/workflows/native-c-harness.yml @@ -5,6 +5,19 @@ on: paths: - ".github/workflows/native-c-harness.yml" - "engine/quickjs-c/**" + - "engine/runtime/**" + - "hosts/ios-legacy/**" + - "hosts/ipodtouch4/**" + - "tools/ipodtouch4*.ts" + - "tools/pocket-runtime-client.ts" + - "tools/target-contract.ts" + - "tests/ipodtouch4*.test.ts" + - "tests/pocket-runtime-server.test.ts" + - "tests/fixtures/ipodtouch4-runtime.c" + - "tests/fixtures/dev-*" + - "tests/fixtures/3ds-devserver-host.c" + - "tests/fixtures/3ds-stubs/**" + - "hosts/3ds/src/devserver.c" - "engine/ui-cabi/**" - "engine/core/**" - "framework/src/**" @@ -25,6 +38,19 @@ on: paths: - ".github/workflows/native-c-harness.yml" - "engine/quickjs-c/**" + - "engine/runtime/**" + - "hosts/ios-legacy/**" + - "hosts/ipodtouch4/**" + - "tools/ipodtouch4*.ts" + - "tools/pocket-runtime-client.ts" + - "tools/target-contract.ts" + - "tests/ipodtouch4*.test.ts" + - "tests/pocket-runtime-server.test.ts" + - "tests/fixtures/ipodtouch4-runtime.c" + - "tests/fixtures/dev-*" + - "tests/fixtures/3ds-devserver-host.c" + - "tests/fixtures/3ds-stubs/**" + - "hosts/3ds/src/devserver.c" - "engine/ui-cabi/**" - "engine/core/**" - "framework/src/**" @@ -74,5 +100,19 @@ jobs: run: cargo test --locked --manifest-path engine/ui-cabi/Cargo.toml --features harness-access - name: Link and execute the real C allocator run: bun test tests/ui-cabi-allocator.test.ts + - name: Fetch the pinned legacy Apple QuickJS sources + run: | + bun -e ' + import { ensureQuickJsCheckout } from "./tools/native-source.ts"; + const { compiler: c } = await Bun.file("tools/cli/iphone4s-toolchain.json").json(); + ensureQuickJsCheckout("iPod runtime harness", process.env.RUNNER_TEMP + "/pocketjs-qjs", { + repository: c.quickJsRepository, revision: c.quickJsRevision, version: c.quickJsVersion, + });' + - name: Shared Pocket Runtime server behind the 3DS and POSIX socket pumps + run: bun test tests/pocket-runtime-server.test.ts + - name: Pocket Runtime upload, admission, recovery and real guest + env: + POCKETJS_QUICKJS_SOURCE: ${{ runner.temp }}/pocketjs-qjs/libquickjs-sys/embed/quickjs + run: bun test tests/ipodtouch4-package.test.ts tests/ipodtouch4-runtime.test.ts - name: Contract drift run: bun tests/contract.ts diff --git a/contracts/spec/pocket-runtime-wire.ts b/contracts/spec/pocket-runtime-wire.ts index e9dfdaf07..eb3a2c5e1 100644 --- a/contracts/spec/pocket-runtime-wire.ts +++ b/contracts/spec/pocket-runtime-wire.ts @@ -42,9 +42,11 @@ export const POCKET_RUNTIME_MSG = { export interface PocketRuntimeAck { readonly accepted: boolean; + /** 0 accepted; 2 the pairing key did not match (the Runtime closes next). */ readonly status: number; readonly hostAbi: number; readonly generation: number; + /** Bit 0: the Runtime admits `.pocket` uploads (engine/runtime/dev_server.h). */ readonly flags: number; readonly activeHash: bigint; } diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 44c556cff..8794607cf 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -128,6 +128,20 @@ The shim needs only `{ send(line), recv() -> line | null }`: but does not encrypt the LAN connection. The channel updates `.pocket` guests; native `.3dsx` or CIA host changes still require deployment and restart.** +- **iPod touch 4 over USB or Wi-Fi:** `bun ipodtouch4:runtime dev --app clear` + watches guest sources and bridges the existing panel to Pocket Runtime. + USB forwards the connection through the pinned SSH tunnel; `--lan` selects + the paired device through UDP discovery. **Tree inspection, evaluation and + logs share the PKRT TCP connection with binary `.pocket` uploads.** The + receiver is the server state machine the 3DS host compiles + (`engine/runtime/dev_server.c`) behind a POSIX socket pump; it validates + each package against the shell's baked target contract before changing + guests and commits its generation after a GLES presentation. Resigning + active closes sockets, and iOS 6 terminates the shell in the background; + the relaunch reloads the committed generation and the desktop session + reconnects when Runtime is back in the foreground. + `bun ipodtouch4:runtime capture` uses the USB capture path. See + [iPod touch 4](IPODTOUCH4.md#persistent-pocket-runtime). - **Native desktop (macOS et al., `pocket-ui-wgpu`):** the same file mailbox, minus the USB cable — `engine/crates/pocket-ui-wgpu/src/dbg.rs` is the std twin of the PSP transport. Probed once at `UiSurface::mount`: root = diff --git a/docs/IPODTOUCH4.md b/docs/IPODTOUCH4.md index 5d3345775..7e37b15fb 100644 --- a/docs/IPODTOUCH4.md +++ b/docs/IPODTOUCH4.md @@ -127,3 +127,215 @@ action — a receipt that a gesture interaction completed on the hardware. `dist/ipodtouch4/device-frame.png`. User application icons use **opaque 57×57 and 114×114 artwork**. SpringBoard applies the rounded mask and shadow; `UIPrerenderedIcon` suppresses the stock gloss. The System application path uses a precomposed transparent mask instead. Baking that mask into a User icon adds an inset rim under the native mask. Icon filenames include the artwork revision so an update selects a fresh SpringBoard cache entry. + +## Persistent Pocket Runtime + +**`bun ipodtouch4:runtime deploy` installs `PocketRuntime.app` as a separate +User application**, with bundle identifier `dev.pocket-stack.runtime.ipodtouch4`. +Its embedded recovery guest is Clear. The shell owns a **320×480 logical +surface at density 2**, publishes `ipodtouch4-dev` / ABI 8, and accepts other +applications built for that viewport and capability profile. + +```sh +bun ipodtouch4:runtime deploy +bun ipodtouch4:runtime pair +bun ipodtouch4:runtime launch +bun ipodtouch4:runtime push --app clear +bun ipodtouch4:runtime dev --app clear +``` + +`deploy` builds and installs the native IPA through the deployment path above. +`pair` uses the pinned USB SSH connection to install a 32-byte development +key in the application's container and stores its local copy under +`.pocket/ipodtouch4/devices/`. A repeated `pair` retains the device key; +`pair --rotate` replaces it. Relaunch Runtime after rotation to activate the +replacement. The listener starts after a valid key is present. + +**Guest updates use the 3DS Pocket Runtime wire protocol, with TCP and UDP on +port 8131, and the same server state machine the 3DS host compiles.** +`engine/runtime/dev_protocol.*` is the codec; `engine/runtime/dev_server.*` +owns pairing, the hello/ack handshake, frame dispatch, control records, +uploads, screenshot streaming and the idle timeouts (3 s before the hello, +15 s after). A host adds its socket pump and clock and nothing else: +`engine/runtime/dev_wire_posix.*` for UIKit shells and the host harness, +`hosts/3ds/src/devserver.c` for libctru. The generic desktop client lives in +`tools/pocket-runtime-client.ts`; PICA200 surface decoding and the dual-screen +PNG stay in `tools/3ds-runtime-client.ts`. The default USB route forwards TCP +through the pinned SSH connection. It supports +`POCKETJS_IPODTOUCH4_VIA` and requires no Wi-Fi connection on the device. +The application service channel (`svcwire`, PKNT) has a separate connection +and lifecycle from the Runtime development channel (PKRT). + +```sh +bun ipodtouch4:runtime discover +bun ipodtouch4:runtime status --lan +bun ipodtouch4:runtime push --app clear --lan +bun ipodtouch4:runtime dev --app clear --lan + +# Select a device when UDP discovery cannot cross the network. +bun ipodtouch4:runtime status --host 192.168.1.42 --key /path/to/device.key +``` + +LAN discovery matches a device's pairing-derived identifier with a local key. +**The key authenticates the TCP connection; PKRT does not encrypt LAN traffic.** +The USB SSH route provides encryption through SSH. + +### Package builds and development + +**`pack` builds a `.pocket` without compiling or signing native code.** It +resolves the application's manifest against the private iPod profile and +packages the plan, identity, JavaScript and asset pack. A package upload checks +its manifest and plan on the desktop, then checks its footer, target, ABI and +JavaScript terminator on the device before replacing the guest. **After those +checks the device admits the plan against the target contract baked into the +shell:** +`tools/target-contract.ts` renders `pocket_target_contract.h` for every native +build from the verified plan (viewport, presentation, raster density) and the +`ipodtouch4-dev` registry entry (capability list), and +`pocket_package_validate_plan` in the package layer (`engine/core/src/plan.rs` +through `engine/ui-cabi`) rejects a plan whose target, ABI, surfaces, +presentation or enabled features differ from it. The QuickJS executor holds no +device policy; the registry that resolves manifests on the desktop is the same +source the device checks against. + +```sh +bun ipodtouch4:runtime pack --app clear +bun ipodtouch4:runtime push --package dist/ipodtouch4/packages/clear-main/clear-main.pocket + +bun ipodtouch4:runtime pack --manifest pocket.json --project-root /path/to/app +bun ipodtouch4:runtime dev --manifest pocket.json --project-root /path/to/app +bun ipodtouch4:runtime dev --package /path/to/app.pocket --lan +``` + +`dev` watches source changes, rebuilds the guest, pushes its package and opens +a connection to the existing DevTools hub. It prints the panel URL. Tree +inspection, evaluation and logs use the guest's DevTools bindings; status and +package installation remain in native code. `--no-push` attaches without an +initial update. Subsequent source changes trigger updates. A broken connection +starts a reconnect loop; LAN discovery follows the paired device across an +address change. Compile and admission errors wait for another source change. + +The native capture command remains available: + +```sh +bun ipodtouch4:runtime capture +``` + +It uses the USB capture path and writes `dist/ipodtouch4/device-frame.png`. +The Runtime TCP transport has no screenshot stream in this version. + +### Replacement and recovery + +Runtime keeps uploaded packages and generation records under +`/Library/PocketRuntime`. Each transfer writes `upload.tmp` in +bounded binary chunks, then flushes and validates the completed file. Admission +failure leaves the running guest intact. An incomplete upload is discarded when +its connection closes or a new transfer begins; a completed upload is admitted +on the next frame, and the desktop's connection state has no part in that. + +**A candidate becomes active after its first successful GLES presentation.** +The shell releases the previous guest's textures and QuickJS realm, clears +touch contacts and starts the candidate. It then commits a generation record +with the active and previous accepted package hashes. Stored package filenames +derive from their hashes. Garbage collection retains the active and last-good +packages, plus the newest two generation records. + +A boot or frame error selects the previous accepted package. If that package +fails, Runtime tries the last-good package and then embedded Clear. A restart +reads the newest committed generation; a candidate that failed before +presentation cannot replace that record. Failure of the embedded recovery +guest leaves the native development listener available for another upload. + +**Reload creates a new JavaScript realm and discards its in-memory state.** +Native code changes require an IPA update. The development shell caps the +QuickJS heap at 32 MiB, guest boot at 15 s and each guest turn at 3 s +(`POCKET_DEV_GUEST_BOOT_BUDGET_MS` and `POCKET_DEV_GUEST_TURN_BUDGET_MS`, +build-time constants; the host harness compiles 2 s and 500 ms). Those time +limits include the pending job drain. They do not bound native rendering +calls. On the iPod touch 4 the embedded Clear guest needs more than two +seconds to evaluate and mount, so a 2 s boot budget rejects every guest on the +device. `runtime.status` reports the measured `bootMs` and `firstFrameMs` of +the running guest, and the `staged`/`accepted` receipts carry the same +numbers. Packages are limited to 24 MiB. + +**Runtime receives updates while the application is in the foreground.** +Resigning active closes its development sockets and discards a partial +upload. **After the background transition iOS 6 terminates the shell instead +of suspending it** (`applicationWillTerminate:` arrives; the legacy +single-app shell records the same `terminated` state). The next launch, from +the icon or from `bun ipodtouch4 open-url`, reads the newest committed +generation, boots the active package (about 2.4 s for Clear) and reopens the +paired listener; the desktop session reconnects without operator action. +Guest memory state does not survive a trip to the background. The shell +disables auto-lock while active. The installed User app retains native +SpringBoard deletion; removing it deletes its packages and development key. + +### Validation + +```sh +bun test tests/pocket-runtime-server.test.ts +bun test tests/ipodtouch4-package.test.ts +bun test tests/ipodtouch4-runtime.test.ts +``` + +`pocket-runtime-server.test.ts` replays one transcript through the shared +server without a transport, then drives the desktop client against the +Nintendo 3DS socket pump (compiled on the host with libctru stubbed out) and +the POSIX pump with one scenario: discovery, a rejected key, heartbeats, +control records, uploads, transfer errors, rejected footers and, on the 3DS +pump, a dual-screen screenshot. The runtime tests link the real QuickJS +sources, package reader and retained UI core into a host process. They +exercise TCP transfer, admission failures, target-contract policy, timeouts, +guest replacement, recovery after restart and the compiled Clear application. +`POCKETJS_QUICKJS_SOURCE` can select the pinned QuickJS C source directory; +its default is the legacy Apple source cache. The Native C harness workflow +acquires those sources from the repository's pinned revision and runs the +tests on Linux and macOS. These tests do not exercise UIKit, device +installation or the iPod GPU. + +### Device acceptance + +The host tests do not cover UIKit, EAGL, presentation, installation or USB +forwarding. Before a Runtime build is treated as accepted, run the acceptance +command against a paired Runtime in the foreground and put its receipt +numbers in the pull request: + +```sh +bun ipodtouch4:runtime deploy && bun ipodtouch4:runtime pair && bun ipodtouch4:runtime launch +bun ipodtouch4:runtime acceptance --app clear --auto-background # SpringBoard app switch +bun ipodtouch4:runtime acceptance --app clear # prompts once for the Home button +bun ipodtouch4:runtime acceptance --app clear --skip-background +``` + +The command builds Clear, then runs these steps and writes +`.pocket-build/validation/ipodtouch4-runtime//receipt.json` (an ignored +directory) with every status record it read: + +1. **Repeated replacement** (`--pushes`, default 5): the compiled Clear guest + with a distinct trailer each time. Each push must report `accepted`, + `status` must show the new hash as `active` and a generation advanced by + one, and the receipt keeps the measured `bootMs` and `firstFrameMs`. +2. **First-frame failure**: a guest whose `frame()` throws on its first call + must be `rejected` with `active` unchanged. +3. **Later-frame failure**: a guest that throws after 90 frames must be + `accepted`, then within 20 s `status` must show the previous package as + `active` and `running` with the failed hash absent from `lastGood`. +4. **Reconnect**: the client closes its connection, the session reconnects, + and `getTree` and an `eval` of `ui.__host` answer on the new connection. +5. **Foreground and background** (skipped by `--skip-background`): with + `--auto-background` the command opens Settings through SpringBoard + (`bun ipodtouch4 open-url prefs:root=General`) and, ten seconds later, + Runtime again; without it, the command waits up to three minutes for the + operator to press Home and reopen Runtime. The session must report the + disconnect and the reconnect, the package that was active before the + switch must still be `active`, and a push after the return must be + `accepted`. Because iOS 6 terminates the shell in the background, the + return is a relaunch: the shell's `pid` changes and the receipt carries a + new `bootMs`. +6. **Resident memory**: the shell's acceptance record (`bun ipodtouch4 + status`, field `resident_kb`, read through `task_info`) before the first + push and after the last step; the resident set must not grow by more than + half. Device validation on 2026-09-12 measured 22160 KiB before and + 22288 KiB after seven swaps within one process, Clear booting in + 2.0–2.1 s with its first frame at 2.4 s, recovery from a later-frame + failure in 4.0 s, and a push accepted after the background round trip. diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 4c297166f..7784b1701 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -43,6 +43,7 @@ pub mod draw; pub mod layout; pub mod package; pub mod pak; +pub mod plan; pub mod raster; pub mod compositor; pub mod spec; diff --git a/engine/core/src/plan.rs b/engine/core/src/plan.rs new file mode 100644 index 000000000..7a6ebc534 --- /dev/null +++ b/engine/core/src/plan.rs @@ -0,0 +1,516 @@ +//! Device-side admission of a package's `ResolvedBuildPlan` against the host's +//! target contract. +//! +//! A native shell bakes its contract at build time — target id, host ABI, the +//! surfaces it presents and the capability list of its target registry entry +//! (tools/target-contract.ts renders it as C). Before a `.pocket` replaces the +//! running guest, the plan section is read structurally against that contract; +//! nothing in it is evaluated. Only canonical JSON matches: an escaped string, +//! a non-integer number or a duplicate key never equals a contract value, so a +//! crafted plan cannot pass by spelling a value differently from the desktop +//! resolver (framework/src/manifest/resolve.ts). + +use alloc::vec::Vec; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanError { + /// Not one JSON object, malformed, or nested beyond the reader's limit. + Syntax, + Target, + HostAbi, + Viewport, + Presentation, + Surfaces, + HostExtension, + Features, +} + +/// One presented surface, as the shell laid it out at build time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SurfaceContract<'a> { + pub logical: [u32; 2], + pub physical: [u32; 2], + pub raster_density: u32, + pub presentation: &'a str, +} + +#[derive(Debug, Clone, Copy)] +pub struct TargetContract<'a> { + pub target: &'a str, + pub host_abi: u32, + pub primary: SurfaceContract<'a>, + /// Present only for hosts that drive a second surface (display.auxiliary). + pub auxiliary: Option>, + /// Capability ids the target registry lists for this host. + pub capabilities: &'a [&'a str], + /// Whether a plan may carry a `hostExtension` payload. + pub host_extension: bool, +} + +const MAX_DEPTH: u32 = 32; + +struct Reader<'a> { + bytes: &'a [u8], + at: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Reader { bytes, at: 0 } + } + + fn skip_ws(&mut self) { + while let Some(&c) = self.bytes.get(self.at) { + if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' { + self.at += 1; + } else { + break; + } + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.at).copied() + } + + fn expect(&mut self, c: u8) -> Result<(), PlanError> { + if self.peek() == Some(c) { + self.at += 1; + Ok(()) + } else { + Err(PlanError::Syntax) + } + } + + /// Consumes one complete value and returns its raw bytes. + fn value(&mut self, depth: u32) -> Result<&'a [u8], PlanError> { + self.skip_ws(); + let start = self.at; + match self.peek() { + Some(b'{') => self.object(depth)?, + Some(b'[') => self.array(depth)?, + Some(b'"') => { + self.string()?; + } + Some(b't') => self.literal(b"true")?, + Some(b'f') => self.literal(b"false")?, + Some(b'n') => self.literal(b"null")?, + Some(c) if c == b'-' || c.is_ascii_digit() => self.number()?, + _ => return Err(PlanError::Syntax), + } + Ok(&self.bytes[start..self.at]) + } + + fn object(&mut self, depth: u32) -> Result<(), PlanError> { + if depth >= MAX_DEPTH { + return Err(PlanError::Syntax); + } + self.expect(b'{')?; + self.skip_ws(); + if self.peek() == Some(b'}') { + self.at += 1; + return Ok(()); + } + loop { + self.skip_ws(); + self.string()?; + self.skip_ws(); + self.expect(b':')?; + self.value(depth + 1)?; + self.skip_ws(); + match self.peek() { + Some(b',') => self.at += 1, + Some(b'}') => { + self.at += 1; + return Ok(()); + } + _ => return Err(PlanError::Syntax), + } + } + } + + fn array(&mut self, depth: u32) -> Result<(), PlanError> { + if depth >= MAX_DEPTH { + return Err(PlanError::Syntax); + } + self.expect(b'[')?; + self.skip_ws(); + if self.peek() == Some(b']') { + self.at += 1; + return Ok(()); + } + loop { + self.value(depth + 1)?; + self.skip_ws(); + match self.peek() { + Some(b',') => self.at += 1, + Some(b']') => { + self.at += 1; + return Ok(()); + } + _ => return Err(PlanError::Syntax), + } + } + } + + /// Consumes one string and returns the raw bytes between its quotes. + fn string(&mut self) -> Result<&'a [u8], PlanError> { + self.expect(b'"')?; + let start = self.at; + loop { + match self.peek() { + None => return Err(PlanError::Syntax), + Some(b'"') => { + let raw = &self.bytes[start..self.at]; + self.at += 1; + return Ok(raw); + } + Some(b'\\') => { + self.at += 1; + match self.peek() { + Some(b'u') => { + self.at += 1; + for _ in 0..4 { + match self.peek() { + Some(c) if c.is_ascii_hexdigit() => self.at += 1, + _ => return Err(PlanError::Syntax), + } + } + } + Some(c) if b"\"\\/bfnrt".contains(&c) => self.at += 1, + _ => return Err(PlanError::Syntax), + } + } + Some(c) if c < 0x20 => return Err(PlanError::Syntax), + Some(_) => self.at += 1, + } + } + } + + fn number(&mut self) -> Result<(), PlanError> { + if self.peek() == Some(b'-') { + self.at += 1; + } + let digits = self.digits(); + if digits == 0 { + return Err(PlanError::Syntax); + } + if self.peek() == Some(b'.') { + self.at += 1; + if self.digits() == 0 { + return Err(PlanError::Syntax); + } + } + if matches!(self.peek(), Some(b'e') | Some(b'E')) { + self.at += 1; + if matches!(self.peek(), Some(b'+') | Some(b'-')) { + self.at += 1; + } + if self.digits() == 0 { + return Err(PlanError::Syntax); + } + } + Ok(()) + } + + fn digits(&mut self) -> usize { + let start = self.at; + while matches!(self.peek(), Some(c) if c.is_ascii_digit()) { + self.at += 1; + } + self.at - start + } + + fn literal(&mut self, word: &[u8]) -> Result<(), PlanError> { + if self.bytes[self.at..].starts_with(word) { + self.at += word.len(); + Ok(()) + } else { + Err(PlanError::Syntax) + } + } +} + +/// Visits every member of one JSON object. A duplicate key is a syntax error: +/// the desktop resolver never writes one, and two spellings of one field must +/// not let a plan carry two answers. +fn members<'a>( + object: &'a [u8], + mut visit: impl FnMut(&'a [u8], &'a [u8]) -> Result<(), PlanError>, +) -> Result<(), PlanError> { + let mut reader = Reader::new(object); + reader.skip_ws(); + reader.expect(b'{')?; + reader.skip_ws(); + if reader.peek() == Some(b'}') { + return Ok(()); + } + let mut seen: Vec<&'a [u8]> = Vec::new(); + loop { + reader.skip_ws(); + let key = reader.string()?; + if seen.contains(&key) { + return Err(PlanError::Syntax); + } + seen.push(key); + reader.skip_ws(); + reader.expect(b':')?; + let value = reader.value(1)?; + visit(key, value)?; + reader.skip_ws(); + match reader.peek() { + Some(b',') => reader.at += 1, + Some(b'}') => return Ok(()), + _ => return Err(PlanError::Syntax), + } + } +} + +fn member<'a>(object: &'a [u8], key: &str) -> Result, PlanError> { + let mut found = None; + members(object, |name, value| { + if name == key.as_bytes() { + found = Some(value); + } + Ok(()) + })?; + Ok(found) +} + +/// The bytes of an unescaped string value; an escaped one never matches. +fn as_str(raw: Option<&[u8]>) -> Option<&[u8]> { + let raw = raw?; + if raw.len() < 2 || raw[0] != b'"' || raw[raw.len() - 1] != b'"' { + return None; + } + let inner = &raw[1..raw.len() - 1]; + if inner.contains(&b'\\') { + return None; + } + Some(inner) +} + +/// A canonical unsigned integer: digits only, no leading zero, fits u32. +fn as_u32(raw: Option<&[u8]>) -> Option { + let raw = raw?; + if raw.is_empty() || raw.len() > 10 || !raw.iter().all(u8::is_ascii_digit) { + return None; + } + if raw.len() > 1 && raw[0] == b'0' { + return None; + } + let mut value: u32 = 0; + for &digit in raw { + value = value.checked_mul(10)?.checked_add(u32::from(digit - b'0'))?; + } + Some(value) +} + +fn as_bool(raw: &[u8]) -> Option { + match raw { + b"true" => Some(true), + b"false" => Some(false), + _ => None, + } +} + +fn as_viewport(raw: Option<&[u8]>) -> Option<[u32; 2]> { + let raw = raw?; + let mut reader = Reader::new(raw); + reader.expect(b'[').ok()?; + let width = as_u32(reader.value(1).ok())?; + reader.skip_ws(); + reader.expect(b',').ok()?; + let height = as_u32(reader.value(1).ok())?; + reader.skip_ws(); + reader.expect(b']').ok()?; + reader.skip_ws(); + if reader.at != raw.len() { + return None; + } + Some([width, height]) +} + +fn check_surface( + raw: &[u8], + contract: &SurfaceContract, + size_error: PlanError, + presentation_error: PlanError, +) -> Result<(), PlanError> { + if as_viewport(member(raw, "logical")?) != Some(contract.logical) + || as_viewport(member(raw, "physical")?) != Some(contract.physical) + || as_u32(member(raw, "rasterDensity")?) != Some(contract.raster_density) + { + return Err(size_error); + } + if as_str(member(raw, "presentation")?) != Some(contract.presentation.as_bytes()) { + return Err(presentation_error); + } + Ok(()) +} + +/// Admit `plan` (the package's plan section) for a host described by +/// `contract`. Every baked field must match; a `true` feature must be one of +/// the contract's capabilities; `surfaces` and `hostExtension` may appear only +/// when the contract provides for them. +pub fn validate_plan(plan: &[u8], contract: &TargetContract) -> Result<(), PlanError> { + let mut reader = Reader::new(plan); + let root = reader.value(0)?; + reader.skip_ws(); + if reader.at != plan.len() || root.first() != Some(&b'{') { + return Err(PlanError::Syntax); + } + let target = member(root, "target")?.ok_or(PlanError::Target)?; + if as_str(member(target, "id")?) != Some(contract.target.as_bytes()) { + return Err(PlanError::Target); + } + if as_u32(member(target, "hostAbi")?) != Some(contract.host_abi) { + return Err(PlanError::HostAbi); + } + let viewport = member(root, "viewport")?.ok_or(PlanError::Viewport)?; + check_surface( + viewport, + &contract.primary, + PlanError::Viewport, + PlanError::Presentation, + )?; + match (member(root, "surfaces")?, &contract.auxiliary) { + (None, None) => {} + (Some(surfaces), Some(auxiliary)) => { + let raw = member(surfaces, "auxiliary")?.ok_or(PlanError::Surfaces)?; + check_surface(raw, auxiliary, PlanError::Surfaces, PlanError::Surfaces)?; + } + _ => return Err(PlanError::Surfaces), + } + if member(root, "hostExtension")?.is_some() && !contract.host_extension { + return Err(PlanError::HostExtension); + } + let features = member(root, "features")?.ok_or(PlanError::Features)?; + members(features, |key, value| { + let enabled = as_bool(value).ok_or(PlanError::Features)?; + if enabled && !contract.capabilities.iter().any(|id| id.as_bytes() == key) { + return Err(PlanError::Features); + } + Ok(()) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PLAN: &str = concat!( + r#"{"app":{"entry":"src/main.ts","framework":"vue-vapor","id":"dev.pocket-stack.clear","output":"clear-main","title":"Clear","version":"1.0.0"},"#, + r#""companions":[],"features":{"input.touch":true,"text.glyphs.baked":true},"#, + r#""planHash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","#, + r#""target":{"hostAbi":8,"id":"ipodtouch4-dev"},"#, + r#""viewport":{"logical":[320,480],"physical":[640,960],"policy":"fixed","presentation":"native","rasterDensity":2}}"# + ); + + const CAPABILITIES: &[&str] = &["input.touch", "text.glyphs.baked"]; + + fn contract() -> TargetContract<'static> { + TargetContract { + target: "ipodtouch4-dev", + host_abi: 8, + primary: SurfaceContract { + logical: [320, 480], + physical: [640, 960], + raster_density: 2, + presentation: "native", + }, + auxiliary: None, + capabilities: CAPABILITIES, + host_extension: false, + } + } + + fn check(plan: &str) -> Result<(), PlanError> { + validate_plan(plan.as_bytes(), &contract()) + } + + #[test] + fn admits_the_canonical_plan() { + assert_eq!(check(PLAN), Ok(())); + // Whitespace and member order do not matter; values do. + let spaced = PLAN.replace(",\"target\"", ", \n \"target\""); + assert_eq!(check(&spaced), Ok(())); + } + + #[test] + fn rejects_every_baked_field_drift() { + assert_eq!(check(&PLAN.replace("ipodtouch4-dev", "3ds-dev")), Err(PlanError::Target)); + assert_eq!(check(&PLAN.replace("\"hostAbi\":8", "\"hostAbi\":7")), Err(PlanError::HostAbi)); + assert_eq!(check(&PLAN.replace("[320,480]", "[480,320]")), Err(PlanError::Viewport)); + assert_eq!(check(&PLAN.replace("[640,960]", "[640,961]")), Err(PlanError::Viewport)); + assert_eq!(check(&PLAN.replace("\"rasterDensity\":2", "\"rasterDensity\":1")), Err(PlanError::Viewport)); + assert_eq!(check(&PLAN.replace("\"native\"", "\"fill\"")), Err(PlanError::Presentation)); + assert_eq!(check(&PLAN.replace("\"text.glyphs.baked\":true", "\"input.buttons\":true")), Err(PlanError::Features)); + assert_eq!(check(&PLAN.replace("\"text.glyphs.baked\":true", "\"text.glyphs.baked\":1")), Err(PlanError::Features)); + assert_eq!(check(&PLAN.replace("\"companions\":[]", "\"companions\":[],\"surfaces\":{}")), Err(PlanError::Surfaces)); + assert_eq!(check(&PLAN.replace("\"companions\":[]", "\"companions\":[],\"hostExtension\":{}")), Err(PlanError::HostExtension)); + } + + #[test] + fn unknown_disabled_features_are_fine() { + let plan = PLAN.replace("\"text.glyphs.baked\":true", "\"text.glyphs.baked\":true,\"input.buttons\":false"); + assert_eq!(check(&plan), Ok(())); + } + + #[test] + fn non_canonical_spellings_never_match() { + assert_eq!(check(&PLAN.replace("\"native\"", "\"nati\\u0076e\"")), Err(PlanError::Presentation)); + assert_eq!(check(&PLAN.replace("\"hostAbi\":8", "\"hostAbi\":8.0")), Err(PlanError::HostAbi)); + assert_eq!(check(&PLAN.replace("\"hostAbi\":8", "\"hostAbi\":08")), Err(PlanError::HostAbi)); + assert_eq!(check(&PLAN.replace("[320,480]", "[320,480,0]")), Err(PlanError::Viewport)); + let duplicate = PLAN.replace("\"presentation\":\"native\"", "\"presentation\":\"fill\",\"presentation\":\"native\""); + assert_eq!(check(&duplicate), Err(PlanError::Syntax)); + } + + #[test] + fn rejects_malformed_documents() { + assert_eq!(check("{ invalid"), Err(PlanError::Syntax)); + assert_eq!(check(""), Err(PlanError::Syntax)); + assert_eq!(check("[]"), Err(PlanError::Syntax)); + assert_eq!(check(&format!("{PLAN} trailing")), Err(PlanError::Syntax)); + assert_eq!(check("{\"target\":\"x\"}"), Err(PlanError::Syntax)); + let deep = format!("{}{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(check(&deep), Err(PlanError::Syntax)); + assert_eq!(check("{\"a\":\"\u{1}\"}"), Err(PlanError::Syntax)); + } + + #[test] + fn auxiliary_surfaces_follow_the_contract() { + let auxiliary = SurfaceContract { + logical: [320, 240], + physical: [320, 240], + raster_density: 1, + presentation: "native", + }; + let dual = TargetContract { + target: "3ds-dev", + host_abi: 8, + primary: SurfaceContract { + logical: [400, 240], + physical: [400, 240], + raster_density: 1, + presentation: "native", + }, + auxiliary: Some(auxiliary), + capabilities: &["input.buttons", "input.touch.auxiliary", "text.glyphs.baked"], + host_extension: false, + }; + let plan = concat!( + r#"{"features":{"input.buttons":true,"input.touch.auxiliary":true},"#, + r#""surfaces":{"auxiliary":{"logical":[320,240],"physical":[320,240],"presentation":"native","rasterDensity":1}},"#, + r#""target":{"hostAbi":8,"id":"3ds-dev"},"#, + r#""viewport":{"logical":[400,240],"physical":[400,240],"policy":"fixed","presentation":"native","rasterDensity":1}}"# + ); + assert_eq!(validate_plan(plan.as_bytes(), &dual), Ok(())); + let missing = plan.replace(r#""surfaces":{"auxiliary":{"logical":[320,240],"physical":[320,240],"presentation":"native","rasterDensity":1}},"#, ""); + assert_eq!(validate_plan(missing.as_bytes(), &dual), Err(PlanError::Surfaces)); + let wrong = plan.replace("\"logical\":[320,240]", "\"logical\":[320,200]"); + assert_eq!(validate_plan(wrong.as_bytes(), &dual), Err(PlanError::Surfaces)); + } +} diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 388acafca..0ad5ef472 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -3,6 +3,10 @@ #include "pocket_ui_cabi.h" #include "pocket_spec.h" #include "quickjs.h" +#ifdef POCKET_DEV_RUNTIME +#include "dev_server.h" +#include +#endif #ifdef POCKET_SVC_WIRE #include "svcwire.h" #endif @@ -72,6 +76,11 @@ typedef enum { HostDebugPause, HostDebugStep, HostReportAppAction, +#ifdef POCKET_DEV_RUNTIME + HostDbgActive, + HostDbgPoll, + HostDbgSend, +#endif #ifdef POCKET_SVC_WIRE /* spec ops 30..32 — the host service channel over the PKNT wire * (svcwire.c). Present only in builds whose companion is on the network, @@ -94,6 +103,53 @@ static char reported_action_name[POCKETJS_ACTION_NAME_CAPACITY]; static int32_t reported_action_value; static unsigned long reported_action_sequence; static int runtime_failed; +#ifdef POCKET_DEV_RUNTIME +/* Guest time budgets: a boot or a frame turn (including its job drain) that + * runs past its budget is interrupted and reported as a guest failure. The + * defaults fit the slowest supported device, an 800 MHz Cortex-A8 evaluating + * a few hundred KiB of bundle; the host harness compiles tighter values so + * its hung-guest cases stay short. */ +#ifndef POCKET_DEV_GUEST_BOOT_BUDGET_MS +#define POCKET_DEV_GUEST_BOOT_BUDGET_MS 15000u +#endif +#ifndef POCKET_DEV_GUEST_TURN_BUDGET_MS +#define POCKET_DEV_GUEST_TURN_BUDGET_MS 3000u +#endif +static uint64_t guest_deadline; +static char dev_poll_buffer[32769]; +/* Guest time budgets use the executor's own clock; the server's clock is a + * transport concern. */ +static uint64_t dev_now_ms(void) { + struct timeval time; + gettimeofday(&time, NULL); + return (uint64_t)time.tv_sec * 1000u + (uint64_t)time.tv_usec / 1000u; +} +static int interrupt_guest(JSRuntime *rt, void *opaque) { + (void)rt; + (void)opaque; + return dev_now_ms() >= guest_deadline; +} + +static JSValue dev_console(JSContext *ctx, JSValueConst this_value, + int argc, JSValueConst *argv, int level) { + (void)this_value; + char message[512] = {0}; + size_t length = 0; + for (int i = 0; i < argc && length + 2 < sizeof message; ++i) { + const char *text = JS_ToCString(ctx, argv[i]); + if (!text) return JS_EXCEPTION; + if (i) message[length++] = ' '; + size_t count = strlen(text); + if (count > sizeof message - length - 1) count = sizeof message - length - 1; + memcpy(message + length, text, count); + length += count; + message[length] = 0; + JS_FreeCString(ctx, text); + } + pocket_devserver_report_log(level == 1 ? "warn" : level == 2 ? "error" : "log", message); + return JS_UNDEFINED; +} +#endif #ifdef POCKET_SVC_WIRE /* spec SVC_POLL_BUF (8192) + terminator: one svcPoll batch. */ static char svc_poll_buffer[8193]; @@ -446,6 +502,19 @@ static JSValue host_operation( reported_action_sequence += 1; JS_FreeCString(ctx, text); return JS_UNDEFINED; +#ifdef POCKET_DEV_RUNTIME + case HostDbgActive: + return JS_NewBool(ctx, 1); + case HostDbgPoll: { + size_t length = pocket_devserver_recv_ctrl(dev_poll_buffer, sizeof dev_poll_buffer); + return JS_NewStringLen(ctx, dev_poll_buffer, length); + } + case HostDbgSend: + if (!string_argument(ctx, argc, argv, 0, &text, &text_length)) return JS_EXCEPTION; + pocket_devserver_send_ctrl(text, text_length); + JS_FreeCString(ctx, text); + return JS_UNDEFINED; +#endif #ifdef POCKET_SVC_WIRE case HostSvcOpen: { int open; @@ -490,6 +559,24 @@ static int add_host_operation( static int install_host(int width, int height) { JSValue ui = JS_NewObject(context); if (JS_IsException(ui)) return 0; +#ifdef POCKET_DEV_RUNTIME + if (!add_host_operation(context, ui, "__dbgActive", 0, HostDbgActive) || + !add_host_operation(context, ui, "__dbgPoll", 0, HostDbgPoll) || + !add_host_operation(context, ui, "__dbgSend", 1, HostDbgSend)) { + JS_FreeValue(context, ui); + return 0; + } + JSValue console = JS_NewObject(context); + const char *methods[] = {"log", "info", "debug", "warn", "error"}; + for (size_t i = 0; i < sizeof methods / sizeof methods[0]; ++i) { + JS_SetPropertyStr(context, console, methods[i], JS_NewCFunctionMagic(context, + dev_console, methods[i], 1, JS_CFUNC_generic_magic, i == 3 ? 1 : i == 4 ? 2 : 0)); + } + if (JS_SetPropertyStr(context, global, "console", console) < 0) { + JS_FreeValue(context, ui); + return 0; + } +#endif if (!add_host_operation(context, ui, "createNode", 1, HostCreateNode) || !add_host_operation(context, ui, "destroyNode", 1, HostDestroyNode) || !add_host_operation(context, ui, "insertBefore", 3, HostInsertBefore) || @@ -563,6 +650,12 @@ static int install_host(int width, int height) { static int drain_jobs(void) { for (;;) { +#ifdef POCKET_DEV_RUNTIME + if (dev_now_ms() >= guest_deadline) { + set_error("guest job drain exceeded its time budget"); + return 0; + } +#endif JSContext *pending_context = 0; int result = JS_ExecutePendingJob(runtime, &pending_context); if (result > 0) continue; @@ -575,6 +668,9 @@ static int drain_jobs(void) { } void pocket_runtime_shutdown(void) { +#if defined(POCKET_DEV_RUNTIME) && defined(POCKET_SVC_WIRE) + svcwire_shutdown(); +#endif if (context != 0) { #if defined(POCKET_RUNTIME_HARNESS) if (!JS_IsUndefined(harness_function)) JS_FreeValue(context, harness_function); @@ -622,6 +718,11 @@ int pocket_runtime_boot( } REPORT_BOOT_STAGE(4); JS_SetMaxStackSize(runtime, 256 * 1024); +#ifdef POCKET_DEV_RUNTIME + JS_SetMemoryLimit(runtime, 32u * 1024u * 1024u); + guest_deadline = dev_now_ms() + POCKET_DEV_GUEST_BOOT_BUDGET_MS; + JS_SetInterruptHandler(runtime, interrupt_guest, NULL); +#endif context = JS_NewContext(runtime); if (context == 0) { set_error("QuickJS context allocation failed"); @@ -704,6 +805,9 @@ static int run_frame( unsigned int tick; unsigned int index; if (runtime == 0 || context == 0 || runtime_failed) return 0; +#ifdef POCKET_DEV_RUNTIME + guest_deadline = dev_now_ms() + POCKET_DEV_GUEST_TURN_BUDGET_MS; +#endif #ifdef POCKET_SVC_WIRE /* Bounded, non-blocking: discovery, connect, rx and tx progress once per * guest turn, before the guest polls. */ diff --git a/engine/runtime/dev_protocol.c b/engine/runtime/dev_protocol.c new file mode 100644 index 000000000..afa2d44d0 --- /dev/null +++ b/engine/runtime/dev_protocol.c @@ -0,0 +1,192 @@ +#include "dev_protocol.h" + +#include + +uint16_t pocket_runtime_read_u16(const uint8_t *bytes) { + return (uint16_t)bytes[0] | (uint16_t)((uint16_t)bytes[1] << 8); +} + +uint32_t pocket_runtime_read_u32(const uint8_t *bytes) { + return (uint32_t)bytes[0] | + ((uint32_t)bytes[1] << 8) | + ((uint32_t)bytes[2] << 16) | + ((uint32_t)bytes[3] << 24); +} + +uint64_t pocket_runtime_read_u64(const uint8_t *bytes) { + return (uint64_t)pocket_runtime_read_u32(bytes) | + ((uint64_t)pocket_runtime_read_u32(bytes + 4) << 32); +} + +void pocket_runtime_write_u16(uint8_t *bytes, uint16_t value) { + bytes[0] = (uint8_t)value; + bytes[1] = (uint8_t)(value >> 8); +} + +void pocket_runtime_write_u32(uint8_t *bytes, uint32_t value) { + bytes[0] = (uint8_t)value; + bytes[1] = (uint8_t)(value >> 8); + bytes[2] = (uint8_t)(value >> 16); + bytes[3] = (uint8_t)(value >> 24); +} + +void pocket_runtime_write_u64(uint8_t *bytes, uint64_t value) { + pocket_runtime_write_u32(bytes, (uint32_t)value); + pocket_runtime_write_u32(bytes + 4, (uint32_t)(value >> 32)); +} + +bool pocket_runtime_verify_hello( + const uint8_t *bytes, + size_t length, + const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] +) { + if (bytes == NULL || token == NULL || length != POCKET_RUNTIME_HELLO_BYTES) return false; + if (pocket_runtime_read_u32(bytes) != POCKET_RUNTIME_WIRE_MAGIC || + bytes[4] != POCKET_RUNTIME_WIRE_VERSION || bytes[5] != 0 || + pocket_runtime_read_u16(bytes + 6) != POCKET_RUNTIME_TOKEN_BYTES) { + return false; + } + /* Constant-time token comparison keeps a LAN peer from learning the + * persistent pairing secret one byte at a time. */ + uint8_t different = 0; + for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { + different |= bytes[8 + index] ^ token[index]; + } + return different == 0; +} + +void pocket_runtime_encode_ack( + uint8_t out[POCKET_RUNTIME_ACK_BYTES], + uint8_t status, + uint16_t host_abi, + uint32_t generation, + uint32_t flags, + uint64_t active_hash +) { + memset(out, 0, POCKET_RUNTIME_ACK_BYTES); + pocket_runtime_write_u32(out, POCKET_RUNTIME_WIRE_MAGIC); + out[4] = POCKET_RUNTIME_WIRE_VERSION; + out[5] = status; + pocket_runtime_write_u16(out + 6, host_abi); + pocket_runtime_write_u32(out + 8, generation); + pocket_runtime_write_u32(out + 12, flags); + pocket_runtime_write_u64(out + 16, active_hash); +} + +bool pocket_runtime_parse_frame_header( + const uint8_t *bytes, + size_t length, + PocketRuntimeFrameHeader *out +) { + if (bytes == NULL || out == NULL || length < POCKET_RUNTIME_FRAME_HEADER_BYTES) return false; + uint32_t payload_length = pocket_runtime_read_u32(bytes + 4); + if (bytes[2] != 0 || bytes[3] != 0 || payload_length > POCKET_RUNTIME_MAX_FRAME_BYTES) { + return false; + } + out->type = bytes[0]; + out->flags = bytes[1]; + out->length = payload_length; + return true; +} + +void pocket_runtime_encode_frame_header( + uint8_t out[POCKET_RUNTIME_FRAME_HEADER_BYTES], + uint8_t type, + uint8_t flags, + uint32_t length +) { + memset(out, 0, POCKET_RUNTIME_FRAME_HEADER_BYTES); + out[0] = type; + out[1] = flags; + pocket_runtime_write_u32(out + 4, length); +} + +bool pocket_runtime_parse_package_begin( + const uint8_t *bytes, + size_t length, + PocketRuntimePackageBegin *out +) { + if (bytes == NULL || out == NULL || length != POCKET_RUNTIME_PACKAGE_BEGIN_BYTES) return false; + uint32_t package_length = pocket_runtime_read_u32(bytes); + uint64_t footer_hash = pocket_runtime_read_u64(bytes + 4); + if (package_length == 0 || package_length > 24u * 1024u * 1024u || footer_hash == 0) { + return false; + } + out->length = package_length; + out->footer_hash = footer_hash; + return true; +} + +void pocket_runtime_encode_screenshot_begin( + uint8_t out[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES], + uint32_t frame, + uint16_t top_width, + uint16_t top_height, + uint16_t auxiliary_width, + uint16_t auxiliary_height, + uint32_t top_bytes, + uint32_t auxiliary_bytes +) { + memset(out, 0, POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES); + pocket_runtime_write_u32(out, frame); + pocket_runtime_write_u16(out + 4, top_width); + pocket_runtime_write_u16(out + 6, top_height); + pocket_runtime_write_u16(out + 8, auxiliary_width); + pocket_runtime_write_u16(out + 10, auxiliary_height); + out[12] = POCKET_RUNTIME_SCREENSHOT_FORMAT_ROTATED_RGB8; + pocket_runtime_write_u32(out + 16, top_bytes); + pocket_runtime_write_u32(out + 20, auxiliary_bytes); +} + +uint64_t pocket_runtime_device_id( + const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] +) { + if (token == NULL) return 0; + uint64_t hash = 0xcbf29ce484222325ULL; + for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { + hash ^= token[index]; + hash *= 0x100000001b3ULL; + } + return hash; +} + +bool pocket_runtime_is_discovery_request(const uint8_t *bytes, size_t length) { + return bytes != NULL && length == POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES && + pocket_runtime_read_u32(bytes) == POCKET_RUNTIME_DISCOVERY_MAGIC && + bytes[4] == POCKET_RUNTIME_WIRE_VERSION && + bytes[5] == POCKET_RUNTIME_DISCOVERY_REQUEST && + bytes[6] == 0 && bytes[7] == 0; +} + +static void write_fixed_text(uint8_t *out, size_t length, const char *text) { + memset(out, 0, length); + if (text == NULL) return; + size_t text_length = strlen(text); + if (text_length >= length) text_length = length - 1; + memcpy(out, text, text_length); +} + +void pocket_runtime_encode_discovery_reply( + uint8_t out[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES], + uint16_t host_abi, + uint16_t port, + uint16_t flags, + uint32_t generation, + uint64_t active_hash, + uint64_t device_id, + const char *target, + const char *label +) { + memset(out, 0, POCKET_RUNTIME_DISCOVERY_REPLY_BYTES); + pocket_runtime_write_u32(out, POCKET_RUNTIME_DISCOVERY_MAGIC); + out[4] = POCKET_RUNTIME_WIRE_VERSION; + out[5] = POCKET_RUNTIME_DISCOVERY_REPLY; + pocket_runtime_write_u16(out + 6, host_abi); + pocket_runtime_write_u16(out + 8, port); + pocket_runtime_write_u16(out + 10, flags); + pocket_runtime_write_u32(out + 12, generation); + pocket_runtime_write_u64(out + 16, active_hash); + pocket_runtime_write_u64(out + 24, device_id); + write_fixed_text(out + 32, 16, target); + write_fixed_text(out + 48, 16, label); +} diff --git a/engine/runtime/dev_protocol.h b/engine/runtime/dev_protocol.h new file mode 100644 index 000000000..b5916cafe --- /dev/null +++ b/engine/runtime/dev_protocol.h @@ -0,0 +1,113 @@ +#ifndef POCKETJS_RUNTIME_DEV_PROTOCOL_H +#define POCKETJS_RUNTIME_DEV_PROTOCOL_H + +#include +#include +#include + +#define POCKET_RUNTIME_WIRE_MAGIC 0x54524b50u /* 'PKRT' little-endian */ +#define POCKET_RUNTIME_DISCOVERY_MAGIC 0x44524b50u /* 'PKRD' little-endian */ +#define POCKET_RUNTIME_WIRE_VERSION 1u +#define POCKET_RUNTIME_WIRE_PORT 8131u +#define POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES 8u +#define POCKET_RUNTIME_DISCOVERY_REPLY_BYTES 64u +#define POCKET_RUNTIME_DISCOVERY_REQUEST 1u +#define POCKET_RUNTIME_DISCOVERY_REPLY 2u +#define POCKET_RUNTIME_TOKEN_BYTES 32u +#define POCKET_RUNTIME_HELLO_BYTES 40u +#define POCKET_RUNTIME_ACK_BYTES 24u +#define POCKET_RUNTIME_FRAME_HEADER_BYTES 8u +#define POCKET_RUNTIME_MAX_FRAME_BYTES (64u * 1024u) +#define POCKET_RUNTIME_MAX_CTRL_BYTES (16u * 1024u) +#define POCKET_RUNTIME_PACKAGE_BEGIN_BYTES 12u +#define POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES 24u +#define POCKET_RUNTIME_SCREENSHOT_FORMAT_ROTATED_RGB8 1u + +enum PocketRuntimeMessage { + POCKET_RUNTIME_MSG_PING = 0x01, + POCKET_RUNTIME_MSG_PONG = 0x02, + POCKET_RUNTIME_MSG_CTRL = 0x10, + POCKET_RUNTIME_MSG_PACKAGE_BEGIN = 0x20, + POCKET_RUNTIME_MSG_PACKAGE_CHUNK = 0x21, + POCKET_RUNTIME_MSG_PACKAGE_COMMIT = 0x22, + POCKET_RUNTIME_MSG_PACKAGE_ABORT = 0x23, + POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN = 0x30, + POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK = 0x31, + POCKET_RUNTIME_MSG_SCREENSHOT_END = 0x32, + POCKET_RUNTIME_MSG_STATUS_REQUEST = 0x40, +}; + +typedef struct { + uint8_t type; + uint8_t flags; + uint32_t length; +} PocketRuntimeFrameHeader; + +typedef struct { + uint32_t length; + uint64_t footer_hash; +} PocketRuntimePackageBegin; + +uint16_t pocket_runtime_read_u16(const uint8_t *bytes); +uint32_t pocket_runtime_read_u32(const uint8_t *bytes); +uint64_t pocket_runtime_read_u64(const uint8_t *bytes); +void pocket_runtime_write_u16(uint8_t *bytes, uint16_t value); +void pocket_runtime_write_u32(uint8_t *bytes, uint32_t value); +void pocket_runtime_write_u64(uint8_t *bytes, uint64_t value); + +bool pocket_runtime_verify_hello( + const uint8_t *bytes, + size_t length, + const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] +); +void pocket_runtime_encode_ack( + uint8_t out[POCKET_RUNTIME_ACK_BYTES], + uint8_t status, + uint16_t host_abi, + uint32_t generation, + uint32_t flags, + uint64_t active_hash +); +bool pocket_runtime_parse_frame_header( + const uint8_t *bytes, + size_t length, + PocketRuntimeFrameHeader *out +); +void pocket_runtime_encode_frame_header( + uint8_t out[POCKET_RUNTIME_FRAME_HEADER_BYTES], + uint8_t type, + uint8_t flags, + uint32_t length +); +bool pocket_runtime_parse_package_begin( + const uint8_t *bytes, + size_t length, + PocketRuntimePackageBegin *out +); +void pocket_runtime_encode_screenshot_begin( + uint8_t out[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES], + uint32_t frame, + uint16_t top_width, + uint16_t top_height, + uint16_t auxiliary_width, + uint16_t auxiliary_height, + uint32_t top_bytes, + uint32_t auxiliary_bytes +); +uint64_t pocket_runtime_device_id( + const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] +); +bool pocket_runtime_is_discovery_request(const uint8_t *bytes, size_t length); +void pocket_runtime_encode_discovery_reply( + uint8_t out[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES], + uint16_t host_abi, + uint16_t port, + uint16_t flags, + uint32_t generation, + uint64_t active_hash, + uint64_t device_id, + const char *target, + const char *label +); + +#endif diff --git a/engine/runtime/dev_server.c b/engine/runtime/dev_server.c new file mode 100644 index 000000000..75d641527 --- /dev/null +++ b/engine/runtime/dev_server.c @@ -0,0 +1,789 @@ +/* + * Pocket Runtime wire v1 (PKRT) server, shared by the Nintendo 3DS host and + * the POSIX/UIKit hosts. The transport-neutral state machine here is the only + * place that decides what a frame means; hosts/3ds/src/devserver.c and + * dev_wire_posix.c move bytes between it and their sockets. + * + * Semantics: + * - The hello is answered with an ack. A rejected hello still receives its + * ack (status 2) before the client is closed, so desktop tools can report + * the wrong key instead of a timeout. + * - Unknown frame types are skipped for forward compatibility. Non-zero + * frame flags, malformed headers, oversized or newline-bearing control + * records and a full control ring close the client. + * - PING carries four bytes; the newest PONG is reserved until the output + * queue is empty, so bulk screenshot traffic cannot discard the heartbeat. + * - A package upload streams into the staging file. A new begin replaces an + * unfinished transfer; an offset mismatch, a short commit, an abort or a + * closed client discards the staging file and reports transfer-error. A + * committed upload survives a disconnect until the host takes it. + * - Status and install reports are critical: when they do not fit in the + * output queue the client is closed rather than left waiting for a record + * that will never arrive. Guest control records keep a reserve for them and + * a record larger than one frame is replaced by a ctrlDropped notice. + */ + +#include "dev_server.h" + +#include +#include +#include +#include +#include +#include + +#define RX_CAP (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) +#define TX_CAP (2u * RX_CAP) +#define TX_RESERVE (4u * 1024u) +#define CTRL_CAP (4u * (POCKET_RUNTIME_MAX_CTRL_BYTES + 1u)) +#define HELLO_CAP 1024u +#define SCREENSHOT_CHUNK_BYTES (48u * 1024u) + +static PocketDevServerConfig config; +static int configured; +static uint8_t token[POCKET_RUNTIME_TOKEN_BYTES]; +static uint64_t device_id; +static int paired; +static uint32_t generation; +static uint64_t active_hash; +static int packages_allowed = 1; + +static int client_open; +static int authenticated; +static int closing; +static int failed; +static int expired; +static uint64_t last_rx_ms; +static uint8_t rx[RX_CAP]; +static size_t rx_length; +static uint8_t tx[TX_CAP]; +static size_t tx_length; +static size_t tx_offset; +static int pong_pending; +static uint8_t pong[4]; +static char controls[CTRL_CAP]; +static size_t controls_length; +static char hello[HELLO_CAP]; +static size_t hello_length; + +static FILE *upload; +static uint32_t upload_expected; +static uint32_t upload_received; +static uint64_t upload_hash; +static int upload_ready; + +static int screenshot_requested; +static int screenshot_ready; +static uint8_t *screenshot_top; +static uint8_t *screenshot_auxiliary; +static uint32_t screenshot_top_bytes; +static uint32_t screenshot_auxiliary_bytes; +static uint32_t screenshot_frame; +static uint32_t screenshot_offset; +static uint16_t screenshot_top_width; +static uint16_t screenshot_top_height; +static uint16_t screenshot_auxiliary_width; +static uint16_t screenshot_auxiliary_height; +static uint8_t screenshot_stage; +static uint8_t screenshot_surface; + +static PocketDevServerCounters counters; + +static uint64_t now_ms(void) { + return configured ? config.now_ms() : 0; +} + +static void set_error(char *out, size_t length, const char *format, ...) { + if (out == NULL || length == 0) return; + va_list arguments; + va_start(arguments, format); + vsnprintf(out, length, format, arguments); + va_end(arguments); +} + +/* ---- output queue ---------------------------------------------------- */ + +static int queue_frame(uint8_t type, uint8_t flags, const void *payload, size_t length, int critical) { + if (!client_open || !authenticated || closing || failed) return 0; + if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) return 0; + if (tx_offset > 0) { + memmove(tx, tx + tx_offset, tx_length - tx_offset); + tx_length -= tx_offset; + tx_offset = 0; + } + size_t required = POCKET_RUNTIME_FRAME_HEADER_BYTES + length; + size_t reserve = critical ? 0 : TX_RESERVE; + if (required + reserve > sizeof tx - tx_length) { + if (critical) failed = 1; + return 0; + } + pocket_runtime_encode_frame_header(tx + tx_length, type, flags, (uint32_t)length); + if (length > 0) memcpy(tx + tx_length + POCKET_RUNTIME_FRAME_HEADER_BYTES, payload, length); + tx_length += required; + return 1; +} + +static size_t json_escape(char *out, size_t capacity, const char *text) { + size_t written = 0; + if (capacity == 0) return 0; + if (text == NULL) { + out[0] = '\0'; + return 0; + } + for (const unsigned char *at = (const unsigned char *)text; *at != 0; at += 1) { + const char *escape = NULL; + char unicode[7]; + if (*at == '"') escape = "\\\""; + else if (*at == '\\') escape = "\\\\"; + else if (*at == '\n') escape = "\\n"; + else if (*at == '\r') escape = "\\r"; + else if (*at == '\t') escape = "\\t"; + else if (*at < 0x20) { + snprintf(unicode, sizeof unicode, "\\u%04x", *at); + escape = unicode; + } + if (escape != NULL) { + size_t length = strlen(escape); + if (written + length >= capacity) break; + memcpy(out + written, escape, length); + written += length; + } else { + if (written + 1 >= capacity) break; + out[written++] = (char)*at; + } + } + out[written] = '\0'; + return written; +} + +void pocket_devserver_send_ctrl(const char *line, size_t length) { + if (line == NULL || length == 0) return; + if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) { + /* Too large for any frame. Say so, so the tool waiting on this record + * learns why it is never coming. */ + char notice[128]; + int written = snprintf( + notice, + sizeof notice, + "{\"t\":\"ctrlDropped\",\"bytes\":%u,\"cap\":%u}", + (unsigned)length, + (unsigned)POCKET_RUNTIME_MAX_FRAME_BYTES + ); + if (written > 0) queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, notice, (size_t)written, 0); + return; + } + /* The guest's DevTools hello is replayed to every later client. */ + static const char marker[] = "\"t\":\"hello\""; + int is_hello = 0; + for (size_t offset = 0; offset + sizeof marker - 1 <= length && !is_hello; offset += 1) { + is_hello = memcmp(line + offset, marker, sizeof marker - 1) == 0; + } + if (is_hello && length < sizeof hello) { + memcpy(hello, line, length); + hello[length] = '\0'; + hello_length = length; + } + queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, line, length, 0); +} + +static void send_status(void) { + char text[2048]; + if (!configured || config.status == NULL) return; + text[0] = '\0'; + config.status(text, sizeof text); + size_t length = strlen(text); + if (length > 0) queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, text, length, 1); +} + +void pocket_devserver_report_install(const char *phase, uint64_t hash, const char *message) { + char escaped[640]; + char line[896]; + json_escape(escaped, sizeof escaped, message); + int length = snprintf( + line, + sizeof line, + "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\"," + "\"generation\":%lu,\"message\":\"%s\"}", + phase == NULL ? "unknown" : phase, + (unsigned long long)hash, + (unsigned long)generation, + escaped + ); + if (length > 0 && (size_t)length < sizeof line) { + queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, line, (size_t)length, 1); + } +} + +void pocket_devserver_report_log(const char *level, const char *message) { + char escaped[640]; + char line[768]; + json_escape(escaped, sizeof escaped, message); + int length = snprintf( + line, + sizeof line, + "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", + level == NULL ? "info" : level, + escaped + ); + if (length > 0 && (size_t)length < sizeof line) { + queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, line, (size_t)length, 0); + } +} + +/* ---- control ring ---------------------------------------------------- */ + +static int append_ctrl(const uint8_t *bytes, size_t length) { + if (length == 0 || length + 1 > sizeof controls - controls_length) return 0; + memcpy(controls + controls_length, bytes, length); + controls_length += length; + controls[controls_length++] = '\n'; + return 1; +} + +size_t pocket_devserver_recv_ctrl(char *out, size_t capacity) { + if (out == NULL || capacity <= 1 || controls_length == 0) return 0; + size_t length = controls_length < capacity - 1 ? controls_length : capacity - 1; + /* Every accepted control frame ends in a synthetic newline. Never hand the + * guest a partial JSON record when several queued frames exceed its poll + * capacity. */ + while (length > 0 && controls[length - 1] != '\n') length -= 1; + if (length == 0) return 0; + memcpy(out, controls, length); + out[length] = '\0'; + memmove(controls, controls + length, controls_length - length); + controls_length -= length; + return length; +} + +void pocket_devserver_reset_guest(void) { + controls_length = 0; + hello_length = 0; + hello[0] = '\0'; +} + +/* ---- uploads --------------------------------------------------------- */ + +static void close_upload(void) { + int staged = upload != NULL || upload_ready; + if (upload != NULL) fclose(upload); + upload = NULL; + upload_expected = 0; + upload_received = 0; + upload_hash = 0; + upload_ready = 0; + if (staged && configured) remove(config.upload_path); +} + +static void abort_upload(const char *message) { + uint64_t rejected = upload_hash; + close_upload(); + pocket_devserver_report_install("transfer-error", rejected, message); +} + +static void handle_package_begin(const uint8_t *payload, size_t length) { + PocketRuntimePackageBegin begin; + if (!pocket_runtime_parse_package_begin(payload, length, &begin)) { + abort_upload("invalid package begin frame"); + return; + } + if (!packages_allowed) { + pocket_devserver_report_install( + "rejected", + begin.footer_hash, + "This native host does not accept .pocket guest packages" + ); + return; + } + close_upload(); + upload = fopen(config.upload_path, "wb"); + if (upload == NULL) { + pocket_devserver_report_install("transfer-error", begin.footer_hash, "open package staging file failed"); + return; + } + upload_expected = begin.length; + upload_received = 0; + upload_hash = begin.footer_hash; + pocket_devserver_report_install("receiving", upload_hash, "binary package transfer started"); +} + +static void handle_package_chunk(const uint8_t *payload, size_t length) { + if (upload == NULL || length <= 4) { + abort_upload("package chunk arrived without an active transfer"); + return; + } + uint32_t offset = pocket_runtime_read_u32(payload); + size_t bytes = length - 4; + if (offset != upload_received || bytes > upload_expected - upload_received || + fwrite(payload + 4, 1, bytes, upload) != bytes) { + abort_upload("package chunk offset, length, or staging write failed"); + return; + } + upload_received += (uint32_t)bytes; +} + +static void handle_package_commit(size_t length) { + if (length != 0) { + abort_upload("package commit payload must be empty"); + return; + } + if (upload == NULL || upload_received != upload_expected) { + abort_upload("package commit arrived before every declared byte"); + return; + } + int written = fflush(upload) == 0 && fsync(fileno(upload)) == 0; + if (fclose(upload) != 0) written = 0; + upload = NULL; + if (!written) { + abort_upload("flush package staging file failed"); + return; + } + upload_ready = 1; + counters.uploads += 1; + pocket_devserver_report_install("received", upload_hash, "binary package transfer complete"); +} + +int pocket_devserver_take_upload(uint64_t *declared_hash) { + if (!upload_ready) return 0; + if (declared_hash != NULL) *declared_hash = upload_hash; + upload_ready = 0; + upload_expected = 0; + upload_received = 0; + upload_hash = 0; + return 1; +} + +const char *pocket_devserver_upload_path(void) { + return configured ? config.upload_path : ""; +} + +int pocket_devserver_upload_pending(void) { + return upload_ready; +} + +void pocket_devserver_allow_packages(int allowed) { + packages_allowed = allowed ? 1 : 0; + if (!allowed && (upload != NULL || upload_ready)) { + abort_upload("guest package admission disabled by host"); + } +} + +/* ---- screenshots ----------------------------------------------------- */ + +void pocket_devserver_screenshot_cancel(void) { + if (configured && config.screenshot_free != NULL) { + if (screenshot_top != NULL) config.screenshot_free(screenshot_top); + if (screenshot_auxiliary != NULL) config.screenshot_free(screenshot_auxiliary); + } + screenshot_top = NULL; + screenshot_auxiliary = NULL; + screenshot_top_bytes = 0; + screenshot_auxiliary_bytes = 0; + screenshot_ready = 0; + screenshot_stage = 0; + screenshot_surface = 0; + screenshot_offset = 0; +} + +int pocket_devserver_request_screenshot(void) { + if (!configured || config.screenshot_alloc == NULL) return 0; + if (!pocket_devserver_connected() || screenshot_requested || screenshot_ready) return 0; + screenshot_requested = 1; + return 1; +} + +int pocket_devserver_take_screenshot_request(void) { + if (!screenshot_requested) return 0; + screenshot_requested = 0; + return 1; +} + +int pocket_devserver_screenshot_begin( + uint32_t frame, + uint16_t top_width, + uint16_t top_height, + uint16_t auxiliary_width, + uint16_t auxiliary_height, + uint8_t **top, + uint8_t **auxiliary +) { + if (!configured || config.screenshot_alloc == NULL || config.screenshot_free == NULL) return 0; + if (top == NULL || auxiliary == NULL || screenshot_ready || screenshot_top != NULL) return 0; + uint32_t top_bytes = (uint32_t)top_width * top_height * 3u; + uint32_t auxiliary_bytes = (uint32_t)auxiliary_width * auxiliary_height * 3u; + screenshot_top = config.screenshot_alloc(top_bytes); + screenshot_auxiliary = config.screenshot_alloc(auxiliary_bytes); + if (screenshot_top == NULL || screenshot_auxiliary == NULL) { + pocket_devserver_screenshot_cancel(); + return 0; + } + screenshot_frame = frame; + screenshot_top_width = top_width; + screenshot_top_height = top_height; + screenshot_auxiliary_width = auxiliary_width; + screenshot_auxiliary_height = auxiliary_height; + screenshot_top_bytes = top_bytes; + screenshot_auxiliary_bytes = auxiliary_bytes; + *top = screenshot_top; + *auxiliary = screenshot_auxiliary; + return 1; +} + +void pocket_devserver_screenshot_ready(void) { + if (screenshot_top == NULL || screenshot_auxiliary == NULL) return; + screenshot_ready = 1; + screenshot_stage = 0; + screenshot_surface = 0; + screenshot_offset = 0; +} + +/* Streams one frame at a time into an empty queue, so the heartbeat and every + * control record get a turn between 48 KiB chunks. Surface and stage changes + * that queue nothing continue in the same call. */ +static void queue_screenshot_frame(void) { + while (screenshot_ready && tx_length == tx_offset) { + if (screenshot_stage == 0) { + uint8_t begin[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES]; + pocket_runtime_encode_screenshot_begin( + begin, + screenshot_frame, + screenshot_top_width, + screenshot_top_height, + screenshot_auxiliary_width, + screenshot_auxiliary_height, + screenshot_top_bytes, + screenshot_auxiliary_bytes + ); + if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN, 0, begin, sizeof begin, 0)) { + screenshot_stage = 1; + } + return; + } + if (screenshot_stage == 1) { + const uint8_t *surface = screenshot_surface == 0 ? screenshot_top : screenshot_auxiliary; + uint32_t bytes = screenshot_surface == 0 ? screenshot_top_bytes : screenshot_auxiliary_bytes; + if (screenshot_offset < bytes) { + uint32_t amount = bytes - screenshot_offset; + if (amount > SCREENSHOT_CHUNK_BYTES) amount = SCREENSHOT_CHUNK_BYTES; + static uint8_t payload[4 + SCREENSHOT_CHUNK_BYTES]; + pocket_runtime_write_u32(payload, screenshot_offset); + memcpy(payload + 4, surface + screenshot_offset, amount); + if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK, screenshot_surface, payload, 4 + amount, 0)) { + screenshot_offset += amount; + } + return; + } + if (screenshot_surface == 0) { + screenshot_surface = 1; + screenshot_offset = 0; + continue; + } + screenshot_stage = 2; + continue; + } + if (screenshot_stage == 2) { + uint8_t end[4]; + pocket_runtime_write_u32(end, screenshot_frame); + if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_END, 0, end, sizeof end, 0)) { + screenshot_stage = 3; + } + return; + } + /* The end frame has left the queue: the capture is complete. */ + counters.screenshots += 1; + pocket_devserver_screenshot_cancel(); + } +} + +/* ---- frames ---------------------------------------------------------- */ + +static void handle_frame(const PocketRuntimeFrameHeader *header, const uint8_t *payload) { + if (header->flags != 0) { + failed = 1; + return; + } + switch (header->type) { + case POCKET_RUNTIME_MSG_PING: + if (header->length == sizeof pong) { + memcpy(pong, payload, sizeof pong); + pong_pending = 1; + } + break; + case POCKET_RUNTIME_MSG_PONG: + break; + case POCKET_RUNTIME_MSG_CTRL: + if (header->length == 0 || header->length > POCKET_RUNTIME_MAX_CTRL_BYTES || + memchr(payload, '\0', header->length) != NULL || + memchr(payload, '\n', header->length) != NULL || + memchr(payload, '\r', header->length) != NULL || + !append_ctrl(payload, header->length)) { + failed = 1; + } + break; + case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: + handle_package_begin(payload, header->length); + break; + case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: + handle_package_chunk(payload, header->length); + break; + case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: + handle_package_commit(header->length); + break; + case POCKET_RUNTIME_MSG_PACKAGE_ABORT: + abort_upload("client aborted package transfer"); + break; + case POCKET_RUNTIME_MSG_STATUS_REQUEST: + if (header->length == 0) send_status(); + break; + default: + /* Unknown length-framed messages are skipped for forward compatibility. */ + break; + } +} + +/* ---- client lifecycle ------------------------------------------------ */ + +static void reset_client(void) { + authenticated = 0; + closing = 0; + failed = 0; + expired = 0; + rx_length = 0; + tx_length = 0; + tx_offset = 0; + pong_pending = 0; + controls_length = 0; + screenshot_requested = 0; + pocket_devserver_screenshot_cancel(); +} + +void pocket_devserver_client_open(void) { + reset_client(); + if (upload != NULL && !upload_ready) close_upload(); + last_rx_ms = now_ms(); + client_open = 1; +} + +void pocket_devserver_client_close(void) { + reset_client(); + /* A committed upload is complete and hashed; the host still takes it. */ + if (upload != NULL && !upload_ready) close_upload(); + client_open = 0; +} + +int pocket_devserver_connected(void) { + return client_open && authenticated && !closing && !failed; +} + +size_t pocket_devserver_rx_room(uint8_t **out) { + if (out == NULL || !client_open) return 0; + *out = rx + rx_length; + return sizeof rx - rx_length; +} + +int pocket_devserver_rx_commit(size_t length) { + if (!client_open || failed) return 0; + if (length > sizeof rx - rx_length) { + failed = 1; + return 0; + } + rx_length += length; + counters.rx_bytes += length; + last_rx_ms = now_ms(); + if (closing) { + /* Nothing after a rejected hello is read; its ack is still on the way. */ + rx_length = 0; + return 1; + } + if (!authenticated) { + if (rx_length < POCKET_RUNTIME_HELLO_BYTES) return 1; + int accepted = paired && pocket_runtime_verify_hello(rx, POCKET_RUNTIME_HELLO_BYTES, token); + /* The ack is the first thing on the wire; the queue is empty here. */ + pocket_runtime_encode_ack( + tx, + accepted ? 0 : 2, + config.host_abi, + generation, + packages_allowed ? POCKET_DEV_SERVER_ACK_FLAG_PACKAGES : 0u, + active_hash + ); + tx_length = POCKET_RUNTIME_ACK_BYTES; + tx_offset = 0; + memmove(rx, rx + POCKET_RUNTIME_HELLO_BYTES, rx_length - POCKET_RUNTIME_HELLO_BYTES); + rx_length -= POCKET_RUNTIME_HELLO_BYTES; + if (!accepted) { + counters.auth_failures += 1; + closing = 1; + rx_length = 0; + return 1; + } + authenticated = 1; + counters.connects += 1; + if (hello_length > 0) queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, hello, hello_length, 0); + send_status(); + } + while (!failed && !upload_ready && rx_length >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { + PocketRuntimeFrameHeader header; + if (!pocket_runtime_parse_frame_header(rx, rx_length, &header)) { + failed = 1; + break; + } + size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + (size_t)header.length; + if (rx_length < total) break; + handle_frame(&header, rx + POCKET_RUNTIME_FRAME_HEADER_BYTES); + memmove(rx, rx + total, rx_length - total); + rx_length -= total; + } + return !failed; +} + +size_t pocket_devserver_tx_pending(const uint8_t **out) { + if (out == NULL || !client_open) return 0; + if (authenticated && !closing && !failed && tx_offset == tx_length) { + tx_offset = 0; + tx_length = 0; + if (pong_pending && queue_frame(POCKET_RUNTIME_MSG_PONG, 0, pong, sizeof pong, 1)) { + pong_pending = 0; + } + queue_screenshot_frame(); + } + *out = tx + tx_offset; + return tx_length - tx_offset; +} + +void pocket_devserver_tx_consumed(size_t length) { + if (length > tx_length - tx_offset) length = tx_length - tx_offset; + tx_offset += length; + counters.tx_bytes += length; + if (tx_offset == tx_length) { + tx_offset = 0; + tx_length = 0; + } +} + +int pocket_devserver_client_closing(void) { + if (!client_open) return 0; + if (failed) return 1; + if (closing && tx_offset == tx_length) return 1; + uint64_t limit = authenticated + ? POCKET_DEV_SERVER_IDLE_TIMEOUT_MS + : POCKET_DEV_SERVER_HELLO_TIMEOUT_MS; + if (now_ms() - last_rx_ms > limit) { + if (!expired) { + expired = 1; + counters.timeouts += 1; + } + return 1; + } + return 0; +} + +int pocket_devserver_discovery( + const uint8_t *request, + size_t length, + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES] +) { + if (!configured || !paired || reply == NULL) return 0; + if (!pocket_runtime_is_discovery_request(request, length)) return 0; + pocket_runtime_encode_discovery_reply( + reply, + config.host_abi, + config.port, + pocket_devserver_connected() ? 1u : 0u, + generation, + active_hash, + device_id, + config.target, + config.label + ); + counters.discoveries += 1; + return 1; +} + +/* ---- configuration and pairing --------------------------------------- */ + +int pocket_devserver_configure(const PocketDevServerConfig *value) { + if (value == NULL || value->target == NULL || value->target[0] == '\0' || + value->label == NULL || value->upload_path == NULL || value->upload_path[0] == '\0' || + value->now_ms == NULL || value->port == 0) { + return 0; + } + /* A truncated name would pair the desktop with a Runtime it cannot name. */ + if (strlen(value->target) > POCKET_DEV_SERVER_NAME_BYTES || + strlen(value->label) > POCKET_DEV_SERVER_NAME_BYTES) { + return 0; + } + config = *value; + configured = 1; + return 1; +} + +static int hex_digit(int value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; +} + +int pocket_devserver_load_key(const char *path, char *error, size_t error_length) { + if (path == NULL) { + set_error(error, error_length, "pairing key path is empty"); + return POCKET_DEV_SERVER_KEY_ERROR; + } + FILE *file = fopen(path, "rb"); + if (file == NULL) { + if (errno == ENOENT) return POCKET_DEV_SERVER_KEY_DISABLED; + set_error(error, error_length, "open %s failed (%d)", path, errno); + return POCKET_DEV_SERVER_KEY_ERROR; + } + char hex[66] = {0}; + size_t length = fread(hex, 1, sizeof hex, file); + int close_result = fclose(file); + if (close_result != 0 || (length != 64 && length != 65) || (length == 65 && hex[64] != '\n')) { + set_error(error, error_length, "dev.key must contain exactly 64 hexadecimal characters"); + return POCKET_DEV_SERVER_KEY_ERROR; + } + uint8_t decoded[POCKET_RUNTIME_TOKEN_BYTES]; + for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { + int high = hex_digit(hex[index * 2]); + int low = hex_digit(hex[index * 2 + 1]); + if (high < 0 || low < 0) { + set_error(error, error_length, "dev.key contains a non-hexadecimal character"); + return POCKET_DEV_SERVER_KEY_ERROR; + } + decoded[index] = (uint8_t)((high << 4) | low); + } + memcpy(token, decoded, sizeof token); + device_id = pocket_runtime_device_id(token); + paired = 1; + return POCKET_DEV_SERVER_KEY_READY; +} + +int pocket_devserver_paired(void) { + return paired; +} + +uint64_t pocket_devserver_device_id(void) { + return paired ? device_id : 0; +} + +void pocket_devserver_set_state(uint32_t next_generation, uint64_t next_active_hash) { + generation = next_generation; + active_hash = next_active_hash; +} + +uint32_t pocket_devserver_generation(void) { + return generation; +} + +void pocket_devserver_shutdown(void) { + pocket_devserver_client_close(); + close_upload(); + pocket_devserver_reset_guest(); + memset(token, 0, sizeof token); + device_id = 0; + paired = 0; +} + +void pocket_devserver_counters(PocketDevServerCounters *out) { + if (out != NULL) *out = counters; +} diff --git a/engine/runtime/dev_server.h b/engine/runtime/dev_server.h new file mode 100644 index 000000000..e5274552a --- /dev/null +++ b/engine/runtime/dev_server.h @@ -0,0 +1,148 @@ +#ifndef POCKETJS_RUNTIME_DEV_SERVER_H +#define POCKETJS_RUNTIME_DEV_SERVER_H + +#include +#include + +#include "dev_protocol.h" + +/* + * Pocket Runtime wire v1 (PKRT) server semantics shared by every native host: + * pairing, the hello/ack handshake, frame parsing and dispatch, DevTools + * control records, `.pocket` uploads, two-surface screenshot streaming, + * discovery replies, idle timeouts and the counters hosts report. + * + * The host owns only its transport. It accepts one TCP client, receives into + * pocket_devserver_rx_room, sends what pocket_devserver_tx_pending returns, + * answers UDP datagrams through pocket_devserver_discovery, and supplies its + * clock, staging path, target identity and runtime status through + * PocketDevServerConfig. Every call happens on the host's frame thread and + * nothing here blocks. Package bytes never enter the guest: a committed upload + * waits in the staging file until the host's frame loop takes it. + */ + +#define POCKET_DEV_SERVER_MAX_PACKAGE_BYTES (24u * 1024u * 1024u) +/* A client that has not completed its hello within this window is dropped. */ +#define POCKET_DEV_SERVER_HELLO_TIMEOUT_MS 3000u +/* An authenticated client that sends nothing, not even a PING, is dropped. */ +#define POCKET_DEV_SERVER_IDLE_TIMEOUT_MS 15000u +/* Ack flag bit 0: this Runtime admits `.pocket` uploads. */ +#define POCKET_DEV_SERVER_ACK_FLAG_PACKAGES 1u + +enum { + POCKET_DEV_SERVER_KEY_ERROR = -1, + POCKET_DEV_SERVER_KEY_DISABLED = 0, + POCKET_DEV_SERVER_KEY_READY = 1, +}; + +/* Discovery carries the target id and label in 16-byte NUL-padded fields. */ +#define POCKET_DEV_SERVER_NAME_BYTES 15u + +typedef struct { + /* Target id and label published in discovery replies; borrowed, at most + * POCKET_DEV_SERVER_NAME_BYTES each. */ + const char *target; + const char *label; + uint16_t host_abi; + /* TCP port advertised in discovery replies. */ + uint16_t port; + /* Staging file for one package upload; borrowed. */ + const char *upload_path; + uint64_t (*now_ms)(void); + /* Writes one runtime.status JSON record (no newline) into out. */ + void (*status)(char *out, size_t capacity); + /* Screenshot surface buffers. Leaving both NULL disables screenshots. */ + void *(*screenshot_alloc)(size_t bytes); + void (*screenshot_free)(void *bytes); +} PocketDevServerConfig; + +typedef struct { + uint32_t connects; + uint32_t auth_failures; + uint32_t timeouts; + uint32_t uploads; + uint32_t screenshots; + uint32_t discoveries; + uint64_t rx_bytes; + uint64_t tx_bytes; +} PocketDevServerCounters; + +/* Stores a copy of config; the strings it points at must outlive the server. + * Returns 0 for an incomplete configuration or a name the discovery reply + * cannot carry whole. */ +int pocket_devserver_configure(const PocketDevServerConfig *config); +/* Drops the client, any upload, cached guest records and the pairing token. + * The configuration and counters stay. */ +void pocket_devserver_shutdown(void); +/* Reads a 64-hex-digit pairing key. DISABLED when the file is absent; ERROR, + * with a message in error when provided, when it is unreadable or malformed. */ +int pocket_devserver_load_key(const char *path, char *error, size_t error_length); +int pocket_devserver_paired(void); +uint64_t pocket_devserver_device_id(void); + +/* Runtime facts published in acks, discovery replies and install reports. */ +void pocket_devserver_set_state(uint32_t generation, uint64_t active_hash); +uint32_t pocket_devserver_generation(void); +/* Defaults to allowed. Disallowing discards a staged or in-flight upload. */ +void pocket_devserver_allow_packages(int allowed); + +/* Client lifecycle, driven by the transport. */ +void pocket_devserver_client_open(void); +/* Free receive space; 0 means the client must be closed. */ +size_t pocket_devserver_rx_room(uint8_t **out); +/* Accounts for received bytes and dispatches every complete frame. Returns 0 + * when the client must be closed. */ +int pocket_devserver_rx_commit(size_t length); +/* True while a committed upload waits for the host; the transport stops + * receiving so later frames wait behind the admission decision. */ +int pocket_devserver_upload_pending(void); +/* Bytes waiting to be sent. A pending PONG and the next screenshot chunk are + * queued here whenever the queue is empty. */ +size_t pocket_devserver_tx_pending(const uint8_t **out); +void pocket_devserver_tx_consumed(size_t length); +/* True when the transport must close the client: a rejected hello whose ack + * has been sent, a critical record that did not fit, or an idle timeout. */ +int pocket_devserver_client_closing(void); +void pocket_devserver_client_close(void); +/* An authenticated client is attached. */ +int pocket_devserver_connected(void); +/* Answers one discovery datagram. Returns 1 when reply must be sent. */ +int pocket_devserver_discovery( + const uint8_t *request, + size_t length, + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES] +); + +/* Pocket DevTools JSON-line transport exposed through ui.__dbg*. Records + * handed to recv are complete lines with a trailing newline and NUL. */ +size_t pocket_devserver_recv_ctrl(char *out, size_t capacity); +void pocket_devserver_send_ctrl(const char *line, size_t length); +void pocket_devserver_report_install(const char *phase, uint64_t hash, const char *message); +void pocket_devserver_report_log(const char *level, const char *message); +/* Forgets queued control records and the cached hello of a stopped guest. */ +void pocket_devserver_reset_guest(void); + +/* A committed upload waits in upload_path until the host takes it. Taking it + * hands the staged file to the host; the server no longer touches it. */ +int pocket_devserver_take_upload(uint64_t *declared_hash); +const char *pocket_devserver_upload_path(void); + +/* On-demand two-surface screenshot. The host fills the returned buffers while + * its GPU is idle; the server streams and frees them after the binary send. */ +int pocket_devserver_request_screenshot(void); +int pocket_devserver_take_screenshot_request(void); +int pocket_devserver_screenshot_begin( + uint32_t frame, + uint16_t top_width, + uint16_t top_height, + uint16_t auxiliary_width, + uint16_t auxiliary_height, + uint8_t **top, + uint8_t **auxiliary +); +void pocket_devserver_screenshot_ready(void); +void pocket_devserver_screenshot_cancel(void); + +void pocket_devserver_counters(PocketDevServerCounters *out); + +#endif diff --git a/engine/runtime/dev_wire_posix.c b/engine/runtime/dev_wire_posix.c new file mode 100644 index 000000000..8d43cb079 --- /dev/null +++ b/engine/runtime/dev_wire_posix.c @@ -0,0 +1,210 @@ +/* Non-blocking BSD socket pump for the shared Pocket Runtime server. No + * device SDK symbols: the same file serves UIKit shells and host tests. */ +#include "dev_wire_posix.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif +#define IO_BUDGET (64u * 1024u) + +static int listener = -1; +static int discovery = -1; +static int peer = -1; +static int configured; +static int suspended; +static char key_path[POCKET_DEV_PATH_BYTES]; +static char upload_path[POCKET_DEV_PATH_BYTES]; +static uint16_t listen_port; +static uint64_t next_listen; + +uint64_t pocket_devwire_now_ms(void) { + struct timeval time; + gettimeofday(&time, NULL); + return (uint64_t)time.tv_sec * 1000u + (uint64_t)time.tv_usec / 1000u; +} + +static int nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static int again(void) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; +} + +static void close_peer(void) { + if (peer >= 0) close(peer); + peer = -1; + pocket_devserver_client_close(); +} + +static void close_listeners(void) { + close_peer(); + if (listener >= 0) close(listener); + if (discovery >= 0) close(discovery); + listener = -1; + discovery = -1; +} + +static void listen_if_paired(void) { + uint64_t now = pocket_devwire_now_ms(); + if (listener >= 0 || now < next_listen || !configured || suspended) return; + next_listen = now + 1000; + if (pocket_devserver_load_key(key_path, NULL, 0) != POCKET_DEV_SERVER_KEY_READY) return; + struct sockaddr_in address; + memset(&address, 0, sizeof address); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_ANY); + address.sin_port = htons(listen_port); + listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) return; + int yes = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); + if (!nonblocking(listener) || + bind(listener, (struct sockaddr *)&address, sizeof address) != 0 || + listen(listener, 1) != 0) { + close_listeners(); + return; + } + discovery = socket(AF_INET, SOCK_DGRAM, 0); + if (discovery >= 0) { + setsockopt(discovery, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); + if (!nonblocking(discovery) || bind(discovery, (struct sockaddr *)&address, sizeof address) != 0) { + close(discovery); + discovery = -1; + } + } +} + +int pocket_devwire_init(const PocketDevWireOptions *options) { + pocket_devwire_shutdown(); + if (options == NULL || options->root == NULL || options->root[0] == '\0') return 0; + if (snprintf(key_path, sizeof key_path, "%s/dev.key", options->root) >= (int)sizeof key_path || + snprintf(upload_path, sizeof upload_path, "%s/upload.tmp", options->root) >= (int)sizeof upload_path) { + return 0; + } + PocketDevServerConfig config; + memset(&config, 0, sizeof config); + config.target = options->target; + config.label = options->label; + config.host_abi = options->host_abi; + config.port = options->port; + config.upload_path = upload_path; + config.now_ms = pocket_devwire_now_ms; + config.status = options->status; + if (!pocket_devserver_configure(&config)) return 0; + listen_port = options->port; + configured = 1; + next_listen = 0; + return 1; +} + +void pocket_devwire_suspend(int value) { + suspended = value ? 1 : 0; + if (suspended) close_listeners(); + next_listen = 0; +} + +void pocket_devwire_shutdown(void) { + close_listeners(); + pocket_devserver_shutdown(); + configured = 0; + suspended = 0; +} + +const char *pocket_devwire_state_name(void) { + if (suspended) return "suspended"; + if (pocket_devserver_connected()) return "connected"; + if (listener >= 0) return "listening"; + return "unpaired-or-unavailable"; +} + +static void pump_discovery(void) { + if (discovery < 0) return; + for (int attempt = 0; attempt < 4; attempt += 1) { + uint8_t request[64]; + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; + struct sockaddr_in source; + socklen_t size = sizeof source; + ssize_t length = recvfrom(discovery, request, sizeof request, 0, (struct sockaddr *)&source, &size); + if (length < 0) break; + if (!pocket_devserver_discovery(request, (size_t)length, reply)) continue; + sendto(discovery, reply, sizeof reply, MSG_NOSIGNAL, (struct sockaddr *)&source, size); + } +} + +static void accept_peer(void) { + peer = accept(listener, NULL, NULL); + if (peer < 0) return; + int yes = 1; + setsockopt(peer, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof yes); +#ifdef SO_NOSIGPIPE + if (setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof yes) != 0) { + close(peer); + peer = -1; + return; + } +#endif + if (!nonblocking(peer)) { + close(peer); + peer = -1; + return; + } + pocket_devserver_client_open(); +} + +static void pump_rx(void) { + size_t budget = IO_BUDGET; + while (peer >= 0 && budget > 0 && !pocket_devserver_upload_pending()) { + uint8_t *room = NULL; + size_t capacity = pocket_devserver_rx_room(&room); + if (capacity == 0) { + close_peer(); + return; + } + if (capacity > budget) capacity = budget; + ssize_t count = recv(peer, room, capacity, 0); + if (count > 0) { + budget -= (size_t)count; + if (!pocket_devserver_rx_commit((size_t)count)) close_peer(); + continue; + } + if (count == 0 || !again()) close_peer(); + return; + } +} + +static void pump_tx(void) { + const uint8_t *bytes = NULL; + size_t length = pocket_devserver_tx_pending(&bytes); + if (peer < 0 || length == 0) return; + if (length > IO_BUDGET) length = IO_BUDGET; + ssize_t count = send(peer, bytes, length, MSG_NOSIGNAL); + if (count > 0) pocket_devserver_tx_consumed((size_t)count); + else if (count == 0 || !again()) close_peer(); +} + +void pocket_devwire_pump(void) { + if (suspended || !configured) return; + listen_if_paired(); + if (listener < 0) return; + pump_discovery(); + if (peer < 0) accept_peer(); + if (peer < 0) return; + pump_rx(); + if (peer < 0) return; + pump_tx(); + if (peer < 0) return; + if (pocket_devserver_client_closing()) close_peer(); +} diff --git a/engine/runtime/dev_wire_posix.h b/engine/runtime/dev_wire_posix.h new file mode 100644 index 000000000..97f208e11 --- /dev/null +++ b/engine/runtime/dev_wire_posix.h @@ -0,0 +1,41 @@ +#ifndef POCKETJS_RUNTIME_DEV_WIRE_POSIX_H +#define POCKETJS_RUNTIME_DEV_WIRE_POSIX_H + +#include +#include + +#include "dev_server.h" + +/* + * Pocket Runtime wire transport over non-blocking BSD sockets for POSIX hosts + * (UIKit shells and the host test harness). Protocol semantics belong to + * dev_server.c; this file owns the listener, one peer, UDP discovery and the + * pairing-file and foreground policy: + * - the listener opens once /dev.key holds a valid key, rechecked once + * per second while unpaired; + * - suspending closes every socket and reopens them on resume, so a shell + * that resigns active holds no LAN connection in the background. + * All calls happen on the UI thread; pump bounds socket work per call. + */ + +#define POCKET_DEV_PATH_BYTES 1024 + +typedef struct { + /* Directory holding dev.key and the upload staging file; borrowed. */ + const char *root; + const char *target; + const char *label; + uint16_t host_abi; + uint16_t port; + void (*status)(char *out, size_t capacity); +} PocketDevWireOptions; + +int pocket_devwire_init(const PocketDevWireOptions *options); +void pocket_devwire_pump(void); +void pocket_devwire_suspend(int suspended); +void pocket_devwire_shutdown(void); +/* suspended, connected, listening or unpaired-or-unavailable. */ +const char *pocket_devwire_state_name(void); +uint64_t pocket_devwire_now_ms(void); + +#endif diff --git a/engine/runtime/guest_runtime.c b/engine/runtime/guest_runtime.c new file mode 100644 index 000000000..05a06246c --- /dev/null +++ b/engine/runtime/guest_runtime.c @@ -0,0 +1,330 @@ +/* Frame-confirmed guest replacement. Files are written before the native + * host releases the current realm. Only a successful presentation commits a + * generation; boot/frame failures walk active, last-good, embedded recovery. */ +#include "guest_runtime.h" +#include +#include +#include +#include +#include +#include +#include + +#ifndef POCKETJS_TARGET_ID +#error "POCKETJS_TARGET_ID must come from the build plan" +#endif +#ifndef POCKETJS_HOST_ABI +#error "POCKETJS_HOST_ABI must come from the build plan" +#endif + +typedef struct { + uint8_t *bytes; + PocketGuestPackage guest; +} LoadedPackage; + +static PocketDevHost host; +static PocketGuestPackage embedded; +static LoadedPackage *current; +static char root_path[POCKET_DEV_PATH_BYTES - 128]; +static char phase[24] = "starting", last_error[512]; +static uint32_t generation, frames; +static uint64_t active_hash, last_good_hash, running_hash, requested_hash; +static uint64_t rejected[3]; +static size_t rejected_count; +static int initialized, running, awaiting_frame, failure_pending; +/* Measured on the device for the install receipts and runtime.status. */ +static uint64_t boot_started_ms, boot_ms, first_frame_ms; + +static void error_text(const char *text) { + if (text == last_error) return; + snprintf(last_error, sizeof last_error, "%s", text ? text : "guest failure"); +} +static void path(char out[POCKET_DEV_PATH_BYTES], const char *suffix) { + snprintf(out, POCKET_DEV_PATH_BYTES, "%s/%s", root_path, suffix); +} +static void blob_path(char out[POCKET_DEV_PATH_BYTES], uint64_t hash) { + snprintf(out, POCKET_DEV_PATH_BYTES, "%s/packages/%016llx.pocket", root_path, (unsigned long long)hash); +} +static int mkdir_checked(const char *directory) { + struct stat info; + if (!mkdir(directory, 0700)) return 1; + return errno == EEXIST && !stat(directory, &info) && S_ISDIR(info.st_mode); +} +static int was_rejected(uint64_t hash) { + for (size_t i = 0; i < rejected_count; ++i) if (rejected[i] == hash) return 1; + return 0; +} +static void reject_hash(uint64_t hash) { + if (hash && !was_rejected(hash) && rejected_count < 3) rejected[rejected_count++] = hash; +} +static void release(LoadedPackage *package) { + if (!package) return; + free(package->bytes); + free(package); +} +static void stop_guest(void) { + host.stop(); + release(current); + current = NULL; + running = awaiting_frame = 0; + pocket_devserver_reset_guest(); +} +static LoadedPackage *load_package(const char *filename, uint64_t expected) { + FILE *file = fopen(filename, "rb"); + if (!file) { error_text("cannot open stored package"); return NULL; } + long size = -1; + if (!fseek(file, 0, SEEK_END)) size = ftell(file); + if (size < 24 || (unsigned long)size > POCKET_DEV_SERVER_MAX_PACKAGE_BYTES || fseek(file, 0, SEEK_SET)) { + fclose(file); error_text("invalid package file size"); return NULL; + } + LoadedPackage *package = calloc(1, sizeof *package); + if (package) package->bytes = malloc((size_t)size); + if (!package || !package->bytes) { + release(package); fclose(file); error_text("package allocation failed"); return NULL; + } + int ok = fread(package->bytes, 1, (size_t)size, file) == (size_t)size; + if (fclose(file)) ok = 0; + if (!ok) { release(package); error_text("incomplete package read"); return NULL; } + int result = pocket_package_open(package->bytes, (size_t)size, + (const uint8_t *)POCKETJS_TARGET_ID, sizeof POCKETJS_TARGET_ID - 1, + POCKETJS_HOST_ABI, &package->guest); + if (result || !package->guest.package_hash || (expected && expected != package->guest.package_hash)) { + snprintf(last_error, sizeof last_error, "package admission failed (code %d; expected hash checked)", result); + release(package); return NULL; + } + if (!host.validate_plan(package->guest.plan, package->guest.plan_length)) { + error_text("package plan does not match the runtime target, ABI or viewport"); + release(package); return NULL; + } + return package; +} +static int boot(LoadedPackage *package) { + current = package; + running_hash = package ? package->guest.package_hash : 0; + frames = 0; + failure_pending = 0; + boot_started_ms = pocket_devwire_now_ms(); + boot_ms = first_frame_ms = 0; + if (!host.boot(package ? &package->guest : &embedded)) { + error_text(host.error()); + reject_hash(running_hash); + stop_guest(); + return 0; + } + boot_ms = pocket_devwire_now_ms() - boot_started_ms; + running = awaiting_frame = 1; + snprintf(phase, sizeof phase, "%s", requested_hash ? "candidate" : "recovering"); + return 1; +} +static void recover(void) { + const uint64_t choices[] = {active_hash, last_good_hash}; + for (size_t i = 0; i < 2; ++i) { + if (!choices[i] || was_rejected(choices[i])) continue; + char filename[POCKET_DEV_PATH_BYTES]; + blob_path(filename, choices[i]); + LoadedPackage *package = load_package(filename, choices[i]); + if (package && boot(package)) return; + reject_hash(choices[i]); + } + if (!boot(NULL)) { + strcpy(phase, "failed"); + pocket_devserver_report_log("error", last_error); + } +} +static void load_state(void) { + char directory[POCKET_DEV_PATH_BYTES]; + path(directory, "state"); + DIR *dir = opendir(directory); + if (!dir) return; + struct dirent *entry; + while ((entry = readdir(dir))) { + unsigned int gen; + unsigned long long active, good; + int consumed = 0; + if (sscanf(entry->d_name, "state-%8x-%16llx-%16llx.commit%n", &gen, &active, &good, &consumed) != 3 || + consumed != 55 || entry->d_name[consumed] || gen <= generation) continue; + char filename[POCKET_DEV_PATH_BYTES], marker[10] = {0}; + snprintf(filename, sizeof filename, "%s/state/%.55s", root_path, entry->d_name); + FILE *file = fopen(filename, "rb"); + if (!file) continue; + size_t length = fread(marker, 1, sizeof marker, file); + fclose(file); + if (length != 9 || memcmp(marker, "accepted\n", 9)) continue; + generation = gen; + active_hash = (uint64_t)active; + last_good_hash = (uint64_t)good; + } + closedir(dir); +} +static void collect_old_files(void) { + char directory[POCKET_DEV_PATH_BYTES]; + path(directory, "packages"); + DIR *dir = opendir(directory); + if (dir) { + struct dirent *entry; + while ((entry = readdir(dir))) { + unsigned long long hash; + int consumed = 0; + if (sscanf(entry->d_name, "%16llx.pocket%n", &hash, &consumed) != 1 || + consumed != 23 || entry->d_name[consumed] || hash == active_hash || hash == last_good_hash) continue; + char filename[POCKET_DEV_PATH_BYTES]; + blob_path(filename, (uint64_t)hash); + remove(filename); + } + closedir(dir); + } + path(directory, "state"); + dir = opendir(directory); + if (!dir) return; + struct dirent *entry; + while ((entry = readdir(dir))) { + unsigned int gen; + unsigned long long active, good; + int consumed = 0; + if (sscanf(entry->d_name, "state-%8x-%16llx-%16llx.commit%n", &gen, &active, &good, &consumed) != 3 || + consumed != 55 || entry->d_name[consumed] || gen >= generation - 1) continue; + char filename[POCKET_DEV_PATH_BYTES]; + snprintf(filename, sizeof filename, "%s/state/%.55s", root_path, entry->d_name); + remove(filename); + } + closedir(dir); +} +static int commit(uint64_t active, uint64_t good) { + if (generation == UINT32_MAX) { error_text("runtime generation exhausted"); return 0; } + char temporary[POCKET_DEV_PATH_BYTES], destination[POCKET_DEV_PATH_BYTES]; + path(temporary, "state/state.tmp"); + snprintf(destination, sizeof destination, "%s/state/state-%08x-%016llx-%016llx.commit", + root_path, generation + 1, (unsigned long long)active, (unsigned long long)good); + FILE *file = fopen(temporary, "wb"); + if (!file) { error_text("cannot write runtime generation"); return 0; } + int ok = fputs("accepted\n", file) >= 0 && fflush(file) == 0 && fsync(fileno(file)) == 0; + if (fclose(file)) ok = 0; + if (!ok || rename(temporary, destination)) { + remove(temporary); error_text("cannot commit runtime generation"); return 0; + } + ++generation; + active_hash = active; + last_good_hash = good; + pocket_devserver_set_state(generation, active_hash); + collect_old_files(); + return 1; +} +void pocket_dev_runtime_status(char *out, size_t length) { + snprintf(out, length, + "{\"t\":\"runtime.status\",\"target\":\"%s\",\"hostAbi\":%u,\"phase\":\"%s\"," + "\"generation\":%u,\"active\":\"%016llx\",\"lastGood\":\"%016llx\",\"running\":\"%016llx\"," + "\"frame\":%u,\"bootMs\":%llu,\"firstFrameMs\":%llu,\"transport\":\"%s\"}", + POCKETJS_TARGET_ID, (unsigned)POCKETJS_HOST_ABI, phase, + generation, (unsigned long long)active_hash, (unsigned long long)last_good_hash, + (unsigned long long)running_hash, frames, (unsigned long long)boot_ms, + (unsigned long long)first_frame_ms, pocket_devwire_state_name()); +} +int pocket_dev_runtime_init(const char *root, const PocketDevHost *callbacks, + const PocketGuestPackage *recovery, uint16_t port) { + if (initialized || !root || strlen(root) >= sizeof root_path || !callbacks || !recovery || + !callbacks->boot || !callbacks->stop || !callbacks->validate_plan || !callbacks->error || + !callbacks->label) return 0; + host = *callbacks; + embedded = *recovery; + strcpy(root_path, root); + generation = frames = 0; + active_hash = last_good_hash = running_hash = requested_hash = 0; + rejected_count = 0; + failure_pending = 0; + char directory[POCKET_DEV_PATH_BYTES]; + if (!mkdir_checked(root_path)) return 0; + path(directory, "packages"); + if (!mkdir_checked(directory)) return 0; + path(directory, "state"); + if (!mkdir_checked(directory)) return 0; + load_state(); + const PocketDevWireOptions wire = {root_path, POCKETJS_TARGET_ID, host.label, + POCKETJS_HOST_ABI, port, pocket_dev_runtime_status}; + if (!pocket_devwire_init(&wire)) return 0; + remove(pocket_devserver_upload_path()); + pocket_devserver_set_state(generation, active_hash); + initialized = 1; + recover(); + return 1; +} +void pocket_dev_runtime_failed(const char *message) { + if (!initialized || !running) return; + error_text(message); + failure_pending = 1; +} +int pocket_dev_runtime_running(void) { return running && !failure_pending; } +void pocket_dev_runtime_pump(void) { + if (!initialized) return; + if (failure_pending) { + failure_pending = 0; + pocket_devserver_report_log("error", last_error); + if (requested_hash) pocket_devserver_report_install("rejected", requested_hash, last_error); + requested_hash = 0; + reject_hash(running_hash); + int embedded_failed = running_hash == 0; + stop_guest(); + if (embedded_failed) strcpy(phase, "failed"); + else recover(); + } + pocket_devwire_pump(); + if (awaiting_frame && running) return; + uint64_t hash; + if (!pocket_devserver_take_upload(&hash)) return; + LoadedPackage *candidate = load_package(pocket_devserver_upload_path(), hash); + if (!candidate) { + pocket_devserver_report_install("rejected", hash, last_error); + remove(pocket_devserver_upload_path()); + return; + } + char destination[POCKET_DEV_PATH_BYTES]; + blob_path(destination, hash); + if (rename(pocket_devserver_upload_path(), destination)) { + release(candidate); + pocket_devserver_report_install("rejected", hash, "cannot store admitted package"); + remove(pocket_devserver_upload_path()); + return; + } + stop_guest(); + rejected_count = 0; + requested_hash = hash; + if (boot(candidate)) { + char message[96]; + snprintf(message, sizeof message, "guest booted in %llu ms; waiting for presentation", + (unsigned long long)boot_ms); + pocket_devserver_report_install("staged", hash, message); + } else { + pocket_devserver_report_install("rejected", hash, last_error); + requested_hash = 0; + recover(); + } +} +void pocket_dev_runtime_presented(void) { + if (!running || failure_pending) return; + ++frames; + if (!awaiting_frame) return; + uint64_t good = active_hash; + if (good == running_hash || was_rejected(good)) good = last_good_hash; + if (good == running_hash || was_rejected(good)) good = 0; + if (running_hash != active_hash || good != last_good_hash) { + if (!commit(running_hash, good)) { + pocket_dev_runtime_failed(last_error); + return; + } + } + awaiting_frame = 0; + rejected_count = 0; + strcpy(phase, "accepted"); + first_frame_ms = pocket_devwire_now_ms() - boot_started_ms; + if (requested_hash) { + char message[96]; + snprintf(message, sizeof message, "first frame presented %llu ms after boot (boot %llu ms)", + (unsigned long long)first_frame_ms, (unsigned long long)boot_ms); + pocket_devserver_report_install("accepted", requested_hash, message); + } + requested_hash = 0; +} +void pocket_dev_runtime_shutdown(void) { + if (initialized) stop_guest(); + pocket_devwire_shutdown(); + initialized = 0; +} diff --git a/engine/runtime/guest_runtime.h b/engine/runtime/guest_runtime.h new file mode 100644 index 000000000..2a5a7eabf --- /dev/null +++ b/engine/runtime/guest_runtime.h @@ -0,0 +1,28 @@ +#ifndef POCKETJS_GUEST_RUNTIME_H +#define POCKETJS_GUEST_RUNTIME_H +#include "../ui-cabi/include/pocket_package.h" +#include "dev_server.h" +#include "dev_wire_posix.h" + +/* The native shell owns the window/GL context; the manager owns package + * buffers and disk state. stop must release all borrowed guest data. + * validate_plan admits a package plan against the shell's target contract + * (pocket_package_validate_plan with the generated contract). label names + * the shell in discovery replies. */ +typedef struct { + int (*boot)(const PocketGuestPackage *guest); + void (*stop)(void); + int (*validate_plan)(const uint8_t *plan, size_t length); + const char *(*error)(void); + const char *label; +} PocketDevHost; + +int pocket_dev_runtime_init(const char *root, const PocketDevHost *host, + const PocketGuestPackage *embedded, uint16_t port); +void pocket_dev_runtime_pump(void); +void pocket_dev_runtime_presented(void); +void pocket_dev_runtime_failed(const char *message); +void pocket_dev_runtime_shutdown(void); +void pocket_dev_runtime_status(char *out, size_t length); +int pocket_dev_runtime_running(void); +#endif diff --git a/engine/ui-cabi/include/pocket_package.h b/engine/ui-cabi/include/pocket_package.h new file mode 100644 index 000000000..e46f9e556 --- /dev/null +++ b/engine/ui-cabi/include/pocket_package.h @@ -0,0 +1,57 @@ +#ifndef POCKETJS_UI_PACKAGE_H +#define POCKETJS_UI_PACKAGE_H +#include +#include + +/* Slices borrow the complete package buffer until guest shutdown. */ +typedef struct { + const uint8_t *javascript; + size_t javascript_length; /* includes QuickJS's trailing NUL */ + const uint8_t *pak; + size_t pak_length; + const uint8_t *plan; + size_t plan_length; + uint64_t package_hash; + uint64_t variant_hash; +} PocketGuestPackage; + +/* Same admission/error codes as the 3DS host. Hosts also validate the plan + * against their target contract before changing the running guest. + * 0 = success, 12 = arguments. */ +int32_t pocket_package_open(const uint8_t *bytes, size_t length, + const uint8_t *target, size_t target_length, uint32_t host_abi, + PocketGuestPackage *out); + +/* The host's target contract: its identity, the surfaces it presents and the + * capability ids its target registry entry lists. tools/target-contract.ts + * generates a POCKET_TARGET_CONTRACT instance for each native build from the + * verified build plan and the registry, so the plan admission below shares + * one source of truth with the desktop resolver. Strings are NUL-terminated; + * a NULL auxiliary_presentation means the host has no auxiliary surface. */ +typedef struct { + const char *target; + uint32_t host_abi; + uint32_t logical_width; + uint32_t logical_height; + uint32_t physical_width; + uint32_t physical_height; + uint32_t raster_density; + const char *presentation; + const char *const *capabilities; + size_t capability_count; + uint32_t auxiliary_logical_width; + uint32_t auxiliary_logical_height; + uint32_t auxiliary_physical_width; + uint32_t auxiliary_physical_height; + uint32_t auxiliary_raster_density; + const char *auxiliary_presentation; + uint32_t host_extension; +} PocketTargetContract; + +/* Admit a package plan section for this host without evaluating it. + * 0 = the plan matches the contract; 1 syntax, 2 target, 3 host ABI, + * 4 viewport, 5 presentation, 6 surfaces, 7 host extension, 8 features, + * 12 arguments. */ +int32_t pocket_package_validate_plan(const uint8_t *plan, size_t length, + const PocketTargetContract *contract); +#endif diff --git a/engine/ui-cabi/src/lib.rs b/engine/ui-cabi/src/lib.rs index f9a3300ae..f0253134a 100644 --- a/engine/ui-cabi/src/lib.rs +++ b/engine/ui-cabi/src/lib.rs @@ -36,6 +36,7 @@ pub mod extension; not(feature = "software-only") ))] mod gl; +pub mod package; /// `malloc` and the supported host allocator ABIs provide `max_align_t` /// storage: 16 bytes on their 64-bit targets and 8 on 32-bit ARM. Core texture diff --git a/engine/ui-cabi/src/package.rs b/engine/ui-cabi/src/package.rs new file mode 100644 index 000000000..94da682dd --- /dev/null +++ b/engine/ui-cabi/src/package.rs @@ -0,0 +1,166 @@ +//! Borrowed `.pocket` admission for filesystem-loading C hosts. +use pocketjs_core::package::{select_guest, GuestError, PackageError}; + +#[repr(C)] +pub struct PocketGuestPackage { + javascript: *const u8, + javascript_length: usize, + pak: *const u8, + pak_length: usize, + plan: *const u8, + plan_length: usize, + package_hash: u64, + variant_hash: u64, +} + +/// The caller retains the input bytes until the guest has shut down. +/// Error numbers match the 3DS package ABI. +#[no_mangle] +pub unsafe extern "C" fn pocket_package_open( + ptr: *const u8, + len: usize, + target: *const u8, + target_len: usize, + host_abi: u32, + out: *mut PocketGuestPackage, +) -> i32 { + if ptr.is_null() || len == 0 || target.is_null() || target_len == 0 || out.is_null() { + return 12; + } + let target = match core::str::from_utf8(core::slice::from_raw_parts(target, target_len)) { + Ok(value) if !value.is_empty() => value, + _ => return 12, + }; + match select_guest( + core::slice::from_raw_parts(ptr, len), + target, + host_abi, + false, + ) { + Ok(guest) => { + out.write(PocketGuestPackage { + javascript: guest.js.as_ptr(), + javascript_length: guest.js.len(), + pak: guest.pak.as_ptr(), + pak_length: guest.pak.len(), + plan: guest.plan.as_ptr(), + plan_length: guest.plan.len(), + package_hash: guest.package_hash, + variant_hash: guest.variant_hash, + }); + 0 + } + Err(error) => match error { + GuestError::Package(PackageError::Truncated) => 1, + GuestError::Package(PackageError::BadMagic) => 2, + GuestError::Package(PackageError::BadVersion) => 3, + GuestError::Package(PackageError::HashMismatch) => 4, + GuestError::Package(PackageError::BadUtf8) => 5, + GuestError::MissingVariant => 6, + GuestError::HostAbiMismatch => 7, + GuestError::MissingIdentity => 8, + GuestError::MissingPlan => 9, + GuestError::MissingJavaScript => 10, + GuestError::JavaScriptNotTerminated => 11, + }, + } +} + +use core::ffi::{c_char, CStr}; +use pocketjs_core::plan::{validate_plan, PlanError, SurfaceContract, TargetContract}; + +/// The host's target contract, laid out as engine/ui-cabi/include/pocket_package.h +/// declares it and as tools/target-contract.ts generates it. +#[repr(C)] +pub struct PocketTargetContract { + target: *const c_char, + host_abi: u32, + logical_width: u32, + logical_height: u32, + physical_width: u32, + physical_height: u32, + raster_density: u32, + presentation: *const c_char, + capabilities: *const *const c_char, + capability_count: usize, + auxiliary_logical_width: u32, + auxiliary_logical_height: u32, + auxiliary_physical_width: u32, + auxiliary_physical_height: u32, + auxiliary_raster_density: u32, + /// NULL when the host presents no auxiliary surface. + auxiliary_presentation: *const c_char, + host_extension: u32, +} + +unsafe fn c_text<'a>(ptr: *const c_char) -> Option<&'a str> { + if ptr.is_null() { + return None; + } + CStr::from_ptr(ptr).to_str().ok() +} + +/// Admit a plan section for this host. 0 = the plan matches the contract; +/// 1 syntax, 2 target, 3 host ABI, 4 viewport, 5 presentation, 6 surfaces, +/// 7 host extension, 8 features, 12 invalid arguments. +#[no_mangle] +pub unsafe extern "C" fn pocket_package_validate_plan( + plan: *const u8, + len: usize, + contract: *const PocketTargetContract, +) -> i32 { + if plan.is_null() || len == 0 || contract.is_null() { + return 12; + } + let contract = &*contract; + let (Some(target), Some(presentation)) = (c_text(contract.target), c_text(contract.presentation)) else { + return 12; + }; + if contract.capabilities.is_null() && contract.capability_count != 0 { + return 12; + } + let mut capabilities = alloc::vec::Vec::with_capacity(contract.capability_count); + for index in 0..contract.capability_count { + match c_text(*contract.capabilities.add(index)) { + Some(id) => capabilities.push(id), + None => return 12, + } + } + let auxiliary = if contract.auxiliary_presentation.is_null() { + None + } else { + match c_text(contract.auxiliary_presentation) { + Some(presentation) => Some(SurfaceContract { + logical: [contract.auxiliary_logical_width, contract.auxiliary_logical_height], + physical: [contract.auxiliary_physical_width, contract.auxiliary_physical_height], + raster_density: contract.auxiliary_raster_density, + presentation, + }), + None => return 12, + } + }; + let contract = TargetContract { + target, + host_abi: contract.host_abi, + primary: SurfaceContract { + logical: [contract.logical_width, contract.logical_height], + physical: [contract.physical_width, contract.physical_height], + raster_density: contract.raster_density, + presentation, + }, + auxiliary, + capabilities: &capabilities, + host_extension: contract.host_extension != 0, + }; + match validate_plan(core::slice::from_raw_parts(plan, len), &contract) { + Ok(()) => 0, + Err(PlanError::Syntax) => 1, + Err(PlanError::Target) => 2, + Err(PlanError::HostAbi) => 3, + Err(PlanError::Viewport) => 4, + Err(PlanError::Presentation) => 5, + Err(PlanError::Surfaces) => 6, + Err(PlanError::HostExtension) => 7, + Err(PlanError::Features) => 8, + } +} diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 03a1f7c4f..60d953fbb 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -106,7 +106,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj LIBPATHS := -L$(DEVKITPRO)/libctru/lib LIBS := -lcitro3d -lctru -lm -OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o +OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/dev_server.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o ELF := $(BUILD)/pocketjs-3ds.elf SMDH := $(BUILD)/pocketjs-3ds.smdh @@ -154,6 +154,10 @@ $(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h $(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h $(BUILD)/media.o: $(SOURCE)/media.h $(SOURCE)/media_wire.h $(SOURCE)/media_adpcm.h $(BUILD)/main.o $(BUILD)/qjs.o $(BUILD)/gfx.o: $(SOURCE)/media.h +$(BUILD)/dev_protocol.o: $(CURDIR)/../../engine/runtime/dev_protocol.c $(CURDIR)/../../engine/runtime/dev_protocol.h +$(BUILD)/dev_server.o: $(CURDIR)/../../engine/runtime/dev_server.c $(CURDIR)/../../engine/runtime/dev_server.h $(CURDIR)/../../engine/runtime/dev_protocol.h +$(BUILD)/devserver.o: $(CURDIR)/../../engine/runtime/dev_server.h $(CURDIR)/../../engine/runtime/dev_protocol.h +$(BUILD)/svcwire.o: $(CURDIR)/../../engine/runtime/dev_protocol.h $(ELF): $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a $(CC) $(LDFLAGS) $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a \ diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md index 519af35b4..7b947be08 100644 --- a/hosts/3ds/README.md +++ b/hosts/3ds/README.md @@ -28,7 +28,8 @@ core/ pocketjs-3ds-core: the ui_* C ABI over pocketjs-core include/pocket_core.h the C header for the above src/main.c process boot, reusable guest lifecycle, frame loop src/runtime.c .pocket admission, immutable storage, active/rollback state -src/devserver.c discovery, paired TCP pump, uploads, screenshots, receipts +src/devserver.c libctru socket pump for the shared Pocket Runtime server + (engine/runtime/dev_server.c): discovery, uploads, screenshots src/dev_protocol.c byte-order-safe development wire encoding and admission src/devmenu.c Runtime-owned bottom-screen development menu src/gfx.c the DrawList -> citro3d walker @@ -171,6 +172,17 @@ accepted. **The key authenticates a client but does not encrypt the TCP stream.** Use the listener on a trusted LAN, and pass `--rotate` to `pair` after a key is exposed. +**What a frame means is decided in one place, `engine/runtime/dev_server.c`, +for this host and for the iPod touch 4 Runtime:** the hello/ack handshake +(a wrong key receives a status-2 ack before the close), unknown frame types skipped for +forward compatibility, control records rejected on a newline or a full ring, +uploads that stream to `network-upload.pocket` and survive a disconnect once +committed, the reserved PONG, screenshot chunking, and the idle timeouts of +3 s before the hello and 15 s after. `src/devserver.c` moves bytes between +that server and libctru sockets and supplies the ARM11 clock and linear +screenshot buffers. `tests/pocket-runtime-server.test.ts` runs this same pump +on the desktop with libctru stubbed out. + After pairing, ftpd is not part of the development loop: ```sh diff --git a/hosts/3ds/src/dev_protocol.c b/hosts/3ds/src/dev_protocol.c index afa2d44d0..7bcf3213d 100644 --- a/hosts/3ds/src/dev_protocol.c +++ b/hosts/3ds/src/dev_protocol.c @@ -1,192 +1,2 @@ -#include "dev_protocol.h" - -#include - -uint16_t pocket_runtime_read_u16(const uint8_t *bytes) { - return (uint16_t)bytes[0] | (uint16_t)((uint16_t)bytes[1] << 8); -} - -uint32_t pocket_runtime_read_u32(const uint8_t *bytes) { - return (uint32_t)bytes[0] | - ((uint32_t)bytes[1] << 8) | - ((uint32_t)bytes[2] << 16) | - ((uint32_t)bytes[3] << 24); -} - -uint64_t pocket_runtime_read_u64(const uint8_t *bytes) { - return (uint64_t)pocket_runtime_read_u32(bytes) | - ((uint64_t)pocket_runtime_read_u32(bytes + 4) << 32); -} - -void pocket_runtime_write_u16(uint8_t *bytes, uint16_t value) { - bytes[0] = (uint8_t)value; - bytes[1] = (uint8_t)(value >> 8); -} - -void pocket_runtime_write_u32(uint8_t *bytes, uint32_t value) { - bytes[0] = (uint8_t)value; - bytes[1] = (uint8_t)(value >> 8); - bytes[2] = (uint8_t)(value >> 16); - bytes[3] = (uint8_t)(value >> 24); -} - -void pocket_runtime_write_u64(uint8_t *bytes, uint64_t value) { - pocket_runtime_write_u32(bytes, (uint32_t)value); - pocket_runtime_write_u32(bytes + 4, (uint32_t)(value >> 32)); -} - -bool pocket_runtime_verify_hello( - const uint8_t *bytes, - size_t length, - const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] -) { - if (bytes == NULL || token == NULL || length != POCKET_RUNTIME_HELLO_BYTES) return false; - if (pocket_runtime_read_u32(bytes) != POCKET_RUNTIME_WIRE_MAGIC || - bytes[4] != POCKET_RUNTIME_WIRE_VERSION || bytes[5] != 0 || - pocket_runtime_read_u16(bytes + 6) != POCKET_RUNTIME_TOKEN_BYTES) { - return false; - } - /* Constant-time token comparison keeps a LAN peer from learning the - * persistent pairing secret one byte at a time. */ - uint8_t different = 0; - for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { - different |= bytes[8 + index] ^ token[index]; - } - return different == 0; -} - -void pocket_runtime_encode_ack( - uint8_t out[POCKET_RUNTIME_ACK_BYTES], - uint8_t status, - uint16_t host_abi, - uint32_t generation, - uint32_t flags, - uint64_t active_hash -) { - memset(out, 0, POCKET_RUNTIME_ACK_BYTES); - pocket_runtime_write_u32(out, POCKET_RUNTIME_WIRE_MAGIC); - out[4] = POCKET_RUNTIME_WIRE_VERSION; - out[5] = status; - pocket_runtime_write_u16(out + 6, host_abi); - pocket_runtime_write_u32(out + 8, generation); - pocket_runtime_write_u32(out + 12, flags); - pocket_runtime_write_u64(out + 16, active_hash); -} - -bool pocket_runtime_parse_frame_header( - const uint8_t *bytes, - size_t length, - PocketRuntimeFrameHeader *out -) { - if (bytes == NULL || out == NULL || length < POCKET_RUNTIME_FRAME_HEADER_BYTES) return false; - uint32_t payload_length = pocket_runtime_read_u32(bytes + 4); - if (bytes[2] != 0 || bytes[3] != 0 || payload_length > POCKET_RUNTIME_MAX_FRAME_BYTES) { - return false; - } - out->type = bytes[0]; - out->flags = bytes[1]; - out->length = payload_length; - return true; -} - -void pocket_runtime_encode_frame_header( - uint8_t out[POCKET_RUNTIME_FRAME_HEADER_BYTES], - uint8_t type, - uint8_t flags, - uint32_t length -) { - memset(out, 0, POCKET_RUNTIME_FRAME_HEADER_BYTES); - out[0] = type; - out[1] = flags; - pocket_runtime_write_u32(out + 4, length); -} - -bool pocket_runtime_parse_package_begin( - const uint8_t *bytes, - size_t length, - PocketRuntimePackageBegin *out -) { - if (bytes == NULL || out == NULL || length != POCKET_RUNTIME_PACKAGE_BEGIN_BYTES) return false; - uint32_t package_length = pocket_runtime_read_u32(bytes); - uint64_t footer_hash = pocket_runtime_read_u64(bytes + 4); - if (package_length == 0 || package_length > 24u * 1024u * 1024u || footer_hash == 0) { - return false; - } - out->length = package_length; - out->footer_hash = footer_hash; - return true; -} - -void pocket_runtime_encode_screenshot_begin( - uint8_t out[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES], - uint32_t frame, - uint16_t top_width, - uint16_t top_height, - uint16_t auxiliary_width, - uint16_t auxiliary_height, - uint32_t top_bytes, - uint32_t auxiliary_bytes -) { - memset(out, 0, POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES); - pocket_runtime_write_u32(out, frame); - pocket_runtime_write_u16(out + 4, top_width); - pocket_runtime_write_u16(out + 6, top_height); - pocket_runtime_write_u16(out + 8, auxiliary_width); - pocket_runtime_write_u16(out + 10, auxiliary_height); - out[12] = POCKET_RUNTIME_SCREENSHOT_FORMAT_ROTATED_RGB8; - pocket_runtime_write_u32(out + 16, top_bytes); - pocket_runtime_write_u32(out + 20, auxiliary_bytes); -} - -uint64_t pocket_runtime_device_id( - const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] -) { - if (token == NULL) return 0; - uint64_t hash = 0xcbf29ce484222325ULL; - for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { - hash ^= token[index]; - hash *= 0x100000001b3ULL; - } - return hash; -} - -bool pocket_runtime_is_discovery_request(const uint8_t *bytes, size_t length) { - return bytes != NULL && length == POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES && - pocket_runtime_read_u32(bytes) == POCKET_RUNTIME_DISCOVERY_MAGIC && - bytes[4] == POCKET_RUNTIME_WIRE_VERSION && - bytes[5] == POCKET_RUNTIME_DISCOVERY_REQUEST && - bytes[6] == 0 && bytes[7] == 0; -} - -static void write_fixed_text(uint8_t *out, size_t length, const char *text) { - memset(out, 0, length); - if (text == NULL) return; - size_t text_length = strlen(text); - if (text_length >= length) text_length = length - 1; - memcpy(out, text, text_length); -} - -void pocket_runtime_encode_discovery_reply( - uint8_t out[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES], - uint16_t host_abi, - uint16_t port, - uint16_t flags, - uint32_t generation, - uint64_t active_hash, - uint64_t device_id, - const char *target, - const char *label -) { - memset(out, 0, POCKET_RUNTIME_DISCOVERY_REPLY_BYTES); - pocket_runtime_write_u32(out, POCKET_RUNTIME_DISCOVERY_MAGIC); - out[4] = POCKET_RUNTIME_WIRE_VERSION; - out[5] = POCKET_RUNTIME_DISCOVERY_REPLY; - pocket_runtime_write_u16(out + 6, host_abi); - pocket_runtime_write_u16(out + 8, port); - pocket_runtime_write_u16(out + 10, flags); - pocket_runtime_write_u32(out + 12, generation); - pocket_runtime_write_u64(out + 16, active_hash); - pocket_runtime_write_u64(out + 24, device_id); - write_fixed_text(out + 32, 16, target); - write_fixed_text(out + 48, 16, label); -} +/* Keep the devkitARM build entry point; the codec is shared with UIKit. */ +#include "../../../engine/runtime/dev_protocol.c" diff --git a/hosts/3ds/src/dev_protocol.h b/hosts/3ds/src/dev_protocol.h index 7af25de15..6dc2303bf 100644 --- a/hosts/3ds/src/dev_protocol.h +++ b/hosts/3ds/src/dev_protocol.h @@ -1,113 +1 @@ -#ifndef POCKETJS_3DS_DEV_PROTOCOL_H -#define POCKETJS_3DS_DEV_PROTOCOL_H - -#include -#include -#include - -#define POCKET_RUNTIME_WIRE_MAGIC 0x54524b50u /* 'PKRT' little-endian */ -#define POCKET_RUNTIME_DISCOVERY_MAGIC 0x44524b50u /* 'PKRD' little-endian */ -#define POCKET_RUNTIME_WIRE_VERSION 1u -#define POCKET_RUNTIME_WIRE_PORT 8131u -#define POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES 8u -#define POCKET_RUNTIME_DISCOVERY_REPLY_BYTES 64u -#define POCKET_RUNTIME_DISCOVERY_REQUEST 1u -#define POCKET_RUNTIME_DISCOVERY_REPLY 2u -#define POCKET_RUNTIME_TOKEN_BYTES 32u -#define POCKET_RUNTIME_HELLO_BYTES 40u -#define POCKET_RUNTIME_ACK_BYTES 24u -#define POCKET_RUNTIME_FRAME_HEADER_BYTES 8u -#define POCKET_RUNTIME_MAX_FRAME_BYTES (64u * 1024u) -#define POCKET_RUNTIME_MAX_CTRL_BYTES (16u * 1024u) -#define POCKET_RUNTIME_PACKAGE_BEGIN_BYTES 12u -#define POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES 24u -#define POCKET_RUNTIME_SCREENSHOT_FORMAT_ROTATED_RGB8 1u - -enum PocketRuntimeMessage { - POCKET_RUNTIME_MSG_PING = 0x01, - POCKET_RUNTIME_MSG_PONG = 0x02, - POCKET_RUNTIME_MSG_CTRL = 0x10, - POCKET_RUNTIME_MSG_PACKAGE_BEGIN = 0x20, - POCKET_RUNTIME_MSG_PACKAGE_CHUNK = 0x21, - POCKET_RUNTIME_MSG_PACKAGE_COMMIT = 0x22, - POCKET_RUNTIME_MSG_PACKAGE_ABORT = 0x23, - POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN = 0x30, - POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK = 0x31, - POCKET_RUNTIME_MSG_SCREENSHOT_END = 0x32, - POCKET_RUNTIME_MSG_STATUS_REQUEST = 0x40, -}; - -typedef struct { - uint8_t type; - uint8_t flags; - uint32_t length; -} PocketRuntimeFrameHeader; - -typedef struct { - uint32_t length; - uint64_t footer_hash; -} PocketRuntimePackageBegin; - -uint16_t pocket_runtime_read_u16(const uint8_t *bytes); -uint32_t pocket_runtime_read_u32(const uint8_t *bytes); -uint64_t pocket_runtime_read_u64(const uint8_t *bytes); -void pocket_runtime_write_u16(uint8_t *bytes, uint16_t value); -void pocket_runtime_write_u32(uint8_t *bytes, uint32_t value); -void pocket_runtime_write_u64(uint8_t *bytes, uint64_t value); - -bool pocket_runtime_verify_hello( - const uint8_t *bytes, - size_t length, - const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] -); -void pocket_runtime_encode_ack( - uint8_t out[POCKET_RUNTIME_ACK_BYTES], - uint8_t status, - uint16_t host_abi, - uint32_t generation, - uint32_t flags, - uint64_t active_hash -); -bool pocket_runtime_parse_frame_header( - const uint8_t *bytes, - size_t length, - PocketRuntimeFrameHeader *out -); -void pocket_runtime_encode_frame_header( - uint8_t out[POCKET_RUNTIME_FRAME_HEADER_BYTES], - uint8_t type, - uint8_t flags, - uint32_t length -); -bool pocket_runtime_parse_package_begin( - const uint8_t *bytes, - size_t length, - PocketRuntimePackageBegin *out -); -void pocket_runtime_encode_screenshot_begin( - uint8_t out[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES], - uint32_t frame, - uint16_t top_width, - uint16_t top_height, - uint16_t auxiliary_width, - uint16_t auxiliary_height, - uint32_t top_bytes, - uint32_t auxiliary_bytes -); -uint64_t pocket_runtime_device_id( - const uint8_t token[POCKET_RUNTIME_TOKEN_BYTES] -); -bool pocket_runtime_is_discovery_request(const uint8_t *bytes, size_t length); -void pocket_runtime_encode_discovery_reply( - uint8_t out[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES], - uint16_t host_abi, - uint16_t port, - uint16_t flags, - uint32_t generation, - uint64_t active_hash, - uint64_t device_id, - const char *target, - const char *label -); - -#endif +#include "../../../engine/runtime/dev_protocol.h" diff --git a/hosts/3ds/src/dev_server.c b/hosts/3ds/src/dev_server.c new file mode 100644 index 000000000..5bc6631eb --- /dev/null +++ b/hosts/3ds/src/dev_server.c @@ -0,0 +1,2 @@ +/* Keep the devkitARM build entry point; the server semantics are shared. */ +#include "../../../engine/runtime/dev_server.c" diff --git a/hosts/3ds/src/dev_server.h b/hosts/3ds/src/dev_server.h new file mode 100644 index 000000000..8257f8c86 --- /dev/null +++ b/hosts/3ds/src/dev_server.h @@ -0,0 +1 @@ +#include "../../../engine/runtime/dev_server.h" diff --git a/hosts/3ds/src/devserver.c b/hosts/3ds/src/devserver.c index 481750067..30ebe0d19 100644 --- a/hosts/3ds/src/devserver.c +++ b/hosts/3ds/src/devserver.c @@ -1,9 +1,12 @@ /* - * Paired in-process development transport for Pocket Runtime. + * libctru socket pump for the shared Pocket Runtime server + * (engine/runtime/dev_server.c). * - * The main/render thread owns this bounded non-blocking pump. JSON control - * frames feed the existing Pocket DevTools shim; `.pocket` uploads stream to - * SD and screenshots stream from linear memory. Bulk bytes never enter JS. + * The main/render thread owns this bounded non-blocking pump. Everything the + * wire means — pairing, handshake, frame dispatch, control records, uploads, + * screenshot streaming, timeouts — is decided by the shared server; this file + * supplies SOC ownership, the sockets, the ARM11 clock, linear-memory + * screenshot buffers and the 3DS status and counter records. */ #include "devserver.h" @@ -12,16 +15,14 @@ #include #include #include -#include #include #include #include -#include #include #include #include -#include "dev_protocol.h" +#include "dev_server.h" #include "soc.h" #ifndef POCKETJS_HOST_ABI @@ -30,74 +31,23 @@ #ifndef POCKETJS_TARGET_ID #error "POCKETJS_TARGET_ID must come from the verified ResolvedBuildPlan" #endif +/* Host tests bind an ephemeral port; the console keeps the published one. */ +#ifndef POCKETJS_DEV_PORT +#define POCKETJS_DEV_PORT POCKET_RUNTIME_WIRE_PORT +#endif -#define RX_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) -#define CTRL_IN_BYTES (4u * (POCKET_RUNTIME_MAX_CTRL_BYTES + 1u)) -#define CTRL_OUT_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) -#define SCREENSHOT_CHUNK_BYTES (48u * 1024u) +#define IO_BUDGET (64u * 1024u) static int server_fd = -1; static int discovery_fd = -1; static int client_fd = -1; static bool initialized; -static bool authenticated; -static bool handshake_pending; -static bool handshake_accepted; -static uint8_t handshake_ack[POCKET_RUNTIME_ACK_BYTES]; -static size_t handshake_ack_offset; -static uint64_t client_last_rx_ms; -static uint8_t pairing_token[POCKET_RUNTIME_TOKEN_BYTES]; -static uint64_t device_id; - -static uint8_t rx_buffer[RX_BYTES]; -static size_t rx_length; -static uint8_t ctrl_input[CTRL_IN_BYTES]; -static size_t ctrl_input_length; -static uint8_t tx_buffer[CTRL_OUT_BYTES]; -static size_t tx_length; -static size_t tx_offset; -static bool pong_pending; -static uint8_t pong_payload[4]; - -static char hello_cache[1024]; -static size_t hello_cache_length; - -static FILE *upload_file; -static uint32_t upload_expected; -static uint32_t upload_received; -static uint64_t upload_hash; -static bool upload_ready; -static bool packages_allowed = true; - -static bool screenshot_requested; -static bool screenshot_ready; -static uint8_t *screenshot_top; -static uint8_t *screenshot_auxiliary; -static uint32_t screenshot_top_bytes; -static uint32_t screenshot_auxiliary_bytes; -static uint32_t screenshot_frame; -static uint16_t screenshot_top_width; -static uint16_t screenshot_top_height; -static uint16_t screenshot_auxiliary_width; -static uint16_t screenshot_auxiliary_height; -static uint8_t screenshot_stage; -static uint8_t screenshot_surface; -static uint32_t screenshot_offset; static PocketRuntimeState runtime_state; static uint64_t running_hash; static uint64_t variant_hash; static uint32_t runtime_frame; static char runtime_phase[32] = "starting"; - -static uint64_t rx_bytes; -static uint64_t tx_bytes; -static uint32_t connects; -static uint32_t auth_failures; -static uint32_t uploads; -static uint32_t screenshots; -static uint32_t timeouts; -static uint32_t discoveries; static uint32_t frame_commands; static uint32_t frame_vertices; static uint32_t frame_dropped_vertices; @@ -115,8 +65,13 @@ static bool would_block(void) { return errno == EAGAIN || errno == EWOULDBLOCK; } +static bool set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + static void format_ip(char out[16]) { - uint32_t ip = initialized ? gethostid() : 0; + uint32_t ip = initialized ? (uint32_t)gethostid() : 0; snprintf( out, 16, @@ -128,82 +83,57 @@ static void format_ip(char out[16]) { ); } -static void close_upload(void) { - if (upload_file != NULL) fclose(upload_file); - upload_file = NULL; - upload_expected = 0; - upload_received = 0; - upload_hash = 0; +static uint64_t clock_ms(void) { + return osGetTime(); } -void devserver_screenshot_cancel(void) { - if (screenshot_top != NULL) linearFree(screenshot_top); - if (screenshot_auxiliary != NULL) linearFree(screenshot_auxiliary); - screenshot_top = NULL; - screenshot_auxiliary = NULL; - screenshot_top_bytes = 0; - screenshot_auxiliary_bytes = 0; - screenshot_ready = false; - screenshot_stage = 0; - screenshot_surface = 0; - screenshot_offset = 0; -} - -static void disconnect_client(void) { - if (client_fd >= 0) close(client_fd); - client_fd = -1; - authenticated = false; - handshake_pending = false; - handshake_accepted = false; - handshake_ack_offset = 0; - rx_length = 0; - ctrl_input_length = 0; - tx_length = 0; - tx_offset = 0; - pong_pending = false; - screenshot_requested = false; - devserver_screenshot_cancel(); - if (upload_file != NULL && !upload_ready) close_upload(); +static void *screenshot_alloc(size_t bytes) { + return linearAlloc(bytes); } -static bool set_nonblocking(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +static void screenshot_free(void *bytes) { + linearFree(bytes); } -static int hex_digit(int value) { - if (value >= '0' && value <= '9') return value - '0'; - if (value >= 'a' && value <= 'f') return value - 'a' + 10; - if (value >= 'A' && value <= 'F') return value - 'A' + 10; - return -1; +static void write_status(char *out, size_t capacity) { + char ip[16]; + format_ip(ip); + snprintf( + out, + capacity, + "{\"t\":\"runtime.status\",\"phase\":\"%s\",\"target\":\"%s\",\"hostAbi\":%u," + "\"ip\":\"%s\",\"port\":%u,\"generation\":%lu," + "\"active\":\"%016llx\",\"lastGood\":\"%016llx\",\"running\":\"%016llx\"," + "\"frame\":%lu}", + runtime_phase, + POCKETJS_TARGET_ID, + (unsigned)POCKETJS_HOST_ABI, + ip, + (unsigned)POCKETJS_DEV_PORT, + (unsigned long)runtime_state.generation, + (unsigned long long)runtime_state.active_hash, + (unsigned long long)runtime_state.last_good_hash, + (unsigned long long)running_hash, + (unsigned long)runtime_frame + ); } -static DevserverInitResult load_key(char *error, size_t error_length) { - FILE *file = fopen(POCKET_RUNTIME_DEV_KEY, "rb"); - if (file == NULL) { - if (errno == ENOENT) return DEVSERVER_DISABLED; - set_error(error, error_length, "open %s failed (%d)", POCKET_RUNTIME_DEV_KEY, errno); - return DEVSERVER_ERROR; - } - char hex[66] = {0}; - size_t length = fread(hex, 1, sizeof hex, file); - int close_result = fclose(file); - if (close_result != 0 || (length != 64 && length != 65) || - (length == 65 && hex[64] != '\n')) { - set_error(error, error_length, "dev.key must contain exactly 64 hexadecimal characters"); - return DEVSERVER_ERROR; - } - for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { - int high = hex_digit(hex[index * 2]); - int low = hex_digit(hex[index * 2 + 1]); - if (high < 0 || low < 0) { - set_error(error, error_length, "dev.key contains a non-hexadecimal character"); - return DEVSERVER_ERROR; - } - pairing_token[index] = (uint8_t)((high << 4) | low); - } - device_id = pocket_runtime_device_id(pairing_token); - return DEVSERVER_READY; +static const PocketDevServerConfig CONFIG = { + POCKETJS_TARGET_ID, + "PocketJS 3DS", + POCKETJS_HOST_ABI, + POCKETJS_DEV_PORT, + POCKET_RUNTIME_UPLOAD, + clock_ms, + write_status, + screenshot_alloc, + screenshot_free, +}; + +static void close_client(void) { + if (client_fd >= 0) close(client_fd); + client_fd = -1; + pocket_devserver_client_close(); } DevserverInitResult devserver_init( @@ -213,8 +143,13 @@ DevserverInitResult devserver_init( ) { if (initialized) return DEVSERVER_READY; if (state != NULL) runtime_state = *state; - DevserverInitResult key = load_key(error, error_length); - if (key != DEVSERVER_READY) return key; + if (!pocket_devserver_configure(&CONFIG)) { + set_error(error, error_length, "Pocket Runtime server configuration is incomplete"); + return DEVSERVER_ERROR; + } + int key = pocket_devserver_load_key(POCKET_RUNTIME_DEV_KEY, error, error_length); + if (key == POCKET_DEV_SERVER_KEY_DISABLED) return DEVSERVER_DISABLED; + if (key != POCKET_DEV_SERVER_KEY_READY) return DEVSERVER_ERROR; if (!soc_ensure(error, error_length)) return DEVSERVER_ERROR; @@ -230,10 +165,10 @@ DevserverInitResult devserver_init( memset(&address, 0, sizeof address); address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; - address.sin_port = htons(POCKET_RUNTIME_WIRE_PORT); + address.sin_port = htons(POCKETJS_DEV_PORT); if (bind(server_fd, (struct sockaddr *)&address, sizeof address) != 0 || listen(server_fd, 1) != 0 || !set_nonblocking(server_fd)) { - set_error(error, error_length, "Pocket Runtime listen on %u failed (%d)", POCKET_RUNTIME_WIRE_PORT, errno); + set_error(error, error_length, "Pocket Runtime listen on %u failed (%d)", (unsigned)POCKETJS_DEV_PORT, errno); devserver_shutdown(); return DEVSERVER_ERROR; } @@ -247,17 +182,18 @@ DevserverInitResult devserver_init( discovery_fd = -1; } } + pocket_devserver_set_state(runtime_state.generation, runtime_state.active_hash); initialized = true; return DEVSERVER_READY; } void devserver_shutdown(void) { - disconnect_client(); - close_upload(); + close_client(); if (server_fd >= 0) close(server_fd); server_fd = -1; if (discovery_fd >= 0) close(discovery_fd); discovery_fd = -1; + pocket_devserver_shutdown(); /* SOC itself is shared with the svc transport; main owns soc_shutdown. */ initialized = false; } @@ -267,180 +203,49 @@ bool devserver_active(void) { } bool devserver_connected(void) { - return authenticated && client_fd >= 0; + return client_fd >= 0 && pocket_devserver_connected(); +} + +void devserver_allow_packages(bool allowed) { + pocket_devserver_allow_packages(allowed); } void devserver_snapshot(DevserverSnapshot *out) { if (out == NULL) return; + PocketDevServerCounters counters; + pocket_devserver_counters(&counters); memset(out, 0, sizeof *out); out->enabled = initialized; out->discoverable = discovery_fd >= 0; out->connected = devserver_connected(); format_ip(out->ip); snprintf(out->phase, sizeof out->phase, "%s", runtime_phase); - out->port = POCKET_RUNTIME_WIRE_PORT; + out->port = POCKETJS_DEV_PORT; out->host_abi = POCKETJS_HOST_ABI; out->generation = runtime_state.generation; out->running_hash = running_hash; - out->device_id = device_id; - out->connects = connects; - out->auth_failures = auth_failures; - out->timeouts = timeouts; - out->uploads = uploads; - out->screenshots = screenshots; -} - -static bool queue_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { - if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) return false; - uint8_t header[POCKET_RUNTIME_FRAME_HEADER_BYTES]; - pocket_runtime_encode_frame_header(header, type, flags, (uint32_t)length); - if (tx_offset > 0) { - if (tx_offset < tx_length) { - memmove(tx_buffer, tx_buffer + tx_offset, tx_length - tx_offset); - tx_length -= tx_offset; - } else { - tx_length = 0; - } - tx_offset = 0; - } - if (sizeof header + length > sizeof tx_buffer - tx_length) return false; - memcpy(tx_buffer + tx_length, header, sizeof header); - tx_length += sizeof header; - if (length > 0) { - memcpy(tx_buffer + tx_length, payload, length); - tx_length += length; - } - return true; -} - -static size_t json_escape(char *out, size_t capacity, const char *text) { - size_t written = 0; - if (text == NULL) return 0; - for (const unsigned char *at = (const unsigned char *)text; *at != 0; at += 1) { - const char *escape = NULL; - char unicode[7]; - if (*at == '"') escape = "\\\""; - else if (*at == '\\') escape = "\\\\"; - else if (*at == '\n') escape = "\\n"; - else if (*at == '\r') escape = "\\r"; - else if (*at == '\t') escape = "\\t"; - else if (*at < 0x20) { - snprintf(unicode, sizeof unicode, "\\u%04x", *at); - escape = unicode; - } - if (escape != NULL) { - size_t length = strlen(escape); - if (written + length >= capacity) break; - memcpy(out + written, escape, length); - written += length; - } else { - if (written + 1 >= capacity) break; - out[written++] = (char)*at; - } - } - if (capacity > 0) out[written < capacity ? written : capacity - 1] = '\0'; - return written; + out->device_id = pocket_devserver_device_id(); + out->connects = counters.connects; + out->auth_failures = counters.auth_failures; + out->timeouts = counters.timeouts; + out->uploads = counters.uploads; + out->screenshots = counters.screenshots; } -/* - * On the way out a control record may be as large as a frame. tx_buffer is - * sized for a whole frame and the screenshot path already pushes 48 KiB - * through it, whereas MAX_CTRL_BYTES bounds what the TOOL sends — ctrl_input, - * the inbound ring, is sized from it. Holding outgoing records to the inbound - * bound cost the devtools tree dump: past a few hundred nodes it went over - * 16 KiB and was discarded here without a word, which on the tool side is - * indistinguishable from a hung device until the 15 s timeout expires. - */ void devserver_send_ctrl(const char *line, size_t length) { - if (line == NULL || length == 0) return; - if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) { - /* Too large for any frame. Say so, so the caller waiting on this record - * learns why it is never coming. */ - char notice[128]; - int written = snprintf( - notice, - sizeof notice, - "{\"t\":\"ctrlDropped\",\"bytes\":%u,\"cap\":%u}", - (unsigned)length, - (unsigned)POCKET_RUNTIME_MAX_FRAME_BYTES - ); - if (written > 0) { - queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)notice, (size_t)written); - } - return; - } - static const char hello_marker[] = "\"t\":\"hello\""; - bool is_hello = false; - if (length >= sizeof hello_marker - 1) { - for (size_t offset = 0; offset + sizeof hello_marker - 1 <= length; offset += 1) { - if (memcmp(line + offset, hello_marker, sizeof hello_marker - 1) == 0) { - is_hello = true; - break; - } - } - } - if (is_hello && length < sizeof hello_cache) { - memcpy(hello_cache, line, length); - hello_cache[length] = '\0'; - hello_cache_length = length; - } - queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)line, length); + pocket_devserver_send_ctrl(line, length); } -static void send_status(void) { - char message[640]; - char ip[16]; - format_ip(ip); - snprintf( - message, - sizeof message, - "{\"t\":\"runtime.status\",\"phase\":\"%s\",\"target\":\"%s\",\"hostAbi\":%u," - "\"ip\":\"%s\",\"port\":%u,\"generation\":%lu," - "\"active\":\"%016llx\",\"lastGood\":\"%016llx\",\"running\":\"%016llx\"," - "\"frame\":%lu}", - runtime_phase, - POCKETJS_TARGET_ID, - (unsigned)POCKETJS_HOST_ABI, - ip, - (unsigned)POCKET_RUNTIME_WIRE_PORT, - (unsigned long)runtime_state.generation, - (unsigned long long)runtime_state.active_hash, - (unsigned long long)runtime_state.last_good_hash, - (unsigned long long)running_hash, - (unsigned long)runtime_frame - ); - devserver_send_ctrl(message, strlen(message)); +size_t devserver_recv_ctrl(char *out, size_t capacity) { + return pocket_devserver_recv_ctrl(out, capacity); } void devserver_report_install(const char *phase, uint64_t hash, const char *message) { - char escaped[384] = {0}; - char line[640]; - json_escape(escaped, sizeof escaped, message == NULL ? "" : message); - snprintf( - line, - sizeof line, - "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\"," - "\"generation\":%lu,\"message\":\"%s\"}", - phase == NULL ? "unknown" : phase, - (unsigned long long)hash, - (unsigned long)runtime_state.generation, - escaped - ); - devserver_send_ctrl(line, strlen(line)); + pocket_devserver_report_install(phase, hash, message); } void devserver_report_log(const char *level, const char *message) { - char escaped[448] = {0}; - char line[560]; - json_escape(escaped, sizeof escaped, message == NULL ? "" : message); - snprintf( - line, - sizeof line, - "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", - level == NULL ? "info" : level, - escaped - ); - devserver_send_ctrl(line, strlen(line)); + pocket_devserver_report_log(level, message); } void devserver_set_runtime( @@ -454,6 +259,7 @@ void devserver_set_runtime( variant_hash = package == NULL ? 0 : package->guest.variant_hash; runtime_frame = frame; snprintf(runtime_phase, sizeof runtime_phase, "%s", phase == NULL ? "unknown" : phase); + pocket_devserver_set_state(runtime_state.generation, runtime_state.active_hash); } void devserver_set_frame_stats( @@ -469,6 +275,8 @@ void devserver_set_frame_stats( } const char *devserver_debug_stats(void) { + PocketDevServerCounters counters; + pocket_devserver_counters(&counters); snprintf( stats_json, sizeof stats_json, @@ -488,14 +296,14 @@ const char *devserver_debug_stats(void) { (unsigned long)frame_vertices, (unsigned long)frame_dropped_vertices, devserver_connected() ? "true" : "false", - (unsigned long long)rx_bytes, - (unsigned long long)tx_bytes, - (unsigned long)connects, - (unsigned long)auth_failures, - (unsigned long)timeouts, - (unsigned long)discoveries, - (unsigned long)uploads, - (unsigned long)screenshots + (unsigned long long)counters.rx_bytes, + (unsigned long long)counters.tx_bytes, + (unsigned long)counters.connects, + (unsigned long)counters.auth_failures, + (unsigned long)counters.timeouts, + (unsigned long)counters.discoveries, + (unsigned long)counters.uploads, + (unsigned long)counters.screenshots ); return stats_json; } @@ -509,22 +317,14 @@ static void accept_client(void) { return; } client_fd = fd; - authenticated = false; - handshake_pending = false; - handshake_accepted = false; - handshake_ack_offset = 0; - rx_length = 0; - ctrl_input_length = 0; - tx_length = 0; - tx_offset = 0; - pong_pending = false; - client_last_rx_ms = osGetTime(); + pocket_devserver_client_open(); } static void poll_discovery(void) { if (discovery_fd < 0) return; for (uint32_t attempt = 0; attempt < 4; attempt += 1) { uint8_t request[POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES]; + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; struct sockaddr_in sender; socklen_t sender_length = sizeof sender; ssize_t length = recvfrom( @@ -535,334 +335,45 @@ static void poll_discovery(void) { (struct sockaddr *)&sender, &sender_length ); - if (length < 0 && would_block()) return; if (length <= 0) return; - if (!pocket_runtime_is_discovery_request(request, (size_t)length)) continue; - - uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; - pocket_runtime_encode_discovery_reply( - reply, - POCKETJS_HOST_ABI, - POCKET_RUNTIME_WIRE_PORT, - devserver_connected() ? 1u : 0u, - runtime_state.generation, - runtime_state.active_hash, - device_id, - POCKETJS_TARGET_ID, - "PocketJS 3DS" - ); - if (sendto( - discovery_fd, - reply, - sizeof reply, - 0, - (struct sockaddr *)&sender, - sender_length - ) == (ssize_t)sizeof reply) { - discoveries += 1; - } - } -} - -static bool append_ctrl_input(const uint8_t *bytes, size_t length) { - if (length == 0 || length + 1 > sizeof ctrl_input - ctrl_input_length) return false; - memcpy(ctrl_input + ctrl_input_length, bytes, length); - ctrl_input_length += length; - ctrl_input[ctrl_input_length++] = '\n'; - return true; -} - -static void abort_upload(const char *message) { - uint64_t rejected = upload_hash; - close_upload(); - upload_ready = false; - devserver_report_install("transfer-error", rejected, message); -} - -static void handle_package_begin(const uint8_t *payload, size_t length) { - PocketRuntimePackageBegin begin; - if (!pocket_runtime_parse_package_begin(payload, length, &begin)) { - abort_upload("invalid package begin frame"); - return; - } - if (!packages_allowed) { - devserver_report_install("rejected", begin.footer_hash, "This native host does not accept .pocket guest packages"); - return; - } - close_upload(); - upload_ready = false; - upload_file = fopen(POCKET_RUNTIME_UPLOAD, "wb"); - if (upload_file == NULL) { - devserver_report_install("transfer-error", begin.footer_hash, "open network staging file failed"); - return; - } - upload_expected = begin.length; - upload_received = 0; - upload_hash = begin.footer_hash; - devserver_report_install("receiving", upload_hash, "binary package transfer started"); -} - -void devserver_allow_packages(bool allowed) { - packages_allowed = allowed; - if (!allowed && (upload_file != NULL || upload_ready)) - abort_upload("guest package admission disabled by host"); -} - -static void handle_package_chunk(const uint8_t *payload, size_t length) { - if (upload_file == NULL || length <= 4) { - abort_upload("package chunk arrived without an active transfer"); - return; - } - uint32_t offset = pocket_runtime_read_u32(payload); - size_t bytes = length - 4; - if (upload_received > upload_expected || offset != upload_received || - bytes > upload_expected - upload_received || - fwrite(payload + 4, 1, bytes, upload_file) != bytes) { - abort_upload("package chunk offset, length, or SD write failed"); - return; - } - upload_received += (uint32_t)bytes; -} - -static void handle_package_commit(void) { - if (upload_file == NULL || upload_received != upload_expected) { - abort_upload("package commit arrived before every declared byte"); - return; - } - bool written = fflush(upload_file) == 0 && fsync(fileno(upload_file)) == 0; - if (fclose(upload_file) != 0) written = false; - upload_file = NULL; - if (!written) { - abort_upload("flush network package staging file failed"); - return; - } - upload_ready = true; - uploads += 1; - devserver_report_install("received", upload_hash, "binary package transfer complete"); -} - -static void handle_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { - if (flags != 0) { - disconnect_client(); - return; - } - switch (type) { - case POCKET_RUNTIME_MSG_PING: - if (length == sizeof pong_payload) { - memcpy(pong_payload, payload, sizeof pong_payload); - pong_pending = true; - } - break; - case POCKET_RUNTIME_MSG_CTRL: - if (length <= POCKET_RUNTIME_MAX_CTRL_BYTES && - memchr(payload, '\n', length) == NULL && - memchr(payload, '\r', length) == NULL && - append_ctrl_input(payload, length)) break; - disconnect_client(); - break; - case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: - handle_package_begin(payload, length); - break; - case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: - handle_package_chunk(payload, length); - break; - case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: - if (length == 0) handle_package_commit(); - else abort_upload("package commit payload must be empty"); - break; - case POCKET_RUNTIME_MSG_PACKAGE_ABORT: - abort_upload("host aborted package transfer"); - break; - case POCKET_RUNTIME_MSG_STATUS_REQUEST: - if (length == 0) send_status(); - break; - default: - /* Unknown length-framed messages are skipped for forward compatibility. */ - break; + if (!pocket_devserver_discovery(request, (size_t)length, reply)) continue; + sendto(discovery_fd, reply, sizeof reply, 0, (struct sockaddr *)&sender, sender_length); } } static void receive_client(void) { - if (client_fd < 0) return; - if (handshake_pending) return; - while (rx_length < sizeof rx_buffer) { - ssize_t read = recv(client_fd, rx_buffer + rx_length, sizeof rx_buffer - rx_length, 0); - if (read > 0) { - rx_length += (size_t)read; - rx_bytes += (uint64_t)read; - client_last_rx_ms = osGetTime(); - continue; - } - if (read == 0) { - disconnect_client(); + size_t budget = IO_BUDGET; + while (client_fd >= 0 && budget > 0 && !pocket_devserver_upload_pending()) { + uint8_t *room = NULL; + size_t capacity = pocket_devserver_rx_room(&room); + if (capacity == 0) { + close_client(); return; } - if (would_block()) break; - disconnect_client(); - return; - } - - if (!authenticated) { - if (rx_length < POCKET_RUNTIME_HELLO_BYTES) return; - bool accepted = pocket_runtime_verify_hello( - rx_buffer, - POCKET_RUNTIME_HELLO_BYTES, - pairing_token - ); - pocket_runtime_encode_ack( - handshake_ack, - accepted ? 0 : 2, - POCKETJS_HOST_ABI, - runtime_state.generation, - initialized ? 1u : 0u, - runtime_state.active_hash - ); - memmove(rx_buffer, rx_buffer + POCKET_RUNTIME_HELLO_BYTES, rx_length - POCKET_RUNTIME_HELLO_BYTES); - rx_length -= POCKET_RUNTIME_HELLO_BYTES; - handshake_pending = true; - handshake_accepted = accepted; - handshake_ack_offset = 0; - if (!accepted) auth_failures += 1; - return; - } - - while (authenticated && rx_length >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { - PocketRuntimeFrameHeader header; - if (!pocket_runtime_parse_frame_header(rx_buffer, rx_length, &header)) { - disconnect_client(); - return; - } - size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + (size_t)header.length; - if (rx_length < total) break; - handle_frame( - header.type, - header.flags, - rx_buffer + POCKET_RUNTIME_FRAME_HEADER_BYTES, - header.length - ); - if (client_fd < 0) return; - memmove(rx_buffer, rx_buffer + total, rx_length - total); - rx_length -= total; - } - if (rx_length == sizeof rx_buffer) disconnect_client(); -} - -static void queue_screenshot_frame(void) { - if (!screenshot_ready || tx_length != tx_offset) return; - if (screenshot_stage == 0) { - uint8_t begin[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES]; - pocket_runtime_encode_screenshot_begin( - begin, - screenshot_frame, - screenshot_top_width, - screenshot_top_height, - screenshot_auxiliary_width, - screenshot_auxiliary_height, - screenshot_top_bytes, - screenshot_auxiliary_bytes - ); - if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN, 0, begin, sizeof begin)) { - screenshot_stage = 1; - } - return; - } - if (screenshot_stage == 1) { - uint8_t *surface = screenshot_surface == 0 ? screenshot_top : screenshot_auxiliary; - uint32_t bytes = screenshot_surface == 0 ? screenshot_top_bytes : screenshot_auxiliary_bytes; - if (screenshot_offset < bytes) { - uint32_t amount = bytes - screenshot_offset; - if (amount > SCREENSHOT_CHUNK_BYTES) amount = SCREENSHOT_CHUNK_BYTES; - uint8_t payload[4 + SCREENSHOT_CHUNK_BYTES]; - pocket_runtime_write_u32(payload, screenshot_offset); - memcpy(payload + 4, surface + screenshot_offset, amount); - if (queue_frame( - POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK, - screenshot_surface, - payload, - 4 + amount - )) { - screenshot_offset += amount; - } - return; - } - if (screenshot_surface == 0) { - screenshot_surface = 1; - screenshot_offset = 0; - return; - } - screenshot_stage = 2; - } - if (screenshot_stage == 2) { - uint8_t end[4]; - pocket_runtime_write_u32(end, screenshot_frame); - if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_END, 0, end, sizeof end)) { - screenshot_stage = 3; + if (capacity > budget) capacity = budget; + ssize_t read = recv(client_fd, room, capacity, 0); + if (read > 0) { + budget -= (size_t)read; + if (!pocket_devserver_rx_commit((size_t)read)) close_client(); + continue; } + if (read == 0 || !would_block()) close_client(); return; } - if (screenshot_stage == 3) { - screenshots += 1; - devserver_screenshot_cancel(); - } } static void send_client(void) { - if (client_fd < 0) return; - if (handshake_pending) { - ssize_t sent = send( - client_fd, - handshake_ack + handshake_ack_offset, - sizeof handshake_ack - handshake_ack_offset, - 0 - ); - if (sent > 0) { - handshake_ack_offset += (size_t)sent; - tx_bytes += (uint64_t)sent; - if (handshake_ack_offset == sizeof handshake_ack) { - bool accepted = handshake_accepted; - handshake_pending = false; - handshake_ack_offset = 0; - if (!accepted) { - disconnect_client(); - return; - } - authenticated = true; - connects += 1; - if (hello_cache_length > 0) { - queue_frame( - POCKET_RUNTIME_MSG_CTRL, - 0, - (const uint8_t *)hello_cache, - hello_cache_length - ); - } - send_status(); - } - return; - } - if (sent < 0 && would_block()) return; - disconnect_client(); - return; - } - if (!devserver_connected()) return; - if (pong_pending && tx_length == tx_offset && - queue_frame(POCKET_RUNTIME_MSG_PONG, 0, pong_payload, sizeof pong_payload)) { - pong_pending = false; - } - queue_screenshot_frame(); - if (tx_offset >= tx_length) return; - ssize_t sent = send(client_fd, tx_buffer + tx_offset, tx_length - tx_offset, 0); + const uint8_t *bytes = NULL; + size_t length = pocket_devserver_tx_pending(&bytes); + if (client_fd < 0 || length == 0) return; + if (length > IO_BUDGET) length = IO_BUDGET; + ssize_t sent = send(client_fd, bytes, length, 0); if (sent > 0) { - tx_offset += (size_t)sent; - tx_bytes += (uint64_t)sent; - if (tx_offset == tx_length) { - tx_offset = 0; - tx_length = 0; - } + pocket_devserver_tx_consumed((size_t)sent); return; } if (sent < 0 && would_block()) return; - disconnect_client(); + close_client(); } void devserver_poll(void) { @@ -870,48 +381,20 @@ void devserver_poll(void) { poll_discovery(); accept_client(); receive_client(); - if (client_fd >= 0 && osGetTime() - client_last_rx_ms > 15u * 1000u) { - timeouts += 1; - disconnect_client(); - return; - } send_client(); -} - -size_t devserver_recv_ctrl(char *out, size_t capacity) { - if (out == NULL || capacity <= 1 || ctrl_input_length == 0) return 0; - size_t length = ctrl_input_length < capacity - 1 ? ctrl_input_length : capacity - 1; - /* Every accepted control frame has a synthetic newline. Never hand QuickJS - * a partial JSON record when several queued frames approach its poll cap. */ - while (length > 0 && ctrl_input[length - 1] != '\n') length -= 1; - if (length == 0) return 0; - memcpy(out, ctrl_input, length); - out[length] = '\0'; - memmove(ctrl_input, ctrl_input + length, ctrl_input_length - length); - ctrl_input_length -= length; - return length; + if (client_fd >= 0 && pocket_devserver_client_closing()) close_client(); } bool devserver_take_upload(uint64_t *declared_hash) { - if (!upload_ready) return false; - upload_ready = false; - if (declared_hash != NULL) *declared_hash = upload_hash; - upload_expected = 0; - upload_received = 0; - upload_hash = 0; - return true; + return pocket_devserver_take_upload(declared_hash); } bool devserver_request_screenshot(void) { - if (!devserver_connected() || screenshot_requested || screenshot_ready) return false; - screenshot_requested = true; - return true; + return pocket_devserver_request_screenshot(); } bool devserver_take_screenshot_request(void) { - if (!screenshot_requested) return false; - screenshot_requested = false; - return true; + return pocket_devserver_take_screenshot_request(); } bool devserver_screenshot_begin( @@ -923,31 +406,21 @@ bool devserver_screenshot_begin( uint8_t **top, uint8_t **auxiliary ) { - if (top == NULL || auxiliary == NULL || screenshot_ready || screenshot_top != NULL) return false; - uint32_t top_bytes = (uint32_t)top_width * top_height * 3u; - uint32_t auxiliary_bytes = (uint32_t)auxiliary_width * auxiliary_height * 3u; - screenshot_top = linearAlloc(top_bytes); - screenshot_auxiliary = linearAlloc(auxiliary_bytes); - if (screenshot_top == NULL || screenshot_auxiliary == NULL) { - devserver_screenshot_cancel(); - return false; - } - screenshot_frame = frame; - screenshot_top_width = top_width; - screenshot_top_height = top_height; - screenshot_auxiliary_width = auxiliary_width; - screenshot_auxiliary_height = auxiliary_height; - screenshot_top_bytes = top_bytes; - screenshot_auxiliary_bytes = auxiliary_bytes; - *top = screenshot_top; - *auxiliary = screenshot_auxiliary; - return true; + return pocket_devserver_screenshot_begin( + frame, + top_width, + top_height, + auxiliary_width, + auxiliary_height, + top, + auxiliary + ); } void devserver_screenshot_ready(void) { - if (screenshot_top == NULL || screenshot_auxiliary == NULL) return; - screenshot_ready = true; - screenshot_stage = 0; - screenshot_surface = 0; - screenshot_offset = 0; + pocket_devserver_screenshot_ready(); +} + +void devserver_screenshot_cancel(void) { + pocket_devserver_screenshot_cancel(); } diff --git a/hosts/ios-legacy/runtime.c b/hosts/ios-legacy/runtime.c index 3df43b58f..f61427d96 100644 --- a/hosts/ios-legacy/runtime.c +++ b/hosts/ios-legacy/runtime.c @@ -1,4 +1,15 @@ #include "pocket_runtime.h" +#ifdef POCKET_DEV_RUNTIME +#include "guest_runtime.h" +/* Generated per build by tools/target-contract.ts from the target registry + * and the verified plan: the contract every uploaded plan must match. */ +#include "pocket_target_contract.h" +#ifndef POCKET_DEV_RUNTIME_LABEL +#error "POCKET_DEV_RUNTIME_LABEL must name the shell for discovery" +#endif +static int g_dev_started; +static int g_dev_suspended; +#endif /* The svc transport's state, for the acceptance record: "absent" on builds * without the network channel, else discover / connecting / hello / up / * up-usb / backoff (svcwire.c). */ @@ -10,9 +21,11 @@ #endif #include +#include #include #include #include +#include #include #include #include @@ -348,8 +361,16 @@ static unsigned long now_us(void) { } /* Best-effort, device-local proof fetched through the scoped USB SSH helper. */ +/* Resident set of this process, for leak checks across guest swaps. */ +static unsigned long resident_kilobytes(void) { + struct task_basic_info info; + mach_msg_type_number_t count = TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) return 0; + return (unsigned long)(info.resident_size / 1024); +} + static void write_acceptance_record(void) { - char record[896]; + char record[1024]; unsigned long next_heartbeat = g_status_heartbeat + 1; time_t written_at = time(NULL); const char *state = g_state == POCKET_STATE_RUNNING @@ -368,7 +389,7 @@ static void write_acceptance_record(void) { "frame_us=%lu\nsubmit_us=%lu\npresent_us=%lu\n" "window_frames=%lu\nwindow_us=%lu\nblit_us=%lu\n" "damage_attempts=%lu\ndamage_failures=%lu\ndamage_full_redraws=%lu\n" - "damage_pixels=%lu\ncomposites=%lu\ndamage_regions_last=%lu\nsvc=%s\nerror=%s\n", + "damage_pixels=%lu\ncomposites=%lu\ndamage_regions_last=%lu\nresident_kb=%lu\nsvc=%s\nerror=%s\n", POCKET_BUILD_ID, state, (long)getpid(), @@ -401,6 +422,7 @@ static void write_acceptance_record(void) { pocket_runtime_damage_pixels(), g_composites, g_damage_regions_last, + resident_kilobytes(), POCKET_SVC_STATE_NAME(), g_state == POCKET_STATE_FAILED ? g_status_message : "" ); @@ -645,7 +667,11 @@ static void stop_timer(void) { static void fail_runtime(const char *message) { copy_status_message(message); g_state = POCKET_STATE_FAILED; +#ifdef POCKET_DEV_RUNTIME + pocket_dev_runtime_failed(g_status_message); +#else stop_timer(); +#endif pocket_runtime_shutdown(); g_framebuffer = NULL; g_framebuffer_width = 0; @@ -1012,6 +1038,12 @@ static void teardown_gl(void) { g_gl_renderbuffer = 0; } g_gl_ready = 0; +#ifdef POCKET_DEV_RUNTIME + if (g_gl_context != NULL) { + send_bool_class_object(objc_getClass("EAGLContext"), "setCurrentContext:", NULL); + send_void(g_gl_context, "release"); + } +#endif g_gl_context = NULL; g_gl_width = 0; g_gl_height = 0; @@ -1042,6 +1074,9 @@ static int setup_gl(id view) { g_gl_context = send_id_int(send_id((id)eagl, "alloc"), "initWithAPI:", 1); if (g_gl_context == NULL) return 0; if (!send_bool_class_object(eagl, "setCurrentContext:", g_gl_context)) { +#ifdef POCKET_DEV_RUNTIME + send_void(g_gl_context, "release"); +#endif g_gl_context = NULL; return 0; } @@ -1181,12 +1216,8 @@ static int present_gl(unsigned long *submitted_us) { ) ? 1 : 0; } -static int boot_embedded_runtime(void) { - size_t java_script_length = 0; - size_t pack_length = 0; - const uint8_t *java_script = getsectdata("__DATA", "__pocket_js", &java_script_length); - const uint8_t *pack = getsectdata("__DATA", "__pocket_pak", &pack_length); - +static int boot_runtime_bytes(const uint8_t *java_script, size_t java_script_length, + const uint8_t *pack, size_t pack_length) { /* The packager adds a C terminator for diagnostics; JS_Eval wants byte length. */ if (java_script != NULL && java_script_length > 0 && java_script[java_script_length - 1] == 0) { java_script_length -= 1; @@ -1234,6 +1265,53 @@ static int boot_embedded_runtime(void) { return 1; } +#ifdef POCKET_DEV_RUNTIME +static void dev_stop_guest(void) { + if (g_gl_ready) glFinish(); + teardown_gl(); + pocket_runtime_shutdown(); + g_framebuffer = NULL; + /* UITouch identities from the old tree must not address new node handles. */ + memset(g_touch_slots, 0, sizeof g_touch_slots); + g_touch_awaiting_completion = 0; +} +static int dev_boot_guest(const PocketGuestPackage *guest) { + g_guest_frames = g_last_record_attempt_frame = g_observed_action_sequence = 0; + g_window_start_frame = g_window_start_us = g_window_frames = g_window_us = 0; + g_frame_us_total = g_present_us_total = g_submit_us_total = g_timed_frames = 0; + g_state = POCKET_STATE_STARTING; + return boot_runtime_bytes(guest->javascript, guest->javascript_length, guest->pak, guest->pak_length); +} +static int dev_validate_plan(const uint8_t *plan, size_t length) { + return pocket_package_validate_plan(plan, length, &POCKET_TARGET_CONTRACT) == 0; +} +static const char *dev_guest_error(void) { return g_status_message; } +#endif + +static int boot_embedded_runtime(void) { + size_t java_script_length = 0, pack_length = 0; + const uint8_t *java_script = getsectdata("__DATA", "__pocket_js", &java_script_length); + const uint8_t *pack = getsectdata("__DATA", "__pocket_pak", &pack_length); +#ifdef POCKET_DEV_RUNTIME + PocketGuestPackage recovery = {0}; + recovery.javascript = java_script; + recovery.javascript_length = java_script_length; + recovery.pak = pack; + recovery.pak_length = pack_length; + const PocketDevHost callbacks = { + dev_boot_guest, dev_stop_guest, dev_validate_plan, dev_guest_error, POCKET_DEV_RUNTIME_LABEL, + }; + g_dev_started = 1; + if (!pocket_dev_runtime_init(POCKET_DEV_RUNTIME_ROOT, &callbacks, &recovery, POCKET_RUNTIME_WIRE_PORT)) { + fail_runtime("Cannot initialize Pocket Runtime storage"); + return 0; + } + return pocket_dev_runtime_running(); +#else + return boot_runtime_bytes(java_script, java_script_length, pack, pack_length); +#endif +} + /* * Ask UIKit to recomposite only the rectangle the core says changed. * @@ -1286,11 +1364,18 @@ static void pocket_tick(id self, SEL command, id timer) { (void)command; (void)timer; +#ifdef POCKET_DEV_RUNTIME + if (g_dev_suspended) return; + if (!g_dev_started) boot_embedded_runtime(); + pocket_dev_runtime_pump(); + if (!pocket_dev_runtime_running()) return; +#else if (g_state == POCKET_STATE_STARTING) { if (!boot_embedded_runtime()) { return; } } +#endif if (g_state != POCKET_STATE_RUNNING) { return; } @@ -1381,6 +1466,10 @@ static void pocket_tick(id self, SEL command, id timer) { */ capture_software_frame_if_requested(); } +#ifdef POCKET_DEV_RUNTIME + if (g_guest_frames == 1 && g_gl_ready) glFinish(); + pocket_dev_runtime_presented(); +#endif finished_us = now_us(); if (finished_us >= frame_started_us) { g_frame_us_total += present_started_us - frame_started_us; @@ -1845,6 +1934,9 @@ static void terminate_application(void) { g_state = POCKET_STATE_TERMINATED; copy_status_message("Application terminated"); write_acceptance_record(); +#ifdef POCKET_DEV_RUNTIME + pocket_dev_runtime_shutdown(); +#endif teardown_gl(); pocket_runtime_shutdown(); g_framebuffer = NULL; @@ -1951,6 +2043,26 @@ static Class register_view_class(void) { return cls; } +#ifdef POCKET_DEV_RUNTIME +static void dev_resign_active(id self, SEL command, id application) { + (void)self; (void)command; (void)application; + /* Registered for both applicationWillResignActive: and + * applicationDidEnterBackground:. GL work is allowed only in the first, + * while the app is still in the foreground; a GL call after entering the + * background is a reason for iOS to terminate the process. */ + if (!g_dev_suspended && g_gl_ready) glFinish(); + g_dev_suspended = 1; + memset(g_touch_slots, 0, sizeof g_touch_slots); + g_touch_awaiting_completion = 0; + pocket_devwire_suspend(1); +} +static void dev_become_active(id self, SEL command, id application) { + (void)self; (void)command; (void)application; + g_dev_suspended = 0; + pocket_devwire_suspend(0); +} +#endif + static Class register_delegate_class(void) { Class cls = objc_allocateClassPair(objc_getClass("NSObject"), "PocketJSRuntimeDelegate", 0); BOOL methods_added; @@ -1984,6 +2096,11 @@ static Class register_delegate_class(void) { if (!methods_added) { return NULL; } +#ifdef POCKET_DEV_RUNTIME + if (!class_addMethod(cls, sel_registerName("applicationWillResignActive:"), (void (*)(void))dev_resign_active, "v@:@") || + !class_addMethod(cls, sel_registerName("applicationDidEnterBackground:"), (void (*)(void))dev_resign_active, "v@:@") || + !class_addMethod(cls, sel_registerName("applicationDidBecomeActive:"), (void (*)(void))dev_become_active, "v@:@")) return NULL; +#endif objc_registerClassPair(cls); return cls; } diff --git a/hosts/ipodtouch4/README.md b/hosts/ipodtouch4/README.md index 697497245..2c809736f 100644 --- a/hosts/ipodtouch4/README.md +++ b/hosts/ipodtouch4/README.md @@ -23,3 +23,11 @@ data; uninstall removes the container and its runtime receipt files. Use `bun ipodtouch4 doctor`, then the build, deploy, launch, status, capture, and uninstall commands documented in `docs/IPODTOUCH4.md`. + +`bun ipodtouch4:runtime deploy` builds a separate `PocketRuntime.app`. After +`pair` and `launch`, `push` replaces the guest with a validated `.pocket`, and +`dev` watches source changes and bridges DevTools. USB uses the pinned SSH +forwarder; `--lan` uses paired discovery and the shared 3DS Runtime protocol. +The native shell confirms an update after its first GLES presentation and +retains a previous accepted package for recovery. See the persistent Runtime +section of `docs/IPODTOUCH4.md` for commands and acceptance boundaries. diff --git a/hosts/ipodtouch4/runtime.c b/hosts/ipodtouch4/runtime.c index 1e38e301d..7b360d70e 100644 --- a/hosts/ipodtouch4/runtime.c +++ b/hosts/ipodtouch4/runtime.c @@ -3,6 +3,7 @@ /* Every User app owns its tmp directory. Resolve it at runtime because iOS * chooses a container UUID at installation and may change it on update. */ extern void *NSTemporaryDirectory(void); +extern void *NSHomeDirectory(void); extern void *sel_registerName(const char *name); extern void *objc_msgSend(void); static const char *pocket_ipod_receipt_path(unsigned index, const char *suffix) { @@ -22,5 +23,17 @@ static const char *pocket_ipod_receipt_path(unsigned index, const char *suffix) #define POCKET_GL_DEFAULT 1 #define POCKET_REQUIRE_GL 1 +#ifdef POCKET_DEV_RUNTIME +static const char *pocket_ipod_runtime_root(void) { + static char path[1024]; + const char *home = ((const char *(*)(void *, void *))objc_msgSend)( + NSHomeDirectory(), sel_registerName("UTF8String")); + snprintf(path, sizeof path, "%s/Library/PocketRuntime", home); + return path; +} +#define POCKET_DEV_RUNTIME_ROOT pocket_ipod_runtime_root() +#define POCKET_DEV_RUNTIME_LABEL "PocketJS iPod4" +#endif + /* The iPod touch 4 shares the iPhone 4S legacy UIKit implementation. */ #include "../ios-legacy/runtime.c" diff --git a/package.json b/package.json index c10781911..c396e6cfb 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "iphone4s": "bun tools/iphone4s.ts", "ipodtouch": "bun tools/ipodtouch.ts", "ipodtouch4": "bun tools/ipodtouch4.ts", + "ipodtouch4:runtime": "bun tools/ipodtouch4-runtime.ts", "meizu-m8": "bun tools/meizu-m8.ts", "blackberry-android": "bun tools/blackberry-android.ts", "blackberry-qnx": "bun tools/blackberry-qnx.ts", diff --git a/tests/3ds-runtime-wire.test.ts b/tests/3ds-runtime-wire.test.ts index 147349a9d..8ef45fa51 100644 --- a/tests/3ds-runtime-wire.test.ts +++ b/tests/3ds-runtime-wire.test.ts @@ -33,6 +33,7 @@ import { combinePocketRuntimeScreens, decodePocketRuntimeSurface, discoverPocketRuntimes, + render3dsScreenshotPng, } from "../tools/3ds-runtime-client.ts"; const ROOT = new URL("..", import.meta.url).pathname; @@ -44,7 +45,7 @@ afterEach(() => { describe("Nintendo 3DS Pocket Runtime wire", () => { test("keeps TypeScript and C protocol constants byte-exact", () => { - const header = readFileSync(join(ROOT, "hosts/3ds/src/dev_protocol.h"), "utf8"); + const header = readFileSync(join(ROOT, "engine/runtime/dev_protocol.h"), "utf8"); expect(header).toContain("#define POCKET_RUNTIME_WIRE_MAGIC 0x54524b50u"); expect(header).toContain("#define POCKET_RUNTIME_DISCOVERY_MAGIC 0x44524b50u"); expect(header).toContain("#define POCKET_RUNTIME_WIRE_PORT 8131u"); @@ -343,7 +344,8 @@ describe("Nintendo 3DS Pocket Runtime wire", () => { await client.sendCtrl({ t: "screenshot" }); const image = await screenshot; expect(image.frame).toBe(77); - expect(image.png.subarray(1, 4).toString()).toBe("PNG"); + expect(image.top).toEqual(Uint8Array.of(0, 0, 255, 0, 255, 0)); + expect(render3dsScreenshotPng(image).subarray(1, 4).toString()).toBe("PNG"); } finally { client.close(); connection.peer?.destroy(); diff --git a/tests/e2e/azahar-devserver.ts b/tests/e2e/azahar-devserver.ts index 529e22849..0b07e1178 100644 --- a/tests/e2e/azahar-devserver.ts +++ b/tests/e2e/azahar-devserver.ts @@ -25,6 +25,7 @@ import { import { PocketRuntimeClient, discoverPocketRuntimes, + render3dsScreenshotPng, type DiscoveredPocketRuntime, type PocketRuntimeScreenshot, } from "../../tools/3ds-runtime-client.ts"; @@ -201,17 +202,19 @@ async function evaluate( return await result; } -function assertScreenshot(screenshot: PocketRuntimeScreenshot): void { +function assertScreenshot(screenshot: PocketRuntimeScreenshot): Buffer { if (screenshot.metadata.topWidth !== 400 || screenshot.metadata.topHeight !== 240 || screenshot.metadata.auxiliaryWidth !== 320 || screenshot.metadata.auxiliaryHeight !== 240) { throw new Error(`wrong screenshot surfaces: ${JSON.stringify(screenshot.metadata)}`); } - if (screenshot.png.subarray(1, 4).toString() !== "PNG") { + const png = render3dsScreenshotPng(screenshot); + if (png.subarray(1, 4).toString() !== "PNG") { throw new Error("combined screenshot is not a PNG"); } - if (screenshot.png.readUInt32BE(16) !== 400 || screenshot.png.readUInt32BE(20) !== 480) { + if (png.readUInt32BE(16) !== 400 || png.readUInt32BE(20) !== 480) { throw new Error("combined screenshot is not 400 x 480"); } + return png; } let client: PocketRuntimeClient | null = null; @@ -287,8 +290,7 @@ try { const screenshotPromise = client.waitForScreenshot(20_000); await client.sendCtrl({ t: "screenshot" }); const screenshot = await screenshotPromise; - assertScreenshot(screenshot); - writeFileSync(SCREENSHOT, screenshot.png); + writeFileSync(SCREENSHOT, assertScreenshot(screenshot)); console.log(`PASS captured both screens at frame ${screenshot.frame}`); const good = new Uint8Array(readFileSync(pocket)); diff --git a/tests/fixtures/3ds-devserver-host.c b/tests/fixtures/3ds-devserver-host.c new file mode 100644 index 000000000..6fdd03efe --- /dev/null +++ b/tests/fixtures/3ds-devserver-host.c @@ -0,0 +1,115 @@ +/* Host process for the Nintendo 3DS Pocket Runtime pump (hosts/3ds/src/ + * devserver.c) with libctru stubbed out (tests/fixtures/3ds-stubs): the same + * socket code that runs on the console, driven by the desktop client. The + * runtime paths are the console's literal `sdmc:` paths, resolved relative to + * the scratch directory this process starts in. */ +#include +#include +#include +#include +#include + +#include "devserver.h" + +/* soc.c owns the console's socket service; on the host there is none. */ +bool soc_ensure(char *error, size_t error_length) { + (void)error; + (void)error_length; + return true; +} +bool soc_active(void) { return true; } +void soc_shutdown(void) {} + +static volatile sig_atomic_t stopped; +static PocketRuntimeState state = {1, 0x1122334455667788ULL, 0}; +static uint32_t frame; + +static void stop_signal(int signal_number) { + (void)signal_number; + stopped = 1; +} + +static void admit_upload(void) { + uint64_t declared = 0; + if (!devserver_take_upload(&declared)) return; + FILE *file = fopen(POCKET_RUNTIME_UPLOAD, "rb"); + uint64_t footer = 0; + long size = 0; + if (file != NULL && fseek(file, 0, SEEK_END) == 0) { + size = ftell(file); + if (size >= 8 && fseek(file, size - 8, SEEK_SET) == 0) { + uint8_t bytes[8]; + if (fread(bytes, 1, 8, file) == 8) memcpy(&footer, bytes, 8); + } + } + if (file != NULL) fclose(file); + remove(POCKET_RUNTIME_UPLOAD); + if (footer != 0 && footer == declared) { + state.generation += 1; + state.active_hash = declared; + devserver_set_runtime(&state, NULL, "accepted", frame); + devserver_report_install("accepted", declared, "footer verified"); + } else { + devserver_report_install("rejected", declared, "footer mismatch"); + } +} + +static void handle_controls(void) { + char lines[4096]; + size_t length = devserver_recv_ctrl(lines, sizeof lines); + char *cursor = lines; + while (length > 0) { + char *end = strchr(cursor, '\n'); + if (end == NULL) break; + *end = '\0'; + if (strstr(cursor, "\"screenshot\"") != NULL) { + devserver_request_screenshot(); + } else { + char reply[4200]; + int written = snprintf(reply, sizeof reply, "{\"t\":\"echo\",\"length\":%lu}", (unsigned long)(end - cursor)); + if (written > 0) devserver_send_ctrl(reply, (size_t)written); + } + length -= (size_t)(end - cursor) + 1; + cursor = end + 1; + } +} + +static void serve_screenshot(void) { + if (!devserver_take_screenshot_request()) return; + uint8_t *top = NULL; + uint8_t *auxiliary = NULL; + if (!devserver_screenshot_begin(frame, 400, 240, 320, 240, &top, &auxiliary)) { + devserver_report_log("error", "screenshot: buffer allocation failed"); + return; + } + for (size_t index = 0; index < 400u * 240u * 3u; index += 1) top[index] = (uint8_t)index; + for (size_t index = 0; index < 320u * 240u * 3u; index += 1) auxiliary[index] = (uint8_t)(255 - (index & 0xff)); + devserver_screenshot_ready(); +} + +int main(int argc, char **argv) { + if (argc != 2) return 2; + if (chdir(argv[1]) != 0) return 2; + signal(SIGTERM, stop_signal); + signal(SIGINT, stop_signal); + signal(SIGPIPE, SIG_IGN); + char error[256] = {0}; + DevserverInitResult result = devserver_init(&state, error, sizeof error); + if (result != DEVSERVER_READY) { + fprintf(stderr, "devserver_init: %d %s\n", (int)result, error); + return 3; + } + devserver_set_runtime(&state, NULL, "booted", 0); + puts("3ds devserver ready"); + fflush(stdout); + while (!stopped) { + devserver_poll(); + frame += 1; + admit_upload(); + handle_controls(); + serve_screenshot(); + usleep(2000); + } + devserver_shutdown(); + return 0; +} diff --git a/tests/fixtures/3ds-stubs/3ds.h b/tests/fixtures/3ds-stubs/3ds.h new file mode 100644 index 000000000..e64f1f4f8 --- /dev/null +++ b/tests/fixtures/3ds-stubs/3ds.h @@ -0,0 +1,28 @@ +/* Host-test stand-in for <3ds.h>: the handful of libctru symbols the Pocket + * Runtime socket pump uses, so hosts/3ds/src/devserver.c runs as an ordinary + * process against the desktop client. */ +#ifndef POCKETJS_TEST_3DS_STUB_H +#define POCKETJS_TEST_3DS_STUB_H + +#include +#include +#include +#include + +typedef uint64_t u64; + +static inline u64 osGetTime(void) { + struct timeval time; + gettimeofday(&time, NULL); + return (u64)time.tv_sec * 1000u + (u64)time.tv_usec / 1000u; +} + +static inline void *linearAlloc(size_t bytes) { + return malloc(bytes); +} + +static inline void linearFree(void *bytes) { + free(bytes); +} + +#endif diff --git a/tests/fixtures/dev-server-core.c b/tests/fixtures/dev-server-core.c new file mode 100644 index 000000000..78a1f874d --- /dev/null +++ b/tests/fixtures/dev-server-core.c @@ -0,0 +1,368 @@ +/* Transcript test for the shared Pocket Runtime server (engine/runtime/ + * dev_server.c): the transport is a pair of in-memory queues and the clock a + * counter, so every rule the 3DS and POSIX pumps rely on runs here without a + * socket. Run from an empty scratch directory. */ +#include +#include +#include +#include +#include +#include + +#include "dev_server.h" + +static uint64_t clock_now = 1000; +static uint64_t fake_now(void) { return clock_now; } +static void status(char *out, size_t capacity) { + snprintf(out, capacity, "{\"t\":\"runtime.status\",\"phase\":\"test\",\"generation\":%lu}", + (unsigned long)pocket_devserver_generation()); +} +static void *shot_alloc(size_t bytes) { return malloc(bytes); } +static void shot_free(void *bytes) { free(bytes); } + +static uint8_t token[POCKET_RUNTIME_TOKEN_BYTES]; +static uint8_t out_type, out_flags; +static uint8_t out_payload[POCKET_RUNTIME_MAX_FRAME_BYTES]; +static size_t out_length; + +static int exists(const char *path) { + struct stat info; + return stat(path, &info) == 0; +} +static void write_text(const char *path, const char *text) { + FILE *file = fopen(path, "wb"); + assert(file != NULL); + fputs(text, file); + assert(fclose(file) == 0); +} +/* Pull one frame off the output queue; 0 when it is empty. */ +static int next_frame(void) { + const uint8_t *bytes = NULL; + size_t pending = pocket_devserver_tx_pending(&bytes); + if (pending == 0) return 0; + PocketRuntimeFrameHeader header; + assert(pocket_runtime_parse_frame_header(bytes, pending, &header)); + assert(pending >= POCKET_RUNTIME_FRAME_HEADER_BYTES + header.length); + out_type = header.type; + out_flags = header.flags; + out_length = header.length; + memcpy(out_payload, bytes + POCKET_RUNTIME_FRAME_HEADER_BYTES, header.length); + pocket_devserver_tx_consumed(POCKET_RUNTIME_FRAME_HEADER_BYTES + header.length); + return 1; +} +static int payload_has(const char *text) { + return out_length >= strlen(text) && memmem(out_payload, out_length, text, strlen(text)) != NULL; +} +static void expect_ctrl(const char *text) { + assert(next_frame()); + assert(out_type == POCKET_RUNTIME_MSG_CTRL); + assert(payload_has(text)); +} +static int feed(const uint8_t *bytes, size_t length) { + uint8_t *room = NULL; + size_t capacity = pocket_devserver_rx_room(&room); + assert(capacity >= length); + memcpy(room, bytes, length); + return pocket_devserver_rx_commit(length); +} +static size_t encode(uint8_t *out, uint8_t type, const void *payload, size_t length) { + pocket_runtime_encode_frame_header(out, type, 0, (uint32_t)length); + if (length > 0) memcpy(out + POCKET_RUNTIME_FRAME_HEADER_BYTES, payload, length); + return POCKET_RUNTIME_FRAME_HEADER_BYTES + length; +} +static int frame(uint8_t type, const void *payload, size_t length) { + static uint8_t buffer[POCKET_RUNTIME_FRAME_HEADER_BYTES + POCKET_RUNTIME_MAX_FRAME_BYTES]; + return feed(buffer, encode(buffer, type, payload, length)); +} +static void hello(uint8_t *out, int valid) { + memset(out, 0, POCKET_RUNTIME_HELLO_BYTES); + pocket_runtime_write_u32(out, POCKET_RUNTIME_WIRE_MAGIC); + out[4] = POCKET_RUNTIME_WIRE_VERSION; + pocket_runtime_write_u16(out + 6, POCKET_RUNTIME_TOKEN_BYTES); + memcpy(out + 8, token, sizeof token); + if (!valid) out[39] ^= 1; +} +static void expect_ack(uint8_t status_code) { + const uint8_t *bytes = NULL; + size_t pending = pocket_devserver_tx_pending(&bytes); + assert(pending >= POCKET_RUNTIME_ACK_BYTES); + assert(pocket_runtime_read_u32(bytes) == POCKET_RUNTIME_WIRE_MAGIC); + assert(bytes[5] == status_code); + assert(pocket_runtime_read_u16(bytes + 6) == 8); + assert(pocket_runtime_read_u32(bytes + 8) == 3); + assert(pocket_runtime_read_u32(bytes + 12) == POCKET_DEV_SERVER_ACK_FLAG_PACKAGES); + assert(pocket_runtime_read_u64(bytes + 16) == 0xe01adc15327d4203ULL); + pocket_devserver_tx_consumed(POCKET_RUNTIME_ACK_BYTES); +} +static void authenticate(void) { + uint8_t bytes[POCKET_RUNTIME_HELLO_BYTES]; + pocket_devserver_client_open(); + hello(bytes, 1); + assert(feed(bytes, sizeof bytes)); + expect_ack(0); + while (next_frame()) assert(out_type == POCKET_RUNTIME_MSG_CTRL); + assert(pocket_devserver_connected()); +} +static void package_bytes(uint8_t *out, size_t length, uint64_t hash) { + for (size_t index = 0; index < length; index += 1) out[index] = (uint8_t)(index * 7); + pocket_runtime_write_u64(out + length - 8, hash); +} +static void begin(uint32_t length, uint64_t hash) { + uint8_t payload[POCKET_RUNTIME_PACKAGE_BEGIN_BYTES]; + pocket_runtime_write_u32(payload, length); + pocket_runtime_write_u64(payload + 4, hash); + assert(frame(POCKET_RUNTIME_MSG_PACKAGE_BEGIN, payload, sizeof payload)); +} +static void chunk(uint32_t offset, const uint8_t *bytes, size_t length) { + static uint8_t payload[4 + POCKET_RUNTIME_MAX_FRAME_BYTES]; + pocket_runtime_write_u32(payload, offset); + memcpy(payload + 4, bytes, length); + assert(frame(POCKET_RUNTIME_MSG_PACKAGE_CHUNK, payload, 4 + length)); +} +static void upload(const uint8_t *bytes, size_t length, uint64_t hash) { + begin((uint32_t)length, hash); + expect_ctrl("\"phase\":\"receiving\""); + chunk(0, bytes, 60); + chunk(60, bytes + 60, length - 60); + assert(frame(POCKET_RUNTIME_MSG_PACKAGE_COMMIT, NULL, 0)); + expect_ctrl("\"phase\":\"received\""); + assert(pocket_devserver_upload_pending()); +} + +int main(int argc, char **argv) { + assert(argc == 2); + assert(chdir(argv[1]) == 0); + for (uint32_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) token[index] = (uint8_t)index; + char error[128] = {0}; + + /* Pairing keys. */ + assert(pocket_devserver_load_key("missing.key", error, sizeof error) == POCKET_DEV_SERVER_KEY_DISABLED); + write_text("bad.key", "not a key\n"); + assert(pocket_devserver_load_key("bad.key", error, sizeof error) == POCKET_DEV_SERVER_KEY_ERROR); + assert(strstr(error, "64 hexadecimal") != NULL); + write_text("nonhex.key", "zz0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + assert(pocket_devserver_load_key("nonhex.key", error, sizeof error) == POCKET_DEV_SERVER_KEY_ERROR); + assert(!pocket_devserver_paired()); + write_text("dev.key", "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\n"); + assert(pocket_devserver_load_key("dev.key", error, sizeof error) == POCKET_DEV_SERVER_KEY_READY); + assert(pocket_devserver_paired()); + assert(pocket_devserver_device_id() == 0xe6cb594c1a148ac5ULL); + + const PocketDevServerConfig config = { + "test-dev", "PocketJS Test", 8, 8131, "upload.tmp", fake_now, status, shot_alloc, shot_free, + }; + const PocketDevServerConfig incomplete = { + "test-dev", "PocketJS Test", 8, 0, "upload.tmp", fake_now, status, shot_alloc, shot_free, + }; + assert(!pocket_devserver_configure(&incomplete)); + const PocketDevServerConfig long_label = { + "test-dev", "PocketJS Sixteen", 8, 8131, "upload.tmp", fake_now, status, shot_alloc, shot_free, + }; + assert(!pocket_devserver_configure(&long_label)); + assert(pocket_devserver_configure(&config)); + pocket_devserver_set_state(3, 0xe01adc15327d4203ULL); + + /* Discovery answers only real requests, with the configured identity. */ + uint8_t request[POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES] = {0}; + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; + pocket_runtime_write_u32(request, POCKET_RUNTIME_DISCOVERY_MAGIC); + request[4] = POCKET_RUNTIME_WIRE_VERSION; + request[5] = POCKET_RUNTIME_DISCOVERY_REQUEST; + assert(!pocket_devserver_discovery(request, 7, reply)); + assert(pocket_devserver_discovery(request, sizeof request, reply)); + assert(pocket_runtime_read_u16(reply + 6) == 8 && pocket_runtime_read_u16(reply + 8) == 8131); + assert(pocket_runtime_read_u16(reply + 10) == 0); + assert(pocket_runtime_read_u32(reply + 12) == 3); + assert(pocket_runtime_read_u64(reply + 16) == 0xe01adc15327d4203ULL); + assert(pocket_runtime_read_u64(reply + 24) == 0xe6cb594c1a148ac5ULL); + assert(strcmp((const char *)reply + 32, "test-dev") == 0); + assert(strcmp((const char *)reply + 48, "PocketJS Test") == 0); + + /* A rejected hello still receives its ack, then the client is closed. */ + uint8_t bytes[POCKET_RUNTIME_HELLO_BYTES + 64]; + pocket_devserver_client_open(); + hello(bytes, 0); + assert(feed(bytes, POCKET_RUNTIME_HELLO_BYTES)); + assert(!pocket_devserver_client_closing()); + expect_ack(2); + assert(pocket_devserver_client_closing()); + assert(!pocket_devserver_connected()); + pocket_devserver_client_close(); + + /* A coalesced hello and status request: ack, then status twice (connect + * receipt and the request's answer). */ + pocket_devserver_client_open(); + hello(bytes, 1); + size_t length = POCKET_RUNTIME_HELLO_BYTES + encode(bytes + POCKET_RUNTIME_HELLO_BYTES, POCKET_RUNTIME_MSG_STATUS_REQUEST, NULL, 0); + assert(feed(bytes, length)); + expect_ack(0); + expect_ctrl("\"t\":\"runtime.status\""); + expect_ctrl("\"generation\":3"); + assert(!next_frame()); + assert(pocket_devserver_connected()); + assert(pocket_devserver_discovery(request, sizeof request, reply) && pocket_runtime_read_u16(reply + 10) == 1); + + /* Unknown frame types and wrong-sized pings are skipped; PING echoes. */ + assert(frame(0x7f, "abc", 3)); + assert(pocket_devserver_connected() && !next_frame()); + assert(frame(POCKET_RUNTIME_MSG_PING, "\x01\x02", 2)); + assert(!next_frame()); + assert(frame(POCKET_RUNTIME_MSG_PING, "\x01\x02\x03\x04", 4)); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_PONG && out_length == 4 && memcmp(out_payload, "\x01\x02\x03\x04", 4) == 0); + assert(frame(POCKET_RUNTIME_MSG_STATUS_REQUEST, "x", 1)); + assert(!next_frame()); + + /* Control records arrive as whole lines, never split. */ + char line[128]; + assert(frame(POCKET_RUNTIME_MSG_CTRL, "{\"t\":\"getTree\"}", 15)); + assert(pocket_devserver_recv_ctrl(line, 8) == 0); + assert(pocket_devserver_recv_ctrl(line, sizeof line) == 16); + assert(strcmp(line, "{\"t\":\"getTree\"}\n") == 0); + assert(frame(POCKET_RUNTIME_MSG_CTRL, "{\"t\":\"a\"}", 9)); + assert(frame(POCKET_RUNTIME_MSG_CTRL, "{\"t\":\"b\"}", 9)); + assert(pocket_devserver_recv_ctrl(line, 12) == 10); + assert(pocket_devserver_recv_ctrl(line, sizeof line) == 10 && strcmp(line, "{\"t\":\"b\"}\n") == 0); + + /* Guest output: hello caching, a dropped-record notice, escaped logs. */ + pocket_devserver_send_ctrl("{\"t\":\"hello\",\"v\":1}", 19); + expect_ctrl("\"t\":\"hello\""); + static char big[POCKET_RUNTIME_MAX_FRAME_BYTES + 1]; + memset(big, 'a', sizeof big); + pocket_devserver_send_ctrl(big, sizeof big); + expect_ctrl("\"t\":\"ctrlDropped\""); + pocket_devserver_report_log("warn", "quote \" and\nline"); + expect_ctrl("\"args\":[\"quote \\\" and\\nline\"]"); + assert(!next_frame()); + pocket_devserver_client_close(); + authenticate(); + /* The cached hello precedes the status receipt on every new client. */ + pocket_devserver_client_close(); + pocket_devserver_client_open(); + hello(bytes, 1); + assert(feed(bytes, POCKET_RUNTIME_HELLO_BYTES)); + expect_ack(0); + expect_ctrl("\"t\":\"hello\""); + expect_ctrl("\"t\":\"runtime.status\""); + assert(!next_frame()); + pocket_devserver_reset_guest(); + pocket_devserver_client_close(); + authenticate(); + + /* Malformed control closes the client. */ + assert(!frame(POCKET_RUNTIME_MSG_CTRL, "{\n}", 3)); + assert(pocket_devserver_client_closing()); + pocket_devserver_client_close(); + authenticate(); + uint8_t flagged[POCKET_RUNTIME_FRAME_HEADER_BYTES]; + pocket_runtime_encode_frame_header(flagged, POCKET_RUNTIME_MSG_PING, 1, 0); + assert(!feed(flagged, sizeof flagged)); + pocket_devserver_client_close(); + authenticate(); + + /* Uploads. */ + uint8_t package[100]; + package_bytes(package, sizeof package, 0x0102030405060708ULL); + begin(sizeof package, 0x0102030405060708ULL); + expect_ctrl("\"phase\":\"receiving\""); + chunk(1, package, 16); + expect_ctrl("\"phase\":\"transfer-error\""); + assert(!exists("upload.tmp") && !pocket_devserver_upload_pending()); + upload(package, sizeof package, 0x0102030405060708ULL); + /* Later frames wait behind the admission decision. */ + assert(frame(POCKET_RUNTIME_MSG_STATUS_REQUEST, NULL, 0)); + assert(!next_frame()); + uint64_t declared = 0; + assert(pocket_devserver_take_upload(&declared) && declared == 0x0102030405060708ULL); + assert(!pocket_devserver_upload_pending()); + FILE *staged = fopen("upload.tmp", "rb"); + assert(staged != NULL); + uint8_t stored[128]; + assert(fread(stored, 1, sizeof stored, staged) == sizeof package && memcmp(stored, package, sizeof package) == 0); + fclose(staged); + assert(remove("upload.tmp") == 0); + assert(pocket_devserver_rx_commit(0)); + expect_ctrl("\"t\":\"runtime.status\""); + assert(!next_frame()); + /* Short commit, abort, and a disconnect discard the staging file. */ + begin(sizeof package, 0x0102030405060708ULL); + expect_ctrl("\"phase\":\"receiving\""); + chunk(0, package, 60); + assert(frame(POCKET_RUNTIME_MSG_PACKAGE_COMMIT, NULL, 0)); + expect_ctrl("before every declared byte"); + assert(!exists("upload.tmp")); + begin(sizeof package, 0x0102030405060708ULL); + expect_ctrl("\"phase\":\"receiving\""); + assert(frame(POCKET_RUNTIME_MSG_PACKAGE_ABORT, NULL, 0)); + expect_ctrl("aborted"); + assert(!exists("upload.tmp")); + begin(sizeof package, 0x0102030405060708ULL); + expect_ctrl("\"phase\":\"receiving\""); + chunk(0, package, 60); + pocket_devserver_client_close(); + assert(!exists("upload.tmp")); + authenticate(); + /* A committed upload survives the client that sent it. */ + upload(package, sizeof package, 0x0102030405060708ULL); + pocket_devserver_client_close(); + assert(pocket_devserver_upload_pending() && exists("upload.tmp")); + assert(pocket_devserver_take_upload(&declared) && declared == 0x0102030405060708ULL); + assert(remove("upload.tmp") == 0); + authenticate(); + /* Hosts that refuse packages answer with a rejection. */ + pocket_devserver_allow_packages(0); + begin(sizeof package, 0x0102030405060708ULL); + expect_ctrl("\"phase\":\"rejected\""); + pocket_devserver_allow_packages(1); + + /* Screenshot streaming: begin, both surfaces in order, end. */ + assert(pocket_devserver_request_screenshot()); + assert(!pocket_devserver_request_screenshot()); + assert(pocket_devserver_take_screenshot_request()); + assert(!pocket_devserver_take_screenshot_request()); + uint8_t *top = NULL; + uint8_t *auxiliary = NULL; + assert(pocket_devserver_screenshot_begin(7, 2, 1, 1, 1, &top, &auxiliary)); + memcpy(top, "\x01\x02\x03\x04\x05\x06", 6); + memcpy(auxiliary, "\x09\x08\x07", 3); + pocket_devserver_screenshot_ready(); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN); + assert(pocket_runtime_read_u32(out_payload) == 7 && pocket_runtime_read_u16(out_payload + 4) == 2); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK && out_flags == 0); + assert(out_length == 10 && pocket_runtime_read_u32(out_payload) == 0 && memcmp(out_payload + 4, "\x01\x02\x03\x04\x05\x06", 6) == 0); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK && out_flags == 1); + assert(out_length == 7 && memcmp(out_payload + 4, "\x09\x08\x07", 3) == 0); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_SCREENSHOT_END && pocket_runtime_read_u32(out_payload) == 7); + assert(!next_frame()); + /* The heartbeat answer is queued before the next screenshot chunk. */ + assert(pocket_devserver_request_screenshot() && pocket_devserver_take_screenshot_request()); + assert(pocket_devserver_screenshot_begin(8, 1, 1, 1, 1, &top, &auxiliary)); + pocket_devserver_screenshot_ready(); + assert(frame(POCKET_RUNTIME_MSG_PING, "\x0a\x0b\x0c\x0d", 4)); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_PONG); + assert(next_frame() && out_type == POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN); + while (next_frame()) assert(out_type != POCKET_RUNTIME_MSG_PONG); + + /* Timeouts: idle after authentication, and a hello that never arrives. */ + PocketDevServerCounters counters; + pocket_devserver_counters(&counters); + assert(counters.connects == 8 && counters.auth_failures == 1 && counters.uploads == 2); + assert(counters.screenshots == 2 && counters.discoveries == 2 && counters.timeouts == 0); + clock_now += POCKET_DEV_SERVER_IDLE_TIMEOUT_MS; + assert(!pocket_devserver_client_closing()); + clock_now += 1; + assert(pocket_devserver_client_closing()); + pocket_devserver_client_close(); + pocket_devserver_client_open(); + clock_now += POCKET_DEV_SERVER_HELLO_TIMEOUT_MS + 1; + assert(pocket_devserver_client_closing()); + pocket_devserver_client_close(); + pocket_devserver_counters(&counters); + assert(counters.timeouts == 2); + + /* Shutdown forgets the pairing. */ + pocket_devserver_shutdown(); + assert(!pocket_devserver_paired()); + assert(!pocket_devserver_discovery(request, sizeof request, reply)); + puts("ok"); + return 0; +} diff --git a/tests/fixtures/dev-wire-posix-host.c b/tests/fixtures/dev-wire-posix-host.c new file mode 100644 index 000000000..1e7250b8b --- /dev/null +++ b/tests/fixtures/dev-wire-posix-host.c @@ -0,0 +1,89 @@ +/* Host process for the POSIX Pocket Runtime transport (engine/runtime/ + * dev_wire_posix.c) without a guest: control records are echoed and an + * upload is admitted by its footer alone, so the desktop client can drive + * the same scenario it drives against the 3DS pump. */ +#include +#include +#include +#include +#include + +#include "dev_wire_posix.h" + +static volatile sig_atomic_t stopped; +static uint32_t generation = 1; +static uint64_t active = 0x1122334455667788ULL; + +static void stop_signal(int signal_number) { + (void)signal_number; + stopped = 1; +} + +static void status(char *out, size_t capacity) { + snprintf(out, capacity, + "{\"t\":\"runtime.status\",\"target\":\"host-dev\",\"hostAbi\":8,\"phase\":\"test\"," + "\"generation\":%lu,\"active\":\"%016llx\",\"transport\":\"%s\"}", + (unsigned long)generation, (unsigned long long)active, pocket_devwire_state_name()); +} + +static void admit_upload(void) { + uint64_t declared = 0; + if (!pocket_devserver_take_upload(&declared)) return; + const char *path = pocket_devserver_upload_path(); + FILE *file = fopen(path, "rb"); + uint64_t footer = 0; + long size = 0; + if (file != NULL && fseek(file, 0, SEEK_END) == 0) { + size = ftell(file); + if (size >= 8 && fseek(file, size - 8, SEEK_SET) == 0) { + uint8_t bytes[8]; + if (fread(bytes, 1, 8, file) == 8) footer = pocket_runtime_read_u64(bytes); + } + } + if (file != NULL) fclose(file); + remove(path); + if (footer != 0 && footer == declared) { + generation += 1; + active = declared; + pocket_devserver_set_state(generation, active); + pocket_devserver_report_install("accepted", declared, "footer verified"); + } else { + pocket_devserver_report_install("rejected", declared, "footer mismatch"); + } +} + +static void echo_controls(void) { + char lines[4096]; + size_t length = pocket_devserver_recv_ctrl(lines, sizeof lines); + char *cursor = lines; + while (length > 0) { + char *end = strchr(cursor, '\n'); + if (end == NULL) break; + *end = '\0'; + char reply[4200]; + int written = snprintf(reply, sizeof reply, "{\"t\":\"echo\",\"length\":%lu}", (unsigned long)(end - cursor)); + if (written > 0) pocket_devserver_send_ctrl(reply, (size_t)written); + length -= (size_t)(end - cursor) + 1; + cursor = end + 1; + } +} + +int main(int argc, char **argv) { + if (argc != 3) return 2; + signal(SIGTERM, stop_signal); + signal(SIGINT, stop_signal); + signal(SIGPIPE, SIG_IGN); + const PocketDevWireOptions options = {argv[1], "host-dev", "PocketJS Host", 8, (uint16_t)atoi(argv[2]), status}; + if (!pocket_devwire_init(&options)) return 3; + pocket_devserver_set_state(generation, active); + puts("posix wire ready"); + fflush(stdout); + while (!stopped) { + pocket_devwire_pump(); + admit_upload(); + echo_controls(); + usleep(2000); + } + pocket_devwire_shutdown(); + return 0; +} diff --git a/tests/fixtures/ipodtouch4-runtime.c b/tests/fixtures/ipodtouch4-runtime.c new file mode 100644 index 000000000..f7716fb52 --- /dev/null +++ b/tests/fixtures/ipodtouch4-runtime.c @@ -0,0 +1,43 @@ +#include "guest_runtime.h" +#include "pocket_runtime.h" +#include "pocket_target_contract.h" +#include +#include +#include +#include + +static volatile sig_atomic_t stopped; +/* The host Rust archive uses aborting panics, as the native allocator probe. */ +void rust_eh_personality(void) { abort(); } +static void stop_signal(int signal_number) { (void)signal_number; stopped = 1; } +static int boot(const PocketGuestPackage *guest) { + return pocket_runtime_boot((const char *)guest->javascript, guest->javascript_length - 1, + guest->pak, guest->pak_length, 320, 480); +} +static int validate(const uint8_t *bytes, size_t length) { + return pocket_package_validate_plan(bytes, length, &POCKET_TARGET_CONTRACT) == 0; +} +int main(int argc, char **argv) { + if (argc != 3) return 2; + signal(SIGTERM, stop_signal); + signal(SIGINT, stop_signal); + static const uint8_t javascript[] = "globalThis.frame = function() {};"; + static const uint8_t pak[] = {0}; + const PocketGuestPackage recovery = {javascript, sizeof javascript, pak, sizeof pak, NULL, 0, 0, 0}; + const PocketDevHost host = {boot, pocket_runtime_shutdown, validate, pocket_runtime_error, "Pocket Harness"}; + if (!pocket_dev_runtime_init(argv[1], &host, &recovery, (uint16_t)atoi(argv[2]))) return 3; + puts("runtime harness ready"); + fflush(stdout); + while (!stopped) { + pocket_dev_runtime_pump(); + if (pocket_dev_runtime_running()) { + PocketRuntimeContactsInput input = {0}; + if (!pocket_runtime_tick_contacts(&input) || !pocket_runtime_render()) + pocket_dev_runtime_failed(pocket_runtime_error()); + else pocket_dev_runtime_presented(); + } + usleep(5000); + } + pocket_dev_runtime_shutdown(); + return 0; +} diff --git a/tests/ipodtouch4-package.test.ts b/tests/ipodtouch4-package.test.ts new file mode 100644 index 000000000..6a1492db6 --- /dev/null +++ b/tests/ipodtouch4-package.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { encodePocketPackage, POCKET_SECTION } from "../contracts/spec/pocket-package.ts"; +import { canonicalJson } from "../framework/src/manifest/plan.ts"; +import { resolveIPodTouch4BuildPlan } from "../tools/ipodtouch4-profile.ts"; +import { verifyIPodTouch4Package } from "../tools/ipodtouch4-package.ts"; +import { parseRuntimeOptions } from "../tools/ipodtouch4-runtime.ts"; +import { makeVariant } from "../tools/pocket-pack.ts"; + +const manifest = JSON.parse(readFileSync(new URL("../apps/clear/pocket.json", import.meta.url), "utf8")); +const plan = resolveIPodTouch4BuildPlan(manifest); +function fixture(mutate?: (variant: ReturnType) => void) { + const variant = makeVariant({ target: plan.target.id, hostAbi: plan.target.hostAbi, + planJson: canonicalJson(plan), identity: { id: plan.app.id, title: plan.app.title, output: plan.app.output }, + js: new TextEncoder().encode("globalThis.frame = () => {};"), pak: new Uint8Array([0]) }); + mutate?.(variant); + return encodePocketPackage({ manifest: new TextEncoder().encode(JSON.stringify(manifest)), variants: [variant] }); +} +test("iPod package re-admits its own manifest and exact target", () => { + expect(verifyIPodTouch4Package(fixture())).toEqual(plan); + expect(() => verifyIPodTouch4Package(fixture((v) => { v.target = "3ds-dev"; }))).toThrow("target/ABI"); + expect(() => verifyIPodTouch4Package(fixture((v) => { v.hostAbi = 7; }))).toThrow("target/ABI"); +}); +test("iPod package rejects changed plans and missing payloads despite a valid container hash", () => { + expect(() => verifyIPodTouch4Package(fixture((v) => { + v.sections.find((s) => s.kind === POCKET_SECTION.plan)!.bytes = new TextEncoder().encode(JSON.stringify({ ...plan, planHash: "altered" })); + }))).toThrow("plan differs"); + expect(() => verifyIPodTouch4Package(fixture((v) => { v.sections = v.sections.filter((s) => s.kind !== POCKET_SECTION.pak); }))).toThrow("section 4"); + expect(() => verifyIPodTouch4Package(fixture((v) => { v.sections.find((s) => s.kind === POCKET_SECTION.js)!.bytes = new Uint8Array([1]); }))).toThrow("terminator"); +}); +test("Runtime CLI selects USB by default and makes LAN and external package inputs explicit", () => { + expect(parseRuntimeOptions(["push"])).toMatchObject({ command: "push", app: "clear", lan: false, port: 8131 }); + expect(parseRuntimeOptions(["dev", "--lan", "--no-push", "--app", "clear"])).toMatchObject({ lan: true, noPush: true }); + expect(parseRuntimeOptions(["status", "--host", "192.0.2.1", "--key", "dev.key"])).toMatchObject({ host: "192.0.2.1", lan: true }); + expect(() => parseRuntimeOptions(["push", "--port", "NaN"])).toThrow("port"); + expect(() => parseRuntimeOptions(["push", "--app", "../../etc"])).toThrow("--manifest"); + expect(() => parseRuntimeOptions(["push", "--key"])).toThrow("requires a value"); +}); diff --git a/tests/ipodtouch4-runtime.test.ts b/tests/ipodtouch4-runtime.test.ts new file mode 100644 index 000000000..c36e9c406 --- /dev/null +++ b/tests/ipodtouch4-runtime.test.ts @@ -0,0 +1,267 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer, createConnection } from "node:net"; +import { encodePocketPackage, POCKET_SECTION } from "../contracts/spec/pocket-package.ts"; +import { canonicalJson } from "../framework/src/manifest/plan.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { IPODTOUCH4_DEV_CONTRACTS, resolveIPodTouch4BuildPlan } from "../tools/ipodtouch4-profile.ts"; +import { ipodtouch4QuickJsPath, IPODTOUCH4_TOOLCHAIN } from "../tools/ipodtouch4-toolchain.ts"; +import { makeVariant } from "../tools/pocket-pack.ts"; +import { buildIPodTouch4Package } from "../tools/ipodtouch4-package.ts"; +import { discoverPocketRuntimes, PocketRuntimeClient } from "../tools/pocket-runtime-client.ts"; +import { renderTargetContractHeader, resolveNativeTargetContract } from "../tools/target-contract.ts"; +import { encodePocketRuntimeFrame, encodePocketRuntimeHello, encodePocketRuntimePackageBegin, + encodePocketRuntimePackageChunk, pocketPackageFooterHash, POCKET_RUNTIME_MSG } from "../contracts/spec/pocket-runtime-wire.ts"; + +const ROOT = new URL("..", import.meta.url).pathname; +const directory = mkdtempSync(join(tmpdir(), "pocket-ipod-runtime-")); +const binary = join(directory, "runtime"); +const token = new Uint8Array(32).fill(0x67); +const manifest = JSON.parse(readFileSync(join(ROOT, "apps/clear/pocket.json"), "utf8")); +const plan = resolveIPodTouch4BuildPlan(manifest); +const children: Bun.Subprocess[] = []; +const clients: PocketRuntimeClient[] = []; +let clearPackage: string; + +// The real QuickJS sources and the C ABI's pinned nightly are the +// prerequisites; the Native C harness workflow provides both and names the +// sources through POCKETJS_QUICKJS_SOURCE, which makes them mandatory. Without +// that variable a checkout that lacks either reports a skip, not a failure. +const quickjs = process.env.POCKETJS_QUICKJS_SOURCE ?? join(ipodtouch4QuickJsPath(), "libquickjs-sys/embed/quickjs"); +const toolchain = IPODTOUCH4_TOOLCHAIN.compiler.rustToolchain; +const missing = [ + ...(existsSync(join(quickjs, "quickjs.c")) ? [] : [`pinned QuickJS sources at ${quickjs}`]), + ...(Bun.spawnSync(["rustup", "which", "--toolchain", toolchain, "cargo"]).exitCode === 0 ? [] : [`rust toolchain ${toolchain}`]), + ...(Bun.which("cc") ? [] : ["a C compiler"]), +]; +if (missing.length > 0 && process.env.POCKETJS_QUICKJS_SOURCE) { + throw new Error(`iPod runtime harness prerequisites are missing: ${missing.join(", ")}`); +} +const describeRuntime = missing.length === 0 ? describe : describe.skip; + +afterAll(async () => { + clients.forEach((client) => client.close()); + for (const child of children) if (child.exitCode === null) child.kill(); + await Promise.all(children.map((child) => child.exited)); + rmSync(directory, { recursive: true, force: true }); +}); + +async function run(command: string[], cwd = ROOT, env = process.env) { + const process = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([process.exited, + new Response(process.stdout).text(), new Response(process.stderr).text()]); + if (code) throw new Error(`${command.join(" ")}\n${out}${err}`); + return out.trim(); +} +describeRuntime("iPod touch 4 Pocket Runtime with the real QuickJS", () => { +beforeAll(async () => { + const cargo = await run(["rustup", "which", "--toolchain", toolchain, "cargo"]); + const rustc = await run(["rustup", "which", "--toolchain", toolchain, "rustc"]); + const target = join(ROOT, ".pocket-build/ipodtouch4-runtime-tests/rust"); + await run([cargo, "build", "--locked", "--release", "--features", "bare-platform,software-only", + "--manifest-path", join(ROOT, "engine/ui-cabi/Cargo.toml"), "--target-dir", target], ROOT, + { ...process.env, RUSTC: rustc }); + // The same generated contract the native build bakes into PocketRuntime.app. + writeFileSync(join(directory, "pocket_target_contract.h"), + renderTargetContractHeader(resolveNativeTargetContract(extractHostBuildInputs(plan), IPODTOUCH4_DEV_CONTRACTS))); + const objects: string[] = []; + for (const name of ["quickjs", "cutils", "dtoa", "libregexp", "libunicode"]) { + const object = join(directory, `${name}.o`); + await run(["cc", "-O1", "-D_GNU_SOURCE", `-DCONFIG_VERSION=\"${IPODTOUCH4_TOOLCHAIN.compiler.quickJsVersion}\"`, + "-I", quickjs, "-c", join(quickjs, `${name}.c`), "-o", object]); + objects.push(object); + } + await run(["cc", "-std=c11", "-D_DEFAULT_SOURCE", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", + '-DPOCKETJS_TARGET_ID="ipodtouch4-dev"', "-DPOCKETJS_HOST_ABI=8", "-DPOCKET_RASTER_DENSITY=2", "-DPOCKET_DEV_RUNTIME", + // Tight guest budgets keep the hung-guest cases short; the device build + // keeps the defaults sized for the iPod's CPU. + "-DPOCKET_DEV_GUEST_BOOT_BUDGET_MS=2000u", "-DPOCKET_DEV_GUEST_TURN_BUDGET_MS=500u", + "-I", join(ROOT, "engine/runtime"), "-I", join(ROOT, "engine/quickjs-c"), "-I", join(ROOT, "engine/ui-cabi/include"), + "-I", join(ROOT, "contracts/generated"), "-I", directory, "-isystem", quickjs, + join(ROOT, "tests/fixtures/ipodtouch4-runtime.c"), join(ROOT, "engine/quickjs-c/pocket_runtime.c"), + ...["dev_protocol", "dev_server", "dev_wire_posix", "guest_runtime"].map((name) => join(ROOT, `engine/runtime/${name}.c`)), + ...objects, join(target, "release/libpocketjs_symbian_core.a"), "-lm", "-lpthread", + ...(process.platform === "linux" ? ["-ldl"] : []), "-o", binary]); + clearPackage = (await buildIPodTouch4Package({ manifest: "apps/clear/pocket.json", outdir: join(directory, "clear") })).path; +}, 180000); + + +function packageBytes(source: string, mutate?: (variant: ReturnType) => void) { + const variant = makeVariant({ target: plan.target.id, hostAbi: plan.target.hostAbi, planJson: canonicalJson(plan), + identity: { output: plan.app.output, id: plan.app.id, title: plan.app.title }, + js: new TextEncoder().encode(source), pak: new Uint8Array([0]) }); + mutate?.(variant); + return encodePocketPackage({ manifest: new TextEncoder().encode(JSON.stringify(manifest)), variants: [variant] }); +} +async function port() { + const server = createServer(); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no test port"); + await new Promise((done) => server.close(() => done())); + return address.port; +} +async function start(name: string, paired = true) { + const root = join(directory, name); + mkdirSync(root, { recursive: true }); + if (paired) writeFileSync(join(root, "dev.key"), Buffer.from(token).toString("hex") + "\n"); + const address = { host: "127.0.0.1", port: await port(), token }; + const child = Bun.spawn([binary, root, String(address.port)], { stdout: "pipe", stderr: "pipe" }); + children.push(child); + const reader = child.stdout.getReader(); + const ready = await reader.read(); + reader.releaseLock(); + expect(new TextDecoder().decode(ready.value)).toContain("runtime harness ready"); + return { root, address, child }; +} +async function connect(address: { host: string; port: number; token: Uint8Array }) { + for (let i = 0; i < 40; ++i) { + const client = new PocketRuntimeClient({ ...address, timeoutMs: 2000 }); + try { await client.connect(); clients.push(client); return client; } + catch { client.close(); await Bun.sleep(50); } + } + throw new Error("runtime did not accept a connection"); +} +async function status(client: PocketRuntimeClient) { + const reply = client.waitForCtrl((message) => message.t === "runtime.status"); + await client.requestStatus(); + return await reply; +} +async function install(client: PocketRuntimeClient, bytes: Uint8Array) { + const hash = pocketPackageFooterHash(bytes).toString(16).padStart(16, "0"); + const result = client.waitForCtrl((message) => message.t === "runtime.install" && message.hash === hash && + ["accepted", "rejected", "transfer-error"].includes(String(message.phase)), 10000); + const [, verdict] = await Promise.all([client.install(bytes), result]); + return verdict; +} +function hash(bytes: Uint8Array) { return pocketPackageFooterHash(bytes).toString(16).padStart(16, "0"); } + +test("real QuickJS: accepts, rejects incompatible packages, rolls back eval/frame failures and recovers after restart", async () => { + const device = await start("recovery"); + let client = await connect(device.address); + expect((await status(client)).active).toBe("0000000000000000"); + const first = packageBytes("globalThis.frame = function() {}; // first"); + const second = packageBytes("globalThis.frame = function() {}; // second"); + expect((await install(client, first)).phase).toBe("accepted"); + expect((await install(client, second)).phase).toBe("accepted"); + expect(await status(client)).toMatchObject({ active: hash(second), lastGood: hash(first), generation: 2 }); + const invalid = [ + packageBytes("globalThis.frame = function() {};", (v) => { v.target = "3ds-dev"; }), + packageBytes("globalThis.frame = function() {};", (v) => { v.hostAbi = 99; }), + packageBytes("globalThis.frame = function() {};", (v) => { + const section = v.sections.find((s) => s.kind === POCKET_SECTION.plan)!; + section.bytes = new TextEncoder().encode(canonicalJson({ ...plan, viewport: { ...plan.viewport, logical: [480, 320] } })); + }), + packageBytes("globalThis.frame = function() {};", (v) => { + v.sections.find((s) => s.kind === POCKET_SECTION.plan)!.bytes = new TextEncoder().encode("{ invalid"); + }), + // Target-contract policy lives in the package layer: a presentation the + // shell does not offer, a feature outside the registry's capability list + // and a host extension the shell cannot carry are all refused. + packageBytes("globalThis.frame = function() {};", (v) => { + const section = v.sections.find((s) => s.kind === POCKET_SECTION.plan)!; + section.bytes = new TextEncoder().encode(canonicalJson({ ...plan, viewport: { ...plan.viewport, presentation: "fill" } })); + }), + packageBytes("globalThis.frame = function() {};", (v) => { + const section = v.sections.find((s) => s.kind === POCKET_SECTION.plan)!; + section.bytes = new TextEncoder().encode(canonicalJson({ ...plan, features: { ...plan.features, "input.buttons": true } })); + }), + packageBytes("globalThis.frame = function() {};", (v) => { + const section = v.sections.find((s) => s.kind === POCKET_SECTION.plan)!; + section.bytes = new TextEncoder().encode(canonicalJson({ ...plan, hostExtension: { kind: "idf-host", version: 1 } })); + }), + packageBytes("this is not javascript!"), + packageBytes("while (true) {}"), + packageBytes("globalThis.frame = function() { throw new Error('first frame failed'); };"), + packageBytes("globalThis.frame = function() { while (true) {} };"), + ]; + for (const bytes of invalid) { + expect((await install(client, bytes)).phase).toBe("rejected"); + expect((await status(client)).active).toBe(hash(second)); + } + const corrupt = new Uint8Array(first); corrupt[20] ^= 1; + expect((await install(client, corrupt)).phase).toBe("rejected"); + expect((await status(client)).active).toBe(hash(second)); + client.close(); device.child.kill(); await device.child.exited; + const restarted = await start("recovery"); + client = await connect(restarted.address); + expect(await status(client)).toMatchObject({ active: hash(second), lastGood: hash(first), generation: 2, phase: "accepted" }); + // Corrupt the newest disk blob. A restart must present last-good before it + // publishes a new generation, and must not retain the corrupt blob as backup. + client.close(); restarted.child.kill(); await restarted.child.exited; + writeFileSync(join(device.root, "packages", `${hash(second)}.pocket`), "torn"); + const recovered = await start("recovery"); + client = await connect(recovered.address); + expect(await status(client)).toMatchObject({ active: hash(first), lastGood: "0000000000000000", generation: 3 }); +}, 30000); + +test("unpaired listener stays closed, pairing enables discovery, fragmented uploads and disconnects preserve active", async () => { + const device = await start("wire", false); + const unpairedConnects = await new Promise((done) => { + const probe = createConnection({ host: device.address.host, port: device.address.port }); + probe.once("connect", () => { probe.destroy(); done(true); }); + probe.once("error", () => { probe.destroy(); done(false); }); + }); + expect(unpairedConnects).toBe(false); + expect(await discoverPocketRuntimes({ addresses: ["127.0.0.1"], port: device.address.port, timeoutMs: 100 })).toHaveLength(0); + writeFileSync(join(device.root, "dev.key"), Buffer.from(token).toString("hex")); + let client = await connect(device.address); + const bytes = packageBytes("globalThis.frame = function() {}; // wire"); + expect((await install(client, bytes)).phase).toBe("accepted"); + const before = await status(client); + const badOffset = client.waitForCtrl((message) => message.t === "runtime.install" && message.phase === "transfer-error"); + await client.sendFrame(POCKET_RUNTIME_MSG.packageBegin, encodePocketRuntimePackageBegin(bytes.length, pocketPackageFooterHash(bytes))); + await client.sendFrame(POCKET_RUNTIME_MSG.packageChunk, encodePocketRuntimePackageChunk(1, bytes.subarray(0, 16))); + expect((await badOffset).phase).toBe("transfer-error"); + expect((await status(client)).active).toBe(before.active); + await client.sendFrame(POCKET_RUNTIME_MSG.packageBegin, encodePocketRuntimePackageBegin(bytes.length, pocketPackageFooterHash(bytes))); + await client.sendFrame(POCKET_RUNTIME_MSG.packageChunk, encodePocketRuntimePackageChunk(0, bytes.subarray(0, 30))); + client.close(); + await Bun.sleep(50); + client = await connect(device.address); + expect((await status(client)).active).toBe(before.active); + expect(existsSync(join(device.root, "upload.tmp"))).toBe(false); + const discoveries = await discoverPocketRuntimes({ addresses: ["127.0.0.1"], port: device.address.port, timeoutMs: 100 }); + expect(discoveries[0]).toMatchObject({ target: "ipodtouch4-dev", label: "Pocket Harness", hostAbi: 8 }); + client.close(); + await Bun.sleep(50); + // A wrong key is answered with a rejection ack, the same as on the 3DS. + const unauthorized = new PocketRuntimeClient({ ...device.address, token: new Uint8Array(32), timeoutMs: 500 }); + try { await expect(unauthorized.connect()).rejects.toThrow("rejected the pairing token (status 2)"); } + finally { unauthorized.close(); } + await Bun.sleep(50); + // Actual TCP fragmentation, including a coalesced hello and status frame. + const socket = createConnection({ host: device.address.host, port: device.address.port }); + await new Promise((ready) => socket.once("connect", ready)); + const transcript = Buffer.concat([encodePocketRuntimeHello(token), encodePocketRuntimeFrame(POCKET_RUNTIME_MSG.statusRequest)]); + const data = new Promise((ready) => socket.once("data", (bytes) => ready(Buffer.from(bytes)))); + for (let i = 0; i < transcript.length; i += 3) { socket.write(transcript.subarray(i, i + 3)); await Bun.sleep(1); } + expect((await data).readUInt32LE(0)).toBe(0x54524b50); + socket.destroy(); +}, 15000); + +test("the compiled Clear app renders, answers DevTools, and recovers from a later guest exception", async () => { + const device = await start("clear"); + const client = await connect(device.address); + expect((await install(client, readFileSync(clearPackage))).phase).toBe("accepted"); + const tree = client.waitForCtrl((message) => message.t === "tree"); + await client.sendCtrl({ t: "getTree" }); + expect((await tree).t).toBe("tree"); + const evaluated = client.waitForCtrl((message) => message.t === "evalResult" && message.id === "probe"); + await client.sendCtrl({ t: "eval", id: "probe", code: "ui.__host + ':' + ui.__hostAbi" }); + expect(await evaluated).toMatchObject({ ok: true, value: "ipodtouch4-dev:8" }); + const laterFailure = packageBytes("let n=0; globalThis.frame = function() { if (++n > 25) throw Error('late failure'); };"); + expect((await install(client, laterFailure)).phase).toBe("accepted"); + for (let i = 0; i < 50; ++i) { + const current = await status(client); + if (current.active === hash(readFileSync(clearPackage))) { + expect(current).toMatchObject({ phase: "accepted", generation: 3, lastGood: "0000000000000000" }); + return; + } + await Bun.sleep(20); + } + throw new Error("late guest failure did not restore Clear"); +}, 15000); +}); diff --git a/tests/png.ts b/tests/png.ts index 84f67bbaa..1509eb22f 100644 --- a/tests/png.ts +++ b/tests/png.ts @@ -1,76 +1,4 @@ -// tests/png.ts — minimal deterministic PNG encoder (extracted from -// tests/golden.ts so tools/tape.ts can render replay frames too; the -// dreamcart framework/test/golden.ts copy note travels with it). -// -// Determinism: Bun.deflateSync is deterministic, chunks carry no time or -// text metadata — byte equality is meaningful across runs and machines. - -const CRC = (() => { - const t = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - t[n] = c >>> 0; - } - return t; -})(); - -function crc32(buf: Uint8Array): number { - let c = 0xffffffff; - for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -} - -function chunk(type: string, data: Uint8Array): Buffer { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length, 0); - const body = Buffer.concat([Buffer.from(type, "ascii"), data]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(body), 0); - return Buffer.concat([len, body, crc]); -} - -/** adler32 (the zlib-stream trailer). */ -function adler32(buf: Uint8Array): number { - let a = 1; - let b = 0; - for (let i = 0; i < buf.length; i++) { - a = (a + buf[i]) % 65521; - b = (b + a) % 65521; - } - return ((b << 16) | a) >>> 0; -} - -/** Bun.deflateSync emits a RAW deflate stream; PNG IDAT needs the zlib - * wrapper (2-byte header + adler32 trailer) — add it ourselves. */ -function zlibWrap(raw: Uint8Array): Buffer { - // (cast: Buffer is Uint8Array but this one is always heap-backed) - const body = Bun.deflateSync(raw as Uint8Array); - const out = Buffer.alloc(body.length + 6); - out[0] = 0x78; // CM=8, CINFO=7 - out[1] = 0x01; // FCHECK making (out[0]<<8|out[1]) % 31 == 0, FLEVEL 0 - Buffer.from(body).copy(out, 2); - out.writeUInt32BE(adler32(raw), body.length + 2); - return out; -} - -export function encodePNG(rgba: Uint8Array, w: number, h: number): Buffer { - const stride = w * 4; - const raw = Buffer.alloc((stride + 1) * h); - for (let y = 0; y < h; y++) { - raw[y * (stride + 1)] = 0; // filter: none - Buffer.from(rgba.buffer, rgba.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1); - } - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(w, 0); - ihdr.writeUInt32BE(h, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // color type RGBA - const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - return Buffer.concat([ - sig, - chunk("IHDR", ihdr), - chunk("IDAT", zlibWrap(raw)), - chunk("IEND", Buffer.alloc(0)), - ]); -} +// The deterministic PNG encoder lives in tools/png.ts (production tooling +// must not import from tests/). This re-export keeps the golden and e2e +// helpers on their existing import path. +export { encodePNG } from "../tools/png.ts"; diff --git a/tests/pocket-runtime-server.test.ts b/tests/pocket-runtime-server.test.ts new file mode 100644 index 000000000..abb50b7f9 --- /dev/null +++ b/tests/pocket-runtime-server.test.ts @@ -0,0 +1,228 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; +import { + encodePocketRuntimePackageBegin, + encodePocketRuntimePackageChunk, + pocketPackageFooterHash, + pocketRuntimeDeviceId, + POCKET_RUNTIME_MAX_FRAME_BYTES, + POCKET_RUNTIME_MSG, +} from "../contracts/spec/pocket-runtime-wire.ts"; +import { + discoverPocketRuntimes, + PocketRuntimeClient, + render3dsScreenshotPng, +} from "../tools/3ds-runtime-client.ts"; + +// One PKRT server (engine/runtime/dev_server.c) behind two socket pumps: the +// Nintendo 3DS one with libctru stubbed out and the POSIX one the UIKit shell +// uses. The desktop client drives both through the same scenario, so the +// hosts cannot drift apart in what a frame means. + +const ROOT = new URL("..", import.meta.url).pathname; +const directory = mkdtempSync(join(tmpdir(), "pocket-runtime-server-")); +const children: Bun.Subprocess[] = []; +const clients: PocketRuntimeClient[] = []; +const token = Uint8Array.from({ length: 32 }, (_, index) => 0x40 + index); +const keyText = `${Buffer.from(token).toString("hex")}\n`; +const compiler = Bun.which("cc"); +const SHARED = ["engine/runtime/dev_server.c", "engine/runtime/dev_protocol.c"].map((path) => join(ROOT, path)); +const WARNINGS = ["-D_DEFAULT_SOURCE", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror"]; + +afterAll(async () => { + clients.forEach((client) => client.close()); + for (const child of children) if (child.exitCode === null) child.kill(); + await Promise.all(children.map((child) => child.exited)); + rmSync(directory, { recursive: true, force: true }); +}); + +function compile(args: readonly string[], output: string): void { + expect(compiler).not.toBeNull(); + const result = Bun.spawnSync([compiler!, ...args, "-o", output]); + expect(result.exitCode, result.stderr.toString()).toBe(0); +} + +async function freePort(): Promise { + const server = createServer(); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no test port"); + await new Promise((done) => server.close(() => done())); + return address.port; +} + +async function spawnReady(command: string[], cwd: string, marker: string): Promise { + const child = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" }); + children.push(child); + const reader = child.stdout.getReader(); + const ready = await reader.read(); + reader.releaseLock(); + expect(new TextDecoder().decode(ready.value)).toContain(marker); + return child; +} + +test("the shared server replays one transcript without a transport", () => { + const binary = join(directory, "core-transcript"); + compile(["-std=c11", ...WARNINGS, "-I", join(ROOT, "engine/runtime"), + join(ROOT, "tests/fixtures/dev-server-core.c"), ...SHARED], binary); + const scratch = join(directory, "core-scratch"); + mkdirSync(scratch, { recursive: true }); + const run = Bun.spawnSync([binary, scratch]); + expect(run.exitCode, run.stderr.toString()).toBe(0); + expect(run.stdout.toString()).toContain("ok"); +}); + +interface Transport { + readonly name: string; + readonly target: string; + readonly label: string; + readonly screenshots: boolean; + start(): Promise; +} + +const transports: readonly Transport[] = [ + { + name: "Nintendo 3DS libctru pump", + target: "3ds-dev", + label: "PocketJS 3DS", + screenshots: true, + async start() { + const port = await freePort(); + const binary = join(directory, `3ds-devserver-${port}`); + compile(["-std=gnu11", ...WARNINGS, + '-DPOCKETJS_TARGET_ID="3ds-dev"', '-DPOCKETJS_RUNTIME_SLOT="0123456789abcdef"', "-DPOCKETJS_HOST_ABI=8", + `-DPOCKETJS_DEV_PORT=${port}`, + "-I", join(ROOT, "tests/fixtures/3ds-stubs"), "-I", join(ROOT, "hosts/3ds/src"), + "-I", join(ROOT, "hosts/3ds/include"), "-I", join(ROOT, "engine/runtime"), + join(ROOT, "tests/fixtures/3ds-devserver-host.c"), join(ROOT, "hosts/3ds/src/devserver.c"), ...SHARED], binary); + // The console's literal sdmc: paths, relative to the scratch directory. + const root = join(directory, `3ds-root-${port}`); + mkdirSync(join(root, "sdmc:/pocketjs/runtime/apps/0123456789abcdef"), { recursive: true }); + writeFileSync(join(root, "sdmc:/pocketjs/runtime/dev.key"), keyText); + await spawnReady([binary, root], ROOT, "3ds devserver ready"); + return port; + }, + }, + { + name: "POSIX socket pump", + target: "host-dev", + label: "PocketJS Host", + screenshots: false, + async start() { + const port = await freePort(); + const binary = join(directory, "posix-devwire"); + compile(["-std=c11", ...WARNINGS, "-I", join(ROOT, "engine/runtime"), + join(ROOT, "tests/fixtures/dev-wire-posix-host.c"), join(ROOT, "engine/runtime/dev_wire_posix.c"), ...SHARED], binary); + const root = join(directory, `posix-root-${port}`); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "dev.key"), keyText); + await spawnReady([binary, root, String(port)], ROOT, "posix wire ready"); + return port; + }, + }, +]; + +function packageBytes(length: number, seed: number): Uint8Array { + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index += 1) bytes[index] = (index * seed) & 0xff; + bytes[length - 8] = 0x11; + bytes[length - 1] = seed; + return bytes; +} + +function hex(hash: bigint): string { + return hash.toString(16).padStart(16, "0"); +} + +async function status(client: PocketRuntimeClient): Promise> { + const reply = client.waitForCtrl((message) => message.t === "runtime.status"); + await client.requestStatus(); + return await reply; +} + +for (const transport of transports) { + test(`${transport.name} keeps the shared PKRT semantics`, async () => { + const port = await transport.start(); + const target = { host: "127.0.0.1", port }; + + const found = await discoverPocketRuntimes({ addresses: ["127.0.0.1"], port, timeoutMs: 500 }); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ + target: transport.target, label: transport.label, hostAbi: 8, port, flags: 0, + generation: 1, activeHash: 0x1122334455667788n, deviceId: pocketRuntimeDeviceId(token), + }); + + // A wrong key is answered with a rejection ack before the close. + const stranger = new PocketRuntimeClient({ ...target, token: new Uint8Array(32), timeoutMs: 2000 }); + try { await expect(stranger.connect()).rejects.toThrow("rejected the pairing token (status 2)"); } + finally { stranger.close(); } + + const client = new PocketRuntimeClient({ ...target, token, timeoutMs: 5000, heartbeatIntervalMs: 50, heartbeatTimeoutMs: 2000 }); + clients.push(client); + const pong = new Promise((resolve) => client.once("pong", () => resolve())); + const ack = await client.connect(); + expect(ack).toMatchObject({ accepted: true, hostAbi: 8, generation: 1, flags: 1, activeHash: 0x1122334455667788n }); + expect(await status(client)).toMatchObject({ target: transport.target, generation: 1, active: hex(0x1122334455667788n) }); + await pong; + expect((await discoverPocketRuntimes({ addresses: ["127.0.0.1"], port, timeoutMs: 300 }))[0]?.flags).toBe(1); + + // Guest control records travel both ways; unknown frames are skipped. + const echo = client.waitForCtrl((message) => message.t === "echo"); + await client.sendCtrl({ t: "probe", n: 1 }); + expect((await echo).length).toBe(JSON.stringify({ t: "probe", n: 1 }).length); + await client.sendFrame(0x7f, Uint8Array.of(1, 2, 3)); + expect((await status(client)).generation).toBe(1); + + // Uploads: receiving, received, then the host's admission verdict. + const phases: string[] = []; + client.on("ctrl", (message: Record) => { + if (message.t === "runtime.install") phases.push(String(message.phase)); + }); + const good = packageBytes(70_000, 3); + const hash = pocketPackageFooterHash(good); + const verdict = client.waitForCtrl((message) => message.t === "runtime.install" && message.hash === hex(hash) && + ["accepted", "rejected", "transfer-error"].includes(String(message.phase))); + expect(await client.install(good)).toBe(hash); + expect((await verdict).phase).toBe("accepted"); + expect(phases).toEqual(["receiving", "received", "accepted"]); + expect(await status(client)).toMatchObject({ generation: 2, active: hex(hash) }); + + const misplaced = client.waitForCtrl((message) => message.t === "runtime.install" && message.phase === "transfer-error"); + await client.sendFrame(POCKET_RUNTIME_MSG.packageBegin, encodePocketRuntimePackageBegin(good.length, hash)); + await client.sendFrame(POCKET_RUNTIME_MSG.packageChunk, encodePocketRuntimePackageChunk(1, good.subarray(0, 16))); + expect(String((await misplaced).message)).toContain("offset"); + + // A declared footer that does not match the bytes is the host's call. + const rejected = client.waitForCtrl((message) => message.t === "runtime.install" && message.phase === "rejected"); + await client.sendFrame(POCKET_RUNTIME_MSG.packageBegin, encodePocketRuntimePackageBegin(good.length, hash ^ 1n)); + const chunkBytes = POCKET_RUNTIME_MAX_FRAME_BYTES - 4; + for (let offset = 0; offset < good.length; offset += chunkBytes) { + await client.sendFrame(POCKET_RUNTIME_MSG.packageChunk, + encodePocketRuntimePackageChunk(offset, good.subarray(offset, Math.min(offset + chunkBytes, good.length)))); + } + await client.sendFrame(POCKET_RUNTIME_MSG.packageCommit); + expect((await rejected).hash).toBe(hex(hash ^ 1n)); + expect(await status(client)).toMatchObject({ generation: 2, active: hex(hash) }); + + if (transport.screenshots) { + const shot = client.waitForScreenshot(5000); + await client.sendCtrl({ t: "screenshot" }); + const image = await shot; + expect(image.metadata).toMatchObject({ topWidth: 400, topHeight: 240, auxiliaryWidth: 320, auxiliaryHeight: 240 }); + expect(image.top[5]).toBe(5); + expect(image.auxiliary[0]).toBe(255); + const png = render3dsScreenshotPng(image); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([400, 480]); + } + + // A malformed control record closes the connection. + const closed = new Promise((resolve) => client.once("close", () => resolve())); + await client.sendFrame(POCKET_RUNTIME_MSG.ctrl, new TextEncoder().encode("{\n}")); + await closed; + expect(client.connected).toBe(false); + }, 20000); +} diff --git a/tools/3ds-dev.ts b/tools/3ds-dev.ts index ae7dcdc31..f05b4f526 100644 --- a/tools/3ds-dev.ts +++ b/tools/3ds-dev.ts @@ -32,6 +32,7 @@ import { PocketRuntimeSession, discoverPocketRuntimes, parsePocketRuntimeToken, + render3dsScreenshotPng, type DiscoveredPocketRuntime, type PocketRuntimeScreenshot, } from "./3ds-runtime-client.ts"; @@ -392,13 +393,14 @@ async function probe(): Promise { shotPromise, ]); const output = screenshotPath(screenshot.frame); + const png = render3dsScreenshotPng(screenshot); mkdirSync(dirname(output), { recursive: true }); - writeFileSync(output, screenshot.png); + writeFileSync(output, png); console.log(`status: ${JSON.stringify(status)}`); console.log(`devStats: ${JSON.stringify(stats.data)}`); console.log(`tree frame: ${String(tree.frame ?? "?")}`); console.log(`eval: ${String(evaluation.value ?? "?")}`); - console.log(`screenshot: ${output} (${screenshot.png.length} bytes)`); + console.log(`screenshot: ${output} (${png.length} bytes)`); } finally { client.close(); } @@ -424,13 +426,14 @@ async function dev(): Promise { let currentPackage = value("--package") ? resolve(value("--package")!) : ""; const forwardScreenshot = (screenshot: PocketRuntimeScreenshot) => { + const png = render3dsScreenshotPng(screenshot); if (socket.readyState === WebSocket.OPEN) { - const data = `data:image/png;base64,${screenshot.png.toString("base64")}`; + const data = `data:image/png;base64,${png.toString("base64")}`; socket.send(JSON.stringify({ t: "screenshot", frame: screenshot.frame, data })); } const output = screenshotPath(screenshot.frame); mkdirSync(dirname(output), { recursive: true }); - writeFileSync(output, screenshot.png); + writeFileSync(output, png); console.log(`screenshot ${output}`); }; session.on("screenshot", forwardScreenshot); diff --git a/tools/3ds-runtime-client.ts b/tools/3ds-runtime-client.ts index 1e94b6163..eed1f5c15 100644 --- a/tools/3ds-runtime-client.ts +++ b/tools/3ds-runtime-client.ts @@ -1,602 +1,11 @@ -import { EventEmitter } from "node:events"; -import { createSocket } from "node:dgram"; -import { Socket } from "node:net"; -import { - POCKET_RUNTIME_ACK_BYTES, - POCKET_RUNTIME_FRAME_HEADER_BYTES, - POCKET_RUNTIME_MAX_CTRL_BYTES, - POCKET_RUNTIME_MAX_FRAME_BYTES, - POCKET_RUNTIME_MSG, - POCKET_RUNTIME_WIRE_PORT, - PocketRuntimeFrameDecoder, - decodePocketRuntimeAck, - decodePocketRuntimeDiscoveryReply, - decodePocketRuntimeScreenshotBegin, - encodePocketRuntimeDiscoveryRequest, - encodePocketRuntimeFrame, - encodePocketRuntimeHello, - encodePocketRuntimePackageBegin, - encodePocketRuntimePackageChunk, - pocketPackageFooterHash, - type PocketRuntimeAck, - type PocketRuntimeDiscovery, - type PocketRuntimeFrame, - type PocketRuntimeScreenshotBegin, -} from "../contracts/spec/pocket-runtime-wire.ts"; -import { encodePNG } from "../tests/png.ts"; +// Nintendo 3DS additions to the generic Pocket Runtime client: PICA200 +// surface decoding and the top/bottom PNG composition its screenshots need. +// Everything protocol-level is re-exported from pocket-runtime-client.ts. +import type { PocketRuntimeScreenshotBegin } from "../contracts/spec/pocket-runtime-wire.ts"; +import type { PocketRuntimeScreenshot } from "./pocket-runtime-client.ts"; +import { encodePNG } from "./png.ts"; -export interface PocketRuntimeScreenshot { - readonly frame: number; - readonly top: Uint8Array; - readonly auxiliary: Uint8Array; - readonly metadata: PocketRuntimeScreenshotBegin; - readonly png: Buffer; -} - -export interface PocketRuntimeClientOptions { - readonly host: string; - readonly token: Uint8Array; - readonly port?: number; - readonly timeoutMs?: number; - readonly heartbeatIntervalMs?: number; - readonly heartbeatTimeoutMs?: number; -} - -export interface DiscoveredPocketRuntime extends PocketRuntimeDiscovery { - readonly address: string; -} - -export interface PocketRuntimeDiscoveryOptions { - readonly port?: number; - readonly timeoutMs?: number; - readonly addresses?: readonly string[]; -} - -export interface PocketRuntimeSessionOptions { - readonly createClient: () => PocketRuntimeClient | Promise; - readonly retryDelayMs?: number; -} - -export async function discoverPocketRuntimes( - options: PocketRuntimeDiscoveryOptions = {}, -): Promise { - const port = options.port ?? POCKET_RUNTIME_WIRE_PORT; - const timeoutMs = options.timeoutMs ?? 4_000; - const addresses = options.addresses ?? ["255.255.255.255", "127.0.0.1"]; - const socket = createSocket("udp4"); - const found = new Map(); - return await new Promise((resolve, reject) => { - let timer: ReturnType | null = null; - let retry: ReturnType | null = null; - let settle: ReturnType | null = null; - let settled = false; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - if (retry) clearInterval(retry); - if (settle) clearTimeout(settle); - socket.close(); - if (error) reject(error); - else resolve([...found.values()].sort((a, b) => a.address.localeCompare(b.address))); - }; - socket.on("message", (message, remote) => { - try { - const value = decodePocketRuntimeDiscoveryReply(new Uint8Array(message)); - const key = value.deviceId.toString(16); - const current = found.get(key); - const candidate = { - ...value, - address: remote.address, - }; - // One Runtime can answer through more than one local interface. Its - // pairing-derived ID is stable across DHCP changes, so keep one route - // and prefer a LAN address over loopback when both answer. - if (!current || (current.address === "127.0.0.1" && remote.address !== "127.0.0.1")) { - found.set(key, candidate); - } - // Startup may need several seconds for SOC to become reachable. Once - // the first reply arrives, keep only a short window for other devices - // on the LAN instead of charging every command the startup timeout. - if (settle) clearTimeout(settle); - settle = setTimeout( - () => finish(), - Math.min(150, Math.max(10, Math.floor(timeoutMs / 2))), - ); - } catch { - // Other UDP services may share the broadcast domain. - } - }); - socket.once("error", (error) => finish(error)); - socket.bind(0, "0.0.0.0", () => { - socket.setBroadcast(true); - const request = encodePocketRuntimeDiscoveryRequest(); - const send = () => { - for (const address of addresses) socket.send(request, port, address, () => {}); - }; - send(); - // UDP discovery is deliberately stateless. Repeating the tiny request - // inside the same bounded window tolerates one dropped Wi-Fi broadcast. - retry = setInterval(send, Math.max(20, Math.min(250, Math.floor(timeoutMs / 3)))); - timer = setTimeout(() => finish(), timeoutMs); - }); - }); -} - -type CtrlValue = Record; - -export class PocketRuntimeClient extends EventEmitter { - readonly host: string; - readonly port: number; - readonly token: Uint8Array; - readonly timeoutMs: number; - readonly heartbeatIntervalMs: number; - readonly heartbeatTimeoutMs: number; - #socket: Socket | null = null; - #decoder = new PocketRuntimeFrameDecoder(); - #handshake = new Uint8Array(0); - #connected = false; - #closed = false; - #pingTimer: ReturnType | null = null; - #lastPongMs = 0; - #screenshot: { - metadata: PocketRuntimeScreenshotBegin; - top: Uint8Array; - auxiliary: Uint8Array; - topReceived: number; - auxiliaryReceived: number; - } | null = null; - - constructor(options: PocketRuntimeClientOptions) { - super(); - this.host = options.host; - this.port = options.port ?? POCKET_RUNTIME_WIRE_PORT; - this.token = options.token; - this.timeoutMs = options.timeoutMs ?? 10_000; - this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 2_000; - this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 8_000; - if (this.heartbeatIntervalMs <= 0 || this.heartbeatTimeoutMs <= this.heartbeatIntervalMs) { - throw new Error("Pocket Runtime heartbeat timeout must exceed its positive interval"); - } - } - - get connected(): boolean { - return this.#connected && !this.#closed; - } - - async connect(): Promise { - if (this.#socket) throw new Error("Pocket Runtime client is already connected"); - const socket = new Socket(); - this.#socket = socket; - socket.setNoDelay(true); - socket.on("data", (chunk: Buffer) => this.#onData(new Uint8Array(chunk))); - socket.on("error", (error) => this.emit("socketError", error)); - socket.on("close", () => { - this.#connected = false; - this.#closed = true; - if (this.#pingTimer) clearInterval(this.#pingTimer); - this.#pingTimer = null; - this.emit("close"); - }); - - await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`Pocket Runtime connection to ${this.host}:${this.port} timed out`)), - this.timeoutMs, - ); - const fail = (error: Error) => { - clearTimeout(timer); - reject(error); - }; - socket.once("error", fail); - socket.connect(this.port, this.host, () => { - socket.off("error", fail); - clearTimeout(timer); - resolve(); - }); - }); - const ackPromise = new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("Pocket Runtime handshake timed out")), - this.timeoutMs, - ); - const onAck = (value: PocketRuntimeAck) => { - clearTimeout(timer); - this.off("protocolError", onError); - resolve(value); - }; - const onError = (error: Error) => { - clearTimeout(timer); - this.off("ack", onAck); - reject(error); - }; - this.once("ack", onAck); - this.once("protocolError", onError); - }); - await this.#write(encodePocketRuntimeHello(this.token)); - const ack = await ackPromise; - if (!ack.accepted) { - this.close(); - throw new Error(`Pocket Runtime rejected the pairing token (status ${ack.status})`); - } - this.#connected = true; - this.#lastPongMs = Date.now(); - this.#pingTimer = setInterval(() => { - if (Date.now() - this.#lastPongMs > this.heartbeatTimeoutMs) { - this.emit( - "heartbeatTimeout", - new Error(`Pocket Runtime did not answer a heartbeat within ${this.heartbeatTimeoutMs} ms`), - ); - this.close(); - return; - } - const payload = new Uint8Array(4); - new DataView(payload.buffer).setUint32(0, Date.now() >>> 0, true); - void this.sendFrame(POCKET_RUNTIME_MSG.ping, payload).catch((error) => { - this.emit("socketError", error); - this.close(); - }); - }, this.heartbeatIntervalMs); - return ack; - } - - async sendFrame( - type: number, - payload: Uint8Array = new Uint8Array(0), - flags = 0, - ): Promise { - if (!this.#socket || this.#closed) throw new Error("Pocket Runtime socket is closed"); - await this.#write(encodePocketRuntimeFrame(type, payload, flags)); - } - - async sendCtrl(value: string | CtrlValue): Promise { - const text = typeof value === "string" ? value : JSON.stringify(value); - const bytes = new TextEncoder().encode(text); - if (bytes.length === 0 || bytes.length > POCKET_RUNTIME_MAX_CTRL_BYTES || /[\r\n]/.test(text)) { - throw new Error(`Pocket Runtime control must be one JSON record of at most ${POCKET_RUNTIME_MAX_CTRL_BYTES} bytes`); - } - await this.sendFrame(POCKET_RUNTIME_MSG.ctrl, bytes); - } - - async requestStatus(): Promise { - await this.sendFrame(POCKET_RUNTIME_MSG.statusRequest); - } - - async install(bytes: Uint8Array): Promise { - const hash = pocketPackageFooterHash(bytes); - await this.sendFrame( - POCKET_RUNTIME_MSG.packageBegin, - encodePocketRuntimePackageBegin(bytes.length, hash), - ); - const chunkBytes = POCKET_RUNTIME_MAX_FRAME_BYTES - 4; - for (let offset = 0; offset < bytes.length; offset += chunkBytes) { - const chunk = bytes.subarray(offset, Math.min(offset + chunkBytes, bytes.length)); - await this.sendFrame( - POCKET_RUNTIME_MSG.packageChunk, - encodePocketRuntimePackageChunk(offset, chunk), - ); - } - await this.sendFrame(POCKET_RUNTIME_MSG.packageCommit); - return hash; - } - - async waitForCtrl( - predicate: (value: CtrlValue) => boolean, - timeoutMs = this.timeoutMs, - ): Promise { - return await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer); - this.off("ctrl", onCtrl); - this.off("close", onClose); - this.off("protocolError", onProtocolError); - }; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Pocket Runtime control response timed out after ${timeoutMs} ms`)); - }, timeoutMs); - const onCtrl = (value: CtrlValue) => { - // The device says when a record was too large for a frame. Whatever is - // being awaited may well be that record, and a stated size beats - // waiting out the timeout with nothing to go on. - if (value.t === "ctrlDropped") { - cleanup(); - reject( - new Error( - `Pocket Runtime dropped a ${String(value.bytes)} byte control record ` + - `(frame cap ${String(value.cap)})`, - ), - ); - return; - } - if (!predicate(value)) return; - cleanup(); - resolve(value); - }; - const onClose = () => { - cleanup(); - reject(new Error("Pocket Runtime connection closed while waiting for control")); - }; - const onProtocolError = (error: Error) => { - cleanup(); - reject(error); - }; - this.on("ctrl", onCtrl); - this.once("close", onClose); - this.once("protocolError", onProtocolError); - }); - } - - async waitForScreenshot(timeoutMs = this.timeoutMs): Promise { - return await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer); - this.off("screenshot", onScreenshot); - this.off("close", onClose); - this.off("protocolError", onProtocolError); - }; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Pocket Runtime screenshot timed out after ${timeoutMs} ms`)); - }, timeoutMs); - const onScreenshot = (value: PocketRuntimeScreenshot) => { - cleanup(); - resolve(value); - }; - const onClose = () => { - cleanup(); - reject(new Error("Pocket Runtime connection closed while waiting for screenshot")); - }; - const onProtocolError = (error: Error) => { - cleanup(); - reject(error); - }; - this.on("screenshot", onScreenshot); - this.once("close", onClose); - this.once("protocolError", onProtocolError); - }); - } - - close(): void { - this.#closed = true; - if (this.#pingTimer) clearInterval(this.#pingTimer); - this.#pingTimer = null; - this.#socket?.destroy(); - this.#socket = null; - } - - async #write(bytes: Uint8Array): Promise { - const socket = this.#socket; - if (!socket || socket.destroyed) throw new Error("Pocket Runtime socket is closed"); - await new Promise((resolve, reject) => { - socket.write(bytes, (error) => error ? reject(error) : resolve()); - }); - } - - #onData(chunk: Uint8Array): void { - try { - if (!this.#connected) { - const joined = new Uint8Array(this.#handshake.length + chunk.length); - joined.set(this.#handshake); - joined.set(chunk, this.#handshake.length); - this.#handshake = joined; - if (this.#handshake.length < POCKET_RUNTIME_ACK_BYTES) return; - const ackBytes = this.#handshake.slice(0, POCKET_RUNTIME_ACK_BYTES); - const remainder = this.#handshake.slice(POCKET_RUNTIME_ACK_BYTES); - this.#handshake = new Uint8Array(0); - const ack = decodePocketRuntimeAck(ackBytes); - this.#connected = ack.accepted; - this.emit("ack", ack); - if (ack.accepted && remainder.length > 0) this.#decodeFrames(remainder); - return; - } - this.#decodeFrames(chunk); - } catch (error) { - this.emit("protocolError", error instanceof Error ? error : new Error(String(error))); - this.close(); - } - } - - #decodeFrames(chunk: Uint8Array): void { - for (const frame of this.#decoder.push(chunk)) this.#handleFrame(frame); - } - - #handleFrame(frame: PocketRuntimeFrame): void { - switch (frame.type) { - case POCKET_RUNTIME_MSG.ping: - void this.sendFrame(POCKET_RUNTIME_MSG.pong, frame.payload).catch(() => {}); - return; - case POCKET_RUNTIME_MSG.pong: - this.#lastPongMs = Date.now(); - this.emit("pong", frame.payload); - return; - case POCKET_RUNTIME_MSG.ctrl: { - const line = new TextDecoder().decode(frame.payload); - this.emit("ctrlLine", line); - try { - this.emit("ctrl", JSON.parse(line) as CtrlValue); - } catch { - this.emit("protocolError", new Error("Pocket Runtime sent invalid control JSON")); - } - return; - } - case POCKET_RUNTIME_MSG.screenshotBegin: { - const metadata = decodePocketRuntimeScreenshotBegin(frame.payload); - this.#screenshot = { - metadata, - top: new Uint8Array(metadata.topBytes), - auxiliary: new Uint8Array(metadata.auxiliaryBytes), - topReceived: 0, - auxiliaryReceived: 0, - }; - return; - } - case POCKET_RUNTIME_MSG.screenshotChunk: { - const shot = this.#screenshot; - if (!shot || frame.payload.length <= 4 || frame.flags > 1) { - throw new Error("Pocket Runtime screenshot chunk arrived out of sequence"); - } - const offset = new DataView( - frame.payload.buffer, - frame.payload.byteOffset, - 4, - ).getUint32(0, true); - const destination = frame.flags === 0 ? shot.top : shot.auxiliary; - const received = frame.flags === 0 ? shot.topReceived : shot.auxiliaryReceived; - const bytes = frame.payload.subarray(4); - if (offset !== received || offset + bytes.length > destination.length) { - throw new Error("Pocket Runtime screenshot chunk is out of order or exceeds its surface"); - } - destination.set(bytes, offset); - if (frame.flags === 0) shot.topReceived += bytes.length; - else shot.auxiliaryReceived += bytes.length; - return; - } - case POCKET_RUNTIME_MSG.screenshotEnd: { - const shot = this.#screenshot; - if (!shot || frame.payload.length !== 4) { - throw new Error("Pocket Runtime screenshot end arrived out of sequence"); - } - const frameNumber = new DataView( - frame.payload.buffer, - frame.payload.byteOffset, - 4, - ).getUint32(0, true); - if (frameNumber !== shot.metadata.frame) { - throw new Error("Pocket Runtime screenshot frame identity changed mid-transfer"); - } - if (shot.topReceived !== shot.top.length || - shot.auxiliaryReceived !== shot.auxiliary.length) { - throw new Error("Pocket Runtime screenshot ended before both surfaces were complete"); - } - const png = combinePocketRuntimeScreens(shot.metadata, shot.top, shot.auxiliary); - const result: PocketRuntimeScreenshot = { - frame: frameNumber, - top: shot.top, - auxiliary: shot.auxiliary, - metadata: shot.metadata, - png, - }; - this.#screenshot = null; - this.emit("screenshot", result); - return; - } - default: - this.emit("unknownFrame", frame); - } - } -} - -/** - * Keeps one logical DevTools attachment across replaceable TCP clients. - * A client still owns exactly one ordered connection; this session owns only - * desktop-side reconnect policy and never enters the Runtime or guest API. - */ -export class PocketRuntimeSession extends EventEmitter { - readonly retryDelayMs: number; - #createClient: PocketRuntimeSessionOptions["createClient"]; - #client: PocketRuntimeClient | null = null; - #connecting: Promise | null = null; - #stopped = false; - - constructor(options: PocketRuntimeSessionOptions) { - super(); - this.#createClient = options.createClient; - this.retryDelayMs = options.retryDelayMs ?? 750; - } - - get connected(): boolean { - return this.#client?.connected ?? false; - } - - async start(): Promise { - if (this.#stopped) throw new Error("Pocket Runtime session is closed"); - if (this.#client?.connected) return this.#client; - return await this.#connectOnce(false); - } - - async requireClient(): Promise { - if (this.#stopped) throw new Error("Pocket Runtime session is closed"); - if (this.#client?.connected) return this.#client; - return await this.#reconnect(); - } - - async sendCtrl(value: string | CtrlValue): Promise { - const client = await this.requireClient(); - await client.sendCtrl(value); - } - - close(): void { - if (this.#stopped) return; - this.#stopped = true; - this.#client?.close(); - this.#client = null; - } - - async #connectOnce(reconnecting: boolean): Promise { - const client = await this.#createClient(); - const forward = (event: string) => (...args: unknown[]) => this.emit(event, ...args); - for (const event of [ - "screenshot", - "ctrlLine", - "ctrl", - "protocolError", - "unknownFrame", - "pong", - "heartbeatTimeout", - "socketError", - ]) { - client.on(event, forward(event)); - } - client.once("close", () => { - if (this.#client !== client) return; - this.#client = null; - this.emit("disconnect"); - if (!this.#stopped) void this.#reconnect().catch(() => {}); - }); - - let ack: PocketRuntimeAck; - try { - ack = await client.connect(); - } catch (error) { - client.close(); - throw error; - } - if (this.#stopped) { - client.close(); - throw new Error("Pocket Runtime session is closed"); - } - if (!client.connected) { - client.close(); - throw new Error("Pocket Runtime connection closed during its handshake"); - } - this.#client = client; - this.emit(reconnecting ? "reconnect" : "connect", client, ack); - return client; - } - - #reconnect(): Promise { - if (this.#connecting) return this.#connecting; - const task = (async () => { - while (!this.#stopped) { - this.emit("reconnectAttempt"); - try { - return await this.#connectOnce(true); - } catch (error) { - if (this.#stopped) break; - this.emit("reconnectError", error instanceof Error ? error : new Error(String(error))); - await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs)); - } - } - throw new Error("Pocket Runtime session is closed"); - })(); - this.#connecting = task; - void task.finally(() => { - if (this.#connecting === task) this.#connecting = null; - }).catch(() => {}); - return task; - } -} +export * from "./pocket-runtime-client.ts"; /** PICA target RGB8 is B,G,R in rotated column-major screen order. */ export function decodePocketRuntimeSurface( @@ -621,6 +30,7 @@ export function decodePocketRuntimeSurface( return rgba; } +/** Top screen over bottom screen, each centered, as one PNG. */ export function combinePocketRuntimeScreens( metadata: PocketRuntimeScreenshotBegin, top: Uint8Array, @@ -658,10 +68,7 @@ export function combinePocketRuntimeScreens( return encodePNG(Buffer.from(rgba), width, height); } -export function parsePocketRuntimeToken(text: string): Uint8Array { - const hex = text.trim(); - if (!/^[0-9a-f]{64}$/i.test(hex)) { - throw new Error("Pocket Runtime key must contain exactly 64 hexadecimal characters"); - } - return Uint8Array.from(Buffer.from(hex, "hex")); +/** The PNG of one streamed 3DS screenshot. */ +export function render3dsScreenshotPng(screenshot: PocketRuntimeScreenshot): Buffer { + return combinePocketRuntimeScreens(screenshot.metadata, screenshot.top, screenshot.auxiliary); } diff --git a/tools/devtools-bridge.ts b/tools/devtools-bridge.ts index 116608280..5a3e0d699 100644 --- a/tools/devtools-bridge.ts +++ b/tools/devtools-bridge.ts @@ -21,7 +21,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { bundleHash, launcherBundleHash } from "./bundle-hash.ts"; -import { encodePNG } from "../tests/png.ts"; +import { encodePNG } from "./png.ts"; export interface BridgeEvent { type: diff --git a/tools/ipodtouch4-package.ts b/tools/ipodtouch4-package.ts new file mode 100644 index 000000000..34d6762c7 --- /dev/null +++ b/tools/ipodtouch4-package.ts @@ -0,0 +1,68 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { canonicalJson } from "../framework/src/manifest/plan.ts"; +import { decodeIdentity, decodePocketPackage, encodePocketPackage, findSection, POCKET_SECTION } from "../contracts/spec/pocket-package.ts"; +import { IPODTOUCH4_DEV_TARGET_ID, resolveIPodTouch4BuildPlan } from "./ipodtouch4-profile.ts"; +import { makeVariant } from "./pocket-pack.ts"; + +const ROOT = new URL("..", import.meta.url).pathname; +export const IPODTOUCH4_MAX_PACKAGE_BYTES = 24 * 1024 * 1024; + +/** Admission on the desktop checks the manifest, plan and identity before any + * transfer. The native receiver checks the bytes again, including its viewport. */ +export function verifyIPodTouch4Package(bytes: Uint8Array) { + if (bytes.length > IPODTOUCH4_MAX_PACKAGE_BYTES) throw new Error("iPod package exceeds 24 MiB"); + const pkg = decodePocketPackage(bytes); + const manifest = JSON.parse(new TextDecoder().decode(pkg.manifest)); + const plan = resolveIPodTouch4BuildPlan(manifest); + const variant = pkg.variants.find((entry) => entry.target === IPODTOUCH4_DEV_TARGET_ID); + if (!variant || variant.hostAbi !== plan.target.hostAbi) throw new Error("package has no matching iPod target/ABI"); + const section = (kind: number) => { + const value = findSection(variant, kind); + if (!value?.length) throw new Error(`iPod package is missing section ${kind}`); + return value; + }; + if (canonicalJson(JSON.parse(new TextDecoder().decode(section(POCKET_SECTION.plan)))) !== canonicalJson(plan)) { + throw new Error("iPod package plan differs from its resolved manifest"); + } + const identity = decodeIdentity(section(POCKET_SECTION.identity)); + if (identity.id !== plan.app.id || identity.output !== plan.app.output || identity.title !== plan.app.title) { + throw new Error("iPod package identity differs from its plan"); + } + const js = section(POCKET_SECTION.js); + if (js.at(-1) !== 0) throw new Error("iPod package JavaScript needs its QuickJS terminator"); + section(POCKET_SECTION.pak); + return plan; +} + +export async function buildIPodTouch4Package(options: { + manifest: string; + projectRoot?: string; + outdir?: string; +}) { + const projectRoot = resolve(options.projectRoot ?? ROOT); + const manifestPath = resolve(projectRoot, options.manifest); + const manifest = readFileSync(manifestPath); + const plan = resolveIPodTouch4BuildPlan(JSON.parse(manifest.toString())); + const outdir = resolve(options.outdir ?? join(ROOT, "dist/ipodtouch4/packages", plan.app.output)); + mkdirSync(outdir, { recursive: true }); + const planPath = join(outdir, "plan.json"); + writeFileSync(planPath, JSON.stringify(plan, null, 2) + "\n"); + const child = Bun.spawn([process.execPath, join(ROOT, "tools/build.ts"), + `--plan=${planPath}`, `--project-root=${projectRoot}`, `--outdir=${outdir}`], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }); + if (await child.exited) throw new Error("iPod guest build failed"); + const bytes = encodePocketPackage({ manifest, variants: [makeVariant({ + target: plan.target.id, + hostAbi: plan.target.hostAbi, + planJson: canonicalJson(plan), + identity: { id: plan.app.id, title: plan.app.title, output: plan.app.output }, + js: readFileSync(join(outdir, `${plan.app.output}.js`)), + pak: readFileSync(join(outdir, `${plan.app.output}.pak`)), + })] }); + verifyIPodTouch4Package(bytes); + const path = join(outdir, `${plan.app.output}.pocket`); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, bytes); + return { path, plan, projectRoot, manifestPath }; +} diff --git a/tools/ipodtouch4-runtime.ts b/tools/ipodtouch4-runtime.ts new file mode 100644 index 000000000..cf05977cb --- /dev/null +++ b/tools/ipodtouch4-runtime.ts @@ -0,0 +1,451 @@ +#!/usr/bin/env bun +import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { encodePocketPackage } from "../contracts/spec/pocket-package.ts"; +import { pocketPackageFooterHash, pocketRuntimeDeviceId, POCKET_RUNTIME_WIRE_PORT } from "../contracts/spec/pocket-runtime-wire.ts"; +import { canonicalJson } from "../framework/src/manifest/plan.ts"; +import { startDevServer } from "../hosts/web/server.ts"; +import { IPODTOUCH4_DEV_TARGET_ID, IPODTOUCH4_DEV_HOST_ABI } from "./ipodtouch4-profile.ts"; +import { IPODTOUCH4_APPS, IPODTOUCH4_RUNTIME_KEYS, pairIPodTouch4Runtime, withIPodTouch4RuntimeUsb } from "./ipodtouch4.ts"; +import { buildIPodTouch4Package, verifyIPodTouch4Package } from "./ipodtouch4-package.ts"; +import { makeVariant } from "./pocket-pack.ts"; +import { discoverPocketRuntimes, parsePocketRuntimeToken, PocketRuntimeClient, PocketRuntimeSession } from "./pocket-runtime-client.ts"; + +const ROOT = resolve(new URL("..", import.meta.url).pathname); +type Options = { command: string; app: string; manifest?: string; projectRoot: string; + package?: string; host?: string; key?: string; port: number; lan: boolean; rotate: boolean; + panelPort: number; noPush: boolean; pushes: number; skipBackground: boolean; autoBackground: boolean; }; +type Target = { host: string; port: number; token: Uint8Array }; + +export function parseRuntimeOptions(argv: readonly string[]): Options { + const args = [...argv]; + const result: Options = { command: args.shift() ?? "help", app: "clear", projectRoot: ROOT, + port: POCKET_RUNTIME_WIRE_PORT, lan: false, rotate: false, panelPort: 8130, noPush: false, + pushes: 5, skipBackground: false, autoBackground: false }; + while (args.length) { + const arg = args.shift()!; + if (arg === "--lan") result.lan = true; + else if (arg === "--usb") result.lan = false; + else if (arg === "--rotate") result.rotate = true; + else if (arg === "--no-push") result.noPush = true; + else if (arg === "--skip-background") result.skipBackground = true; + else if (arg === "--auto-background") result.autoBackground = true; + else if (arg === "--help" || arg === "-h") result.command = "help"; + else { + const value = args.shift(); + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (arg === "--app") result.app = value; + else if (arg === "--manifest") result.manifest = value; + else if (arg === "--project-root") result.projectRoot = resolve(value); + else if (arg === "--package") result.package = resolve(value); + else if (arg === "--host") { result.host = value; result.lan = true; } + else if (arg === "--key") result.key = resolve(value); + else if (arg === "--port") result.port = Number(value); + else if (arg === "--panel-port") result.panelPort = Number(value); + else if (arg === "--pushes") result.pushes = Number(value); + else throw new Error(`unknown option ${arg}`); + } + } + for (const port of [result.port, result.panelPort]) { + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("port must be an integer from 1 to 65535"); + } + if (!/^[a-zA-Z0-9_-]+$/.test(result.app)) throw new Error("--app must name a repository app; use --manifest for external apps"); + if (!Number.isInteger(result.pushes) || result.pushes < 1 || result.pushes > 50) throw new Error("--pushes must be an integer from 1 to 50"); + return result; +} +function keys(options: Options): Uint8Array[] { + if (options.key) return [parsePocketRuntimeToken(readFileSync(options.key, "utf8"))]; + if (!existsSync(IPODTOUCH4_RUNTIME_KEYS)) return []; + return readdirSync(IPODTOUCH4_RUNTIME_KEYS).filter((name) => name.endsWith(".key")) + .map((name) => parsePocketRuntimeToken(readFileSync(join(IPODTOUCH4_RUNTIME_KEYS, name), "utf8"))); +} +async function lanTarget(options: Options) { + const tokens = keys(options); + if (options.host && options.key) return { host: options.host, port: options.port, token: tokens[0] }; + const devices = await discoverPocketRuntimes({ port: options.port, + ...(options.host ? { addresses: [options.host] } : {}) }); + const matches = devices.filter((device) => device.target === IPODTOUCH4_DEV_TARGET_ID && device.hostAbi === IPODTOUCH4_DEV_HOST_ABI) + .flatMap((device) => { + const token = tokens.find((value) => pocketRuntimeDeviceId(value) === device.deviceId); + return token ? [{ host: device.address, port: device.port, token }] : []; + }); + if (matches.length !== 1) throw new Error(`found ${matches.length} paired iPod Runtimes; use --host and --key to select one`); + return matches[0]; +} +async function packagePath(options: Options): Promise { + if (options.package) { + verifyIPodTouch4Package(readFileSync(options.package)); + return options.package; + } + const result = await buildIPodTouch4Package({ + manifest: options.manifest ?? `apps/${options.app}/pocket.json`, projectRoot: options.projectRoot, + }); + return result.path; +} +export async function pushIPodTouch4Package(client: PocketRuntimeClient, filename: string): Promise { + const bytes = readFileSync(filename); + verifyIPodTouch4Package(bytes); + const hash = pocketPackageFooterHash(bytes).toString(16).padStart(16, "0"); + const verdict = client.waitForCtrl((message) => message.t === "runtime.install" && message.hash === hash && + ["accepted", "rejected", "transfer-error"].includes(String(message.phase)), 60000); + // Attach both rejections before sending: an upload failure must not leave a + // later verdict timeout as an unhandled promise rejection. + const [, result] = await Promise.all([client.install(bytes), verdict]); + if (result.phase !== "accepted") throw new Error(`Runtime rejected ${hash}: ${String(result.message)}`); + console.log(`accepted ${hash} (${bytes.length} bytes)`); +} +function report(message: Record) { + if (message.t === "log") console.log(`[${String(message.level)}] ${JSON.stringify(message.args)}`); + else if (message.t === "runtime.install") console.log(`${message.phase}: ${message.message}`); +} +async function checkHost(client: PocketRuntimeClient) { + const response = client.waitForCtrl((message) => message.t === "runtime.status"); + await client.requestStatus(); + const status = await response; + if (status.target !== IPODTOUCH4_DEV_TARGET_ID || status.hostAbi !== IPODTOUCH4_DEV_HOST_ABI) { + throw new Error("connected Runtime is not the supported iPod touch 4 target/ABI"); + } + return status; +} +async function once(options: Options, target: { host: string; port: number; token: Uint8Array }, filename?: string) { + const client = new PocketRuntimeClient(target); + client.on("ctrl", report); + try { + await client.connect(); + const status = await checkHost(client); + if (options.command === "status") console.log(JSON.stringify(status, null, 2)); + else if (filename) await pushIPodTouch4Package(client, filename); + } finally { client.close(); } +} + +async function dev(options: Options, firstTarget: { host: string; port: number; token: Uint8Array }) { + let target = firstTarget; + const deviceId = pocketRuntimeDeviceId(target.token); + let attempts = 0; + const session = new PocketRuntimeSession({ createClient: async () => { + if (attempts++ && options.lan && !options.host) { + const devices = await discoverPocketRuntimes({ port: options.port }); + const match = devices.find((entry) => entry.deviceId === deviceId && entry.target === IPODTOUCH4_DEV_TARGET_ID); + if (!match) throw new Error("paired iPod Runtime is not answering discovery"); + target = { ...target, host: match.address, port: match.port }; + } + return new PocketRuntimeClient(target); + } }); + const server = startDevServer({ port: options.panelPort, portRetries: 10 }); + const socket = new WebSocket(`ws://127.0.0.1:${server.port}/ws?role=device`); + let stopped = false; + let building = false; + let dirty = !options.noPush; + let timer: ReturnType | undefined; + const watchers: ReturnType[] = []; + let finish!: () => void; + const finished = new Promise((resolveDone) => { finish = resolveDone; }); + const cleanup = () => { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + for (const watcher of watchers) watcher.close(); + session.close(); + socket.close(); + server.stop(); + finish(); + }; + process.once("SIGINT", cleanup); + process.once("SIGTERM", cleanup); + const rebuild = async () => { + if (building || stopped || !dirty || !session.connected) return; + building = true; + dirty = false; + try { + const filename = await packagePath(options); + if (!stopped) { + const client = await session.requireClient(); + await checkHost(client); + await pushIPodTouch4Package(client, filename); + } + } catch (error) { + console.error(String(error)); + // Keep the latest build pending across a connection loss. A compile or + // admission error waits for another source edit instead of retrying it. + if (!session.connected) dirty = true; + } finally { + building = false; + if (dirty && !stopped) timer = setTimeout(() => void rebuild(), 300); + } + }; + session.on("ctrl", report); + session.on("ctrlLine", (line: string) => { if (socket.readyState === WebSocket.OPEN) socket.send(line); }); + session.on("connect", () => console.log("Runtime connected")); + session.on("reconnect", () => { console.log("Runtime reconnected"); void rebuild(); }); + session.on("disconnect", () => console.log("Runtime disconnected; waiting for it to return")); + let reconnectErrors = 0; + session.on("reconnectError", (error: Error) => { + if (++reconnectErrors === 1 || reconnectErrors % 12 === 0) console.error(error.message); + }); + socket.onmessage = (event) => { + if (typeof event.data === "string") void session.sendCtrl(event.data).catch((error) => console.error(String(error))); + }; + try { + await new Promise((opened, reject) => { + socket.onopen = () => opened(); + socket.onerror = () => reject(new Error("DevTools panel connection failed")); + }); + // requireClient keeps the foreground/background reconnect loop alive even + // when Runtime is not open yet; signal handlers can still close it. + const client = await session.requireClient(); + await checkHost(client); + const watched = options.package ? [dirname(options.package)] : [...new Set([options.projectRoot, ROOT])]; + for (const directory of watched) { + const watcher = watch(directory, { recursive: true }, (_event, name) => { + if (!name) return; + const file = name.toString(); + if (file.split(/[\\/]/).some((part) => ["node_modules", ".git", ".pocket", ".pocket-build", "dist", "target"].includes(part))) return; + if (options.package && resolve(directory, file) !== options.package) return; + if (!options.package && directory === ROOT && /^(engine|hosts|site|tests|docs)\//.test(file)) return; + dirty = true; + if (timer) clearTimeout(timer); + timer = setTimeout(() => void rebuild(), 300); + }); + watcher.on("error", (error) => { console.error(`watch failed: ${error.message}`); cleanup(); }); + watchers.push(watcher); + } + console.log(`DevTools: ${server.panelUrl}\nWatching ${options.package ?? options.manifest ?? options.app}; Ctrl-C to stop`); + await rebuild(); + await finished; + } finally { + cleanup(); + process.off("SIGINT", cleanup); + process.off("SIGTERM", cleanup); + } +} + +async function statusOf(client: PocketRuntimeClient): Promise> { + const reply = client.waitForCtrl((message) => message.t === "runtime.status"); + await client.requestStatus(); + return await reply; +} +async function verdictOf(client: PocketRuntimeClient, bytes: Uint8Array): Promise> { + const hash = pocketPackageFooterHash(bytes).toString(16).padStart(16, "0"); + const verdict = client.waitForCtrl((message) => message.t === "runtime.install" && message.hash === hash && + ["accepted", "rejected", "transfer-error"].includes(String(message.phase)), 60000); + const [, result] = await Promise.all([client.install(bytes), verdict]); + return { ...result, hash }; +} +/** SpringBoard app switch over the USB SSH tunnel (bun ipodtouch4 open-url). */ +async function openUrlOnDevice(url: string): Promise { + const child = Bun.spawn([process.execPath, join(ROOT, "tools/ipodtouch4.ts"), "open-url", url], { + cwd: ROOT, env: { ...process.env, POCKETJS_IPODTOUCH4_APP: "runtime", POCKETJS_IPODTOUCH4_APP_FILE: "" }, + stdout: "pipe", stderr: "pipe", + }); + const [code, err] = await Promise.all([child.exited, new Response(child.stderr).text()]); + if (code) throw new Error(`open-url ${url} failed: ${err.trim()}`); +} +/** The native shell's acceptance record (bun ipodtouch4 status for the Runtime + * app), read over the USB SSH tunnel; null when it is unavailable. */ +async function shellRecord(): Promise | null> { + const child = Bun.spawn([process.execPath, join(ROOT, "tools/ipodtouch4.ts"), "status"], { + cwd: ROOT, env: { ...process.env, POCKETJS_IPODTOUCH4_APP: "runtime", POCKETJS_IPODTOUCH4_APP_FILE: "" }, + stdout: "pipe", stderr: "pipe", + }); + const [code, out] = await Promise.all([child.exited, new Response(child.stdout).text()]); + const start = out.indexOf("{"); + const end = out.lastIndexOf("}"); + if (code || start < 0 || end < start) return null; + try { return JSON.parse(out.slice(start, end + 1)) as Record; } catch { return null; } +} + +/** + * The device acceptance pass from docs/IPODTOUCH4.md, run against a paired + * Runtime in the foreground: repeated replacement, a first-frame failure, a + * later-frame failure with recovery, a reconnect, one foreground/background + * round trip (the operator presses Home) and the shell's resident memory + * before and after. The receipt goes to .pocket-build/validation/. + */ +async function acceptance(options: Options, target: Target): Promise { + const runId = new Date().toISOString().replace(/[:.]/g, "-"); + const runDir = join(ROOT, ".pocket-build/validation/ipodtouch4-runtime", runId); + mkdirSync(runDir, { recursive: true }); + const steps: Record[] = []; + const failures: string[] = []; + const record = (step: string, ok: boolean, data: Record) => { + steps.push({ step, ok, ...data }); + console.log(`${ok ? "[ok]" : "[FAIL]"} ${step} ${JSON.stringify(data)}`); + if (!ok) failures.push(step); + }; + const built = await buildIPodTouch4Package({ + manifest: options.manifest ?? `apps/${options.app}/pocket.json`, projectRoot: options.projectRoot, + }); + const outdir = dirname(built.path); + const manifest = readFileSync(built.manifestPath); + const js = readFileSync(join(outdir, `${built.plan.app.output}.js`)); + const pak = readFileSync(join(outdir, `${built.plan.app.output}.pak`)); + const plan = built.plan; + const synthesize = (source: Uint8Array, assets: Uint8Array) => encodePocketPackage({ manifest, variants: [makeVariant({ + target: plan.target.id, hostAbi: plan.target.hostAbi, planJson: canonicalJson(plan), + identity: { id: plan.app.id, title: plan.app.title, output: plan.app.output }, js: source, pak: assets, + })] }); + // Each replacement carries a distinct hash: the same compiled guest with a run-specific trailer. + const replacement = (n: number) => synthesize(Buffer.concat([js, Buffer.from(`\n// acceptance ${runId} push ${n}\n`)]), pak); + const encoder = new TextEncoder(); + const firstFrameFailure = synthesize(encoder.encode( + "globalThis.frame = function () { throw new Error('acceptance: first frame failure'); };"), new Uint8Array([0])); + const laterFailure = synthesize(encoder.encode( + "let n = 0; globalThis.frame = function () { if (++n > 90) throw new Error('acceptance: later frame failure'); };"), new Uint8Array([0])); + + const session = new PocketRuntimeSession({ createClient: () => new PocketRuntimeClient({ ...target, timeoutMs: 15000 }) }); + session.on("ctrl", report); + try { + let client = await session.start(); + await checkHost(client); + const before = await statusOf(client); + record("baseline", before.phase === "accepted", { status: before }); + const shellBefore = await shellRecord(); + let active = String(before.active); + let generation = Number(before.generation); + + for (let n = 1; n <= options.pushes; n += 1) { + const verdict = await verdictOf(client, replacement(n)); + const status = await statusOf(client); + const ok = verdict.phase === "accepted" && status.active === verdict.hash && Number(status.generation) === generation + 1; + record(`replacement ${n}/${options.pushes}`, ok, { + hash: verdict.hash, phase: verdict.phase, message: verdict.message, + generation: status.generation, bootMs: status.bootMs, firstFrameMs: status.firstFrameMs, + }); + if (ok) { active = String(status.active); generation = Number(status.generation); } + } + + { + const verdict = await verdictOf(client, firstFrameFailure); + const status = await statusOf(client); + record("first-frame failure", verdict.phase === "rejected" && status.active === active && status.phase === "accepted", { + phase: verdict.phase, message: verdict.message, active: status.active, generation: status.generation, + }); + } + { + const verdict = await verdictOf(client, laterFailure); + const startedAt = Date.now(); + let recovered: Record | null = null; + while (Date.now() - startedAt < 20000) { + const status = await statusOf(client); + if (status.active === active && status.running === active && status.phase === "accepted") { recovered = status; break; } + await Bun.sleep(250); + } + record("later-frame failure", verdict.phase === "accepted" && recovered !== null && recovered.lastGood !== verdict.hash, { + phase: verdict.phase, message: verdict.message, recoveredAfterMs: recovered ? Date.now() - startedAt : null, status: recovered, + }); + generation = recovered ? Number(recovered.generation) : generation; + } + { + client.close(); + client = await session.requireClient(); + const tree = client.waitForCtrl((message) => message.t === "tree"); + await client.sendCtrl({ t: "getTree" }); + const evaluated = client.waitForCtrl((message) => message.t === "evalResult" && message.id === "acceptance"); + await client.sendCtrl({ t: "eval", id: "acceptance", code: "ui.__host + ':' + ui.__hostAbi" }); + const [treeResult, evalResult] = await Promise.all([tree, evaluated]); + record("reconnect", treeResult.t === "tree" && evalResult.value === `${IPODTOUCH4_DEV_TARGET_ID}:${IPODTOUCH4_DEV_HOST_ABI}`, { + tree: treeResult.t, eval: evalResult.value, + }); + } + if (!options.skipBackground) { + // Automatic: SpringBoard brings Settings forward and, ten seconds later, + // Runtime again. The shell resigns active, closes its sockets, and + // reopens the listener when it becomes active; the same callbacks the + // Home button drives. Manual: the operator presses Home and reopens. + const wait = (event: string, timeoutMs: number) => new Promise((done) => { + const timer = setTimeout(() => done(false), timeoutMs); + session.once(event, () => { clearTimeout(timer); done(true); }); + }); + const timeoutMs = options.autoBackground ? 30000 : 180000; + const disconnecting = wait("disconnect", timeoutMs); + if (options.autoBackground) await openUrlOnDevice("prefs:root=General"); + else console.log("\n>>> Press the Home button on the iPod now, wait ten seconds, then reopen Pocket Runtime (up to three minutes)."); + const disconnected = await disconnecting; + const reconnecting = wait("reconnect", timeoutMs); + if (options.autoBackground && disconnected) { + await Bun.sleep(10000); + await openUrlOnDevice(`${IPODTOUCH4_APPS.runtime.scheme}://launch`); + } + const reconnected = disconnected ? await reconnecting : false; + let status: Record | null = null; + let verdict: Record | null = null; + if (reconnected) { + client = await session.requireClient(); + status = await statusOf(client); + verdict = await verdictOf(client, replacement(options.pushes + 1)); + } + // The package that was active before the switch must still be active on + // return (generation and hash reloaded from disk), and a push must land. + const kept = status !== null && status.active === active && status.phase === "accepted"; + record("background/foreground", disconnected && reconnected && kept && verdict?.phase === "accepted", { + mode: options.autoBackground ? "springboard app switch" : "home button", + disconnected, reconnected, statusAfterReturn: status, pushAfterReturn: verdict?.phase ?? null, + }); + if (verdict?.phase === "accepted") { active = String(verdict.hash); generation += 1; } + } + const shellAfter = await shellRecord(); + const beforeKb = Number(shellBefore?.resident_kb ?? NaN); + const afterKb = Number(shellAfter?.resident_kb ?? NaN); + const measured = Number.isFinite(beforeKb) && Number.isFinite(afterKb) && beforeKb > 0; + record("resident memory", !measured || afterKb <= beforeKb * 1.5, { + beforeKb: measured ? beforeKb : null, afterKb: measured ? afterKb : null, + note: measured ? "after all swaps versus the first accepted guest" : "shell record unavailable", + }); + writeFileSync(join(runDir, "shell-before.json"), JSON.stringify(shellBefore, null, 2)); + writeFileSync(join(runDir, "shell-after.json"), JSON.stringify(shellAfter, null, 2)); + } finally { + session.close(); + } + writeFileSync(join(runDir, "receipt.json"), JSON.stringify({ runId, host: target.host, port: target.port, pushes: options.pushes, steps, failures }, null, 2) + "\n"); + console.log(`receipt: ${join(runDir, "receipt.json")}`); + if (failures.length > 0) throw new Error(`acceptance failed: ${failures.join(", ")}`); + console.log("acceptance passed"); +} + +function usage() { + console.log(`Pocket Runtime for iPod touch 4 + bun ipodtouch4:runtime build|deploy|launch|capture|uninstall + bun ipodtouch4:runtime pair [--rotate] + bun ipodtouch4:runtime pack [--app clear | --manifest path --project-root directory] + bun ipodtouch4:runtime discover + bun ipodtouch4:runtime status [--lan | --host IP --key file] + bun ipodtouch4:runtime push [--app clear | --package file.pocket] [--lan] + bun ipodtouch4:runtime dev [--app clear | --manifest path --project-root directory | --package file.pocket] [--lan] [--no-push] + bun ipodtouch4:runtime acceptance [--app clear] [--pushes 5] [--auto-background | --skip-background] [--lan] + +USB uses the existing pinned SSH tunnel by default. --lan discovers a paired +Runtime; --host and --key work when broadcast is unavailable. Runtime must +be in the foreground. Native build commands produce a separate PocketRuntime +User app, using Clear as the embedded recovery guest (320x480, density 2).`); +} +export async function main(argv = Bun.argv.slice(2)) { + const options = parseRuntimeOptions(argv); + if (["help", "--help", "-h"].includes(options.command)) return usage(); + if (["build", "deploy", "launch", "capture", "uninstall"].includes(options.command)) { + const child = Bun.spawn([process.execPath, join(ROOT, "tools/ipodtouch4.ts"), options.command], { + cwd: ROOT, env: { ...process.env, POCKETJS_IPODTOUCH4_APP: "runtime", POCKETJS_IPODTOUCH4_APP_FILE: "" }, + stdout: "inherit", stderr: "inherit", stdin: "inherit", + }); + if (await child.exited) throw new Error(`Runtime ${options.command} failed`); + return; + } + if (options.command === "pair") return await pairIPodTouch4Runtime(options.rotate); + if (options.command === "pack") { console.log(await packagePath(options)); return; } + if (options.command === "discover") { + const devices = await discoverPocketRuntimes({ port: options.port }); + console.log(JSON.stringify(devices.filter((entry) => entry.target === IPODTOUCH4_DEV_TARGET_ID), + (_key, value) => typeof value === "bigint" ? value.toString(16) : value, 2)); + return; + } + if (!["push", "status", "dev", "acceptance"].includes(options.command)) throw new Error(`unknown Runtime command ${options.command}`); + const filename = options.command === "push" ? await packagePath(options) : undefined; + const operation = async (target: Target) => { + if (options.command === "dev") await dev(options, target); + else if (options.command === "acceptance") await acceptance(options, target); + else await once(options, target, filename); + }; + if (options.lan) await operation(await lanTarget(options)); + else await withIPodTouch4RuntimeUsb((host, port, token) => operation({ host, port, token })); +} + +if (import.meta.main) main().catch((error) => { console.error(String(error)); process.exitCode = 1; }); diff --git a/tools/ipodtouch4.ts b/tools/ipodtouch4.ts index 747ac2950..7441ae18e 100644 --- a/tools/ipodtouch4.ts +++ b/tools/ipodtouch4.ts @@ -9,7 +9,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { createServer } from "node:net"; +import { createConnection, createServer } from "node:net"; import { dirname, join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; @@ -33,7 +33,9 @@ import { IPODTOUCH4_PHYSICAL_VIEWPORT, IPODTOUCH4_RASTER_DENSITY, resolveIPodTouch4BuildPlan, + IPODTOUCH4_DEV_CONTRACTS, } from "./ipodtouch4-profile.ts"; +import { renderTargetContractHeader, resolveNativeTargetContract } from "./target-contract.ts"; import { IPOD_INSTALLER, ipodAppReceiptPaths, parseInstalledIPodApp, shellQuote, userDeploymentScript, @@ -89,6 +91,8 @@ export interface IPodTouch4App { readonly svcWire: boolean; /** Disable iOS's idle timer while the app runs (a remote must not auto-lock). */ readonly keepAwake: boolean; + /** A persistent native shell that accepts validated .pocket replacements. */ + readonly devRuntime?: boolean; } /** The fields an external app's descriptor file must carry. */ @@ -105,6 +109,20 @@ const EXTERNAL_FIELDS = [ ] as const; export const IPODTOUCH4_APPS: Readonly> = { + runtime: { + id: "runtime", + manifest: "apps/clear/pocket.json", + bundleId: "dev.pocket-stack.runtime.ipodtouch4", + bundleName: "PocketRuntime.app", + executable: "PocketRuntime", + title: "Pocket Runtime", + scheme: "pocket-runtime-ipodtouch4", + receiptSlug: "pocket-runtime-ipodtouch4", + actionName: ACTION_NAME, + svcWire: true, + keepAwake: true, + devRuntime: true, + }, clear: { id: "clear", manifest: "apps/clear/pocket.json", @@ -244,6 +262,8 @@ interface DeviceStatus { readonly raster_density: number; readonly drawable_width: number; readonly drawable_height: number; + /** Resident set in KiB; 0 for records written before the field existed. */ + readonly resident_kb: number; readonly error: string; } @@ -640,6 +660,14 @@ async function build(): Promise { const cargoHome = join(ipodtouch4CacheRoot(), "build/cargo-home"); rmSync(nativeBuild, { recursive: true, force: true }); mkdirSync(nativeBuild, { recursive: true }); + if (APP.devRuntime) { + // The plan admission contract the shell bakes in: viewport from the + // verified plan, capabilities from the target registry. + writeFileSync( + join(nativeBuild, "pocket_target_contract.h"), + renderTargetContractHeader(resolveNativeTargetContract(inputs, IPODTOUCH4_DEV_CONTRACTS)), + ); + } mkdirSync(rustTarget, { recursive: true }); mkdirSync(cargoHome, { recursive: true }); @@ -692,6 +720,9 @@ async function build(): Promise { const firstParty = [ ...warnings, + ...(APP.devRuntime + ? ["-DPOCKET_DEV_RUNTIME", "-I", join(REPOSITORY, "engine/runtime"), "-I", join(REPOSITORY, "engine/ui-cabi/include"), "-I", nativeBuild] + : []), `-DPOCKET_LOGICAL_WIDTH=${inputs.viewport.logical[0]}`, `-DPOCKET_LOGICAL_HEIGHT=${inputs.viewport.logical[1]}`, `-DPOCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`, @@ -701,6 +732,16 @@ async function build(): Promise { ...(APP.svcWire ? ["-DPOCKET_SVC_WIRE"] : []), ]; const svcWireDefines = APP.svcWire ? ["-DPOCKET_SVC_WIRE", "-I", join(REPOSITORY, "hosts/ios-legacy")] : []; + const devDefines = APP.devRuntime ? ["-DPOCKET_DEV_RUNTIME", "-I", join(REPOSITORY, "engine/runtime")] : []; + const devObjects: string[] = []; + if (APP.devRuntime) { + for (const name of ["dev_protocol", "dev_server", "dev_wire_posix", "guest_runtime"]) { + const object = join(nativeBuild, `${name}.o`); + compile(join(REPOSITORY, `engine/runtime/${name}.c`), object, [...warnings, + `-DPOCKETJS_TARGET_ID=\"${inputs.target}\"`, `-DPOCKETJS_HOST_ABI=${inputs.hostAbi}`]); + devObjects.push(object); + } + } const crtGlobalsObject = join(nativeBuild, "crt_globals.o"); const runtimeIdentityObject = join(nativeBuild, "runtime.build-id-input.o"); const pocketRuntimeObject = join(nativeBuild, "pocket_runtime.o"); @@ -716,6 +757,7 @@ async function build(): Promise { compile(join(REPOSITORY, "engine/quickjs-c/pocket_runtime.c"), pocketRuntimeObject, [ ...warnings, ...svcWireDefines, + ...devDefines, `-DPOCKETJS_TARGET_ID=\"${inputs.target}\"`, `-DPOCKETJS_HOST_ABI=${inputs.hostAbi}`, `-DPOCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`, @@ -756,6 +798,7 @@ async function build(): Promise { { label: "native/runtime.build-id-input.o", path: runtimeIdentityObject }, { label: "native/pocket_runtime.o", path: pocketRuntimeObject }, ...(APP.svcWire ? [{ label: "native/svcwire.o", path: svcWireObject }] : []), + ...devObjects.map((path) => ({ label: `native/${path.slice(nativeBuild.length + 1)}`, path })), { label: "native/compat.o", path: compatObject }, ...quickJsObjects.map((path) => ({ label: `native/${path.slice(nativeBuild.length + 1)}`, path })), { label: "native/libpocketjs_symbian_core.a", path: rustLibrary }, @@ -782,7 +825,7 @@ async function build(): Promise { "-no_source_version", "-no_compact_unwind", "-no_adhoc_codesign", "-no_encryption", "-e", "start", "-o", executable, join(nativeBuild, "csu-start.o"), join(nativeBuild, "csu-dyld-glue.o"), crtGlobalsObject, - runtimeObject, pocketRuntimeObject, ...(APP.svcWire ? [svcWireObject] : []), compatObject, + runtimeObject, pocketRuntimeObject, ...(APP.svcWire ? [svcWireObject] : []), ...devObjects, compatObject, "-force_load", rustLibrary, ...quickJsObjects, "-sectcreate", "__DATA", "__pocket_js", embeddedJavaScript, "-sectcreate", "__DATA", "__pocket_pak", guestPak, @@ -896,9 +939,85 @@ async function deploy(): Promise { console.log(`deployed User app ${receipt.buildId} with byte-exact readback`); } -function installedApp(port: number) { - const raw = mustRemote(port, `${IPOD_INSTALLER} lookup ${shellQuote(BUNDLE_ID)}`); - return parseInstalledIPodApp(raw, BUNDLE_ID, BUNDLE_NAME); +function installedApp(port: number, app = APP) { + const raw = mustRemote(port, `${IPOD_INSTALLER} lookup ${shellQuote(app.bundleId)}`); + return parseInstalledIPodApp(raw, app.bundleId, app.bundleName); +} + +/** Runtime keys are local development secrets, separate from SSH credentials. */ +export const IPODTOUCH4_RUNTIME_KEYS = join(REPOSITORY, ".pocket/ipodtouch4/devices"); + +export async function pairIPodTouch4Runtime(rotate = false): Promise { + await withTunnel((port, udid) => { + const app = installedApp(port, IPODTOUCH4_APPS.runtime); + const root = `${app.Container}/Library/PocketRuntime`; + const remoteKey = `${root}/dev.key`; + let token = randomBytes(32).toString("hex"); + if (!rotate && remote(port, `test -f ${shellQuote(remoteKey)}`).exitCode === 0) { + token = mustRemote(port, `cat ${shellQuote(remoteKey)}`).trim(); + if (!/^[0-9a-f]{64}$/i.test(token)) throw new Error("runtime dev.key is invalid; use pair --rotate to replace it"); + } + mkdirSync(IPODTOUCH4_RUNTIME_KEYS, { recursive: true, mode: 0o700 }); + const deviceName = createHash("sha256").update(udid).digest("hex").slice(0, 24); + const key = join(IPODTOUCH4_RUNTIME_KEYS, `${deviceName}.key`); + const temporary = `${key}.${randomBytes(8).toString("hex")}.tmp`; + writeFileSync(temporary, token + "\n", { mode: 0o600 }); + try { + mustRemote(port, `set -eu; mkdir -p ${shellQuote(root)}; chmod 700 ${shellQuote(root)}; chown mobile:mobile ${shellQuote(root)}`); + copyToDevice(port, temporary, `${remoteKey}.new`); + mustRemote(port, `set -eu; chmod 600 ${shellQuote(remoteKey + ".new")}; chown mobile:mobile ${shellQuote(remoteKey + ".new")}; mv ${shellQuote(remoteKey + ".new")} ${shellQuote(remoteKey)}`); + if (mustRemote(port, `cat ${shellQuote(remoteKey)}`).trim() !== token) throw new Error("runtime pairing readback failed"); + writeFileSync(key, token + "\n", { mode: 0o600 }); + chmodSync(key, 0o600); + } finally { rmSync(temporary, { force: true }); } + console.log(`paired Pocket Runtime; key file: ${key}${rotate ? "; relaunch Runtime to activate the new key" : ""}`); + }); +} + +/** Forward the Runtime through the existing pinned USB SSH connection. This + * also supports POCKETJS_IPODTOUCH4_VIA without requiring LAN access on iOS. */ +export async function withIPodTouch4RuntimeUsb(operation: (host: string, port: number, token: Uint8Array) => Promise): Promise { + return await withTunnel(async (sshPort, udid) => { + const deviceName = createHash("sha256").update(udid).digest("hex").slice(0, 24); + const key = join(IPODTOUCH4_RUNTIME_KEYS, `${deviceName}.key`); + if (!existsSync(key)) throw new Error("run bun ipodtouch4:runtime pair for this USB device first"); + const text = readFileSync(key, "utf8").trim(); + if (!/^[0-9a-f]{64}$/i.test(text)) throw new Error("local runtime pairing key is invalid"); + const local = await availableLocalPort(); + const relay = Bun.spawn(["ssh", ...sshArgs(sshPort, "").slice(0, -2), + "-o", "ExitOnForwardFailure=yes", "-N", "-L", `127.0.0.1:${local}:127.0.0.1:8131`, "root@127.0.0.1"], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }); + try { + for (let attempt = 0; attempt < 30; ++attempt) { + if (relay.exitCode !== null) break; + const ready = await new Promise((resolveReady) => { + const socket = createConnection({ host: "127.0.0.1", port: local }); + const finish = (ok: boolean) => { socket.destroy(); resolveReady(ok); }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(200, () => finish(false)); + }); + if (ready) return await operation("127.0.0.1", local, Buffer.from(text, "hex")); + await Bun.sleep(100); + } + throw new Error("Runtime USB forwarding did not become ready"); + } finally { + if (relay.exitCode === null) relay.kill(); + await relay.exited; + } + }); +} + +/** Bring another app to the foreground (or this one back) through + * SpringBoard, the transition the Runtime acceptance pass exercises. */ +async function openUrl(url: string | undefined): Promise { + if (!url || !/^[a-z][a-z0-9+.-]*:[^'\s]*$/i.test(url)) { + throw new Error("pocket ipodtouch4: open-url needs one URL without quotes or spaces"); + } + await withTunnel((port) => { + mustRemote(port, `/bin/su mobile -c '/usr/bin/uiopen ${url}'; echo opened`); + console.log(`opened ${url}`); + }); } async function uninstall(): Promise { @@ -969,6 +1088,7 @@ async function readDeviceStatus(port: number, paths: ReturnType { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Uint8Array): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); +} + +/** adler32 (the zlib-stream trailer). */ +function adler32(buf: Uint8Array): number { + let a = 1; + let b = 0; + for (let i = 0; i < buf.length; i++) { + a = (a + buf[i]) % 65521; + b = (b + a) % 65521; + } + return ((b << 16) | a) >>> 0; +} + +/** Bun.deflateSync emits a RAW deflate stream; PNG IDAT needs the zlib + * wrapper (2-byte header + adler32 trailer) — add it ourselves. */ +function zlibWrap(raw: Uint8Array): Buffer { + // (cast: Buffer is Uint8Array but this one is always heap-backed) + const body = Bun.deflateSync(raw as Uint8Array); + const out = Buffer.alloc(body.length + 6); + out[0] = 0x78; // CM=8, CINFO=7 + out[1] = 0x01; // FCHECK making (out[0]<<8|out[1]) % 31 == 0, FLEVEL 0 + Buffer.from(body).copy(out, 2); + out.writeUInt32BE(adler32(raw), body.length + 2); + return out; +} + +export function encodePNG(rgba: Uint8Array, w: number, h: number): Buffer { + const stride = w * 4; + const raw = Buffer.alloc((stride + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; // filter: none + Buffer.from(rgba.buffer, rgba.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + return Buffer.concat([ + sig, + chunk("IHDR", ihdr), + chunk("IDAT", zlibWrap(raw)), + chunk("IEND", Buffer.alloc(0)), + ]); +} diff --git a/tools/pocket-runtime-client.ts b/tools/pocket-runtime-client.ts new file mode 100644 index 000000000..1b3dc4025 --- /dev/null +++ b/tools/pocket-runtime-client.ts @@ -0,0 +1,609 @@ +import { EventEmitter } from "node:events"; +import { createSocket } from "node:dgram"; +import { Socket } from "node:net"; +import { + POCKET_RUNTIME_ACK_BYTES, + POCKET_RUNTIME_FRAME_HEADER_BYTES, + POCKET_RUNTIME_MAX_CTRL_BYTES, + POCKET_RUNTIME_MAX_FRAME_BYTES, + POCKET_RUNTIME_MSG, + POCKET_RUNTIME_WIRE_PORT, + PocketRuntimeFrameDecoder, + decodePocketRuntimeAck, + decodePocketRuntimeDiscoveryReply, + decodePocketRuntimeScreenshotBegin, + encodePocketRuntimeDiscoveryRequest, + encodePocketRuntimeFrame, + encodePocketRuntimeHello, + encodePocketRuntimePackageBegin, + encodePocketRuntimePackageChunk, + pocketPackageFooterHash, + type PocketRuntimeAck, + type PocketRuntimeDiscovery, + type PocketRuntimeFrame, + type PocketRuntimeScreenshotBegin, +} from "../contracts/spec/pocket-runtime-wire.ts"; + +// Generic Pocket Runtime (PKRT) client and session: discovery, pairing, +// heartbeats, control records, uploads and the raw screenshot surfaces every +// Runtime streams. Surface decoding is a host concern; the Nintendo 3DS +// module (3ds-runtime-client.ts) turns PICA200 surfaces into PNGs. + +/** Both raw surfaces of one screenshot, exactly as the device streamed them. */ +export interface PocketRuntimeScreenshot { + readonly frame: number; + readonly top: Uint8Array; + readonly auxiliary: Uint8Array; + readonly metadata: PocketRuntimeScreenshotBegin; +} + +export interface PocketRuntimeClientOptions { + readonly host: string; + readonly token: Uint8Array; + readonly port?: number; + readonly timeoutMs?: number; + readonly heartbeatIntervalMs?: number; + readonly heartbeatTimeoutMs?: number; +} + +export interface DiscoveredPocketRuntime extends PocketRuntimeDiscovery { + readonly address: string; +} + +export interface PocketRuntimeDiscoveryOptions { + readonly port?: number; + readonly timeoutMs?: number; + readonly addresses?: readonly string[]; +} + +export interface PocketRuntimeSessionOptions { + readonly createClient: () => PocketRuntimeClient | Promise; + readonly retryDelayMs?: number; +} + +export async function discoverPocketRuntimes( + options: PocketRuntimeDiscoveryOptions = {}, +): Promise { + const port = options.port ?? POCKET_RUNTIME_WIRE_PORT; + const timeoutMs = options.timeoutMs ?? 4_000; + const addresses = options.addresses ?? ["255.255.255.255", "127.0.0.1"]; + const socket = createSocket("udp4"); + const found = new Map(); + return await new Promise((resolve, reject) => { + let timer: ReturnType | null = null; + let retry: ReturnType | null = null; + let settle: ReturnType | null = null; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (retry) clearInterval(retry); + if (settle) clearTimeout(settle); + socket.close(); + if (error) reject(error); + else resolve([...found.values()].sort((a, b) => a.address.localeCompare(b.address))); + }; + socket.on("message", (message, remote) => { + try { + const value = decodePocketRuntimeDiscoveryReply(new Uint8Array(message)); + const key = value.deviceId.toString(16); + const current = found.get(key); + const candidate = { + ...value, + address: remote.address, + }; + // One Runtime can answer through more than one local interface. Its + // pairing-derived ID is stable across DHCP changes, so keep one route + // and prefer a LAN address over loopback when both answer. + if (!current || (current.address === "127.0.0.1" && remote.address !== "127.0.0.1")) { + found.set(key, candidate); + } + // Startup may need several seconds for SOC to become reachable. Once + // the first reply arrives, keep only a short window for other devices + // on the LAN instead of charging every command the startup timeout. + if (settle) clearTimeout(settle); + settle = setTimeout( + () => finish(), + Math.min(150, Math.max(10, Math.floor(timeoutMs / 2))), + ); + } catch { + // Other UDP services may share the broadcast domain. + } + }); + socket.once("error", (error) => finish(error)); + socket.bind(0, "0.0.0.0", () => { + socket.setBroadcast(true); + const request = encodePocketRuntimeDiscoveryRequest(); + const send = () => { + for (const address of addresses) socket.send(request, port, address, () => {}); + }; + send(); + // UDP discovery is deliberately stateless. Repeating the tiny request + // inside the same bounded window tolerates one dropped Wi-Fi broadcast. + retry = setInterval(send, Math.max(20, Math.min(250, Math.floor(timeoutMs / 3)))); + timer = setTimeout(() => finish(), timeoutMs); + }); + }); +} + +type CtrlValue = Record; + +export class PocketRuntimeClient extends EventEmitter { + readonly host: string; + readonly port: number; + readonly token: Uint8Array; + readonly timeoutMs: number; + readonly heartbeatIntervalMs: number; + readonly heartbeatTimeoutMs: number; + #socket: Socket | null = null; + #decoder = new PocketRuntimeFrameDecoder(); + #handshake = new Uint8Array(0); + #connected = false; + #closed = false; + #pingTimer: ReturnType | null = null; + #lastPongMs = 0; + #screenshot: { + metadata: PocketRuntimeScreenshotBegin; + top: Uint8Array; + auxiliary: Uint8Array; + topReceived: number; + auxiliaryReceived: number; + } | null = null; + + constructor(options: PocketRuntimeClientOptions) { + super(); + this.host = options.host; + this.port = options.port ?? POCKET_RUNTIME_WIRE_PORT; + this.token = options.token; + this.timeoutMs = options.timeoutMs ?? 10_000; + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 2_000; + this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 8_000; + if (this.heartbeatIntervalMs <= 0 || this.heartbeatTimeoutMs <= this.heartbeatIntervalMs) { + throw new Error("Pocket Runtime heartbeat timeout must exceed its positive interval"); + } + } + + get connected(): boolean { + return this.#connected && !this.#closed; + } + + async connect(): Promise { + if (this.#socket) throw new Error("Pocket Runtime client is already connected"); + const socket = new Socket(); + this.#socket = socket; + socket.setNoDelay(true); + socket.on("data", (chunk: Buffer) => this.#onData(new Uint8Array(chunk))); + socket.on("error", (error) => this.emit("socketError", error)); + socket.on("close", () => { + this.#connected = false; + this.#closed = true; + if (this.#pingTimer) clearInterval(this.#pingTimer); + this.#pingTimer = null; + this.emit("close"); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Pocket Runtime connection to ${this.host}:${this.port} timed out`)), + this.timeoutMs, + ); + const fail = (error: Error) => { + clearTimeout(timer); + reject(error); + }; + socket.once("error", fail); + socket.connect(this.port, this.host, () => { + socket.off("error", fail); + clearTimeout(timer); + resolve(); + }); + }); + const ackPromise = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Pocket Runtime handshake timed out")), + this.timeoutMs, + ); + const onAck = (value: PocketRuntimeAck) => { + clearTimeout(timer); + this.off("protocolError", onError); + resolve(value); + }; + const onError = (error: Error) => { + clearTimeout(timer); + this.off("ack", onAck); + reject(error); + }; + this.once("ack", onAck); + this.once("protocolError", onError); + }); + await this.#write(encodePocketRuntimeHello(this.token)); + const ack = await ackPromise; + if (!ack.accepted) { + this.close(); + throw new Error(`Pocket Runtime rejected the pairing token (status ${ack.status})`); + } + this.#connected = true; + this.#lastPongMs = Date.now(); + this.#pingTimer = setInterval(() => { + if (Date.now() - this.#lastPongMs > this.heartbeatTimeoutMs) { + this.emit( + "heartbeatTimeout", + new Error(`Pocket Runtime did not answer a heartbeat within ${this.heartbeatTimeoutMs} ms`), + ); + this.close(); + return; + } + const payload = new Uint8Array(4); + new DataView(payload.buffer).setUint32(0, Date.now() >>> 0, true); + void this.sendFrame(POCKET_RUNTIME_MSG.ping, payload).catch((error) => { + this.emit("socketError", error); + this.close(); + }); + }, this.heartbeatIntervalMs); + return ack; + } + + async sendFrame( + type: number, + payload: Uint8Array = new Uint8Array(0), + flags = 0, + ): Promise { + if (!this.#socket || this.#closed) throw new Error("Pocket Runtime socket is closed"); + await this.#write(encodePocketRuntimeFrame(type, payload, flags)); + } + + async sendCtrl(value: string | CtrlValue): Promise { + const text = typeof value === "string" ? value : JSON.stringify(value); + const bytes = new TextEncoder().encode(text); + if (bytes.length === 0 || bytes.length > POCKET_RUNTIME_MAX_CTRL_BYTES || /[\r\n]/.test(text)) { + throw new Error(`Pocket Runtime control must be one JSON record of at most ${POCKET_RUNTIME_MAX_CTRL_BYTES} bytes`); + } + await this.sendFrame(POCKET_RUNTIME_MSG.ctrl, bytes); + } + + async requestStatus(): Promise { + await this.sendFrame(POCKET_RUNTIME_MSG.statusRequest); + } + + async install(bytes: Uint8Array): Promise { + const hash = pocketPackageFooterHash(bytes); + await this.sendFrame( + POCKET_RUNTIME_MSG.packageBegin, + encodePocketRuntimePackageBegin(bytes.length, hash), + ); + const chunkBytes = POCKET_RUNTIME_MAX_FRAME_BYTES - 4; + for (let offset = 0; offset < bytes.length; offset += chunkBytes) { + const chunk = bytes.subarray(offset, Math.min(offset + chunkBytes, bytes.length)); + await this.sendFrame( + POCKET_RUNTIME_MSG.packageChunk, + encodePocketRuntimePackageChunk(offset, chunk), + ); + } + await this.sendFrame(POCKET_RUNTIME_MSG.packageCommit); + return hash; + } + + async waitForCtrl( + predicate: (value: CtrlValue) => boolean, + timeoutMs = this.timeoutMs, + ): Promise { + return await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.off("ctrl", onCtrl); + this.off("close", onClose); + this.off("protocolError", onProtocolError); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Pocket Runtime control response timed out after ${timeoutMs} ms`)); + }, timeoutMs); + const onCtrl = (value: CtrlValue) => { + // The device says when a record was too large for a frame. Whatever is + // being awaited may well be that record, and a stated size beats + // waiting out the timeout with nothing to go on. + if (value.t === "ctrlDropped") { + cleanup(); + reject( + new Error( + `Pocket Runtime dropped a ${String(value.bytes)} byte control record ` + + `(frame cap ${String(value.cap)})`, + ), + ); + return; + } + if (!predicate(value)) return; + cleanup(); + resolve(value); + }; + const onClose = () => { + cleanup(); + reject(new Error("Pocket Runtime connection closed while waiting for control")); + }; + const onProtocolError = (error: Error) => { + cleanup(); + reject(error); + }; + this.on("ctrl", onCtrl); + this.once("close", onClose); + this.once("protocolError", onProtocolError); + }); + } + + async waitForScreenshot(timeoutMs = this.timeoutMs): Promise { + return await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.off("screenshot", onScreenshot); + this.off("close", onClose); + this.off("protocolError", onProtocolError); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Pocket Runtime screenshot timed out after ${timeoutMs} ms`)); + }, timeoutMs); + const onScreenshot = (value: PocketRuntimeScreenshot) => { + cleanup(); + resolve(value); + }; + const onClose = () => { + cleanup(); + reject(new Error("Pocket Runtime connection closed while waiting for screenshot")); + }; + const onProtocolError = (error: Error) => { + cleanup(); + reject(error); + }; + this.on("screenshot", onScreenshot); + this.once("close", onClose); + this.once("protocolError", onProtocolError); + }); + } + + close(): void { + this.#closed = true; + if (this.#pingTimer) clearInterval(this.#pingTimer); + this.#pingTimer = null; + this.#socket?.destroy(); + this.#socket = null; + } + + async #write(bytes: Uint8Array): Promise { + const socket = this.#socket; + if (!socket || socket.destroyed) throw new Error("Pocket Runtime socket is closed"); + await new Promise((resolve, reject) => { + socket.write(bytes, (error) => error ? reject(error) : resolve()); + }); + } + + #onData(chunk: Uint8Array): void { + try { + if (!this.#connected) { + const joined = new Uint8Array(this.#handshake.length + chunk.length); + joined.set(this.#handshake); + joined.set(chunk, this.#handshake.length); + this.#handshake = joined; + if (this.#handshake.length < POCKET_RUNTIME_ACK_BYTES) return; + const ackBytes = this.#handshake.slice(0, POCKET_RUNTIME_ACK_BYTES); + const remainder = this.#handshake.slice(POCKET_RUNTIME_ACK_BYTES); + this.#handshake = new Uint8Array(0); + const ack = decodePocketRuntimeAck(ackBytes); + this.#connected = ack.accepted; + this.emit("ack", ack); + if (ack.accepted && remainder.length > 0) this.#decodeFrames(remainder); + return; + } + this.#decodeFrames(chunk); + } catch (error) { + this.emit("protocolError", error instanceof Error ? error : new Error(String(error))); + this.close(); + } + } + + #decodeFrames(chunk: Uint8Array): void { + for (const frame of this.#decoder.push(chunk)) this.#handleFrame(frame); + } + + #handleFrame(frame: PocketRuntimeFrame): void { + switch (frame.type) { + case POCKET_RUNTIME_MSG.ping: + void this.sendFrame(POCKET_RUNTIME_MSG.pong, frame.payload).catch(() => {}); + return; + case POCKET_RUNTIME_MSG.pong: + this.#lastPongMs = Date.now(); + this.emit("pong", frame.payload); + return; + case POCKET_RUNTIME_MSG.ctrl: { + const line = new TextDecoder().decode(frame.payload); + this.emit("ctrlLine", line); + try { + this.emit("ctrl", JSON.parse(line) as CtrlValue); + } catch { + this.emit("protocolError", new Error("Pocket Runtime sent invalid control JSON")); + } + return; + } + case POCKET_RUNTIME_MSG.screenshotBegin: { + const metadata = decodePocketRuntimeScreenshotBegin(frame.payload); + this.#screenshot = { + metadata, + top: new Uint8Array(metadata.topBytes), + auxiliary: new Uint8Array(metadata.auxiliaryBytes), + topReceived: 0, + auxiliaryReceived: 0, + }; + return; + } + case POCKET_RUNTIME_MSG.screenshotChunk: { + const shot = this.#screenshot; + if (!shot || frame.payload.length <= 4 || frame.flags > 1) { + throw new Error("Pocket Runtime screenshot chunk arrived out of sequence"); + } + const offset = new DataView( + frame.payload.buffer, + frame.payload.byteOffset, + 4, + ).getUint32(0, true); + const destination = frame.flags === 0 ? shot.top : shot.auxiliary; + const received = frame.flags === 0 ? shot.topReceived : shot.auxiliaryReceived; + const bytes = frame.payload.subarray(4); + if (offset !== received || offset + bytes.length > destination.length) { + throw new Error("Pocket Runtime screenshot chunk is out of order or exceeds its surface"); + } + destination.set(bytes, offset); + if (frame.flags === 0) shot.topReceived += bytes.length; + else shot.auxiliaryReceived += bytes.length; + return; + } + case POCKET_RUNTIME_MSG.screenshotEnd: { + const shot = this.#screenshot; + if (!shot || frame.payload.length !== 4) { + throw new Error("Pocket Runtime screenshot end arrived out of sequence"); + } + const frameNumber = new DataView( + frame.payload.buffer, + frame.payload.byteOffset, + 4, + ).getUint32(0, true); + if (frameNumber !== shot.metadata.frame) { + throw new Error("Pocket Runtime screenshot frame identity changed mid-transfer"); + } + if (shot.topReceived !== shot.top.length || + shot.auxiliaryReceived !== shot.auxiliary.length) { + throw new Error("Pocket Runtime screenshot ended before both surfaces were complete"); + } + const result: PocketRuntimeScreenshot = { + frame: frameNumber, + top: shot.top, + auxiliary: shot.auxiliary, + metadata: shot.metadata, + }; + this.#screenshot = null; + this.emit("screenshot", result); + return; + } + default: + this.emit("unknownFrame", frame); + } + } +} + +/** + * Keeps one logical DevTools attachment across replaceable TCP clients. + * A client still owns exactly one ordered connection; this session owns only + * desktop-side reconnect policy and never enters the Runtime or guest API. + */ +export class PocketRuntimeSession extends EventEmitter { + readonly retryDelayMs: number; + #createClient: PocketRuntimeSessionOptions["createClient"]; + #client: PocketRuntimeClient | null = null; + #connecting: Promise | null = null; + #stopped = false; + + constructor(options: PocketRuntimeSessionOptions) { + super(); + this.#createClient = options.createClient; + this.retryDelayMs = options.retryDelayMs ?? 750; + } + + get connected(): boolean { + return this.#client?.connected ?? false; + } + + async start(): Promise { + if (this.#stopped) throw new Error("Pocket Runtime session is closed"); + if (this.#client?.connected) return this.#client; + return await this.#connectOnce(false); + } + + async requireClient(): Promise { + if (this.#stopped) throw new Error("Pocket Runtime session is closed"); + if (this.#client?.connected) return this.#client; + return await this.#reconnect(); + } + + async sendCtrl(value: string | CtrlValue): Promise { + const client = await this.requireClient(); + await client.sendCtrl(value); + } + + close(): void { + if (this.#stopped) return; + this.#stopped = true; + this.#client?.close(); + this.#client = null; + } + + async #connectOnce(reconnecting: boolean): Promise { + const client = await this.#createClient(); + const forward = (event: string) => (...args: unknown[]) => this.emit(event, ...args); + for (const event of [ + "screenshot", + "ctrlLine", + "ctrl", + "protocolError", + "unknownFrame", + "pong", + "heartbeatTimeout", + "socketError", + ]) { + client.on(event, forward(event)); + } + client.once("close", () => { + if (this.#client !== client) return; + this.#client = null; + this.emit("disconnect"); + if (!this.#stopped) void this.#reconnect().catch(() => {}); + }); + + let ack: PocketRuntimeAck; + try { + ack = await client.connect(); + } catch (error) { + client.close(); + throw error; + } + if (this.#stopped) { + client.close(); + throw new Error("Pocket Runtime session is closed"); + } + if (!client.connected) { + client.close(); + throw new Error("Pocket Runtime connection closed during its handshake"); + } + this.#client = client; + this.emit(reconnecting ? "reconnect" : "connect", client, ack); + return client; + } + + #reconnect(): Promise { + if (this.#connecting) return this.#connecting; + const task = (async () => { + while (!this.#stopped) { + this.emit("reconnectAttempt"); + try { + return await this.#connectOnce(true); + } catch (error) { + if (this.#stopped) break; + this.emit("reconnectError", error instanceof Error ? error : new Error(String(error))); + await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs)); + } + } + throw new Error("Pocket Runtime session is closed"); + })(); + this.#connecting = task; + void task.finally(() => { + if (this.#connecting === task) this.#connecting = null; + }).catch(() => {}); + return task; + } +} + +export function parsePocketRuntimeToken(text: string): Uint8Array { + const hex = text.trim(); + if (!/^[0-9a-f]{64}$/i.test(hex)) { + throw new Error("Pocket Runtime key must contain exactly 64 hexadecimal characters"); + } + return Uint8Array.from(Buffer.from(hex, "hex")); +} diff --git a/tools/tape.ts b/tools/tape.ts index 7ee663571..f574f6a0c 100644 --- a/tools/tape.ts +++ b/tools/tape.ts @@ -28,7 +28,7 @@ import { type Tape, } from "../framework/src/devtools.ts"; import { __packTouch } from "../framework/src/touch.ts"; -import { encodePNG } from "../tests/png.ts"; +import { encodePNG } from "./png.ts"; import { SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); diff --git a/tools/target-contract.ts b/tools/target-contract.ts new file mode 100644 index 000000000..d48164bcf --- /dev/null +++ b/tools/target-contract.ts @@ -0,0 +1,108 @@ +// The target contract a native shell bakes in: its identity and surfaces from +// the verified build plan, its capability list from the target registry. +// Rendered as C for engine/ui-cabi/include/pocket_package.h's +// pocket_package_validate_plan, so device-side plan admission and the desktop +// resolver (framework/src/manifest/resolve.ts) share one source of truth. +import type { HostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import type { + PlatformContractRegistry, + PresentationMode, + Viewport, +} from "../contracts/spec/platforms.ts"; + +export interface NativeSurfaceContract { + readonly logical: Viewport; + readonly physical: Viewport; + readonly presentation: PresentationMode; + readonly rasterDensity: number; +} + +export interface NativeTargetContract { + readonly target: string; + readonly hostAbi: number; + readonly primary: NativeSurfaceContract; + /** Present only when the plan resolved an auxiliary surface. */ + readonly auxiliary?: NativeSurfaceContract; + /** Capability ids of the registry entry, sorted by codepoint. */ + readonly capabilities: readonly string[]; + /** Whether packages for this shell may carry a hostExtension payload. */ + readonly hostExtension: boolean; +} + +export function resolveNativeTargetContract( + inputs: HostBuildInputs, + registry: PlatformContractRegistry, +): NativeTargetContract { + const profile = registry.targets[inputs.target]; + if (!profile) throw new Error(`target contract: ${inputs.target} is not in the registry`); + return { + target: inputs.target, + hostAbi: inputs.hostAbi, + primary: { + logical: inputs.viewport.logical, + physical: inputs.viewport.physical, + presentation: inputs.viewport.presentation, + rasterDensity: inputs.viewport.rasterDensity, + }, + ...(inputs.surfaces ? { auxiliary: inputs.surfaces.auxiliary } : {}), + capabilities: [...profile.capabilities].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)), + hostExtension: inputs.hostExtension !== undefined, + }; +} + +function cText(value: string): string { + if (!/^[\x20-\x7e]*$/.test(value) || /["\\]/.test(value)) { + throw new Error(`target contract: ${JSON.stringify(value)} is not a plain ASCII identifier`); + } + return `"${value}"`; +} + +function cUnsigned(value: number, name: string): string { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) { + throw new Error(`target contract: ${name} must be an unsigned 32-bit integer`); + } + return `${value}u`; +} + +/** Deterministic C for one build: `POCKET_TARGET_CONTRACT`. */ +export function renderTargetContractHeader(contract: NativeTargetContract): string { + const auxiliary = contract.auxiliary; + const lines = [ + "/* Generated by tools/target-contract.ts from the target registry and the", + " * verified build plan. Do not edit: it is rewritten by every native build. */", + "#ifndef POCKET_TARGET_CONTRACT_H", + "#define POCKET_TARGET_CONTRACT_H", + "", + '#include "pocket_package.h"', + "", + `#define POCKET_TARGET_CONTRACT_ID ${cText(contract.target)}`, + `#define POCKET_TARGET_CONTRACT_HOST_ABI ${cUnsigned(contract.hostAbi, "hostAbi")}`, + "", + "static const char *const POCKET_TARGET_CONTRACT_CAPABILITIES[] = {", + ...contract.capabilities.map((id) => ` ${cText(id)},`), + "};", + "", + "static const PocketTargetContract POCKET_TARGET_CONTRACT = {", + " POCKET_TARGET_CONTRACT_ID,", + " POCKET_TARGET_CONTRACT_HOST_ABI,", + ` ${cUnsigned(contract.primary.logical[0], "logical width")}, ${cUnsigned(contract.primary.logical[1], "logical height")},`, + ` ${cUnsigned(contract.primary.physical[0], "physical width")}, ${cUnsigned(contract.primary.physical[1], "physical height")},`, + ` ${cUnsigned(contract.primary.rasterDensity, "rasterDensity")},`, + ` ${cText(contract.primary.presentation)},`, + " POCKET_TARGET_CONTRACT_CAPABILITIES,", + ` ${cUnsigned(contract.capabilities.length, "capability count")},`, + auxiliary + ? ` ${cUnsigned(auxiliary.logical[0], "auxiliary logical width")}, ${cUnsigned(auxiliary.logical[1], "auxiliary logical height")},` + : " 0u, 0u,", + auxiliary + ? ` ${cUnsigned(auxiliary.physical[0], "auxiliary physical width")}, ${cUnsigned(auxiliary.physical[1], "auxiliary physical height")},` + : " 0u, 0u,", + auxiliary ? ` ${cUnsigned(auxiliary.rasterDensity, "auxiliary rasterDensity")},` : " 0u,", + auxiliary ? ` ${cText(auxiliary.presentation)},` : " NULL,", + ` ${contract.hostExtension ? "1u" : "0u"},`, + "};", + "", + "#endif", + ]; + return lines.join("\n") + "\n"; +} diff --git a/tools/test.ts b/tools/test.ts index 40aa7f55f..edee0b8ad 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -58,6 +58,7 @@ const SUITE: readonly Stage[] = [ "tests/3ds-profile.test.ts", "tests/3ds-runtime-state.test.ts", "tests/3ds-runtime-wire.test.ts", + "tests/pocket-runtime-server.test.ts", "tests/3ds-soc.test.ts", "tests/iphone2g-profile.test.ts", "tests/iphone4s-profile.test.ts", @@ -65,6 +66,8 @@ const SUITE: readonly Stage[] = [ "tests/ipodtouch4-profile.test.ts", "tests/ipodtouch4-installation.test.ts", "tests/ipodtouch4-svcwire.test.ts", + "tests/ipodtouch4-package.test.ts", + "tests/ipodtouch4-runtime.test.ts", "tests/meizu-m8-profile.test.ts", "tests/blackberry-classic.test.ts", "tests/pocket-input.test.ts",