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..df0e64cb 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,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pocket-apple" +version = "0.1.0" +dependencies = [ + "log", + "pocket-mod", + "pocket-ui-surface", + "pocketjs-core", + "rquickjs", +] + [[package]] name = "pocket-db" version = "0.1.0" @@ -1830,6 +1939,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 +2093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" dependencies = [ "rquickjs-core", + "rquickjs-macro", ] [[package]] @@ -1987,12 +2107,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 +2165,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 +2295,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 +2899,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..ca3fb4b8 --- /dev/null +++ b/engine/apple/Cargo.toml @@ -0,0 +1,28 @@ +# 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). + +[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 } +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/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..e8fc16cc --- /dev/null +++ b/engine/apple/examples/render_hero.rs @@ -0,0 +1,118 @@ +//! Renders a guest bundle through the pocket-apple C ABI and writes PPM +//! 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. + +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-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"); + 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)