From 535d43a3ad8b251d1bdbc1afc990d420bd590721 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 9 Aug 2026 11:51:19 -0700 Subject: [PATCH 1/2] =?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 4fe7bd87c1fe641c11967138194119938ef43f9d Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:19:53 +0800 Subject: [PATCH 2/2] fix(apple): scope rquickjs bindgen to iOS targets; point render_hero at the mounted entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #255: - rquickjs's bindgen feature was enabled unconditionally on a workspace member, so cargo feature unification rebuilt rquickjs-sys with bindgen (libclang required) for every desktop build of the engine workspace — including `cargo test --workspace` in the release workflow, which installs no clang. Scope it to cfg(target_os = "ios") following the hosts/pocketbook precedent; desktop builds keep the pre-generated bindings. pocket-apple never uses rquickjs directly (the dep exists only to flip the feature), and anyhow was declared but unused — drop it. - render_hero's usage line and defaults pointed at ../dist/hero.js, but `bun tools/build.ts hero` builds the component-only bundle, which installs no frame() and fails eval with "bundle installed no frame()". Point both at the hero-main mounted entry and document the build step. Verified: `cargo build -p pocket-apple` (desktop, no bindgen in the resolved graph per cargo tree), `cargo build -p pocket-apple --target aarch64-apple-ios` (bindgen active), and zero-arg `cargo run -p pocket-apple --example render_hero` renders 180 frames, non-blank, byte-identical across two instances. Co-Authored-By: Claude Fable 5 --- engine/Cargo.lock | 1 - engine/apple/Cargo.toml | 13 ++++++++----- engine/apple/examples/render_hero.rs | 10 ++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/engine/Cargo.lock b/engine/Cargo.lock index d07f7ce2..df0e64cb 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -1736,7 +1736,6 @@ dependencies = [ name = "pocket-apple" version = "0.1.0" dependencies = [ - "anyhow", "log", "pocket-mod", "pocket-ui-surface", diff --git a/engine/apple/Cargo.toml b/engine/apple/Cargo.toml index f1fb354c..ca3fb4b8 100644 --- a/engine/apple/Cargo.toml +++ b/engine/apple/Cargo.toml @@ -1,9 +1,6 @@ # 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" @@ -20,6 +17,12 @@ crate-type = ["staticlib", "rlib"] pocket-mod = { workspace = true } pocket-ui-surface = { workspace = true } pocketjs-core = { workspace = true } -rquickjs = { workspace = true, features = ["bindgen"] } -anyhow = { workspace = true } log = { workspace = true } + +# rquickjs ships pre-generated FFI bindings for common targets but NOT the +# aarch64-apple-ios family, so generate them at build time (bindgen; requires +# libclang) for iOS builds only. Desktop builds — including the examples and +# `cargo test --workspace` in CI — keep the pre-generated bindings, so cargo's +# feature unification never drags libclang into the other workspace members. +[target.'cfg(target_os = "ios")'.dependencies] +rquickjs = { workspace = true, features = ["bindgen"] } diff --git a/engine/apple/examples/render_hero.rs b/engine/apple/examples/render_hero.rs index bae513a0..e8fc16cc 100644 --- a/engine/apple/examples/render_hero.rs +++ b/engine/apple/examples/render_hero.rs @@ -1,6 +1,8 @@ //! 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 +//! snapshots. Build the MOUNTED demo entry first — the bare app name builds a +//! component-only bundle that installs no frame() and cannot boot here: +//! bun tools/build.ts hero-main +//! cargo run -p pocket-apple --example render_hero -- ../dist/hero-main.js ../dist/hero-main.pak /tmp/hero //! Exit is nonzero if two independent instances disagree on the final frame //! (determinism check) or the frame is blank. @@ -93,8 +95,8 @@ 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 bundle_path = args.get(1).map(String::as_str).unwrap_or("../dist/hero-main.js"); + let pak_path = args.get(2).map(String::as_str).unwrap_or("../dist/hero-main.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");