Skip to content

feat(apple): iOS host — pocket-apple core crate, PocketSurfaceView, external-guest mode - #255

Open
NathanWalker wants to merge 2 commits into
pocket-stack:mainfrom
NathanWalker:feat/apple-host
Open

feat(apple): iOS host — pocket-apple core crate, PocketSurfaceView, external-guest mode#255
NathanWalker wants to merge 2 commits into
pocket-stack:mainfrom
NathanWalker:feat/apple-host

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Aug 9, 2026

Copy link
Copy Markdown

What

A new engine/apple workspace member (pocket-apple) plus a UIKit view class, packaging modern iOS as a PocketJS host. The composition mirrors hosts/pocketbook: one pocket_mod::Guest realm, one pocket_ui_surface::UiSurface, and pocketjs_core::raster driven incrementally through a DamageTracker, exposed behind a small C ABI.

  • engine/apple/src/lib.rs — guest-owning mode: pocket_apple_create/load_pak/eval_bundle/frame/render/hit_test_bounds/destroy, plus an effect channel over the ui.svc* ops (set_effect_callback drains guest svcSend lines during frame; post_event queues lines for the guest's next svcPoll).
  • engine/apple/src/core_host.rs — external-guest mode: pocket_apple_core_* owns only the core, pak feed, raster pipeline, and svc queues, for hosts whose JS engine lives elsewhere (demonstrated with the NativeScript runtime: the guest bundle evaluates in the embedding runtime's context and globalThis.ui delegates each op over the bridge).
  • engine/apple/apple/PocketSurfaceView.{h,m} — CADisplayLink capped at 60 Hz, packed touch words with aspect-fit inverse mapping, damage-gated compositing of the ARGB32 framebuffer into layer.contents.
  • engine/apple/build-xcframework.sh — clang-linked dynamic framework per slice (device arm64 + simulator arm64), no Xcode project.
  • apps/nsengine — reference guest for the effect channel: an effect driver over svcSend + a per-frame poll pump, and a platform-reach probe (typeof UIDevice) that distinguishes a sidecar realm from an embedding-runtime host.

rquickjs uses its bindgen feature here: no pregenerated bindings exist for aarch64-apple-ios targets.

Validation

  • cargo run -p pocket-apple --example render_hero — 180 frames of apps/hero/main.tsx at 480×272 density 2: non-blank, byte-identical across two independent instances, incremental damage plans active. Release mode renders 360 frames plus two guest boots in 0.34 s wall.
  • Verified on the iOS 26.5 simulator inside two NativeScript apps (embedded-surface and external-guest modes), including the guest→host→guest effect round trip.

Not included (follow-ups)

  • Manifest/target registration: bundles are built plan-less; a dev-profile target id (the tools/iphone2g-profile.ts pattern) is the likely next step before POCKET_TARGETS registration.
  • GPU path: the software rasterizer ships this iteration; the follow-up is pocket-ui-wgpu rendering into a CAMetalLayer surface as a second path behind the same view class.

@NathanWalker
NathanWalker marked this pull request as ready for review August 9, 2026 17:12
…xternal-guest mode

A new engine/apple workspace member packaging modern iOS as a PocketJS
host. The composition mirrors hosts/pocketbook: one pocket_mod::Guest
realm, one pocket_ui_surface::UiSurface, and pocketjs_core::raster driven
incrementally through a DamageTracker, exposed behind a small C ABI.

- engine/apple/src/lib.rs — guest-owning mode: create/load_pak/eval_bundle/
  frame/render/hit_test_bounds/destroy, plus an effect channel over the
  ui.svc* ops (set_effect_callback drains guest svcSend lines during frame;
  post_event queues lines for the guest's next svcPoll).
- engine/apple/src/core_host.rs — external-guest mode: pocket_apple_core_*
  owns only the core, pak feed, raster pipeline, and svc queues, for hosts
  whose JS engine lives elsewhere (demonstrated with the NativeScript
  runtime evaluating the guest bundle in its own context).
- engine/apple/apple/PocketSurfaceView.{h,m} — CADisplayLink capped at
  60 Hz, latched touch contacts (a down+up between two ticks still reaches
  the guest as one present frame then a release), aspect-fit inverse touch
  mapping, damage-gated compositing of the ARGB32 framebuffer.
- engine/apple/build-xcframework.sh — clang-linked dynamic framework per
  slice (device arm64 + simulator arm64), no Xcode project.
- pocket-ui-surface additionally mounts hitTestBounds (spec op 42), the
  touch-path hit authority the gesture layer prefers over the ink-claiming
  hitTest.
- apps/nsengine — reference guest: an effect driver over svcSend, a
  per-frame poll pump, a focusable pressable button, and a platform-reach
  probe that distinguishes a sidecar realm from an embedding-runtime host.

rquickjs uses its bindgen feature: no pregenerated bindings exist for
aarch64-apple-ios targets. Validation: the render_hero example drives the
ABI end to end — 180 frames of apps/hero/main.tsx at 480x272 density 2
render non-blank and byte-identical across two independent instances.
Build guests with --density matching the surface density (glyphs bake at
build time; density 4 supersamples cleanly on 3x screens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…at the mounted entry

Review fixes for pocket-stack#255:

- rquickjs's bindgen feature was enabled unconditionally on a workspace
  member, so cargo feature unification rebuilt rquickjs-sys with bindgen
  (libclang required) for every desktop build of the engine workspace —
  including `cargo test --workspace` in the release workflow, which
  installs no clang. Scope it to cfg(target_os = "ios") following the
  hosts/pocketbook precedent; desktop builds keep the pre-generated
  bindings. pocket-apple never uses rquickjs directly (the dep exists
  only to flip the feature), and anyhow was declared but unused — drop it.

- render_hero's usage line and defaults pointed at ../dist/hero.js, but
  `bun tools/build.ts hero` builds the component-only bundle, which
  installs no frame() and fails eval with "bundle installed no frame()".
  Point both at the hero-main mounted entry and document the build step.

Verified: `cargo build -p pocket-apple` (desktop, no bindgen in the
resolved graph per cargo tree), `cargo build -p pocket-apple --target
aarch64-apple-ios` (bindgen active), and zero-arg
`cargo run -p pocket-apple --example render_hero` renders 180 frames,
non-blank, byte-identical across two instances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind

Copy link
Copy Markdown
Collaborator

Review — verified locally, two fixes pushed, remaining findings below

Verdict: the architecture direction is right and the core claims check out. The composition genuinely reuses the shared crates (no copy of pak feeding, op mounting, or guest plumbing in lib.rs; boot order matches hosts/pocketbook/src/main.rs:110-121 exactly), op 42 is the pre-existing registry number (contracts/spec/spec.ts:229), the touch word packing matches every other host bit-for-bit, and the ARGB32/BGRA claim matches the convention documented in hosts/iphone2g/pocket_runtime.h:23-26. What needs work before this graduates is the duplication inside the crate and the touch-hit channel choice — details ranked below.

What I ran

  • bun run test on this branch with the pushed fixes: 11/11 stages green in 39 s.
  • cargo build -p pocket-apple (desktop) and cargo build -p pocket-apple --target aarch64-apple-ios: both clean.
  • cargo run -p pocket-apple --example render_hero (zero args, after the pushed fix): rendered 180 frames at 960x544 (density 2) | non_blank=true deterministic=true — the determinism claim reproduces, including byte-identical frames across two instances and live damage plans.
  • bunx tsc --noEmit: clean.

Pushed to this branch (4fe7bd8)

  1. rquickjs's bindgen feature was enabled unconditionally on a workspace member. Cargo unifies features across members built together, so every desktop build of the engine workspace — including cargo test --workspace in release.yml:87, which installs no clang — would compile rquickjs-sys through bindgen/clang-sys. Scoped it to cfg(target_os = "ios") following the hosts/pocketbook/Cargo.toml:39-44 precedent. pocket-apple never uses rquickjs directly (the dep exists only to flip the feature), and anyhow was declared but unused, so both leave [dependencies]. Verified: cargo tree -p pocket-apple has no bindgen/clang on desktop and keeps them for --target aarch64-apple-ios.
  2. render_hero's usage line and defaults pointed at ../dist/hero.js — but bun tools/build.ts hero builds the component-only bundle, which installs no frame() and dies with bundle installed no frame() (I lost real time to this before finding hero-main). Defaults and doc comment now name the mounted entry and the build command.

Remaining findings, ranked (none pushed — they need your call)

  1. The touch path uses the op-42 fallback instead of the fact channel. contracts/spec/spec.ts:229-245 defines hitTestBounds as the cold-path query — "the guest only issues this op when no fact channel exists". The other Apple host already implements the fact channel (hosts/iphone2g/pocket_runtime.c:612-652 passes hits as frame() argument 4); pocket-apple has no hits parameter anywhere, so guest-side resolution runs against the post-tick tree (framework/src/input.ts:733) — one frame off by construction. Mounting op 42 in pocket-ui-surface also flips pocketbook's touch path from ink-claiming hitTest to layout-box hits, and no golden or test pins the mounted-op set, so nothing would catch a regression there. Suggest: extend pocket_mod::Guest::frame_with_touches with the hits array (every UiSurface host inherits it), or land as-is with the limitation stated and a pocketbook-pinning test.
  2. core_host.rs duplicates the crate it sits next to. core_host.rs:123-186 re-implements UiSurface::feed_pak (surface.rs:148-209); rd_u16/decode_pix_header are copied verbatim (core_host.rs:71-81 vs surface.rs:536-549 — private there, so the PR copied instead of exporting); the svcPoll batching loop is line-for-line (core_host.rs:426-454 vs surface.rs:478-496). It also silently drops 13 ops the sidecar mode has — hitTest/setCursor/setCursorPos (so cursor mode is unreachable in external-guest mode, framework/src/input.ts:744), the five devtools ops, loadTileTexture/freeTexture/uploadImgEntry, setPropBatch — worth either mounting or documenting as external-guest limitations.
  3. The two modes duplicate each other: with_handle/with_core, error codes, create validation, ~50-line render blocks, post_event, destroy all exist twice inside this one PR (lib.rs:82-402 vs core_host.rs:45-559).
  4. The incremental-raster + Err→full-frame fallback is now the repo's fifth copy (pocketbook framebuffer.rs:74-96, symbian lib.rs:577-641, wasm lib.rs:353-366, plus both new files). Symbian's copy counts planning failures; these two discard that signal. A pocketjs_core::raster helper would absorb all five.
  5. build-xcframework.sh is the only .sh file in the repository — the repo rule is Bun TypeScript wrappers (docs/STRUCTURE.md:37-39; the follow-up feat(ios): pocket ios — transitional ios-dev target, NativeScript shell, play on the simulator #256 wraps the script rather than porting it, tools/ios.ts:28).
  6. Placement: docs/STRUCTURE.md:53-54 (added in feat(ios): pocket ios — transitional ios-dev target, NativeScript shell, play on the simulator #256) says "a new platform embedding → hosts/<platform>/", and the engine/ member list doesn't name apple/. engine/symbian — the precedent for a device C-ABI staticlib — is a standalone excluded crate with its own lock and CI step. Worth either moving or documenting the exception.
  7. Smaller rot: apps/nsengine/channel.ts is a third copy of the svc connect/parse shape (apps/note/svc.ts:73-96, apps/ipod-nano/svc.ts:153-176) and skips the svcOpen gate, so it pushes into a channel the host may have refused; PocketSurfaceView.m:243-245 silently clamps x/y to 511 with no ≤511 guard at pocket_apple_create (pocketbook documents and enforces this, hosts/pocketbook/src/main.rs:41-47) — latent until a >512-logical viewport, i.e. exactly this platform; "latched" collides with the existing framework/src/frame.ts:35-46 term for the opposite mechanism; pocket_apple_abi_version() has no caller; PocketSurfaceView.m:352 hardcodes 0x8080 (fourth spelling of ANALOG_CENTER); include/pocket_apple.h:21 hand-mirrors POCKET_APPLE_MAX_DAMAGE_REGIONS 8 while Rust derives it — the repo generates such constants (contracts/spec/gen-rust.ts); externalSurfaceWithLogicalWidth: boots a full QuickJS realm only to destroy it (PocketSurfaceView.m:41-52).
  8. Coverage: neither new .rs file has a test, bun run test has no cargo stage, and no PR-triggered workflow builds engine/ — the render_hero determinism claim is a manual result with no regression gate.

Verified clean, for the record: op-42 registry consistency; touch encoding; damage-plan arithmetic (full redraw can't be skipped, damage.rs:180-188); frame-boundary discipline (effects drain post-tick, post_event queues to the next svcPoll); set_identity/load_pak gating vs mount-time publication; import discipline in apps/nsengine (framework vs solid-js split per repo rules); no second QuickJS binding (rquickjs 0.12 is already the workspace binding; the git-pinned libquickjs-sys stays in the psp/vita workspaces).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants