diff --git a/.gitignore b/.gitignore index 1f5ff1d7..339e05a8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,11 @@ 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/ +# NativeScript shell working state (hosts/apple/ns-shell): `pocket ios` stages +# guest assets, and the ns CLI owns platforms/ and hooks/ (node_modules/ is +# ignored repo-wide above). +hosts/apple/ns-shell/platforms/ +hosts/apple/ns-shell/hooks/ +hosts/apple/ns-shell/src/assets/pocket/ +hosts/apple/ns-shell/package-lock.json diff --git a/README.md b/README.md index ce872934..fe652254 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ a hardware protocol receipt, and a manual screen check prove different things. | **macOS widget** | Registered Guest profile | Dynamic native window, pointer, keyboard/IME, clipboard, and runtime glyph paths | | **Browser, desktop, headless Bun** | Guest development and verification hosts | WASM/native rendering, interactive development, deterministic simulation, and image goldens | | **Nokia E7 / Symbian** | Hardware-tested development Guest host | SIS install, launch, visible rendering, keys, and rotation on the reference device; not a production target profile | +| **iOS (NativeScript host)** | Development Guest host | Simulator boot, rendering, touch, and the guest↔host service round trip in both guest modes (sidecar realm and the NativeScript runtime as guest engine); not a production target profile | | **GBA, Game Boy, NES** | Pocket Vapor AOT | Per-interaction emulator parity against the Vue oracle, including logical characters and styles | | **ESP32 MeowBit** | Pocket Vapor AOT | Optional physical-board UART replay verifies the logical grid and exercises LCD commits; it neither reads panel pixels nor actuates GPIO buttons | | **Playdate** | Pocket Vapor AOT | Native-boundary tests and Simulator/device package smoke; physical display and input acceptance remains manual | diff --git a/apps/launcher/images.json b/apps/launcher/images.json index 1cd205d2..cc096b09 100644 --- a/apps/launcher/images.json +++ b/apps/launcher/images.json @@ -8,6 +8,12 @@ "covers/refl-note-main.png": { "linear": true }, + "covers/cover-nsengine-main.png": { + "linear": true + }, + "covers/refl-nsengine-main.png": { + "linear": true + }, "covers/cover-cafe-main.png": { "linear": true }, diff --git a/apps/launcher/registry.generated.ts b/apps/launcher/registry.generated.ts index dc21c7aa..fc334b60 100644 --- a/apps/launcher/registry.generated.ts +++ b/apps/launcher/registry.generated.ts @@ -18,6 +18,7 @@ export interface RegistryApp { export const REGISTRY: readonly RegistryApp[] = [ { output: "note-main", id: "dev.pocket-stack.note", title: "Pocket Note", cover: "covers/cover-note-main.png", refl: "covers/refl-note-main.png" }, + { output: "nsengine-main", id: "dev.pocket-stack.nsengine", title: "PocketJS NS Engine", cover: "covers/cover-nsengine-main.png", refl: "covers/refl-nsengine-main.png" }, { output: "cafe-main", id: "dev.pocket-stack.cafe", title: "PocketJS: Café", cover: "covers/cover-cafe-main.png", refl: "covers/refl-cafe-main.png" }, { output: "chrome-main", id: "dev.pocket-stack.chrome", title: "PocketJS: Chrome", cover: "covers/cover-chrome-main.png", refl: "covers/refl-chrome-main.png" }, { output: "cursor-main", id: "dev.pocket-stack.cursor", title: "PocketJS: Cursor", cover: "covers/cover-cursor-main.png", refl: "covers/refl-cursor-main.png" }, diff --git a/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/apps/nsengine/pocket.json b/apps/nsengine/pocket.json new file mode 100644 index 00000000..31263750 --- /dev/null +++ b/apps/nsengine/pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.nsengine", + "name": "pocketjs-nsengine", + "title": "PocketJS NS Engine", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.touch", "text.glyphs.baked"] + } + }, + "app": { + "entry": "apps/nsengine/main.tsx", + "output": "nsengine-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [480, 272], + "presentation": "integer-fit" + } + } + } +} diff --git a/docs/APPLE.md b/docs/APPLE.md new file mode 100644 index 00000000..a93533a0 --- /dev/null +++ b/docs/APPLE.md @@ -0,0 +1,117 @@ +# Modern iOS via NativeScript + +`pocket ios` runs PocketJS guests on the iOS simulator inside a NativeScript +shell app. The native core is `engine/apple` ([PR #255](https://github.com/pocket-stack/pocketjs/pull/255)): +the `pocket-apple` crate behind a C ABI, and `PocketSurfaceView`, a UIKit view +driving one guest realm and one software-rastered surface per instance. The +NativeScript side is the published +[`@nativescript/pocketjs`](https://github.com/NativeScript/pocketjs) plugin, +whose npm package carries a prebuilt `PocketApple.xcframework` — the default +flow needs **no Rust toolchain**. + +## Current status + +| Claim | Evidence | +| --- | --- | +| Guest boots, renders, animates at 60 fps | iOS 26.5 simulator, `apps/nsengine` at density 4 | +| Touch reaches the guest with aspect-fit inverse mapping | `Ping host` pressable increments on tap | +| Guest ↔ host service round trip | `ns.ping` → shell reply renders in the guest stat tile, unprompted on mount | +| External-guest mode (the app's JS runtime is the guest engine) | Guest code reads `UIDevice.currentDevice.systemVersion` | +| Platform-contract identity enforced end to end | Plan-built bundles bake `ios-dev`/7 and mount only on hosts publishing the same pair | +| Real-device run | **Not yet exercised** — simulator only | + +## One-time setup + +```sh +pocket ios doctor # Xcode, arm64 iOS 16+ simulator runtime, node, ns CLI +pocket ios setup # adds the two Rust iOS targets (only needed for --rebuild-native) +``` + +**An Apple Silicon Mac is required.** `PocketApple.xcframework` and the +`@nativescript/ios-quickjs` runtime ship `ios-arm64`/`ios-arm64-simulator` +slices only, so the shell excludes `x86_64` for simulator builds +(`hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig`). CocoaPods is not +required: neither the shell nor the plugin carries a Podfile. + +## Build and run a demo + +```sh +pocket play ios nsengine # build, stage, launch on the simulator +pocket ios play nsengine --external-guest # the NativeScript runtime as the guest engine +pocket ios build nsengine --density=4 # guest artifacts only (dist/ios/nsengine/) +pocket ios devices # admissible simulators +``` + +The flow: resolve the app's manifest against the `ios-dev` profile → run +`tools/build.ts` from the plan → stage `.pocketjs`, `.pak`, +`.plan.json` and `current.json` into the shell's `src/assets/pocket/` +→ `npm install` (first run) → boot an arm64 simulator → +`ns run ios --device --no-hmr --justlaunch`. The shell runs in place, +so only the first run pays the full cost: **~47 s cold** (npm install + full +Xcode build, Apple Silicon, warm simulator) and **~16 s on repeat runs** +(`--no-build`; ~25 s with a guest rebuild). `--attach` keeps `ns run` +attached for console output. + +**Density is load-bearing:** glyph atlases bake at build time, and the shell +sets the surface's raster scale from the staged plan — a guest built at one +density and rastered at another renders soft text. `--density=1..4`, default 3. + +## The ios-dev profile + +`tools/ios-profile.ts` follows the transitional pattern +(`tools/iphone2g-profile.ts`): a scoped registry that stays out of +`POCKET_TARGETS` until the host has device-level acceptance. Profile: +platform `ios`, form `embedded` (a fixed 480×272 logical viewport letterboxed +by the view), presentations `native` + `integer-fit`, capabilities +`input.touch` + `text.glyphs.baked` only — `PocketSurfaceView` reports no +buttons and a centered analog. + +**The identity contract:** bundles built from a resolved plan bake +`__POCKET_TARGET__`/`__POCKET_HOST_ABI__` and refuse to mount unless the host +publishes the same pair (`framework/src/host.ts`). Three places publish +`"ios-dev"` / `7` and must stay in agreement: this profile, +`PocketSurfaceView.m` (`pocket_apple_set_identity` at init), and the plugin's +external-guest `ui` mount. `tests/ios-profile.test.ts` guards the first two. + +## The two guest modes + +- **Sidecar (default, `PocketView`)** — the guest runs in the QuickJS realm + embedded in the xcframework. The host app's runtime never sees guest code; + the surface composes into the app's layout like any UIView. +- **External guest (`--external-guest`, `PocketHostView`)** — the shell's own + JS runtime evaluates the bundle; `globalThis.ui` delegates each op over the + NativeScript metadata bindings to the same native core. Guest code reaches + the whole iOS platform with no per-API glue. + +Both modes run the identical bundle; `current.json` selects the view class. + +## The shell (hosts/apple/ns-shell) + +Authored and committed: `package.json`, `nativescript.config.ts`, +`webpack.config.js`, `tsconfig.json`, `references.d.ts`, `src/app.ts`, +`App_Resources/iOS/{build.xcconfig,Info.plist,LaunchScreen.storyboard}`. +Generated and gitignored: `node_modules/`, `platforms/`, `hooks/`, +`src/assets/pocket/`, `package-lock.json`. The shell is plan-driven — it reads +the staged plan for viewport and density and the staged mode for the view +class, so nothing is templated at stage time. Its `tsconfig.json` pins +`@nativescript/core` paths so the plugin's typings resolve when the plugin is +a `file:` symlink. `--shell-dir=` stages into another NativeScript app +instead. + +## Pre-publish overrides + +`--plugin-path=` and `--runtime-tgz=` point the shell at a +local `@nativescript/pocketjs` checkout and a local +`@nativescript/ios-quickjs` tarball. The committed `package.json` names the +published packages; overrides are applied for the `npm install` and the +template is restored afterwards. With `--plugin-path`, a present +`engine/apple/dist/PocketApple.xcframework` is copied into the local plugin +(`--rebuild-native` rebuilds it first). + +## Sources + +- `engine/apple/` — pocket-apple crate, `PocketSurfaceView`, `build-xcframework.sh` +- `tools/ios.ts`, `tools/ios-profile.ts` — the CLI flow and the profile +- `hosts/apple/ns-shell/` — the committed shell +- `apps/nsengine/` — the reference guest (service channel + platform probe) +- [`@nativescript/pocketjs`](https://github.com/NativeScript/pocketjs) — the plugin repo diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 9ff57a36..e2924e80 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -23,6 +23,7 @@ pocketjs/ │ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build │ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) │ ├─ symbian/ Nokia E7 Qt/QuickJS runtime + visible toolchain probe +│ ├─ apple/ NativeScript iOS shell over engine/apple + @nativescript/pocketjs │ ├─ web/ browser dev host (wasm core) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) ├─ framework/ Guest: @pocketjs/framework diff --git a/engine/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..e6b49c36 --- /dev/null +++ b/engine/apple/apple/PocketSurfaceView.m @@ -0,0 +1,531 @@ +#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 + +// Platform-contract identity published to plan-built guests +// (framework/src/host.ts assertNativeHostContract). Must match the ios-dev +// profile in tools/ios-profile.ts; external-guest hosts publish the same pair +// on the ui namespace they mount. +static const char *const kPocketSurfaceHostId = "ios-dev"; +static const uint32_t kPocketSurfaceHostAbi = 7; + +typedef struct { + __weak UITouch *touch; + CGPoint point; + 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]; + } else if (pocket_apple_set_identity(_handle, kPocketSurfaceHostId, + kPocketSurfaceHostAbi) != 0) { + [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) diff --git a/hosts/apple/ns-shell/App_Resources/iOS/Info.plist b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist new file mode 100644 index 00000000..90de7ad4 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiresFullScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard b/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard new file mode 100644 index 00000000..fb2dffd3 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/LaunchScreen.storyboard @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig b/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig new file mode 100644 index 00000000..d808a553 --- /dev/null +++ b/hosts/apple/ns-shell/App_Resources/iOS/build.xcconfig @@ -0,0 +1,6 @@ +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; +IPHONEOS_DEPLOYMENT_TARGET = 16.0; +// PocketApple.xcframework ships ios-arm64 and ios-arm64-simulator slices only +// (engine/apple/build-xcframework.sh), and @nativescript/ios-quickjs likewise +// has no x86_64 simulator slice, so simulator builds are arm64-only. +EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 armv6 armv7 armv7s armv8 x86_64 diff --git a/hosts/apple/ns-shell/nativescript.config.ts b/hosts/apple/ns-shell/nativescript.config.ts new file mode 100644 index 00000000..da97426f --- /dev/null +++ b/hosts/apple/ns-shell/nativescript.config.ts @@ -0,0 +1,10 @@ +import { NativeScriptConfig } from '@nativescript/core'; + +export default { + id: 'dev.pocketjs.shell', + appPath: 'src', + appResourcesPath: 'App_Resources', + ios: { + runtimePackageName: '@nativescript/ios-quickjs', + }, +} as NativeScriptConfig; diff --git a/hosts/apple/ns-shell/package.json b/hosts/apple/ns-shell/package.json new file mode 100644 index 00000000..48e41482 --- /dev/null +++ b/hosts/apple/ns-shell/package.json @@ -0,0 +1,21 @@ +{ + "name": "pocketjs-ns-shell", + "version": "0.0.0", + "private": true, + "main": "src/app.ts", + "dependencies": { + "@nativescript/core": "9.1.0-alpha.11", + "@nativescript/pocketjs": "^0.1.0" + }, + "devDependencies": { + "@nativescript/ios-quickjs": "9.0.0-preview.10", + "@nativescript/types": "~9.0.0", + "@nativescript/webpack": "~5.0.32", + "nativescript": "9.1.0-alpha.17", + "typescript": "~5.9.0" + }, + "overrides": { + "@nativescript/core": "9.1.0-alpha.11" + }, + "scripts": {} +} diff --git a/hosts/apple/ns-shell/references.d.ts b/hosts/apple/ns-shell/references.d.ts new file mode 100644 index 00000000..d7433268 --- /dev/null +++ b/hosts/apple/ns-shell/references.d.ts @@ -0,0 +1 @@ +/// diff --git a/hosts/apple/ns-shell/src/app.ts b/hosts/apple/ns-shell/src/app.ts new file mode 100644 index 00000000..5b7630b9 --- /dev/null +++ b/hosts/apple/ns-shell/src/app.ts @@ -0,0 +1,58 @@ +// The `pocket ios play` shell: hosts one PocketJS surface and answers the +// guest's service channel. tools/ios.ts stages the guest bundle, its resolved +// build plan, and current.json (which app + which guest mode) into +// src/assets/pocket before launching. +import { Application, File, Frame, GridLayout, Page, Screen, knownFolders } from '@nativescript/core'; +import { PocketHostView, PocketView } from '@nativescript/pocketjs'; + +type BridgeCommand = { t?: string; id?: number; kind?: string; payload?: { n?: number } }; +type StagedApp = { app: string; externalGuest?: boolean }; +type StagedPlan = { viewport: { logical: [number, number]; rasterDensity: number } }; + +function readJson(relativePath: string): T { + const path = knownFolders.currentApp().path + relativePath; + return JSON.parse(File.fromPath(path).readTextSync()) as T; +} + +function createMainPage(): Page { + const staged = readJson('/assets/pocket/current.json'); + const plan = readJson(`/assets/pocket/${staged.app}.plan.json`); + const [logicalWidth, logicalHeight] = plan.viewport.logical; + + const page = new Page(); + page.actionBarHidden = true; + page.backgroundColor = '#020617'; + + const root = new GridLayout(); + const pocket = staged.externalGuest ? new PocketHostView() : new PocketView(); + pocket.viewportWidth = logicalWidth; + pocket.viewportHeight = logicalHeight; + // Glyph atlases bake at build density; the surface must raster at the same + // scale or text renders soft. Never leave this to the screen-scale default. + pocket.density = plan.viewport.rasterDensity; + const width = Screen.mainScreen.widthDIPs; + pocket.width = width as never; + pocket.height = Math.round((width * logicalHeight) / logicalWidth) as never; + pocket.on('loaded', () => console.log('[pocket-shell] guest loaded')); + pocket.on('error', (event) => + console.error('[pocket-shell] error:', (event as { message?: string }).message), + ); + pocket.on('effect', (event) => { + const cmd = (event as { data?: BridgeCommand }).data; + if (cmd?.t === 'cmd' && cmd.kind === 'ns.ping') { + pocket.post({ t: 'result', id: cmd.id, result: `pong ${cmd.payload?.n ?? 0}` }); + } + }); + pocket.src = `~/assets/pocket/${staged.app}`; + root.addChild(pocket); + page.content = root; + return page; +} + +Application.run({ + create: () => { + const frame = new Frame(); + frame.navigate({ create: createMainPage }); + return frame; + }, +}); diff --git a/hosts/apple/ns-shell/tsconfig.json b/hosts/apple/ns-shell/tsconfig.json new file mode 100644 index 00000000..cf3c88ba --- /dev/null +++ b/hosts/apple/ns-shell/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "paths": { + "~/*": ["./src/*"], + "@nativescript/core": ["./node_modules/@nativescript/core"], + "@nativescript/core/*": ["./node_modules/@nativescript/core/*"] + }, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "noEmitHelpers": true, + "importHelpers": true, + "baseUrl": "." + }, + "include": ["./src", "./references.d.ts"], + "exclude": ["node_modules", "platforms"] +} diff --git a/hosts/apple/ns-shell/webpack.config.js b/hosts/apple/ns-shell/webpack.config.js new file mode 100644 index 00000000..f82b019c --- /dev/null +++ b/hosts/apple/ns-shell/webpack.config.js @@ -0,0 +1,6 @@ +const webpack = require('@nativescript/webpack'); + +module.exports = (env) => { + webpack.init(env); + return webpack.resolveConfig(); +}; diff --git a/package.json b/package.json index 53900ec8..602d58d6 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,11 @@ "tools", "apps/hero/app.tsx", "apps/iphone2g-demo", + "apps/nsengine", + "hosts/apple", "hosts/iphone2g", "hosts/web", + "docs/APPLE.md", "docs/IPHONE2G.md", "assets/brand", "assets/fonts", @@ -151,6 +154,7 @@ "psp:switch": "bun psplink", "vita": "bun tools/vita.ts", "symbian": "bun tools/symbian.ts", + "ios": "bun tools/ios.ts", "iphone2g": "bun tools/iphone2g.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2a9d2af9..24752cc7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -69,7 +69,7 @@ describe("published PocketJS CLI", () => { script: import.meta.url, args: Bun.argv.slice(2), }));\n`; - for (const name of ["pocket", "play", "symbian", "vita"]) { + for (const name of ["pocket", "play", "symbian", "vita", "ios"]) { writeFileSync(join(scripts, `${name}.ts`), recorder); } @@ -82,6 +82,8 @@ describe("published PocketJS CLI", () => { { cliArgs: ["symbian", "doctor", "--device"], script: "symbian.ts", args: ["doctor", "--device"] }, { cliArgs: ["symbian", "coda", "usb"], script: "symbian.ts", args: ["coda", "usb"] }, { cliArgs: ["symbian", "coda", "usb", "launch"], script: "symbian.ts", args: ["coda", "usb", "launch"] }, + { cliArgs: ["ios", "play", "nsengine"], script: "ios.ts", args: ["play", "nsengine"] }, + { cliArgs: ["play", "ios", "nsengine"], script: "play.ts", args: ["ios", "nsengine"] }, ]; for (const fixture of cases) { diff --git a/tests/ios-profile.test.ts b/tests/ios-profile.test.ts new file mode 100644 index 00000000..fd523fd2 --- /dev/null +++ b/tests/ios-profile.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { checkAppTypes } from "../framework/compiler/app-check.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { + IOS_DEV_CONTRACTS, + IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_HOST_ABI, + IOS_DEV_TARGET_ID, + IOS_DEV_VIEWPORT, + iosDevContracts, + resolveIOSDevBuildPlan, +} from "../tools/ios-profile.ts"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const MANIFEST_PATH = join(REPOSITORY, "apps/nsengine/pocket.json"); +const ENTRY_PATH = join(REPOSITORY, "apps/nsengine/main.tsx"); +const SURFACE_VIEW_PATH = join(REPOSITORY, "engine/apple/apple/PocketSurfaceView.m"); +const ROOT_TSCONFIG = join(REPOSITORY, "tsconfig.json"); +const JSX_DECLARATIONS = join(REPOSITORY, "framework/src/jsx.d.ts"); + +function demoManifest(): Record { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); +} + +function typeErrors(result: ReturnType): string { + return result.diagnostics + .filter((diagnostic) => diagnostic.category === "error") + .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) + .join("\n"); +} + +describe("private iOS build profile", () => { + test("stays private and describes the embedded PocketSurfaceView", () => { + expect(POCKET_TARGETS).not.toHaveProperty(IOS_DEV_TARGET_ID); + expect(IOS_DEV_CONTRACTS.targets[IOS_DEV_TARGET_ID]).toEqual({ + hostAbi: IOS_DEV_HOST_ABI, + platform: "ios", + form: "embedded", + display: { + physicalViewport: [ + IOS_DEV_VIEWPORT[0] * IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_VIEWPORT[1] * IOS_DEV_DEFAULT_DENSITY, + ], + logicalViewports: [[480, 272]], + presentations: ["native", "integer-fit"], + rasterDensity: IOS_DEV_DEFAULT_DENSITY, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }); + }); + + test("resolves the nsengine demo to an exact surface plan", () => { + const plan = resolveIOSDevBuildPlan(demoManifest()); + + expect(plan.target).toEqual({ + id: IOS_DEV_TARGET_ID, + hostAbi: IOS_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: [480, 272], + physical: [480 * IOS_DEV_DEFAULT_DENSITY, 272 * IOS_DEV_DEFAULT_DENSITY], + presentation: "integer-fit", + rasterDensity: IOS_DEV_DEFAULT_DENSITY, + }); + expect(plan.features).toEqual({ + "input.touch": true, + "text.glyphs.baked": true, + }); + expect(plan.app.entry).toBe("apps/nsengine/main.tsx"); + expect(plan.app.output).toBe("nsengine-main"); + expect(verifyPlanHash(plan)).toBe(true); + }); + + test("density selects the physical surface", () => { + expect( + iosDevContracts(2).targets[IOS_DEV_TARGET_ID].display.physicalViewport, + ).toEqual([960, 544]); + expect(resolveIOSDevBuildPlan(demoManifest(), 4).viewport.physical).toEqual([1920, 1088]); + expect(() => iosDevContracts(0)).toThrow("1..4"); + expect(() => iosDevContracts(5)).toThrow("1..4"); + expect(() => iosDevContracts(2.5)).toThrow("1..4"); + }); + + test("refuses capabilities and viewports the surface cannot provide", () => { + const needsButtons = demoManifest(); + needsButtons.engine.capabilities.requires.push("input.buttons"); + expect(() => resolveIOSDevBuildPlan(needsButtons)).toThrow("input.buttons"); + + const wrongViewport = demoManifest(); + wrongViewport.app.viewport.fixed.logical = [320, 480]; + expect(() => resolveIOSDevBuildPlan(wrongViewport)).toThrow("320x480"); + }); + + test("the native surface publishes the profile's identity", () => { + // Plan-built bundles refuse hosts whose ui.__host/__hostAbi differ + // (framework/src/host.ts assertNativeHostContract); the surface, the + // profile and any external-guest host must agree on this pair. + const surface = readFileSync(SURFACE_VIEW_PATH, "utf8"); + expect(surface).toContain(`kPocketSurfaceHostId = "${IOS_DEV_TARGET_ID}"`); + expect(surface).toContain(`kPocketSurfaceHostAbi = ${IOS_DEV_HOST_ABI}`); + expect(surface).toContain("pocket_apple_set_identity(_handle, kPocketSurfaceHostId,"); + }); + + test("type-checks the nsengine demo's explicit imports", () => { + const result = checkAppTypes({ + entry: ENTRY_PATH, + tsconfigPath: ROOT_TSCONFIG, + declarationFiles: [JSX_DECLARATIONS], + }); + + expect(typeErrors(result)).toBe(""); + expect(result.ok).toBe(true); + expect( + result.checkedFiles.some((file) => file.endsWith("/apps/nsengine/main.tsx")), + ).toBe(true); + expect( + result.checkedFiles.some((file) => file.endsWith("/apps/nsengine/app.tsx")), + ).toBe(true); + }); +}); diff --git a/tests/launcher-sim.test.ts b/tests/launcher-sim.test.ts index 46f67f6b..945be8d9 100644 --- a/tests/launcher-sim.test.ts +++ b/tests/launcher-sim.test.ts @@ -87,9 +87,21 @@ describe("launcher registry admission", () => { } }); - test("Vita admits the same current demo set through its own target profile", () => { - expect(vitaRegistry.apps).toEqual(registry.apps); - expect(vitaRegistry.apps).toHaveLength(17); + test("Vita admits every PSP demo, plus the touch-only surfaces", () => { + // Everything PSP admits, Vita admits (same entries, same metadata). + for (const app of registry.apps) { + expect(vitaRegistry.apps).toContainEqual(app); + } + // The Vita-only delta is exactly the demos requiring input.touch, which + // PSP does not advertise. The committed display registry is the union + // (scanDisplayRegistry); each host intersects at runtime. + const pspOutputs = new Set(registry.apps.map((a) => a.output)); + const vitaOnly = vitaRegistry.apps + .map((a) => a.output) + .filter((output) => !pspOutputs.has(output)); + expect(vitaOnly).toEqual(["nsengine-main"]); + expect(registry.apps).toHaveLength(17); + expect(vitaRegistry.apps).toHaveLength(18); }); test("committed registry.generated.ts is fresh (re-run tools/launcher.ts scan)", async () => { diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index ca24b2c1..adfc55ca 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -39,8 +39,11 @@ describe("published npm artifacts", () => { "tools", "apps/hero/app.tsx", "apps/iphone2g-demo", + "apps/nsengine", + "hosts/apple", "hosts/iphone2g", "hosts/web", + "docs/APPLE.md", "docs/IPHONE2G.md", "assets/brand", "assets/fonts", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index f7b0ab22..f6eec168 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -344,6 +344,7 @@ describe("semantic resolution", () => { "hero-vue-vapor": [true, true, false], im: [true, true, false], "iphone2g-demo": [false, false, false], // admitted only by the private iphone2g-dev profile + nsengine: [false, true, false], // targets the private ios-dev profile; vita shares its touch + integer-fit contract "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app library: [true, true, false], diff --git a/tests/test-suite.test.ts b/tests/test-suite.test.ts index 81a2b050..027dd6ba 100644 --- a/tests/test-suite.test.ts +++ b/tests/test-suite.test.ts @@ -30,4 +30,15 @@ describe("declared test suite", () => { expect(iphone2gTests).not.toHaveLength(0); expect(iphone2gTests.filter((file) => !declared.has(file))).toEqual([]); }); + + test("runs every iOS test in the CI unit stage", () => { + const declared = unitTestFiles(); + const iosTests = readdirSync(join(repository, "tests")) + .filter((file) => /^ios-.*\.test\.ts$/.test(file)) + .map((file) => `tests/${file}`) + .sort(); + + expect(iosTests).not.toHaveLength(0); + expect(iosTests.filter((file) => !declared.has(file))).toEqual([]); + }); }); diff --git a/tools/cli/README.md b/tools/cli/README.md index f151df15..327e42e6 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -2,8 +2,9 @@ The [PocketJS](https://pocketjs.dev) toolchain CLI — `doctor`/`setup` for the bun + Rust + PSP toolchain (flutter-doctor style), manifest-first app -scaffolding, build/run passthrough for PSP and PS Vita, and an isolated -Nokia E7 / Symbian development toolchain. +scaffolding, build/run passthrough for PSP and PS Vita, an isolated +Nokia E7 / Symbian development toolchain, and an Apple iOS flow that stages +guests into a NativeScript shell and launches them on the simulator. ```sh npm install -g @pocketjs/cli @@ -26,6 +27,10 @@ pocket symbian build probe pocket symbian deploy dist/symbian/pocketjs-e7-probe.sis pocket symbian coda usb pocket symbian coda usb launch +pocket ios doctor +pocket ios setup +pocket ios play nsengine # NativeScript shell on the arm64 iOS simulator +pocket play ios nsengine # the same flow through the play front door pocket hw my-app # build + run on a real PSP over PSPLINK pocket psplink # interactive multi-app switcher on a real PSP pocket devtools my-app # DevTools panel + USB debug bridge, one command diff --git a/tools/cli/bin.mjs b/tools/cli/bin.mjs index 8216966e..e3e6f4fc 100644 --- a/tools/cli/bin.mjs +++ b/tools/cli/bin.mjs @@ -8,9 +8,11 @@ // pocket check|compile|build --target [...args] // resolve pocket.json once, then build from its plan // pocket play vita build, install and launch a demo in Vita3K +// pocket play ios build, stage and launch a demo on the iOS simulator // pocket dev|psp|vita|hw|psplink|devtools|tape [...args] // low-level passthrough to the checkout's bun scripts // pocket symbian Nokia E7 toolchain doctor/setup/build/deploy +// pocket ios Apple iOS doctor/setup/build/play on the simulator // // The published CLI ships the same manifest consumed by PocketJS build scripts. @@ -368,6 +370,7 @@ const SCRIPTS = { psp: "tools/psp.ts", vita: "tools/vita.ts", symbian: "tools/symbian.ts", + ios: "tools/ios.ts", hw: "tools/hw.ts", psplink: "tools/psplink.ts", devtools: "tools/devtools.ts", @@ -412,10 +415,12 @@ const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI check + emit JS/pak from one resolved build plan pocket build --target T check + compile + package PSP or Vita artifacts pocket play vita build, install and launch a demo in Vita3K + pocket play ios build, stage and launch a demo on the iOS simulator pocket dev -main build + serve an app in the browser pocket psp build the PSP EBOOT pocket vita build the PS Vita VPK pocket symbian Nokia E7 doctor/setup/build-probe/deploy + pocket ios Apple iOS doctor/setup/build/play on the simulator pocket hw build + run on a real PSP over PSPLINK pocket psplink interactive multi-app switcher on a real PSP pocket devtools [app] DevTools panel + USB debug bridge (one command) @@ -441,6 +446,7 @@ switch (cmd) { case "psp": case "vita": case "symbian": + case "ios": case "hw": case "psplink": case "devtools": diff --git a/tools/ios-profile.ts b/tools/ios-profile.ts new file mode 100644 index 00000000..fe9d0b9f --- /dev/null +++ b/tools/ios-profile.ts @@ -0,0 +1,80 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Transitional modern-iOS profile used only by `pocket ios`. + * + * It deliberately stays out of the production `POCKET_TARGETS` registry until + * the Apple host has passed a device acceptance suite. The surface is a + * PocketSurfaceView framed inside another runtime's view hierarchy (a + * NativeScript layout today), so the form is "embedded": the 480x272 logical + * viewport is fixed and the view letterboxes it with aspect-fit. The native + * host publishes this identity via pocket_apple_set_identity + * (engine/apple/apple/PocketSurfaceView.m), and external-guest hosts publish + * the same pair on the ui namespace they mount — all three must agree. + * + * Raster density is the surface's raster scale (PocketSurfaceView clamps + * 1..4). Glyph atlases bake at build time, so guests must build at the + * density the surface renders — a mismatch renders soft text. + */ +export const IOS_DEV_TARGET_ID = "ios-dev"; +export const IOS_DEV_HOST_ABI = 7; +export const IOS_DEV_VIEWPORT = [480, 272] as const; +export const IOS_DEV_DEFAULT_DENSITY = 3; +export const IOS_DEV_MAX_DENSITY = 4; + +export function iosDevContracts(rasterDensity: number = IOS_DEV_DEFAULT_DENSITY) { + if ( + !Number.isInteger(rasterDensity) || + rasterDensity < 1 || + rasterDensity > IOS_DEV_MAX_DENSITY + ) { + throw new Error( + `pocket ios: raster density must be an integer 1..${IOS_DEV_MAX_DENSITY}, got ${rasterDensity}`, + ); + } + const [width, height] = IOS_DEV_VIEWPORT; + return definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [IOS_DEV_TARGET_ID]: { + hostAbi: IOS_DEV_HOST_ABI, + platform: "ios", + form: "embedded", + display: { + physicalViewport: [width * rasterDensity, height * rasterDensity], + logicalViewports: [[width, height]], + presentations: ["native", "integer-fit"], + rasterDensity, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }, + }), + ); +} + +export const IOS_DEV_CONTRACTS = iosDevContracts(); + +export function resolveIOSDevBuildPlan( + input: unknown, + rasterDensity?: number, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: IOS_DEV_TARGET_ID }, + rasterDensity === undefined ? IOS_DEV_CONTRACTS : iosDevContracts(rasterDensity), + ); + if (!resolution.ok) { + throw new Error( + `pocket ios: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/ios.ts b/tools/ios.ts new file mode 100644 index 00000000..b290858b --- /dev/null +++ b/tools/ios.ts @@ -0,0 +1,513 @@ +// tools/ios.ts — the pocket ios toolchain: doctor/setup for Xcode, the +// simulator and the NativeScript CLI; guest builds against the transitional +// ios-dev profile; staging into the committed NativeScript shell +// (hosts/apple/ns-shell); and launch on an arm64 iOS simulator. +// +// pocket ios doctor +// pocket ios setup --yes +// pocket ios devices +// pocket ios native [--force] +// pocket ios build nsengine [--density=1..4] +// pocket ios stage nsengine [--external-guest] [flags] +// pocket ios play nsengine [--external-guest] [--device=] [flags] +// +// `play` is also reachable as `pocket play ios `. +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { demoManifestFor } from "./demo-identity.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { + IOS_DEV_DEFAULT_DENSITY, + IOS_DEV_MAX_DENSITY, + IOS_DEV_TARGET_ID, + resolveIOSDevBuildPlan, +} from "./ios-profile.ts"; + +const ROOT = new URL("..", import.meta.url).pathname; +const DEFAULT_SHELL = resolve(ROOT, "hosts/apple/ns-shell"); +const XCFRAMEWORK_SCRIPT = resolve(ROOT, "engine/apple/build-xcframework.sh"); +const XCFRAMEWORK_DIST = resolve(ROOT, "engine/apple/dist/PocketApple.xcframework"); +const MIN_IOS_RUNTIME = 16; + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +async function spawn( + command: string, + args: readonly string[], + options: { inherit?: boolean; cwd?: string; timeoutMs?: number } = {}, +): Promise { + const child = Bun.spawn({ + cmd: [command, ...args], + cwd: options.cwd ?? ROOT, + stdout: options.inherit ? "inherit" : "pipe", + stderr: options.inherit ? "inherit" : "pipe", + stdin: "ignore", + }); + let timedOut = false; + const timer = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill(); + }, options.timeoutMs) + : undefined; + // Drain both pipes concurrently with the exit wait (the tools/symbian.ts + // shape): `simctl list -j` output routinely outruns the 64 KB pipe buffer, + // and awaiting exited first deadlocks against a blocked child. + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + options.inherit ? Promise.resolve("") : new Response(child.stdout as ReadableStream).text(), + options.inherit ? Promise.resolve("") : new Response(child.stderr as ReadableStream).text(), + ]); + if (timer) clearTimeout(timer); + return { + exitCode: timedOut ? 124 : exitCode, + stdout, + stderr: timedOut ? `${stderr}\ncommand timed out` : stderr, + }; +} + +function flagValue(args: readonly string[], name: string): string | undefined { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function check(label: string, ok: boolean, detail?: string): boolean { + console.log(` [${ok ? "ok" : "missing"}] ${label}${detail ? `: ${detail}` : ""}`); + return ok; +} + +function info(label: string, detail: string): void { + console.log(` [info] ${label}: ${detail}`); +} + +// --------------------------------------------------------------------------- +// Simulator inventory + +export interface Simulator { + udid: string; + name: string; + state: string; + runtimeName: string; + runtimeVersion: number; +} + +async function admissibleRuntimes(): Promise> { + const result = await spawn("xcrun", ["simctl", "list", "-j", "runtimes"]); + const admitted = new Map(); + if (result.exitCode !== 0) return admitted; + const parsed = JSON.parse(result.stdout) as { + runtimes?: Array<{ + identifier?: string; + isAvailable?: boolean; + name?: string; + platform?: string; + supportedArchitectures?: string[]; + version?: string; + }>; + }; + for (const runtime of parsed.runtimes ?? []) { + const version = Number.parseFloat(runtime.version ?? "0"); + if ( + runtime.identifier && + runtime.isAvailable === true && + (runtime.platform === "iOS" || runtime.identifier.includes("SimRuntime.iOS")) && + (runtime.supportedArchitectures ?? []).includes("arm64") && + version >= MIN_IOS_RUNTIME + ) { + admitted.set(runtime.identifier, { name: runtime.name ?? runtime.identifier, version }); + } + } + return admitted; +} + +export async function admissibleSimulators(): Promise { + const runtimes = await admissibleRuntimes(); + const result = await spawn("xcrun", ["simctl", "list", "-j", "devices", "available"]); + if (result.exitCode !== 0) return []; + const parsed = JSON.parse(result.stdout) as { + devices?: Record< + string, + Array<{ udid?: string; name?: string; state?: string; deviceTypeIdentifier?: string }> + >; + }; + const simulators: Simulator[] = []; + for (const [runtimeId, devices] of Object.entries(parsed.devices ?? {})) { + const runtime = runtimes.get(runtimeId); + if (!runtime) continue; + for (const device of devices) { + if (!device.udid || !device.name) continue; + if (!(device.deviceTypeIdentifier ?? "").includes("iPhone")) continue; + simulators.push({ + udid: device.udid, + name: device.name, + state: device.state ?? "Shutdown", + runtimeName: runtime.name, + runtimeVersion: runtime.version, + }); + } + } + return simulators; +} + +async function pickSimulator(request?: string): Promise { + const simulators = await admissibleSimulators(); + if (simulators.length === 0) { + throw new Error( + "pocket ios: no arm64 iPhone simulator with an iOS 16+ runtime is available — " + + "install one with `xcodebuild -downloadPlatform iOS` or via Xcode > Settings > Platforms", + ); + } + if (request) { + const match = + simulators.find((simulator) => simulator.udid === request) ?? + simulators.find((simulator) => simulator.name.toLowerCase() === request.toLowerCase()); + if (!match) { + throw new Error( + `pocket ios: no available simulator matches "${request}" — run \`pocket ios devices\``, + ); + } + return match; + } + const booted = simulators.find((simulator) => simulator.state === "Booted"); + if (booted) return booted; + return simulators.sort((a, b) => b.runtimeVersion - a.runtimeVersion)[0]; +} + +// --------------------------------------------------------------------------- +// doctor / setup / devices + +async function commandVersion(command: string, args: readonly string[]): Promise { + if (!Bun.which(command)) return null; + const result = await spawn(command, args); + return result.exitCode === 0 ? result.stdout.trim().split("\n")[0] : null; +} + +async function doctor(): Promise { + console.log("PocketJS iOS doctor\n"); + console.log("required:"); + const arm64 = (await spawn("uname", ["-m"])).stdout.trim() === "arm64"; + let ok = check( + "Apple Silicon host", + arm64, + arm64 ? undefined : "PocketApple.xcframework and @nativescript/ios-quickjs ship arm64 slices only", + ); + const xcode = await commandVersion("xcodebuild", ["-version"]); + ok = check("Xcode", xcode !== null, xcode ?? "xcode-select --install, then install Xcode") && ok; + const clang = (await spawn("xcrun", ["--find", "clang"])).exitCode === 0; + ok = check("xcrun clang", clang) && ok; + const runtimes = await admissibleRuntimes(); + ok = check( + `arm64 iOS ${MIN_IOS_RUNTIME}+ simulator runtime`, + runtimes.size > 0, + runtimes.size > 0 + ? [...runtimes.values()].map((runtime) => runtime.name).join(", ") + : "xcodebuild -downloadPlatform iOS", + ) && ok; + const simulators = await admissibleSimulators(); + ok = check( + "iPhone simulator device", + simulators.length > 0, + simulators.length > 0 ? `${simulators.length} available` : "create one in Xcode > Devices", + ) && ok; + ok = check("bun", Bun.which("bun") !== null) && ok; + const node = await commandVersion("node", ["--version"]); + const nodeMajor = node ? Number.parseInt(node.replace(/^v/, ""), 10) : 0; + ok = check("node >= 18", nodeMajor >= 18, node ?? "install Node 18+") && ok; + const nsVersion = await commandVersion("ns", ["--version"]); + ok = check("NativeScript CLI", nsVersion !== null, nsVersion ?? "npm install -g nativescript") && ok; + + console.log("\noptional (engine development — the shell consumes the prebuilt plugin):"); + const rustup = Bun.which("rustup") !== null; + check("rustup", rustup); + if (rustup) { + const targets = (await spawn("rustup", ["target", "list", "--installed"])).stdout; + check("aarch64-apple-ios target", targets.includes("aarch64-apple-ios\n")); + check("aarch64-apple-ios-sim target", targets.includes("aarch64-apple-ios-sim")); + } + info( + "CocoaPods", + "not required — neither the shell nor @nativescript/pocketjs carries a Podfile", + ); + console.log(ok ? "\nready: pocket play ios nsengine" : "\nfix the missing items above, then re-run"); + return ok; +} + +async function setup(): Promise { + if (!Bun.which("rustup")) { + console.log("pocket ios setup: rustup not found — install from https://rustup.rs (only needed to rebuild the native surface)"); + } else { + await spawn("rustup", ["target", "add", "aarch64-apple-ios", "aarch64-apple-ios-sim"], { + inherit: true, + }); + } + console.log("everything else is diagnosed, not installed — run `pocket ios doctor`:"); + console.log(" Xcode + simulator runtime: xcodebuild -downloadPlatform iOS"); + console.log(" NativeScript CLI: npm install -g nativescript"); +} + +async function devices(): Promise { + const simulators = await admissibleSimulators(); + if (simulators.length === 0) { + console.log("no admissible simulators (arm64 iPhone, iOS 16+) — xcodebuild -downloadPlatform iOS"); + return; + } + for (const simulator of simulators) { + console.log(` ${simulator.udid} ${simulator.state.padEnd(8)} ${simulator.name} (${simulator.runtimeName})`); + } +} + +// --------------------------------------------------------------------------- +// native / build / stage + +async function buildNative(force: boolean): Promise { + if (existsSync(XCFRAMEWORK_DIST) && !force) { + console.log(`pocket ios: PocketApple.xcframework present (${XCFRAMEWORK_DIST}) — use --force to rebuild`); + return; + } + const result = await spawn("bash", [XCFRAMEWORK_SCRIPT], { inherit: true }); + if (result.exitCode !== 0 || !existsSync(XCFRAMEWORK_DIST)) { + throw new Error("pocket ios: build-xcframework.sh failed (rustup targets missing? run `pocket ios setup`)"); + } +} + +interface GuestArtifacts { + appOutput: string; + bundle: string; + pak: string; + planPath: string; +} + +function normalizeDemoName(demo: string): string { + return demo.replace(/-main$/, ""); +} + +async function buildGuest(demoArg: string, density: number): Promise { + const demo = normalizeDemoName(demoArg); + const manifest = demoManifestFor(ROOT, demo); + const plan = resolveIOSDevBuildPlan(manifest, density); + const planDir = resolve(ROOT, ".pocket/ios"); + mkdirSync(planDir, { recursive: true }); + const planPath = resolve(planDir, `${demo}.plan.json`); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + + const outdir = resolve(ROOT, `dist/ios/${demo}`); + const built = await spawn( + "bun", + ["tools/build.ts", `--plan=${planPath}`, `--project-root=${ROOT}`, `--outdir=${outdir}`], + { inherit: true }, + ); + if (built.exitCode !== 0) throw new Error(`pocket ios: guest build failed for ${demo}`); + + const inputs = extractHostBuildInputs(plan, { expectedTarget: IOS_DEV_TARGET_ID }); + const bundle = resolve(outdir, `${inputs.appOutput}.js`); + const pak = resolve(outdir, `${inputs.appOutput}.pak`); + if (!existsSync(bundle) || !existsSync(pak)) { + throw new Error(`pocket ios: expected ${bundle} and ${pak} after the build`); + } + return { appOutput: inputs.appOutput, bundle, pak, planPath }; +} + +interface StageOptions { + shellDir: string; + externalGuest: boolean; + pluginPath?: string; + runtimeTgz?: string; +} + +function stageAssets(artifacts: GuestArtifacts, options: StageOptions): void { + const assets = resolve(options.shellDir, "src/assets/pocket"); + mkdirSync(assets, { recursive: true }); + cpSync(artifacts.bundle, resolve(assets, `${artifacts.appOutput}.pocketjs`)); + cpSync(artifacts.pak, resolve(assets, `${artifacts.appOutput}.pak`)); + cpSync(artifacts.planPath, resolve(assets, `${artifacts.appOutput}.plan.json`)); + writeFileSync( + resolve(assets, "current.json"), + JSON.stringify({ app: artifacts.appOutput, externalGuest: options.externalGuest }, null, 2) + "\n", + ); +} + +async function installShellDependencies(options: StageOptions): Promise { + const packagePath = resolve(options.shellDir, "package.json"); + const hasOverrides = options.pluginPath !== undefined || options.runtimeTgz !== undefined; + if (!hasOverrides && existsSync(resolve(options.shellDir, "node_modules"))) { + return; + } + const committed = readFileSync(packagePath, "utf8"); + try { + if (hasOverrides) { + const manifest = JSON.parse(committed) as { + dependencies: Record; + devDependencies: Record; + }; + if (options.pluginPath) { + manifest.dependencies["@nativescript/pocketjs"] = `file:${resolve(options.pluginPath)}`; + } + if (options.runtimeTgz) { + manifest.devDependencies["@nativescript/ios-quickjs"] = `file:${resolve(options.runtimeTgz)}`; + } + writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + "\n"); + } + const installed = await spawn("npm", ["install", "--no-audit", "--no-fund"], { + inherit: true, + cwd: options.shellDir, + }); + if (installed.exitCode !== 0) throw new Error("pocket ios: npm install failed in the shell"); + } finally { + // The committed template names the published packages; overrides only + // ever live in node_modules. + writeFileSync(packagePath, committed); + } +} + +async function vendPluginXcframework(options: StageOptions): Promise { + // Only meaningful against a local plugin checkout: the npm package already + // carries a prebuilt PocketApple.xcframework. + if (!options.pluginPath) return; + if (!existsSync(XCFRAMEWORK_DIST)) return; + const destination = resolve(options.pluginPath, "platforms/ios/PocketApple.xcframework"); + rmSync(destination, { recursive: true, force: true }); + cpSync(XCFRAMEWORK_DIST, destination, { recursive: true }); +} + +// --------------------------------------------------------------------------- +// play + +async function play(demoArg: string, args: readonly string[]): Promise { + const density = Number(flagValue(args, "--density") ?? IOS_DEV_DEFAULT_DENSITY); + const options: StageOptions = { + shellDir: resolve(flagValue(args, "--shell-dir") ?? DEFAULT_SHELL), + externalGuest: args.includes("--external-guest"), + pluginPath: flagValue(args, "--plugin-path"), + runtimeTgz: flagValue(args, "--runtime-tgz"), + }; + + if (args.includes("--rebuild-native")) { + await buildNative(true); + } + await vendPluginXcframework(options); + + let artifacts: GuestArtifacts; + if (args.includes("--no-build")) { + const demo = normalizeDemoName(demoArg); + const planPath = resolve(ROOT, `.pocket/ios/${demo}.plan.json`); + if (!existsSync(planPath)) { + throw new Error(`pocket ios: --no-build but no prior plan at ${planPath}`); + } + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const inputs = extractHostBuildInputs(plan, { expectedTarget: IOS_DEV_TARGET_ID }); + artifacts = { + appOutput: inputs.appOutput, + bundle: resolve(ROOT, `dist/ios/${demo}/${inputs.appOutput}.js`), + pak: resolve(ROOT, `dist/ios/${demo}/${inputs.appOutput}.pak`), + planPath, + }; + if (!existsSync(artifacts.bundle) || !existsSync(artifacts.pak)) { + throw new Error("pocket ios: --no-build but no prior guest artifacts — drop the flag"); + } + } else { + artifacts = await buildGuest(demoArg, density); + } + stageAssets(artifacts, options); + await installShellDependencies(options); + + if (args.includes("--no-launch")) { + console.log(`pocket ios: staged ${artifacts.appOutput} into ${options.shellDir} (launch skipped)`); + return; + } + + const simulator = await pickSimulator(flagValue(args, "--device")); + console.log(`pocket ios: launching on ${simulator.name} (${simulator.runtimeName}, ${simulator.udid})`); + // Idempotent: "Unable to boot device in current state: Booted" is fine. + await spawn("xcrun", ["simctl", "boot", simulator.udid]); + await spawn("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]); + + const runArgs = ["run", "ios", "--device", simulator.udid, "--no-hmr"]; + if (!args.includes("--attach")) runArgs.push("--justlaunch"); + if (args.includes("--release")) runArgs.push("--release"); + const ran = await spawn("ns", runArgs, { inherit: true, cwd: options.shellDir }); + if (ran.exitCode !== 0) throw new Error("pocket ios: ns run ios failed"); +} + +// --------------------------------------------------------------------------- + +const HELP = `PocketJS Apple / iOS toolchain + + pocket ios doctor inspect Xcode, the simulator, Rust targets and the NativeScript CLI + pocket ios setup add the two Rust iOS targets; print install hints for the rest + pocket ios devices list the arm64 iOS simulators this target can run on + pocket ios native [--force] build engine/apple/dist/PocketApple.xcframework + pocket ios build [--density=1..${IOS_DEV_MAX_DENSITY}] + resolve the ${IOS_DEV_TARGET_ID} plan and emit dist/ios// + pocket ios stage [flags] build + copy assets into the shell, without launching + pocket ios play [flags] stage, then build and launch the shell on the simulator + +flags for stage/play: + --density=1..${IOS_DEV_MAX_DENSITY} guest raster density (default ${IOS_DEV_DEFAULT_DENSITY}; glyphs bake at this scale) + --external-guest evaluate the guest in the shell's own runtime (PocketHostView) + --device= pick a specific simulator (default: booted, else newest runtime) + --rebuild-native rebuild PocketApple.xcframework first (needs Rust iOS targets) + --no-build reuse the previous guest build for this app + --no-launch stage only + --attach stay attached to ns run for console output (default exits after launch) + --release build the shell in release configuration + --shell-dir= stage into another NativeScript app instead of hosts/apple/ns-shell + --plugin-path= use a local @nativescript/pocketjs checkout instead of npm + --runtime-tgz= use a local @nativescript/ios-quickjs tgz instead of npm +`; + +export async function iosMain(args: readonly string[] = Bun.argv.slice(2)): Promise { + const [command, ...rest] = args; + try { + switch (command) { + case "doctor": { + const ok = await doctor(); + if (!ok) process.exitCode = 1; + return; + } + case "setup": + await setup(); + return; + case "devices": + await devices(); + return; + case "native": + await buildNative(rest.includes("--force")); + return; + case "build": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios build: missing app name"); + const density = Number(flagValue(rest, "--density") ?? IOS_DEV_DEFAULT_DENSITY); + const artifacts = await buildGuest(rest[0], density); + console.log(`pocket ios: built ${artifacts.bundle}`); + return; + } + case "stage": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios stage: missing app name"); + await play(rest[0], [...rest.slice(1), "--no-launch"]); + return; + } + case "play": { + if (!rest[0] || rest[0].startsWith("--")) throw new Error("pocket ios play: missing app name"); + await play(rest[0], rest.slice(1)); + return; + } + default: + console.log(HELP); + if (command !== undefined && command !== "help" && command !== "--help") { + process.exitCode = 1; + } + } + } catch (error) { + console.error(String(error instanceof Error ? error.message : error)); + process.exitCode = 1; + } +} + +if (import.meta.main) { + await iosMain(); +} diff --git a/tools/play.ts b/tools/play.ts index bca909df..cbf28525 100644 --- a/tools/play.ts +++ b/tools/play.ts @@ -50,6 +50,7 @@ function usage(message?: string): never { if (message) console.error(`play: ${message}\n`); console.error( "usage: bun play vita [--fullscreen] [--no-build] [--no-launch] [--framework=solid|vue-vapor|octane]\n" + + " bun play ios [ios flags — see `bun tools/ios.ts --help`]\n" + `demos: ${demos().join(", ")}`, ); process.exit(message ? 2 : 0); @@ -124,10 +125,20 @@ const args = Bun.argv.slice(2); if (args.includes("--help") || args.includes("-h")) usage(); const platform = args.shift(); const demoArg = args.shift(); -const playTargets = { vita: true } as const; +const playTargets = { vita: true, ios: true } as const; if (!platform || !(platform in playTargets)) usage(`unsupported platform ${platform ?? ""}`); if (!demoArg) usage("missing demo name"); +if (platform === "ios") { + // tools/ios.ts owns the Apple flow, its flags included (see `pocket ios`). + const proc = Bun.spawn([Bun.which("bun") ?? "bun", `${ROOT}tools/ios.ts`, "play", demoArg, ...args], { + cwd: ROOT, + stdout: "inherit", + stderr: "inherit", + }); + process.exit(await proc.exited); +} + const fullscreen = args.includes("--fullscreen"); const noBuild = args.includes("--no-build"); const noLaunch = args.includes("--no-launch"); diff --git a/tools/test.ts b/tools/test.ts index 679b3f47..d0999ed5 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -47,6 +47,7 @@ const SUITE: readonly Stage[] = [ "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", "tests/iphone2g-profile.test.ts", + "tests/ios-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts", "tests/iphone2g-device-transaction.test.ts",