# Embedded app

> C embed ABI, host view controllers, mobile canvas libraries, and experimental iOS/Android host shims around the desktop-first runtime.

- Repository: vercel-labs/native
- GitHub: https://github.com/vercel-labs/native
- Human docs: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms-full.txt

## Source Files

- `docs/src/app/embed/layout.tsx`
- `src/embed/c_api.zig`
- `src/embed/host.zig`
- `src/tooling/embedlib.zig`
- `examples/mobile-canvas/build.zig`
- `examples/ios/README.md`

---

---
title: "Embedded app"
description: "C embed ABI, host view controllers, mobile canvas libraries, and experimental iOS/Android host shims around the desktop-first runtime."
---

`EmbeddedApp` (`src/embed/host.zig`) wraps a `Runtime` and a `runtime.App` so a host owns the event loop: each method dispatches a platform event (`app_start`, `app_activated`, `surface_resized`, `frame_requested`, `app_shutdown`, and GPU-surface input) without the desktop platform run loop. Mobile and in-process hosts link a static library that exports the `native_sdk_app_*` C ABI (`src/embed/c_api.zig`) answered by either the fixed WebView shell (`MobileHostApp`) or a canvas `UiApp` host (`UiAppHost` via `addMobileLib`).

<Warning>
Mobile support is experimental, including embedding. iOS simulator and Android emulator paths are real and verified; APIs and tooling may still change. Desktop remains the mature surface.
</Warning>

## Architecture

Two product shapes share one lifecycle and one C symbol set:

| Shape | Library root | Host type | Presentation |
| --- | --- | --- | --- |
| Fixed WebView shell | `src/embed/c_exports.zig` (`zig build lib`) | `MobileHostApp` | Host owns UIKit/Android chrome + `WKWebView` / Android `WebView` workspace |
| Canvas `UiApp` | `src/embed/app_exports.zig` (`native_sdk.addMobileLib`) | `UiAppHost(AppDef)` | Host blits CPU-rendered RGBA8 pixels from `native_sdk_app_render_pixels` / `_damage` |

```mermaid
flowchart TB
  subgraph Host["Host process (Swift / ObjC / Kotlin / C)"]
    VC["View controller / Activity / NativeActivity"]
    Surface["WKWebView or CAMetalLayer / ANativeWindow"]
  end

  subgraph Lib["Static library lib*.a"]
    CABI["native_sdk_app_* C ABI\nMobileCApi(Host)"]
    EA["EmbeddedApp\nRuntime + App"]
    Mode{"scene option"}
    Web["MobileHostApp\nWebView shell"]
    Canvas["UiAppHost(AppDef)\ngpu_surface mobile-surface"]
  end

  VC -->|"create/start/viewport/frame\ntouch/command/stop"| CABI
  CABI --> EA
  EA --> Mode
  Mode -->|webview / c_exports| Web
  Mode -->|canvas / app_exports| Canvas
  Canvas -->|"render_pixels / damage"| Surface
  Web -->|"load source / bridge"| Surface
```

Canonical mobile surface label: `mobile-surface` (`types.mobile_gpu_surface_label`). Canvas apps must place a `gpu_surface` with that label in window 1 and set `Options.canvas_label` to the same string (`ui_host.mobile_shell_scene` is the default single-surface scene).

## Zig: EmbeddedApp

```zig
var embedded = native_sdk.embed.EmbeddedApp.init(my_app.app(), my_platform);

try embedded.start();       // .app_start
try embedded.activate();    // .app_activated
try embedded.deactivate();  // .app_deactivated
try embedded.resize(surface); // .surface_resized (+ .gpu_surface_resized when native_handle != null)
try embedded.frame();       // .frame_requested
try embedded.stop();        // .app_shutdown
```

| Method | Dispatched event / behavior |
| --- | --- |
| `init` / `initInPlace` | Build `Runtime` with the given `Platform` |
| `start` | `.app_start` |
| `activate` / `deactivate` | `.app_activated` / `.app_deactivated` |
| `resize` | `.surface_resized`; if `surface.native_handle != null`, also `.gpu_surface_resized` for window 1 / `mobile-surface` |
| `touch` / `scroll` / `key` / `text` / `ime` | `.gpu_surface_input` on window 1 / `mobile-surface` |
| `frame` | `.frame_requested` |
| `command` | `dispatchCommand` with `source = .native_view`, window 1, view label `mobile-header` |
| `stop` | `.app_shutdown` |
| `widgetSemantics` / `widgetTextGeometry` / `widgetAction` | Canvas accessibility snapshot and actions on the mobile surface |
| `textInputState` | Focus/IME intent for system keyboard show/hide |
| `audioEvent` | Platform `.audio` event from shim player reports |
| `gpuFrameState` / `canvasRevisions` | GPU frame diagnostics and revision gating for present |

