Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
90 changes: 90 additions & 0 deletions apps/nsengine/app.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View class="flex-col flex-1 gap-1 rounded-lg bg-slate-800 p-3 shadow">
<Text class="text-xs text-slate-400 tracking-wide">{props.label}</Text>
<Text class={props.valueClass}>{props.value}</Text>
</View>
);
}

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 (
<Screen class="relative flex-col w-full h-full overflow-hidden justify-between p-5 bg-gradient-to-b from-slate-950 to-slate-900">
<View class="flex-row items-center justify-between">
<View class="flex-row items-center gap-3">
<Image class="w-10 h-10 rounded-lg shadow" src="logo.png" />
<View class="flex-col">
<Text class="text-base text-white font-bold tracking-wide">
PocketJS × NativeScript
</Text>
<Text class="text-xs text-slate-400 tracking-wide">
one Rust core · two JS worlds
</Text>
</View>
</View>
<Image class="w-8 h-8" src={spinnerSrc()} />
</View>

<View class="flex-row gap-3">
<Stat label="guest → host" value={reply()} valueClass="text-xs font-bold text-cyan-400" />
<Stat label="host → guest" value={hostEvent()} valueClass="text-xs font-bold text-blue-400" />
<Stat label="platform" value={platformReach} valueClass="text-xs font-bold text-emerald-400" />
</View>

<View class="flex-row items-center gap-4">
<View class="rounded-xl bg-blue-600 px-5 py-2 shadow" focusable onPress={ping}>
<Text class="text-sm font-bold text-white">Ping host · {count()}</Text>
</View>
<View class="h-1 flex-1 rounded bg-gradient-to-r from-blue-500 to-cyan-500" />
</View>
<Text class="text-slate-900">{GLYPH_SEED}</Text>
</Screen>
);
}
57 changes: 57 additions & 0 deletions apps/nsengine/channel.ts
Original file line number Diff line number Diff line change
@@ -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<number, (result: unknown) => 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<string, unknown>) => 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<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} 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);
}
}
}
7 changes: 7 additions & 0 deletions apps/nsengine/main.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <App />);
24 changes: 24 additions & 0 deletions apps/nsengine/pocket.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
117 changes: 117 additions & 0 deletions docs/APPLE.md
Original file line number Diff line number Diff line change
@@ -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 `<output>.pocketjs`, `<output>.pak`,
`<output>.plan.json` and `current.json` into the shell's `src/assets/pocket/`
→ `npm install` (first run) → boot an arm64 simulator →
`ns run ios --device <udid> --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=<path>` stages into another NativeScript app
instead.

## Pre-publish overrides

`--plugin-path=<checkout>` and `--runtime-tgz=<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
1 change: 1 addition & 0 deletions docs/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading