From 535d43a3ad8b251d1bdbc1afc990d420bd590721 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 9 Aug 2026 11:51:19 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(apple):=20iOS=20host=20=E2=80=94=20poc?= =?UTF-8?q?ket-apple=20core=20crate,=20PocketSurfaceView,=20external-guest?= =?UTF-8?q?=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new engine/apple workspace member packaging modern iOS as a PocketJS host. The composition mirrors hosts/pocketbook: one pocket_mod::Guest realm, one pocket_ui_surface::UiSurface, and pocketjs_core::raster driven incrementally through a DamageTracker, exposed behind a small C ABI. - engine/apple/src/lib.rs — guest-owning mode: create/load_pak/eval_bundle/ frame/render/hit_test_bounds/destroy, plus an effect channel over the ui.svc* ops (set_effect_callback drains guest svcSend lines during frame; post_event queues lines for the guest's next svcPoll). - engine/apple/src/core_host.rs — external-guest mode: pocket_apple_core_* owns only the core, pak feed, raster pipeline, and svc queues, for hosts whose JS engine lives elsewhere (demonstrated with the NativeScript runtime evaluating the guest bundle in its own context). - engine/apple/apple/PocketSurfaceView.{h,m} — CADisplayLink capped at 60 Hz, latched touch contacts (a down+up between two ticks still reaches the guest as one present frame then a release), aspect-fit inverse touch mapping, damage-gated compositing of the ARGB32 framebuffer. - engine/apple/build-xcframework.sh — clang-linked dynamic framework per slice (device arm64 + simulator arm64), no Xcode project. - pocket-ui-surface additionally mounts hitTestBounds (spec op 42), the touch-path hit authority the gesture layer prefers over the ink-claiming hitTest. - apps/nsengine — reference guest: an effect driver over svcSend, a per-frame poll pump, a focusable pressable button, and a platform-reach probe that distinguishes a sidecar realm from an embedding-runtime host. rquickjs uses its bindgen feature: no pregenerated bindings exist for aarch64-apple-ios targets. Validation: the render_hero example drives the ABI end to end — 180 frames of apps/hero/main.tsx at 480x272 density 2 render non-blank and byte-identical across two independent instances. Build guests with --density matching the surface density (glyphs bake at build time; density 4 supersamples cleanly on 3x screens). Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + apps/nsengine/app.tsx | 90 +++ apps/nsengine/channel.ts | 57 ++ apps/nsengine/main.tsx | 7 + engine/Cargo.lock | 157 ++++- engine/Cargo.toml | 1 + engine/apple/Cargo.toml | 25 + engine/apple/apple/PocketSurfaceView.h | 84 +++ engine/apple/apple/PocketSurfaceView.m | 521 ++++++++++++++++ engine/apple/build-xcframework.sh | 77 +++ engine/apple/examples/render_hero.rs | 116 ++++ engine/apple/include/pocket_apple.h | 148 +++++ engine/apple/src/core_host.rs | 559 ++++++++++++++++++ engine/apple/src/lib.rs | 402 +++++++++++++ .../crates/pocket-ui-surface/src/surface.rs | 7 + 15 files changed, 2249 insertions(+), 3 deletions(-) create mode 100644 apps/nsengine/app.tsx create mode 100644 apps/nsengine/channel.ts create mode 100644 apps/nsengine/main.tsx create mode 100644 engine/apple/Cargo.toml create mode 100644 engine/apple/apple/PocketSurfaceView.h create mode 100644 engine/apple/apple/PocketSurfaceView.m create mode 100755 engine/apple/build-xcframework.sh create mode 100644 engine/apple/examples/render_hero.rs create mode 100644 engine/apple/include/pocket_apple.h create mode 100644 engine/apple/src/core_host.rs create mode 100644 engine/apple/src/lib.rs diff --git a/.gitignore b/.gitignore index 1f5ff1d7..d350d13e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ tests/goldens/web/*.actual.png tests/goldens/psp/*.actual.png # irecovery readline history, written when the iPhone 2G runbook drives iBoot .irecovery +engine/apple/dist/ diff --git a/apps/nsengine/app.tsx b/apps/nsengine/app.tsx new file mode 100644 index 00000000..37ce9888 --- /dev/null +++ b/apps/nsengine/app.tsx @@ -0,0 +1,90 @@ +// @title NS Engine — pocketjs guest talking to a NativeScript host +import { createSignal, onMount } from "solid-js"; +import { Image, Screen, Text, View } from "@pocketjs/framework/components"; +import { runEffect } from "@pocketjs/framework/effects"; +import { createSpriteAnimation, onFrame } from "@pocketjs/framework/lifecycle"; +import { pumpHostLines } from "./channel.ts"; + +const SPINNER_FRAMES = [ + "spinner-00.svg", + "spinner-01.svg", + "spinner-02.svg", + "spinner-03.svg", + "spinner-04.svg", + "spinner-05.svg", + "spinner-06.svg", + "spinner-07.svg", +]; + +// Bakes the glyphs dynamic host strings may use (digits, punctuation). +const GLYPH_SEED = "0123456789 #:{}\"pong hello from NativeScript,.!?-_iOS via sandboxed realm"; + +// In a sidecar realm (Direction A) no platform globals exist; when the +// NativeScript runtime is the guest engine (Direction B), the whole iOS +// surface is one identifier away. +declare const UIDevice: { currentDevice: { systemVersion: string } } | undefined; +const platformReach = typeof UIDevice !== "undefined" + ? `iOS ${UIDevice!.currentDevice.systemVersion} via NativeScript` + : "sandboxed realm"; + +function Stat(props: { label: string; value: string; valueClass: string }) { + return ( + + {props.label} + {props.value} + + ); +} + +export default function App() { + const [reply, setReply] = createSignal("waiting"); + const [hostEvent, setHostEvent] = createSignal("none yet"); + const [count, setCount] = createSignal(0); + const spinnerSrc = createSpriteAnimation(SPINNER_FRAMES, { frameStep: 5 }); + + onFrame(() => pumpHostLines((message) => { + setHostEvent(String(message["msg"] ?? JSON.stringify(message))); + })); + + const ping = () => { + const n = count() + 1; + setCount(n); + runEffect("ns.ping", { n }, (result) => setReply(String(result))); + }; + + // Fire one round trip unprompted so the channel proves itself on boot. + onMount(() => ping()); + + return ( + + + + + + + PocketJS × NativeScript + + + one Rust core · two JS worlds + + + + + + + + + + + + + + + Ping host · {count()} + + + + {GLYPH_SEED} + + ); +} diff --git a/apps/nsengine/channel.ts b/apps/nsengine/channel.ts new file mode 100644 index 00000000..4ee3c02a --- /dev/null +++ b/apps/nsengine/channel.ts @@ -0,0 +1,57 @@ +// Effect driver over the ui.svc* host-service channel: commands go out as +// JSON lines (svcSend), results and host-initiated events come back through +// the per-frame poll pump (svcPoll). Protocol: +// guest -> host {t:"cmd", id, kind, payload} +// host -> guest {t:"result", id, result} | {t:"event", ...anything} + +import { getOps } from "@pocketjs/framework"; +import { installEffectDriver } from "@pocketjs/framework/effects"; + +type SvcOps = { + svcSend?: (line: string) => void; + svcPoll?: () => string | null; +}; + +const pendingDeliver = new Map void>(); + +export function installSvcEffectDriver(): void { + installEffectDriver((cmd, deliver) => { + const ops = getOps() as SvcOps; + if (typeof ops.svcSend !== "function") { + return; // host without a service channel: commands drop, app stays pure + } + pendingDeliver.set(cmd.id, deliver); + ops.svcSend(JSON.stringify({ t: "cmd", id: cmd.id, kind: cmd.kind, payload: cmd.payload })); + }); +} + +/** Run once per frame: matches results to pending effects, forwards events. */ +export function pumpHostLines(onEvent: (event: Record) => void): void { + const ops = getOps() as SvcOps; + if (typeof ops.svcPoll !== "function") { + return; + } + const batch = ops.svcPoll(); + if (!batch) { + return; + } + for (const line of batch.split("\n")) { + if (!line) { + continue; + } + let message: Record; + try { + message = JSON.parse(line) as Record; + } catch { + continue; + } + const id = message["id"]; + if (message["t"] === "result" && typeof id === "number" && pendingDeliver.has(id)) { + const deliver = pendingDeliver.get(id)!; + pendingDeliver.delete(id); + deliver(message["result"]); + } else { + onEvent(message); + } + } +} diff --git a/apps/nsengine/main.tsx b/apps/nsengine/main.tsx new file mode 100644 index 00000000..606418f0 --- /dev/null +++ b/apps/nsengine/main.tsx @@ -0,0 +1,7 @@ +// @title NS Engine +import { mount } from "@pocketjs/framework/solid"; +import App from "./app.tsx"; +import { installSvcEffectDriver } from "./channel.ts"; + +installSvcEffectDriver(); +mount(() => ); diff --git a/engine/Cargo.lock b/engine/Cargo.lock index 3ce5f614..d07f7ce2 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -193,6 +193,26 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -317,7 +337,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -332,6 +361,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -368,6 +408,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -502,6 +551,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "env_filter" version = "2.0.0" @@ -578,6 +633,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -689,6 +750,12 @@ dependencies = [ "libm", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.16.0" @@ -874,6 +941,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "image" version = "0.25.10" @@ -911,6 +984,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1161,6 +1243,12 @@ dependencies = [ "paste", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1199,7 +1287,7 @@ dependencies = [ "log", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "strum", "thiserror 2.0.18", @@ -1245,6 +1333,16 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "note-widget" version = "0.1.0" @@ -1634,6 +1732,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pocket-apple" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocket-mod", + "pocket-ui-surface", + "pocketjs-core", + "rquickjs", +] + [[package]] name = "pocket-db" version = "0.1.0" @@ -1830,6 +1940,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1974,6 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" dependencies = [ "rquickjs-core", + "rquickjs-macro", ] [[package]] @@ -1987,12 +2108,30 @@ dependencies = [ "rquickjs-sys", ] +[[package]] +name = "rquickjs-macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e6cf4b6e695b526cb430a6f445d3ccb9908696b99f1c1f8a1480af38bed5e6" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn", +] + [[package]] name = "rquickjs-sys" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" dependencies = [ + "bindgen", "cc", ] @@ -2027,6 +2166,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2151,6 +2296,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -2749,7 +2900,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 38eb5f5e..7a8c0d88 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -11,6 +11,7 @@ [workspace] resolver = "2" members = [ + "apple", "crates/pocket-db", "crates/pocket-fs", "crates/pocket-mod", diff --git a/engine/apple/Cargo.toml b/engine/apple/Cargo.toml new file mode 100644 index 00000000..f1fb354c --- /dev/null +++ b/engine/apple/Cargo.toml @@ -0,0 +1,25 @@ +# pocket-apple — the PocketJS Apple host core: one pocket-mod guest realm, +# the pocket-ui-surface `ui` mount, and pocketjs-core's software rasterizer +# behind a small C ABI consumed by PocketSurfaceView (UIKit). +# +# rquickjs needs its `bindgen` feature here: it ships no pregenerated +# bindings for aarch64-apple-ios targets. + +[package] +name = "pocket-apple" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The PocketJS `ui` surface for Apple platforms: pocket-mod guest, pak feeding, software raster DrawList behind a C ABI" + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +pocket-mod = { workspace = true } +pocket-ui-surface = { workspace = true } +pocketjs-core = { workspace = true } +rquickjs = { workspace = true, features = ["bindgen"] } +anyhow = { workspace = true } +log = { workspace = true } diff --git a/engine/apple/apple/PocketSurfaceView.h b/engine/apple/apple/PocketSurfaceView.h new file mode 100644 index 00000000..76aa3b12 --- /dev/null +++ b/engine/apple/apple/PocketSurfaceView.h @@ -0,0 +1,84 @@ +// PocketSurfaceView — a UIKit view that hosts one PocketJS guest: display-link +// driven ticks, packed touch input, and damage-gated compositing of the +// software-rasterized ARGB framebuffer. Main thread only. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PocketSurfaceView : UIView + +// density is the raster scale (1..4; use 2 or 3 to match screen scale). +- (instancetype)initWithFrame:(CGRect)frame + logicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density; + +// Struct-free convenience for bridged callers; frame starts at zero and is +// laid out by the parent view system. ++ (instancetype)surfaceWithLogicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density; + +// External-guest mode: no embedded QuickJS realm — the embedding runtime +// (e.g. NativeScript) owns the guest, mounts globalThis.ui over the ui* +// methods below, and receives onTick to run globalThis.frame each display +// tick. evalBundle is invalid in this mode; loadPak feeds the core directly. ++ (instancetype)externalSurfaceWithLogicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density; + +// External mode only: runs before the core tick; call globalThis.frame here. +@property(nonatomic, copy, nullable) void (^onTick) + (uint32_t buttons, uint32_t analog, NSArray *touches); + +// ---- external-guest ui.* ops -------------------------------------------- +- (int32_t)uiCreateNode:(int32_t)nodeType; +- (void)uiDestroyNode:(int32_t)nodeId; +- (void)uiInsertBefore:(int32_t)parent child:(int32_t)child anchor:(int32_t)anchor; +- (void)uiRemoveChild:(int32_t)parent child:(int32_t)child; +- (void)uiSetStyle:(int32_t)nodeId style:(int32_t)styleId; +- (void)uiSetProp:(int32_t)nodeId prop:(int32_t)prop value:(double)value; +- (void)uiSetText:(int32_t)nodeId text:(NSString *)text; +- (void)uiReplaceText:(int32_t)nodeId text:(NSString *)text; +- (float)uiMeasureText:(NSString *)text fontSlot:(int32_t)fontSlot; +- (int32_t)uiUploadTexture:(NSData *)pixels width:(uint32_t)width height:(uint32_t)height psm:(uint32_t)psm; +- (void)uiSetImage:(int32_t)nodeId texture:(int32_t)texture; +- (void)uiSetSprite:(int32_t)nodeId atlas:(int32_t)atlas frames:(int32_t)frames cols:(int32_t)cols step:(int32_t)step; +- (int32_t)uiAnimate:(int32_t)nodeId prop:(int32_t)prop to:(double)to dur:(int32_t)durationMs easing:(int32_t)easing delay:(int32_t)delayMs; +- (void)uiCancelAnim:(int32_t)animId; +- (void)uiSetFocus:(int32_t)nodeId; +- (void)uiSetActive:(int32_t)nodeId active:(int32_t)active; +- (int32_t)uiHitTestBounds:(float)x y:(float)y; +- (NSDictionary *)uiTextures; +- (NSArray *> *)uiSprites; +- (void)uiSvcSend:(NSString *)line; +- (NSString *_Nullable)uiSvcPoll; +- (BOOL)uiSvcOpen:(NSString *)name; + +// Feed assets before start. Returns NO with `lastError` set on failure. +- (BOOL)loadPak:(NSData *)pak; +- (BOOL)evalBundle:(NSString *)source label:(nullable NSString *)label; + +// Convenience: reads .js and .pak from a directory. +- (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory; + +// Starts/stops the CADisplayLink. start after evalBundle succeeds. +- (void)start; +- (void)stop; + +// Guest -> host effect lines (JSON by convention), delivered on the main +// thread during the display tick. +@property(nonatomic, copy, nullable) void (^onEffect)(NSString *line); + +// Host -> guest: queued for the guest's next poll (frame-boundary delivery). +- (void)postEvent:(NSString *)line; + +@property(nonatomic, readonly) uint32_t logicalWidth; +@property(nonatomic, readonly) uint32_t logicalHeight; +@property(nonatomic, readonly, nullable) NSString *lastError; +@property(nonatomic, copy, nullable) void (^onError)(NSString *message); + +@end + +NS_ASSUME_NONNULL_END diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m new file mode 100644 index 00000000..b0225613 --- /dev/null +++ b/engine/apple/apple/PocketSurfaceView.m @@ -0,0 +1,521 @@ +#import "PocketSurfaceView.h" + +#import + +#include "pocket_apple.h" + +// The guest sees at most 8 contacts; slots map UITouch identity to the packed +// word's id bits for the touch's lifetime. +#define POCKET_MAX_TOUCHES 8 + +typedef struct { + __weak UITouch *touch; + CGPoint point; + BOOL live; + BOOL reported; + BOOL used; +} PocketTouchSlot; + +@implementation PocketSurfaceView { + PocketApple *_handle; + PocketAppleCore *_coreHandle; + CADisplayLink *_displayLink; + CGColorSpaceRef _colorSpace; + PocketTouchSlot _touchSlots[POCKET_MAX_TOUCHES]; + uint32_t _density; + BOOL _running; +} + ++ (instancetype)surfaceWithLogicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density { + return [[self alloc] initWithFrame:CGRectZero + logicalWidth:logicalWidth + logicalHeight:logicalHeight + density:density]; +} + ++ (instancetype)externalSurfaceWithLogicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density { + PocketSurfaceView *view = [[self alloc] initWithFrame:CGRectZero + logicalWidth:logicalWidth + logicalHeight:logicalHeight + density:density]; + if (view != nil && view->_handle != NULL) { + pocket_apple_destroy(view->_handle); + view->_handle = NULL; + view->_coreHandle = pocket_apple_core_create(density, logicalWidth, logicalHeight); + if (view->_coreHandle == NULL) { + [view captureError]; + } + } + return view; +} + +- (instancetype)initWithFrame:(CGRect)frame + logicalWidth:(uint32_t)logicalWidth + logicalHeight:(uint32_t)logicalHeight + density:(uint32_t)density { + self = [super initWithFrame:frame]; + if (self) { + _logicalWidth = logicalWidth; + _logicalHeight = logicalHeight; + _density = density; + _handle = pocket_apple_create(density, logicalWidth, logicalHeight); + if (_handle == NULL) { + [self captureError]; + } + _colorSpace = CGColorSpaceCreateDeviceRGB(); + self.multipleTouchEnabled = YES; + self.layer.contentsGravity = kCAGravityResizeAspect; + self.backgroundColor = [UIColor blackColor]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidEnterBackground) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [_displayLink invalidate]; + if (_handle != NULL) { + pocket_apple_destroy(_handle); + _handle = NULL; + } + if (_coreHandle != NULL) { + pocket_apple_core_destroy(_coreHandle); + _coreHandle = NULL; + } + if (_colorSpace != NULL) { + CGColorSpaceRelease(_colorSpace); + } +} + +- (void)captureError { + const char *message = pocket_apple_last_error(); + _lastError = message != NULL ? @(message) : @"unknown pocket-apple error"; + if (self.onError != nil) { + self.onError(_lastError); + } +} + +static void PocketSurfaceEffectTrampoline(const char *line, void *context) { + PocketSurfaceView *view = (__bridge PocketSurfaceView *)context; + if (view.onEffect != nil && line != NULL) { + view.onEffect(@(line)); + } +} + +- (void)setOnEffect:(void (^)(NSString *))onEffect { + _onEffect = [onEffect copy]; + if (_handle != NULL) { + // The handle is destroyed in dealloc, so the unretained self reference + // can never outlive the registration. + pocket_apple_set_effect_callback( + _handle, onEffect != nil ? PocketSurfaceEffectTrampoline : NULL, + (__bridge void *)self); + } +} + +- (void)postEvent:(NSString *)line { + if (line.length == 0) { + return; + } + if (_coreHandle != NULL) { + pocket_apple_core_post_event(_coreHandle, line.UTF8String); + } else if (_handle != NULL) { + pocket_apple_post_event(_handle, line.UTF8String); + } +} + +- (BOOL)loadPak:(NSData *)pak { + if (pak.length == 0) { + return NO; + } + if (_coreHandle != NULL) { + if (pocket_apple_core_load_pak(_coreHandle, pak.bytes, pak.length) != 0) { + [self captureError]; + return NO; + } + return YES; + } + if (_handle == NULL) { + return NO; + } + if (pocket_apple_load_pak(_handle, pak.bytes, pak.length) != 0) { + [self captureError]; + return NO; + } + return YES; +} + +- (BOOL)evalBundle:(NSString *)source label:(NSString *)label { + if (_handle == NULL || source.length == 0) { + return NO; + } + NSData *utf8 = [source dataUsingEncoding:NSUTF8StringEncoding]; + if (pocket_apple_eval_bundle(_handle, utf8.bytes, utf8.length, + label != nil ? label.UTF8String : NULL) != 0) { + [self captureError]; + return NO; + } + return YES; +} + +- (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory { + NSString *jsPath = [directory stringByAppendingPathComponent: + [name stringByAppendingPathExtension:@"js"]]; + NSString *pakPath = [directory stringByAppendingPathComponent: + [name stringByAppendingPathExtension:@"pak"]]; + NSData *pak = [NSData dataWithContentsOfFile:pakPath]; + NSString *bundle = [NSString stringWithContentsOfFile:jsPath + encoding:NSUTF8StringEncoding + error:nil]; + if (pak == nil || bundle == nil) { + _lastError = [NSString stringWithFormat:@"missing app assets: %@ / %@", jsPath, pakPath]; + if (self.onError != nil) { + self.onError(_lastError); + } + return NO; + } + return [self loadPak:pak] && [self evalBundle:bundle label:name]; +} + +- (void)start { + if (_running || (_handle == NULL && _coreHandle == NULL)) { + return; + } + _running = YES; + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayTick:)]; + if (@available(iOS 15.0, *)) { + // The core advances in exact 1/60 s steps; cap the link to match. + _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(60, 60, 60); + } + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +} + +- (void)stop { + _running = NO; + [_displayLink invalidate]; + _displayLink = nil; +} + +- (void)appDidEnterBackground { + _displayLink.paused = YES; +} + +- (void)appWillEnterForeground { + if (_running) { + _displayLink.paused = NO; + } +} + +// The layer letterboxes with resizeAspect; touches must invert the same fit. +- (CGRect)fittedContentRect { + CGSize bounds = self.bounds.size; + if (bounds.width <= 0 || bounds.height <= 0 || _logicalWidth == 0 || _logicalHeight == 0) { + return CGRectZero; + } + CGFloat scale = MIN(bounds.width / _logicalWidth, bounds.height / _logicalHeight); + CGFloat width = _logicalWidth * scale; + CGFloat height = _logicalHeight * scale; + return CGRectMake((bounds.width - width) / 2, (bounds.height - height) / 2, width, height); +} + +- (BOOL)logicalPointForPoint:(CGPoint)point outX:(uint32_t *)outX outY:(uint32_t *)outY { + CGRect content = [self fittedContentRect]; + if (CGRectIsEmpty(content)) { + return NO; + } + CGFloat x = (point.x - content.origin.x) / content.size.width * _logicalWidth; + CGFloat y = (point.y - content.origin.y) / content.size.height * _logicalHeight; + if (x < 0 || y < 0 || x >= _logicalWidth || y >= _logicalHeight) { + return NO; + } + // Packed coordinates carry 9 bits per axis. + *outX = (uint32_t)MIN(x, 511.0); + *outY = (uint32_t)MIN(y, 511.0); + return YES; +} + +// Contacts latch until the display tick has reported them at least once: +// a down+up that lands between two ticks still reaches the guest as one +// present frame followed by an absent one (the contract's release edge). +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + for (UITouch *touch in touches) { + for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) { + if (!_touchSlots[slot].used) { + _touchSlots[slot].touch = touch; + _touchSlots[slot].point = [touch locationInView:self]; + _touchSlots[slot].live = YES; + _touchSlots[slot].reported = NO; + _touchSlots[slot].used = YES; + break; + } + } + } +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + for (UITouch *touch in touches) { + for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) { + if (_touchSlots[slot].used && _touchSlots[slot].touch == touch) { + _touchSlots[slot].point = [touch locationInView:self]; + } + } + } +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + [self releaseTouches:touches]; +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + [self releaseTouches:touches]; +} + +- (void)releaseTouches:(NSSet *)touches { + for (UITouch *touch in touches) { + for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) { + if (_touchSlots[slot].used && _touchSlots[slot].touch == touch) { + _touchSlots[slot].live = NO; + if (_touchSlots[slot].reported) { + _touchSlots[slot].used = NO; + } + } + } + } +} + +- (size_t)collectTouchWords:(uint32_t[POCKET_MAX_TOUCHES])words { + size_t count = 0; + for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) { + if (!_touchSlots[slot].used) { + continue; + } + if (_touchSlots[slot].live && _touchSlots[slot].touch != nil) { + _touchSlots[slot].point = [_touchSlots[slot].touch locationInView:self]; + } + uint32_t x = 0; + uint32_t y = 0; + if ([self logicalPointForPoint:_touchSlots[slot].point outX:&x outY:&y]) { + words[count++] = ((uint32_t)(slot & 0xff) << 18) | ((y & 0x1ff) << 9) | (x & 0x1ff); + } + _touchSlots[slot].reported = YES; + if (!_touchSlots[slot].live) { + _touchSlots[slot].used = NO; + } + } + return count; +} + +- (void)presentFrame:(const PocketAppleFrame *)frame { + if (frame->region_count == 0 && self.layer.contents != nil) { + return; + } + size_t length = (size_t)frame->stride_bytes * frame->height_px; + CFDataRef data = CFDataCreate(NULL, frame->pixels, (CFIndex)length); + if (data == NULL) { + return; + } + CGDataProviderRef provider = CGDataProviderCreateWithCFData(data); + CGImageRef image = CGImageCreate( + frame->width_px, frame->height_px, 8, 32, frame->stride_bytes, _colorSpace, + (CGBitmapInfo)kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little, provider, NULL, + false, kCGRenderingIntentDefault); + if (image != NULL) { + self.layer.contents = (__bridge id)image; + CGImageRelease(image); + } + CGDataProviderRelease(provider); + CFRelease(data); +} + +- (void)handleDisplayTick:(CADisplayLink *)link { + uint32_t words[POCKET_MAX_TOUCHES]; + size_t count = [self collectTouchWords:words]; + + if (_coreHandle != NULL) { + if (self.onTick != nil) { + NSMutableArray *touches = [NSMutableArray arrayWithCapacity:count]; + for (size_t i = 0; i < count; i++) { + [touches addObject:@(words[i])]; + } + self.onTick(0, 0x8080, touches); + } + pocket_apple_core_tick(_coreHandle); + if (self.onEffect != nil) { + pocket_apple_core_drain_effects(_coreHandle, PocketSurfaceEffectTrampoline, + (__bridge void *)self); + } + PocketAppleFrame frame; + if (pocket_apple_core_render(_coreHandle, &frame) != 0) { + [self captureError]; + [self stop]; + return; + } + [self presentFrame:&frame]; + return; + } + + if (_handle == NULL) { + return; + } + if (pocket_apple_frame(_handle, 0, 0, count > 0 ? words : NULL, count) != 0) { + [self captureError]; + [self stop]; + return; + } + PocketAppleFrame frame; + if (pocket_apple_render(_handle, &frame) != 0) { + [self captureError]; + [self stop]; + return; + } + [self presentFrame:&frame]; +} + +// ---- external-guest ui.* ops -------------------------------------------- + +- (int32_t)uiCreateNode:(int32_t)nodeType { + return _coreHandle != NULL ? pocket_apple_core_create_node(_coreHandle, (uint32_t)nodeType) : 0; +} + +- (void)uiDestroyNode:(int32_t)nodeId { + if (_coreHandle != NULL) pocket_apple_core_destroy_node(_coreHandle, nodeId); +} + +- (void)uiInsertBefore:(int32_t)parent child:(int32_t)child anchor:(int32_t)anchor { + if (_coreHandle != NULL) pocket_apple_core_insert_before(_coreHandle, parent, child, anchor); +} + +- (void)uiRemoveChild:(int32_t)parent child:(int32_t)child { + if (_coreHandle != NULL) pocket_apple_core_remove_child(_coreHandle, parent, child); +} + +- (void)uiSetStyle:(int32_t)nodeId style:(int32_t)styleId { + if (_coreHandle != NULL) pocket_apple_core_set_style(_coreHandle, nodeId, styleId); +} + +- (void)uiSetProp:(int32_t)nodeId prop:(int32_t)prop value:(double)value { + if (_coreHandle != NULL) pocket_apple_core_set_prop(_coreHandle, nodeId, (uint32_t)prop, value); +} + +- (void)uiSetText:(int32_t)nodeId text:(NSString *)text { + if (_coreHandle == NULL) return; + NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; + pocket_apple_core_set_text(_coreHandle, nodeId, utf8.bytes, utf8.length); +} + +- (void)uiReplaceText:(int32_t)nodeId text:(NSString *)text { + if (_coreHandle == NULL) return; + NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; + pocket_apple_core_replace_text(_coreHandle, nodeId, utf8.bytes, utf8.length); +} + +- (float)uiMeasureText:(NSString *)text fontSlot:(int32_t)fontSlot { + if (_coreHandle == NULL) return 0; + NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; + return pocket_apple_core_measure_text(_coreHandle, utf8.bytes, utf8.length, (uint32_t)fontSlot); +} + +- (int32_t)uiUploadTexture:(NSData *)pixels width:(uint32_t)width height:(uint32_t)height psm:(uint32_t)psm { + if (_coreHandle == NULL || pixels.length == 0) return -1; + return pocket_apple_core_upload_texture(_coreHandle, pixels.bytes, pixels.length, width, height, psm); +} + +- (void)uiSetImage:(int32_t)nodeId texture:(int32_t)texture { + if (_coreHandle != NULL) pocket_apple_core_set_image(_coreHandle, nodeId, texture); +} + +- (void)uiSetSprite:(int32_t)nodeId atlas:(int32_t)atlas frames:(int32_t)frames cols:(int32_t)cols step:(int32_t)step { + if (_coreHandle != NULL) { + pocket_apple_core_set_sprite(_coreHandle, nodeId, atlas, (uint32_t)frames, (uint32_t)cols, + (uint32_t)step); + } +} + +- (int32_t)uiAnimate:(int32_t)nodeId prop:(int32_t)prop to:(double)to dur:(int32_t)durationMs easing:(int32_t)easing delay:(int32_t)delayMs { + if (_coreHandle == NULL) return -1; + return pocket_apple_core_animate(_coreHandle, nodeId, (uint32_t)prop, to, (uint32_t)durationMs, + (uint32_t)easing, (uint32_t)delayMs); +} + +- (void)uiCancelAnim:(int32_t)animId { + if (_coreHandle != NULL) pocket_apple_core_cancel_anim(_coreHandle, animId); +} + +- (void)uiSetFocus:(int32_t)nodeId { + if (_coreHandle != NULL) pocket_apple_core_set_focus(_coreHandle, nodeId); +} + +- (void)uiSetActive:(int32_t)nodeId active:(int32_t)active { + if (_coreHandle != NULL) pocket_apple_core_set_active(_coreHandle, nodeId, active); +} + +- (int32_t)uiHitTestBounds:(float)x y:(float)y { + if (_coreHandle != NULL) return pocket_apple_core_hit_test_bounds(_coreHandle, x, y); + if (_handle != NULL) return pocket_apple_hit_test_bounds(_handle, x, y); + return 0; +} + +- (NSDictionary *)uiTextures { + NSMutableDictionary *table = [NSMutableDictionary dictionary]; + if (_coreHandle != NULL) { + uint32_t count = pocket_apple_core_texture_count(_coreHandle); + for (uint32_t i = 0; i < count; i++) { + const char *name = pocket_apple_core_texture_name(_coreHandle, i); + if (name != NULL) { + table[@(name)] = @(pocket_apple_core_texture_handle(_coreHandle, i)); + } + } + } + return table; +} + +- (NSArray *> *)uiSprites { + NSMutableArray *sprites = [NSMutableArray array]; + if (_coreHandle != NULL) { + uint32_t count = pocket_apple_core_sprite_count(_coreHandle); + for (uint32_t i = 0; i < count; i++) { + const char *name = pocket_apple_core_sprite_name(_coreHandle, i); + int32_t info[4] = {0}; + if (name != NULL && pocket_apple_core_sprite_info(_coreHandle, i, info) == 0) { + [sprites addObject:@{ + @"name" : @(name), + @"handle" : @(info[0]), + @"frames" : @(info[1]), + @"cols" : @(info[2]), + @"step" : @(info[3]), + }]; + } + } + } + return sprites; +} + +- (void)uiSvcSend:(NSString *)line { + if (_coreHandle == NULL) return; + NSData *utf8 = [line dataUsingEncoding:NSUTF8StringEncoding]; + pocket_apple_core_svc_send(_coreHandle, utf8.bytes, utf8.length); +} + +- (NSString *)uiSvcPoll { + if (_coreHandle == NULL) return nil; + const char *batch = pocket_apple_core_svc_poll(_coreHandle); + return batch != NULL ? @(batch) : nil; +} + +- (BOOL)uiSvcOpen:(NSString *)name { + return _coreHandle != NULL; +} + +@end diff --git a/engine/apple/build-xcframework.sh b/engine/apple/build-xcframework.sh new file mode 100755 index 00000000..b49683f9 --- /dev/null +++ b/engine/apple/build-xcframework.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Builds PocketApple.xcframework: the pocket-apple Rust staticlib plus the +# compiled PocketSurfaceView, packaged as a dynamic framework per slice +# (device arm64 + simulator arm64). Output: engine/apple/dist/. +set -euo pipefail + +cd "$(dirname "$0")" +APPLE_DIR="$PWD" +ENGINE_DIR="$(cd .. && pwd)" +DIST="$APPLE_DIR/dist" +MIN_IOS="16.0" + +rm -rf "$DIST" +mkdir -p "$DIST" + +build_slice() { + local rust_target="$1" sdk="$2" clang_target="$3" slice="$4" + + (cd "$ENGINE_DIR" && IPHONEOS_DEPLOYMENT_TARGET="$MIN_IOS" cargo build -p pocket-apple --release --target "$rust_target") + + local fw="$DIST/$slice/PocketApple.framework" + mkdir -p "$fw/Headers" "$fw/Modules" + + cp "$APPLE_DIR/include/pocket_apple.h" "$fw/Headers/" + cp "$APPLE_DIR/apple/PocketSurfaceView.h" "$fw/Headers/" + cat > "$fw/Headers/PocketApple.h" <<'EOF' +#import +#include +EOF + cat > "$fw/Modules/module.modulemap" <<'EOF' +framework module PocketApple { + umbrella header "PocketApple.h" + export * + module * { export * } +} +EOF + cat > "$fw/Info.plist" < + + + + CFBundleDevelopmentRegionen + CFBundleExecutablePocketApple + CFBundleIdentifierdev.pocketjs.PocketApple + CFBundleInfoDictionaryVersion6.0 + CFBundleNamePocketApple + CFBundlePackageTypeFMWK + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + MinimumOSVersion$MIN_IOS + + +EOF + + xcrun -sdk "$sdk" clang \ + -target "$clang_target" \ + -fobjc-arc -fapplication-extension \ + -dynamiclib \ + -install_name "@rpath/PocketApple.framework/PocketApple" \ + -I "$APPLE_DIR/include" \ + "$APPLE_DIR/apple/PocketSurfaceView.m" \ + "$ENGINE_DIR/target/$rust_target/release/libpocket_apple.a" \ + -framework Foundation -framework UIKit -framework QuartzCore -framework CoreGraphics \ + -dead_strip \ + -o "$fw/PocketApple" +} + +build_slice aarch64-apple-ios iphoneos "arm64-apple-ios$MIN_IOS" ios-arm64 +build_slice aarch64-apple-ios-sim iphonesimulator "arm64-apple-ios$MIN_IOS-simulator" ios-arm64-simulator + +rm -rf "$DIST/PocketApple.xcframework" +xcodebuild -create-xcframework \ + -framework "$DIST/ios-arm64/PocketApple.framework" \ + -framework "$DIST/ios-arm64-simulator/PocketApple.framework" \ + -output "$DIST/PocketApple.xcframework" + +echo "OK: $DIST/PocketApple.xcframework" diff --git a/engine/apple/examples/render_hero.rs b/engine/apple/examples/render_hero.rs new file mode 100644 index 00000000..bae513a0 --- /dev/null +++ b/engine/apple/examples/render_hero.rs @@ -0,0 +1,116 @@ +//! Renders a guest bundle through the pocket-apple C ABI and writes PPM +//! snapshots. Usage: +//! cargo run -p pocket-apple --example render_hero -- ../dist/hero.js ../dist/hero.pak /tmp/hero +//! Exit is nonzero if two independent instances disagree on the final frame +//! (determinism check) or the frame is blank. + +use std::ffi::CString; + +use pocket_apple::{ + pocket_apple_create, pocket_apple_destroy, pocket_apple_eval_bundle, pocket_apple_frame, + pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, PocketAppleFrame, +}; + +const WIDTH: u32 = 480; +const HEIGHT: u32 = 272; +const DENSITY: u32 = 2; +const FRAMES: u32 = 180; + +fn last_error() -> String { + unsafe { + std::ffi::CStr::from_ptr(pocket_apple_last_error()) + .to_string_lossy() + .into_owned() + } +} + +fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec, u32, u32, u64) { + let handle = pocket_apple_create(DENSITY, WIDTH, HEIGHT); + assert!(!handle.is_null(), "create failed: {}", last_error()); + assert_eq!( + pocket_apple_load_pak(handle, pak.as_ptr(), pak.len()), + 0, + "load_pak failed: {}", + last_error() + ); + let label = CString::new("hero").unwrap(); + assert_eq!( + pocket_apple_eval_bundle(handle, bundle.as_ptr(), bundle.len(), label.as_ptr()), + 0, + "eval failed: {}", + last_error() + ); + + let mut frame = unsafe { std::mem::zeroed::() }; + let mut damage_total: u64 = 0; + for tick in 0..FRAMES { + assert_eq!( + pocket_apple_frame(handle, 0, 0, std::ptr::null(), 0), + 0, + "frame {tick} failed: {}", + last_error() + ); + assert_eq!( + pocket_apple_render(handle, &mut frame), + 0, + "render {tick} failed: {}", + last_error() + ); + for region in frame.regions.iter().take(frame.region_count as usize) { + damage_total += (region[2] as u64) * (region[3] as u64); + } + } + let len = (frame.stride_bytes * frame.height_px) as usize; + let pixels = unsafe { std::slice::from_raw_parts(frame.pixels, len) }.to_vec(); + let (w, h) = (frame.width_px, frame.height_px); + pocket_apple_destroy(handle); + (pixels, w, h, damage_total) +} + +fn write_ppm(path: &str, argb: &[u8], width: u32, height: u32) { + let mut out = format!("P6\n{width} {height}\n255\n").into_bytes(); + for chunk in argb.chunks_exact(4) { + // ARGB32 little-endian in memory: B, G, R, A. + out.extend_from_slice(&[chunk[2], chunk[1], chunk[0]]); + } + std::fs::write(path, out).expect("write ppm"); +} + +struct StderrLogger; + +impl log::Log for StderrLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, record: &log::Record) { + eprintln!("[{}] {}", record.target(), record.args()); + } + fn flush(&self) {} +} + +static LOGGER: StderrLogger = StderrLogger; + +fn main() { + let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug)); + let args: Vec = std::env::args().collect(); + let bundle_path = args.get(1).map(String::as_str).unwrap_or("../dist/hero.js"); + let pak_path = args.get(2).map(String::as_str).unwrap_or("../dist/hero.pak"); + let out_base = args.get(3).map(String::as_str).unwrap_or("/tmp/hero"); + + let bundle = std::fs::read(bundle_path).expect("read bundle"); + let pak = std::fs::read(pak_path).expect("read pak"); + + let (first, w, h, damage_a) = run_instance(&bundle, &pak); + let (second, _, _, damage_b) = run_instance(&bundle, &pak); + + let non_blank = first.chunks_exact(4).any(|px| px[0] != 0 || px[1] != 0 || px[2] != 0); + let deterministic = first == second; + + write_ppm(&format!("{out_base}-frame{FRAMES}.ppm"), &first, w, h); + println!( + "rendered {FRAMES} frames at {w}x{h} (density {DENSITY}) | non_blank={non_blank} deterministic={deterministic} damage_px_a={damage_a} damage_px_b={damage_b}" + ); + if !non_blank || !deterministic { + std::process::exit(1); + } +} diff --git a/engine/apple/include/pocket_apple.h b/engine/apple/include/pocket_apple.h new file mode 100644 index 00000000..f9d64e04 --- /dev/null +++ b/engine/apple/include/pocket_apple.h @@ -0,0 +1,148 @@ +// pocket-apple C ABI — the PocketJS guest + ui surface + software rasterizer +// (engine/apple/src/lib.rs). Single-threaded: create, drive, and destroy a +// handle from one thread (in practice the main thread, with CADisplayLink). +// +// Call order per handle: +// create -> load_pak* -> [set_identity] -> eval_bundle +// -> per tick: frame, render -> destroy +// load_pak/set_identity are rejected after eval_bundle: the surface publishes +// both to the guest when `ui` is mounted. + +#ifndef POCKET_APPLE_H +#define POCKET_APPLE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKET_APPLE_MAX_DAMAGE_REGIONS 8 + +typedef struct PocketApple PocketApple; + +// One rendered frame. `pixels` is ARGB32 words — BGRA byte order in memory on +// little-endian, i.e. kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst. +// The pointer stays valid until the next render/destroy on the same handle. +typedef struct PocketAppleFrame { + const uint8_t *pixels; + uint32_t width_px; + uint32_t height_px; + uint32_t stride_bytes; + // Repaint rects in pixel coordinates as x, y, w, h. region_count == 0 means + // nothing changed this frame; previous contents are still current. + int32_t regions[POCKET_APPLE_MAX_DAMAGE_REGIONS][4]; + uint32_t region_count; + int32_t full_redraw; +} PocketAppleFrame; + +uint32_t pocket_apple_abi_version(void); + +// Last failure message for this thread; valid until the next failing call. +const char *pocket_apple_last_error(void); + +// density is the raster scale (1..4); logical viewport is in pocketjs units +// (hero is 480x272). Returns NULL on failure. +PocketApple *pocket_apple_create(uint32_t density, uint32_t logical_width, + uint32_t logical_height); + +int32_t pocket_apple_set_identity(PocketApple *handle, const char *host_id, + uint32_t host_abi); + +int32_t pocket_apple_load_pak(PocketApple *handle, const uint8_t *bytes, + size_t length); + +// Mounts `ui` on first call, evaluates the bundle, and requires it to install +// globalThis.frame. `label` may be NULL ("app"). +int32_t pocket_apple_eval_bundle(PocketApple *handle, const uint8_t *source, + size_t length, const char *label); + +// touches: up to 8 packed words, (id & 0xff) << 18 | (y & 0x1ff) << 9 | +// (x & 0x1ff), logical coordinates; a contact present this tick means +// down/move, absent means released. analog 0 means centered (0x8080). +int32_t pocket_apple_frame(PocketApple *handle, uint32_t buttons, + uint32_t analog, const uint32_t *touches, + size_t touch_count); + +int32_t pocket_apple_render(PocketApple *handle, PocketAppleFrame *out); + +// Logical coordinates; returns the hit node id or 0. +int32_t pocket_apple_hit_test_bounds(PocketApple *handle, float x, float y); + +// Guest -> host effect sink: lines the guest's effect driver svcSend()s +// (JSON by convention), delivered synchronously during pocket_apple_frame on +// the calling thread. context must outlive the registration. +typedef void (*PocketAppleEffectCallback)(const char *line, void *context); +int32_t pocket_apple_set_effect_callback(PocketApple *handle, + PocketAppleEffectCallback callback, + void *context); + +// Host -> guest: queue one line for the guest's next svcPoll (delivered at a +// frame boundary, never mid-tick). +int32_t pocket_apple_post_event(PocketApple *handle, const char *line); + +void pocket_apple_destroy(PocketApple *handle); + +// ---- external-guest mode --------------------------------------------------- +// The embedding runtime owns the JS guest; this side owns only the core, the +// pak feed, the raster pipeline, and the svc queues. Mount globalThis.ui in +// the embedding engine over these ops. Same single-thread rules. + +typedef struct PocketAppleCore PocketAppleCore; + +PocketAppleCore *pocket_apple_core_create(uint32_t density, uint32_t logical_width, + uint32_t logical_height); +int32_t pocket_apple_core_load_pak(PocketAppleCore *handle, const uint8_t *bytes, + size_t length); + +int32_t pocket_apple_core_create_node(PocketAppleCore *handle, uint32_t node_type); +void pocket_apple_core_destroy_node(PocketAppleCore *handle, int32_t id); +void pocket_apple_core_insert_before(PocketAppleCore *handle, int32_t parent, int32_t child, + int32_t anchor); +void pocket_apple_core_remove_child(PocketAppleCore *handle, int32_t parent, int32_t child); +void pocket_apple_core_set_style(PocketAppleCore *handle, int32_t id, int32_t style); +void pocket_apple_core_set_prop(PocketAppleCore *handle, int32_t id, uint32_t prop, double value); +void pocket_apple_core_set_text(PocketAppleCore *handle, int32_t id, const uint8_t *text, + size_t length); +void pocket_apple_core_replace_text(PocketAppleCore *handle, int32_t id, const uint8_t *text, + size_t length); +float pocket_apple_core_measure_text(PocketAppleCore *handle, const uint8_t *text, size_t length, + uint32_t font_slot); +int32_t pocket_apple_core_upload_texture(PocketAppleCore *handle, const uint8_t *bytes, + size_t length, uint32_t width, uint32_t height, + uint32_t psm); +void pocket_apple_core_set_image(PocketAppleCore *handle, int32_t id, int32_t texture); +void pocket_apple_core_set_sprite(PocketAppleCore *handle, int32_t id, int32_t atlas, + uint32_t frames, uint32_t cols, uint32_t step); +int32_t pocket_apple_core_animate(PocketAppleCore *handle, int32_t id, uint32_t prop, double to, + uint32_t duration_ms, uint32_t easing, uint32_t delay_ms); +void pocket_apple_core_cancel_anim(PocketAppleCore *handle, int32_t anim_id); +void pocket_apple_core_set_focus(PocketAppleCore *handle, int32_t id); +void pocket_apple_core_set_active(PocketAppleCore *handle, int32_t id, int32_t active); +int32_t pocket_apple_core_hit_test_bounds(PocketAppleCore *handle, float x, float y); + +uint32_t pocket_apple_core_texture_count(PocketAppleCore *handle); +const char *pocket_apple_core_texture_name(PocketAppleCore *handle, uint32_t index); +int32_t pocket_apple_core_texture_handle(PocketAppleCore *handle, uint32_t index); +uint32_t pocket_apple_core_sprite_count(PocketAppleCore *handle); +const char *pocket_apple_core_sprite_name(PocketAppleCore *handle, uint32_t index); +// out must hold 4 int32: handle, frames, cols, step. +int32_t pocket_apple_core_sprite_info(PocketAppleCore *handle, uint32_t index, int32_t *out); + +void pocket_apple_core_svc_send(PocketAppleCore *handle, const uint8_t *text, size_t length); +// Newline-joined batch or NULL; valid until the next poll on this handle. +const char *pocket_apple_core_svc_poll(PocketAppleCore *handle); +int32_t pocket_apple_core_post_event(PocketAppleCore *handle, const char *line); +void pocket_apple_core_drain_effects(PocketAppleCore *handle, PocketAppleEffectCallback callback, + void *context); + +void pocket_apple_core_tick(PocketAppleCore *handle); +int32_t pocket_apple_core_render(PocketAppleCore *handle, PocketAppleFrame *out); +void pocket_apple_core_destroy(PocketAppleCore *handle); + +#ifdef __cplusplus +} +#endif + +#endif // POCKET_APPLE_H diff --git a/engine/apple/src/core_host.rs b/engine/apple/src/core_host.rs new file mode 100644 index 00000000..82bd80e1 --- /dev/null +++ b/engine/apple/src/core_host.rs @@ -0,0 +1,559 @@ +//! External-guest mode: the JS engine lives elsewhere (a NativeScript +//! runtime), so this side owns only `pocketjs_core::Ui`, the pak feed, the +//! raster pipeline, and the svc queues. The host mounts `globalThis.ui` in +//! its own engine and delegates each op to the `pocket_apple_core_*` C ABI. +//! Same single-thread rules as the guest-owning mode. + +use std::collections::VecDeque; +use std::ffi::{c_char, CString}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::slice; + +use pocket_ui_surface::walk_pak; +use pocketjs_core::damage::{DamagePolicy, DamageTracker}; +use pocketjs_core::raster; +use pocketjs_core::Ui; + +use crate::{set_last_error, PocketAppleFrame, POCKET_APPLE_MAX_DAMAGE_REGIONS}; + +const OK: i32 = 0; +const ERR_BAD_ARGUMENT: i32 = -1; +const ERR_PANIC: i32 = -4; + +pub struct SpriteReg { + pub name: CString, + pub handle: i32, + pub frames: u16, + pub cols: u16, + pub step: u16, +} + +pub struct PocketAppleCore { + ui: Ui, + framebuffer: Vec, + tracker: DamageTracker, + density: u32, + logical_width: u32, + logical_height: u32, + textures: Vec<(CString, i32)>, + sprites: Vec, + svc_in: VecDeque, + svc_out: VecDeque, + svc_poll_batch: CString, +} + +fn with_core( + handle: *mut PocketAppleCore, + default: R, + f: impl FnOnce(&mut PocketAppleCore) -> R, +) -> R { + if handle.is_null() { + set_last_error("null core handle"); + return default; + } + let state = unsafe { &mut *handle }; + match catch_unwind(AssertUnwindSafe(|| f(state))) { + Ok(value) => value, + Err(_) => { + set_last_error("panic inside pocket-apple core"); + default + } + } +} + +fn str_arg<'a>(bytes: *const u8, length: usize) -> Option<&'a str> { + if bytes.is_null() { + return Some(""); + } + std::str::from_utf8(unsafe { slice::from_raw_parts(bytes, length) }).ok() +} + +fn rd_u16(b: &[u8], off: usize) -> Option { + Some(u16::from_le_bytes([*b.get(off)?, *b.get(off + 1)?])) +} + +fn decode_pix_header(blob: &[u8], pixels_off: usize) -> Option<(u32, u32, u32, &[u8])> { + let w = rd_u16(blob, 0)? as u32; + let h = rd_u16(blob, 2)? as u32; + let psm = *blob.get(4)? as u32; + let pixels = blob.get(pixels_off..)?; + Some((w, h, psm, pixels)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_create( + density: u32, + logical_width: u32, + logical_height: u32, +) -> *mut PocketAppleCore { + let result = catch_unwind(|| { + if density == 0 + || density > raster::MAX_RENDER_SCALE + || logical_width == 0 + || logical_height == 0 + { + set_last_error("invalid density or viewport"); + return std::ptr::null_mut(); + } + let mut ui = Ui::new_with_raster_density(density); + ui.set_viewport(logical_width as f32, logical_height as f32); + let pixel_len = + (logical_width * density) as usize * (logical_height * density) as usize * 4; + Box::into_raw(Box::new(PocketAppleCore { + ui, + framebuffer: vec![0; pixel_len], + tracker: DamageTracker::default(), + density, + logical_width, + logical_height, + textures: Vec::new(), + sprites: Vec::new(), + svc_in: VecDeque::new(), + svc_out: VecDeque::new(), + svc_poll_batch: CString::default(), + })) + }); + result.unwrap_or(std::ptr::null_mut()) +} + +/// Mirrors `UiSurface::feed_pak`: styles and font atlases feed the core, +/// images and sprite atlases upload as textures and land in the name tables +/// the host publishes as `ui.__textures` / `ui.__sprites`. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_load_pak( + handle: *mut PocketAppleCore, + bytes: *const u8, + length: usize, +) -> i32 { + with_core(handle, ERR_PANIC, |state| { + if bytes.is_null() || length == 0 { + return ERR_BAD_ARGUMENT; + } + let pak = unsafe { slice::from_raw_parts(bytes, length) }; + for entry in walk_pak(pak) { + if entry.key == "ui:styles" { + if !state.ui.load_styles(entry.blob) { + log::warn!("pocket-apple: bad styles.bin in pak"); + } + } else if entry.key.starts_with("ui:font.") { + if !state.ui.load_font_atlas(entry.blob) { + log::warn!("pocket-apple: bad font atlas {}", entry.key); + } + } else if let Some(name) = entry.key.strip_prefix("ui:img.") { + let Some((w, h, psm, pixels)) = decode_pix_header(entry.blob, 8) else { + continue; + }; + let texture = state.ui.upload_texture(pixels, w, h, psm); + if texture >= 0 { + if let Ok(name) = CString::new(name) { + state.textures.push((name, texture)); + } + } + } else if let Some(name) = entry.key.strip_prefix("ui:sprite.") { + let Some((w, h, psm, pixels)) = decode_pix_header(entry.blob, 16) else { + continue; + }; + let (Some(frames), Some(cols), Some(step)) = ( + rd_u16(entry.blob, 6), + rd_u16(entry.blob, 8), + rd_u16(entry.blob, 10), + ) else { + continue; + }; + let texture = state.ui.upload_texture(pixels, w, h, psm); + if texture >= 0 { + if let Ok(name) = CString::new(name) { + state.sprites.push(SpriteReg { + name, + handle: texture, + frames, + cols, + step, + }); + } + } + } + } + OK + }) +} + +// ---- ui.* ops ------------------------------------------------------------ + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_create_node(handle: *mut PocketAppleCore, node_type: u32) -> i32 { + with_core(handle, 0, |state| state.ui.create_node(node_type as u8)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_destroy_node(handle: *mut PocketAppleCore, id: i32) { + with_core(handle, (), |state| state.ui.destroy_node(id)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_insert_before( + handle: *mut PocketAppleCore, + parent: i32, + child: i32, + anchor: i32, +) { + with_core(handle, (), |state| state.ui.insert_before(parent, child, anchor)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_remove_child( + handle: *mut PocketAppleCore, + parent: i32, + child: i32, +) { + with_core(handle, (), |state| state.ui.remove_child(parent, child)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_style(handle: *mut PocketAppleCore, id: i32, style: i32) { + with_core(handle, (), |state| state.ui.set_style(id, style)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_prop( + handle: *mut PocketAppleCore, + id: i32, + prop: u32, + value: f64, +) { + with_core(handle, (), |state| state.ui.set_prop(id, prop as u8, value)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_text( + handle: *mut PocketAppleCore, + id: i32, + text: *const u8, + length: usize, +) { + with_core(handle, (), |state| { + if let Some(text) = str_arg(text, length) { + state.ui.set_text(id, text); + } + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_replace_text( + handle: *mut PocketAppleCore, + id: i32, + text: *const u8, + length: usize, +) { + with_core(handle, (), |state| { + if let Some(text) = str_arg(text, length) { + state.ui.replace_text(id, text); + } + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_measure_text( + handle: *mut PocketAppleCore, + text: *const u8, + length: usize, + font_slot: u32, +) -> f32 { + with_core(handle, 0.0, |state| { + str_arg(text, length) + .map(|text| state.ui.measure_text(text, font_slot as u8)) + .unwrap_or(0.0) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_upload_texture( + handle: *mut PocketAppleCore, + bytes: *const u8, + length: usize, + width: u32, + height: u32, + psm: u32, +) -> i32 { + with_core(handle, -1, |state| { + if bytes.is_null() || length == 0 { + return -1; + } + let data = unsafe { slice::from_raw_parts(bytes, length) }; + state.ui.upload_texture(data, width, height, psm) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_image(handle: *mut PocketAppleCore, id: i32, texture: i32) { + with_core(handle, (), |state| state.ui.set_image(id, texture)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_sprite( + handle: *mut PocketAppleCore, + id: i32, + atlas: i32, + frames: u32, + cols: u32, + step: u32, +) { + with_core(handle, (), |state| state.ui.set_sprite(id, atlas, frames, cols, step)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_animate( + handle: *mut PocketAppleCore, + id: i32, + prop: u32, + to: f64, + duration_ms: u32, + easing: u32, + delay_ms: u32, +) -> i32 { + with_core(handle, -1, |state| { + state + .ui + .animate(id, prop as u8, to, duration_ms, easing as u8, delay_ms) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_cancel_anim(handle: *mut PocketAppleCore, anim_id: i32) { + with_core(handle, (), |state| state.ui.cancel_anim(anim_id)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_focus(handle: *mut PocketAppleCore, id: i32) { + with_core(handle, (), |state| state.ui.set_focus(id)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_set_active(handle: *mut PocketAppleCore, id: i32, active: i32) { + with_core(handle, (), |state| state.ui.set_active(id, active != 0)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_hit_test_bounds( + handle: *mut PocketAppleCore, + x: f32, + y: f32, +) -> i32 { + with_core(handle, 0, |state| state.ui.hit_test_bounds(x, y)) +} + +// ---- texture / sprite tables (published as ui.__textures / __sprites) ---- + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_texture_count(handle: *mut PocketAppleCore) -> u32 { + with_core(handle, 0, |state| state.textures.len() as u32) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_texture_name( + handle: *mut PocketAppleCore, + index: u32, +) -> *const c_char { + with_core(handle, std::ptr::null(), |state| { + state + .textures + .get(index as usize) + .map(|(name, _)| name.as_ptr()) + .unwrap_or(std::ptr::null()) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_texture_handle( + handle: *mut PocketAppleCore, + index: u32, +) -> i32 { + with_core(handle, -1, |state| { + state + .textures + .get(index as usize) + .map(|(_, texture)| *texture) + .unwrap_or(-1) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_sprite_count(handle: *mut PocketAppleCore) -> u32 { + with_core(handle, 0, |state| state.sprites.len() as u32) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_sprite_name( + handle: *mut PocketAppleCore, + index: u32, +) -> *const c_char { + with_core(handle, std::ptr::null(), |state| { + state + .sprites + .get(index as usize) + .map(|sprite| sprite.name.as_ptr()) + .unwrap_or(std::ptr::null()) + }) +} + +/// Packs handle plus atlas geometry: [handle, frames, cols, step]. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_sprite_info( + handle: *mut PocketAppleCore, + index: u32, + out: *mut i32, +) -> i32 { + with_core(handle, ERR_BAD_ARGUMENT, |state| { + if out.is_null() { + return ERR_BAD_ARGUMENT; + } + let Some(sprite) = state.sprites.get(index as usize) else { + return ERR_BAD_ARGUMENT; + }; + let slots = unsafe { slice::from_raw_parts_mut(out, 4) }; + slots[0] = sprite.handle; + slots[1] = sprite.frames as i32; + slots[2] = sprite.cols as i32; + slots[3] = sprite.step as i32; + OK + }) +} + +// ---- svc channel ---------------------------------------------------------- + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_svc_send( + handle: *mut PocketAppleCore, + text: *const u8, + length: usize, +) { + with_core(handle, (), |state| { + if let Some(line) = str_arg(text, length) { + state.svc_out.push_back(line.to_string()); + } + }); +} + +/// Newline-joined batch of queued host lines, or NULL when empty. The +/// returned pointer stays valid until the next poll on the same handle. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_svc_poll(handle: *mut PocketAppleCore) -> *const c_char { + with_core(handle, std::ptr::null(), |state| { + if state.svc_in.is_empty() { + return std::ptr::null(); + } + let mut batch = String::new(); + for line in state.svc_in.drain(..) { + batch.push_str(&line); + batch.push('\n'); + } + state.svc_poll_batch = CString::new(batch).unwrap_or_default(); + state.svc_poll_batch.as_ptr() + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_post_event( + handle: *mut PocketAppleCore, + line: *const c_char, +) -> i32 { + with_core(handle, ERR_PANIC, |state| { + if line.is_null() { + return ERR_BAD_ARGUMENT; + } + match unsafe { std::ffi::CStr::from_ptr(line) }.to_str() { + Ok(text) => { + state.svc_in.push_back(text.to_string()); + OK + } + Err(_) => ERR_BAD_ARGUMENT, + } + }) +} + +/// Drains guest svcSend lines into `callback` (guest -> host effects). +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_drain_effects( + handle: *mut PocketAppleCore, + callback: Option, + context: *mut std::ffi::c_void, +) { + with_core(handle, (), |state| { + let Some(callback) = callback else { return }; + while let Some(line) = state.svc_out.pop_front() { + if let Ok(line) = CString::new(line) { + callback(line.as_ptr(), context); + } + } + }); +} + +// ---- frame ---------------------------------------------------------------- + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_tick(handle: *mut PocketAppleCore) { + with_core(handle, (), |state| state.ui.tick()); +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_render( + handle: *mut PocketAppleCore, + out: *mut PocketAppleFrame, +) -> i32 { + with_core(handle, ERR_PANIC, |state| { + if out.is_null() { + return ERR_BAD_ARGUMENT; + } + let words = state.ui.draw().words.clone(); + let plan = match raster::render_scaled_argb_incremental( + &state.ui, + &words, + &mut state.framebuffer, + state.density, + &mut state.tracker, + DamagePolicy::default(), + ) { + Ok(plan) => plan, + Err(_) => { + raster::render_scaled_argb(&state.ui, &words, &mut state.framebuffer, state.density); + state.tracker.invalidate(); + pocketjs_core::damage::DamagePlan::full(pocketjs_core::damage::DamageRect::new( + 0, + 0, + state.logical_width as i32, + state.logical_height as i32, + )) + } + }; + + let width_px = state.logical_width * state.density; + let frame = unsafe { &mut *out }; + frame.pixels = state.framebuffer.as_ptr(); + frame.width_px = width_px; + frame.height_px = state.logical_height * state.density; + frame.stride_bytes = width_px * 4; + frame.full_redraw = i32::from(plan.is_full_redraw()); + frame.region_count = plan.region_count().min(POCKET_APPLE_MAX_DAMAGE_REGIONS) as u32; + frame.regions = [[0; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS]; + for (slot, rect) in frame.regions.iter_mut().zip(plan.regions()) { + let scale = state.density as i32; + *slot = [ + rect.x0.max(0) * scale, + rect.y0.max(0) * scale, + (rect.x1 - rect.x0).max(0) * scale, + (rect.y1 - rect.y0).max(0) * scale, + ]; + } + OK + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_core_destroy(handle: *mut PocketAppleCore) { + if handle.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + drop(Box::from_raw(handle)); + })); +} diff --git a/engine/apple/src/lib.rs b/engine/apple/src/lib.rs new file mode 100644 index 00000000..7841c215 --- /dev/null +++ b/engine/apple/src/lib.rs @@ -0,0 +1,402 @@ +//! pocket-apple — the PocketJS Apple host core behind a C ABI. +//! +//! Composition mirrors `hosts/pocketbook`: one `pocket_mod::Guest` (QuickJS +//! realm), one `pocket_ui_surface::UiSurface` (`globalThis.ui` + pak feeding), +//! and `pocketjs_core::raster` driven incrementally through a `DamageTracker`. +//! The framebuffer is ARGB32 words — BGRA byte order in memory on +//! little-endian, i.e. `kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst` +//! for CoreGraphics without any swizzling. +//! +//! Threading: everything here is single-threaded by construction (`UiSurface` +//! is `Rc>`). Create, drive, and destroy a handle from one thread — +//! in practice the main thread, alongside CADisplayLink. +//! +//! Call order per handle: `create` → `load_pak`* → `eval_bundle` → per tick +//! `frame` then `render` → `destroy`. `load_pak` and `set_identity` are +//! rejected after `eval_bundle` because the surface publishes both to the +//! guest at mount time. + +use std::cell::RefCell; +use std::ffi::{c_char, CString}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::slice; + +pub mod core_host; + +use pocket_mod::Guest; +use pocket_ui_surface::UiSurface; +use pocketjs_core::damage::{DamagePolicy, DamageTracker, DEFAULT_DAMAGE_REGIONS}; +use pocketjs_core::raster; +use pocketjs_core::spec; + +pub const POCKET_APPLE_ABI_VERSION: u32 = 1; +pub const POCKET_APPLE_MAX_DAMAGE_REGIONS: usize = DEFAULT_DAMAGE_REGIONS; + +const OK: i32 = 0; +const ERR_BAD_ARGUMENT: i32 = -1; +const ERR_BAD_STATE: i32 = -2; +const ERR_GUEST: i32 = -3; +const ERR_PANIC: i32 = -4; + +thread_local! { + static LAST_ERROR: RefCell = RefCell::new(CString::new("").unwrap()); +} + +pub(crate) fn set_last_error(message: impl AsRef) { + let sanitized = message.as_ref().replace('\0', " "); + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = CString::new(sanitized).unwrap_or_default(); + }); +} + +pub type PocketAppleEffectCallback = + extern "C" fn(line: *const c_char, context: *mut std::ffi::c_void); + +pub struct PocketApple { + guest: Guest, + surface: UiSurface, + framebuffer: Vec, + tracker: DamageTracker, + density: u32, + logical_width: u32, + logical_height: u32, + mounted: bool, + effect_callback: Option<(PocketAppleEffectCallback, *mut std::ffi::c_void)>, +} + +/// One rendered frame. `pixels` stays valid until the next `render`, a +/// `destroy`, or any other call that mutates the handle. +#[repr(C)] +pub struct PocketAppleFrame { + pub pixels: *const u8, + pub width_px: u32, + pub height_px: u32, + pub stride_bytes: u32, + /// Repaint rects in pixel coordinates as x, y, w, h. `region_count == 0` + /// means nothing changed this frame; the previous contents are current. + pub regions: [[i32; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS], + pub region_count: u32, + pub full_redraw: i32, +} + +fn with_handle( + handle: *mut PocketApple, + default: R, + f: impl FnOnce(&mut PocketApple) -> R, +) -> R { + if handle.is_null() { + set_last_error("null handle"); + return default; + } + let state = unsafe { &mut *handle }; + match catch_unwind(AssertUnwindSafe(|| f(state))) { + Ok(value) => value, + Err(_) => { + set_last_error("panic inside pocket-apple"); + default + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_abi_version() -> u32 { + POCKET_APPLE_ABI_VERSION +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_last_error() -> *const c_char { + LAST_ERROR.with(|slot| slot.borrow().as_ptr()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_create( + density: u32, + logical_width: u32, + logical_height: u32, +) -> *mut PocketApple { + let result = catch_unwind(|| { + if density == 0 + || density > raster::MAX_RENDER_SCALE + || logical_width == 0 + || logical_height == 0 + { + set_last_error("invalid density or viewport"); + return std::ptr::null_mut(); + } + let guest = match Guest::new() { + Ok(guest) => guest, + Err(error) => { + set_last_error(format!("guest create failed: {error}")); + return std::ptr::null_mut(); + } + }; + let surface = UiSurface::new_with_density( + (logical_width as f32, logical_height as f32), + density, + ); + let pixel_len = + (logical_width * density) as usize * (logical_height * density) as usize * 4; + Box::into_raw(Box::new(PocketApple { + guest, + surface, + framebuffer: vec![0; pixel_len], + tracker: DamageTracker::default(), + density, + logical_width, + logical_height, + mounted: false, + effect_callback: None, + })) + }); + result.unwrap_or(std::ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_set_identity( + handle: *mut PocketApple, + host_id: *const c_char, + host_abi: u32, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if state.mounted { + set_last_error("identity must be set before eval_bundle"); + return ERR_BAD_STATE; + } + if host_id.is_null() { + return ERR_BAD_ARGUMENT; + } + let id = unsafe { std::ffi::CStr::from_ptr(host_id) }; + match id.to_str() { + Ok(id) => { + state.surface.set_identity(id, host_abi); + OK + } + Err(_) => ERR_BAD_ARGUMENT, + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_load_pak( + handle: *mut PocketApple, + bytes: *const u8, + length: usize, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if state.mounted { + set_last_error("pak must be fed before eval_bundle"); + return ERR_BAD_STATE; + } + if bytes.is_null() || length == 0 { + return ERR_BAD_ARGUMENT; + } + let pak = unsafe { slice::from_raw_parts(bytes, length) }; + state.surface.feed_pak(pak); + OK + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_eval_bundle( + handle: *mut PocketApple, + source: *const u8, + length: usize, + label: *const c_char, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if source.is_null() || length == 0 { + return ERR_BAD_ARGUMENT; + } + let bytes = unsafe { slice::from_raw_parts(source, length) }; + let bundle = match std::str::from_utf8(bytes) { + Ok(text) => text, + Err(_) => { + set_last_error("bundle is not UTF-8"); + return ERR_BAD_ARGUMENT; + } + }; + let label = if label.is_null() { + "app" + } else { + unsafe { std::ffi::CStr::from_ptr(label) } + .to_str() + .unwrap_or("app") + }; + if !state.mounted { + if let Err(error) = state.surface.mount(&state.guest) { + set_last_error(format!("ui mount failed: {error}")); + return ERR_GUEST; + } + state.mounted = true; + } + if let Err(error) = state.guest.eval(label, bundle) { + set_last_error(format!("bundle eval failed: {error}")); + return ERR_GUEST; + } + if !state.guest.has_frame() { + set_last_error("bundle installed no frame() — is this a PocketJS app?"); + return ERR_GUEST; + } + OK + }) +} + +/// `touches`: up to 8 packed words, `(id & 0xff) << 18 | (y & 0x1ff) << 9 | +/// (x & 0x1ff)` in logical coordinates. Pass `analog = 0x8080` when centered. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_frame( + handle: *mut PocketApple, + buttons: u32, + analog: u32, + touches: *const u32, + touch_count: usize, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if !state.mounted { + set_last_error("frame before eval_bundle"); + return ERR_BAD_STATE; + } + let touch_words: &[u32] = if touches.is_null() || touch_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(touches, touch_count.min(8)) } + }; + let analog = if analog == 0 { spec::ANALOG_CENTER } else { analog }; + if let Err(error) = state.guest.frame_with_touches(buttons, analog, touch_words) { + set_last_error(format!("guest frame failed: {error}")); + return ERR_GUEST; + } + state.surface.tick(); + if let Some((callback, context)) = state.effect_callback { + for line in state.surface.svc_drain() { + if let Ok(line) = CString::new(line) { + callback(line.as_ptr(), context); + } + } + } + OK + }) +} + +/// Register the guest -> host effect sink. Lines are whatever the guest's +/// effect driver `svcSend`s (JSON by convention), delivered synchronously +/// during `pocket_apple_frame` on the calling thread. `context` must stay +/// valid until the callback is replaced or the handle destroyed. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_set_effect_callback( + handle: *mut PocketApple, + callback: Option, + context: *mut std::ffi::c_void, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + state.effect_callback = callback.map(|cb| (cb, context)); + OK + }) +} + +/// Queue one line for the guest's next `svcPoll` — host -> guest facts land +/// at a frame boundary, per the "no mid-tick callbacks" law. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_post_event( + handle: *mut PocketApple, + line: *const c_char, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if line.is_null() { + return ERR_BAD_ARGUMENT; + } + match unsafe { std::ffi::CStr::from_ptr(line) }.to_str() { + Ok(text) => { + state.surface.svc_push(text); + OK + } + Err(_) => ERR_BAD_ARGUMENT, + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_render( + handle: *mut PocketApple, + out: *mut PocketAppleFrame, +) -> i32 { + with_handle(handle, ERR_PANIC, |state| { + if out.is_null() { + return ERR_BAD_ARGUMENT; + } + if !state.mounted { + set_last_error("render before eval_bundle"); + return ERR_BAD_STATE; + } + let density = state.density; + let framebuffer = &mut state.framebuffer; + let tracker = &mut state.tracker; + let plan = state.surface.with_ui(|ui| { + let words = ui.draw().words.clone(); + match raster::render_scaled_argb_incremental( + ui, + &words, + framebuffer, + density, + tracker, + DamagePolicy::default(), + ) { + Ok(plan) => plan, + Err(_) => { + raster::render_scaled_argb(ui, &words, framebuffer, density); + tracker.invalidate(); + pocketjs_core::damage::DamagePlan::full( + pocketjs_core::damage::DamageRect::new( + 0, + 0, + state.logical_width as i32, + state.logical_height as i32, + ), + ) + } + } + }); + + let width_px = state.logical_width * density; + let height_px = state.logical_height * density; + let frame = unsafe { &mut *out }; + frame.pixels = state.framebuffer.as_ptr(); + frame.width_px = width_px; + frame.height_px = height_px; + frame.stride_bytes = width_px * 4; + frame.full_redraw = i32::from(plan.is_full_redraw()); + frame.region_count = plan.region_count().min(POCKET_APPLE_MAX_DAMAGE_REGIONS) as u32; + frame.regions = [[0; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS]; + for (slot, rect) in frame.regions.iter_mut().zip(plan.regions()) { + let scale = density as i32; + let x = rect.x0.max(0) * scale; + let y = rect.y0.max(0) * scale; + let w = (rect.x1 - rect.x0).max(0) * scale; + let h = (rect.y1 - rect.y0).max(0) * scale; + *slot = [x, y, w, h]; + } + OK + }) +} + +/// Hit test in logical coordinates. Returns the focusable node id or 0. +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_hit_test_bounds( + handle: *mut PocketApple, + x: f32, + y: f32, +) -> i32 { + with_handle(handle, 0, |state| { + state.surface.with_ui(|ui| ui.hit_test_bounds(x, y)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pocket_apple_destroy(handle: *mut PocketApple) { + if handle.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + drop(Box::from_raw(handle)); + })); +} diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs index ce5c21ae..3c75dda3 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -348,6 +348,13 @@ impl UiSurface { ui.borrow_mut().ui.hit_test(x as f32, y as f32) }); + // Touch-path hit authority (spec op 42): the gesture layer + // prefers the bounds hit over the ink-claiming hitTest above. + let ui = self.inner.clone(); + op!("hitTestBounds", move |x: f64, y: f64| { + ui.borrow_mut().ui.hit_test_bounds(x as f32, y as f32) + }); + let ui = self.inner.clone(); op!("setCursor", move |tex: i32, hot_x: f64, hot_y: f64, w: f64, h: f64| { ui.borrow_mut().ui.set_cursor(tex, hot_x as f32, hot_y as f32, w as f32, h as f32) From 2e343a53cdaa74977754740b03f7a5a1cbf43d39 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 9 Aug 2026 15:09:29 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(ios):=20pocket=20ios=20=E2=80=94=20tra?= =?UTF-8?q?nsitional=20ios-dev=20target,=20NativeScript=20shell,=20play=20?= =?UTF-8?q?on=20the=20simulator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI follow-up feat/apple-host's description names: a dev-profile target id and a first-class run flow for the Apple host. `pocket play ios nsengine` builds a guest from a resolved plan, stages it into a committed NativeScript shell, and launches it on an arm64 iOS simulator. tools/ios-profile.ts registers the transitional ios-dev profile (hostAbi 7, platform ios, form embedded, fixed 480x272, raster density 1..4 as a registry input, capabilities input.touch + text.glyphs.baked) following the iphone2g-profile pattern: out of POCKET_TARGETS until device acceptance. apps/nsengine gains a committed manifest — the root-template fallback demands input.buttons, which this surface cannot advertise. PocketSurfaceView now applies pocket_apple_set_identity("ios-dev", 7) at init: plan-built bundles bake __POCKET_TARGET__/__POCKET_HOST_ABI__ and assertNativeHostContract refuses hosts that publish nothing; the C ABI existed but nothing called it. External-guest hosts (@nativescript/pocketjs) mount the same pair on their ui namespace, and a source-text test guards the agreement. tools/ios.ts owns the flow — doctor/setup (symbian shape; Rust targets are the only mutation, and only --rebuild-native needs them since the published plugin ships a prebuilt PocketApple.xcframework), devices, native, build, stage, and play (admissible-simulator pick, simctl boot, ns run ios --device --no-hmr --justlaunch). --plugin-path/--runtime-tgz point the shell at local builds for pre-publish validation and restore the committed template afterwards. pocket play ios delegates here; bin.mjs gains the ios passthrough. hosts/apple/ns-shell is the committed shell: plan-driven (viewport, density, and guest mode read from the staged plan + current.json), answers the ns.ping service channel, and runs in place so repeat runs rebuild incrementally (~47 s cold, ~16 s warm on Apple Silicon). Its tsconfig pins @nativescript/core paths so the plugin's typings resolve from a file: symlink. Validated on the iOS 26.5 simulator in both guest modes: unprompted ns.ping round trip renders pong 1, and --external-guest reads UIDevice.currentDevice.systemVersion from guest code. Unit stage green including tests/ios-profile.test.ts, the nsengine admission-matrix row, and the CLI dispatch cases. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 + README.md | 1 + apps/nsengine/pocket.json | 24 + docs/APPLE.md | 117 +++++ docs/STRUCTURE.md | 1 + engine/apple/apple/PocketSurfaceView.m | 10 + .../ns-shell/App_Resources/iOS/Info.plist | 47 ++ .../App_Resources/iOS/LaunchScreen.storyboard | 23 + .../ns-shell/App_Resources/iOS/build.xcconfig | 6 + hosts/apple/ns-shell/nativescript.config.ts | 10 + hosts/apple/ns-shell/package.json | 21 + hosts/apple/ns-shell/references.d.ts | 1 + hosts/apple/ns-shell/src/app.ts | 58 ++ hosts/apple/ns-shell/tsconfig.json | 24 + hosts/apple/ns-shell/webpack.config.js | 6 + package.json | 1 + tests/cli.test.ts | 4 +- tests/ios-profile.test.ts | 124 +++++ tests/platform-contracts.test.ts | 1 + tools/cli/README.md | 9 +- tools/cli/bin.mjs | 6 + tools/ios-profile.ts | 80 +++ tools/ios.ts | 496 ++++++++++++++++++ tools/play.ts | 13 +- tools/test.ts | 1 + 25 files changed, 1087 insertions(+), 4 deletions(-) create mode 100644 apps/nsengine/pocket.json create mode 100644 docs/APPLE.md create mode 100644 hosts/apple/ns-shell/App_Resources/iOS/Info.plist create mode 100644 hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard create mode 100644 hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig create mode 100644 hosts/apple/ns-shell/nativescript.config.ts create mode 100644 hosts/apple/ns-shell/package.json create mode 100644 hosts/apple/ns-shell/references.d.ts create mode 100644 hosts/apple/ns-shell/src/app.ts create mode 100644 hosts/apple/ns-shell/tsconfig.json create mode 100644 hosts/apple/ns-shell/webpack.config.js create mode 100644 tests/ios-profile.test.ts create mode 100644 tools/ios-profile.ts create mode 100644 tools/ios.ts diff --git a/.gitignore b/.gitignore index d350d13e..339e05a8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ tests/goldens/psp/*.actual.png # irecovery readline history, written when the iPhone 2G runbook drives iBoot .irecovery engine/apple/dist/ +# NativeScript shell working state (hosts/apple/ns-shell): `pocket ios` stages +# guest assets, and the ns CLI owns platforms/ and hooks/ (node_modules/ is +# ignored repo-wide above). +hosts/apple/ns-shell/platforms/ +hosts/apple/ns-shell/hooks/ +hosts/apple/ns-shell/src/assets/pocket/ +hosts/apple/ns-shell/package-lock.json diff --git a/README.md b/README.md index ce872934..fe652254 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ a hardware protocol receipt, and a manual screen check prove different things. | **macOS widget** | Registered Guest profile | Dynamic native window, pointer, keyboard/IME, clipboard, and runtime glyph paths | | **Browser, desktop, headless Bun** | Guest development and verification hosts | WASM/native rendering, interactive development, deterministic simulation, and image goldens | | **Nokia E7 / Symbian** | Hardware-tested development Guest host | SIS install, launch, visible rendering, keys, and rotation on the reference device; not a production target profile | +| **iOS (NativeScript host)** | Development Guest host | Simulator boot, rendering, touch, and the guest↔host service round trip in both guest modes (sidecar realm and the NativeScript runtime as guest engine); not a production target profile | | **GBA, Game Boy, NES** | Pocket Vapor AOT | Per-interaction emulator parity against the Vue oracle, including logical characters and styles | | **ESP32 MeowBit** | Pocket Vapor AOT | Optional physical-board UART replay verifies the logical grid and exercises LCD commits; it neither reads panel pixels nor actuates GPIO buttons | | **Playdate** | Pocket Vapor AOT | Native-boundary tests and Simulator/device package smoke; physical display and input acceptance remains manual | diff --git a/apps/nsengine/pocket.json b/apps/nsengine/pocket.json new file mode 100644 index 00000000..31263750 --- /dev/null +++ b/apps/nsengine/pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.nsengine", + "name": "pocketjs-nsengine", + "title": "PocketJS NS Engine", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.touch", "text.glyphs.baked"] + } + }, + "app": { + "entry": "apps/nsengine/main.tsx", + "output": "nsengine-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [480, 272], + "presentation": "integer-fit" + } + } + } +} diff --git a/docs/APPLE.md b/docs/APPLE.md new file mode 100644 index 00000000..a93533a0 --- /dev/null +++ b/docs/APPLE.md @@ -0,0 +1,117 @@ +# Modern iOS via NativeScript + +`pocket ios` runs PocketJS guests on the iOS simulator inside a NativeScript +shell app. The native core is `engine/apple` ([PR #255](https://github.com/pocket-stack/pocketjs/pull/255)): +the `pocket-apple` crate behind a C ABI, and `PocketSurfaceView`, a UIKit view +driving one guest realm and one software-rastered surface per instance. The +NativeScript side is the published +[`@nativescript/pocketjs`](https://github.com/NativeScript/pocketjs) plugin, +whose npm package carries a prebuilt `PocketApple.xcframework` — the default +flow needs **no Rust toolchain**. + +## Current status + +| Claim | Evidence | +| --- | --- | +| Guest boots, renders, animates at 60 fps | iOS 26.5 simulator, `apps/nsengine` at density 4 | +| Touch reaches the guest with aspect-fit inverse mapping | `Ping host` pressable increments on tap | +| Guest ↔ host service round trip | `ns.ping` → shell reply renders in the guest stat tile, unprompted on mount | +| External-guest mode (the app's JS runtime is the guest engine) | Guest code reads `UIDevice.currentDevice.systemVersion` | +| Platform-contract identity enforced end to end | Plan-built bundles bake `ios-dev`/7 and mount only on hosts publishing the same pair | +| Real-device run | **Not yet exercised** — simulator only | + +## One-time setup + +```sh +pocket ios doctor # Xcode, arm64 iOS 16+ simulator runtime, node, ns CLI +pocket ios setup # adds the two Rust iOS targets (only needed for --rebuild-native) +``` + +**An Apple Silicon Mac is required.** `PocketApple.xcframework` and the +`@nativescript/ios-quickjs` runtime ship `ios-arm64`/`ios-arm64-simulator` +slices only, so the shell excludes `x86_64` for simulator builds +(`hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig`). CocoaPods is not +required: neither the shell nor the plugin carries a Podfile. + +## Build and run a demo + +```sh +pocket play ios nsengine # build, stage, launch on the simulator +pocket ios play nsengine --external-guest # the NativeScript runtime as the guest engine +pocket ios build nsengine --density=4 # guest artifacts only (dist/ios/nsengine/) +pocket ios devices # admissible simulators +``` + +The flow: resolve the app's manifest against the `ios-dev` profile → run +`tools/build.ts` from the plan → stage `.pocketjs`, `.pak`, +`.plan.json` and `current.json` into the shell's `src/assets/pocket/` +→ `npm install` (first run) → boot an arm64 simulator → +`ns run ios --device --no-hmr --justlaunch`. The shell runs in place, +so only the first run pays the full cost: **~47 s cold** (npm install + full +Xcode build, Apple Silicon, warm simulator) and **~16 s on repeat runs** +(`--no-build`; ~25 s with a guest rebuild). `--attach` keeps `ns run` +attached for console output. + +**Density is load-bearing:** glyph atlases bake at build time, and the shell +sets the surface's raster scale from the staged plan — a guest built at one +density and rastered at another renders soft text. `--density=1..4`, default 3. + +## The ios-dev profile + +`tools/ios-profile.ts` follows the transitional pattern +(`tools/iphone2g-profile.ts`): a scoped registry that stays out of +`POCKET_TARGETS` until the host has device-level acceptance. Profile: +platform `ios`, form `embedded` (a fixed 480×272 logical viewport letterboxed +by the view), presentations `native` + `integer-fit`, capabilities +`input.touch` + `text.glyphs.baked` only — `PocketSurfaceView` reports no +buttons and a centered analog. + +**The identity contract:** bundles built from a resolved plan bake +`__POCKET_TARGET__`/`__POCKET_HOST_ABI__` and refuse to mount unless the host +publishes the same pair (`framework/src/host.ts`). Three places publish +`"ios-dev"` / `7` and must stay in agreement: this profile, +`PocketSurfaceView.m` (`pocket_apple_set_identity` at init), and the plugin's +external-guest `ui` mount. `tests/ios-profile.test.ts` guards the first two. + +## The two guest modes + +- **Sidecar (default, `PocketView`)** — the guest runs in the QuickJS realm + embedded in the xcframework. The host app's runtime never sees guest code; + the surface composes into the app's layout like any UIView. +- **External guest (`--external-guest`, `PocketHostView`)** — the shell's own + JS runtime evaluates the bundle; `globalThis.ui` delegates each op over the + NativeScript metadata bindings to the same native core. Guest code reaches + the whole iOS platform with no per-API glue. + +Both modes run the identical bundle; `current.json` selects the view class. + +## The shell (hosts/apple/ns-shell) + +Authored and committed: `package.json`, `nativescript.config.ts`, +`webpack.config.js`, `tsconfig.json`, `references.d.ts`, `src/app.ts`, +`App_Resources/iOS/{build.xcconfig,Info.plist,LaunchScreen.storyboard}`. +Generated and gitignored: `node_modules/`, `platforms/`, `hooks/`, +`src/assets/pocket/`, `package-lock.json`. The shell is plan-driven — it reads +the staged plan for viewport and density and the staged mode for the view +class, so nothing is templated at stage time. Its `tsconfig.json` pins +`@nativescript/core` paths so the plugin's typings resolve when the plugin is +a `file:` symlink. `--shell-dir=` stages into another NativeScript app +instead. + +## Pre-publish overrides + +`--plugin-path=` and `--runtime-tgz=` point the shell at a +local `@nativescript/pocketjs` checkout and a local +`@nativescript/ios-quickjs` tarball. The committed `package.json` names the +published packages; overrides are applied for the `npm install` and the +template is restored afterwards. With `--plugin-path`, a present +`engine/apple/dist/PocketApple.xcframework` is copied into the local plugin +(`--rebuild-native` rebuilds it first). + +## Sources + +- `engine/apple/` — pocket-apple crate, `PocketSurfaceView`, `build-xcframework.sh` +- `tools/ios.ts`, `tools/ios-profile.ts` — the CLI flow and the profile +- `hosts/apple/ns-shell/` — the committed shell +- `apps/nsengine/` — the reference guest (service channel + platform probe) +- [`@nativescript/pocketjs`](https://github.com/NativeScript/pocketjs) — the plugin repo diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 9ff57a36..e2924e80 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -23,6 +23,7 @@ pocketjs/ │ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build │ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) │ ├─ symbian/ Nokia E7 Qt/QuickJS runtime + visible toolchain probe +│ ├─ apple/ NativeScript iOS shell over engine/apple + @nativescript/pocketjs │ ├─ web/ browser dev host (wasm core) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) ├─ framework/ Guest: @pocketjs/framework diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m index b0225613..e6b49c36 100644 --- a/engine/apple/apple/PocketSurfaceView.m +++ b/engine/apple/apple/PocketSurfaceView.m @@ -8,6 +8,13 @@ // word's id bits for the touch's lifetime. #define POCKET_MAX_TOUCHES 8 +// Platform-contract identity published to plan-built guests +// (framework/src/host.ts assertNativeHostContract). Must match the ios-dev +// profile in tools/ios-profile.ts; external-guest hosts publish the same pair +// on the ui namespace they mount. +static const char *const kPocketSurfaceHostId = "ios-dev"; +static const uint32_t kPocketSurfaceHostAbi = 7; + typedef struct { __weak UITouch *touch; CGPoint point; @@ -65,6 +72,9 @@ - (instancetype)initWithFrame:(CGRect)frame _handle = pocket_apple_create(density, logicalWidth, logicalHeight); if (_handle == NULL) { [self captureError]; + } else if (pocket_apple_set_identity(_handle, kPocketSurfaceHostId, + kPocketSurfaceHostAbi) != 0) { + [self captureError]; } _colorSpace = CGColorSpaceCreateDeviceRGB(); self.multipleTouchEnabled = YES; diff --git a/hosts/apple/ns-shell/App_Resources/iOS/Info.plist b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist new file mode 100644 index 00000000..90de7ad4 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiresFullScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard b/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard new file mode 100644 index 00000000..fb2dffd3 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig b/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig new file mode 100644 index 00000000..d808a553 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig @@ -0,0 +1,6 @@ +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; +IPHONEOS_DEPLOYMENT_TARGET = 16.0; +// PocketApple.xcframework ships ios-arm64 and ios-arm64-simulator slices only +// (engine/apple/build-xcframework.sh), and @nativescript/ios-quickjs likewise +// has no x86_64 simulator slice, so simulator builds are arm64-only. +EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 armv6 armv7 armv7s armv8 x86_64 diff --git a/hosts/apple/ns-shell/nativescript.config.ts b/hosts/apple/ns-shell/nativescript.config.ts new file mode 100644 index 00000000..da97426f --- /dev/null +++ b/hosts/apple/ns-shell/nativescript.config.ts @@ -0,0 +1,10 @@ +import { NativeScriptConfig } from '@nativescript/core'; + +export default { + id: 'dev.pocketjs.shell', + appPath: 'src', + appResourcesPath: 'App_Resources', + ios: { + runtimePackageName: '@nativescript/ios-quickjs', + }, +} as NativeScriptConfig; diff --git a/hosts/apple/ns-shell/package.json b/hosts/apple/ns-shell/package.json new file mode 100644 index 00000000..48e41482 --- /dev/null +++ b/hosts/apple/ns-shell/package.json @@ -0,0 +1,21 @@ +{ + "name": "pocketjs-ns-shell", + "version": "0.0.0", + "private": true, + "main": "src/app.ts", + "dependencies": { + "@nativescript/core": "9.1.0-alpha.11", + "@nativescript/pocketjs": "^0.1.0" + }, + "devDependencies": { + "@nativescript/ios-quickjs": "9.0.0-preview.10", + "@nativescript/types": "~9.0.0", + "@nativescript/webpack": "~5.0.32", + "nativescript": "9.1.0-alpha.17", + "typescript": "~5.9.0" + }, + "overrides": { + "@nativescript/core": "9.1.0-alpha.11" + }, + "scripts": {} +} diff --git a/hosts/apple/ns-shell/references.d.ts b/hosts/apple/ns-shell/references.d.ts new file mode 100644 index 00000000..d7433268 --- /dev/null +++ b/hosts/apple/ns-shell/references.d.ts @@ -0,0 +1 @@ +/// diff --git a/hosts/apple/ns-shell/src/app.ts b/hosts/apple/ns-shell/src/app.ts new file mode 100644 index 00000000..5b7630b9 --- /dev/null +++ b/hosts/apple/ns-shell/src/app.ts @@ -0,0 +1,58 @@ +// The `pocket ios play` shell: hosts one PocketJS surface and answers the +// guest's service channel. tools/ios.ts stages the guest bundle, its resolved +// build plan, and current.json (which app + which guest mode) into +// src/assets/pocket before launching. +import { Application, File, Frame, GridLayout, Page, Screen, knownFolders } from '@nativescript/core'; +import { PocketHostView, PocketView } from '@nativescript/pocketjs'; + +type BridgeCommand = { t?: string; id?: number; kind?: string; payload?: { n?: number } }; +type StagedApp = { app: string; externalGuest?: boolean }; +type StagedPlan = { viewport: { logical: [number, number]; rasterDensity: number } }; + +function readJson(relativePath: string): T { + const path = knownFolders.currentApp().path + relativePath; + return JSON.parse(File.fromPath(path).readTextSync()) as T; +} + +function createMainPage(): Page { + const staged = readJson('/assets/pocket/current.json'); + const plan = readJson(`/assets/pocket/${staged.app}.plan.json`); + const [logicalWidth, logicalHeight] = plan.viewport.logical; + + const page = new Page(); + page.actionBarHidden = true; + page.backgroundColor = '#020617'; + + const root = new GridLayout(); + const pocket = staged.externalGuest ? new PocketHostView() : new PocketView(); + pocket.viewportWidth = logicalWidth; + pocket.viewportHeight = logicalHeight; + // Glyph atlases bake at build density; the surface must raster at the same + // scale or text renders soft. Never leave this to the screen-scale default. + pocket.density = plan.viewport.rasterDensity; + const width = Screen.mainScreen.widthDIPs; + pocket.width = width as never; + pocket.height = Math.round((width * logicalHeight) / logicalWidth) as never; + pocket.on('loaded', () => console.log('[pocket-shell] guest loaded')); + pocket.on('error', (event) => + console.error('[pocket-shell] error:', (event as { message?: string }).message), + ); + pocket.on('effect', (event) => { + const cmd = (event as { data?: BridgeCommand }).data; + if (cmd?.t === 'cmd' && cmd.kind === 'ns.ping') { + pocket.post({ t: 'result', id: cmd.id, result: `pong ${cmd.payload?.n ?? 0}` }); + } + }); + pocket.src = `~/assets/pocket/${staged.app}`; + root.addChild(pocket); + page.content = root; + return page; +} + +Application.run({ + create: () => { + const frame = new Frame(); + frame.navigate({ create: createMainPage }); + return frame; + }, +}); diff --git a/hosts/apple/ns-shell/tsconfig.json b/hosts/apple/ns-shell/tsconfig.json new file mode 100644 index 00000000..cf3c88ba --- /dev/null +++ b/hosts/apple/ns-shell/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "paths": { + "~/*": ["./src/*"], + "@nativescript/core": ["./node_modules/@nativescript/core"], + "@nativescript/core/*": ["./node_modules/@nativescript/core/*"] + }, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "noEmitHelpers": true, + "importHelpers": true, + "baseUrl": "." + }, + "include": ["./src", "./references.d.ts"], + "exclude": ["node_modules", "platforms"] +} diff --git a/hosts/apple/ns-shell/webpack.config.js b/hosts/apple/ns-shell/webpack.config.js new file mode 100644 index 00000000..f82b019c --- /dev/null +++ b/hosts/apple/ns-shell/webpack.config.js @@ -0,0 +1,6 @@ +const webpack = require('@nativescript/webpack'); + +module.exports = (env) => { + webpack.init(env); + return webpack.resolveConfig(); +}; diff --git a/package.json b/package.json index 53900ec8..1d443b30 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "psp:switch": "bun psplink", "vita": "bun tools/vita.ts", "symbian": "bun tools/symbian.ts", + "ios": "bun tools/ios.ts", "iphone2g": "bun tools/iphone2g.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2a9d2af9..24752cc7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -69,7 +69,7 @@ describe("published PocketJS CLI", () => { script: import.meta.url, args: Bun.argv.slice(2), }));\n`; - for (const name of ["pocket", "play", "symbian", "vita"]) { + for (const name of ["pocket", "play", "symbian", "vita", "ios"]) { writeFileSync(join(scripts, `${name}.ts`), recorder); } @@ -82,6 +82,8 @@ describe("published PocketJS CLI", () => { { cliArgs: ["symbian", "doctor", "--device"], script: "symbian.ts", args: ["doctor", "--device"] }, { cliArgs: ["symbian", "coda", "usb"], script: "symbian.ts", args: ["coda", "usb"] }, { cliArgs: ["symbian", "coda", "usb", "launch"], script: "symbian.ts", args: ["coda", "usb", "launch"] }, + { cliArgs: ["ios", "play", "nsengine"], script: "ios.ts", args: ["play", "nsengine"] }, + { cliArgs: ["play", "ios", "nsengine"], script: "play.ts", args: ["ios", "nsengine"] }, ]; for (const fixture of cases) { diff --git a/tests/ios-profile.test.ts b/tests/ios-profile.test.ts new file mode 100644 index 00000000..fd523fd2 --- /dev/null +++ b/tests/ios-profile.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { checkAppTypes } from "../framework/compiler/app-check.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { + IOS_DEV_CONTRACTS, + IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_HOST_ABI, + IOS_DEV_TARGET_ID, + IOS_DEV_VIEWPORT, + iosDevContracts, + resolveIOSDevBuildPlan, +} from "../tools/ios-profile.ts"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const MANIFEST_PATH = join(REPOSITORY, "apps/nsengine/pocket.json"); +const ENTRY_PATH = join(REPOSITORY, "apps/nsengine/main.tsx"); +const SURFACE_VIEW_PATH = join(REPOSITORY, "engine/apple/apple/PocketSurfaceView.m"); +const ROOT_TSCONFIG = join(REPOSITORY, "tsconfig.json"); +const JSX_DECLARATIONS = join(REPOSITORY, "framework/src/jsx.d.ts"); + +function demoManifest(): Record { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); +} + +function typeErrors(result: ReturnType): string { + return result.diagnostics + .filter((diagnostic) => diagnostic.category === "error") + .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) + .join("\n"); +} + +describe("private iOS build profile", () => { + test("stays private and describes the embedded PocketSurfaceView", () => { + expect(POCKET_TARGETS).not.toHaveProperty(IOS_DEV_TARGET_ID); + expect(IOS_DEV_CONTRACTS.targets[IOS_DEV_TARGET_ID]).toEqual({ + hostAbi: IOS_DEV_HOST_ABI, + platform: "ios", + form: "embedded", + display: { + physicalViewport: [ + IOS_DEV_VIEWPORT[0] * IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_VIEWPORT[1] * IOS_DEV_DEFAULT_DENSITY, + ], + logicalViewports: [[480, 272]], + presentations: ["native", "integer-fit"], + rasterDensity: IOS_DEV_DEFAULT_DENSITY, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }); + }); + + test("resolves the nsengine demo to an exact surface plan", () => { + const plan = resolveIOSDevBuildPlan(demoManifest()); + + expect(plan.target).toEqual({ + id: IOS_DEV_TARGET_ID, + hostAbi: IOS_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: [480, 272], + physical: [480 * IOS_DEV_DEFAULT_DENSITY, 272 * IOS_DEV_DEFAULT_DENSITY], + presentation: "integer-fit", + rasterDensity: IOS_DEV_DEFAULT_DENSITY, + }); + expect(plan.features).toEqual({ + "input.touch": true, + "text.glyphs.baked": true, + }); + expect(plan.app.entry).toBe("apps/nsengine/main.tsx"); + expect(plan.app.output).toBe("nsengine-main"); + expect(verifyPlanHash(plan)).toBe(true); + }); + + test("density selects the physical surface", () => { + expect( + iosDevContracts(2).targets[IOS_DEV_TARGET_ID].display.physicalViewport, + ).toEqual([960, 544]); + expect(resolveIOSDevBuildPlan(demoManifest(), 4).viewport.physical).toEqual([1920, 1088]); + expect(() => iosDevContracts(0)).toThrow("1..4"); + expect(() => iosDevContracts(5)).toThrow("1..4"); + expect(() => iosDevContracts(2.5)).toThrow("1..4"); + }); + + test("refuses capabilities and viewports the surface cannot provide", () => { + const needsButtons = demoManifest(); + needsButtons.engine.capabilities.requires.push("input.buttons"); + expect(() => resolveIOSDevBuildPlan(needsButtons)).toThrow("input.buttons"); + + const wrongViewport = demoManifest(); + wrongViewport.app.viewport.fixed.logical = [320, 480]; + expect(() => resolveIOSDevBuildPlan(wrongViewport)).toThrow("320x480"); + }); + + test("the native surface publishes the profile's identity", () => { + // Plan-built bundles refuse hosts whose ui.__host/__hostAbi differ + // (framework/src/host.ts assertNativeHostContract); the surface, the + // profile and any external-guest host must agree on this pair. + const surface = readFileSync(SURFACE_VIEW_PATH, "utf8"); + expect(surface).toContain(`kPocketSurfaceHostId = "${IOS_DEV_TARGET_ID}"`); + expect(surface).toContain(`kPocketSurfaceHostAbi = ${IOS_DEV_HOST_ABI}`); + expect(surface).toContain("pocket_apple_set_identity(_handle, kPocketSurfaceHostId,"); + }); + + test("type-checks the nsengine demo's explicit imports", () => { + const result = checkAppTypes({ + entry: ENTRY_PATH, + tsconfigPath: ROOT_TSCONFIG, + declarationFiles: [JSX_DECLARATIONS], + }); + + expect(typeErrors(result)).toBe(""); + expect(result.ok).toBe(true); + expect( + result.checkedFiles.some((file) => file.endsWith("/apps/nsengine/main.tsx")), + ).toBe(true); + expect( + result.checkedFiles.some((file) => file.endsWith("/apps/nsengine/app.tsx")), + ).toBe(true); + }); +}); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index f7b0ab22..f6eec168 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -344,6 +344,7 @@ describe("semantic resolution", () => { "hero-vue-vapor": [true, true, false], im: [true, true, false], "iphone2g-demo": [false, false, false], // admitted only by the private iphone2g-dev profile + nsengine: [false, true, false], // targets the private ios-dev profile; vita shares its touch + integer-fit contract "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app library: [true, true, false], diff --git a/tools/cli/README.md b/tools/cli/README.md index f151df15..327e42e6 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -2,8 +2,9 @@ The [PocketJS](https://pocketjs.dev) toolchain CLI — `doctor`/`setup` for the bun + Rust + PSP toolchain (flutter-doctor style), manifest-first app -scaffolding, build/run passthrough for PSP and PS Vita, and an isolated -Nokia E7 / Symbian development toolchain. +scaffolding, build/run passthrough for PSP and PS Vita, an isolated +Nokia E7 / Symbian development toolchain, and an Apple iOS flow that stages +guests into a NativeScript shell and launches them on the simulator. ```sh npm install -g @pocketjs/cli @@ -26,6 +27,10 @@ pocket symbian build probe pocket symbian deploy dist/symbian/pocketjs-e7-probe.sis pocket symbian coda usb pocket symbian coda usb launch +pocket ios doctor +pocket ios setup +pocket ios play nsengine # NativeScript shell on the arm64 iOS simulator +pocket play ios nsengine # the same flow through the play front door pocket hw my-app # build + run on a real PSP over PSPLINK pocket psplink # interactive multi-app switcher on a real PSP pocket devtools my-app # DevTools panel + USB debug bridge, one command diff --git a/tools/cli/bin.mjs b/tools/cli/bin.mjs index 8216966e..e3e6f4fc 100644 --- a/tools/cli/bin.mjs +++ b/tools/cli/bin.mjs @@ -8,9 +8,11 @@ // pocket check|compile|build --target [...args] // resolve pocket.json once, then build from its plan // pocket play vita build, install and launch a demo in Vita3K +// pocket play ios build, stage and launch a demo on the iOS simulator // pocket dev|psp|vita|hw|psplink|devtools|tape [...args] // low-level passthrough to the checkout's bun scripts // pocket symbian Nokia E7 toolchain doctor/setup/build/deploy +// pocket ios Apple iOS doctor/setup/build/play on the simulator // // The published CLI ships the same manifest consumed by PocketJS build scripts. @@ -368,6 +370,7 @@ const SCRIPTS = { psp: "tools/psp.ts", vita: "tools/vita.ts", symbian: "tools/symbian.ts", + ios: "tools/ios.ts", hw: "tools/hw.ts", psplink: "tools/psplink.ts", devtools: "tools/devtools.ts", @@ -412,10 +415,12 @@ const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI check + emit JS/pak from one resolved build plan pocket build --target T check + compile + package PSP or Vita artifacts pocket play vita build, install and launch a demo in Vita3K + pocket play ios build, stage and launch a demo on the iOS simulator pocket dev -main build + serve an app in the browser pocket psp build the PSP EBOOT pocket vita build the PS Vita VPK pocket symbian Nokia E7 doctor/setup/build-probe/deploy + pocket ios Apple iOS doctor/setup/build/play on the simulator pocket hw build + run on a real PSP over PSPLINK pocket psplink interactive multi-app switcher on a real PSP pocket devtools [app] DevTools panel + USB debug bridge (one command) @@ -441,6 +446,7 @@ switch (cmd) { case "psp": case "vita": case "symbian": + case "ios": case "hw": case "psplink": case "devtools": diff --git a/tools/ios-profile.ts b/tools/ios-profile.ts new file mode 100644 index 00000000..fe9d0b9f --- /dev/null +++ b/tools/ios-profile.ts @@ -0,0 +1,80 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Transitional modern-iOS profile used only by `pocket ios`. + * + * It deliberately stays out of the production `POCKET_TARGETS` registry until + * the Apple host has passed a device acceptance suite. The surface is a + * PocketSurfaceView framed inside another runtime's view hierarchy (a + * NativeScript layout today), so the form is "embedded": the 480x272 logical + * viewport is fixed and the view letterboxes it with aspect-fit. The native + * host publishes this identity via pocket_apple_set_identity + * (engine/apple/apple/PocketSurfaceView.m), and external-guest hosts publish + * the same pair on the ui namespace they mount — all three must agree. + * + * Raster density is the surface's raster scale (PocketSurfaceView clamps + * 1..4). Glyph atlases bake at build time, so guests must build at the + * density the surface renders — a mismatch renders soft text. + */ +export const IOS_DEV_TARGET_ID = "ios-dev"; +export const IOS_DEV_HOST_ABI = 7; +export const IOS_DEV_VIEWPORT = [480, 272] as const; +export const IOS_DEV_DEFAULT_DENSITY = 3; +export const IOS_DEV_MAX_DENSITY = 4; + +export function iosDevContracts(rasterDensity: number = IOS_DEV_DEFAULT_DENSITY) { + if ( + !Number.isInteger(rasterDensity) || + rasterDensity < 1 || + rasterDensity > IOS_DEV_MAX_DENSITY + ) { + throw new Error( + `pocket ios: raster density must be an integer 1..${IOS_DEV_MAX_DENSITY}, got ${rasterDensity}`, + ); + } + const [width, height] = IOS_DEV_VIEWPORT; + return definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [IOS_DEV_TARGET_ID]: { + hostAbi: IOS_DEV_HOST_ABI, + platform: "ios", + form: "embedded", + display: { + physicalViewport: [width * rasterDensity, height * rasterDensity], + logicalViewports: [[width, height]], + presentations: ["native", "integer-fit"], + rasterDensity, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }, + }), + ); +} + +export const IOS_DEV_CONTRACTS = iosDevContracts(); + +export function resolveIOSDevBuildPlan( + input: unknown, + rasterDensity?: number, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: IOS_DEV_TARGET_ID }, + rasterDensity === undefined ? IOS_DEV_CONTRACTS : iosDevContracts(rasterDensity), + ); + if (!resolution.ok) { + throw new Error( + `pocket ios: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/ios.ts b/tools/ios.ts new file mode 100644 index 00000000..280544cc --- /dev/null +++ b/tools/ios.ts @@ -0,0 +1,496 @@ +// tools/ios.ts — the pocket ios toolchain: doctor/setup for Xcode, the +// simulator and the NativeScript CLI; guest builds against the transitional +// ios-dev profile; staging into the committed NativeScript shell +// (hosts/apple/ns-shell); and launch on an arm64 iOS simulator. +// +// pocket ios doctor +// pocket ios setup --yes +// pocket ios devices +// pocket ios native [--force] +// pocket ios build nsengine [--density=1..4] +// pocket ios stage nsengine [--external-guest] [flags] +// pocket ios play nsengine [--external-guest] [--device=] [flags] +// +// `play` is also reachable as `pocket play ios `. +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { demoManifestFor } from "./demo-identity.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { + IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_MAX_DENSITY, + IOS_DEV_TARGET_ID, + resolveIOSDevBuildPlan, +} from "./ios-profile.ts"; + +const ROOT = new URL("..", import.meta.url).pathname; +const DEFAULT_SHELL = resolve(ROOT, "hosts/apple/ns-shell"); +const XCFRAMEWORK_SCRIPT = resolve(ROOT, "engine/apple/build-xcframework.sh"); +const XCFRAMEWORK_DIST = resolve(ROOT, "engine/apple/dist/PocketApple.xcframework"); +const MIN_IOS_RUNTIME = 16; + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +async function spawn( + command: string, + args: readonly string[], + options: { inherit?: boolean; cwd?: string } = {}, +): Promise { + const child = Bun.spawn({ + cmd: [command, ...args], + cwd: options.cwd ?? ROOT, + stdout: options.inherit ? "inherit" : "pipe", + stderr: options.inherit ? "inherit" : "pipe", + stdin: "ignore", + }); + const exitCode = await child.exited; + const stdout = options.inherit ? "" : await new Response(child.stdout as ReadableStream).text(); + const stderr = options.inherit ? "" : await new Response(child.stderr as ReadableStream).text(); + return { exitCode, stdout, stderr }; +} + +function flagValue(args: readonly string[], name: string): string | undefined { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function check(label: string, ok: boolean, detail?: string): boolean { + console.log(` [${ok ? "ok" : "missing"}] ${label}${detail ? `: ${detail}` : ""}`); + return ok; +} + +function info(label: string, detail: string): void { + console.log(` [info] ${label}: ${detail}`); +} + +// --------------------------------------------------------------------------- +// Simulator inventory + +export interface Simulator { + udid: string; + name: string; + state: string; + runtimeName: string; + runtimeVersion: number; +} + +async function admissibleRuntimes(): Promise> { + const result = await spawn("xcrun", ["simctl", "list", "-j", "runtimes"]); + const admitted = new Map(); + if (result.exitCode !== 0) return admitted; + const parsed = JSON.parse(result.stdout) as { + runtimes?: Array<{ + identifier?: string; + isAvailable?: boolean; + name?: string; + platform?: string; + supportedArchitectures?: string[]; + version?: string; + }>; + }; + for (const runtime of parsed.runtimes ?? []) { + const version = Number.parseFloat(runtime.version ?? "0"); + if ( + runtime.identifier && + runtime.isAvailable === true && + (runtime.platform === "iOS" || runtime.identifier.includes("SimRuntime.iOS")) && + (runtime.supportedArchitectures ?? []).includes("arm64") && + version >= MIN_IOS_RUNTIME + ) { + admitted.set(runtime.identifier, { name: runtime.name ?? runtime.identifier, version }); + } + } + return admitted; +} + +export async function admissibleSimulators(): Promise { + const runtimes = await admissibleRuntimes(); + const result = await spawn("xcrun", ["simctl", "list", "-j", "devices", "available"]); + if (result.exitCode !== 0) return []; + const parsed = JSON.parse(result.stdout) as { + devices?: Record< + string, + Array<{ udid?: string; name?: string; state?: string; deviceTypeIdentifier?: string }> + >; + }; + const simulators: Simulator[] = []; + for (const [runtimeId, devices] of Object.entries(parsed.devices ?? {})) { + const runtime = runtimes.get(runtimeId); + if (!runtime) continue; + for (const device of devices) { + if (!device.udid || !device.name) continue; + if (!(device.deviceTypeIdentifier ?? "").includes("iPhone")) continue; + simulators.push({ + udid: device.udid, + name: device.name, + state: device.state ?? "Shutdown", + runtimeName: runtime.name, + runtimeVersion: runtime.version, + }); + } + } + return simulators; +} + +async function pickSimulator(request?: string): Promise { + const simulators = await admissibleSimulators(); + if (simulators.length === 0) { + throw new Error( + "pocket ios: no arm64 iPhone simulator with an iOS 16+ runtime is available — " + + "install one with `xcodebuild -downloadPlatform iOS` or via Xcode > Settings > Platforms", + ); + } + if (request) { + const match = + simulators.find((simulator) => simulator.udid === request) ?? + simulators.find((simulator) => simulator.name.toLowerCase() === request.toLowerCase()); + if (!match) { + throw new Error( + `pocket ios: no available simulator matches "${request}" — run \`pocket ios devices\``, + ); + } + return match; + } + const booted = simulators.find((simulator) => simulator.state === "Booted"); + if (booted) return booted; + return simulators.sort((a, b) => b.runtimeVersion - a.runtimeVersion)[0]; +} + +// --------------------------------------------------------------------------- +// doctor / setup / devices + +async function commandVersion(command: string, args: readonly string[]): Promise { + if (!Bun.which(command)) return null; + const result = await spawn(command, args); + return result.exitCode === 0 ? result.stdout.trim().split("\n")[0] : null; +} + +async function doctor(): Promise { + console.log("PocketJS iOS doctor\n"); + console.log("required:"); + const arm64 = (await spawn("uname", ["-m"])).stdout.trim() === "arm64"; + let ok = check( + "Apple Silicon host", + arm64, + arm64 ? undefined : "PocketApple.xcframework and @nativescript/ios-quickjs ship arm64 slices only", + ); + const xcode = await commandVersion("xcodebuild", ["-version"]); + ok = check("Xcode", xcode !== null, xcode ?? "xcode-select --install, then install Xcode") && ok; + const clang = (await spawn("xcrun", ["--find", "clang"])).exitCode === 0; + ok = check("xcrun clang", clang) && ok; + const runtimes = await admissibleRuntimes(); + ok = check( + `arm64 iOS ${MIN_IOS_RUNTIME}+ simulator runtime`, + runtimes.size > 0, + runtimes.size > 0 + ? [...runtimes.values()].map((runtime) => runtime.name).join(", ") + : "xcodebuild -downloadPlatform iOS", + ) && ok; + const simulators = await admissibleSimulators(); + ok = check( + "iPhone simulator device", + simulators.length > 0, + simulators.length > 0 ? `${simulators.length} available` : "create one in Xcode > Devices", + ) && ok; + ok = check("bun", Bun.which("bun") !== null) && ok; + const node = await commandVersion("node", ["--version"]); + const nodeMajor = node ? Number.parseInt(node.replace(/^v/, ""), 10) : 0; + ok = check("node >= 18", nodeMajor >= 18, node ?? "install Node 18+") && ok; + const nsVersion = await commandVersion("ns", ["--version"]); + ok = check("NativeScript CLI", nsVersion !== null, nsVersion ?? "npm install -g nativescript") && ok; + + console.log("\noptional (engine development — the shell consumes the prebuilt plugin):"); + const rustup = Bun.which("rustup") !== null; + check("rustup", rustup); + if (rustup) { + const targets = (await spawn("rustup", ["target", "list", "--installed"])).stdout; + check("aarch64-apple-ios target", targets.includes("aarch64-apple-ios\n")); + check("aarch64-apple-ios-sim target", targets.includes("aarch64-apple-ios-sim")); + } + info( + "CocoaPods", + "not required — neither the shell nor @nativescript/pocketjs carries a Podfile", + ); + console.log(ok ? "\nready: pocket play ios nsengine" : "\nfix the missing items above, then re-run"); + return ok; +} + +async function setup(): Promise { + if (!Bun.which("rustup")) { + console.log("pocket ios setup: rustup not found — install from https://rustup.rs (only needed to rebuild the native surface)"); + } else { + await spawn("rustup", ["target", "add", "aarch64-apple-ios", "aarch64-apple-ios-sim"], { + inherit: true, + }); + } + console.log("everything else is diagnosed, not installed — run `pocket ios doctor`:"); + console.log(" Xcode + simulator runtime: xcodebuild -downloadPlatform iOS"); + console.log(" NativeScript CLI: npm install -g nativescript"); +} + +async function devices(): Promise { + const simulators = await admissibleSimulators(); + if (simulators.length === 0) { + console.log("no admissible simulators (arm64 iPhone, iOS 16+) — xcodebuild -downloadPlatform iOS"); + return; + } + for (const simulator of simulators) { + console.log(` ${simulator.udid} ${simulator.state.padEnd(8)} ${simulator.name} (${simulator.runtimeName})`); + } +} + +// --------------------------------------------------------------------------- +// native / build / stage + +async function buildNative(force: boolean): Promise { + if (existsSync(XCFRAMEWORK_DIST) && !force) { + console.log(`pocket ios: PocketApple.xcframework present (${XCFRAMEWORK_DIST}) — use --force to rebuild`); + return; + } + const result = await spawn("bash", [XCFRAMEWORK_SCRIPT], { inherit: true }); + if (result.exitCode !== 0 || !existsSync(XCFRAMEWORK_DIST)) { + throw new Error("pocket ios: build-xcframework.sh failed (rustup targets missing? run `pocket ios setup`)"); + } +} + +interface GuestArtifacts { + appOutput: string; + bundle: string; + pak: string; + planPath: string; +} + +function normalizeDemoName(demo: string): string { + return demo.replace(/-main$/, ""); +} + +async function buildGuest(demoArg: string, density: number): Promise { + const demo = normalizeDemoName(demoArg); + const manifest = demoManifestFor(ROOT, demo); + const plan = resolveIOSDevBuildPlan(manifest, density); + const planDir = resolve(ROOT, ".pocket/ios"); + mkdirSync(planDir, { recursive: true }); + const planPath = resolve(planDir, `${demo}.plan.json`); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + + const outdir = resolve(ROOT, `dist/ios/${demo}`); + const built = await spawn( + "bun", + ["tools/build.ts", `--plan=${planPath}`, `--project-root=${ROOT}`, `--outdir=${outdir}`], + { inherit: true }, + ); + if (built.exitCode !== 0) throw new Error(`pocket ios: guest build failed for ${demo}`); + + const inputs = extractHostBuildInputs(plan, { expectedTarget: IOS_DEV_TARGET_ID }); + const bundle = resolve(outdir, `${inputs.appOutput}.js`); + const pak = resolve(outdir, `${inputs.appOutput}.pak`); + if (!existsSync(bundle) || !existsSync(pak)) { + throw new Error(`pocket ios: expected ${bundle} and ${pak} after the build`); + } + return { appOutput: inputs.appOutput, bundle, pak, planPath }; +} + +interface StageOptions { + shellDir: string; + externalGuest: boolean; + pluginPath?: string; + runtimeTgz?: string; +} + +function stageAssets(artifacts: GuestArtifacts, options: StageOptions): void { + const assets = resolve(options.shellDir, "src/assets/pocket"); + mkdirSync(assets, { recursive: true }); + cpSync(artifacts.bundle, resolve(assets, `${artifacts.appOutput}.pocketjs`)); + cpSync(artifacts.pak, resolve(assets, `${artifacts.appOutput}.pak`)); + cpSync(artifacts.planPath, resolve(assets, `${artifacts.appOutput}.plan.json`)); + writeFileSync( + resolve(assets, "current.json"), + JSON.stringify({ app: artifacts.appOutput, externalGuest: options.externalGuest }, null, 2) + "\n", + ); +} + +async function installShellDependencies(options: StageOptions): Promise { + const packagePath = resolve(options.shellDir, "package.json"); + const hasOverrides = options.pluginPath !== undefined || options.runtimeTgz !== undefined; + if (!hasOverrides && existsSync(resolve(options.shellDir, "node_modules"))) { + return; + } + const committed = readFileSync(packagePath, "utf8"); + try { + if (hasOverrides) { + const manifest = JSON.parse(committed) as { + dependencies: Record; + devDependencies: Record; + }; + if (options.pluginPath) { + manifest.dependencies["@nativescript/pocketjs"] = `file:${resolve(options.pluginPath)}`; + } + if (options.runtimeTgz) { + manifest.devDependencies["@nativescript/ios-quickjs"] = `file:${resolve(options.runtimeTgz)}`; + } + writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + "\n"); + } + const installed = await spawn("npm", ["install", "--no-audit", "--no-fund"], { + inherit: true, + cwd: options.shellDir, + }); + if (installed.exitCode !== 0) throw new Error("pocket ios: npm install failed in the shell"); + } finally { + // The committed template names the published packages; overrides only + // ever live in node_modules. + writeFileSync(packagePath, committed); + } +} + +async function vendPluginXcframework(options: StageOptions): Promise { + // Only meaningful against a local plugin checkout: the npm package already + // carries a prebuilt PocketApple.xcframework. + if (!options.pluginPath) return; + if (!existsSync(XCFRAMEWORK_DIST)) return; + const destination = resolve(options.pluginPath, "platforms/ios/PocketApple.xcframework"); + rmSync(destination, { recursive: true, force: true }); + cpSync(XCFRAMEWORK_DIST, destination, { recursive: true }); +} + +// --------------------------------------------------------------------------- +// play + +async function play(demoArg: string, args: readonly string[]): Promise { + const density = Number(flagValue(args, "--density") ?? IOS_DEV_DEFAULT_DENSITY); + const options: StageOptions = { + shellDir: resolve(flagValue(args, "--shell-dir") ?? DEFAULT_SHELL), + externalGuest: args.includes("--external-guest"), + pluginPath: flagValue(args, "--plugin-path"), + runtimeTgz: flagValue(args, "--runtime-tgz"), + }; + + if (args.includes("--rebuild-native")) { + await buildNative(true); + } + await vendPluginXcframework(options); + + let artifacts: GuestArtifacts; + if (args.includes("--no-build")) { + const demo = normalizeDemoName(demoArg); + const planPath = resolve(ROOT, `.pocket/ios/${demo}.plan.json`); + if (!existsSync(planPath)) { + throw new Error(`pocket ios: --no-build but no prior plan at ${planPath}`); + } + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const inputs = extractHostBuildInputs(plan, { expectedTarget: IOS_DEV_TARGET_ID }); + artifacts = { + appOutput: inputs.appOutput, + bundle: resolve(ROOT, `dist/ios/${demo}/${inputs.appOutput}.js`), + pak: resolve(ROOT, `dist/ios/${demo}/${inputs.appOutput}.pak`), + planPath, + }; + if (!existsSync(artifacts.bundle) || !existsSync(artifacts.pak)) { + throw new Error("pocket ios: --no-build but no prior guest artifacts — drop the flag"); + } + } else { + artifacts = await buildGuest(demoArg, density); + } + stageAssets(artifacts, options); + await installShellDependencies(options); + + if (args.includes("--no-launch")) { + console.log(`pocket ios: staged ${artifacts.appOutput} into ${options.shellDir} (launch skipped)`); + return; + } + + const simulator = await pickSimulator(flagValue(args, "--device")); + console.log(`pocket ios: launching on ${simulator.name} (${simulator.runtimeName}, ${simulator.udid})`); + // Idempotent: "Unable to boot device in current state: Booted" is fine. + await spawn("xcrun", ["simctl", "boot", simulator.udid]); + await spawn("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]); + + const runArgs = ["run", "ios", "--device", simulator.udid, "--no-hmr"]; + if (!args.includes("--attach")) runArgs.push("--justlaunch"); + if (args.includes("--release")) runArgs.push("--release"); + const ran = await spawn("ns", runArgs, { inherit: true, cwd: options.shellDir }); + if (ran.exitCode !== 0) throw new Error("pocket ios: ns run ios failed"); +} + +// --------------------------------------------------------------------------- + +const HELP = `PocketJS Apple / iOS toolchain + + pocket ios doctor inspect Xcode, the simulator, Rust targets and the NativeScript CLI + pocket ios setup add the two Rust iOS targets; print install hints for the rest + pocket ios devices list the arm64 iOS simulators this target can run on + pocket ios native [--force] build engine/apple/dist/PocketApple.xcframework + pocket ios build [--density=1..${IOS_DEV_MAX_DENSITY}] + resolve the ${IOS_DEV_TARGET_ID} plan and emit dist/ios// + pocket ios stage [flags] build + copy assets into the shell, without launching + pocket ios play [flags] stage, then build and launch the shell on the simulator + +flags for stage/play: + --density=1..${IOS_DEV_MAX_DENSITY} guest raster density (default ${IOS_DEV_DEFAULT_DENSITY}; glyphs bake at this scale) + --external-guest evaluate the guest in the shell's own runtime (PocketHostView) + --device= pick a specific simulator (default: booted, else newest runtime) + --rebuild-native rebuild PocketApple.xcframework first (needs Rust iOS targets) + --no-build reuse the previous guest build for this app + --no-launch stage only + --attach stay attached to ns run for console output (default exits after launch) + --release build the shell in release configuration + --shell-dir= stage into another NativeScript app instead of hosts/apple/ns-shell + --plugin-path= use a local @nativescript/pocketjs checkout instead of npm + --runtime-tgz= use a local @nativescript/ios-quickjs tgz instead of npm +`; + +export async function iosMain(args: readonly string[] = Bun.argv.slice(2)): Promise { + const [command, ...rest] = args; + try { + switch (command) { + case "doctor": { + const ok = await doctor(); + if (!ok) process.exitCode = 1; + return; + } + case "setup": + await setup(); + return; + case "devices": + await devices(); + return; + case "native": + await buildNative(rest.includes("--force")); + return; + case "build": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios build: missing app name"); + const density = Number(flagValue(rest, "--density") ?? IOS_DEV_DEFAULT_DENSITY); + const artifacts = await buildGuest(rest[0], density); + console.log(`pocket ios: built ${artifacts.bundle}`); + return; + } + case "stage": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios stage: missing app name"); + await play(rest[0], [...rest.slice(1), "--no-launch"]); + return; + } + case "play": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios play: missing app name"); + await play(rest[0], rest.slice(1)); + return; + } + default: + console.log(HELP); + if (command !== undefined && command !== "help" && command !== "--help") { + process.exitCode = 1; + } + } + } catch (error) { + console.error(String(error instanceof Error ? error.message : error)); + process.exitCode = 1; + } +} + +if (import.meta.main) { + await iosMain(); +} diff --git a/tools/play.ts b/tools/play.ts index bca909df..cbf28525 100644 --- a/tools/play.ts +++ b/tools/play.ts @@ -50,6 +50,7 @@ function usage(message?: string): never { if (message) console.error(`play: ${message}\n`); console.error( "usage: bun play vita [--fullscreen] [--no-build] [--no-launch] [--framework=solid|vue-vapor|octane]\n" + + " bun play ios [ios flags — see `bun tools/ios.ts --help`]\n" + `demos: ${demos().join(", ")}`, ); process.exit(message ? 2 : 0); @@ -124,10 +125,20 @@ const args = Bun.argv.slice(2); if (args.includes("--help") || args.includes("-h")) usage(); const platform = args.shift(); const demoArg = args.shift(); -const playTargets = { vita: true } as const; +const playTargets = { vita: true, ios: true } as const; if (!platform || !(platform in playTargets)) usage(`unsupported platform ${platform ?? ""}`); if (!demoArg) usage("missing demo name"); +if (platform === "ios") { + // tools/ios.ts owns the Apple flow, its flags included (see `pocket ios`). + const proc = Bun.spawn([Bun.which("bun") ?? "bun", `${ROOT}tools/ios.ts`, "play", demoArg, ...args], { + cwd: ROOT, + stdout: "inherit", + stderr: "inherit", + }); + process.exit(await proc.exited); +} + const fullscreen = args.includes("--fullscreen"); const noBuild = args.includes("--no-build"); const noLaunch = args.includes("--no-launch"); diff --git a/tools/test.ts b/tools/test.ts index 679b3f47..d0999ed5 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -47,6 +47,7 @@ const SUITE: readonly Stage[] = [ "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", "tests/iphone2g-profile.test.ts", + "tests/ios-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts", "tests/iphone2g-device-transaction.test.ts", From f5a597563987a2d799818f0c7ae93b9c3401ee7d Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:31:19 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ios):=20review=20fixes=20=E2=80=94=20la?= =?UTF-8?q?uncher=20display=20union,=20published=20files=20map,=20spawn=20?= =?UTF-8?q?drain,=20ios=20test=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #256: - apps/nsengine's manifest makes it the repo's first Vita-only demo, so the committed launcher display registry (a deliberate cross-target union — each host intersects at runtime) was stale and the "launcher sim" gate stage failed: registry.generated.ts/images.json regenerated via `bun tools/launcher.ts scan`, and the Vita-equality pin in tests/launcher-sim.test.ts evolves into what it actually guards — PSP's deck is a subset of Vita's, with the Vita-only delta asserted exactly (["nsengine-main"]). Covers are gitignored build artifacts; `bun tools/launcher.ts covers` renders the new one on demand. - tools/cli/README.md ships `pocket ios doctor|setup|play nsengine` to @pocketjs/cli users, but the npm files map carried none of the assets the flow resolves (hosts/apple/ns-shell, apps/nsengine/pocket.json, docs/APPLE.md), so every published-install run failed at the shell or manifest lookup. Added the three entries following the iphone2g precedent, plus the matching pin in tests/npm-package.test.ts (engine/apple stays git-only: the default flow consumes the prebuilt plugin and needs no Rust). - tools/ios.ts spawn awaited child.exited before reading either pipe and carried no timeout — `simctl list -j` output past the 64 KB pipe buffer deadlocks doctor/devices. Adopted the tools/symbian.ts shape: concurrent drain via Promise.all plus an optional timeoutMs that kills and reports 124. - tests/test-suite.test.ts only enforced unit-stage registration for the iphone2g-*.test.ts family; added the same guard for ios-*.test.ts so a future ios test cannot be committed and never run. Verified: `bun run test` 11/11 stages green (the launcher sim stage failed before this commit); tests/{npm-package,test-suite,ios-profile, cli,launcher-sim}.test.ts individually green; `bun tools/ios.ts doctor`/`devices` exercise the new spawn against real simctl output. Co-Authored-By: Claude Fable 5 --- apps/launcher/images.json | 6 ++++++ apps/launcher/registry.generated.ts | 1 + package.json | 3 +++ tests/launcher-sim.test.ts | 18 +++++++++++++++--- tests/npm-package.test.ts | 3 +++ tests/test-suite.test.ts | 11 +++++++++++ tools/ios.ts | 27 ++++++++++++++++++++++----- 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/launcher/images.json b/apps/launcher/images.json index 1cd205d2..cc096b09 100644 --- a/apps/launcher/images.json +++ b/apps/launcher/images.json @@ -8,6 +8,12 @@ "covers/refl-note-main.png": { "linear": true }, + "covers/cover-nsengine-main.png": { + "linear": true + }, + "covers/refl-nsengine-main.png": { + "linear": true + }, "covers/cover-cafe-main.png": { "linear": true }, diff --git a/apps/launcher/registry.generated.ts b/apps/launcher/registry.generated.ts index dc21c7aa..fc334b60 100644 --- a/apps/launcher/registry.generated.ts +++ b/apps/launcher/registry.generated.ts @@ -18,6 +18,7 @@ export interface RegistryApp { export const REGISTRY: readonly RegistryApp[] = [ { output: "note-main", id: "dev.pocket-stack.note", title: "Pocket Note", cover: "covers/cover-note-main.png", refl: "covers/refl-note-main.png" }, + { output: "nsengine-main", id: "dev.pocket-stack.nsengine", title: "PocketJS NS Engine", cover: "covers/cover-nsengine-main.png", refl: "covers/refl-nsengine-main.png" }, { output: "cafe-main", id: "dev.pocket-stack.cafe", title: "PocketJS: Café", cover: "covers/cover-cafe-main.png", refl: "covers/refl-cafe-main.png" }, { output: "chrome-main", id: "dev.pocket-stack.chrome", title: "PocketJS: Chrome", cover: "covers/cover-chrome-main.png", refl: "covers/refl-chrome-main.png" }, { output: "cursor-main", id: "dev.pocket-stack.cursor", title: "PocketJS: Cursor", cover: "covers/cover-cursor-main.png", refl: "covers/refl-cursor-main.png" }, diff --git a/package.json b/package.json index 1d443b30..602d58d6 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,11 @@ "tools", "apps/hero/app.tsx", "apps/iphone2g-demo", + "apps/nsengine", + "hosts/apple", "hosts/iphone2g", "hosts/web", + "docs/APPLE.md", "docs/IPHONE2G.md", "assets/brand", "assets/fonts", diff --git a/tests/launcher-sim.test.ts b/tests/launcher-sim.test.ts index 46f67f6b..945be8d9 100644 --- a/tests/launcher-sim.test.ts +++ b/tests/launcher-sim.test.ts @@ -87,9 +87,21 @@ describe("launcher registry admission", () => { } }); - test("Vita admits the same current demo set through its own target profile", () => { - expect(vitaRegistry.apps).toEqual(registry.apps); - expect(vitaRegistry.apps).toHaveLength(17); + test("Vita admits every PSP demo, plus the touch-only surfaces", () => { + // Everything PSP admits, Vita admits (same entries, same metadata). + for (const app of registry.apps) { + expect(vitaRegistry.apps).toContainEqual(app); + } + // The Vita-only delta is exactly the demos requiring input.touch, which + // PSP does not advertise. The committed display registry is the union + // (scanDisplayRegistry); each host intersects at runtime. + const pspOutputs = new Set(registry.apps.map((a) => a.output)); + const vitaOnly = vitaRegistry.apps + .map((a) => a.output) + .filter((output) => !pspOutputs.has(output)); + expect(vitaOnly).toEqual(["nsengine-main"]); + expect(registry.apps).toHaveLength(17); + expect(vitaRegistry.apps).toHaveLength(18); }); test("committed registry.generated.ts is fresh (re-run tools/launcher.ts scan)", async () => { diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index ca24b2c1..adfc55ca 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -39,8 +39,11 @@ describe("published npm artifacts", () => { "tools", "apps/hero/app.tsx", "apps/iphone2g-demo", + "apps/nsengine", + "hosts/apple", "hosts/iphone2g", "hosts/web", + "docs/APPLE.md", "docs/IPHONE2G.md", "assets/brand", "assets/fonts", diff --git a/tests/test-suite.test.ts b/tests/test-suite.test.ts index 81a2b050..027dd6ba 100644 --- a/tests/test-suite.test.ts +++ b/tests/test-suite.test.ts @@ -30,4 +30,15 @@ describe("declared test suite", () => { expect(iphone2gTests).not.toHaveLength(0); expect(iphone2gTests.filter((file) => !declared.has(file))).toEqual([]); }); + + test("runs every iOS test in the CI unit stage", () => { + const declared = unitTestFiles(); + const iosTests = readdirSync(join(repository, "tests")) + .filter((file) => /^ios-.*\.test\.ts$/.test(file)) + .map((file) => `tests/${file}`) + .sort(); + + expect(iosTests).not.toHaveLength(0); + expect(iosTests.filter((file) => !declared.has(file))).toEqual([]); + }); }); diff --git a/tools/ios.ts b/tools/ios.ts index 280544cc..b290858b 100644 --- a/tools/ios.ts +++ b/tools/ios.ts @@ -38,7 +38,7 @@ interface CommandResult { async function spawn( command: string, args: readonly string[], - options: { inherit?: boolean; cwd?: string } = {}, + options: { inherit?: boolean; cwd?: string; timeoutMs?: number } = {}, ): Promise { const child = Bun.spawn({ cmd: [command, ...args], @@ -47,10 +47,27 @@ async function spawn( stderr: options.inherit ? "inherit" : "pipe", stdin: "ignore", }); - const exitCode = await child.exited; - const stdout = options.inherit ? "" : await new Response(child.stdout as ReadableStream).text(); - const stderr = options.inherit ? "" : await new Response(child.stderr as ReadableStream).text(); - return { exitCode, stdout, stderr }; + let timedOut = false; + const timer = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill(); + }, options.timeoutMs) + : undefined; + // Drain both pipes concurrently with the exit wait (the tools/symbian.ts + // shape): `simctl list -j` output routinely outruns the 64 KB pipe buffer, + // and awaiting exited first deadlocks against a blocked child. + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + options.inherit ? Promise.resolve("") : new Response(child.stdout as ReadableStream).text(), + options.inherit ? Promise.resolve("") : new Response(child.stderr as ReadableStream).text(), + ]); + if (timer) clearTimeout(timer); + return { + exitCode: timedOut ? 124 : exitCode, + stdout, + stderr: timedOut ? `${stderr}\ncommand timed out` : stderr, + }; } function flagValue(args: readonly string[], name: string): string | undefined {