Hermetic tests use `NullPlatform`:

```zig
var null_platform = native_sdk.NullPlatform.init(.{});
var embedded = native_sdk.embed.EmbeddedApp.init(.{
    .context = &state,
    .name = "embedded",
    .source = native_sdk.WebViewSource.html("<p>Embedded</p>"),
}, null_platform.platform());
try embedded.start();
```

## C ABI: native_sdk_app_*

`MobileCApi(Host)` generates the full export set; `exportMobileCApi(Host)` `@export`s every symbol under its canonical name. Every embed static library must export the names in `mobile_export_symbol_names` (`build/app.zig`).

Opaque handle: `void *` from `native_sdk_app_create()` (null on failure). Errors are recorded with `recordError`; hosts read `native_sdk_app_last_error_name`. Success flags on query APIs return `1` / `0`.

### Lifecycle

| C function | Host timing (typical) |
| --- | --- |
| `native_sdk_app_create` | Controller/activity create |
| `native_sdk_app_set_asset_root` / `set_asset_entry` | Before start (packaged frontend) |
| `native_sdk_app_set_text_measure` / `set_audio_service` / `set_image_service` | Before start (optional platform services) |
| `native_sdk_app_set_automation_dir` | Before/at start for on-device automation |
| `native_sdk_app_start` | After configuration |
| `native_sdk_app_activate` / `deactivate` | Scene active / resign; `onResume` / `onPause` |
| `native_sdk_app_viewport` or `resize` | Layout / surface size change |
| `native_sdk_app_frame` | Display link / choreographer / test tick |
| `native_sdk_app_stop` then `destroy` | Teardown |

`native_sdk_app_viewport` publishes safe-area (and host form-factor / tabs-projected flags) through the window-chrome channel, then resizes with safe + keyboard insets. Prefer it over bare `resize` on mobile so layout sees notch, home indicator, and keyboard clearance.

### Input and commands

| Function | Notes |
| --- | --- |
| `native_sdk_app_touch` | Phases: `0` down, `1` up, `2` drag, `3` cancel (`NATIVE_SDK_TOUCH_PHASE_*`) |
| `native_sdk_app_scroll` | Wheel/pan deltas in view points |
| `native_sdk_app_key` | Phases: `0` down, `1` up; modifiers bitmask |
| `native_sdk_app_text` | Committed UTF-8 text |
| `native_sdk_app_ime` | Kinds: set / commit / cancel composition |
| `native_sdk_app_command` | Stable command id string (max 128 bytes stored) |
| `native_sdk_app_text_input_state` | `active != 0` ⇒ show system keyboard; widget bounds in view points |

WebView shell examples dispatch header actions as `mobile.back` and `mobile.refresh`. Canvas chrome tabs/actions dispatch their declared shell ids (for example `tabs.counter`, `action.increment` in `examples/mobile-canvas`).

### Presentation and semantics (canvas)

| Function | Behavior |
| --- | --- |
| `native_sdk_app_gpu_frame_state` | Status, revisions, budget counters |
| `native_sdk_app_render_pixel_size` | RGBA8 byte length for a scale (`scale <= 0` → 1) |
| `native_sdk_app_render_pixels` | Full CPU reference render into caller buffer |
| `native_sdk_app_render_pixels_damage` | Incremental copy of dirty region when host retains the buffer; falls back to full render |
| `native_sdk_app_widget_semantics_*` | Accessibility snapshot nodes |
| `native_sdk_app_widget_text_geometry` | Caret/selection/composition bounds |
| `native_sdk_app_widget_action` | Focus, press, text, composition, and related actions |

`UiAppHost.frame` synthesizes the `gpu_surface_frame` a desktop display link would deliver, then runs the runtime frame. Pixel present rides the dirty-scissored path used by software desktop hosts.

### Platform services (register before start)

| Function | Role |
| --- | --- |
| `native_sdk_app_set_text_measure` | Host typographic widths; null keeps deterministic estimator |
| `native_sdk_app_set_audio_service` | Playback (+ optional `load_url` streaming); partial tables fail with `InvalidCommand` |
| `native_sdk_app_audio_event` | Async player reports: kind `0` loaded, `1` position, `2` completed, `3` failed — call between ABI entry points, not from inside a service callback |
| `native_sdk_app_set_image_service` | Decode for `fx.registerImageBytes`; without it, `UnsupportedService` / widget fallbacks |
| `native_sdk_app_set_automation_dir` | Write `snapshot.txt` and consume `command-<n>.txt` under an absolute container path |

