From 99fcc3afc4476bcd8ad20b7a9008f19ae079fb6f Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Thu, 10 Sep 2026 13:51:27 +0800 Subject: [PATCH 1/3] feat(ipodtouch4): add persistent Pocket Runtime hot updates --- .github/workflows/3ds-runtime.yml | 4 +- .github/workflows/native-c-harness.yml | 26 + docs/DEVTOOLS.md | 10 + docs/IPODTOUCH4.md | 128 +++++ engine/quickjs-c/pocket_runtime.c | 164 ++++++ engine/quickjs-c/pocket_runtime.h | 5 + engine/runtime/dev_protocol.c | 192 +++++++ engine/runtime/dev_protocol.h | 113 ++++ engine/runtime/dev_server.c | 391 ++++++++++++++ engine/runtime/dev_server.h | 28 + engine/runtime/guest_runtime.c | 310 +++++++++++ engine/runtime/guest_runtime.h | 23 + engine/ui-cabi/include/pocket_package.h | 23 + engine/ui-cabi/src/lib.rs | 1 + engine/ui-cabi/src/package.rs | 67 +++ hosts/3ds/Makefile | 2 + hosts/3ds/src/dev_protocol.c | 194 +------ hosts/3ds/src/dev_protocol.h | 114 +--- hosts/ios-legacy/runtime.c | 107 +++- hosts/ipodtouch4/README.md | 8 + hosts/ipodtouch4/runtime.c | 12 + package.json | 1 + tests/3ds-runtime-wire.test.ts | 2 +- tests/fixtures/ipodtouch4-runtime.c | 42 ++ tests/ipodtouch4-package.test.ts | 38 ++ tests/ipodtouch4-runtime.test.ts | 227 ++++++++ tools/3ds-runtime-client.ts | 669 +----------------------- tools/ipodtouch4-package.ts | 68 +++ tools/ipodtouch4-runtime.ts | 257 +++++++++ tools/ipodtouch4.ts | 103 +++- tools/pocket-runtime-client.ts | 667 +++++++++++++++++++++++ tools/test.ts | 1 + 32 files changed, 3011 insertions(+), 986 deletions(-) create mode 100644 engine/runtime/dev_protocol.c create mode 100644 engine/runtime/dev_protocol.h create mode 100644 engine/runtime/dev_server.c create mode 100644 engine/runtime/dev_server.h create mode 100644 engine/runtime/guest_runtime.c create mode 100644 engine/runtime/guest_runtime.h create mode 100644 engine/ui-cabi/include/pocket_package.h create mode 100644 engine/ui-cabi/src/package.rs create mode 100644 tests/fixtures/ipodtouch4-runtime.c create mode 100644 tests/ipodtouch4-package.test.ts create mode 100644 tests/ipodtouch4-runtime.test.ts create mode 100644 tools/ipodtouch4-package.ts create mode 100644 tools/ipodtouch4-runtime.ts create mode 100644 tools/pocket-runtime-client.ts diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index 488aa6844..8b41a11c0 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/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'] 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/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'] permissions: contents: read jobs: diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml index 61ac92795..cec9c6230 100644 --- a/.github/workflows/native-c-harness.yml +++ b/.github/workflows/native-c-harness.yml @@ -5,6 +5,13 @@ 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" + - "tests/ipodtouch4*.test.ts" + - "tests/fixtures/ipodtouch4-runtime.c" - "engine/ui-cabi/**" - "engine/core/**" - "framework/src/**" @@ -25,6 +32,13 @@ 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" + - "tests/ipodtouch4*.test.ts" + - "tests/fixtures/ipodtouch4-runtime.c" - "engine/ui-cabi/**" - "engine/core/**" - "framework/src/**" @@ -74,5 +88,17 @@ 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: 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/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 44c556cff..99f4cc9e2 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -128,6 +128,16 @@ 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 + native receiver validates each package before changing guests and commits + its generation after a GLES presentation. Resigning active closes sockets; + the desktop session reconnects when Runtime returns to 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..2250dce37 100644 --- a/docs/IPODTOUCH4.md +++ b/docs/IPODTOUCH4.md @@ -127,3 +127,131 @@ 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.** The shared codec lives in `engine/runtime/dev_protocol.*`; the +desktop client lives in `tools/pocket-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, +JavaScript terminator and viewport on the device before replacing the guest. + +```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. A disconnected or incomplete upload +is discarded. + +**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 two seconds and each guest turn at +500 ms. Those time limits include the pending job drain. They do not bound +native rendering calls. 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. Returning to the foreground reopens the paired listener. 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/ipodtouch4-package.test.ts +bun test tests/ipodtouch4-runtime.test.ts +``` + +The runtime tests link the real QuickJS sources, package reader and retained UI +core into a host process. They exercise TCP transfer, admission failures, +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. diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 388acafca..222794f97 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -3,6 +3,9 @@ #include "pocket_ui_cabi.h" #include "pocket_spec.h" #include "quickjs.h" +#ifdef POCKET_DEV_RUNTIME +#include "dev_server.h" +#endif #ifdef POCKET_SVC_WIRE #include "svcwire.h" #endif @@ -72,6 +75,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 +102,35 @@ 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 +static uint64_t guest_deadline; +static char dev_poll_buffer[32769]; +static int interrupt_guest(JSRuntime *rt, void *opaque) { + (void)rt; + (void)opaque; + return pocket_devwire_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_devwire_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 +483,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_devwire_poll(dev_poll_buffer, sizeof dev_poll_buffer - 1); + return JS_NewStringLen(ctx, dev_poll_buffer, length); + } + case HostDbgSend: + if (!string_argument(ctx, argc, argv, 0, &text, &text_length)) return JS_EXCEPTION; + pocket_devwire_send(text, text_length); + JS_FreeCString(ctx, text); + return JS_UNDEFINED; +#endif #ifdef POCKET_SVC_WIRE case HostSvcOpen: { int open; @@ -490,6 +540,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 +631,12 @@ static int install_host(int width, int height) { static int drain_jobs(void) { for (;;) { +#ifdef POCKET_DEV_RUNTIME + if (pocket_devwire_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 +649,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 +699,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 = pocket_devwire_now_ms() + 2000; + JS_SetInterruptHandler(runtime, interrupt_guest, NULL); +#endif context = JS_NewContext(runtime); if (context == 0) { set_error("QuickJS context allocation failed"); @@ -704,6 +786,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 = pocket_devwire_now_ms() + 500; +#endif #ifdef POCKET_SVC_WIRE /* Bounded, non-blocking: discovery, connect, rx and tx progress once per * guest turn, before the guest polls. */ @@ -989,3 +1074,82 @@ size_t pocket_runtime_length(void) { const char *pocket_runtime_error(void) { return last_error; } + +#ifdef POCKET_DEV_RUNTIME +static int plan_number(JSContext *ctx, JSValueConst object, const char *name, int expected) { + JSValue value = JS_GetPropertyStr(ctx, object, name); + double number = 0; + int ok = JS_IsNumber(value) && JS_ToFloat64(ctx, &number, value) == 0 && number == expected; + JS_FreeValue(ctx, value); + return ok; +} +static int plan_string(JSContext *ctx, JSValueConst object, const char *name, const char *expected) { + JSValue value = JS_GetPropertyStr(ctx, object, name); + size_t length = 0; + const char *text = JS_IsString(value) ? JS_ToCStringLen(ctx, &length, value) : NULL; + int ok = text && length == strlen(expected) && memcmp(text, expected, length) == 0; + if (text) JS_FreeCString(ctx, text); + JS_FreeValue(ctx, value); + return ok; +} +static int plan_dimensions(JSContext *ctx, JSValueConst object, const char *name, int width, int height) { + JSValue array = JS_GetPropertyStr(ctx, object, name); + int ok = JS_IsArray(ctx, array) == 1 && plan_number(ctx, array, "length", 2) && + plan_number(ctx, array, "0", width) && plan_number(ctx, array, "1", height); + JS_FreeValue(ctx, array); + return ok; +} +int pocket_runtime_validate_plan(const uint8_t *bytes, size_t length, int width, int height) { + if (!bytes || !length || length > 256u * 1024u) return 0; + JSRuntime *rt = JS_NewRuntime(); + if (!rt) return 0; + JS_SetMemoryLimit(rt, 4u * 1024u * 1024u); + JS_SetMaxStackSize(rt, 128u * 1024u); + JSContext *ctx = JS_NewContext(rt); + if (!ctx) { JS_FreeRuntime(rt); return 0; } + /* JSON parsing does not evaluate package JavaScript or expose host APIs. */ + JSValue plan = JS_ParseJSON(ctx, (const char *)bytes, length, "plan.json"); + if (JS_IsException(plan) || !JS_IsObject(plan)) { + JS_FreeValue(ctx, plan); + JS_FreeContext(ctx); + JS_FreeRuntime(rt); + return 0; + } + JSValue target = JS_GetPropertyStr(ctx, plan, "target"); + JSValue viewport = JS_GetPropertyStr(ctx, plan, "viewport"); + JSValue surfaces = JS_GetPropertyStr(ctx, plan, "surfaces"); + JSValue extension = JS_GetPropertyStr(ctx, plan, "hostExtension"); + JSValue features = JS_GetPropertyStr(ctx, plan, "features"); + int ok = JS_IsObject(plan) && !JS_IsException(plan) && + plan_string(ctx, target, "id", POCKETJS_TARGET_ID) && + plan_number(ctx, target, "hostAbi", POCKETJS_HOST_ABI) && + plan_dimensions(ctx, viewport, "logical", width, height) && + plan_dimensions(ctx, viewport, "physical", width * POCKET_RASTER_DENSITY, height * POCKET_RASTER_DENSITY) && + plan_number(ctx, viewport, "rasterDensity", POCKET_RASTER_DENSITY) && + plan_string(ctx, viewport, "presentation", "native") && + JS_IsUndefined(surfaces) && JS_IsUndefined(extension) && JS_IsObject(features); + JSPropertyEnum *properties = NULL; + uint32_t count = 0; + if (ok && JS_GetOwnPropertyNames(ctx, &properties, &count, features, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) == 0) { + for (uint32_t i = 0; i < count; ++i) { + const char *name = JS_AtomToCString(ctx, properties[i].atom); + JSValue value = JS_GetProperty(ctx, features, properties[i].atom); + if (!JS_IsBool(value) || (JS_ToBool(ctx, value) && (!name || + (strcmp(name, "input.touch") && strcmp(name, "text.glyphs.baked"))))) ok = 0; + JS_FreeValue(ctx, value); + if (name) JS_FreeCString(ctx, name); + JS_FreeAtom(ctx, properties[i].atom); + } + js_free(ctx, properties); + } else ok = 0; + JS_FreeValue(ctx, features); + JS_FreeValue(ctx, extension); + JS_FreeValue(ctx, surfaces); + JS_FreeValue(ctx, viewport); + JS_FreeValue(ctx, target); + JS_FreeValue(ctx, plan); + JS_FreeContext(ctx); + JS_FreeRuntime(rt); + return ok; +} +#endif diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h index 10b44301e..de074e1f8 100644 --- a/engine/quickjs-c/pocket_runtime.h +++ b/engine/quickjs-c/pocket_runtime.h @@ -137,4 +137,9 @@ size_t pocket_runtime_length(void); const char *pocket_runtime_error(void); void pocket_runtime_shutdown(void); +#ifdef POCKET_DEV_RUNTIME +/* Parse only plan metadata in an isolated, bounded realm; never evaluate it. */ +int pocket_runtime_validate_plan(const uint8_t *plan, size_t length, int width, int height); +#endif + #endif 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..f728d8308 --- /dev/null +++ b/engine/runtime/dev_server.c @@ -0,0 +1,391 @@ +/* Pocket Runtime wire v1 over non-blocking BSD sockets (UIKit and host tests). + * Uses the same codec and desktop client as the 3DS. No device SDK symbols. */ +#include "dev_server.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif +#define RX_CAP (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) +#define TX_CAP (2u * RX_CAP) +#define CTRL_CAP (32u * 1024u) +#define IO_BUDGET (64u * 1024u) + +static int listener = -1, discovery = -1, peer = -1; +static int authenticated, suspended, configured, upload_ready; +static uint8_t token[32], rx[RX_CAP], tx[TX_CAP]; +static char controls[CTRL_CAP], hello[1024]; +static size_t rx_len, tx_len, tx_off, controls_len, hello_len; +static char key_path[POCKET_DEV_PATH_BYTES], upload_path[POCKET_DEV_PATH_BYTES]; +static char target_id[16]; +static uint16_t host_abi, listen_port; +static uint32_t generation, upload_expected, upload_received; +static uint64_t active_hash, device_id, last_rx, next_listen, upload_hash; +static FILE *upload; +static void (*write_status)(char *, size_t); + +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 abort_upload(void) { + if (upload) fclose(upload); + upload = NULL; + upload_expected = upload_received = 0; + upload_hash = 0; + upload_ready = 0; + if (upload_path[0]) remove(upload_path); +} +static void disconnect(void) { + if (peer >= 0) close(peer); + peer = -1; + authenticated = 0; + rx_len = tx_len = tx_off = controls_len = 0; + abort_upload(); +} +static void close_listeners(void) { + disconnect(); + if (listener >= 0) close(listener); + if (discovery >= 0) close(discovery); + listener = discovery = -1; +} + +static int queue(uint8_t type, const void *bytes, size_t length, int critical) { + if (peer < 0 || length > POCKET_RUNTIME_MAX_FRAME_BYTES) return 0; + if (tx_off) { + memmove(tx, tx + tx_off, tx_len - tx_off); + tx_len -= tx_off; + tx_off = 0; + } + size_t required = POCKET_RUNTIME_FRAME_HEADER_BYTES + length; + size_t reserve = critical ? 0 : 4096; + if (required + reserve > sizeof tx - tx_len) { + if (critical) disconnect(); + return 0; + } + pocket_runtime_encode_frame_header(tx + tx_len, type, 0, (uint32_t)length); + if (length) memcpy(tx + tx_len + POCKET_RUNTIME_FRAME_HEADER_BYTES, bytes, length); + tx_len += required; + return 1; +} +static void escape(char *out, size_t cap, const char *in) { + size_t length = 0; + for (; *in && length + 7 < cap; ++in) { + unsigned char c = (unsigned char)*in; + if (c == '"' || c == '\\') { out[length++] = '\\'; out[length++] = (char)c; } + else if (c < 32) { length += (size_t)snprintf(out + length, cap - length, "\\u%04x", c); } + else out[length++] = (char)c; + } + out[length] = 0; +} +void pocket_devwire_report(const char *phase, uint64_t hash, const char *message) { + char escaped[1024], line[1280]; + escape(escaped, sizeof escaped, message ? message : ""); + int length = snprintf(line, sizeof line, + "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\",\"generation\":%u,\"message\":\"%s\"}", + phase, (unsigned long long)hash, generation, escaped); + if (authenticated && length > 0 && (size_t)length < sizeof line) + queue(POCKET_RUNTIME_MSG_CTRL, line, (size_t)length, 1); +} +void pocket_devwire_log(const char *level, const char *message) { + char escaped[1024], line[1152]; + escape(escaped, sizeof escaped, message ? message : ""); + int length = snprintf(line, sizeof line, "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", level, escaped); + if (authenticated && length > 0 && (size_t)length < sizeof line) + queue(POCKET_RUNTIME_MSG_CTRL, line, (size_t)length, 0); +} +void pocket_devwire_send(const char *text, size_t length) { + if (!text || !length || length > POCKET_RUNTIME_MAX_FRAME_BYTES) return; + static const char marker[] = "\"t\":\"hello\""; + int is_hello = 0; + for (size_t i = 0; i + sizeof marker - 1 <= length && !is_hello; ++i) + is_hello = memcmp(text + i, marker, sizeof marker - 1) == 0; + if (length < sizeof hello && is_hello) { + memcpy(hello, text, length); + hello[length] = 0; + hello_len = length; + } + if (authenticated) queue(POCKET_RUNTIME_MSG_CTRL, text, length, 0); +} +size_t pocket_devwire_poll(char *out, size_t capacity) { + size_t length = controls_len < capacity ? controls_len : capacity; + while (length && controls[length - 1] != '\n') --length; + if (length) { + memcpy(out, controls, length); + memmove(controls, controls + length, controls_len - length); + controls_len -= length; + } + return length; +} +void pocket_devwire_reset_guest(void) { controls_len = hello_len = 0; hello[0] = 0; } +static void status(void) { + char text[2048]; + if (!write_status) return; + write_status(text, sizeof text); + queue(POCKET_RUNTIME_MSG_CTRL, text, strlen(text), 1); +} + +static int hex(int c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +static int load_key(void) { + char text[66]; + FILE *file = fopen(key_path, "rb"); + if (!file) return 0; + size_t length = fread(text, 1, sizeof text, file); + fclose(file); + if (length != 64 && !(length == 65 && text[64] == '\n')) return 0; + for (size_t i = 0; i < sizeof token; ++i) { + int high = hex(text[2 * i]), low = hex(text[2 * i + 1]); + if (high < 0 || low < 0) return 0; + token[i] = (uint8_t)((high << 4) | low); + } + device_id = pocket_runtime_device_id(token); + return 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 (!load_key()) 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) || listen(listener, 1)) { + 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)) { + close(discovery); + discovery = -1; + } + } +} +int pocket_devwire_init(const char *root, const char *target, uint16_t abi, + uint16_t port, void (*status_callback)(char *, size_t)) { + pocket_devwire_shutdown(); + if (!root || !target || strlen(target) >= sizeof target_id || !port) return 0; + if (snprintf(key_path, sizeof key_path, "%s/dev.key", root) >= (int)sizeof key_path || + snprintf(upload_path, sizeof upload_path, "%s/upload.tmp", root) >= (int)sizeof upload_path) return 0; + strcpy(target_id, target); + host_abi = abi; + listen_port = port; + write_status = status_callback; + configured = 1; + next_listen = 0; + return 1; +} +void pocket_devwire_state(uint32_t next_generation, uint64_t active) { + generation = next_generation; + active_hash = active; +} +void pocket_devwire_suspend(int value) { + suspended = value; + if (value) close_listeners(); + next_listen = 0; +} +void pocket_devwire_shutdown(void) { + close_listeners(); + configured = suspended = 0; + pocket_devwire_reset_guest(); +} +const char *pocket_devwire_upload_path(void) { return upload_path; } +int pocket_devwire_connected(void) { return authenticated; } +const char *pocket_devwire_state_name(void) { + if (suspended) return "suspended"; + if (authenticated) return "connected"; + if (listener >= 0) return "listening"; + return "unpaired-or-unavailable"; +} +int pocket_devwire_take_upload(uint64_t *hash) { + if (!upload_ready) return 0; + *hash = upload_hash; + upload_ready = 0; + upload_hash = 0; + return 1; +} +static void transfer_error(const char *message) { + uint64_t hash = upload_hash; + abort_upload(); + pocket_devwire_report("transfer-error", hash, message); +} + +static void handle_frame(PocketRuntimeFrameHeader header, const uint8_t *payload) { + switch (header.type) { + case POCKET_RUNTIME_MSG_PING: + if (header.length <= 16) queue(POCKET_RUNTIME_MSG_PONG, payload, header.length, 1); + else disconnect(); + break; + case POCKET_RUNTIME_MSG_PONG: break; + case POCKET_RUNTIME_MSG_STATUS_REQUEST: + if (header.length) disconnect(); else status(); + break; + case POCKET_RUNTIME_MSG_CTRL: + if (!header.length || header.length > POCKET_RUNTIME_MAX_CTRL_BYTES || + header.length + 1 > sizeof controls - controls_len || + memchr(payload, 0, header.length) || memchr(payload, '\n', header.length) || memchr(payload, '\r', header.length)) { + disconnect(); break; + } + memcpy(controls + controls_len, payload, header.length); + controls_len += header.length; + controls[controls_len++] = '\n'; + break; + case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: { + PocketRuntimePackageBegin begin; + if (!pocket_runtime_parse_package_begin(payload, header.length, &begin) || + begin.length < 24 || begin.length > POCKET_DEV_MAX_PACKAGE || !begin.footer_hash) { + disconnect(); break; + } + if (upload || upload_ready) { transfer_error("another upload is in progress"); break; } + upload_hash = begin.footer_hash; + upload_expected = begin.length; + upload_received = 0; + upload = fopen(upload_path, "wb"); + if (!upload) transfer_error("cannot open package staging file"); + break; + } + case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: + if (!upload || header.length <= 4 || pocket_runtime_read_u32(payload) != upload_received || + header.length - 4 > upload_expected - upload_received) { + transfer_error("invalid package chunk offset or length"); break; + } + if (fwrite(payload + 4, 1, header.length - 4, upload) != header.length - 4) { + transfer_error("package write failed"); break; + } + upload_received += header.length - 4; + break; + case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: { + if (header.length || !upload || upload_received != upload_expected) { + transfer_error("incomplete package transfer"); break; + } + int ok = fflush(upload) == 0 && fsync(fileno(upload)) == 0; + if (fclose(upload)) ok = 0; + upload = NULL; + if (!ok) transfer_error("package flush failed"); + else upload_ready = 1; + break; + } + case POCKET_RUNTIME_MSG_PACKAGE_ABORT: + abort_upload(); + break; + default: disconnect(); break; + } +} +static void pump_discovery(void) { + if (discovery < 0) return; + for (int i = 0; i < 4; ++i) { + uint8_t request[64], 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_runtime_is_discovery_request(request, (size_t)length)) continue; + pocket_runtime_encode_discovery_reply(reply, host_abi, listen_port, authenticated ? 1 : 0, + generation, active_hash, device_id, target_id, "PocketJS iPod4"); + sendto(discovery, reply, sizeof reply, MSG_NOSIGNAL, (struct sockaddr *)&source, size); + } +} +static void pump_rx(void) { + size_t budget = IO_BUDGET; + unsigned frames = 0; + while (peer >= 0 && frames < 8 && !upload_ready) { + if (!authenticated && rx_len >= POCKET_RUNTIME_HELLO_BYTES) { + if (!pocket_runtime_verify_hello(rx, POCKET_RUNTIME_HELLO_BYTES, token)) { disconnect(); return; } + memmove(rx, rx + POCKET_RUNTIME_HELLO_BYTES, rx_len - POCKET_RUNTIME_HELLO_BYTES); + rx_len -= POCKET_RUNTIME_HELLO_BYTES; + pocket_runtime_encode_ack(tx, 0, host_abi, generation, 0, active_hash); + tx_len = POCKET_RUNTIME_ACK_BYTES; + authenticated = 1; + if (hello_len) queue(POCKET_RUNTIME_MSG_CTRL, hello, hello_len, 0); + status(); + } + if (authenticated && rx_len >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { + PocketRuntimeFrameHeader header; + if (!pocket_runtime_parse_frame_header(rx, rx_len, &header) || header.flags) { disconnect(); return; } + size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + header.length; + if (rx_len >= total) { + handle_frame(header, rx + POCKET_RUNTIME_FRAME_HEADER_BYTES); + if (peer < 0) return; + memmove(rx, rx + total, rx_len - total); + rx_len -= total; + ++frames; + continue; + } + } + if (!budget) break; + size_t room = sizeof rx - rx_len; + if (!room) { disconnect(); return; } + if (room > budget) room = budget; + ssize_t count = recv(peer, rx + rx_len, room, 0); + if (count <= 0) { + if (count == 0 || !again()) disconnect(); + break; + } + rx_len += (size_t)count; + budget -= (size_t)count; + last_rx = pocket_devwire_now_ms(); + } +} +static void pump_tx(void) { + if (peer < 0 || tx_off == tx_len) return; + size_t length = tx_len - tx_off; + if (length > IO_BUDGET) length = IO_BUDGET; + ssize_t count = send(peer, tx + tx_off, length, MSG_NOSIGNAL); + if (count > 0) { + tx_off += (size_t)count; + if (tx_off == tx_len) tx_off = tx_len = 0; + } else if (!count || !again()) disconnect(); +} +void pocket_devwire_pump(void) { + if (suspended || !configured) return; + listen_if_paired(); + if (listener < 0) return; + pump_discovery(); + if (peer < 0) { + peer = accept(listener, NULL, NULL); + if (peer >= 0) { + 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)) { disconnect(); return; } +#endif + if (!nonblocking(peer)) { disconnect(); return; } + last_rx = pocket_devwire_now_ms(); + } + } + if (peer < 0) return; + if (pocket_devwire_now_ms() - last_rx > (authenticated ? 10000u : 3000u)) { + disconnect(); return; + } + pump_rx(); + pump_tx(); +} diff --git a/engine/runtime/dev_server.h b/engine/runtime/dev_server.h new file mode 100644 index 000000000..03b4012b1 --- /dev/null +++ b/engine/runtime/dev_server.h @@ -0,0 +1,28 @@ +#ifndef POCKETJS_POSIX_DEV_SERVER_H +#define POCKETJS_POSIX_DEV_SERVER_H +#include +#include +#include "dev_protocol.h" + +#define POCKET_DEV_MAX_PACKAGE (24u * 1024u * 1024u) +#define POCKET_DEV_PATH_BYTES 1024 + +/* Single UI-thread owner. pump bounds socket work, including unauthenticated + * traffic. The package consumer runs outside the parser at a frame boundary. */ +int pocket_devwire_init(const char *root, const char *target, uint16_t abi, + uint16_t port, void (*status)(char *, size_t)); +void pocket_devwire_pump(void); +void pocket_devwire_suspend(int suspended); +void pocket_devwire_shutdown(void); +void pocket_devwire_state(uint32_t generation, uint64_t active); +int pocket_devwire_take_upload(uint64_t *hash); +const char *pocket_devwire_upload_path(void); +int pocket_devwire_connected(void); +const char *pocket_devwire_state_name(void); +size_t pocket_devwire_poll(char *out, size_t capacity); +void pocket_devwire_send(const char *text, size_t length); +void pocket_devwire_reset_guest(void); +void pocket_devwire_report(const char *phase, uint64_t hash, const char *message); +void pocket_devwire_log(const char *level, const char *message); +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..0b1a99f53 --- /dev/null +++ b/engine/runtime/guest_runtime.c @@ -0,0 +1,310 @@ +/* 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; + +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_devwire_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_MAX_PACKAGE || 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; + if (!host.boot(package ? &package->guest : &embedded)) { + error_text(host.error()); + reject_hash(running_hash); + stop_guest(); + return 0; + } + 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_devwire_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_devwire_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,\"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, 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) 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(); + if (!pocket_devwire_init(root_path, POCKETJS_TARGET_ID, POCKETJS_HOST_ABI, port, pocket_dev_runtime_status)) return 0; + remove(pocket_devwire_upload_path()); + pocket_devwire_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_devwire_log("error", last_error); + if (requested_hash) pocket_devwire_report("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_devwire_take_upload(&hash)) return; + LoadedPackage *candidate = load_package(pocket_devwire_upload_path(), hash); + if (!candidate) { + pocket_devwire_report("rejected", hash, last_error); + remove(pocket_devwire_upload_path()); + return; + } + char destination[POCKET_DEV_PATH_BYTES]; + blob_path(destination, hash); + if (rename(pocket_devwire_upload_path(), destination)) { + release(candidate); + pocket_devwire_report("rejected", hash, "cannot store admitted package"); + remove(pocket_devwire_upload_path()); + return; + } + stop_guest(); + rejected_count = 0; + requested_hash = hash; + if (boot(candidate)) { + pocket_devwire_report("staged", hash, "guest booted; waiting for presentation"); + } else { + pocket_devwire_report("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"); + if (requested_hash) pocket_devwire_report("accepted", requested_hash, "first frame presented"); + 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..dae453077 --- /dev/null +++ b/engine/runtime/guest_runtime.h @@ -0,0 +1,23 @@ +#ifndef POCKETJS_GUEST_RUNTIME_H +#define POCKETJS_GUEST_RUNTIME_H +#include "../ui-cabi/include/pocket_package.h" +#include "dev_server.h" + +/* The native shell owns the window/GL context; the manager owns package + * buffers and disk state. stop must release all borrowed guest data. */ +typedef struct { + int (*boot)(const PocketGuestPackage *guest); + void (*stop)(void); + int (*validate_plan)(const uint8_t *plan, size_t length); + const char *(*error)(void); +} 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..907236f3a --- /dev/null +++ b/engine/ui-cabi/include/pocket_package.h @@ -0,0 +1,23 @@ +#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's + * viewport 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); +#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..c31f807dc --- /dev/null +++ b/engine/ui-cabi/src/package.rs @@ -0,0 +1,67 @@ +//! 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, + }, + } +} diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 03a1f7c4f..9d990abfe 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -154,6 +154,8 @@ $(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)/devserver.o $(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/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/ios-legacy/runtime.c b/hosts/ios-legacy/runtime.c index 3df43b58f..941e15c99 100644 --- a/hosts/ios-legacy/runtime.c +++ b/hosts/ios-legacy/runtime.c @@ -1,4 +1,9 @@ #include "pocket_runtime.h" +#ifdef POCKET_DEV_RUNTIME +#include "guest_runtime.h" +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). */ @@ -13,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -645,7 +651,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 +1022,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 +1058,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 +1200,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 +1249,51 @@ 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_runtime_validate_plan(plan, length, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT); +} +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}; + 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 +1346,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 +1448,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 +1916,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 +2025,22 @@ 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; + g_dev_suspended = 1; + memset(g_touch_slots, 0, sizeof g_touch_slots); + g_touch_awaiting_completion = 0; + pocket_devwire_suspend(1); + if (g_gl_ready) glFinish(); +} +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 +2074,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..1179c65a9 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,16 @@ 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() +#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..beaf3339b 100644 --- a/tests/3ds-runtime-wire.test.ts +++ b/tests/3ds-runtime-wire.test.ts @@ -44,7 +44,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"); diff --git a/tests/fixtures/ipodtouch4-runtime.c b/tests/fixtures/ipodtouch4-runtime.c new file mode 100644 index 000000000..244ac0949 --- /dev/null +++ b/tests/fixtures/ipodtouch4-runtime.c @@ -0,0 +1,42 @@ +#include "guest_runtime.h" +#include "pocket_runtime.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_runtime_validate_plan(bytes, length, 320, 480); +} +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}; + 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..a6fa9649c --- /dev/null +++ b/tests/ipodtouch4-runtime.test.ts @@ -0,0 +1,227 @@ +import { afterAll, beforeAll, 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 { 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 { 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; + +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(); +} +beforeAll(async () => { + const quickjs = process.env.POCKETJS_QUICKJS_SOURCE ?? join(ipodtouch4QuickJsPath(), "libquickjs-sys/embed/quickjs"); + if (!existsSync(join(quickjs, "quickjs.c"))) throw new Error("set POCKETJS_QUICKJS_SOURCE to the pinned QuickJS source directory (see native-c-harness.yml)"); + const toolchain = IPODTOUCH4_TOOLCHAIN.compiler.rustToolchain; + 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 }); + 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", + "-I", join(ROOT, "engine/runtime"), "-I", join(ROOT, "engine/quickjs-c"), "-I", join(ROOT, "engine/ui-cabi/include"), + "-I", join(ROOT, "contracts/generated"), "-isystem", quickjs, + join(ROOT, "tests/fixtures/ipodtouch4-runtime.c"), join(ROOT, "engine/quickjs-c/pocket_runtime.c"), + ...["dev_protocol", "dev_server", "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); + +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 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"); + }), + 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]?.target).toBe("ipodtouch4-dev"); + client.close(); + await Bun.sleep(50); + const unauthorized = new PocketRuntimeClient({ ...device.address, token: new Uint8Array(32), timeoutMs: 500 }); + try { await expect(unauthorized.connect()).rejects.toThrow(); } + 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/tools/3ds-runtime-client.ts b/tools/3ds-runtime-client.ts index 1e94b6163..459830d75 100644 --- a/tools/3ds-runtime-client.ts +++ b/tools/3ds-runtime-client.ts @@ -1,667 +1,2 @@ -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"; - -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; - } -} - -/** PICA target RGB8 is B,G,R in rotated column-major screen order. */ -export function decodePocketRuntimeSurface( - bytes: Uint8Array, - width: number, - height: number, -): Uint8Array { - if (bytes.length !== width * height * 3) { - throw new Error("Pocket Runtime surface has the wrong RGB8 byte count"); - } - const rgba = new Uint8Array(width * height * 4); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const source = (x * height + (height - 1 - y)) * 3; - const destination = (y * width + x) * 4; - rgba[destination] = bytes[source + 2]; - rgba[destination + 1] = bytes[source + 1]; - rgba[destination + 2] = bytes[source]; - rgba[destination + 3] = 255; - } - } - return rgba; -} - -export function combinePocketRuntimeScreens( - metadata: PocketRuntimeScreenshotBegin, - top: Uint8Array, - auxiliary: Uint8Array, -): Buffer { - const width = Math.max(metadata.topWidth, metadata.auxiliaryWidth); - const height = metadata.topHeight + metadata.auxiliaryHeight; - const rgba = new Uint8Array(width * height * 4); - for (let index = 3; index < rgba.length; index += 4) rgba[index] = 255; - const copy = (surface: Uint8Array, sourceWidth: number, sourceHeight: number, x: number, y: number) => { - for (let row = 0; row < sourceHeight; row++) { - const sourceAt = row * sourceWidth * 4; - const destinationAt = ((y + row) * width + x) * 4; - rgba.set(surface.subarray(sourceAt, sourceAt + sourceWidth * 4), destinationAt); - } - }; - copy( - decodePocketRuntimeSurface(top, metadata.topWidth, metadata.topHeight), - metadata.topWidth, - metadata.topHeight, - Math.floor((width - metadata.topWidth) / 2), - 0, - ); - copy( - decodePocketRuntimeSurface( - auxiliary, - metadata.auxiliaryWidth, - metadata.auxiliaryHeight, - ), - metadata.auxiliaryWidth, - metadata.auxiliaryHeight, - Math.floor((width - metadata.auxiliaryWidth) / 2), - metadata.topHeight, - ); - 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")); -} +// Compatibility entry point for existing 3DS tooling and consumers. +export * from "./pocket-runtime-client.ts"; 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..969812d20 --- /dev/null +++ b/tools/ipodtouch4-runtime.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync, readdirSync, watch } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { pocketPackageFooterHash, pocketRuntimeDeviceId, POCKET_RUNTIME_WIRE_PORT } from "../contracts/spec/pocket-runtime-wire.ts"; +import { startDevServer } from "../hosts/web/server.ts"; +import { IPODTOUCH4_DEV_TARGET_ID, IPODTOUCH4_DEV_HOST_ABI } from "./ipodtouch4-profile.ts"; +import { IPODTOUCH4_RUNTIME_KEYS, pairIPodTouch4Runtime, withIPodTouch4RuntimeUsb } from "./ipodtouch4.ts"; +import { buildIPodTouch4Package, verifyIPodTouch4Package } from "./ipodtouch4-package.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; }; + +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 }; + 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 === "--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 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"); + 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); + } +} + +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] + +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"].includes(options.command)) throw new Error(`unknown Runtime command ${options.command}`); + const filename = options.command === "push" ? await packagePath(options) : undefined; + const operation = async (target: { host: string; port: number; token: Uint8Array }) => { + if (options.command === "dev") await dev(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..afeb6abe7 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"; @@ -89,6 +89,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 +107,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", @@ -692,6 +708,7 @@ async function build(): Promise { const firstParty = [ ...warnings, + ...(APP.devRuntime ? ["-DPOCKET_DEV_RUNTIME", "-I", join(REPOSITORY, "engine/runtime")] : []), `-DPOCKET_LOGICAL_WIDTH=${inputs.viewport.logical[0]}`, `-DPOCKET_LOGICAL_HEIGHT=${inputs.viewport.logical[1]}`, `-DPOCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`, @@ -701,6 +718,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", "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 +743,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 +784,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 +811,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 +925,73 @@ 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; + } + }); } async function uninstall(): Promise { diff --git a/tools/pocket-runtime-client.ts b/tools/pocket-runtime-client.ts new file mode 100644 index 000000000..1e94b6163 --- /dev/null +++ b/tools/pocket-runtime-client.ts @@ -0,0 +1,667 @@ +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"; + +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; + } +} + +/** PICA target RGB8 is B,G,R in rotated column-major screen order. */ +export function decodePocketRuntimeSurface( + bytes: Uint8Array, + width: number, + height: number, +): Uint8Array { + if (bytes.length !== width * height * 3) { + throw new Error("Pocket Runtime surface has the wrong RGB8 byte count"); + } + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const source = (x * height + (height - 1 - y)) * 3; + const destination = (y * width + x) * 4; + rgba[destination] = bytes[source + 2]; + rgba[destination + 1] = bytes[source + 1]; + rgba[destination + 2] = bytes[source]; + rgba[destination + 3] = 255; + } + } + return rgba; +} + +export function combinePocketRuntimeScreens( + metadata: PocketRuntimeScreenshotBegin, + top: Uint8Array, + auxiliary: Uint8Array, +): Buffer { + const width = Math.max(metadata.topWidth, metadata.auxiliaryWidth); + const height = metadata.topHeight + metadata.auxiliaryHeight; + const rgba = new Uint8Array(width * height * 4); + for (let index = 3; index < rgba.length; index += 4) rgba[index] = 255; + const copy = (surface: Uint8Array, sourceWidth: number, sourceHeight: number, x: number, y: number) => { + for (let row = 0; row < sourceHeight; row++) { + const sourceAt = row * sourceWidth * 4; + const destinationAt = ((y + row) * width + x) * 4; + rgba.set(surface.subarray(sourceAt, sourceAt + sourceWidth * 4), destinationAt); + } + }; + copy( + decodePocketRuntimeSurface(top, metadata.topWidth, metadata.topHeight), + metadata.topWidth, + metadata.topHeight, + Math.floor((width - metadata.topWidth) / 2), + 0, + ); + copy( + decodePocketRuntimeSurface( + auxiliary, + metadata.auxiliaryWidth, + metadata.auxiliaryHeight, + ), + metadata.auxiliaryWidth, + metadata.auxiliaryHeight, + Math.floor((width - metadata.auxiliaryWidth) / 2), + metadata.topHeight, + ); + 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")); +} diff --git a/tools/test.ts b/tools/test.ts index 40aa7f55f..3304f7a0f 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -65,6 +65,7 @@ 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/meizu-m8-profile.test.ts", "tests/blackberry-classic.test.ts", "tests/pocket-input.test.ts", From 94a8838f95f12741923682de1a12d32953c544cb Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Sat, 12 Sep 2026 14:43:32 +0800 Subject: [PATCH 2/3] refactor(runtime): share the Pocket Runtime server and target-contract admission Address the review on #397 so the iPod Runtime is shared infrastructure rather than a fork of the 3DS runtime behaviour. - One PKRT server state machine, engine/runtime/dev_server.c, decides what a frame means for every host: pairing, hello/ack (a wrong key is acked with status 2 before the close), frame dispatch with unknown types skipped, control records, uploads that survive a disconnect once committed, the reserved PONG, screenshot chunking, 3 s/15 s idle timeouts and counters. hosts/3ds/src/devserver.c keeps only libctru sockets, osGetTime and linear screenshot buffers; engine/runtime/dev_wire_posix.c is the BSD socket pump for UIKit shells and the host harness. Target id, label and port are configuration; the discovery label comes from the shell. - Plan admission leaves engine/quickjs-c. engine/core/src/plan.rs validates a plan section against a PocketTargetContract exposed through engine/ui-cabi (pocket_package_validate_plan); tools/target-contract.ts renders that contract per native build from the target registry and the verified plan, so the desktop resolver and the device share one source of truth for capabilities, presentation and viewport. - tools/pocket-runtime-client.ts is the generic PKRT client; PICA200 surface decoding and the dual-screen PNG move to tools/3ds-runtime-client.ts. The PNG encoder moves to tools/png.ts (tests/png.ts re-exports it) so no production tool imports from tests/. - tests/pocket-runtime-server.test.ts replays one transcript through the shared server and drives the 3DS pump (compiled on the host with libctru stubbed out) and the POSIX pump with the same desktop client scenario. docs/IPODTOUCH4.md lists the device acceptance pass that remains to be run on hardware. --- .github/workflows/3ds-runtime.yml | 6 +- .github/workflows/native-c-harness.yml | 14 + contracts/spec/pocket-runtime-wire.ts | 2 + docs/DEVTOOLS.md | 6 +- docs/IPODTOUCH4.md | 87 +- engine/core/src/lib.rs | 1 + engine/core/src/plan.rs | 516 +++++++++++ engine/quickjs-c/pocket_runtime.c | 101 +-- engine/quickjs-c/pocket_runtime.h | 5 - engine/runtime/dev_server.c | 1054 ++++++++++++++++------- engine/runtime/dev_server.h | 166 +++- engine/runtime/dev_wire_posix.c | 210 +++++ engine/runtime/dev_wire_posix.h | 41 + engine/runtime/guest_runtime.c | 44 +- engine/runtime/guest_runtime.h | 7 +- engine/ui-cabi/include/pocket_package.h | 38 +- engine/ui-cabi/src/package.rs | 99 +++ hosts/3ds/Makefile | 6 +- hosts/3ds/README.md | 14 +- hosts/3ds/src/dev_server.c | 2 + hosts/3ds/src/dev_server.h | 1 + hosts/3ds/src/devserver.c | 819 ++++-------------- hosts/ios-legacy/runtime.c | 12 +- hosts/ipodtouch4/runtime.c | 1 + tests/3ds-runtime-wire.test.ts | 4 +- tests/e2e/azahar-devserver.ts | 12 +- tests/fixtures/3ds-devserver-host.c | 115 +++ tests/fixtures/3ds-stubs/3ds.h | 28 + tests/fixtures/dev-server-core.c | 368 ++++++++ tests/fixtures/dev-wire-posix-host.c | 89 ++ tests/fixtures/ipodtouch4-runtime.c | 5 +- tests/ipodtouch4-runtime.test.ts | 67 +- tests/png.ts | 80 +- tests/pocket-runtime-server.test.ts | 228 +++++ tools/3ds-dev.ts | 11 +- tools/3ds-runtime-client.ts | 74 +- tools/devtools-bridge.ts | 2 +- tools/ipodtouch4.ts | 16 +- tools/launcher.ts | 2 +- tools/png.ts | 77 ++ tools/pocket-runtime-client.ts | 70 +- tools/tape.ts | 2 +- tools/target-contract.ts | 108 +++ tools/test.ts | 2 + 44 files changed, 3277 insertions(+), 1335 deletions(-) create mode 100644 engine/core/src/plan.rs create mode 100644 engine/runtime/dev_wire_posix.c create mode 100644 engine/runtime/dev_wire_posix.h create mode 100644 hosts/3ds/src/dev_server.c create mode 100644 hosts/3ds/src/dev_server.h create mode 100644 tests/fixtures/3ds-devserver-host.c create mode 100644 tests/fixtures/3ds-stubs/3ds.h create mode 100644 tests/fixtures/dev-server-core.c create mode 100644 tests/fixtures/dev-wire-posix-host.c create mode 100644 tests/pocket-runtime-server.test.ts create mode 100644 tools/png.ts create mode 100644 tools/target-contract.ts diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index 8b41a11c0..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/**', 'engine/runtime/**', 'contracts/**', 'tools/3ds*.ts', 'tools/pocket-runtime-client.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/**', 'engine/runtime/**', 'contracts/**', 'tools/3ds*.ts', 'tools/pocket-runtime-client.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 cec9c6230..2e868865b 100644 --- a/.github/workflows/native-c-harness.yml +++ b/.github/workflows/native-c-harness.yml @@ -10,8 +10,14 @@ on: - "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/**" @@ -37,8 +43,14 @@ on: - "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/**" @@ -96,6 +108,8 @@ jobs: 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 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 99f4cc9e2..1f0c30370 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -133,8 +133,10 @@ The shim needs only `{ send(line), recv() -> line | null }`: 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 - native receiver validates each package before changing guests and commits - its generation after a GLES presentation. Resigning active closes sockets; + 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; the desktop session reconnects when Runtime returns to the foreground. `bun ipodtouch4:runtime capture` uses the USB capture path. See [iPod touch 4](IPODTOUCH4.md#persistent-pocket-runtime). diff --git a/docs/IPODTOUCH4.md b/docs/IPODTOUCH4.md index 2250dce37..d8dbb85f3 100644 --- a/docs/IPODTOUCH4.md +++ b/docs/IPODTOUCH4.md @@ -152,9 +152,16 @@ key in the application's container and stores its local copy under 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.** The shared codec lives in `engine/runtime/dev_protocol.*`; the -desktop client lives in `tools/pocket-runtime-client.ts`. The default USB -route forwards TCP through the pinned SSH connection. It supports +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). @@ -178,8 +185,18 @@ The USB SSH route provides encryption through SSH. **`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, -JavaScript terminator and viewport on the device before replacing the guest. +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 @@ -212,8 +229,9 @@ The Runtime TCP transport has no screenshot stream in this version. 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. A disconnected or incomplete upload -is discarded. +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 @@ -243,15 +261,56 @@ 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 ``` -The runtime tests link the real QuickJS sources, package reader and retained UI -core into a host process. They exercise TCP transfer, admission failures, -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 +`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 this pass on an +iPod touch 4 and record the results in the pull request: + +```sh +bun ipodtouch4:runtime deploy && bun ipodtouch4:runtime pair && bun ipodtouch4:runtime launch +bun ipodtouch4:runtime status # phase accepted, generation 0, active 0 +bun ipodtouch4:runtime push --app clear # accepted; status shows generation 1 +``` + +1. **Repeated replacement.** Push Clear five times with a source edit between + pushes. Each push reports `accepted`; `status` advances the generation each + time and the device stays responsive to touch after every swap. +2. **First-frame failure.** Push a guest whose `frame()` throws on its first + call. The report is `rejected`, `status` keeps the previous `active` hash + and the previous guest is back on screen. +3. **Later-frame failure.** Push a guest that throws after a few seconds. The + push is `accepted`; after the failure `status` shows the previous package + as `active` with the failed hash absent from `lastGood`. +4. **Foreground and background.** With `dev` attached, press Home, wait ten + seconds and reopen Runtime. `dev` logs the disconnect and the reconnect, a + push after the reconnect is `accepted`, and a transfer interrupted by the + Home press is reported as `transfer-error` and does not activate. +5. **Reconnect.** Kill `dev` while connected, start it again with `--no-push` + and confirm the tree and eval answers return on the new connection. +6. **GL teardown.** After steps 1–5, `bun ipodtouch4 status` (the wrapper's + acceptance record) must show an advancing frame counter and the 640×960 + density-2 drawable, and `capture` must produce the current guest. Read the + process memory in the SSH session (`vmmap` or `ps -o rss`) before step 1 + and after step 5: the resident size must not grow with the number of + swaps beyond one guest's working set. 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 222794f97..45481906a 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -5,6 +5,7 @@ #include "quickjs.h" #ifdef POCKET_DEV_RUNTIME #include "dev_server.h" +#include #endif #ifdef POCKET_SVC_WIRE #include "svcwire.h" @@ -105,10 +106,17 @@ static int runtime_failed; #ifdef POCKET_DEV_RUNTIME 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 pocket_devwire_now_ms() >= guest_deadline; + return dev_now_ms() >= guest_deadline; } static JSValue dev_console(JSContext *ctx, JSValueConst this_value, @@ -127,7 +135,7 @@ static JSValue dev_console(JSContext *ctx, JSValueConst this_value, message[length] = 0; JS_FreeCString(ctx, text); } - pocket_devwire_log(level == 1 ? "warn" : level == 2 ? "error" : "log", message); + pocket_devserver_report_log(level == 1 ? "warn" : level == 2 ? "error" : "log", message); return JS_UNDEFINED; } #endif @@ -487,12 +495,12 @@ static JSValue host_operation( case HostDbgActive: return JS_NewBool(ctx, 1); case HostDbgPoll: { - size_t length = pocket_devwire_poll(dev_poll_buffer, sizeof dev_poll_buffer - 1); + 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_devwire_send(text, text_length); + pocket_devserver_send_ctrl(text, text_length); JS_FreeCString(ctx, text); return JS_UNDEFINED; #endif @@ -632,7 +640,7 @@ static int install_host(int width, int height) { static int drain_jobs(void) { for (;;) { #ifdef POCKET_DEV_RUNTIME - if (pocket_devwire_now_ms() >= guest_deadline) { + if (dev_now_ms() >= guest_deadline) { set_error("guest job drain exceeded its time budget"); return 0; } @@ -701,7 +709,7 @@ int pocket_runtime_boot( JS_SetMaxStackSize(runtime, 256 * 1024); #ifdef POCKET_DEV_RUNTIME JS_SetMemoryLimit(runtime, 32u * 1024u * 1024u); - guest_deadline = pocket_devwire_now_ms() + 2000; + guest_deadline = dev_now_ms() + 2000; JS_SetInterruptHandler(runtime, interrupt_guest, NULL); #endif context = JS_NewContext(runtime); @@ -787,7 +795,7 @@ static int run_frame( unsigned int index; if (runtime == 0 || context == 0 || runtime_failed) return 0; #ifdef POCKET_DEV_RUNTIME - guest_deadline = pocket_devwire_now_ms() + 500; + guest_deadline = dev_now_ms() + 500; #endif #ifdef POCKET_SVC_WIRE /* Bounded, non-blocking: discovery, connect, rx and tx progress once per @@ -1074,82 +1082,3 @@ size_t pocket_runtime_length(void) { const char *pocket_runtime_error(void) { return last_error; } - -#ifdef POCKET_DEV_RUNTIME -static int plan_number(JSContext *ctx, JSValueConst object, const char *name, int expected) { - JSValue value = JS_GetPropertyStr(ctx, object, name); - double number = 0; - int ok = JS_IsNumber(value) && JS_ToFloat64(ctx, &number, value) == 0 && number == expected; - JS_FreeValue(ctx, value); - return ok; -} -static int plan_string(JSContext *ctx, JSValueConst object, const char *name, const char *expected) { - JSValue value = JS_GetPropertyStr(ctx, object, name); - size_t length = 0; - const char *text = JS_IsString(value) ? JS_ToCStringLen(ctx, &length, value) : NULL; - int ok = text && length == strlen(expected) && memcmp(text, expected, length) == 0; - if (text) JS_FreeCString(ctx, text); - JS_FreeValue(ctx, value); - return ok; -} -static int plan_dimensions(JSContext *ctx, JSValueConst object, const char *name, int width, int height) { - JSValue array = JS_GetPropertyStr(ctx, object, name); - int ok = JS_IsArray(ctx, array) == 1 && plan_number(ctx, array, "length", 2) && - plan_number(ctx, array, "0", width) && plan_number(ctx, array, "1", height); - JS_FreeValue(ctx, array); - return ok; -} -int pocket_runtime_validate_plan(const uint8_t *bytes, size_t length, int width, int height) { - if (!bytes || !length || length > 256u * 1024u) return 0; - JSRuntime *rt = JS_NewRuntime(); - if (!rt) return 0; - JS_SetMemoryLimit(rt, 4u * 1024u * 1024u); - JS_SetMaxStackSize(rt, 128u * 1024u); - JSContext *ctx = JS_NewContext(rt); - if (!ctx) { JS_FreeRuntime(rt); return 0; } - /* JSON parsing does not evaluate package JavaScript or expose host APIs. */ - JSValue plan = JS_ParseJSON(ctx, (const char *)bytes, length, "plan.json"); - if (JS_IsException(plan) || !JS_IsObject(plan)) { - JS_FreeValue(ctx, plan); - JS_FreeContext(ctx); - JS_FreeRuntime(rt); - return 0; - } - JSValue target = JS_GetPropertyStr(ctx, plan, "target"); - JSValue viewport = JS_GetPropertyStr(ctx, plan, "viewport"); - JSValue surfaces = JS_GetPropertyStr(ctx, plan, "surfaces"); - JSValue extension = JS_GetPropertyStr(ctx, plan, "hostExtension"); - JSValue features = JS_GetPropertyStr(ctx, plan, "features"); - int ok = JS_IsObject(plan) && !JS_IsException(plan) && - plan_string(ctx, target, "id", POCKETJS_TARGET_ID) && - plan_number(ctx, target, "hostAbi", POCKETJS_HOST_ABI) && - plan_dimensions(ctx, viewport, "logical", width, height) && - plan_dimensions(ctx, viewport, "physical", width * POCKET_RASTER_DENSITY, height * POCKET_RASTER_DENSITY) && - plan_number(ctx, viewport, "rasterDensity", POCKET_RASTER_DENSITY) && - plan_string(ctx, viewport, "presentation", "native") && - JS_IsUndefined(surfaces) && JS_IsUndefined(extension) && JS_IsObject(features); - JSPropertyEnum *properties = NULL; - uint32_t count = 0; - if (ok && JS_GetOwnPropertyNames(ctx, &properties, &count, features, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) == 0) { - for (uint32_t i = 0; i < count; ++i) { - const char *name = JS_AtomToCString(ctx, properties[i].atom); - JSValue value = JS_GetProperty(ctx, features, properties[i].atom); - if (!JS_IsBool(value) || (JS_ToBool(ctx, value) && (!name || - (strcmp(name, "input.touch") && strcmp(name, "text.glyphs.baked"))))) ok = 0; - JS_FreeValue(ctx, value); - if (name) JS_FreeCString(ctx, name); - JS_FreeAtom(ctx, properties[i].atom); - } - js_free(ctx, properties); - } else ok = 0; - JS_FreeValue(ctx, features); - JS_FreeValue(ctx, extension); - JS_FreeValue(ctx, surfaces); - JS_FreeValue(ctx, viewport); - JS_FreeValue(ctx, target); - JS_FreeValue(ctx, plan); - JS_FreeContext(ctx); - JS_FreeRuntime(rt); - return ok; -} -#endif diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h index de074e1f8..10b44301e 100644 --- a/engine/quickjs-c/pocket_runtime.h +++ b/engine/quickjs-c/pocket_runtime.h @@ -137,9 +137,4 @@ size_t pocket_runtime_length(void); const char *pocket_runtime_error(void); void pocket_runtime_shutdown(void); -#ifdef POCKET_DEV_RUNTIME -/* Parse only plan metadata in an isolated, bounded realm; never evaluate it. */ -int pocket_runtime_validate_plan(const uint8_t *plan, size_t length, int width, int height); -#endif - #endif diff --git a/engine/runtime/dev_server.c b/engine/runtime/dev_server.c index f728d8308..75d641527 100644 --- a/engine/runtime/dev_server.c +++ b/engine/runtime/dev_server.c @@ -1,391 +1,789 @@ -/* Pocket Runtime wire v1 over non-blocking BSD sockets (UIKit and host tests). - * Uses the same codec and desktop client as the 3DS. No device SDK symbols. */ +/* + * 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 #include #include #include -#include -#include #include -#ifndef MSG_NOSIGNAL -#define MSG_NOSIGNAL 0 -#endif #define RX_CAP (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) #define TX_CAP (2u * RX_CAP) -#define CTRL_CAP (32u * 1024u) -#define IO_BUDGET (64u * 1024u) - -static int listener = -1, discovery = -1, peer = -1; -static int authenticated, suspended, configured, upload_ready; -static uint8_t token[32], rx[RX_CAP], tx[TX_CAP]; -static char controls[CTRL_CAP], hello[1024]; -static size_t rx_len, tx_len, tx_off, controls_len, hello_len; -static char key_path[POCKET_DEV_PATH_BYTES], upload_path[POCKET_DEV_PATH_BYTES]; -static char target_id[16]; -static uint16_t host_abi, listen_port; -static uint32_t generation, upload_expected, upload_received; -static uint64_t active_hash, device_id, last_rx, next_listen, upload_hash; +#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 void (*write_status)(char *, size_t); +static uint32_t upload_expected; +static uint32_t upload_received; +static uint64_t upload_hash; +static int upload_ready; -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 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 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 abort_upload(void) { - if (upload) fclose(upload); - upload = NULL; - upload_expected = upload_received = 0; - upload_hash = 0; - upload_ready = 0; - if (upload_path[0]) remove(upload_path); -} -static void disconnect(void) { - if (peer >= 0) close(peer); - peer = -1; - authenticated = 0; - rx_len = tx_len = tx_off = controls_len = 0; - abort_upload(); +static PocketDevServerCounters counters; + +static uint64_t now_ms(void) { + return configured ? config.now_ms() : 0; } -static void close_listeners(void) { - disconnect(); - if (listener >= 0) close(listener); - if (discovery >= 0) close(discovery); - listener = discovery = -1; + +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); } -static int queue(uint8_t type, const void *bytes, size_t length, int critical) { - if (peer < 0 || length > POCKET_RUNTIME_MAX_FRAME_BYTES) return 0; - if (tx_off) { - memmove(tx, tx + tx_off, tx_len - tx_off); - tx_len -= tx_off; - tx_off = 0; +/* ---- 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 : 4096; - if (required + reserve > sizeof tx - tx_len) { - if (critical) disconnect(); + 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_len, type, 0, (uint32_t)length); - if (length) memcpy(tx + tx_len + POCKET_RUNTIME_FRAME_HEADER_BYTES, bytes, length); - tx_len += required; + 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 void escape(char *out, size_t cap, const char *in) { - size_t length = 0; - for (; *in && length + 7 < cap; ++in) { - unsigned char c = (unsigned char)*in; - if (c == '"' || c == '\\') { out[length++] = '\\'; out[length++] = (char)c; } - else if (c < 32) { length += (size_t)snprintf(out + length, cap - length, "\\u%04x", c); } - else out[length++] = (char)c; - } - out[length] = 0; -} -void pocket_devwire_report(const char *phase, uint64_t hash, const char *message) { - char escaped[1024], line[1280]; - escape(escaped, sizeof escaped, message ? message : ""); - int length = snprintf(line, sizeof line, - "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\",\"generation\":%u,\"message\":\"%s\"}", - phase, (unsigned long long)hash, generation, escaped); - if (authenticated && length > 0 && (size_t)length < sizeof line) - queue(POCKET_RUNTIME_MSG_CTRL, line, (size_t)length, 1); -} -void pocket_devwire_log(const char *level, const char *message) { - char escaped[1024], line[1152]; - escape(escaped, sizeof escaped, message ? message : ""); - int length = snprintf(line, sizeof line, "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", level, escaped); - if (authenticated && length > 0 && (size_t)length < sizeof line) - queue(POCKET_RUNTIME_MSG_CTRL, line, (size_t)length, 0); -} -void pocket_devwire_send(const char *text, size_t length) { - if (!text || !length || length > POCKET_RUNTIME_MAX_FRAME_BYTES) return; + +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 i = 0; i + sizeof marker - 1 <= length && !is_hello; ++i) - is_hello = memcmp(text + i, marker, sizeof marker - 1) == 0; - if (length < sizeof hello && is_hello) { - memcpy(hello, text, length); - hello[length] = 0; - hello_len = length; - } - if (authenticated) queue(POCKET_RUNTIME_MSG_CTRL, text, length, 0); -} -size_t pocket_devwire_poll(char *out, size_t capacity) { - size_t length = controls_len < capacity ? controls_len : capacity; - while (length && controls[length - 1] != '\n') --length; - if (length) { - memcpy(out, controls, length); - memmove(controls, controls + length, controls_len - length); - controls_len -= length; + for (size_t offset = 0; offset + sizeof marker - 1 <= length && !is_hello; offset += 1) { + is_hello = memcmp(line + offset, marker, sizeof marker - 1) == 0; } - return length; + 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); } -void pocket_devwire_reset_guest(void) { controls_len = hello_len = 0; hello[0] = 0; } -static void status(void) { + +static void send_status(void) { char text[2048]; - if (!write_status) return; - write_status(text, sizeof text); - queue(POCKET_RUNTIME_MSG_CTRL, text, strlen(text), 1); + 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); } -static int hex(int c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -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); + } } -static int load_key(void) { - char text[66]; - FILE *file = fopen(key_path, "rb"); - if (!file) return 0; - size_t length = fread(text, 1, sizeof text, file); - fclose(file); - if (length != 64 && !(length == 65 && text[64] == '\n')) return 0; - for (size_t i = 0; i < sizeof token; ++i) { - int high = hex(text[2 * i]), low = hex(text[2 * i + 1]); - if (high < 0 || low < 0) return 0; - token[i] = (uint8_t)((high << 4) | low); + +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); } - device_id = pocket_runtime_device_id(token); +} + +/* ---- 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; } -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 (!load_key()) 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) || listen(listener, 1)) { - close_listeners(); + +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; } - 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)) { - close(discovery); - discovery = -1; - } + 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"); } -int pocket_devwire_init(const char *root, const char *target, uint16_t abi, - uint16_t port, void (*status_callback)(char *, size_t)) { - pocket_devwire_shutdown(); - if (!root || !target || strlen(target) >= sizeof target_id || !port) return 0; - if (snprintf(key_path, sizeof key_path, "%s/dev.key", root) >= (int)sizeof key_path || - snprintf(upload_path, sizeof upload_path, "%s/upload.tmp", root) >= (int)sizeof upload_path) return 0; - strcpy(target_id, target); - host_abi = abi; - listen_port = port; - write_status = status_callback; - configured = 1; - next_listen = 0; - return 1; + +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; } -void pocket_devwire_state(uint32_t next_generation, uint64_t active) { - generation = next_generation; - active_hash = active; -} -void pocket_devwire_suspend(int value) { - suspended = value; - if (value) close_listeners(); - next_listen = 0; -} -void pocket_devwire_shutdown(void) { - close_listeners(); - configured = suspended = 0; - pocket_devwire_reset_guest(); -} -const char *pocket_devwire_upload_path(void) { return upload_path; } -int pocket_devwire_connected(void) { return authenticated; } -const char *pocket_devwire_state_name(void) { - if (suspended) return "suspended"; - if (authenticated) return "connected"; - if (listener >= 0) return "listening"; - return "unpaired-or-unavailable"; -} -int pocket_devwire_take_upload(uint64_t *hash) { + +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; - *hash = upload_hash; + if (declared_hash != NULL) *declared_hash = upload_hash; upload_ready = 0; + upload_expected = 0; + upload_received = 0; upload_hash = 0; return 1; } -static void transfer_error(const char *message) { - uint64_t hash = upload_hash; - abort_upload(); - pocket_devwire_report("transfer-error", hash, message); + +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(); + } } -static void handle_frame(PocketRuntimeFrameHeader header, const uint8_t *payload) { - switch (header.type) { +/* ---- 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 <= 16) queue(POCKET_RUNTIME_MSG_PONG, payload, header.length, 1); - else disconnect(); + if (header->length == sizeof pong) { + memcpy(pong, payload, sizeof pong); + pong_pending = 1; + } break; - case POCKET_RUNTIME_MSG_PONG: break; - case POCKET_RUNTIME_MSG_STATUS_REQUEST: - if (header.length) disconnect(); else status(); + case POCKET_RUNTIME_MSG_PONG: break; case POCKET_RUNTIME_MSG_CTRL: - if (!header.length || header.length > POCKET_RUNTIME_MAX_CTRL_BYTES || - header.length + 1 > sizeof controls - controls_len || - memchr(payload, 0, header.length) || memchr(payload, '\n', header.length) || memchr(payload, '\r', header.length)) { - disconnect(); break; + 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; } - memcpy(controls + controls_len, payload, header.length); - controls_len += header.length; - controls[controls_len++] = '\n'; break; - case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: { - PocketRuntimePackageBegin begin; - if (!pocket_runtime_parse_package_begin(payload, header.length, &begin) || - begin.length < 24 || begin.length > POCKET_DEV_MAX_PACKAGE || !begin.footer_hash) { - disconnect(); break; - } - if (upload || upload_ready) { transfer_error("another upload is in progress"); break; } - upload_hash = begin.footer_hash; - upload_expected = begin.length; - upload_received = 0; - upload = fopen(upload_path, "wb"); - if (!upload) transfer_error("cannot open package staging file"); + case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: + handle_package_begin(payload, header->length); break; - } case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: - if (!upload || header.length <= 4 || pocket_runtime_read_u32(payload) != upload_received || - header.length - 4 > upload_expected - upload_received) { - transfer_error("invalid package chunk offset or length"); break; - } - if (fwrite(payload + 4, 1, header.length - 4, upload) != header.length - 4) { - transfer_error("package write failed"); break; - } - upload_received += header.length - 4; + handle_package_chunk(payload, header->length); break; - case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: { - if (header.length || !upload || upload_received != upload_expected) { - transfer_error("incomplete package transfer"); break; - } - int ok = fflush(upload) == 0 && fsync(fileno(upload)) == 0; - if (fclose(upload)) ok = 0; - upload = NULL; - if (!ok) transfer_error("package flush failed"); - else upload_ready = 1; + case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: + handle_package_commit(header->length); break; - } case POCKET_RUNTIME_MSG_PACKAGE_ABORT: - abort_upload(); + abort_upload("client aborted package transfer"); break; - default: disconnect(); break; - } -} -static void pump_discovery(void) { - if (discovery < 0) return; - for (int i = 0; i < 4; ++i) { - uint8_t request[64], 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_runtime_is_discovery_request(request, (size_t)length)) continue; - pocket_runtime_encode_discovery_reply(reply, host_abi, listen_port, authenticated ? 1 : 0, - generation, active_hash, device_id, target_id, "PocketJS iPod4"); - sendto(discovery, reply, sizeof reply, MSG_NOSIGNAL, (struct sockaddr *)&source, size); - } -} -static void pump_rx(void) { - size_t budget = IO_BUDGET; - unsigned frames = 0; - while (peer >= 0 && frames < 8 && !upload_ready) { - if (!authenticated && rx_len >= POCKET_RUNTIME_HELLO_BYTES) { - if (!pocket_runtime_verify_hello(rx, POCKET_RUNTIME_HELLO_BYTES, token)) { disconnect(); return; } - memmove(rx, rx + POCKET_RUNTIME_HELLO_BYTES, rx_len - POCKET_RUNTIME_HELLO_BYTES); - rx_len -= POCKET_RUNTIME_HELLO_BYTES; - pocket_runtime_encode_ack(tx, 0, host_abi, generation, 0, active_hash); - tx_len = POCKET_RUNTIME_ACK_BYTES; - authenticated = 1; - if (hello_len) queue(POCKET_RUNTIME_MSG_CTRL, hello, hello_len, 0); - status(); - } - if (authenticated && rx_len >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { - PocketRuntimeFrameHeader header; - if (!pocket_runtime_parse_frame_header(rx, rx_len, &header) || header.flags) { disconnect(); return; } - size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + header.length; - if (rx_len >= total) { - handle_frame(header, rx + POCKET_RUNTIME_FRAME_HEADER_BYTES); - if (peer < 0) return; - memmove(rx, rx + total, rx_len - total); - rx_len -= total; - ++frames; - continue; - } + 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; } - if (!budget) break; - size_t room = sizeof rx - rx_len; - if (!room) { disconnect(); return; } - if (room > budget) room = budget; - ssize_t count = recv(peer, rx + rx_len, room, 0); - if (count <= 0) { - if (count == 0 || !again()) disconnect(); + 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; } - rx_len += (size_t)count; - budget -= (size_t)count; - last_rx = pocket_devwire_now_ms(); - } -} -static void pump_tx(void) { - if (peer < 0 || tx_off == tx_len) return; - size_t length = tx_len - tx_off; - if (length > IO_BUDGET) length = IO_BUDGET; - ssize_t count = send(peer, tx + tx_off, length, MSG_NOSIGNAL); - if (count > 0) { - tx_off += (size_t)count; - if (tx_off == tx_len) tx_off = tx_len = 0; - } else if (!count || !again()) disconnect(); -} -void pocket_devwire_pump(void) { - if (suspended || !configured) return; - listen_if_paired(); - if (listener < 0) return; - pump_discovery(); - if (peer < 0) { - peer = accept(listener, NULL, NULL); - if (peer >= 0) { - 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)) { disconnect(); return; } -#endif - if (!nonblocking(peer)) { disconnect(); return; } - last_rx = pocket_devwire_now_ms(); + 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; } - if (peer < 0) return; - if (pocket_devwire_now_ms() - last_rx > (authenticated ? 10000u : 3000u)) { - disconnect(); return; + 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; } - pump_rx(); - pump_tx(); + 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 index 03b4012b1..e5274552a 100644 --- a/engine/runtime/dev_server.h +++ b/engine/runtime/dev_server.h @@ -1,28 +1,148 @@ -#ifndef POCKETJS_POSIX_DEV_SERVER_H -#define POCKETJS_POSIX_DEV_SERVER_H +#ifndef POCKETJS_RUNTIME_DEV_SERVER_H +#define POCKETJS_RUNTIME_DEV_SERVER_H + #include #include + #include "dev_protocol.h" -#define POCKET_DEV_MAX_PACKAGE (24u * 1024u * 1024u) -#define POCKET_DEV_PATH_BYTES 1024 - -/* Single UI-thread owner. pump bounds socket work, including unauthenticated - * traffic. The package consumer runs outside the parser at a frame boundary. */ -int pocket_devwire_init(const char *root, const char *target, uint16_t abi, - uint16_t port, void (*status)(char *, size_t)); -void pocket_devwire_pump(void); -void pocket_devwire_suspend(int suspended); -void pocket_devwire_shutdown(void); -void pocket_devwire_state(uint32_t generation, uint64_t active); -int pocket_devwire_take_upload(uint64_t *hash); -const char *pocket_devwire_upload_path(void); -int pocket_devwire_connected(void); -const char *pocket_devwire_state_name(void); -size_t pocket_devwire_poll(char *out, size_t capacity); -void pocket_devwire_send(const char *text, size_t length); -void pocket_devwire_reset_guest(void); -void pocket_devwire_report(const char *phase, uint64_t hash, const char *message); -void pocket_devwire_log(const char *level, const char *message); -uint64_t pocket_devwire_now_ms(void); +/* + * 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 index 0b1a99f53..ca09c9092 100644 --- a/engine/runtime/guest_runtime.c +++ b/engine/runtime/guest_runtime.c @@ -65,14 +65,14 @@ static void stop_guest(void) { release(current); current = NULL; running = awaiting_frame = 0; - pocket_devwire_reset_guest(); + 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_MAX_PACKAGE || fseek(file, 0, SEEK_SET)) { + 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); @@ -123,7 +123,7 @@ static void recover(void) { } if (!boot(NULL)) { strcpy(phase, "failed"); - pocket_devwire_log("error", last_error); + pocket_devserver_report_log("error", last_error); } } static void load_state(void) { @@ -200,7 +200,7 @@ static int commit(uint64_t active, uint64_t good) { ++generation; active_hash = active; last_good_hash = good; - pocket_devwire_state(generation, active_hash); + pocket_devserver_set_state(generation, active_hash); collect_old_files(); return 1; } @@ -214,7 +214,9 @@ void pocket_dev_runtime_status(char *out, size_t length) { } 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) return 0; + 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); @@ -229,9 +231,11 @@ int pocket_dev_runtime_init(const char *root, const PocketDevHost *callbacks, path(directory, "state"); if (!mkdir_checked(directory)) return 0; load_state(); - if (!pocket_devwire_init(root_path, POCKETJS_TARGET_ID, POCKETJS_HOST_ABI, port, pocket_dev_runtime_status)) return 0; - remove(pocket_devwire_upload_path()); - pocket_devwire_state(generation, active_hash); + 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; @@ -246,8 +250,8 @@ void pocket_dev_runtime_pump(void) { if (!initialized) return; if (failure_pending) { failure_pending = 0; - pocket_devwire_log("error", last_error); - if (requested_hash) pocket_devwire_report("rejected", requested_hash, last_error); + 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; @@ -258,28 +262,28 @@ void pocket_dev_runtime_pump(void) { pocket_devwire_pump(); if (awaiting_frame && running) return; uint64_t hash; - if (!pocket_devwire_take_upload(&hash)) return; - LoadedPackage *candidate = load_package(pocket_devwire_upload_path(), hash); + if (!pocket_devserver_take_upload(&hash)) return; + LoadedPackage *candidate = load_package(pocket_devserver_upload_path(), hash); if (!candidate) { - pocket_devwire_report("rejected", hash, last_error); - remove(pocket_devwire_upload_path()); + 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_devwire_upload_path(), destination)) { + if (rename(pocket_devserver_upload_path(), destination)) { release(candidate); - pocket_devwire_report("rejected", hash, "cannot store admitted package"); - remove(pocket_devwire_upload_path()); + 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)) { - pocket_devwire_report("staged", hash, "guest booted; waiting for presentation"); + pocket_devserver_report_install("staged", hash, "guest booted; waiting for presentation"); } else { - pocket_devwire_report("rejected", hash, last_error); + pocket_devserver_report_install("rejected", hash, last_error); requested_hash = 0; recover(); } @@ -300,7 +304,7 @@ void pocket_dev_runtime_presented(void) { awaiting_frame = 0; rejected_count = 0; strcpy(phase, "accepted"); - if (requested_hash) pocket_devwire_report("accepted", requested_hash, "first frame presented"); + if (requested_hash) pocket_devserver_report_install("accepted", requested_hash, "first frame presented"); requested_hash = 0; } void pocket_dev_runtime_shutdown(void) { diff --git a/engine/runtime/guest_runtime.h b/engine/runtime/guest_runtime.h index dae453077..2a5a7eabf 100644 --- a/engine/runtime/guest_runtime.h +++ b/engine/runtime/guest_runtime.h @@ -2,14 +2,19 @@ #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. */ + * 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, diff --git a/engine/ui-cabi/include/pocket_package.h b/engine/ui-cabi/include/pocket_package.h index 907236f3a..e46f9e556 100644 --- a/engine/ui-cabi/include/pocket_package.h +++ b/engine/ui-cabi/include/pocket_package.h @@ -15,9 +15,43 @@ typedef struct { uint64_t variant_hash; } PocketGuestPackage; -/* Same admission/error codes as the 3DS host. Hosts also validate the plan's - * viewport before changing the running guest. 0 = success, 12 = arguments. */ +/* 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/package.rs b/engine/ui-cabi/src/package.rs index c31f807dc..94da682dd 100644 --- a/engine/ui-cabi/src/package.rs +++ b/engine/ui-cabi/src/package.rs @@ -65,3 +65,102 @@ pub unsafe extern "C" fn pocket_package_open( }, } } + +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 9d990abfe..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 @@ -155,7 +155,9 @@ $(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)/devserver.o $(BUILD)/svcwire.o: $(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_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 941e15c99..7dfa6ceb3 100644 --- a/hosts/ios-legacy/runtime.c +++ b/hosts/ios-legacy/runtime.c @@ -1,6 +1,12 @@ #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 @@ -1267,7 +1273,7 @@ static int dev_boot_guest(const PocketGuestPackage *guest) { 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_runtime_validate_plan(plan, length, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT); + return pocket_package_validate_plan(plan, length, &POCKET_TARGET_CONTRACT) == 0; } static const char *dev_guest_error(void) { return g_status_message; } #endif @@ -1282,7 +1288,9 @@ static int boot_embedded_runtime(void) { 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}; + 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"); diff --git a/hosts/ipodtouch4/runtime.c b/hosts/ipodtouch4/runtime.c index 1179c65a9..7b360d70e 100644 --- a/hosts/ipodtouch4/runtime.c +++ b/hosts/ipodtouch4/runtime.c @@ -32,6 +32,7 @@ static const char *pocket_ipod_runtime_root(void) { 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. */ diff --git a/tests/3ds-runtime-wire.test.ts b/tests/3ds-runtime-wire.test.ts index beaf3339b..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; @@ -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 index 244ac0949..f7716fb52 100644 --- a/tests/fixtures/ipodtouch4-runtime.c +++ b/tests/fixtures/ipodtouch4-runtime.c @@ -1,5 +1,6 @@ #include "guest_runtime.h" #include "pocket_runtime.h" +#include "pocket_target_contract.h" #include #include #include @@ -14,7 +15,7 @@ static int boot(const PocketGuestPackage *guest) { guest->pak, guest->pak_length, 320, 480); } static int validate(const uint8_t *bytes, size_t length) { - return pocket_runtime_validate_plan(bytes, length, 320, 480); + return pocket_package_validate_plan(bytes, length, &POCKET_TARGET_CONTRACT) == 0; } int main(int argc, char **argv) { if (argc != 3) return 2; @@ -23,7 +24,7 @@ int main(int argc, char **argv) { 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}; + 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); diff --git a/tests/ipodtouch4-runtime.test.ts b/tests/ipodtouch4-runtime.test.ts index a6fa9649c..dcddcd65d 100644 --- a/tests/ipodtouch4-runtime.test.ts +++ b/tests/ipodtouch4-runtime.test.ts @@ -1,15 +1,17 @@ -import { afterAll, beforeAll, expect, test } from "bun:test"; +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 { resolveIPodTouch4BuildPlan } from "../tools/ipodtouch4-profile.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"; @@ -23,6 +25,29 @@ 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, @@ -30,16 +55,17 @@ async function run(command: string[], cwd = ROOT, env = process.env) { 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 quickjs = process.env.POCKETJS_QUICKJS_SOURCE ?? join(ipodtouch4QuickJsPath(), "libquickjs-sys/embed/quickjs"); - if (!existsSync(join(quickjs, "quickjs.c"))) throw new Error("set POCKETJS_QUICKJS_SOURCE to the pinned QuickJS source directory (see native-c-harness.yml)"); - const toolchain = IPODTOUCH4_TOOLCHAIN.compiler.rustToolchain; 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`); @@ -50,20 +76,14 @@ beforeAll(async () => { 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", "-I", join(ROOT, "engine/runtime"), "-I", join(ROOT, "engine/quickjs-c"), "-I", join(ROOT, "engine/ui-cabi/include"), - "-I", join(ROOT, "contracts/generated"), "-isystem", quickjs, + "-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", "guest_runtime"].map((name) => join(ROOT, `engine/runtime/${name}.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); -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 packageBytes(source: string, mutate?: (variant: ReturnType) => void) { const variant = makeVariant({ target: plan.target.id, hostAbi: plan.target.hostAbi, planJson: canonicalJson(plan), @@ -134,6 +154,21 @@ test("real QuickJS: accepts, rejects incompatible packages, rolls back eval/fram 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'); };"), @@ -186,11 +221,12 @@ test("unpaired listener stays closed, pairing enables discovery, fragmented uplo 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]?.target).toBe("ipodtouch4-dev"); + 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(); } + 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. @@ -225,3 +261,4 @@ test("the compiled Clear app renders, answers DevTools, and recovers from a late } 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 459830d75..eed1f5c15 100644 --- a/tools/3ds-runtime-client.ts +++ b/tools/3ds-runtime-client.ts @@ -1,2 +1,74 @@ -// Compatibility entry point for existing 3DS tooling and consumers. +// 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 * from "./pocket-runtime-client.ts"; + +/** PICA target RGB8 is B,G,R in rotated column-major screen order. */ +export function decodePocketRuntimeSurface( + bytes: Uint8Array, + width: number, + height: number, +): Uint8Array { + if (bytes.length !== width * height * 3) { + throw new Error("Pocket Runtime surface has the wrong RGB8 byte count"); + } + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const source = (x * height + (height - 1 - y)) * 3; + const destination = (y * width + x) * 4; + rgba[destination] = bytes[source + 2]; + rgba[destination + 1] = bytes[source + 1]; + rgba[destination + 2] = bytes[source]; + rgba[destination + 3] = 255; + } + } + return rgba; +} + +/** Top screen over bottom screen, each centered, as one PNG. */ +export function combinePocketRuntimeScreens( + metadata: PocketRuntimeScreenshotBegin, + top: Uint8Array, + auxiliary: Uint8Array, +): Buffer { + const width = Math.max(metadata.topWidth, metadata.auxiliaryWidth); + const height = metadata.topHeight + metadata.auxiliaryHeight; + const rgba = new Uint8Array(width * height * 4); + for (let index = 3; index < rgba.length; index += 4) rgba[index] = 255; + const copy = (surface: Uint8Array, sourceWidth: number, sourceHeight: number, x: number, y: number) => { + for (let row = 0; row < sourceHeight; row++) { + const sourceAt = row * sourceWidth * 4; + const destinationAt = ((y + row) * width + x) * 4; + rgba.set(surface.subarray(sourceAt, sourceAt + sourceWidth * 4), destinationAt); + } + }; + copy( + decodePocketRuntimeSurface(top, metadata.topWidth, metadata.topHeight), + metadata.topWidth, + metadata.topHeight, + Math.floor((width - metadata.topWidth) / 2), + 0, + ); + copy( + decodePocketRuntimeSurface( + auxiliary, + metadata.auxiliaryWidth, + metadata.auxiliaryHeight, + ), + metadata.auxiliaryWidth, + metadata.auxiliaryHeight, + Math.floor((width - metadata.auxiliaryWidth) / 2), + metadata.topHeight, + ); + return encodePNG(Buffer.from(rgba), width, height); +} + +/** 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.ts b/tools/ipodtouch4.ts index afeb6abe7..841ac068f 100644 --- a/tools/ipodtouch4.ts +++ b/tools/ipodtouch4.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, @@ -656,6 +658,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 }); @@ -708,7 +718,9 @@ async function build(): Promise { const firstParty = [ ...warnings, - ...(APP.devRuntime ? ["-DPOCKET_DEV_RUNTIME", "-I", join(REPOSITORY, "engine/runtime")] : []), + ...(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}`, @@ -721,7 +733,7 @@ async function build(): Promise { 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", "guest_runtime"]) { + 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}`]); diff --git a/tools/launcher.ts b/tools/launcher.ts index a3e28eb04..445ad0b71 100644 --- a/tools/launcher.ts +++ b/tools/launcher.ts @@ -26,7 +26,7 @@ import { import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; -import { encodePNG } from "../tests/png.ts"; +import { encodePNG } from "./png.ts"; import { SHOT_W, SHOT_H, downscaleShot } from "../hosts/sim/shot.ts"; import { SYMBIAN_E7_DEFAULT_VIEWPORT, diff --git a/tools/png.ts b/tools/png.ts new file mode 100644 index 000000000..7808fbfe1 --- /dev/null +++ b/tools/png.ts @@ -0,0 +1,77 @@ +// tools/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). Shared by +// the golden tests, the tape and launcher tools and the 3DS runtime client. +// +// 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)), + ]); +} diff --git a/tools/pocket-runtime-client.ts b/tools/pocket-runtime-client.ts index 1e94b6163..1b3dc4025 100644 --- a/tools/pocket-runtime-client.ts +++ b/tools/pocket-runtime-client.ts @@ -23,14 +23,18 @@ import { type PocketRuntimeFrame, type PocketRuntimeScreenshotBegin, } from "../contracts/spec/pocket-runtime-wire.ts"; -import { encodePNG } from "../tests/png.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; - readonly png: Buffer; } export interface PocketRuntimeClientOptions { @@ -469,13 +473,11 @@ export class PocketRuntimeClient extends EventEmitter { 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); @@ -598,66 +600,6 @@ export class PocketRuntimeSession extends EventEmitter { } } -/** PICA target RGB8 is B,G,R in rotated column-major screen order. */ -export function decodePocketRuntimeSurface( - bytes: Uint8Array, - width: number, - height: number, -): Uint8Array { - if (bytes.length !== width * height * 3) { - throw new Error("Pocket Runtime surface has the wrong RGB8 byte count"); - } - const rgba = new Uint8Array(width * height * 4); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const source = (x * height + (height - 1 - y)) * 3; - const destination = (y * width + x) * 4; - rgba[destination] = bytes[source + 2]; - rgba[destination + 1] = bytes[source + 1]; - rgba[destination + 2] = bytes[source]; - rgba[destination + 3] = 255; - } - } - return rgba; -} - -export function combinePocketRuntimeScreens( - metadata: PocketRuntimeScreenshotBegin, - top: Uint8Array, - auxiliary: Uint8Array, -): Buffer { - const width = Math.max(metadata.topWidth, metadata.auxiliaryWidth); - const height = metadata.topHeight + metadata.auxiliaryHeight; - const rgba = new Uint8Array(width * height * 4); - for (let index = 3; index < rgba.length; index += 4) rgba[index] = 255; - const copy = (surface: Uint8Array, sourceWidth: number, sourceHeight: number, x: number, y: number) => { - for (let row = 0; row < sourceHeight; row++) { - const sourceAt = row * sourceWidth * 4; - const destinationAt = ((y + row) * width + x) * 4; - rgba.set(surface.subarray(sourceAt, sourceAt + sourceWidth * 4), destinationAt); - } - }; - copy( - decodePocketRuntimeSurface(top, metadata.topWidth, metadata.topHeight), - metadata.topWidth, - metadata.topHeight, - Math.floor((width - metadata.topWidth) / 2), - 0, - ); - copy( - decodePocketRuntimeSurface( - auxiliary, - metadata.auxiliaryWidth, - metadata.auxiliaryHeight, - ), - metadata.auxiliaryWidth, - metadata.auxiliaryHeight, - Math.floor((width - metadata.auxiliaryWidth) / 2), - metadata.topHeight, - ); - 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)) { 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 3304f7a0f..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", @@ -66,6 +67,7 @@ const SUITE: readonly Stage[] = [ "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", From 779beab6e3de78819c09a2cfdb5902f0856d7b01 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Sat, 12 Sep 2026 17:28:17 +0800 Subject: [PATCH 3/3] fix(ipodtouch4): size guest budgets for the device and add the Runtime acceptance pass Device validation of the persistent Runtime on an iPod touch 4 (iOS 6.1.6): - The embedded Clear guest needs 2.0-2.8 s to evaluate and mount on the 800 MHz Cortex-A8, so the 2 s boot budget rejected every guest with "InternalError: interrupted". The budgets are now build constants (POCKET_DEV_GUEST_BOOT_BUDGET_MS 15 s, POCKET_DEV_GUEST_TURN_BUDGET_MS 3 s); the host harness keeps 2 s / 500 ms so its hung-guest cases stay short. - The staged/accepted receipts and runtime.status report the measured bootMs and firstFrameMs; the shell's acceptance record reports resident_kb (task_info) for leak checks across swaps. - `bun ipodtouch4:runtime acceptance` runs the documented pass: repeated replacement, first-frame and later-frame failures with recovery, reconnect, one background round trip (SpringBoard app switch with --auto-background, or the Home button) and resident memory before/after, writing a receipt under .pocket-build/validation/. `bun ipodtouch4 open-url` drives SpringBoard for it. - GL work in the shell's resign-active path happens only while the app is still in the foreground. iOS 6 terminates this shell in the background (applicationWillTerminate:, the legacy single-app shell behaves the same); the relaunch reloads the committed generation, and the docs say so. --- docs/DEVTOOLS.md | 6 +- docs/IPODTOUCH4.md | 83 +++++++----- engine/quickjs-c/pocket_runtime.c | 15 ++- engine/runtime/guest_runtime.c | 24 +++- hosts/ios-legacy/runtime.c | 20 ++- tests/ipodtouch4-runtime.test.ts | 3 + tools/ipodtouch4-runtime.ts | 206 +++++++++++++++++++++++++++++- tools/ipodtouch4.ts | 18 +++ 8 files changed, 329 insertions(+), 46 deletions(-) diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 1f0c30370..8794607cf 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -136,8 +136,10 @@ The shim needs only `{ send(line), recv() -> line | null }`: 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; - the desktop session reconnects when Runtime returns to the foreground. + 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, diff --git a/docs/IPODTOUCH4.md b/docs/IPODTOUCH4.md index d8dbb85f3..7e37b15fb 100644 --- a/docs/IPODTOUCH4.md +++ b/docs/IPODTOUCH4.md @@ -248,13 +248,25 @@ 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 two seconds and each guest turn at -500 ms. Those time limits include the pending job drain. They do not bound -native rendering calls. Packages are limited to 24 MiB. +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. Returning to the foreground reopens the paired listener. The shell +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. @@ -284,33 +296,46 @@ 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 this pass on an -iPod touch 4 and record the results in the pull request: +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 status # phase accepted, generation 0, active 0 -bun ipodtouch4:runtime push --app clear # accepted; status shows generation 1 +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 ``` -1. **Repeated replacement.** Push Clear five times with a source edit between - pushes. Each push reports `accepted`; `status` advances the generation each - time and the device stays responsive to touch after every swap. -2. **First-frame failure.** Push a guest whose `frame()` throws on its first - call. The report is `rejected`, `status` keeps the previous `active` hash - and the previous guest is back on screen. -3. **Later-frame failure.** Push a guest that throws after a few seconds. The - push is `accepted`; after the failure `status` shows the previous package - as `active` with the failed hash absent from `lastGood`. -4. **Foreground and background.** With `dev` attached, press Home, wait ten - seconds and reopen Runtime. `dev` logs the disconnect and the reconnect, a - push after the reconnect is `accepted`, and a transfer interrupted by the - Home press is reported as `transfer-error` and does not activate. -5. **Reconnect.** Kill `dev` while connected, start it again with `--no-push` - and confirm the tree and eval answers return on the new connection. -6. **GL teardown.** After steps 1–5, `bun ipodtouch4 status` (the wrapper's - acceptance record) must show an advancing frame counter and the 640×960 - density-2 drawable, and `capture` must produce the current guest. Read the - process memory in the SSH session (`vmmap` or `ps -o rss`) before step 1 - and after step 5: the resident size must not grow with the number of - swaps beyond one guest's working set. +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/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 45481906a..0ad5ef472 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -104,6 +104,17 @@ 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 @@ -709,7 +720,7 @@ int pocket_runtime_boot( JS_SetMaxStackSize(runtime, 256 * 1024); #ifdef POCKET_DEV_RUNTIME JS_SetMemoryLimit(runtime, 32u * 1024u * 1024u); - guest_deadline = dev_now_ms() + 2000; + guest_deadline = dev_now_ms() + POCKET_DEV_GUEST_BOOT_BUDGET_MS; JS_SetInterruptHandler(runtime, interrupt_guest, NULL); #endif context = JS_NewContext(runtime); @@ -795,7 +806,7 @@ static int run_frame( unsigned int index; if (runtime == 0 || context == 0 || runtime_failed) return 0; #ifdef POCKET_DEV_RUNTIME - guest_deadline = dev_now_ms() + 500; + 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 diff --git a/engine/runtime/guest_runtime.c b/engine/runtime/guest_runtime.c index ca09c9092..05a06246c 100644 --- a/engine/runtime/guest_runtime.c +++ b/engine/runtime/guest_runtime.c @@ -32,6 +32,8 @@ 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; @@ -101,12 +103,15 @@ static int boot(LoadedPackage *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; @@ -208,9 +213,11 @@ 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,\"transport\":\"%s\"}", POCKETJS_TARGET_ID, (unsigned)POCKETJS_HOST_ABI, phase, + "\"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, pocket_devwire_state_name()); + (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) { @@ -281,7 +288,10 @@ void pocket_dev_runtime_pump(void) { rejected_count = 0; requested_hash = hash; if (boot(candidate)) { - pocket_devserver_report_install("staged", hash, "guest booted; waiting for presentation"); + 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; @@ -304,7 +314,13 @@ void pocket_dev_runtime_presented(void) { awaiting_frame = 0; rejected_count = 0; strcpy(phase, "accepted"); - if (requested_hash) pocket_devserver_report_install("accepted", requested_hash, "first frame presented"); + 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) { diff --git a/hosts/ios-legacy/runtime.c b/hosts/ios-legacy/runtime.c index 7dfa6ceb3..f61427d96 100644 --- a/hosts/ios-legacy/runtime.c +++ b/hosts/ios-legacy/runtime.c @@ -21,6 +21,7 @@ static int g_dev_suspended; #endif #include +#include #include #include #include @@ -360,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 @@ -380,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(), @@ -413,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 : "" ); @@ -2036,11 +2046,15 @@ static Class register_view_class(void) { #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); - if (g_gl_ready) glFinish(); } static void dev_become_active(id self, SEL command, id application) { (void)self; (void)command; (void)application; diff --git a/tests/ipodtouch4-runtime.test.ts b/tests/ipodtouch4-runtime.test.ts index dcddcd65d..c36e9c406 100644 --- a/tests/ipodtouch4-runtime.test.ts +++ b/tests/ipodtouch4-runtime.test.ts @@ -75,6 +75,9 @@ beforeAll(async () => { } 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"), diff --git a/tools/ipodtouch4-runtime.ts b/tools/ipodtouch4-runtime.ts index 969812d20..cf05977cb 100644 --- a/tools/ipodtouch4-runtime.ts +++ b/tools/ipodtouch4-runtime.ts @@ -1,28 +1,35 @@ #!/usr/bin/env bun -import { existsSync, readFileSync, readdirSync, watch } from "node:fs"; +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_RUNTIME_KEYS, pairIPodTouch4Runtime, withIPodTouch4RuntimeUsb } from "./ipodtouch4.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; }; + 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 }; + 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(); @@ -35,6 +42,7 @@ export function parseRuntimeOptions(argv: readonly string[]): Options { 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}`); } } @@ -42,6 +50,7 @@ export function parseRuntimeOptions(argv: readonly string[]): Options { 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[] { @@ -210,6 +219,189 @@ async function dev(options: Options, firstTarget: { host: string; port: number; } } +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 @@ -219,6 +411,7 @@ function usage() { 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 @@ -244,10 +437,11 @@ export async function main(argv = Bun.argv.slice(2)) { (_key, value) => typeof value === "bigint" ? value.toString(16) : value, 2)); return; } - if (!["push", "status", "dev"].includes(options.command)) throw new Error(`unknown Runtime command ${options.command}`); + 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: { host: string; port: number; token: Uint8Array }) => { + 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)); diff --git a/tools/ipodtouch4.ts b/tools/ipodtouch4.ts index 841ac068f..7441ae18e 100644 --- a/tools/ipodtouch4.ts +++ b/tools/ipodtouch4.ts @@ -262,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; } @@ -1006,6 +1008,18 @@ export async function withIPodTouch4RuntimeUsb(operation: (host: string, port }); } +/** 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 { await withTunnel((port) => { const app = installedApp(port); @@ -1074,6 +1088,7 @@ async function readDeviceStatus(port: number, paths: ReturnType