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 `