Without audio registration, hosts set `audio_playback` / `audio_streaming` false so `fx.playAudio` fails honestly instead of using `NullPlatform`'s hermetic fake player.

### Declared platform chrome

Apps declare tabs and an optional primary action in shell metadata (`ShellConfig.chrome` / `mobileOptions().scene.chrome`). Projecting hosts (toolkit iOS host) query:

| Function | Purpose |
| --- | --- |
| `native_sdk_app_chrome_tab_count` / `_tab_at` | Declared tabs (`MobileChromeItem`: id, label, icon) |
| `native_sdk_app_chrome_primary_action` | Optional FAB-style action; `0` if none |
| `native_sdk_app_chrome_selected_tab` | Model selection index via `selected_tab_fn`, or `-1` |
| `native_sdk_app_chrome_navigation_depth` | `navigation_depth_fn` derivation for push/pop; `-1` if unused |
| `native_sdk_app_chrome_navigation_back_command` | Back command for system edge gesture |
| `native_sdk_app_chrome_icon_pixels` | Icon-vocabulary glyph → white-on-transparent RGBA8 template |
| `native_sdk_app_set_form_factor` | `0` unknown, `1` compact, `2` regular |
| `native_sdk_app_set_chrome_tabs_projected` | Host reports OS bar is live so canvas chrome can yield |

Selection and navigation state live in the model. Taps and completed back gestures re-enter through `native_sdk_app_command` so journals replay without a host. Desktop ignores projection (in-canvas chrome). Android projection of the tab bar is not complete in-tree; WebView shell chrome APIs return empty zeros for the fixed shell.

## Building the embed library

### Default WebView library

From the repository root:

```bash
zig build lib -Dtarget=aarch64-ios
# install path historically: zig-out/lib/libnative-sdk.a
```

Root module: `c_exports.zig` → `exportMobileCApi(MobileHostApp)`.

### App canvas library: addMobileLib

```zig
// examples/mobile-canvas/build.zig
native_sdk.addMobileLib(b, b.dependency("native_sdk", .{}), .{ .name = "mobile-canvas" });
// optional: .main = "src/main.zig", .scene = .canvas | .webview
```

- Canvas scene: root `app_exports.zig`, imports user module as `"app"`, host `UiAppHost(@import("app"))`.
- WebView scene: root `c_exports.zig`; app module not compiled in.
- Android targets force PIC so the archive links into a host `.so`.
- x86_64 Debug uses LLVM to avoid SysV float ABI miscompiles on viewport insets.
- Standard `addApp` / `addAppArtifacts` auto-register the `lib` step when `-Dtarget` is iOS or Android.

App module contract for canvas:

- `pub const Model`, `pub const Msg`
- `pub fn initModel() Model`
- `pub fn mobileOptions() UiApp(Model, Msg).Options` with `canvas_label = "mobile-surface"` and a matching scene
- optional `pub const features: UiAppFeatures`
- optional `on_command`, `selected_tab_fn`, `navigation_depth_fn`, `navigation_back_command`

### Target-keyed staging (CLI hosts)

Toolkit iOS/Android tiers stage archives under `.native/embed/<target-triple>/lib/lib<name>.a` (`src/tooling/embedlib.zig`) so concurrent triples do not clobber each other. Hosts run `requireArchiveClass` before link:

| Expected class | Target family |
| --- | --- |
| `macho` | iOS / Apple |
| `elf_aarch64` | Android arm64 |

Mismatch prints the staged path, found class, and rebuild line (`zig build lib -Dtarget=...`) and returns `error.EmbedLibraryMismatch` — avoiding silent ELF-into-Mach-O / Mach-O-into-ELF link failures.

## Host contract by platform event

| Host event | ABI | Example |
| --- | --- | --- |
| Create + start | `create` → configure → `start` | Swift `viewDidLoad`; Android `onCreate` |
| Activate | `activate` / `deactivate` | `SceneDelegate`; `onResume` / `onPause` |
| Size / safe area / keyboard | `viewport` then `frame` | `viewDidLayoutSubviews`; `surfaceChanged` / content rect |
| Touch | `touch` / `scroll` then `frame` | Canvas shims; Android `MotionEvent` |
| Native chrome actions | `command` | `mobile.back` / `mobile.refresh` or declared tab ids |
| Shutdown | `stop` → `destroy` | deinit / `onDestroy` |

UIKit and Android views own safe areas, orientation, system Back, and keyboard avoidance. The runtime receives metrics and commands; package-time shell metadata describes header/workspace structure. Dynamic runtime native-view mutation remains a desktop-oriented API.

## Examples

| Path | Role |
| --- | --- |
| `examples/ios` | Xcode + Swift `UIViewController`, UIKit header, `WKWebView`, `native_sdk.h`, links `libnative-sdk.a` |
| `examples/android` | Gradle/Kotlin + JNI to the same C ABI |
| `examples/mobile-canvas` | Minimal canvas `UiApp` + `addMobileLib`; hand shims under `ios/` and `android/` |
| `examples/ui-inbox` (with `-Dmobile=true`) | Larger canvas app through the same shims |

### iOS WebView shell (`examples/ios`)

```bash
zig build lib -Dtarget=aarch64-ios
mkdir -p examples/ios/Libraries
cp zig-out/lib/libnative-sdk.a examples/ios/Libraries/libnative-sdk.a
open examples/ios/NativeSdkIOSExample.xcodeproj
```

`NativeSdkDyldShim.c` supplies `_dyld_get_image_header_containing_address` via `dladdr` for panic symbolication on iOS.

### Canvas shims (`examples/mobile-canvas`)

**iOS** (`ios/run.sh`): cross-compile `zig build lib -Dtarget=aarch64-ios-simulator`, link ObjC `main.m` (CADisplayLink, CAMetalLayer blit from RGBA→BGRA, CoreText measure, UITextInput IME). Verify with screenshot non-blank; `verify_input.sh` / `verify_layout.sh` drive XCUITest + automation snapshots.

**Android** (`android/run.sh`): `zig build lib -Dtarget=aarch64-linux-android`, NativeActivity `main.c` + NDK clang → `libnative_sdk_shim.so`, aapt2/apksigner APK (no Gradle). AChoreographer pumps frames; ANativeWindow presents RGBA8 without swizzle. Soft keyboard / IME deliberately unwired (needs Java `InputConnection`); text measure uses estimator unless a host registers a callback. `minSdkVersion` 29 for TLSDESC / choreographer APIs.

Shared header for canvas shims: `examples/mobile-canvas/ios/native_sdk_app.h` (subset aligned with `src/embed/types.zig`).

### Toolkit-owned mobile apps

`native dev --target ios|android` and `native package --target ios|android` build the app embed library and wrap the SDK host (not the hand examples). Same ABI; toolkit host adds audio/image registration, full IME, and package generation. Frames still use the CPU reference renderer on mobile today; markup hot reload does not yet reach simulators/devices.

## Limits and constraints

| Limit | Value / rule |
| --- | --- |
| Command name storage | 128 bytes |
| Input text buffers | 512 bytes |
| Surface identity | Window id `1`, label `mobile-surface` |
| Command source view | `mobile-header` for embed `command` path |
| Android PIC | Required for embed objects |
| Audio without service | Declined (no silent fake player) |
| Image without service | `UnsupportedService` / fallbacks |
| Chrome projection | Model is source of truth; host is read-only projector |
| Cross-target archive | Must match object format (embedlib gate) |

## Troubleshooting

| Symptom | Check |
| --- | --- |
| `native_sdk_app_create` returns null | Host `create()` failed (invalid canvas scene / `canvas_label`, OOM) |
| Corrupt safe-area / keyboard floats on x86_64 emulator | Ensure embed lib built with LLVM workaround path for Debug |
| Host loads but symbols missing / crash on first call | Wrong triple archive; rebuild with matching `-Dtarget`; Android: confirm `elf_aarch64` not Mach-O |
| Blank surface | Call `viewport`/`resize` with non-zero size before `frame`; for canvas, present when revision changes via `render_pixels` / damage |
| Keyboard never shows (canvas) | Poll `text_input_state` after input; Android minimal NativeActivity has no IME |
| Audio “works” but no sound | Register complete audio service before `start` |
| Tab bar never appears | Host must project chrome; declaration alone is inert on non-projecting hosts |
| Automation empty | `set_automation_dir` to writable absolute path; enable host automation flag (`NATIVE_SDK_AUTOMATION` / debug property as documented by the shim) |

## Related pages

<CardGroup>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Desktop multi-window composition and GPU surfaces vs the single mobile surface contract.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and experimental mobile maturity notes.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, command queue, and agent surface; mobile uses set_automation_dir.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds and platform service seams the embed audio/image bridges implement.
  </Card>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Shared command routing used by native chrome and embed native_sdk_app_command.
  </Card>
  <Card title="App model" href="/app-model">
    Model / Msg / update loop driven by UiAppHost for canvas embed libraries.
  </Card>
</CardGroup>
