ReviewOS

also looking at this

craft-native/craft

Zig-native mobile: iOS compiles, serves a bridge call on a simulator, and is gated against drift

#83
Merged glennmichael123 wants to merge feat/mobile-zig-native into main
28 files +2460 -6557
.gitignoremodified+3-0
Changes to .gitignore
@@ -46,3 +46,6 @@ pantry
4646# Signing material: the private key and CSR for the Developer ID certificate.
4747# Never committed — the key here is what signs releases.
4848.signing/
49
50# Phase 1 iOS slice fixture build output
51packages/ios/fixtures/zig-slice/build/
docs/android-development.mdadded+503-0
Changes to docs/android-development.md
@@ -0,0 +1,503 @@
1# Android Development
2
3Making Android a Zig-native platform: Zig reaches Android's Java APIs through JNI, the way
4`packages/zig/src/macos.zig` reaches AppKit through `objc_msgSend`.
5
6This document is the working plan. It is meant to be checked off, and the phase gates are
7meant to be run.
8
9---
10
11## Where Android actually stands
12
13**`zig build build-android` exits 0 and ships nothing.**
14
15```console
16$ ls -l zig-out/lib/libcraft-android-arm64.a
17-rw-r--r-- 2990 bytes
18
19$ ar t zig-out/lib/libcraft-android-arm64.a
20/ ← symbol table (empty)
21// ← string table
22/0 ← one .o with no exported symbols
23
24$ nm zig-out/lib/libcraft-android-arm64.a | grep -c ' T '
250
26```
27
28An empty archive is a legal archive, so the build is green.
29
30**Root cause:** `build.zig:1807-1819` builds a static library rooted at `src/android.zig`,
31which has **zero `export fn`**. Zig only emits code reachable from an export — `pub` is
32visibility, not linkage.
33
34Because nothing is reachable, `mobile.zig` is never analysed at all, so its `comptime`
35`@export` block at `mobile.zig:165-170` never runs, silently dropping the only two `Java_*`
36symbols in the codebase. And even if they had survived, they bind to
37`app.craft.CraftValueCallback` and `app.craft.CraftActivity` — **classes that exist nowhere
38in this repo and that no template could generate.**
39
40Compare iOS, which gets this right: `src/ios_main.zig` has 8 `export fn craft_ios_*` and
41`build.zig:1590` roots the library there, not at `ios.zig`. **Android has no equivalent
42`android_main.zig`.**
43
44### What does ship
45
46`packages/android/templates/CraftBridge.kt.template` — 3,880 lines of Kotlin with **103**
47`@JavascriptInterface` methods — plus a genuinely functional builder
48(`packages/android/src/index.ts`, 599 lines) that runs `gradle wrapper`, `./gradlew`,
49`adb install -r`, and `adb shell am start -n`.
50
51Good news for the JNI plan: the Kotlin has **no coroutines and no Compose**
52(`suspend fun` count: 0). Everything it does is plain Java-API work that JNI can reach.
53
54### The bridge never connects
55
56Three verified mismatches:
57
58| | TypeScript expects | Native provides |
59|---|---|---|
60| interface name | `window.CraftBridge` | `addJavascriptInterface(craftBridge, "CraftAndroid")` |
61| envelope | `{method, params, callbackId}` | 103 typed methods, no `postMessage` at all |
62| ready event | `craft:ready` | `craftReady` |
63
64So `window.CraftBridge.postMessage(...)` is a `TypeError` on every call.
65
66---
67
68## Four blockers to clear before anything works
69
70### 1. Android currently links GTK
71
72`bridge.zig:7` switches on `builtin.os.tag`. Android's target is
73`.os_tag = .linux, .abi = .android` (`build.zig:1801-1804`), so the `.linux` arm catches it
74and routes `evalJS` into `linux.zig` — which holds **44** `extern "c"` GTK/WebKit2GTK
75declarations.
76
77Undefined symbols cost nothing in a *static archive*, which is precisely why this has never
78surfaced. **The moment we emit a `.so`, the linker demands `gtk_init` and 43 friends.**
79
80The `.linux` arm needs an `.abi == .android` guard, landed **in the same commit** as the
81linkage change. `build.zig:1940-1942`'s `linkPlatformLibraries` has the same landmine —
82its `.linux` arm links `gtk+-3.0` and `webkit2gtk-4.1`, and the Android libs must never
83start calling it without the same guard.
84
85### 2. `` makes mangled `Java_*` symbols impossible
86
87`MainActivity.kt.template:1` is `package `, substituted with the user's
88chosen id. JNI symbol mangling embeds the package, so a fixed export name cannot exist.
89
90**Fix, in two parts:**
91
921. **Put the native-binding class in a package Craft owns.** A new
93 `CraftNative.kt.template` declaring `package app.craft.runtime` — no substitution at all.
94 Gradle compiles multiple packages in one module without complaint, and
95 `applicationId`/`namespace` are unaffected. Now every native-facing class name is a
96 compile-time constant on the Zig side.
97
982. **Bind via `JNI_OnLoad` + `RegisterNatives`, exporting zero `Java_*` symbols.**
99
100Why `RegisterNatives` rather than mangled names, now that the package is fixed:
101
102- **It fails loudly at load.** A missing binding throws `UnsatisfiedLinkError` during
103 `onCreate`, naming the method. Mangled-name binding fails at the first call of that one
104 method, months later.
105- **The table is enumerable Zig data**, so a conformance test can assert bijection with the
106 Kotlin `external fun` declarations. Mangled names give you no artifact to check.
107- **It survives R8.** `build.gradle.kts.app.template:22-28` sets `isMinifyEnabled = true`
108 for release and `proguard-rules.pro.template` is 9 lines with no keep rule. Add
109 `-keep class app.craft.runtime.CraftNative { *; }`.
110
111### 3. The JNI vtable in the tree is mis-ordered
112
113`mobile.zig:25-40` declares `JNINativeInterface` with `reserved0..3`, then jumps straight to
114`GetVersion, FindClass, GetMethodID, …` with a literal comment at line 31:
115
116```zig
117// ... many more function pointers ...
118```
119
120The real vtable has ~230 slots in a fixed ABI order: `FindClass` is index 6,
121`GetObjectClass` is 31, `NewStringUTF` is 167. **Every function pointer read from this
122struct reads the wrong slot.** Even if it linked, every call would jump to a garbage
123address.
124
125There is also a *second*, incompatible `jni` namespace at `android.zig:20-30` — opaque
126aliases with no vtable at all.
127
128### 4. Nothing here has ever been compiled
129
130`mobile.zig:11` reads `const jni = if (target.os.tag == .linux) struct {...} else struct {};`
131so on a macOS host `jni` is an **empty struct** and `jni.JNIEnv` does not exist.
132
133Proof that no function body has ever been analysed: `mobile.zig:1292` has
134`if (callback) |cb|` where the parameter is the non-optional `*const fn (bool) void` — an
135unconditional compile error that has never fired.
136
137Other never-executed bugs in the same region: `requestPermission` (`:1275-1298`) resolves
138the **static** `ActivityCompat.requestPermissions` via `GetMethodID` and passes 2 varargs
139where the signature demands 3 with a `jobjectArray`; `createWebView` (`:1103-1106`) and
140`evaluateJavaScript` (`:1198-1206`) invoke constructors through `CallObjectMethod` where
141`NewObject` is required.
142
143---
144
145## Architecture
146
147```
148page ── craft.getPlatform()
149 │ craft-mobile.js → _req('device', 'getPlatform', d)
150 ▼
151 window.CraftBridge.postMessage(JSON.stringify({t, a, d, i}))
152 │ [JavaBridge thread]
153 ▼
154 CraftNative.postMessage → nativeHandleMessage (RegisterNatives)
155 │
156 ▼
157 android_dispatch.zig ── routes on `t`
158 │
159 ├── Zig arm → android_jni.zig → Java APIs
160 └── host arm → CraftNative.callHost(...) (Phase A6 escape hatch)
161 │
162 ▼
163 format reply ON the JavaBridge thread ← request_context is threadlocal
164 │
165 ▼
166 CraftNative.evaluateOnUi(script) → webView.post { evaluateJavascript }
167```
168
169### The threading constraint iOS doesn't have
170
171`@JavascriptInterface` methods run on the WebView's **JavaBridge** thread.
172`WebView.evaluateJavascript` **must** be called on the UI thread or it throws.
173
174And `request_context.zig` is `threadlocal` — a frame pushed on JavaBridge is invisible from
175the UI thread.
176
177**Therefore: format the reply on the JavaBridge thread, where the context frame lives, then
178hand the finished string across.** Three lines of Kotlin:
179
180```kotlin
181fun evaluateOnUi(script: String) = webView.post {
182 webView.evaluateJavascript(script, null)
183}
184```
185
186Do not reply from the UI thread. This removes an entire crash class and is worth the Kotlin.
187
188---
189
190## Decision: `@cImport` the NDK's `jni.h`
191
192**Do not hand-write the vtable. Do not vendor a copy of `jni.h`.**
193
194- **A wrong slot index compiles, links, and jumps to garbage at runtime.** `mobile.zig:25-40`
195 is the existence proof. There is no compiler check for "is `FindClass` the 7th field?",
196 the failure is silent, and it is silent *on a phone*. A 230-slot transcription is 230
197 chances to make that mistake, and reviewing it is unreviewable.
198- **Cross-compiling from macOS is already solved here.** `build.zig:9` takes `-Dmacos-sdk`
199 and `applySdkPaths` (`:1963-1976`) feeds it through `addSystemIncludePath`. Its docstring
200 is directly on point about avoiding `--sysroot` because it breaks `@cImport`. Mirror it
201 exactly as `-Dandroid-ndk`. `jni.h` is pure C — `stdarg.h` + `stdint.h` — with no Bionic
202 dependency, so translate-c reads it on a macOS host.
203- **Reproducibility comes from pinning the NDK**, which CI already does
204 (`mobile-e2e.yml:164-166` pins `ndk;26.1.10909125`). Pin it in `deps.yaml`/`pantry.jsonc`
205 beside the Zig pin.
206
207### Use only the `*A` call forms
208
209`jni.h` declares ~60 slots three times: `CallVoidMethod(...)` (C varargs),
210`CallVoidMethodV(va_list)`, and `CallVoidMethodA(const jvalue*)`.
211
212**Use `*A` exclusively.** They are non-variadic and ABI-exact, which sidesteps the pinned
213Zig version's one real hazard with variadic function-pointer fields. As a bonus, they
214structurally kill the constructor bug class: `NewObjectA` cannot be spelled as
215`CallObjectMethodA`.
216
217### The facade earns its keep
218
219`src/android_jni.zig` wraps the `@cImport` and exposes `callVoid(env, obj, mid, args)`-style
220helpers that:
221
2221. always use `*A`,
2232. call `ExceptionCheck`/`ExceptionDescribe`/`ExceptionClear` after **every** call — a
224 pending JNI exception makes the *next* JNI call abort the process, and nothing in the
225 current code checks even once,
2263. confine `@cImport` to one file, so nothing else becomes un-host-testable and a future
227 NDK change touches exactly one place.
228
229### The ABI test the hand-rolled version could never have
230
231Gated on `-Dandroid-ndk` being present:
232
233```zig
234@offsetOf(c.JNINativeInterface, "FindClass") / @sizeOf(usize) == 6
235@offsetOf(c.JNINativeInterface, "GetObjectClass") / @sizeOf(usize) == 31
236@offsetOf(c.JNINativeInterface, "NewStringUTF") / @sizeOf(usize) == 167
237@offsetOf(c.JNINativeInterface, "RegisterNatives") / @sizeOf(usize) == 215
238@sizeOf(c.JNINativeInterface) / @sizeOf(usize) >= 229 // non-vacuity floor
239```
240
241That last line matters: without it, a truncated or empty import passes. Same idiom as
242`capabilities_test.zig:92`.
243
244---
245
246## Phase A0 — Demolition
247
248Delete rather than fix. Every one of these is unreachable or actively misleading, and
249leaving them means the next reader can't tell new code from fossil.
250
251| Target | Lines | Why |
252|---|---:|---|
253| `src/android_template.zig` | 553 | Zero importers; a second Gradle/Kotlin generator superseded by `packages/android/templates/` |
254| `src/js_bridge.zig` | 635 | Zero importers (the 13 grep hits are struct *field* names, not imports) |
255| `test/android_test.zig` | 373 | Not wired into `build.zig` at all; uses the removed pre-0.15 `ArrayList.init(allocator)` at `:323`, so it wouldn't compile if it were |
256| `mobile.zig:11-110` — the `jni` namespace | 100 | Mis-ordered vtable; not fixable in place |
257| `mobile.zig:162-206` — `@export` block + both `Java_*_impl` | 45 | Bind to classes that don't exist |
258| `mobile.zig:1028-1377` — `pub const Android` | 350 | Every call reads the broken vtable; contains the static-method, constructor, and non-optional-callback bugs |
259| `mobile.zig:115-160` — `AndroidCallbackStorage` | 46 | A fixed 16-slot `% 16` table that silently aliases the 17th concurrent callback onto the 1st |
260| `android.zig:20-30` — the second `jni` namespace | 11 | Opaque aliases, no vtable |
261| `android.zig:333-412` — nine "handlers" | 80 | `handleShowToast` extracts the message, discards it, and replies `{"success":true}`. Worse than a missing handler |
262| `android.zig:419-493` — `AndroidFeatures` | 75 | Ten stubs; `isNetworkConnected()` hardcodes `true` |
263| `android.zig:260-295` — substring JSON | 36 | Breaks on any escaped quote; `std.json` is the house pattern |
264| `examples/android/main.zig` + its `build.zig` step | ~40 | Builds a *desktop* Cocoa/GTK executable and calls it the Android demo |
265
266**Keep:** `android.zig`'s `StringHashMap(Handler)` dispatcher shape (`:202-258`) as the seed
267for `android_dispatch.zig`, and `packages/android/templates/test-bridges.html` as the
268Phase A1 fixture page.
269
270**Gate:** `zig build && zig build test` green; `git grep -c 'jni\.' packages/zig/src` = 0.
271
272---
273
274## Phase A1 — Vertical slice: `device.getPlatform` on an emulator
275
276### Why this action
277
278It **cannot be faked past its own assertion**. CI compares the value the *page* printed
279against `adb shell getprop ro.build.version.sdk` and `ro.product.model`. The current stub at
280`android.zig:336` returns a hardcoded `{"os":"android","version":"14"}` and would fail.
281
282It also exercises the full vtable surface in one call — `FindClass("android/os/Build$VERSION")`
283→ `GetStaticFieldID` → `GetStaticIntField`, plus `GetStringUTFChars`/`ReleaseStringUTFChars`.
284If the imported slots were wrong, this crashes rather than lying.
285
286`showToast` was the obvious alternative and is the wrong choice: asserting a Toast from CI
287means OCR on a screenshot.
288
289### What lands
290
291**New Zig files:**
292
293| File | Contents |
294|---|---|
295| `src/android_jni.zig` | `@cImport` of `jni.h`, `*A`-only wrappers, `ExceptionCheck` on every path, `JavaVM*` cache, `attachCurrentThread()` |
296| `src/android_main.zig` | **The export root.** `JNI_OnLoad`, the `JNINativeMethod` table, `nativeOnCreate`, `nativeHandleMessage`, `nativeOnDestroy` |
297| `src/android_dispatch.zig` | `std.json` parse of `{t,a,d,i}`, `request_context` push/pop, route on `t`, reply via `bridge_error.sendResultToJS` |
298| `src/android_log.zig` | `__android_log_write` wrapper + a `std.log` `logFn` override so existing sites land in logcat |
299| `src/android_device.zig` | The `device` namespace with one action |
300
301**Zig edits:**
302
303- `bridge.zig:6-21` — an `.android` arm **before** `.linux`, guarded on
304 `builtin.abi == .android`. Without this, the `.so` link fails on GTK.
305- `build.zig:1807-1848` — root at `android_main.zig`; `.linkage = .dynamic`; name
306 `craft_android`; add `-Dandroid-ndk` and `applyAndroidNdkPaths` modelled on
307 `applySdkPaths`; `linkSystemLibrary("log")`; `-z max-page-size=16384`.
308- `src/js/craft-bridge.js:161-175` — `_post` gains a transport switch:
309 `webkit.messageHandlers.craft` → `window.CraftBridge` → warn. **Shared with iOS.**
310
311**Kotlin:**
312
313- New `CraftNative.kt.template` in `package app.craft.runtime`: `System.loadLibrary("craft_android")`,
314 `@JavascriptInterface fun postMessage(json: String)`, `fun evaluateOnUi(script: String)`,
315 and the `external fun` declarations.
316- `MainActivity.kt.template:100` — `"CraftAndroid"` → `"CraftBridge"`, fixing the name
317 mismatch against `core.ts:723`.
318- Install the bridge script via `WebViewCompat.addDocumentStartJavaScript`
319 (`androidx.webkit:webkit:1.12.1` is already a dependency, and `minSdk` is 26) rather than
320 at `onPageFinished` (`:70-73`), which any page calling `craft.*` at parse time loses today.
321 Dispatch **`craft:ready`**; emit the legacy `craftReady` alongside it for one release.
322
323`CraftBridge.kt` is **still generated and still registered** in Phase A1. The slice must not
324regress any existing app.
325
326**TypeScript / packaging:**
327
328- `index.ts` — `init()` writes `app/src/main/java/app/craft/runtime/CraftNative.kt`; a new
329 `syncNativeLibs()` copies the `.so` into `jniLibs/{arm64-v8a,x86_64}/`; **`init()` (not
330 `build()`) materializes the Gradle wrapper** — today `:341` writes
331 `gradle-wrapper.properties` but never `gradlew` itself.
332- `build.gradle.kts.app.template` — `ndk { abiFilters += listOf("arm64-v8a", "x86_64") }`.
333- `proguard-rules.pro.template` — the keep rule.
334
335### Gate
336
337```bash
338zig build build-android-x86 -Dandroid-ndk=$ANDROID_NDK_HOME
339llvm-nm -D --defined-only …/libcraft_android.so | grep -q ' T JNI_OnLoad'
340llvm-nm -D --undefined-only …/libcraft_android.so | grep -c 'gtk_\|webkit_' # must be 0
341llvm-readelf -l …/libcraft_android.so | grep LOAD | grep -q 0x4000 # 16KiB, Android 15+
342craft android init E2E --output /tmp/e2e && craft android build --output /tmp/e2e
343unzip -l …/app-debug.apk | grep -q 'lib/x86_64/libcraft_android.so'
344adb install -r … && adb shell am start -n …/.MainActivity
345adb logcat -d | grep CRAFT_RT > out
346test "$(jq -r .sdkInt <out)" = "$(adb shell getprop ro.build.version.sdk)"
347test "$(jq -r .model <out)" = "$(adb shell getprop ro.product.model)"
348```
349
350`WebChromeClient` is already set (`MainActivity.kt.template:97`) and forwards `console.log`
351to logcat.
352
353### Deferred
354
355102 of the 103 Kotlin methods (Kotlin keeps serving them); permissions; `onActivityResult`;
356event emission; arm64 exercise (builds, not tested); release/R8 builds;
357`AttachCurrentThread`; `PushLocalFrame`; the capability gate (A2).
358
359### Risks
360
361| Risk | Detection | Mitigation |
362|---|---|---|
363| **GTK link failure** — the likeliest blocker | Fires at the first `.so` link | Land the `bridge.zig` guard *before* flipping linkage; assert 0 undefined `gtk_` symbols |
364| `FindClass` returns null in `JNI_OnLoad` | `UnsatisfiedLinkError` at load | Fall back to one mangled `Java_app_craft_runtime_CraftNative_nativeBoot` called from Kotlin's static init, receiving `jclass` directly |
365| Replying off the UI thread | Silent dropped reply | The `evaluateOnUi` shim; detect via zero `E/chromium` lines |
366| 16 KiB page alignment | `.so` refuses to load on Android 15+ | `llvm-readelf -l` assertion above |
367| ABI directory naming | Zig emits `x86_64`/`aarch64`; jniLibs wants `x86_64`/`arm64-v8a`. A mismatch silently ships an APK with no native lib | The `unzip -l` assertion |
368
369---
370
371## Phases A2–A6
372
373| Phase | Scope | Gate |
374|---|---|---|
375| **A2** | Extract `macos.zig:4564-4645` into a shared `mobile_dispatch.zig` (iOS gets it free). Generalize `capabilities_test.zig`, which hardcodes desktop `@embedFile`s. Add the `RegisterNatives` ↔ Kotlin `external fun` parity test | `zig build test` fails on any drift |
376| **A3** | **Tier 0** (~20): pure JNI, no permissions, no async — `getDeviceInfo`, `getNetworkStatus`, `log`, `openURL`, `share`, `clipboard*`, `vibrate`, `haptic`, `setBadge`, `setKeepAwake`, `lockOrientation`, `setFlashlight` | `adb shell` ground truth per action (`cmd clipboard`, `dumpsys deviceidle`) |
377| **A4** | **Tier 1** (~25): state, storage, callbacks. First background threads → `AttachCurrentThread`. **`dbExecute`/`dbQuery` use the vendored SQLite** already compiled into every other target (`build.zig:1953-1959`), not JNI to `SQLiteDatabase` | Round-trip through the bridge + `dumpsys jobscheduler` |
378| **A5** | **Tier 2** (~30): permissions and Activity results | `pm grant`/`revoke`; **the denied path must reply with an error, not hang** |
379| **A6** | **Tier 3** (~28): Play Billing, ML Kit, Firebase, Health Connect, ARCore, Wear | Kotlin `@JavascriptInterface` ratchet reaches its floor |
380
381**A3 adds `PushLocalFrame`/`PopLocalFrame`** per handler, before `Intent`/`Uri` construction
382turns into a reference leak nobody can find.
383
384**A5 lands the two mechanisms as their own sub-phase before any action uses them.** Two new
385`RegisterNatives` entries (`nativeOnPermissionResult`, `nativeOnActivityResult`) fed from
386`MainActivity.kt.template:220-224`'s existing hook. Correlation needs a real
387`AutoHashMap(i32, PendingRequest)`, not a `% 16` array.
388
389`ActivityCompat.requestPermissions` is **static** (`GetStaticMethodID`,
390`CallStaticVoidMethodA`) and takes **three** args with a `jobjectArray` built by
391`NewObjectArray` + `SetObjectArrayElement`. `mobile.zig:1275-1298` gets both wrong and is
392the best worked example of what not to do.
393
394### A6: one declared escape hatch, not a JNI slog
395
396BillingClient, ML Kit, Play Review, Firebase, Health Connect, and ARCore are listener-heavy,
397generic-heavy builder APIs. Reaching them from JNI means hand-writing anonymous inner
398classes, which JNI cannot do at all without a Java shim. There is no Zig-purity win
399available; there is only a large, fragile, unreviewable surface.
400
401Instead: Zig calls `CraftNative.callHost(namespace, action, dataJson, requestId)`, and
402Kotlin answers through the **same** `nativeDeliverResult(requestId, json)` every other path
403uses. Zig still owns dispatch, correlation, capability truth, and the wire format. Kotlin
404becomes a leaf.
405
406Add `host_delegated` as a fifth `NamespaceStatus` beside `declared`, `undeclared`,
407`unavailable`, and `unrouted` (`capabilities.zig:64-73`), so `craft.capabilities()` tells
408apps honestly which surfaces are native-Zig and which are host-delegated. That is the same
409ethos as the existing "a namespace craft hasn't audited says so", extended one step.
410
411Only then delete the superseded Kotlin — expected residue 25–30 methods, down from 103.
412
413---
414
415## The conformance gate
416
417`max_undeclared = 0` for Android, **permanently**. Desktop's ratchet sits at 45
418(`capabilities_test.zig:57`) only because it inherited 52 unaudited namespaces. Android
419starts from an empty tree and has nothing to grandfather, so there is no reason to ever
420permit an undeclared Android namespace. This is the cheapest place to hold the line, and it
421is only cheap *now*.
422
423Three gates:
424
4251. **`test/capabilities_android_test.zig`** — every declared action dispatches; every
426 dispatched action is declared; a declared namespace contains no raw literal action
427 comparison. With a non-vacuity floor so a regex matching nothing fails.
4282. **`RegisterNatives` ↔ `CraftNative.kt` parity** — embed the template, scan `external fun`,
429 assert bijection with the Zig table including JNI signature strings. This is what makes
430 `RegisterNatives` worth choosing over mangled names.
4313. **Kotlin-surface ratchet** — embed `CraftBridge.kt.template`, count `@JavascriptInterface`
432 (103 today), assert `<= max_kotlin_javascript_interface`, and lower the constant each
433 phase. The spec becomes a countdown with a build-enforced direction.
434
435---
436
437## CI
438
439### `ci.yml` — every PR, no emulator, ~2 min
440
441After `zig build test` on the Linux leg:
442
443```bash
444sdkmanager --install "ndk;26.1.10909125"
445zig build build-android-all -Dandroid-ndk=$ANDROID_NDK_HOME -Doptimize=ReleaseSafe
446llvm-nm -D --defined-only …/libcraft_android.so | grep -q ' T JNI_OnLoad'
447llvm-nm -D --undefined-only …/libcraft_android.so | grep -c 'gtk_\|webkit_' | grep -q '^0$'
448llvm-readelf -l …/libcraft_android.so | grep LOAD | grep -q 0x4000
449test "$(stat -c%s …/libcraft_android.so)" -gt 100000
450```
451
452**That size floor is the direct answer to the 2,990-byte archive: an empty artifact must
453fail the build.** The `gtk_` check is the standing guard on the `bridge.zig` regression.
454
455### `mobile-e2e.yml` — currently proves nothing
456
457The Android job boots an AVD, takes a screenshot, and asserts nothing, because its only test
458step is behind `if [ -x "packages/android/gradlew" ]` — a path that **can never exist**,
459since projects are generated into a user-chosen `--output` directory.
460
461- Restore `pull_request` triggers on `packages/zig/src/android_*.zig`, `packages/android/**`, `build.zig`
462- Delete the `gradlew` guard
463- Replace the emulator script with the Phase A1 chain
464- Keep screenshots as **artifacts**, not tests
465
466`release.yml` is not touched.
467
468### `packages/android/src/index.ts`
469
4701. `init()` materializes the Gradle wrapper and fails loudly there — a project is either
471 complete or absent
4722. `syncNativeLibs()` copies the `.so` per ABI
4733. Writes `CraftNative.kt` with **no** `` substitution
4744. `build()` fails when the `.so` is missing, rather than producing an APK that
475 `UnsatisfiedLinkError`s at launch
4765. `index.test.ts` asserts `jniLibs/`, `gradlew`, `CraftNative.kt`, and the `"CraftBridge"`
477 registration
478
479Per `CLAUDE.md`'s distribution rule, the `.so` resolution lives here in the Android builder
480— a legitimate dev-loop override — not in `binary-resolver.ts`.
481
482---
483
484## Prerequisites
485
486| | Needed | Status |
487|---|---|---|
488| Phase A0 | pinned Zig only | ✅ works today |
489| Phase A1+ | Android SDK, NDK (pinned `26.1.10909125`), `adb`, `gradle` | ❌ none present |
490
491Zig is pinned at `0.17.0-dev.1509+bb296ab9b`.
492
493---
494
495## Honest accounting
496
497Android is further from working than iOS: iOS has nine concrete compile errors, while
498Android has no JNI layer at all. But Android has one advantage — everything it needs to
499reach is plain Java API, with no Kotlin-only surface in the way.
500
501The `host_delegated` escape hatch in A6 exists so the last 28 actions don't become an
502open-ended JNI slog. And `CraftBridge.kt` keeps serving every action Zig hasn't taken over,
503so no phase ships a broken app.
docs/ios-development.mdadded+400-0
Changes to docs/ios-development.md
@@ -0,0 +1,400 @@
1# iOS Development
2
3Making iOS a Zig-native platform: Zig drives UIKit through `objc_msgSend`, exactly as
4`packages/zig/src/macos.zig` drives AppKit today.
5
6This document is the working plan. It is meant to be checked off, and the phase gates are
7meant to be run.
8
9---
10
11## Where iOS actually stands
12
13Three facts, each verified, that together explain why "iOS is broken" has been true for
14months without anyone being able to point at a single cause.
15
16**1. The Zig iOS sources have not compiled since 2026-05-07.** The Zig 0.17 migration
17(`1b6f285`) broke them; on 2026-08-18 the mobile E2E workflow was made
18`workflow_dispatch`-only (`e1bc449`) rather than repaired. `zig build build-ios-simulator`
19fails with 9 errors.
20
21**2. Nothing links the Zig library anyway.** There are no references to `craft_ios_*`,
22`@_silgen_name`, or `libcraft` anywhere in `packages/ios/`. The XcodeGen spec
23(`templates/project.yml.template`) sets no `LIBRARY_SEARCH_PATHS` and names no `.a`. Even a
24green Zig build would be dead weight.
25
26**3. What actually ships is Swift.** `packages/ios/templates/CraftApp.swift` is 5,169 lines
27with **106** dispatcher actions and a properly registered `WKScriptMessageHandler`. It
28works. `craft ios init/build/open/run` shells out to `xcodegen`, `xcodebuild`, and `simctl`,
29and those work too.
30
31So the goal is not "repair the Zig build." It is to move the native layer from Swift to Zig
32without ever having a non-working app in between.
33
34### The spec has already drifted inside one file
35
36`CraftApp.swift`'s injected JavaScript calls five `ota*` actions — `otaConfigure`,
37`otaCheckForUpdate`, `otaDownloadUpdate`, `otaApplyUpdate`, `otaRollback` — that its own
38`switch` handles **zero** times. Those promises never settle; they don't even time out,
39because the OTA methods bypass the `_createCallback` path that owns the timeout.
40
41A further 33 handled actions are unreachable from the injected JS.
42
43That is one file disagreeing with itself. The conformance gate in Phase 2 exists to make
44this class of bug impossible rather than merely fixed.
45
46---
47
48## Counting actions correctly
49
50The dispatcher region (`CraftApp.swift:516-1078`) holds exactly **106** distinct actions.
51
52A naive `grep 'case "'` over the whole file returns **139**, because sub-switches use string
53cases for haptic styles, permission names, orientations, health types, and AR primitives.
54
55**Any scanner must bound itself to the dispatcher region**, or it will fail forever on
56`case "box"`.
57
58---
59
60## Architecture
61
62```text
63page ── craft.getDeviceInfo()
64 │ craft-mobile.js → _req('mobile', 'getDeviceInfo', d)
65 ▼
66 window.webkit.messageHandlers.craft.postMessage({t, a, d, i})
67 │
68 ▼
69 WKScriptMessageHandler (Zig, registered at runtime)
70 │
71 ▼
72 ios_dispatch.zig ── routes on `t`
73 │
74 ├── Zig arm → bridge_*.zig → sendResultToJS
75 └── Swift arm → CraftSwiftShim (shrinks each phase)
76```
77
78### One envelope: `{t, a, d, i}`
79
80There are currently **five** incompatible bridge envelopes in the repo. iOS adopts the
81desktop one.
82
83Reply correlation is the hardest part of a bridge, and the desktop implementation is
84already written and tested — dual pending tables, per-call timeout, and a `_forget` so a
85late reply cannot settle someone else's call (`craft-bridge.js:203-233`), with
86`formatResultJS` stamping the request id from `request_context`
87(`bridge_error.zig:221-241`).
88
89Adopting Swift's envelope instead would mean rewriting all of that, including its reply
90escaping at `CraftApp.swift:2447`, which replaces only `'` — so any payload containing a
91backslash or a newline breaks out of the JS string literal. `bridge_error.escapeJsonString`
92already handles this correctly.
93
94Compatibility for page code written against `window.craft.getDeviceInfo()` comes from a JS
95shim, **not** from a second wire protocol. Shipping a second reply protocol "temporarily"
96is exactly how five accumulated.
97
98### What we get for free
99
100- `objc_runtime.zig` is already iOS-ready — its gate is
101 `is_darwin = .macos or .ios or .tvos or .watchos` (`objc_runtime.zig:6`).
102- `macos.zig:5071` `setupScriptMessageHandler` and `macos.zig:5017` `didReceiveScriptMessage`
103 use only the ObjC runtime, Foundation, and WebKit — **zero AppKit** — and register under
104 the name `"craft"`, which is what the page already posts to.
105- `craft-bridge.js:161-166` already builds `{t,a,d,i}` and posts to
106 `webkit.messageHandlers.craft`. iOS *is* WKWebView, so **the page side needs no change**.
107- Adding a `.ios` arm to `bridge.evalJS` (`bridge.zig:6-22`, currently
108 `error.UnsupportedPlatform`) lights up every existing `bridge_*.zig` reply path. Six lines.
109- ~20 of the 25 frameworks `CraftApp.swift` imports are Objective-C and reachable via
110 `objc_msgSend`.
111
112---
113
114## Phase 0 — Amputate, then gate
115
116**Needs no Xcode.** All of it works on Command Line Tools.
117
118Most of the nine reported errors live in code that should not exist, so deletion comes
119before repair.
120
121### Delete
122
123| Target | Lines | Why |
124|---|---:|---|
125| `src/js_bridge.zig` | 635 | Zero importers |
126| `src/ios_template.zig` | 633 | Zero importers; superseded by `packages/ios/templates/` |
127| `src/hotreload_mobile.zig` | 271 | Zero importers; won't compile under 0.17 |
128| `packages/typescript/src/bridge/ios.ts` | 1,004 | A fourth envelope, zero importers, not exported |
129| `ios.zig:556-846` — the `JSBridge` struct | ~290 | Substring JSON parsing (`extractJsonObject`'s unguarded `depth -= 1` underflows a `usize`); nine handlers calling four functions that don't exist; `handleMessage` has no caller anywhere |
130| `ios.zig:479-489` — `runMainLoop` | 11 | `UIApplicationMain` owns the run loop |
131| `ios.zig:199-263` — `injectBridgeScript` | 65 | Third envelope; injected before the navigation that wipes it; its fallback resolves `{success:true, browser:true}`, making "works" and "no bridge" indistinguishable |
132| `mobile.zig:972-1024` — `showAlert` | 53 | Uses `keyWindow`, nil in any scene-based app; computes a delay and discards it |
133| `mobile.zig:797-884` — `requestPermission` | 88 | `_ = callback;` at `:803` — discards the caller's callback and passes `null` for every completion handler |
134| `mobile.zig:667-795` — `checkPermissionStatus` + mappers | 129 | Only caller is the deleted `JSBridge` |
135| `ios_main.zig:211-223` — the export test block | 13 | The source of errors 1–3; asserts nothing a compiler wouldn't |
136
137**~3,200 lines**, removing six of the nine errors and three of the five `??*anyopaque`
138sites without writing a replacement.
139
140### Then fix what remains
141
142- `objc_runtime.zig:182,193` — `target.isDarwin()` → `target.os.tag.isDarwin()` (pre-0.17 API)
143- `objc_runtime.zig` — add `pub fn alloc(class: Class) !id` beside `allocInit`. `ios.zig:283`
144 needs the alloc half only, because `:284-286` sends `initWithFrame:` itself
145- `ios.zig:438` — `?objc.id` → `objc.id` in `LoadHTMLFn`
146- `mobile.zig:475-477` — `trackObject` takes `*anyopaque`; unwrap or skip when null
147- `mobile.zig:484` — `@ptrCast` cannot discard optionality; add the null check
148
149### The gate
150
151New `packages/zig/test/ios_surface_test.zig`, wired into `test_step` like `mobile_tests`:
152
153```zig
154test "every iOS decl compiles" {
155 std.testing.refAllDeclsRecursive(@import("../src/ios.zig"));
156 std.testing.refAllDeclsRecursive(@import("../src/mobile.zig").iOS);
157}
158```
159
160This builds for the **host** in seconds. `mobile_test.zig` is green today only because it
161touches enums and structs and Zig analyses function bodies lazily — this forces analysis,
162and every one of the nine errors plus the five latent ones becomes a host-visible compile
163error. Cross-compiling stays the source of truth; this is the loop to iterate in.
164
165**Gate:** `zig build test` passes, and passes *only after* the fixes — verify by reverting
166one and watching it fail. `zig build build-ios-simulator` and `zig build build-ios` both
167compile.
168
169---
170
171## Phase 1 — Vertical slice: `getDeviceInfo` on a simulator
172
173**Needs Xcode.**
174
175One action, round-tripped end to end, observed from inside the Zig process.
176
177### Why this action
178
179- It is a **real spec action** (`CraftApp.swift:647`), so nothing is thrown away later.
180- It reads UIKit through `objc_msgSend` — `[[UIDevice currentDevice] systemName]`,
181 `[[UIScreen mainScreen] bounds]` — proving the runtime is alive in-process.
182- Its result is **unforgeable**: `"systemName":"iOS"` and `"isSimulator":true` cannot come
183 from a fallback path. Compare `ios.zig:239-244`, whose fallback returns `{success:true}`
184 and looks exactly like success.
185
186Rejected, and worth recording why: `haptic` is fire-and-forget *and* a silent no-op on the
187simulator, so it would pass while doing nothing. `clipboardRead` triggers iOS 16+'s "Allow
188Paste?" prompt — a flake generator in CI. `showAlert` needs someone to dismiss it.
189
190### What lands
191
1921. **`UIApplicationMain` restructure.** `run()` shrinks to: register a `CraftAppDelegate`
193 class at runtime, then call `UIApplicationMain` — which never returns, so `run()` becomes
194 `noreturn`. Everything currently at `ios.zig:145-172` moves into
195 `export fn didFinishLaunching(...) callconv(.c) bool`, added under
196 `application:didFinishLaunchingWithOptions:` with type encoding `"B@:@@"`.
197
198 This is forced, not stylistic: `[UIScreen mainScreen]` returns nil before UIKit is
199 initialised, so `createWindow`'s `initWithFrame:` currently receives a garbage `CGRect`,
200 and `makeKeyAndVisible` traps with no `UIApplication` instance.
201
2022. **A real `WKScriptMessageHandler`** — port `macos.zig:5071-5122` and `:5017-5066`
203 structurally unchanged, including the `NSJSONSerialization` step. Do not write an ObjC
204 object walker; do not resurrect substring parsing.
205
2063. **User script at `atDocumentStart`** — port `macos.zig:4037-4049`.
207
208 **The order inverts.** `addScriptMessageHandler:name:` and `addUserScript:` both go on
209 the `WKUserContentController`, which must be attached to the `WKWebViewConfiguration`
210 *before* the webview is constructed. Today `ios.zig:154` injects before `:157` navigates,
211 so the navigation wipes the script.
212
2134. **`.ios` arm on `bridge.evalJS`**, plus `ios.evalJS` mirroring `macos.zig:4485-4496`.
214
2155. **A correct completion block.** `mobile.zig:555-646` is wrong four ways: `??*anyopaque`
216 parameters; a `Block.callback` signature mismatch against the function's own parameter;
217 a **stack-allocated** block handed to an asynchronous API; and a function-local
218 descriptor that dangles even if the block were copied, since `_Block_copy` copies the
219 block body but keeps the descriptor *pointer*.
220
221 Rewrite to `bridge_permissions.zig:131-164`'s shape: module-level `const` block and
222 descriptor, plain `objc.id` parameters, `extern var _NSConcreteStackBlock` with `&`.
223
2246. **`src/bridge_mobile.zig`** with exactly one action, plus a `t == "mobile"` arm in a new
225 `ios_dispatch.zig`.
226
2277. **`packages/ios/fixtures/zig-slice/`** — checked in. `main.m` is a three-line shim so
228 `_main` lives in the app target rather than the static archive.
229
230### Gate
231
232A **closed** loop, not a half loop: page posts → Zig serves → Zig replies → **Zig then
233evaluates `window.__craftSliceAck` through the block from step 5** and prints
234`CRAFT_SLICE_ACK <json>` to stderr. That final hop is what forces the block to be correct
235in Phase 1, where it belongs, since Phases 4–6 are built on it.
236
237`packages/ios/src/slice.test.ts` reuses the existing `pickSimulator` and `bootSimulator`
238(`index.ts:632,665`) and asserts `"systemName":"iOS"` and `"isSimulator":true` within 90s.
239It **skips rather than fails** when `xcode-select -p` reports Command Line Tools, so the
240repo stays green on machines without Xcode.
241
242Belt and braces: also write the ack into the app container and poll
243`simctl get_app_container`, since `--console-pipe` can drop stderr on a cold boot.
244
245### Deferred
246
247`craft-bridge.js` injection; the Swift shim; error replies; main-thread marshalling; config
248plumbing (`AppConfig.orientations`, `status_bar_style` stay inert fields); and the other 105
249actions — `ios_dispatch` answers `UNKNOWN_ACTION` for all of them, in one place.
250
251### Risks
252
253| Risk | Detection | Mitigation |
254|---|---|---|
255| Dead-strip removes `craft_ios_main` | Link error | It's referenced from `main.m`; else `-force_load` |
256| Zig `bool` ≠ ObjC `BOOL` on arm64 | App launches to black, no crash | `BOOL` is `signed char`; if it fails, return `i8` `1` |
257| `_NSConcreteStackBlock` unresolved | Link error | In libSystem; proven on macOS by `bridge_permissions.zig` |
258| `--console-pipe` drops stderr | Timeout, no output | The app-container fallback above |
259
260---
261
262## Phase 2 — Dispatcher, JS surface, and the hand-off
263
264`ios_dispatch.zig` becomes a real dispatcher: namespace routing, `sendErrorToJS` for unknown
265actions, `request_context` push/pop around every dispatch. Note `macos.zig:4646-4665` — the
266desktop bug where `handleMessage` discarded `d`. Do not repeat it.
267
268**The hand-off table is what makes everything after this incremental.** On an action it does
269not serve, `ios_dispatch` looks up `objc_getClass("CraftSwiftShim")` and sends
270`handleAction:payload:callbackId:`. One app, one message handler, one page-visible surface.
271Each later phase moves actions from the Swift arm to the Zig arm and the page notices
272nothing.
273
274`src/js/craft-mobile.js` — deliberately **not** `craft-bridge.js`, which defines
275`craft.window.*`, `craft.tray.*`, and `craft.menu.*` that iOS has no business exposing (a
276page calling `craft.tray.create()` on iOS should get a clear error, not silence). Extract
277the shared transport into `craft-bridge-core.js` and `@embedFile` it into both, so there is
278one transport rather than two.
279
280Also here: the Swift shim's reply helpers get rewritten to call `__craftBridgeResult`,
281which incidentally fixes the `'`-only escaping at `CraftApp.swift:2447`.
282
283---
284
285## Phases 3–9
286
287| Phase | Adds | Gate |
288|---|---|---|
289| **3** | +24 — no permission, no UI, no delegate: `log`, `clipboardRead/Write`, `haptic`, `openURL`, `setBadge`, `getSafeArea`, `setKeepAwake`… | A driver calling all 24, asserting 24/24 **and a floor** so a driver that finds nothing fails |
290| **4** | +10 — permissions and Keychain | **Two concurrent** permission requests resolve to the *right* promises |
291| **5** | +12 — presented UI, on a shared `ios_delegate.zig` class factory | XCUITest cancels each presenter; the rejection must reach JS |
292| **6** | +19 — long-lived subscriptions, `craft:*` events | `simctl location set 37.33,-122.03` → `craft:locationUpdate` with those coordinates |
293| **7** | +15 — contacts, calendar, notifications, health, db | Needs the SQLite fix below; watch `binary-size.yml` |
294| **8** | Swift 5,169 → ~600 lines | Conformance intersection empty |
295| **9** | +3 Vision. **AR stays Swift**, declared with a `reason` | Registry declares the boundary honestly |
296
297**Phase 3 fixes `getSafeArea`**, which currently queries the *window* (`ios.zig:498`) and
298therefore returns zeros. It needs the root view's `safeAreaInsets`.
299
300**Phase 3 also fixes `HapticType`.** `mobile.zig:887` has the seven real tags
301(`selection`, `impact_*`, `notification_*`); `ios.zig:756-761`'s six names were fiction.
302
303**Phase 4 is the highest-risk phase after Phase 1**, because it is the first time a reply is
304produced *outside* the dispatch that requested it. `request_context.current()` is a
305per-dispatch stack, so by the time an `AVCaptureDevice` completion block fires it is empty,
306`formatResultJS` stamps `null`, and correlation silently falls back to action name — which
307hands caller B's answer to caller A whenever two are in flight.
308
309This phase must land a **pending-request table** keyed by the `i` captured at dispatch. Do
310Keychain first (`SecItemAdd` is plain C with no blocks) to keep the two risks separate.
311
312**Phase 7 requires a build change.** `linkPlatformLibraries` adds `vendor/sqlite/sqlite3.c`
313(`build.zig:1955`) but is never called for any iOS target — the calls are at
314`:84,128,150,328,798,1519,1543,1566`, and the iOS targets are `:1586-1648`. `dbExecute` and
315`dbQuery` cannot work until SQLite is linked in.
316
317**Phase 9 recommendation: keep ARKit and SceneKit in Swift permanently.** `CraftApp.swift:4996-5054`
318is SceneKit node-graph manipulation; through `objc_msgSend` it would be miserable and
319low-value. Zig-native is a means, not an end, and five actions of SceneKit glue is where it
320stops paying. Declare the boundary in `capability_registry.zig` with a `reason`.
321
322### What stays Swift, permanently
323
324Thirteen actions have no usable Objective-C surface:
325
326- **StoreKit 2** — `getProducts`, `purchase`, `restorePurchases` (`Product.PurchaseResult` is Swift-only)
327- **ActivityKit** — `startLiveActivity`, `updateLiveActivity`, `endLiveActivity`
328- **WidgetKit** — `updateWidget`, `reloadWidgets`
329- **App Intents** — `registerSiriShortcut`, `removeSiriShortcut`
330- **WatchConnectivity** — `sendToWatch`, `updateWatchContext`, `isWatchReachable` (`WCSession`
331 *is* ObjC, but it is only useful paired with the Watch app template, so it stays with its peer)
332
333---
334
335## The conformance gate
336
337`packages/zig/test/ios_conformance_test.zig`, wired like `capability_conformance_tests`
338(`build.zig:786-819`). It embeds four sources and asserts four directions:
339
340| Assertion | Catches |
341|---|---|
342| **spec ⊆ (zig ∪ shim ∪ not_yet)** | An action silently dropped in migration. `not_yet` is explicit, with a **ratchet that may only go down** — like `max_undeclared` at `capabilities_test.zig:57`. Starts at 105 after Phase 1 |
343| **zig ∩ shim = ∅** | Two servers for one action, where the page gets whichever raced |
344| **js ⊆ (zig ∪ shim)**, no allow-list | The `ota*` class — a JS method whose promise nothing can settle. `CraftApp.swift` fails this **five times today** |
345| **(zig ∪ shim) ⊆ js** modulo `internal_only` | The 33 actions reachable only by hand-writing `postMessage` |
346
347Use `window_lifecycle_test.zig:52-65`'s `callsFunction` helper so a commented-out
348`// case "haptic":` doesn't count as an implementation — that is exactly the failure mode a
349substring scan invites.
350
351Bound the spec scan to the dispatcher region, and assert a floor, so a refactor that renames
352the function makes the scan find zero and **fail** rather than pass.
353
354### Extend it to TypeScript
355
356`api/mobile.ts:169` calls `craft.device.getInfo()` — a `craft.<ns>.<method>` shape no native
357side has ever provided. `packages/react/src/index.ts:6` declares `os_version` where Swift
358returns `systemVersion`.
359
360A bun test asserting every `craft.*` method the TS types promise actually exists in
361`craft-mobile.js` closes the third leg. Without it, Zig↔Swift is pinned and the TypeScript
362contract drifts on its own — which is how this happened in the first place.
363
364---
365
366## CI
367
368`mobile-e2e.yml` today boots a simulator and asserts nothing: both meaningful steps are
369gated on `hashFiles('packages/ios/TestApp/TestApp.xcodeproj')`, which has never matched.
370
371- Restore `pull_request` triggers for `packages/zig/src/ios*.zig`, `packages/zig/src/mobile.zig`,
372 `packages/ios/**`
373- Fix the two path filters the header comment flags as pointing at directories that don't exist
374- Replace the `hashFiles` guards with the checked-in fixture
375- Keep screenshots as **artifacts**, not tests
376
377`release.yml` is not touched. The Apple signing gate stays exactly as it is.
378
379---
380
381## Prerequisites
382
383| | Needed | Status |
384|---|---|---|
385| Phase 0 | nothing beyond the pinned Zig | ✅ works today on Command Line Tools |
386| Phase 1+ | full Xcode — SDK, simulator runtime | ❌ install required |
387
388Zig is pinned at `0.17.0-dev.1509+bb296ab9b`. Clear `.zig-cache` between heavy rounds.
389
390---
391
392## Honest accounting
393
394This replaces working Swift with Zig. The payoff — one implementation instead of three,
395enforced by a compiler and a conformance test rather than by convention — does not arrive
396until Phase 8.
397
398The hand-off table exists so that every phase before then ships a **working app**, not a
399half-migrated one. If this effort stalls at any point, the app still runs: Swift keeps
400serving everything Zig hasn't taken over yet.
packages/ios/fixtures/zig-slice/Info.plistadded+16-0
Changes to packages/ios/fixtures/zig-slice/Info.plist
@@ -0,0 +1,16 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleExecutable</key><string>CraftSlice</string>
6 <key>CFBundleIdentifier</key><string>app.craft.slice</string>
7 <key>CFBundleName</key><string>CraftSlice</string>
8 <key>CFBundlePackageType</key><string>APPL</string>
9 <key>CFBundleShortVersionString</key><string>1.0</string>
10 <key>CFBundleVersion</key><string>1</string>
11 <key>LSRequiresIPhoneOS</key><true/>
12 <key>UILaunchScreen</key><dict/>
13 <key>UISupportedInterfaceOrientations</key>
14 <array><string>UIInterfaceOrientationPortrait</string></array>
15</dict>
16</plist>
packages/ios/fixtures/zig-slice/build-and-run.shadded+88-0
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -0,0 +1,88 @@
1#!/usr/bin/env bash
2# Build the Phase 1 fixture, install it on a simulator, and assert the round trip.
3#
4# Deliberately no Xcode project. `xcodegen` is an extra dependency for a
5# four-file app, and a hand-written .pbxproj is worse. clang can build an app
6# bundle directly, and doing it here keeps every step visible.
7set -euo pipefail
8
9ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
10ZIG="${CRAFT_ZIG:-$HOME/.cache/craft-ci-local/zig-aarch64-macos-0.17.0-dev.1509+bb296ab9b/zig}"
11FIXTURE="$ROOT/packages/ios/fixtures/zig-slice"
12OUT="${1:-$FIXTURE/build}"
13BUNDLE_ID="app.craft.slice"
14
15ARCH="$(uname -m)"
16if [ "$ARCH" = "arm64" ]; then LIB="libcraft-ios-simulator-arm64.a"; TRIPLE="arm64-apple-ios15.0-simulator"
17else LIB="libcraft-ios-simulator-x64.a"; TRIPLE="x86_64-apple-ios15.0-simulator"; fi
18
19echo "==> building the craft iOS simulator library"
20(cd "$ROOT/packages/zig" && "$ZIG" build build-ios-simulator -Doptimize=ReleaseSafe)
21
22SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
23APP="$OUT/CraftSlice.app"
24rm -rf "$OUT"; mkdir -p "$APP"
25
26echo "==> linking CraftSlice"
27clang -isysroot "$SDK" -target "$TRIPLE" \
28 -fobjc-arc -O1 \
29 "$FIXTURE/main.m" "$FIXTURE/page.m" \
30 "$ROOT/packages/zig/zig-out/lib/$LIB" \
31 -framework UIKit -framework WebKit -framework Foundation \
32 -o "$APP/CraftSlice"
33
34cp "$FIXTURE/Info.plist" "$APP/Info.plist"
35
36echo "==> booting a simulator"
37UDID="$(xcrun simctl list devices available -j \
38 | python3 -c 'import json,sys;d=json.load(sys.stdin)["devices"];print(next(x["udid"] for k in d for x in d[k] if "iPhone" in x["name"]))')"
39xcrun simctl boot "$UDID" 2>/dev/null || true
40xcrun simctl bootstatus "$UDID" -b
41
42echo "==> installing and launching"
43xcrun simctl uninstall "$UDID" "$BUNDLE_ID" 2>/dev/null || true
44xcrun simctl install "$UDID" "$APP"
45
46LOG="$OUT/console.log"
47xcrun simctl launch --console-pty "$UDID" "$BUNDLE_ID" > "$LOG" 2>&1 &
48LAUNCH_PID=$!
49
50# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
51# fixed amount, so a slow boot does not read as a failure.
52for _ in $(seq 1 60); do
53 if grep -q 'i=2' "$LOG" 2>/dev/null && grep -q 'i=3' "$LOG" 2>/dev/null; then sleep 1; break; fi
54 sleep 1
55done
56kill "$LAUNCH_PID" 2>/dev/null || true
57
58echo "==> console"
59cat "$LOG" || true
60
61echo
62echo "==> assertions"
63
64# Strip the log colouring before matching, so a grep failure means the line is
65# absent rather than differently decorated.
66PLAIN="$OUT/console.plain"
67sed -e $'s/\x1b\[[0-9;]*m//g' -e 's/\r$//' "$LOG" > "$PLAIN"
68
69count() { grep -c "craft-bridge dispatch t=mobile a=getDeviceInfo i=$1" "$PLAIN" || true; }
70
71C1="$(count 1)"; C2="$(count 2)"; C3="$(count 3)"
72
73[ "$C1" -ge 1 ] || { echo "FAIL: the page's call never reached the dispatcher"; exit 1; }
74echo "ok: page -> native (i=1 seen ${C1}x)"
75
76[ "$C2" -ge 1 ] || { echo "FAIL: the page never got a usable reply back"; exit 1; }
77echo "ok: native -> page, round trip closed (i=2 seen ${C2}x)"
78
79# The round-trip confirmation is sent once, from a guarded branch. More than one
80# means the reply re-entered the handler that sent it — which would make the
81# assertion above pass on its first iteration no matter what followed.
82[ "$C2" -eq 1 ] || { echo "FAIL: round-trip confirmation fired $C2 times; the fixture is looping"; exit 1; }
83echo "ok: no feedback loop"
84
85[ "$C3" -ge 1 ] || { echo "FAIL: the user script had not run when the page executed"; exit 1; }
86echo "ok: user script ran at document start"
87
88echo "PASS"
packages/ios/fixtures/zig-slice/main.madded+18-0
Changes to packages/ios/fixtures/zig-slice/main.m
@@ -0,0 +1,18 @@
1// Entry point for the Phase 1 vertical-slice fixture.
2//
3// `_main` lives here rather than in the Zig static library on purpose: a
4// static archive only contributes the objects the linker is asked for, and a
5// `main` buried in one is exactly the symbol a linker will not go looking for.
6// Three lines of Objective-C is a smaller price than `-force_load`.
7extern int craft_ios_init(void);
8extern void craft_ios_set_html(const char *html, unsigned long len);
9extern int craft_ios_main(int argc, char **argv);
10
11extern const char craft_slice_page[];
12extern const unsigned long craft_slice_page_len;
13
14int main(int argc, char **argv) {
15 craft_ios_init();
16 craft_ios_set_html(craft_slice_page, craft_slice_page_len);
17 return craft_ios_main(argc, argv);
18}
packages/ios/fixtures/zig-slice/page.madded+47-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -0,0 +1,47 @@
1// The fixture page, as a C string so the fixture needs no bundle resources.
2//
3// Signals are carried by *request id*, because ids are already logged by the
4// dispatcher and need no extra native code to observe. Each is sent at most
5// once — an earlier version of this page re-sent its confirmation from inside
6// the reply handler that the confirmation's own reply then re-entered, which
7// span forever and made the assertion pass on its first iteration regardless.
8//
9// i=1 the page called native
10// i=2 native's reply came back carrying a real UIKit systemName
11// i=3 the user script had already run when the page's own script executed
12//
13// i=2 is the one that matters. It cannot be produced by a stub, a browser
14// fallback, or native talking to itself: it exists only if the page received
15// and understood an answer that came from UIKit.
16const char craft_slice_page[] =
17 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
18 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
19 "</head><body><h1 id=\"s\">craft slice</h1><script>\n"
20 "var sentRoundTrip = false;\n"
21 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
22 "\n"
23 "// Read before anything else touches it: the user script is installed at\n"
24 "// atDocumentStart, so it must already have run by the time this inline\n"
25 "// script does. The previous implementation injected after navigation had\n"
26 "// begun, which wiped it — this is the check for that.\n"
27 "if (window.__craftInjectedAtDocumentStart === true && bridge) {\n"
28 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n"
29 "}\n"
30 "\n"
31 "window.__craftBridgeResult = function (action, payload, id) {\n"
32 " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n"
33 " document.getElementById('s').textContent = window.__craftSliceAck;\n"
34 " if (!sentRoundTrip && payload && payload.systemName === 'iOS') {\n"
35 " sentRoundTrip = true;\n"
36 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:2});\n"
37 " }\n"
38 "};\n"
39 "\n"
40 "if (bridge) {\n"
41 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n"
42 "} else {\n"
43 " document.getElementById('s').textContent = 'NO BRIDGE';\n"
44 "}\n"
45 "</script></body></html>";
46
47const unsigned long craft_slice_page_len = sizeof(craft_slice_page) - 1;
packages/ios/templates/CraftApp.swiftmodified+17-25
Changes to packages/ios/templates/CraftApp.swift
@@ -2222,41 +2222,33 @@ struct CraftWebView: UIViewRepresentable {
22222222 _progressCallbacks: [],
22232223 _statusCallbacks: [],
22242224
2225 // OTA is not implemented natively. These five used to post
2226 // to actions the switch below does not handle, and register
2227 // a resolve/reject pair that nothing would ever call — they
2228 // also bypassed _createCallback, which is what owns the 30s
2229 // timeout, so the promises hung forever rather than
2230 // rejecting. An immediate rejection is the honest answer:
2231 // an app can catch it, where it could never catch a hang.
2232 _unavailable: function(name) {
2233 return Promise.reject(new Error(
2234 'craft.ota.' + name + ' is not implemented on this platform'
2235 ));
2236 },
22252237 configure: function(options) {
22262238 this._config = options;
2227 window.webkit.messageHandlers.craft.postMessage({action: 'otaConfigure', config: options});
2239 console.warn('[craft] ota.configure stored locally; OTA is not implemented natively');
22282240 },
22292241 checkForUpdate: function() {
2230 var self = window.craft;
2231 var id = 'cb_' + (++self._callbackId);
2232 window.webkit.messageHandlers.craft.postMessage({action: 'otaCheckForUpdate', callbackId: id});
2233 return new Promise(function(resolve, reject) {
2234 self._callbacks[id] = {resolve: resolve, reject: reject};
2235 });
2242 return window.craft.ota._unavailable('checkForUpdate');
22362243 },
22372244 downloadUpdate: function(options) {
2238 var self = window.craft;
2239 var id = 'cb_' + (++self._callbackId);
2240 window.webkit.messageHandlers.craft.postMessage({action: 'otaDownloadUpdate', options: options || {}, callbackId: id});
2241 return new Promise(function(resolve, reject) {
2242 self._callbacks[id] = {resolve: resolve, reject: reject};
2243 });
2245 return window.craft.ota._unavailable('downloadUpdate');
22442246 },
22452247 applyUpdate: function() {
2246 var self = window.craft;
2247 var id = 'cb_' + (++self._callbackId);
2248 window.webkit.messageHandlers.craft.postMessage({action: 'otaApplyUpdate', callbackId: id});
2249 return new Promise(function(resolve, reject) {
2250 self._callbacks[id] = {resolve: resolve, reject: reject};
2251 });
2248 return window.craft.ota._unavailable('applyUpdate');
22522249 },
22532250 rollback: function() {
2254 var self = window.craft;
2255 var id = 'cb_' + (++self._callbackId);
2256 window.webkit.messageHandlers.craft.postMessage({action: 'otaRollback', callbackId: id});
2257 return new Promise(function(resolve, reject) {
2258 self._callbacks[id] = {resolve: resolve, reject: reject};
2259 });
2251 return window.craft.ota._unavailable('rollback');
22602252 },
22612253 getCurrentBundle: function() {
22622254 // This returns synchronously from stored data
packages/typescript/src/bridge/android.tsdeleted+0-1099
Changes to packages/typescript/src/bridge/android.ts
@@ -1,1099 +0,0 @@
1/**
2 * Craft Android Bridge
3 * Complete Android native bridge with WebView, permissions, and platform APIs
4 */
5
6import { secureId } from './ids.js'
7
8// Types
9export interface AndroidWebViewConfig {
10 url?: string
11 html?: string
12 javaScriptEnabled?: boolean
13 domStorageEnabled?: boolean
14 allowFileAccess?: boolean
15 allowContentAccess?: boolean
16 mediaPlaybackRequiresUserGesture?: boolean
17 supportZoom?: boolean
18 builtInZoomControls?: boolean
19 displayZoomControls?: boolean
20 cacheMode?: 'default' | 'noCache' | 'cacheOnly' | 'cacheElseNetwork'
21 mixedContentMode?: 'never' | 'always' | 'compatibility'
22 safeBrowsingEnabled?: boolean
23}
24
25export interface AndroidPermission {
26 type:
27 | 'camera'
28 | 'microphone'
29 | 'readStorage'
30 | 'writeStorage'
31 | 'location'
32 | 'fineLocation'
33 | 'backgroundLocation'
34 | 'contacts'
35 | 'calendar'
36 | 'phone'
37 | 'sms'
38 | 'bluetooth'
39 | 'bluetoothConnect'
40 | 'bluetoothScan'
41 | 'bluetoothAdvertise'
42 | 'notifications'
43 | 'bodySensors'
44 | 'activityRecognition'
45 | 'nearbyWifiDevices'
46 | 'readMediaImages'
47 | 'readMediaVideo'
48 | 'readMediaAudio'
49 status: 'granted' | 'denied' | 'shouldShowRationale'
50}
51
52export interface AndroidBiometricConfig {
53 title: string
54 subtitle?: string
55 description?: string
56 negativeButtonText: string
57 allowedAuthenticators?: ('biometricStrong' | 'biometricWeak' | 'deviceCredential')[]
58}
59
60export interface AndroidNotificationChannel {
61 id: string
62 name: string
63 description?: string
64 importance: 'none' | 'min' | 'low' | 'default' | 'high' | 'max'
65 enableLights?: boolean
66 lightColor?: string
67 enableVibration?: boolean
68 vibrationPattern?: number[]
69 showBadge?: boolean
70 sound?: string
71 bypassDnd?: boolean
72}
73
74export interface AndroidNotification {
75 channelId: string
76 id: number
77 title: string
78 body: string
79 smallIcon: string
80 largeIcon?: string
81 color?: string
82 autoCancel?: boolean
83 ongoing?: boolean
84 priority?: 'min' | 'low' | 'default' | 'high' | 'max'
85 category?: 'alarm' | 'call' | 'email' | 'error' | 'event' | 'message' | 'progress' | 'promo' | 'recommendation' | 'reminder' | 'service' | 'social' | 'status' | 'system' | 'transport'
86 visibility?: 'public' | 'private' | 'secret'
87 actions?: Array<{ action: string; title: string; icon?: string }>
88 bigTextStyle?: { bigText: string; contentTitle?: string; summaryText?: string }
89 bigPictureStyle?: { picture: string; contentTitle?: string; summaryText?: string; largeIcon?: string }
90 inboxStyle?: { lines: string[]; contentTitle?: string; summaryText?: string }
91}
92
93export interface AndroidMaterialYouColors {
94 primary: string
95 onPrimary: string
96 primaryContainer: string
97 onPrimaryContainer: string
98 secondary: string
99 onSecondary: string
100 secondaryContainer: string
101 onSecondaryContainer: string
102 tertiary: string
103 onTertiary: string
104 tertiaryContainer: string
105 onTertiaryContainer: string
106 error: string
107 onError: string
108 errorContainer: string
109 onErrorContainer: string
110 background: string
111 onBackground: string
112 surface: string
113 onSurface: string
114 surfaceVariant: string
115 onSurfaceVariant: string
116 outline: string
117 outlineVariant: string
118}
119
120export interface AndroidWidget {
121 widgetId: string
122 className: string
123 updatePeriodMillis?: number
124 initialLayout: string
125}
126
127export interface AndroidWorkRequest {
128 id: string
129 workerClass: string
130 inputData?: Record<string, unknown>
131 constraints?: {
132 networkType?: 'connected' | 'unmetered' | 'notRoaming' | 'metered'
133 requiresCharging?: boolean
134 requiresDeviceIdle?: boolean
135 requiresBatteryNotLow?: boolean
136 requiresStorageNotLow?: boolean
137 }
138 backoffPolicy?: { policy: 'linear' | 'exponential'; delay: number }
139 tags?: string[]
140}
141
142// Android WebView Bridge
143export class AndroidWebView {
144 private id: string
145 private config: AndroidWebViewConfig
146
147 constructor(config: AndroidWebViewConfig = {}) {
148 this.id = secureId('webview')
149 this.config = config
150 }
151
152 /**
153 * Create the WebView
154 */
155 async create(): Promise<void> {
156 await this.callNative('createWebView', {
157 id: this.id,
158 config: this.config,
159 })
160 }
161
162 /**
163 * Load a URL
164 */
165 async loadURL(url: string, headers?: Record<string, string>): Promise<void> {
166 await this.callNative('loadURL', { id: this.id, url, headers })
167 }
168
169 /**
170 * Load HTML content
171 */
172 async loadHTML(html: string, baseUrl?: string, mimeType?: string): Promise<void> {
173 await this.callNative('loadHTML', { id: this.id, html, baseUrl, mimeType })
174 }
175
176 /**
177 * Evaluate JavaScript
178 */
179 async evaluateJavaScript<T = unknown>(script: string): Promise<T> {
180 return this.callNative('evaluateJavaScript', { id: this.id, script })
181 }
182
183 /**
184 * Go back in history
185 */
186 async goBack(): Promise<void> {
187 await this.callNative('goBack', { id: this.id })
188 }
189
190 /**
191 * Go forward in history
192 */
193 async goForward(): Promise<void> {
194 await this.callNative('goForward', { id: this.id })
195 }
196
197 /**
198 * Reload the page
199 */
200 async reload(): Promise<void> {
201 await this.callNative('reload', { id: this.id })
202 }
203
204 /**
205 * Stop loading
206 */
207 async stopLoading(): Promise<void> {
208 await this.callNative('stopLoading', { id: this.id })
209 }
210
211 /**
212 * Clear cache
213 */
214 async clearCache(includeDiskFiles = true): Promise<void> {
215 await this.callNative('clearCache', { id: this.id, includeDiskFiles })
216 }
217
218 /**
219 * Destroy the WebView
220 */
221 async destroy(): Promise<void> {
222 await this.callNative('destroyWebView', { id: this.id })
223 }
224
225 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
226 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
227 return new Promise((resolve, reject) => {
228 const callbackId = secureId('cb')
229 ;(window as any)[callbackId] = (result: string) => {
230 delete (window as any)[callbackId]
231 try {
232 const parsed = JSON.parse(result)
233 if (parsed.error) reject(new Error(parsed.error))
234 else resolve(parsed.result)
235 }
236catch {
237 resolve(result as T)
238 }
239 }
240 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
241 })
242 }
243 console.warn(`[AndroidBridge] ${method} called but not in Android WebView context`)
244 return undefined as T
245 }
246}
247
248// Android Permissions
249export class AndroidPermissions {
250 /**
251 * Check permission status
252 */
253 async check(type: AndroidPermission['type']): Promise<AndroidPermission['status']> {
254 return this.callNative('checkPermission', { type })
255 }
256
257 /**
258 * Request single permission
259 */
260 async request(type: AndroidPermission['type']): Promise<AndroidPermission['status']> {
261 return this.callNative('requestPermission', { type })
262 }
263
264 /**
265 * Request multiple permissions
266 */
267 async requestMultiple(types: AndroidPermission['type'][]): Promise<Record<AndroidPermission['type'], AndroidPermission['status']>> {
268 return this.callNative('requestMultiplePermissions', { types })
269 }
270
271 /**
272 * Should show request rationale
273 */
274 async shouldShowRationale(type: AndroidPermission['type']): Promise<boolean> {
275 return this.callNative('shouldShowRequestRationale', { type })
276 }
277
278 /**
279 * Open app settings
280 */
281 async openSettings(): Promise<void> {
282 await this.callNative('openAppSettings', {})
283 }
284
285 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
286 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
287 return new Promise((resolve, reject) => {
288 const callbackId = `cb_${Date.now()}`
289 ;(window as any)[callbackId] = (result: string) => {
290 delete (window as any)[callbackId]
291 try {
292 const parsed = JSON.parse(result)
293 if (parsed.error) reject(new Error(parsed.error))
294 else resolve(parsed.result)
295 }
296catch {
297 resolve(result as T)
298 }
299 }
300 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
301 })
302 }
303 return 'granted' as T
304 }
305}
306
307// Android Biometrics
308export class AndroidBiometrics {
309 /**
310 * Check biometric availability
311 */
312 async isAvailable(): Promise<{ available: boolean; biometricType: 'fingerprint' | 'face' | 'iris' | 'none' }> {
313 return this.callNative('checkBiometricAvailability', {})
314 }
315
316 /**
317 * Authenticate with biometrics
318 */
319 async authenticate(config: AndroidBiometricConfig): Promise<{ success: boolean; error?: string }> {
320 return this.callNative('authenticateBiometric', config)
321 }
322
323 /**
324 * Check if device has enrolled biometrics
325 */
326 async hasEnrolledBiometrics(): Promise<boolean> {
327 return this.callNative('hasEnrolledBiometrics', {})
328 }
329
330 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
331 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
332 return new Promise((resolve, reject) => {
333 const callbackId = `cb_${Date.now()}`
334 ;(window as any)[callbackId] = (result: string) => {
335 delete (window as any)[callbackId]
336 try {
337 const parsed = JSON.parse(result)
338 if (parsed.error) reject(new Error(parsed.error))
339 else resolve(parsed.result)
340 }
341catch {
342 resolve(result as T)
343 }
344 }
345 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
346 })
347 }
348 return { available: false, biometricType: 'none' } as T
349 }
350}
351
352// Android Notifications
353export class AndroidNotifications {
354 /**
355 * Create notification channel (required for Android 8+)
356 */
357 async createChannel(channel: AndroidNotificationChannel): Promise<void> {
358 await this.callNative('createNotificationChannel', channel)
359 }
360
361 /**
362 * Delete notification channel
363 */
364 async deleteChannel(channelId: string): Promise<void> {
365 await this.callNative('deleteNotificationChannel', { channelId })
366 }
367
368 /**
369 * Show notification
370 */
371 async show(notification: AndroidNotification): Promise<void> {
372 await this.callNative('showNotification', notification)
373 }
374
375 /**
376 * Cancel notification
377 */
378 async cancel(notificationId: number): Promise<void> {
379 await this.callNative('cancelNotification', { notificationId })
380 }
381
382 /**
383 * Cancel all notifications
384 */
385 async cancelAll(): Promise<void> {
386 await this.callNative('cancelAllNotifications', {})
387 }
388
389 /**
390 * Request notification permission (Android 13+)
391 */
392 async requestPermission(): Promise<boolean> {
393 return this.callNative('requestNotificationPermission', {})
394 }
395
396 /**
397 * Check if notifications are enabled
398 */
399 async areNotificationsEnabled(): Promise<boolean> {
400 return this.callNative('areNotificationsEnabled', {})
401 }
402
403 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
404 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
405 return new Promise((resolve, reject) => {
406 const callbackId = `cb_${Date.now()}`
407 ;(window as any)[callbackId] = (result: string) => {
408 delete (window as any)[callbackId]
409 try {
410 const parsed = JSON.parse(result)
411 if (parsed.error) reject(new Error(parsed.error))
412 else resolve(parsed.result)
413 }
414catch {
415 resolve(result as T)
416 }
417 }
418 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
419 })
420 }
421 return true as T
422 }
423}
424
425// Android Material You / Dynamic Colors
426export class AndroidMaterialYou {
427 /**
428 * Check if dynamic colors are available
429 */
430 async isAvailable(): Promise<boolean> {
431 return this.callNative('isDynamicColorAvailable', {})
432 }
433
434 /**
435 * Get dynamic colors
436 */
437 async getColors(): Promise<AndroidMaterialYouColors | null> {
438 return this.callNative('getDynamicColors', {})
439 }
440
441 /**
442 * Listen for color changes (wallpaper change)
443 */
444 onColorChange(callback: (colors: AndroidMaterialYouColors) => void): () => void {
445 const handler = (event: CustomEvent) => callback(event.detail)
446 window.addEventListener('material-you-colors' as any, handler)
447 return () => window.removeEventListener('material-you-colors' as any, handler)
448 }
449
450 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
451 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
452 return new Promise((resolve, reject) => {
453 const callbackId = `cb_${Date.now()}`
454 ;(window as any)[callbackId] = (result: string) => {
455 delete (window as any)[callbackId]
456 try {
457 const parsed = JSON.parse(result)
458 if (parsed.error) reject(new Error(parsed.error))
459 else resolve(parsed.result)
460 }
461catch {
462 resolve(result as T)
463 }
464 }
465 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
466 })
467 }
468 return false as T
469 }
470}
471
472// Android Photo Picker
473export class AndroidPhotoPicker {
474 /**
475 * Pick single image
476 */
477 async pickImage(): Promise<{ uri: string; mimeType: string } | null> {
478 return this.callNative('pickImage', {})
479 }
480
481 /**
482 * Pick multiple images
483 */
484 async pickImages(maxItems = 10): Promise<Array<{ uri: string; mimeType: string }>> {
485 return this.callNative('pickImages', { maxItems })
486 }
487
488 /**
489 * Pick single video
490 */
491 async pickVideo(): Promise<{ uri: string; mimeType: string; duration: number } | null> {
492 return this.callNative('pickVideo', {})
493 }
494
495 /**
496 * Pick visual media (images and videos)
497 */
498 async pickVisualMedia(maxItems = 10): Promise<Array<{ uri: string; mimeType: string; type: 'image' | 'video' }>> {
499 return this.callNative('pickVisualMedia', { maxItems })
500 }
501
502 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
503 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
504 return new Promise((resolve, reject) => {
505 const callbackId = `cb_${Date.now()}`
506 ;(window as any)[callbackId] = (result: string) => {
507 delete (window as any)[callbackId]
508 try {
509 const parsed = JSON.parse(result)
510 if (parsed.error) reject(new Error(parsed.error))
511 else resolve(parsed.result)
512 }
513catch {
514 resolve(result as T)
515 }
516 }
517 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
518 })
519 }
520 return null as T
521 }
522}
523
524// Android Work Manager
525export class AndroidWorkManager {
526 /**
527 * Enqueue one-time work
528 */
529 async enqueueOneTime(request: AndroidWorkRequest): Promise<void> {
530 await this.callNative('enqueueOneTimeWork', request)
531 }
532
533 /**
534 * Enqueue periodic work
535 */
536 async enqueuePeriodic(request: AndroidWorkRequest & { repeatInterval: number; repeatIntervalUnit: 'minutes' | 'hours' | 'days' }): Promise<void> {
537 await this.callNative('enqueuePeriodicWork', request)
538 }
539
540 /**
541 * Cancel work by ID
542 */
543 async cancelById(id: string): Promise<void> {
544 await this.callNative('cancelWorkById', { id })
545 }
546
547 /**
548 * Cancel work by tag
549 */
550 async cancelByTag(tag: string): Promise<void> {
551 await this.callNative('cancelWorkByTag', { tag })
552 }
553
554 /**
555 * Cancel all work
556 */
557 async cancelAll(): Promise<void> {
558 await this.callNative('cancelAllWork', {})
559 }
560
561 /**
562 * Get work info by ID
563 */
564 async getWorkInfoById(id: string): Promise<{ state: 'enqueued' | 'running' | 'succeeded' | 'failed' | 'blocked' | 'cancelled'; outputData?: Record<string, unknown> } | null> {
565 return this.callNative('getWorkInfoById', { id })
566 }
567
568 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
569 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
570 return new Promise((resolve, reject) => {
571 const callbackId = `cb_${Date.now()}`
572 ;(window as any)[callbackId] = (result: string) => {
573 delete (window as any)[callbackId]
574 try {
575 const parsed = JSON.parse(result)
576 if (parsed.error) reject(new Error(parsed.error))
577 else resolve(parsed.result)
578 }
579catch {
580 resolve(result as T)
581 }
582 }
583 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
584 })
585 }
586 return undefined as T
587 }
588}
589
590// Android Foreground Service
591export class AndroidForegroundService {
592 /**
593 * Start foreground service
594 */
595 async start(config: {
596 notificationId: number
597 notification: AndroidNotification
598 serviceType?: 'camera' | 'connectedDevice' | 'dataSync' | 'location' | 'mediaPlayback' | 'mediaProjection' | 'microphone' | 'phoneCall' | 'remoteMessaging' | 'shortService'
599 }): Promise<void> {
600 await this.callNative('startForegroundService', config)
601 }
602
603 /**
604 * Stop foreground service
605 */
606 async stop(): Promise<void> {
607 await this.callNative('stopForegroundService', {})
608 }
609
610 /**
611 * Update notification
612 */
613 async updateNotification(notification: Partial<AndroidNotification>): Promise<void> {
614 await this.callNative('updateForegroundNotification', notification)
615 }
616
617 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
618 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
619 return new Promise((resolve, reject) => {
620 const callbackId = `cb_${Date.now()}`
621 ;(window as any)[callbackId] = (result: string) => {
622 delete (window as any)[callbackId]
623 try {
624 const parsed = JSON.parse(result)
625 if (parsed.error) reject(new Error(parsed.error))
626 else resolve(parsed.result)
627 }
628catch {
629 resolve(result as T)
630 }
631 }
632 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
633 })
634 }
635 return undefined as T
636 }
637}
638
639// Android Predictive Back Gesture
640export class AndroidPredictiveBack {
641 /**
642 * Enable predictive back gesture
643 */
644 async enable(): Promise<void> {
645 await this.callNative('enablePredictiveBack', {})
646 }
647
648 /**
649 * Disable predictive back gesture
650 */
651 async disable(): Promise<void> {
652 await this.callNative('disablePredictiveBack', {})
653 }
654
655 /**
656 * Register back callback
657 */
658 onBackPressed(callback: () => boolean): () => void {
659 const handler = (_event: CustomEvent) => {
660 const handled = callback()
661 if ((window as any).CraftBridge) {
662 ;(window as any).CraftBridge.postMessage(
663 JSON.stringify({
664 method: 'backPressedResult',
665 params: { handled },
666 })
667 )
668 }
669 }
670 window.addEventListener('back-pressed' as any, handler)
671 return () => window.removeEventListener('back-pressed' as any, handler)
672 }
673
674 /**
675 * Listen for back progress (for animations)
676 */
677 onBackProgress(callback: (progress: { touchX: number; touchY: number; progress: number }) => void): () => void {
678 const handler = (event: CustomEvent) => callback(event.detail)
679 window.addEventListener('back-progress' as any, handler)
680 return () => window.removeEventListener('back-progress' as any, handler)
681 }
682
683 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
684 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
685 return new Promise((resolve, reject) => {
686 const callbackId = `cb_${Date.now()}`
687 ;(window as any)[callbackId] = (result: string) => {
688 delete (window as any)[callbackId]
689 try {
690 const parsed = JSON.parse(result)
691 if (parsed.error) reject(new Error(parsed.error))
692 else resolve(parsed.result)
693 }
694catch {
695 resolve(result as T)
696 }
697 }
698 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
699 })
700 }
701 return undefined as T
702 }
703}
704
705// Android Per-App Language
706export class AndroidPerAppLanguage {
707 /**
708 * Get current app locale
709 */
710 async getCurrentLocale(): Promise<string> {
711 return this.callNative('getCurrentAppLocale', {})
712 }
713
714 /**
715 * Set app locale
716 */
717 async setLocale(localeTag: string): Promise<void> {
718 await this.callNative('setAppLocale', { localeTag })
719 }
720
721 /**
722 * Reset to system locale
723 */
724 async resetToSystemLocale(): Promise<void> {
725 await this.callNative('resetToSystemLocale', {})
726 }
727
728 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
729 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
730 return new Promise((resolve, reject) => {
731 const callbackId = `cb_${Date.now()}`
732 ;(window as any)[callbackId] = (result: string) => {
733 delete (window as any)[callbackId]
734 try {
735 const parsed = JSON.parse(result)
736 if (parsed.error) reject(new Error(parsed.error))
737 else resolve(parsed.result)
738 }
739catch {
740 resolve(result as T)
741 }
742 }
743 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
744 })
745 }
746 return 'en' as T
747 }
748}
749
750// Android Widgets
751export class AndroidWidgets {
752 /**
753 * Register widget
754 */
755 async register(widget: AndroidWidget): Promise<void> {
756 await this.callNative('registerWidget', widget)
757 }
758
759 /**
760 * Update widget
761 */
762 async update(widgetId: string, views: Record<string, unknown>): Promise<void> {
763 await this.callNative('updateWidget', { widgetId, views })
764 }
765
766 /**
767 * Get widget IDs
768 */
769 async getWidgetIds(className: string): Promise<number[]> {
770 return this.callNative('getWidgetIds', { className })
771 }
772
773 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
774 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
775 return new Promise((resolve, reject) => {
776 const callbackId = `cb_${Date.now()}`
777 ;(window as any)[callbackId] = (result: string) => {
778 delete (window as any)[callbackId]
779 try {
780 const parsed = JSON.parse(result)
781 if (parsed.error) reject(new Error(parsed.error))
782 else resolve(parsed.result)
783 }
784catch {
785 resolve(result as T)
786 }
787 }
788 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
789 })
790 }
791 return [] as T
792 }
793}
794
795// Android Play Billing
796export class AndroidPlayBilling {
797 /**
798 * Check if billing is ready
799 */
800 async isReady(): Promise<boolean> {
801 return this.callNative('isBillingReady', {})
802 }
803
804 /**
805 * Query product details
806 */
807 async queryProducts(productIds: string[], productType: 'inapp' | 'subs'): Promise<
808 Array<{
809 productId: string
810 name: string
811 description: string
812 formattedPrice: string
813 priceAmountMicros: number
814 priceCurrencyCode: string
815 }>
816 > {
817 return this.callNative('queryProductDetails', { productIds, productType })
818 }
819
820 /**
821 * Launch purchase flow
822 */
823 async purchase(productId: string, productType: 'inapp' | 'subs'): Promise<{ purchaseToken: string; orderId: string } | null> {
824 return this.callNative('launchBillingFlow', { productId, productType })
825 }
826
827 /**
828 * Consume purchase
829 */
830 async consumePurchase(purchaseToken: string): Promise<void> {
831 await this.callNative('consumePurchase', { purchaseToken })
832 }
833
834 /**
835 * Acknowledge purchase
836 */
837 async acknowledgePurchase(purchaseToken: string): Promise<void> {
838 await this.callNative('acknowledgePurchase', { purchaseToken })
839 }
840
841 /**
842 * Query purchases
843 */
844 async queryPurchases(productType: 'inapp' | 'subs'): Promise<
845 Array<{
846 productId: string
847 purchaseToken: string
848 orderId: string
849 purchaseTime: number
850 isAcknowledged: boolean
851 purchaseState: 'pending' | 'purchased' | 'unspecified'
852 }>
853 > {
854 return this.callNative('queryPurchases', { productType })
855 }
856
857 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
858 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
859 return new Promise((resolve, reject) => {
860 const callbackId = `cb_${Date.now()}`
861 ;(window as any)[callbackId] = (result: string) => {
862 delete (window as any)[callbackId]
863 try {
864 const parsed = JSON.parse(result)
865 if (parsed.error) reject(new Error(parsed.error))
866 else resolve(parsed.result)
867 }
868catch {
869 resolve(result as T)
870 }
871 }
872 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
873 })
874 }
875 return [] as T
876 }
877}
878
879// Android Firebase
880export class AndroidFirebase {
881 /**
882 * Get FCM token
883 */
884 async getFCMToken(): Promise<string | null> {
885 return this.callNative('getFCMToken', {})
886 }
887
888 /**
889 * Subscribe to topic
890 */
891 async subscribeToTopic(topic: string): Promise<void> {
892 await this.callNative('subscribeToTopic', { topic })
893 }
894
895 /**
896 * Unsubscribe from topic
897 */
898 async unsubscribeFromTopic(topic: string): Promise<void> {
899 await this.callNative('unsubscribeFromTopic', { topic })
900 }
901
902 /**
903 * Log analytics event
904 */
905 async logEvent(name: string, params?: Record<string, unknown>): Promise<void> {
906 await this.callNative('logAnalyticsEvent', { name, params })
907 }
908
909 /**
910 * Set user property
911 */
912 async setUserProperty(name: string, value: string): Promise<void> {
913 await this.callNative('setUserProperty', { name, value })
914 }
915
916 /**
917 * Set user ID
918 */
919 async setUserId(userId: string | null): Promise<void> {
920 await this.callNative('setUserId', { userId })
921 }
922
923 /**
924 * Record exception
925 */
926 async recordException(error: Error): Promise<void> {
927 await this.callNative('recordException', {
928 message: error.message,
929 stack: error.stack,
930 })
931 }
932
933 /**
934 * Set Crashlytics key
935 */
936 async setCrashlyticsKey(key: string, value: string | number | boolean): Promise<void> {
937 await this.callNative('setCrashlyticsKey', { key, value })
938 }
939
940 /**
941 * Get Remote Config value
942 */
943 async getRemoteConfigValue(key: string): Promise<string | number | boolean | null> {
944 return this.callNative('getRemoteConfigValue', { key })
945 }
946
947 /**
948 * Fetch Remote Config
949 */
950 async fetchRemoteConfig(minimumFetchInterval?: number): Promise<void> {
951 await this.callNative('fetchRemoteConfig', { minimumFetchInterval })
952 }
953
954 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
955 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
956 return new Promise((resolve, reject) => {
957 const callbackId = `cb_${Date.now()}`
958 ;(window as any)[callbackId] = (result: string) => {
959 delete (window as any)[callbackId]
960 try {
961 const parsed = JSON.parse(result)
962 if (parsed.error) reject(new Error(parsed.error))
963 else resolve(parsed.result)
964 }
965catch {
966 resolve(result as T)
967 }
968 }
969 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
970 })
971 }
972 return null as T
973 }
974}
975
976// Android Native Components
977export class AndroidNativeComponents {
978 /**
979 * Show bottom sheet
980 */
981 async showBottomSheet(config: {
982 title?: string
983 items?: Array<{ id: string; title: string; icon?: string; subtitle?: string }>
984 dismissible?: boolean
985 halfExpanded?: boolean
986 }): Promise<string | null> {
987 return this.callNative('showBottomSheet', config)
988 }
989
990 /**
991 * Show navigation drawer
992 */
993 async showNavigationDrawer(config: {
994 items: Array<{ id: string; title: string; icon?: string; selected?: boolean; group?: string }>
995 headerTitle?: string
996 headerSubtitle?: string
997 headerImage?: string
998 }): Promise<string | null> {
999 return this.callNative('showNavigationDrawer', config)
1000 }
1001
1002 /**
1003 * Show date picker
1004 */
1005 async showDatePicker(config?: {
1006 initialDate?: string
1007 minDate?: string
1008 maxDate?: string
1009 title?: string
1010 }): Promise<string | null> {
1011 return this.callNative('showDatePicker', config || {})
1012 }
1013
1014 /**
1015 * Show time picker
1016 */
1017 async showTimePicker(config?: {
1018 initialHour?: number
1019 initialMinute?: number
1020 is24Hour?: boolean
1021 title?: string
1022 }): Promise<{ hour: number; minute: number } | null> {
1023 return this.callNative('showTimePicker', config || {})
1024 }
1025
1026 /**
1027 * Show toast
1028 */
1029 async showToast(message: string, duration: 'short' | 'long' = 'short'): Promise<void> {
1030 await this.callNative('showToast', { message, duration })
1031 }
1032
1033 /**
1034 * Show snackbar
1035 */
1036 async showSnackbar(config: {
1037 message: string
1038 action?: { text: string; color?: string }
1039 duration?: 'short' | 'long' | 'indefinite'
1040 }): Promise<boolean> {
1041 return this.callNative('showSnackbar', config)
1042 }
1043
1044 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
1045 if (typeof window !== 'undefined' && (window as any).CraftBridge) {
1046 return new Promise((resolve, reject) => {
1047 const callbackId = `cb_${Date.now()}`
1048 ;(window as any)[callbackId] = (result: string) => {
1049 delete (window as any)[callbackId]
1050 try {
1051 const parsed = JSON.parse(result)
1052 if (parsed.error) reject(new Error(parsed.error))
1053 else resolve(parsed.result)
1054 }
1055catch {
1056 resolve(result as T)
1057 }
1058 }
1059 ;(window as any).CraftBridge.postMessage(JSON.stringify({ method, params, callbackId }))
1060 })
1061 }
1062 return null as T
1063 }
1064}
1065
1066// Export all Android modules
1067export const android: {
1068 WebView: typeof AndroidWebView
1069 Permissions: AndroidPermissions
1070 Biometrics: AndroidBiometrics
1071 Notifications: AndroidNotifications
1072 MaterialYou: AndroidMaterialYou
1073 PhotoPicker: AndroidPhotoPicker
1074 WorkManager: AndroidWorkManager
1075 ForegroundService: AndroidForegroundService
1076 PredictiveBack: AndroidPredictiveBack
1077 PerAppLanguage: AndroidPerAppLanguage
1078 Widgets: AndroidWidgets
1079 PlayBilling: AndroidPlayBilling
1080 Firebase: AndroidFirebase
1081 NativeComponents: AndroidNativeComponents
1082} = {
1083 WebView: AndroidWebView,
1084 Permissions: new AndroidPermissions(),
1085 Biometrics: new AndroidBiometrics(),
1086 Notifications: new AndroidNotifications(),
1087 MaterialYou: new AndroidMaterialYou(),
1088 PhotoPicker: new AndroidPhotoPicker(),
1089 WorkManager: new AndroidWorkManager(),
1090 ForegroundService: new AndroidForegroundService(),
1091 PredictiveBack: new AndroidPredictiveBack(),
1092 PerAppLanguage: new AndroidPerAppLanguage(),
1093 Widgets: new AndroidWidgets(),
1094 PlayBilling: new AndroidPlayBilling(),
1095 Firebase: new AndroidFirebase(),
1096 NativeComponents: new AndroidNativeComponents(),
1097}
1098
1099export default android
packages/typescript/src/bridge/ios.tsdeleted+0-1004
Changes to packages/typescript/src/bridge/ios.ts
@@ -1,1004 +0,0 @@
1/**
2 * Craft iOS Bridge
3 * Complete iOS native bridge with WebView, permissions, haptics, and platform APIs
4 */
5
6import { secureId } from './ids.js'
7
8// Types
9export interface IOSWebViewConfig {
10 url?: string
11 html?: string
12 allowsBackForwardNavigationGestures?: boolean
13 allowsInlineMediaPlayback?: boolean
14 mediaTypesRequiringUserAction?: ('audio' | 'video' | 'all' | 'none')[]
15 suppressesIncrementalRendering?: boolean
16 allowsAirPlayForMediaPlayback?: boolean
17 scrollEnabled?: boolean
18 bounces?: boolean
19 contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always'
20}
21
22export interface IOSPermission {
23 type:
24 | 'camera'
25 | 'microphone'
26 | 'photos'
27 | 'location'
28 | 'locationAlways'
29 | 'contacts'
30 | 'calendar'
31 | 'reminders'
32 | 'notifications'
33 | 'bluetooth'
34 | 'healthKit'
35 | 'motion'
36 | 'speechRecognition'
37 | 'mediaLibrary'
38 | 'siri'
39 | 'faceID'
40 | 'tracking'
41 status: 'notDetermined' | 'restricted' | 'denied' | 'authorized' | 'authorizedWhenInUse' | 'provisional'
42}
43
44export interface IOSHapticFeedback {
45 type: 'impact' | 'notification' | 'selection'
46 style?: 'light' | 'medium' | 'heavy' | 'soft' | 'rigid'
47 notificationType?: 'success' | 'warning' | 'error'
48}
49
50export interface IOSAppClipConfig {
51 invocationURL: string
52 experienceType: 'default' | 'locationBased'
53 businessCategory?: string
54 subtitle?: string
55}
56
57export interface IOSSharePlayActivity {
58 activityIdentifier: string
59 fallbackURL?: string
60 title: string
61 subtitle?: string
62 image?: string
63}
64
65export interface IOSLiveActivity {
66 activityId: string
67 contentState: Record<string, unknown>
68 staleDate?: Date
69 relevanceScore?: number
70}
71
72export interface IOSFocusFilter {
73 identifier: string
74 name: string
75 iconSystemName?: string
76 parameters: Record<string, unknown>
77}
78
79export interface IOSAppIntent {
80 identifier: string
81 title: string
82 description?: string
83 parameters?: Array<{
84 name: string
85 type: 'string' | 'number' | 'boolean' | 'date' | 'url'
86 required?: boolean
87 }>
88}
89
90export interface IOSTipKitTip {
91 identifier: string
92 title: string
93 message: string
94 imageName?: string
95 actions?: Array<{ identifier: string; title: string }>
96}
97
98export interface IOSStoreKitProduct {
99 productId: string
100 displayName: string
101 description: string
102 price: number
103 displayPrice: string
104 type: 'consumable' | 'nonConsumable' | 'autoRenewable' | 'nonRenewable'
105}
106
107export interface IOSCarPlayTemplate {
108 type: 'list' | 'grid' | 'tab' | 'information' | 'pointOfInterest' | 'nowPlaying' | 'alert' | 'action'
109 title: string
110 items?: Array<{ title: string; subtitle?: string; image?: string; handler?: string }>
111}
112
113// iOS WebView Bridge
114export class IOSWebView {
115 private id: string
116 private config: IOSWebViewConfig
117
118 constructor(config: IOSWebViewConfig = {}) {
119 this.id = secureId('webview')
120 this.config = config
121 }
122
123 /**
124 * Create the WebView (calls Objective-C runtime)
125 */
126 async create(): Promise<void> {
127 await this.callNative('createWebView', {
128 id: this.id,
129 config: this.config,
130 })
131 }
132
133 /**
134 * Load a URL
135 */
136 async loadURL(url: string): Promise<void> {
137 await this.callNative('loadURL', { id: this.id, url })
138 }
139
140 /**
141 * Load HTML content
142 */
143 async loadHTML(html: string, baseURL?: string): Promise<void> {
144 await this.callNative('loadHTML', { id: this.id, html, baseURL })
145 }
146
147 /**
148 * Evaluate JavaScript in the WebView
149 */
150 async evaluateJavaScript<T = unknown>(script: string): Promise<T> {
151 return this.callNative('evaluateJavaScript', { id: this.id, script })
152 }
153
154 /**
155 * Go back in history
156 */
157 async goBack(): Promise<void> {
158 await this.callNative('goBack', { id: this.id })
159 }
160
161 /**
162 * Go forward in history
163 */
164 async goForward(): Promise<void> {
165 await this.callNative('goForward', { id: this.id })
166 }
167
168 /**
169 * Reload the current page
170 */
171 async reload(): Promise<void> {
172 await this.callNative('reload', { id: this.id })
173 }
174
175 /**
176 * Stop loading
177 */
178 async stopLoading(): Promise<void> {
179 await this.callNative('stopLoading', { id: this.id })
180 }
181
182 /**
183 * Take a snapshot of the WebView
184 */
185 async takeSnapshot(): Promise<string> {
186 return this.callNative('takeSnapshot', { id: this.id })
187 }
188
189 /**
190 * Destroy the WebView
191 */
192 async destroy(): Promise<void> {
193 await this.callNative('destroyWebView', { id: this.id })
194 }
195
196 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
197 // Bridge to native via window.webkit.messageHandlers or custom bridge
198 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
199 return new Promise((resolve, reject) => {
200 const callbackId = secureId('cb')
201 ;(window as any)[callbackId] = (result: T, error?: string) => {
202 delete (window as any)[callbackId]
203 if (error) reject(new Error(error))
204 else resolve(result)
205 }
206 ;(window as any).webkit.messageHandlers.craft.postMessage({
207 method,
208 params,
209 callbackId,
210 })
211 })
212 }
213 // Fallback for non-iOS environments
214 console.warn(`[IOSBridge] ${method} called but not in iOS WebView context`)
215 return undefined as T
216 }
217}
218
219// iOS Permissions
220export class IOSPermissions {
221 /**
222 * Check permission status
223 */
224 async check(type: IOSPermission['type']): Promise<IOSPermission['status']> {
225 return this.callNative('checkPermission', { type })
226 }
227
228 /**
229 * Request permission
230 */
231 async request(type: IOSPermission['type']): Promise<IOSPermission['status']> {
232 return this.callNative('requestPermission', { type })
233 }
234
235 /**
236 * Open app settings
237 */
238 async openSettings(): Promise<void> {
239 await this.callNative('openSettings', {})
240 }
241
242 /**
243 * Check if can open settings
244 */
245 async canOpenSettings(): Promise<boolean> {
246 return this.callNative('canOpenSettings', {})
247 }
248
249 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
250 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
251 return new Promise((resolve, reject) => {
252 const callbackId = secureId('cb')
253 ;(window as any)[callbackId] = (result: T, error?: string) => {
254 delete (window as any)[callbackId]
255 if (error) reject(new Error(error))
256 else resolve(result)
257 }
258 ;(window as any).webkit.messageHandlers.craft.postMessage({
259 method,
260 params,
261 callbackId,
262 })
263 })
264 }
265 return 'authorized' as T // Default for non-iOS
266 }
267}
268
269// iOS Haptics
270export class IOSHaptics {
271 /**
272 * Trigger impact feedback
273 */
274 async impact(style: IOSHapticFeedback['style'] = 'medium'): Promise<void> {
275 await this.callNative('triggerHaptic', { type: 'impact', style })
276 }
277
278 /**
279 * Trigger notification feedback
280 */
281 async notification(type: IOSHapticFeedback['notificationType'] = 'success'): Promise<void> {
282 await this.callNative('triggerHaptic', { type: 'notification', notificationType: type })
283 }
284
285 /**
286 * Trigger selection feedback
287 */
288 async selection(): Promise<void> {
289 await this.callNative('triggerHaptic', { type: 'selection' })
290 }
291
292 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
293 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
294 return new Promise((resolve) => {
295 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params })
296 resolve(undefined as T)
297 })
298 }
299 return undefined as T
300 }
301}
302
303// iOS App Clips
304export class IOSAppClips {
305 /**
306 * Configure App Clip experience
307 */
308 async configure(config: IOSAppClipConfig): Promise<void> {
309 await this.callNative('configureAppClip', config)
310 }
311
312 /**
313 * Get invocation URL
314 */
315 async getInvocationURL(): Promise<string | null> {
316 return this.callNative('getAppClipInvocationURL', {})
317 }
318
319 /**
320 * Check if running as App Clip
321 */
322 async isAppClip(): Promise<boolean> {
323 return this.callNative('isAppClip', {})
324 }
325
326 /**
327 * Request full app installation
328 */
329 async requestFullAppInstallation(): Promise<void> {
330 await this.callNative('requestFullAppInstallation', {})
331 }
332
333 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
334 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
335 return new Promise((resolve, reject) => {
336 const callbackId = `cb_${Date.now()}`
337 ;(window as any)[callbackId] = (result: T, error?: string) => {
338 delete (window as any)[callbackId]
339 if (error) reject(new Error(error))
340 else resolve(result)
341 }
342 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
343 })
344 }
345 return null as T
346 }
347}
348
349// iOS SharePlay
350export class IOSSharePlay {
351 private activity: IOSSharePlayActivity | null = null
352
353 /**
354 * Start a SharePlay activity
355 */
356 async startActivity(activity: IOSSharePlayActivity): Promise<void> {
357 this.activity = activity
358 await this.callNative('startSharePlayActivity', activity)
359 }
360
361 /**
362 * End the current SharePlay activity
363 */
364 async endActivity(): Promise<void> {
365 if (this.activity) {
366 await this.callNative('endSharePlayActivity', { activityId: this.activity.activityIdentifier })
367 this.activity = null
368 }
369 }
370
371 /**
372 * Check if SharePlay is available
373 */
374 async isAvailable(): Promise<boolean> {
375 return this.callNative('isSharePlayAvailable', {})
376 }
377
378 /**
379 * Send message to participants
380 */
381 async sendMessage(data: unknown): Promise<void> {
382 await this.callNative('sendSharePlayMessage', { data })
383 }
384
385 /**
386 * Listen for messages
387 */
388 onMessage(callback: (data: unknown) => void): () => void {
389 const handler = (event: CustomEvent) => callback(event.detail)
390 window.addEventListener('shareplay-message' as any, handler)
391 return () => window.removeEventListener('shareplay-message' as any, handler)
392 }
393
394 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
395 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
396 return new Promise((resolve, reject) => {
397 const callbackId = `cb_${Date.now()}`
398 ;(window as any)[callbackId] = (result: T, error?: string) => {
399 delete (window as any)[callbackId]
400 if (error) reject(new Error(error))
401 else resolve(result)
402 }
403 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
404 })
405 }
406 return false as T
407 }
408}
409
410// iOS Live Activities
411export class IOSLiveActivities {
412 /**
413 * Start a Live Activity
414 */
415 async start(activity: Omit<IOSLiveActivity, 'activityId'>): Promise<string> {
416 return this.callNative('startLiveActivity', activity)
417 }
418
419 /**
420 * Update a Live Activity
421 */
422 async update(activityId: string, contentState: Record<string, unknown>): Promise<void> {
423 await this.callNative('updateLiveActivity', { activityId, contentState })
424 }
425
426 /**
427 * End a Live Activity
428 */
429 async end(activityId: string, dismissalPolicy?: 'immediate' | 'default' | 'after'): Promise<void> {
430 await this.callNative('endLiveActivity', { activityId, dismissalPolicy })
431 }
432
433 /**
434 * Get all active Live Activities
435 */
436 async getAll(): Promise<IOSLiveActivity[]> {
437 return this.callNative('getAllLiveActivities', {})
438 }
439
440 /**
441 * Check if Live Activities are supported
442 */
443 async areSupported(): Promise<boolean> {
444 return this.callNative('areLiveActivitiesSupported', {})
445 }
446
447 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
448 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
449 return new Promise((resolve, reject) => {
450 const callbackId = `cb_${Date.now()}`
451 ;(window as any)[callbackId] = (result: T, error?: string) => {
452 delete (window as any)[callbackId]
453 if (error) reject(new Error(error))
454 else resolve(result)
455 }
456 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
457 })
458 }
459 return [] as T
460 }
461}
462
463// iOS Focus Filters
464export class IOSFocusFilters {
465 /**
466 * Register a focus filter
467 */
468 async register(filter: IOSFocusFilter): Promise<void> {
469 await this.callNative('registerFocusFilter', filter)
470 }
471
472 /**
473 * Get current focus filter
474 */
475 async getCurrent(): Promise<IOSFocusFilter | null> {
476 return this.callNative('getCurrentFocusFilter', {})
477 }
478
479 /**
480 * Check if a focus is active
481 */
482 async isFocusActive(): Promise<boolean> {
483 return this.callNative('isFocusActive', {})
484 }
485
486 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
487 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
488 return new Promise((resolve, reject) => {
489 const callbackId = `cb_${Date.now()}`
490 ;(window as any)[callbackId] = (result: T, error?: string) => {
491 delete (window as any)[callbackId]
492 if (error) reject(new Error(error))
493 else resolve(result)
494 }
495 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
496 })
497 }
498 return null as T
499 }
500}
501
502// iOS App Intents
503export class IOSAppIntents {
504 /**
505 * Register an App Intent
506 */
507 async register(intent: IOSAppIntent): Promise<void> {
508 await this.callNative('registerAppIntent', intent)
509 }
510
511 /**
512 * Donate an intent (for suggestions)
513 */
514 async donate(intentIdentifier: string, parameters?: Record<string, unknown>): Promise<void> {
515 await this.callNative('donateAppIntent', { intentIdentifier, parameters })
516 }
517
518 /**
519 * Handle intent execution
520 */
521 onExecute(callback: (intent: IOSAppIntent, parameters: Record<string, unknown>) => Promise<unknown>): () => void {
522 const handler = async (event: CustomEvent) => {
523 const result = await callback(event.detail.intent, event.detail.parameters)
524 if ((window as any).webkit?.messageHandlers?.craft) {
525 ;(window as any).webkit.messageHandlers.craft.postMessage({
526 method: 'appIntentResult',
527 params: { intentId: event.detail.intentId, result },
528 })
529 }
530 }
531 window.addEventListener('app-intent-execute' as any, handler)
532 return () => window.removeEventListener('app-intent-execute' as any, handler)
533 }
534
535 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
536 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
537 return new Promise((resolve, reject) => {
538 const callbackId = `cb_${Date.now()}`
539 ;(window as any)[callbackId] = (result: T, error?: string) => {
540 delete (window as any)[callbackId]
541 if (error) reject(new Error(error))
542 else resolve(result)
543 }
544 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
545 })
546 }
547 return undefined as T
548 }
549}
550
551// iOS TipKit
552export class IOSTipKit {
553 /**
554 * Configure TipKit
555 */
556 async configure(options?: { displayFrequency?: 'immediate' | 'hourly' | 'daily' | 'weekly' | 'monthly' }): Promise<void> {
557 await this.callNative('configureTipKit', options || {})
558 }
559
560 /**
561 * Register a tip
562 */
563 async registerTip(tip: IOSTipKitTip): Promise<void> {
564 await this.callNative('registerTip', tip)
565 }
566
567 /**
568 * Show a tip
569 */
570 async showTip(identifier: string): Promise<void> {
571 await this.callNative('showTip', { identifier })
572 }
573
574 /**
575 * Invalidate a tip
576 */
577 async invalidateTip(identifier: string): Promise<void> {
578 await this.callNative('invalidateTip', { identifier })
579 }
580
581 /**
582 * Reset all tips
583 */
584 async resetAllTips(): Promise<void> {
585 await this.callNative('resetAllTips', {})
586 }
587
588 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
589 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
590 return new Promise((resolve, reject) => {
591 const callbackId = `cb_${Date.now()}`
592 ;(window as any)[callbackId] = (result: T, error?: string) => {
593 delete (window as any)[callbackId]
594 if (error) reject(new Error(error))
595 else resolve(result)
596 }
597 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
598 })
599 }
600 return undefined as T
601 }
602}
603
604// iOS StoreKit 2
605export class IOSStoreKit {
606 /**
607 * Get available products
608 */
609 async getProducts(productIds: string[]): Promise<IOSStoreKitProduct[]> {
610 return this.callNative('getProducts', { productIds })
611 }
612
613 /**
614 * Purchase a product
615 */
616 async purchase(productId: string): Promise<{ transactionId: string; status: 'purchased' | 'pending' | 'cancelled' }> {
617 return this.callNative('purchaseProduct', { productId })
618 }
619
620 /**
621 * Restore purchases
622 */
623 async restorePurchases(): Promise<Array<{ productId: string; transactionId: string }>> {
624 return this.callNative('restorePurchases', {})
625 }
626
627 /**
628 * Check entitlement
629 */
630 async checkEntitlement(productId: string): Promise<boolean> {
631 return this.callNative('checkEntitlement', { productId })
632 }
633
634 /**
635 * Get subscription status
636 */
637 async getSubscriptionStatus(productId: string): Promise<{
638 isActive: boolean
639 willRenew: boolean
640 expirationDate?: string
641 gracePeriodExpirationDate?: string
642 } | null> {
643 return this.callNative('getSubscriptionStatus', { productId })
644 }
645
646 /**
647 * Present offer code redemption sheet
648 */
649 async presentOfferCodeRedemption(): Promise<void> {
650 await this.callNative('presentOfferCodeRedemption', {})
651 }
652
653 /**
654 * Listen for transaction updates
655 */
656 onTransactionUpdate(callback: (transaction: { productId: string; transactionId: string; status: string }) => void): () => void {
657 const handler = (event: CustomEvent) => callback(event.detail)
658 window.addEventListener('storekit-transaction' as any, handler)
659 return () => window.removeEventListener('storekit-transaction' as any, handler)
660 }
661
662 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
663 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
664 return new Promise((resolve, reject) => {
665 const callbackId = `cb_${Date.now()}`
666 ;(window as any)[callbackId] = (result: T, error?: string) => {
667 delete (window as any)[callbackId]
668 if (error) reject(new Error(error))
669 else resolve(result)
670 }
671 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
672 })
673 }
674 return [] as T
675 }
676}
677
678// iOS CarPlay
679export class IOSCarPlay {
680 private connected = false
681
682 /**
683 * Check if CarPlay is connected
684 */
685 async isConnected(): Promise<boolean> {
686 return this.callNative('isCarPlayConnected', {})
687 }
688
689 /**
690 * Set root template
691 */
692 async setRootTemplate(template: IOSCarPlayTemplate): Promise<void> {
693 await this.callNative('setCarPlayRootTemplate', template)
694 }
695
696 /**
697 * Push a template
698 */
699 async pushTemplate(template: IOSCarPlayTemplate, animated = true): Promise<void> {
700 await this.callNative('pushCarPlayTemplate', { template, animated })
701 }
702
703 /**
704 * Pop the current template
705 */
706 async popTemplate(animated = true): Promise<void> {
707 await this.callNative('popCarPlayTemplate', { animated })
708 }
709
710 /**
711 * Present an alert
712 */
713 async presentAlert(title: string, message?: string, actions?: Array<{ title: string; style?: 'default' | 'cancel' | 'destructive' }>): Promise<number> {
714 return this.callNative('presentCarPlayAlert', { title, message, actions })
715 }
716
717 /**
718 * Update Now Playing info
719 */
720 async updateNowPlaying(info: {
721 title: string
722 artist?: string
723 album?: string
724 artwork?: string
725 duration?: number
726 elapsedTime?: number
727 playbackRate?: number
728 }): Promise<void> {
729 await this.callNative('updateCarPlayNowPlaying', info)
730 }
731
732 /**
733 * Listen for CarPlay connection changes
734 */
735 onConnectionChange(callback: (connected: boolean) => void): () => void {
736 const handler = (event: CustomEvent) => {
737 this.connected = event.detail.connected
738 callback(event.detail.connected)
739 }
740 window.addEventListener('carplay-connection' as any, handler)
741 return () => window.removeEventListener('carplay-connection' as any, handler)
742 }
743
744 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
745 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
746 return new Promise((resolve, reject) => {
747 const callbackId = `cb_${Date.now()}`
748 ;(window as any)[callbackId] = (result: T, error?: string) => {
749 delete (window as any)[callbackId]
750 if (error) reject(new Error(error))
751 else resolve(result)
752 }
753 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
754 })
755 }
756 return false as T
757 }
758}
759
760// iOS Native Components
761export class IOSNativeComponents {
762 /**
763 * Create a native navigation controller
764 */
765 async createNavigationController(config: {
766 title?: string
767 largeTitleDisplayMode?: 'automatic' | 'always' | 'never'
768 prefersLargeTitles?: boolean
769 tintColor?: string
770 }): Promise<string> {
771 return this.callNative('createNavigationController', config)
772 }
773
774 /**
775 * Create a native tab bar controller
776 */
777 async createTabBarController(tabs: Array<{
778 title: string
779 icon: string
780 selectedIcon?: string
781 badgeValue?: string
782 }>): Promise<string> {
783 return this.callNative('createTabBarController', { tabs })
784 }
785
786 /**
787 * Create a native collection view
788 */
789 async createCollectionView(config: {
790 layout: 'flow' | 'compositional' | 'list'
791 itemSize?: { width: number; height: number }
792 sectionInsets?: { top: number; left: number; bottom: number; right: number }
793 minimumLineSpacing?: number
794 minimumInteritemSpacing?: number
795 }): Promise<string> {
796 return this.callNative('createCollectionView', config)
797 }
798
799 /**
800 * Show SwiftUI view
801 */
802 async showSwiftUIView(viewName: string, props?: Record<string, unknown>): Promise<void> {
803 await this.callNative('showSwiftUIView', { viewName, props })
804 }
805
806 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
807 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
808 return new Promise((resolve, reject) => {
809 const callbackId = `cb_${Date.now()}`
810 ;(window as any)[callbackId] = (result: T, error?: string) => {
811 delete (window as any)[callbackId]
812 if (error) reject(new Error(error))
813 else resolve(result)
814 }
815 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
816 })
817 }
818 return '' as T
819 }
820}
821
822// iOS Push Notifications
823export class IOSPushNotifications {
824 /**
825 * Register for remote notifications
826 */
827 async register(): Promise<string> {
828 return this.callNative('registerForRemoteNotifications', {})
829 }
830
831 /**
832 * Get APNs device token
833 */
834 async getDeviceToken(): Promise<string | null> {
835 return this.callNative('getAPNsDeviceToken', {})
836 }
837
838 /**
839 * Schedule a local notification
840 */
841 async scheduleLocal(notification: {
842 identifier: string
843 title: string
844 body: string
845 subtitle?: string
846 badge?: number
847 sound?: string | 'default'
848 userInfo?: Record<string, unknown>
849 trigger: { type: 'timeInterval'; seconds: number; repeats?: boolean } | { type: 'calendar'; dateComponents: Record<string, number>; repeats?: boolean } | { type: 'location'; latitude: number; longitude: number; radius: number; notifyOnEntry?: boolean; notifyOnExit?: boolean }
850 categoryIdentifier?: string
851 threadIdentifier?: string
852 interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical'
853 relevanceScore?: number
854 }): Promise<void> {
855 await this.callNative('scheduleLocalNotification', notification)
856 }
857
858 /**
859 * Cancel pending notifications
860 */
861 async cancelPending(identifiers: string[]): Promise<void> {
862 await this.callNative('cancelPendingNotifications', { identifiers })
863 }
864
865 /**
866 * Get pending notifications
867 */
868 async getPending(): Promise<Array<{ identifier: string; trigger: unknown }>> {
869 return this.callNative('getPendingNotifications', {})
870 }
871
872 /**
873 * Handle notification response
874 */
875 onNotificationResponse(callback: (response: { identifier: string; actionIdentifier: string; userInfo: Record<string, unknown> }) => void): () => void {
876 const handler = (event: CustomEvent) => callback(event.detail)
877 window.addEventListener('notification-response' as any, handler)
878 return () => window.removeEventListener('notification-response' as any, handler)
879 }
880
881 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
882 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
883 return new Promise((resolve, reject) => {
884 const callbackId = `cb_${Date.now()}`
885 ;(window as any)[callbackId] = (result: T, error?: string) => {
886 delete (window as any)[callbackId]
887 if (error) reject(new Error(error))
888 else resolve(result)
889 }
890 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
891 })
892 }
893 return null as T
894 }
895}
896
897// iOS App Lifecycle
898export class IOSAppLifecycle {
899 /**
900 * Get current app state
901 */
902 async getState(): Promise<'active' | 'inactive' | 'background'> {
903 return this.callNative('getAppState', {})
904 }
905
906 /**
907 * Register for state restoration
908 */
909 async registerStateRestoration(activityType: string): Promise<void> {
910 await this.callNative('registerStateRestoration', { activityType })
911 }
912
913 /**
914 * Save user activity for state restoration
915 */
916 async saveUserActivity(activityType: string, userInfo: Record<string, unknown>): Promise<void> {
917 await this.callNative('saveUserActivity', { activityType, userInfo })
918 }
919
920 /**
921 * Register background task
922 */
923 async registerBackgroundTask(identifier: string, handler: () => Promise<void>): Promise<void> {
924 const wrappedHandler = async (event: CustomEvent) => {
925 if (event.detail.identifier === identifier) {
926 await handler()
927 if ((window as any).webkit?.messageHandlers?.craft) {
928 ;(window as any).webkit.messageHandlers.craft.postMessage({
929 method: 'backgroundTaskComplete',
930 params: { identifier },
931 })
932 }
933 }
934 }
935 window.addEventListener('background-task' as any, wrappedHandler)
936 await this.callNative('registerBackgroundTask', { identifier })
937 }
938
939 /**
940 * Schedule background refresh
941 */
942 async scheduleBackgroundRefresh(identifier: string, earliestBeginDate?: Date): Promise<void> {
943 await this.callNative('scheduleBackgroundRefresh', { identifier, earliestBeginDate: earliestBeginDate?.toISOString() })
944 }
945
946 /**
947 * Listen for app state changes
948 */
949 onStateChange(callback: (state: 'active' | 'inactive' | 'background') => void): () => void {
950 const handler = (event: CustomEvent) => callback(event.detail.state)
951 window.addEventListener('app-state-change' as any, handler)
952 return () => window.removeEventListener('app-state-change' as any, handler)
953 }
954
955 private async callNative<T>(method: string, params: Record<string, unknown> | object): Promise<T> {
956 if (typeof window !== 'undefined' && (window as any).webkit?.messageHandlers?.craft) {
957 return new Promise((resolve, reject) => {
958 const callbackId = `cb_${Date.now()}`
959 ;(window as any)[callbackId] = (result: T, error?: string) => {
960 delete (window as any)[callbackId]
961 if (error) reject(new Error(error))
962 else resolve(result)
963 }
964 ;(window as any).webkit.messageHandlers.craft.postMessage({ method, params, callbackId })
965 })
966 }
967 return 'active' as T
968 }
969}
970
971// Export all iOS modules
972export const ios: {
973 WebView: typeof IOSWebView
974 Permissions: IOSPermissions
975 Haptics: IOSHaptics
976 AppClips: IOSAppClips
977 SharePlay: IOSSharePlay
978 LiveActivities: IOSLiveActivities
979 FocusFilters: IOSFocusFilters
980 AppIntents: IOSAppIntents
981 TipKit: IOSTipKit
982 StoreKit: IOSStoreKit
983 CarPlay: IOSCarPlay
984 NativeComponents: IOSNativeComponents
985 PushNotifications: IOSPushNotifications
986 AppLifecycle: IOSAppLifecycle
987} = {
988 WebView: IOSWebView,
989 Permissions: new IOSPermissions(),
990 Haptics: new IOSHaptics(),
991 AppClips: new IOSAppClips(),
992 SharePlay: new IOSSharePlay(),
993 LiveActivities: new IOSLiveActivities(),
994 FocusFilters: new IOSFocusFilters(),
995 AppIntents: new IOSAppIntents(),
996 TipKit: new IOSTipKit(),
997 StoreKit: new IOSStoreKit(),
998 CarPlay: new IOSCarPlay(),
999 NativeComponents: new IOSNativeComponents(),
1000 PushNotifications: new IOSPushNotifications(),
1001 AppLifecycle: new IOSAppLifecycle(),
1002}
1003
1004export default ios
packages/zig/build.zigmodified+93-45
Changes to packages/zig/build.zig
@@ -161,6 +161,13 @@ pub fn build(b: *std.Build) void {
161161 .root_source_file = b.path("src/mobile.zig"),
162162 });
163163
164 // Rooted at `src/ios.zig`, which reaches `src/mobile.zig` through its own
165 // relative import — so this module owns both files and must not be mixed
166 // with `mobile_module` in one compilation.
167 const ios_module = b.createModule(.{
168 .root_source_file = b.path("src/ios.zig"),
169 });
170
164171 const menubar_module = b.createModule(.{
165172 .root_source_file = b.path("src/menubar.zig"),
166173 });
@@ -292,6 +299,65 @@ pub fn build(b: *std.Build) void {
292299 }),
293300 });
294301
302 // The iOS surface gate. Rooted at `src/ios.zig` rather than sharing
303 // `mobile_module`, because a file may belong to only one module per
304 // compilation and `ios.zig` reaches `mobile.zig` through a relative
305 // import of its own. The `mobile.iOS` half of the gate therefore lives in
306 // `test/mobile_test.zig`, which already owns that module.
307 //
308 // This builds for the host, so it costs seconds and runs in `zig build
309 // test` — which is the point. Cross-compiling to iOS is the truth, but it
310 // is not a loop anyone iterates in locally.
311 const ios_surface_tests = b.addTest(.{
312 .root_module = b.createModule(.{
313 .root_source_file = b.path("test/ios_surface_test.zig"),
314 .target = target,
315 .optimize = optimize,
316 .imports = &.{
317 .{ .name = "../src/ios.zig", .module = ios_module },
318 },
319 }),
320 });
321 // Forcing analysis of every declaration means the linker now sees every
322 // `objc_msgSend`/`sel_registerName` reference too, so the ObjC runtime has
323 // to be present. No frameworks are needed: nothing here calls UIKit or
324 // AppKit, it only resolves classes by name at runtime — which is exactly
325 // why `[UIScreen mainScreen]` returns null on the host rather than failing
326 // to link.
327 if (target.result.os.tag.isDarwin()) {
328 ios_surface_tests.root_module.link_libc = true;
329 ios_surface_tests.root_module.linkSystemLibrary("objc", .{});
330 // `ios_dispatch` replies through `bridge_error`, which reaches
331 // `bridge.evalJS`, which on a macOS *host* build resolves to the
332 // desktop arm and drags in the AppKit/CoreFoundation surface behind it.
333 // That is the price of the gate analysing the real reply path rather
334 // than a stub of one, and it is the right trade: the alternative is a
335 // test that compiles iOS code which never reaches the code it replies
336 // through.
337 ios_surface_tests.root_module.linkFramework("Cocoa", .{});
338 ios_surface_tests.root_module.linkFramework("WebKit", .{});
339 ios_surface_tests.root_module.linkFramework("CoreFoundation", .{});
340 ios_surface_tests.root_module.linkFramework("CoreGraphics", .{});
341 ios_surface_tests.root_module.linkFramework("CoreMIDI", .{});
342 }
343
344 // The iOS conformance gate. It embeds the Swift template as the migration
345 // spec and `bridge_mobile.zig` as what Zig currently serves, so an action
346 // cannot be dropped on the way across without the build saying so.
347 const ios_conformance_tests = b.addTest(.{
348 .root_module = b.createModule(.{
349 .root_source_file = b.path("test/ios_conformance_test.zig"),
350 .target = target,
351 .optimize = optimize,
352 }),
353 });
354 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
355 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
356 });
357 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
358 .root_source_file = b.path("src/bridge_mobile.zig"),
359 });
360
295361 const menubar_tests = b.addTest(.{
296362 .root_module = b.createModule(.{
297363 .root_source_file = b.path("test/menubar_test.zig"),
@@ -1136,6 +1202,8 @@ pub fn build(b: *std.Build) void {
11361202
11371203 const run_api_tests = b.addRunArtifact(api_tests);
11381204 const run_mobile_tests = b.addRunArtifact(mobile_tests);
1205 const run_ios_surface_tests = b.addRunArtifact(ios_surface_tests);
1206 const run_ios_conformance_tests = b.addRunArtifact(ios_conformance_tests);
11391207 const run_menubar_tests = b.addRunArtifact(menubar_tests);
11401208 const run_components_tests = b.addRunArtifact(components_tests);
11411209 const run_gpu_tests = b.addRunArtifact(gpu_tests);
@@ -1261,6 +1329,18 @@ pub fn build(b: *std.Build) void {
12611329 if (js_test_run) |run| test_step.dependOn(&run.step);
12621330 test_step.dependOn(&run_api_tests.step);
12631331 test_step.dependOn(&run_mobile_tests.step);
1332 // Darwin only. `objc_runtime.zig` resolves `objc` to an empty struct off
1333 // Apple platforms, so `ios.zig` cannot compile on Linux at all — `objc.id`
1334 // is not a member of `struct {}`. Forcing analysis of every iOS
1335 // declaration there asks the compiler to check code that has no meaning on
1336 // the target, and CI is right to refuse.
1337 //
1338 // The conformance gate is not gated the same way: it scans source text and
1339 // touches no platform types, so it runs everywhere.
1340 if (target.result.os.tag.isDarwin()) {
1341 test_step.dependOn(&run_ios_surface_tests.step);
1342 }
1343 test_step.dependOn(&run_ios_conformance_tests.step);
12641344 test_step.dependOn(&run_menubar_tests.step);
12651345 test_step.dependOn(&run_components_tests.step);
12661346 test_step.dependOn(&run_gpu_tests.step);
@@ -1339,6 +1419,12 @@ pub fn build(b: *std.Build) void {
13391419 const test_mobile_step = b.step("test:mobile", "Run Mobile tests");
13401420 test_mobile_step.dependOn(&run_mobile_tests.step);
13411421
1422 const test_ios_step = b.step("test:ios", "Run the iOS surface gate");
1423 if (target.result.os.tag.isDarwin()) {
1424 test_ios_step.dependOn(&run_ios_surface_tests.step);
1425 }
1426 test_ios_step.dependOn(&run_ios_conformance_tests.step);
1427
13421428 const test_menubar_step = b.step("test:menubar", "Run Menubar tests");
13431429 test_menubar_step.dependOn(&run_menubar_tests.step);
13441430
@@ -1792,11 +1878,6 @@ pub fn build(b: *std.Build) void {
17921878 const build_android_x86 = b.step("build-android-x86", "Build for Android (x86_64)");
17931879 const build_android_all = b.step("build-android-all", "Build for Android (all architectures)");
17941880
1795 // Android module
1796 const android_module = b.createModule(.{
1797 .root_source_file = b.path("src/android.zig"),
1798 });
1799
18001881 // Android Device (arm64)
18011882 const android_arm64_target = b.resolveTargetQuery(.{
18021883 .cpu_arch = .aarch64,
@@ -1847,46 +1928,13 @@ pub fn build(b: *std.Build) void {
18471928 build_android_x86.dependOn(&android_x86_install.step);
18481929 build_android_all.dependOn(&android_x86_install.step);
18491930
1850 // ========================================================================
1851 // Android Example (demo mode - runs on host for testing)
1852 // ========================================================================
1853
1854 const run_android = b.step("run-android", "Run the Android example (demo mode)");
1855
1856 const android_demo_exe = b.addExecutable(.{
1857 .name = "android-example",
1858 .root_module = b.createModule(.{
1859 .root_source_file = b.path("examples/android/main.zig"),
1860 .target = target,
1861 .optimize = optimize,
1862 .imports = &.{
1863 .{ .name = "craft", .module = craft_module },
1864 .{ .name = "android", .module = android_module },
1865 },
1866 }),
1867 });
1868
1869 switch (target_os) {
1870 .macos => {
1871 android_demo_exe.root_module.linkFramework("Cocoa", .{});
1872 android_demo_exe.root_module.linkFramework("WebKit", .{});
1873 },
1874 .linux => {
1875 android_demo_exe.root_module.linkSystemLibrary("gtk+-3.0", .{});
1876 android_demo_exe.root_module.linkSystemLibrary("webkit2gtk-4.1", .{});
1877 },
1878 .windows => {
1879 android_demo_exe.root_module.linkSystemLibrary("ole32", .{});
1880 android_demo_exe.root_module.linkSystemLibrary("user32", .{});
1881 android_demo_exe.root_module.linkSystemLibrary("gdi32", .{});
1882 android_demo_exe.root_module.linkSystemLibrary("shell32", .{});
1883 },
1884 else => {},
1885 }
1886 android_demo_exe.root_module.link_libc = true;
1887
1888 const run_android_cmd = b.addRunArtifact(android_demo_exe);
1889 run_android.dependOn(&run_android_cmd.step);
1931 // The `run-android` step and its `android-example` executable used to sit
1932 // here. It was not an Android artifact: it built for the *host* and linked
1933 // Cocoa/WebKit on macOS and GTK on Linux, so "running the Android example"
1934 // meant running a desktop printf demo. Its source called
1935 // `AndroidFeatures.getDeviceInfo()`, which returned a hardcoded
1936 // "Android Device" / SDK 34 no matter what, and printed the result as if
1937 // it had queried something.
18901938
18911939 // ========================================================================
18921940 // Android Tests
packages/zig/examples/android/main.zigdeleted+0-281
Changes to packages/zig/examples/android/main.zig
@@ -1,281 +0,0 @@
1const std = ;
2const craft = ;
3
4/// Android Example
5///
6/// Demonstrates how to build Android apps with Craft:
7/// - CraftActivity for Android lifecycle
8/// - JavaScript bridge for web-native communication
9/// - Native Android features (toast, vibration, etc.)
10
11pub fn main() !void {
12 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
13 defer _ = gpa.deinit();
14 const allocator = gpa.allocator();
15
16 std.debug.print("\n=== Craft Android Demo ===\n\n", .{});
17
18 // Demo 1: Create CraftActivity
19 std.debug.print("1. Creating CraftActivity...\n", .{});
20
21 var app = craft.CraftActivity.init(allocator, .{
22 .name = "My Craft App",
23 .package_name = "com.example.craftapp",
24 .initial_content = .{ .html = app_html },
25 .theme = .system,
26 .orientation = .portrait,
27 .enable_javascript = true,
28 .enable_dom_storage = true,
29 .enable_inspector = true, // Enable WebView inspector in debug
30 });
31 defer app.deinit();
32
33 std.debug.print(" Activity created: {s}\n", .{app.config.name});
34 std.debug.print(" Package: {s}\n", .{app.config.package_name});
35
36 // Demo 2: Set lifecycle callbacks
37 std.debug.print("\n2. Setting lifecycle callbacks...\n", .{});
38
39 app.onCreate(onAppCreate);
40 app.onResume(onAppResume);
41 app.onPause(onAppPause);
42 app.onDestroy(onAppDestroy);
43 app.onBackPressed(onBackPressed);
44
45 std.debug.print(" Callbacks registered!\n", .{});
46
47 // Demo 3: Register custom JavaScript handlers
48 std.debug.print("\n3. Registering custom JS handlers...\n", .{});
49
50 try app.run();
51
52 if (app.getBridge()) |bridge| {
53 try bridge.registerHandler("customAction", handleCustomAction);
54 try bridge.registerHandler("getUserData", handleGetUserData);
55 std.debug.print(" Custom handlers registered!\n", .{});
56 }
57
58 // Demo 4: Show available native features
59 std.debug.print("\n4. Available Android features:\n", .{});
60 std.debug.print(" - showToast: Show toast messages\n", .{});
61 std.debug.print(" - vibrate: Device vibration\n", .{});
62 std.debug.print(" - setClipboard/getClipboard: Clipboard access\n", .{});
63 std.debug.print(" - share: Native share dialog\n", .{});
64 std.debug.print(" - openURL: Open URLs in browser\n", .{});
65 std.debug.print(" - showAlert: Native alert dialogs\n", .{});
66
67 // Demo 5: Device info
68 std.debug.print("\n5. Device info:\n", .{});
69 const device = craft.AndroidFeatures.getDeviceInfo();
70 std.debug.print(" Manufacturer: {s}\n", .{device.manufacturer});
71 std.debug.print(" Model: {s}\n", .{device.model});
72 std.debug.print(" OS Version: {s}\n", .{device.os_version});
73 std.debug.print(" SDK Version: {d}\n", .{device.sdk_version});
74
75 std.debug.print("\n=== Demo Complete ===\n", .{});
76 std.debug.print("Build APK with: zig build android-apk\n\n", .{});
77}
78
79// Lifecycle callbacks
80fn onAppCreate() void {
81 std.debug.print(" [LIFECYCLE] onCreate called\n", .{});
82}
83
84fn onAppResume() void {
85 std.debug.print(" [LIFECYCLE] onResume called\n", .{});
86}
87
88fn onAppPause() void {
89 std.debug.print(" [LIFECYCLE] onPause called\n", .{});
90}
91
92fn onAppDestroy() void {
93 std.debug.print(" [LIFECYCLE] onDestroy called\n", .{});
94}
95
96fn onBackPressed() bool {
97 std.debug.print(" [LIFECYCLE] onBackPressed - consuming event\n", .{});
98 return true; // Consume the back button event
99}
100
101// Custom JavaScript handlers
102fn handleCustomAction(params: []const u8, bridge: *craft.AndroidJSBridge, callback_id: []const u8) void {
103 std.debug.print(" [JS] customAction called with: {s}\n", .{params});
104 bridge.sendResponse(callback_id, "{ \"status\": \"ok\" }") catch {};
105}
106
107fn handleGetUserData(_: []const u8, bridge: *craft.AndroidJSBridge, callback_id: []const u8) void {
108 std.debug.print(" [JS] getUserData called\n", .{});
109 const response =
110 \\{ "user": { "name": "John Doe", "email": "john@example.com" } }
111 ;
112 bridge.sendResponse(callback_id, response) catch {};
113}
114
115// Example HTML for the app
116const app_html =
117 \\<!DOCTYPE html>
118 \\<html>
119 \\<head>
120 \\ <meta charset="UTF-8">
121 \\ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
122 \\ <title>Craft Android App</title>
123 \\ <style>
124 \\ * { box-sizing: border-box; margin: 0; padding: 0; }
125 \\ body {
126 \\ font-family: 'Roboto', sans-serif;
127 \\ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
128 \\ min-height: 100vh;
129 \\ padding: 20px;
130 \\ color: white;
131 \\ }
132 \\ .container {
133 \\ max-width: 400px;
134 \\ margin: 0 auto;
135 \\ }
136 \\ h1 {
137 \\ font-size: 1.8rem;
138 \\ margin-bottom: 20px;
139 \\ text-align: center;
140 \\ }
141 \\ .card {
142 \\ background: rgba(255, 255, 255, 0.15);
143 \\ border-radius: 16px;
144 \\ padding: 20px;
145 \\ margin-bottom: 16px;
146 \\ backdrop-filter: blur(10px);
147 \\ }
148 \\ .card h2 {
149 \\ font-size: 1.1rem;
150 \\ margin-bottom: 12px;
151 \\ opacity: 0.9;
152 \\ }
153 \\ button {
154 \\ width: 100%;
155 \\ padding: 14px;
156 \\ border: none;
157 \\ border-radius: 8px;
158 \\ font-size: 1rem;
159 \\ cursor: pointer;
160 \\ margin-bottom: 10px;
161 \\ background: rgba(255, 255, 255, 0.9);
162 \\ color: #333;
163 \\ font-weight: 500;
164 \\ transition: transform 0.1s;
165 \\ }
166 \\ button:active {
167 \\ transform: scale(0.98);
168 \\ }
169 \\ #output {
170 \\ background: rgba(0, 0, 0, 0.2);
171 \\ padding: 12px;
172 \\ border-radius: 8px;
173 \\ font-family: monospace;
174 \\ font-size: 0.85rem;
175 \\ min-height: 60px;
176 \\ word-wrap: break-word;
177 \\ }
178 \\ </style>
179 \\</head>
180 \\<body>
181 \\ <div class="container">
182 \\ <h1>Craft Android</h1>
183 \\
184 \\ <div class="card">
185 \\ <h2>Native Features</h2>
186 \\ <button onclick="showToast()">Show Toast</button>
187 \\ <button onclick="vibrate()">Vibrate</button>
188 \\ <button onclick="shareContent()">Share</button>
189 \\ <button onclick="getPlatform()">Get Platform Info</button>
190 \\ </div>
191 \\
192 \\ <div class="card">
193 \\ <h2>Custom Actions</h2>
194 \\ <button onclick="customAction()">Custom Action</button>
195 \\ <button onclick="getUserData()">Get User Data</button>
196 \\ </div>
197 \\
198 \\ <div class="card">
199 \\ <h2>Output</h2>
200 \\ <div id="output">Ready...</div>
201 \\ </div>
202 \\ </div>
203 \\
204 \\ <script>
205 \\ function log(msg) {
206 \\ document.getElementById('output').textContent = msg;
207 \\ }
208 \\
209 \\ async function invoke(method, params = {}) {
210 \\ return new Promise((resolve) => {
211 \\ const callbackId = 'cb_' + Date.now();
212 \\ window['__craftCallback_' + callbackId] = (result) => {
213 \\ delete window['__craftCallback_' + callbackId];
214 \\ resolve(result);
215 \\ };
216 \\ if (window.CraftBridge) {
217 \\ CraftBridge.postMessage(JSON.stringify({
218 \\ method: method,
219 \\ params: params,
220 \\ callbackId: callbackId
221 \\ }));
222 \\ } else {
223 \\ log('CraftBridge not available (demo mode)');
224 \\ resolve({ demo: true });
225 \\ }
226 \\ });
227 \\ }
228 \\
229 \\ async function showToast() {
230 \\ const result = await invoke('showToast', { message: 'Hello from Craft!' });
231 \\ log('Toast: ' + JSON.stringify(result));
232 \\ }
233 \\
234 \\ async function vibrate() {
235 \\ const result = await invoke('vibrate', { duration: 100 });
236 \\ log('Vibrate: ' + JSON.stringify(result));
237 \\ }
238 \\
239 \\ async function shareContent() {
240 \\ const result = await invoke('share', { text: 'Check out Craft!' });
241 \\ log('Share: ' + JSON.stringify(result));
242 \\ }
243 \\
244 \\ async function getPlatform() {
245 \\ const result = await invoke('getPlatform');
246 \\ log('Platform: ' + JSON.stringify(result));
247 \\ }
248 \\
249 \\ async function customAction() {
250 \\ const result = await invoke('customAction', { action: 'test' });
251 \\ log('Custom: ' + JSON.stringify(result));
252 \\ }
253 \\
254 \\ async function getUserData() {
255 \\ const result = await invoke('getUserData');
256 \\ log('User: ' + JSON.stringify(result));
257 \\ }
258 \\
259 \\ // Listen for native events
260 \\ window.addEventListener('craft:ready', () => {
261 \\ log('Craft native bridge ready!');
262 \\ });
263 \\ </script>
264 \\</body>
265 \\</html>
266;
267
268// Example: Using quick start
269pub fn simpleApp() !void {
270 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
271 defer _ = gpa.deinit();
272 const allocator = gpa.allocator();
273
274 // One-liner to start an Android app
275 try craft.android.quickStart(allocator,
276 \\<!DOCTYPE html>
277 \\<html>
278 \\<body><h1>Hello, Android!</h1></body>
279 \\</html>
280 );
281}
packages/zig/src/android.zigmodified+15-252
Changes to packages/zig/src/android.zig
@@ -13,22 +13,6 @@ const mobile = ;
1313/// });
1414/// try app.run();
1515
16// ============================================================================
17// JNI Types (for cross-compilation)
18// ============================================================================
19
20pub const jni = struct {
21 pub const JNIEnv = *anyopaque;
22 pub const jobject = *anyopaque;
23 pub const jclass = *anyopaque;
24 pub const jstring = *anyopaque;
25 pub const jint = i32;
26 pub const jlong = i64;
27 pub const jboolean = u8;
28 pub const jfloat = f32;
29 pub const jdouble = f64;
30};
31
3216// ============================================================================
3317// CraftActivity - Main Android Activity
3418// ============================================================================
@@ -42,8 +26,13 @@ pub const CraftActivity = struct {
4226 webview: ?*mobile.Android.WebView = null,
4327
4428 // JNI references
45 jni_env: ?jni.JNIEnv = null,
46 activity: ?jni.jobject = null,
29 // These were `?jni.JNIEnv` and `?jni.jobject` against a local `jni`
30 // namespace that aliased everything to `*anyopaque` — no vtable, no
31 // JavaVM, nothing that could actually reach Java. Untyped pointers say the
32 // same thing without implying a binding exists. The real types arrive with
33 // `android_jni.zig`, from the NDK's own `jni.h`.
34 jni_env: ?*anyopaque = null,
35 activity: ?*anyopaque = null,
4736
4837 // Callbacks
4938 on_create: ?*const fn () void = null,
@@ -209,91 +198,27 @@ pub const JSBridge = struct {
209198 const Self = @This();
210199
211200 pub fn init(allocator: std.mem.Allocator) JSBridge {
212 var bridge = JSBridge{
201 // No built-in handlers are registered any more. The nine that used to
202 // be here — `getPlatform`, `showToast`, `vibrate`, `setClipboard`,
203 // `getClipboard`, `share`, `openURL`, `getNetworkStatus`, `showAlert`
204 // — each extracted their arguments, discarded them, and replied
205 // `{"success": true}`. `getPlatform` answered with a hardcoded
206 // `"version": "14"`. A handler that fabricates success is worse than a
207 // missing one: the missing one is reported to the caller.
208 return .{
213209 .handlers = std.StringHashMap(Handler).init(allocator),
214210 .allocator = allocator,
215211 };
216
217 // Register built-in handlers
218 bridge.registerBuiltinHandlers() catch |err| {
219 std.log.warn("failed to register Android built-in JS handlers: {}", .{err});
220 };
221
222 return bridge;
223212 }
224213
225214 pub fn deinit(self: *Self) void {
226215 self.handlers.deinit();
227216 }
228217
229 fn registerBuiltinHandlers(self: *Self) !void {
230 try self.handlers.put("getPlatform", handleGetPlatform);
231 try self.handlers.put("showToast", handleShowToast);
232 try self.handlers.put("vibrate", handleVibrate);
233 try self.handlers.put("setClipboard", handleSetClipboard);
234 try self.handlers.put("getClipboard", handleGetClipboard);
235 try self.handlers.put("share", handleShare);
236 try self.handlers.put("openURL", handleOpenURL);
237 try self.handlers.put("getNetworkStatus", handleGetNetworkStatus);
238 try self.handlers.put("showAlert", handleShowAlert);
239 }
240
241218 pub fn registerHandler(self: *Self, name: []const u8, handler: Handler) !void {
242219 try self.handlers.put(name, handler);
243220 }
244221
245 /// Handle message from JavaScript (called via JNI)
246 pub fn handleMessage(self: *Self, message: []const u8) void {
247 const method = self.extractJsonString(message, "method") orelse return;
248 const callback_id = self.extractJsonString(message, "callbackId") orelse "";
249 const params = self.extractJsonObject(message, "params") orelse "{}";
250
251 if (self.handlers.get(method)) |handler| {
252 handler(params, self, callback_id);
253 } else {
254 self.sendError(callback_id, "Unknown method") catch |err| {
255 std.log.warn("failed to send unknown method error to Android JS: {}", .{err});
256 };
257 }
258 }
259
260 fn extractJsonString(self: *Self, json: []const u8, key: []const u8) ?[]const u8 {
261 _ = self;
262 var pattern_buf: [64]u8 = undefined;
263 const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":\"", .{key}) catch return null;
264
265 if (std.mem.indexOf(u8, json, pattern)) |start| {
266 const value_start = start + pattern.len;
267 if (value_start < json.len) {
268 if (std.mem.indexOf(u8, json[value_start..], "\"")) |end| {
269 return json[value_start..][0..end];
270 }
271 }
272 }
273 return null;
274 }
275
276 fn extractJsonObject(self: *Self, json: []const u8, key: []const u8) ?[]const u8 {
277 _ = self;
278 var pattern_buf: [64]u8 = undefined;
279 const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":{{", .{key}) catch return null;
280
281 if (std.mem.indexOf(u8, json, pattern)) |start| {
282 const value_start = start + pattern.len - 1;
283 if (value_start < json.len) {
284 var depth: usize = 0;
285 var i: usize = value_start;
286 while (i < json.len) : (i += 1) {
287 if (json[i] == '{') depth += 1 else if (json[i] == '}') {
288 depth -= 1;
289 if (depth == 0) return json[value_start .. i + 1];
290 }
291 }
292 }
293 }
294 return null;
295 }
296
297222 pub fn sendResponse(self: *Self, callback_id: []const u8, result: []const u8) !void {
298223 if (self.activity == null) return error.NoActivity;
299224 if (callback_id.len == 0) return;
@@ -328,168 +253,6 @@ pub const JSBridge = struct {
328253
329254 try self.activity.?.evaluateJavaScript(script, null);
330255 }
331
332 // Built-in handlers
333 fn handleGetPlatform(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
334 _ = params;
335 const response =
336 \\{"os": "android", "version": "14", "device": "Android", "native": true}
337 ;
338 bridge.sendResponse(callback_id, response) catch |err| {
339 std.log.debug("failed to send getPlatform response: {}", .{err});
340 };
341 }
342
343 fn handleShowToast(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
344 const message = bridge.extractJsonString(params, "message") orelse "Toast";
345 _ = message;
346 // Would call Toast.makeText via JNI
347 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
348 std.log.debug("failed to send showToast response: {}", .{err});
349 };
350 }
351
352 fn handleVibrate(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
353 _ = params;
354 // Would call Vibrator.vibrate via JNI
355 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
356 std.log.debug("failed to send vibrate response: {}", .{err});
357 };
358 }
359
360 fn handleSetClipboard(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
361 const text = bridge.extractJsonString(params, "text") orelse "";
362 _ = text;
363 // Would call ClipboardManager.setPrimaryClip via JNI
364 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
365 std.log.debug("failed to send setClipboard response: {}", .{err});
366 };
367 }
368
369 fn handleGetClipboard(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
370 _ = params;
371 // Would call ClipboardManager.getPrimaryClip via JNI
372 bridge.sendResponse(callback_id, "{ \"text\": \"\" }") catch |err| {
373 std.log.debug("failed to send getClipboard response: {}", .{err});
374 };
375 }
376
377 fn handleShare(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
378 const text = bridge.extractJsonString(params, "text") orelse "";
379 _ = text;
380 // Would start Intent.ACTION_SEND via JNI
381 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
382 std.log.debug("failed to send share response: {}", .{err});
383 };
384 }
385
386 fn handleOpenURL(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
387 const url = bridge.extractJsonString(params, "url") orelse "";
388 _ = url;
389 // Would start Intent.ACTION_VIEW via JNI
390 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
391 std.log.debug("failed to send openURL response: {}", .{err});
392 };
393 }
394
395 fn handleGetNetworkStatus(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
396 _ = params;
397 // Would check ConnectivityManager via JNI
398 bridge.sendResponse(callback_id, "{ \"connected\": true, \"type\": \"wifi\" }") catch |err| {
399 std.log.debug("failed to send getNetworkStatus response: {}", .{err});
400 };
401 }
402
403 fn handleShowAlert(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
404 const title = bridge.extractJsonString(params, "title") orelse "Alert";
405 const message = bridge.extractJsonString(params, "message") orelse "";
406 _ = title;
407 _ = message;
408 // Would show AlertDialog via JNI
409 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
410 std.log.debug("failed to send showAlert response: {}", .{err});
411 };
412 }
413};
414
415// ============================================================================
416// Android Native Features
417// ============================================================================
418
419pub const AndroidFeatures = struct {
420 /// Show a Toast message
421 pub fn showToast(message: []const u8, long_duration: bool) void {
422 _ = message;
423 _ = long_duration;
424 // JNI call to Toast.makeText().show()
425 }
426
427 /// Vibrate the device
428 pub fn vibrate(duration_ms: i64) void {
429 _ = duration_ms;
430 // JNI call to Vibrator.vibrate()
431 }
432
433 /// Request a permission
434 pub fn requestPermission(permission: []const u8) void {
435 _ = permission;
436 // JNI call to ActivityCompat.requestPermissions()
437 }
438
439 /// Check if permission is granted
440 pub fn hasPermission(permission: []const u8) bool {
441 _ = permission;
442 // JNI call to ContextCompat.checkSelfPermission()
443 return false;
444 }
445
446 /// Open URL in browser
447 pub fn openURL(url: []const u8) void {
448 _ = url;
449 // JNI call to startActivity with ACTION_VIEW intent
450 }
451
452 /// Share text/content
453 pub fn share(text: []const u8) void {
454 _ = text;
455 // JNI call to startActivity with ACTION_SEND intent
456 }
457
458 /// Copy text to clipboard
459 pub fn setClipboard(text: []const u8) void {
460 _ = text;
461 // JNI call to ClipboardManager.setPrimaryClip()
462 }
463
464 /// Get text from clipboard
465 pub fn getClipboard(allocator: std.mem.Allocator) ![]const u8 {
466 _ = allocator;
467 // JNI call to ClipboardManager.getPrimaryClip()
468 return "";
469 }
470
471 /// Check network connectivity
472 pub fn isNetworkConnected() bool {
473 // JNI call to ConnectivityManager.getActiveNetworkInfo()
474 return true;
475 }
476
477 /// Get device info
478 pub fn getDeviceInfo() DeviceInfo {
479 return .{
480 .manufacturer = "Unknown",
481 .model = "Android Device",
482 .os_version = "14",
483 .sdk_version = 34,
484 };
485 }
486
487 pub const DeviceInfo = struct {
488 manufacturer: []const u8,
489 model: []const u8,
490 os_version: []const u8,
491 sdk_version: i32,
492 };
493256};
494257
495258// ============================================================================
packages/zig/src/android_template.zigdeleted+0-553
Changes to packages/zig/src/android_template.zig
@@ -1,553 +0,0 @@
1const std = ;
2const io_context = ;
3
4/// Android Kotlin Template Generator
5/// Generates complete Android app templates with Craft integration
6pub const AndroidTemplate = struct {
7 allocator: std.mem.Allocator,
8 app_name: []const u8,
9 package_name: []const u8,
10 output_dir: []const u8,
11
12 pub fn init(allocator: std.mem.Allocator, app_name: []const u8, package_name: []const u8, output_dir: []const u8) AndroidTemplate {
13 return .{
14 .allocator = allocator,
15 .app_name = app_name,
16 .package_name = package_name,
17 .output_dir = output_dir,
18 };
19 }
20
21 pub fn generate(self: *AndroidTemplate) !void {
22 // Create directory structure
23 try self.createDirectoryStructure();
24
25 // Generate Kotlin files
26 try self.generateMainActivity();
27 try self.generateCraftBridge();
28 try self.generateCameraXBridge();
29 try self.generateBiometricsBridge();
30
31 // Generate configuration files
32 try self.generateAndroidManifest();
33 try self.generateBuildGradle();
34 try self.generateAppBuildGradle();
35 try self.generateStringsXml();
36
37 std.debug.print("Android template generated successfully at: {s}\n", .{self.output_dir});
38 }
39
40 fn createDirectoryStructure(self: *AndroidTemplate) !void {
41 const io = io_context.get();
42 const cwd = io_context.cwd();
43
44 const package_path = try std.mem.replaceOwned(u8, self.allocator, self.package_name, ".", "/");
45 defer self.allocator.free(package_path);
46
47 const dirs = [_][]const u8{
48 self.output_dir,
49 try std.fmt.allocPrint(self.allocator, "{s}/app", .{self.output_dir}),
50 try std.fmt.allocPrint(self.allocator, "{s}/app/src", .{self.output_dir}),
51 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main", .{self.output_dir}),
52 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/java/{s}", .{ self.output_dir, package_path }),
53 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/java/{s}/bridge", .{ self.output_dir, package_path }),
54 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/java/{s}/features", .{ self.output_dir, package_path }),
55 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/res", .{self.output_dir}),
56 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/res/values", .{self.output_dir}),
57 try std.fmt.allocPrint(self.allocator, "{s}/app/src/main/res/layout", .{self.output_dir}),
58 };
59
60 for (dirs) |dir| {
61 cwd.createDir(io, dir, .default_dir) catch |err| {
62 if (err != error.PathAlreadyExists) return err;
63 };
64 }
65 }
66
67 fn generateMainActivity(self: *AndroidTemplate) !void {
68 const content = try std.fmt.allocPrint(
69 self.allocator,
70 \\package {s}
71 \\
72 \\import android.os.Bundle
73 \\import android.webkit.WebView
74 \\import android.webkit.WebSettings
75 \\import androidx.appcompat.app.AppCompatActivity
76 \\import {s}.bridge.CraftBridge
77 \\
78 \\class MainActivity : AppCompatActivity()
130 \\
131 ,
132 .{ self.package_name, self.package_name },
133 );
134 defer self.allocator.free(content);
135
136 const package_path = try std.mem.replaceOwned(u8, self.allocator, self.package_name, ".", "/");
137 defer self.allocator.free(package_path);
138
139 const path = try std.fmt.allocPrint(self.allocator, "app/src/main/java/{s}/MainActivity.kt", .{package_path});
140 defer self.allocator.free(path);
141
142 try self.writeFile(path, content);
143 }
144
145 fn generateCraftBridge(self: *AndroidTemplate) !void {
146 const content = try std.fmt.allocPrint(
147 self.allocator,
148 \\package {s}.bridge
149 \\
150 \\import android.content.Context
151 \\import android.os.Vibrator
152 \\import android.webkit.JavascriptInterface
153 \\import android.webkit.WebView
154 \\import android.widget.Toast
155 \\import org.json.JSONObject
156 \\
157 \\class CraftBridge(private val context: Context, private val webView: WebView)
249 \\
250 ,
251 .{self.package_name},
252 );
253 defer self.allocator.free(content);
254
255 const package_path = try std.mem.replaceOwned(u8, self.allocator, self.package_name, ".", "/");
256 defer self.allocator.free(package_path);
257
258 const path = try std.fmt.allocPrint(self.allocator, "app/src/main/java/{s}/bridge/CraftBridge.kt", .{package_path});
259 defer self.allocator.free(path);
260
261 try self.writeFile(path, content);
262 }
263
264 fn generateCameraXBridge(self: *AndroidTemplate) !void {
265 const content = try std.fmt.allocPrint(
266 self.allocator,
267 \\package {s}.features
268 \\
269 \\import android.content.Context
270 \\import androidx.camera.core.CameraSelector
271 \\import androidx.camera.core.Preview
272 \\import androidx.camera.lifecycle.ProcessCameraProvider
273 \\import androidx.camera.view.PreviewView
274 \\import androidx.core.content.ContextCompat
275 \\import androidx.lifecycle.LifecycleOwner
276 \\import com.google.common.util.concurrent.ListenableFuture
277 \\
278 \\class CameraXBridge(private val context: Context)
308 \\
309 ,
310 .{self.package_name},
311 );
312 defer self.allocator.free(content);
313
314 const package_path = try std.mem.replaceOwned(u8, self.allocator, self.package_name, ".", "/");
315 defer self.allocator.free(package_path);
316
317 const path = try std.fmt.allocPrint(self.allocator, "app/src/main/java/{s}/features/CameraXBridge.kt", .{package_path});
318 defer self.allocator.free(path);
319
320 try self.writeFile(path, content);
321 }
322
323 fn generateBiometricsBridge(self: *AndroidTemplate) !void {
324 const content = try std.fmt.allocPrint(
325 self.allocator,
326 \\package {s}.features
327 \\
328 \\import android.content.Context
329 \\import androidx.biometric.BiometricManager
330 \\import androidx.biometric.BiometricPrompt
331 \\import androidx.core.content.ContextCompat
332 \\import androidx.fragment.app.FragmentActivity
333 \\
334 \\class BiometricsBridge(private val context: Context)
370 \\
371 ,
372 .{self.package_name},
373 );
374 defer self.allocator.free(content);
375
376 const package_path = try std.mem.replaceOwned(u8, self.allocator, self.package_name, ".", "/");
377 defer self.allocator.free(package_path);
378
379 const path = try std.fmt.allocPrint(self.allocator, "app/src/main/java/{s}/features/BiometricsBridge.kt", .{package_path});
380 defer self.allocator.free(path);
381
382 try self.writeFile(path, content);
383 }
384
385 fn generateAndroidManifest(self: *AndroidTemplate) !void {
386 const content = try std.fmt.allocPrint(
387 self.allocator,
388 \\<?xml version="1.0" encoding="utf-8"?>
389 \\<manifest xmlns:android="http://schemas.android.com/apk/res/android"
390 \\ package="{s}">
391 \\
392 \\ <uses-permission android:name="android.permission.INTERNET" />
393 \\ <uses-permission android:name="android.permission.CAMERA" />
394 \\ <uses-permission android:name="android.permission.VIBRATE" />
395 \\ <uses-permission android:name="android.permission.USE_BIOMETRIC" />
396 \\ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
397 \\
398 \\ <application
399 \\ android:allowBackup="true"
400 \\ android:hardwareAccelerated="true"
401 \\ android:largeHeap="true"
402 \\ android:icon="@mipmap/ic_launcher"
403 \\ android:label="@string/app_name"
404 \\ android:roundIcon="@mipmap/ic_launcher_round"
405 \\ android:supportsRtl="true"
406 \\ android:theme="@style/Theme.AppCompat.Light.DarkActionBar"
407 \\ android:usesCleartextTraffic="true">
408 \\ <activity
409 \\ android:name=".MainActivity"
410 \\ android:exported="true">
411 \\ <intent-filter>
412 \\ <action android:name="android.intent.action.MAIN" />
413 \\ <category android:name="android.intent.category.LAUNCHER" />
414 \\ </intent-filter>
415 \\ </activity>
416 \\ </application>
417 \\</manifest>
418 \\
419 ,
420 .{self.package_name},
421 );
422 defer self.allocator.free(content);
423
424 try self.writeFile("app/src/main/AndroidManifest.xml", content);
425 }
426
427 fn generateBuildGradle(self: *AndroidTemplate) !void {
428 const content =
429 \\// Top-level build file
430 \\buildscript {
431 \\ ext.kotlin_version = "1.9.0"
432 \\ repositories {
433 \\ google()
434 \\ mavenCentral()
435 \\ }
436 \\ dependencies {
437 \\ classpath "com.android.tools.build:gradle:8.1.0"
438 \\ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
439 \\ }
440 \\}
441 \\
442 \\allprojects {
443 \\ repositories {
444 \\ google()
445 \\ mavenCentral()
446 \\ }
447 \\}
448 \\
449 \\task clean(type: Delete) {
450 \\ delete rootProject.buildDir
451 \\}
452 \\
453 ;
454
455 try self.writeFile("build.gradle", content);
456 }
457
458 fn generateAppBuildGradle(self: *AndroidTemplate) !void {
459 const content = try std.fmt.allocPrint(
460 self.allocator,
461 \\plugins
465 \\
466 \\android
493 \\
494 \\dependencies
509 \\
510 ,
511 .{self.package_name},
512 );
513 defer self.allocator.free(content);
514
515 try self.writeFile("app/build.gradle", content);
516 }
517
518 fn generateStringsXml(self: *AndroidTemplate) !void {
519 const content = try std.fmt.allocPrint(
520 self.allocator,
521 \\<resources>
522 \\ <string name="app_name">{s}</string>
523 \\</resources>
524 \\
525 ,
526 .{self.app_name},
527 );
528 defer self.allocator.free(content);
529
530 try self.writeFile("app/src/main/res/values/strings.xml", content);
531 }
532
533 fn writeFile(self: *AndroidTemplate, relative_path: []const u8, content: []const u8) !void {
534 const full_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.output_dir, relative_path });
535 defer self.allocator.free(full_path);
536
537 const io = io_context.get();
538 const cwd = io_context.cwd();
539 const file = try cwd.createFile(io, full_path, .{});
540 defer file.close(io);
541
542 try file.writeStreamingAll(io, content);
543 }
544};
545
546// Test
547test "Android template generation" {
548 const allocator = std.testing.allocator;
549 const template = AndroidTemplate.init(allocator, "TestApp", "com.test.app", "test_android_output");
550
551 // Would generate files - skipping in test
552 _ = template;
553}
packages/zig/src/bridge.zigmodified+12-0
Changes to packages/zig/src/bridge.zig
@@ -9,7 +9,19 @@ pub fn evalJS(script: []const u8) !void {
99 const macos = ;
1010 try macos.tryEvalJS(script);
1111 },
12 .ios => {
13 const ios_dispatch = ;
14 try ios_dispatch.evalJS(script);
15 },
1216 .linux => {
17 // Android reports `os.tag == .linux` (its target is
18 // `linux-android`), so without this guard it lands in the GTK arm
19 // below and drags in `linux.zig`'s 44 `extern "c"` GTK/WebKit2GTK
20 // declarations. That costs nothing in a static archive — which is
21 // exactly why it has never surfaced — and fails the link the
22 // moment Android emits a shared library.
23 if (comptime builtin.abi == .android) return error.AndroidBridgeNotImplemented;
24
1325 const linux = ;
1426 try linux.evalJS(script);
1527 },
packages/zig/src/bridge_mobile.zigadded+147-0
Changes to packages/zig/src/bridge_mobile.zig
@@ -0,0 +1,147 @@
1const std = ;
2const builtin = ;
3const capabilities = ;
4const bridge_error = ;
5const objc_runtime = ;
6
7const objc = objc_runtime.objc;
8
9/// The `mobile` namespace: what a page can ask about the device it is running on.
10///
11/// This is the first namespace craft serves from Zig on a phone. It carries one
12/// action, deliberately — the point of the first one is to prove the whole path
13/// (page → script message handler → dispatcher → native → reply → page), and a
14/// second action proves nothing the first did not.
15///
16/// `getDeviceInfo` was chosen over the alternatives because its answer cannot be
17/// faked. `"systemName":"iOS"` and `"isSimulator":true` can only come from a
18/// real UIKit process; a stub, a browser fallback, or a half-wired bridge
19/// cannot produce them. Compare the fallback this replaces, which resolved
20/// `{success:true, browser:true}` and made "the bridge worked" and "there is no
21/// bridge" identical to the caller.
22///
23/// `haptic` would have been the obvious first pick and is the wrong one: it is
24/// fire-and-forget, so it exercises no reply path, and Core Haptics is a silent
25/// no-op on the simulator — it would have passed while doing nothing.
26pub const A = struct {
27 pub const get_device_info = "getDeviceInfo";
28};
29
30pub const capability_actions = [_]capabilities.ActionDecl{
31 .{ .name = A.get_device_info, .reply = .result },
32};
33
34pub const MobileBridge = struct {
35 allocator: std.mem.Allocator,
36
37 const Self = @This();
38
39 pub fn init(allocator: std.mem.Allocator) Self {
40 return .{ .allocator = allocator };
41 }
42
43 pub fn deinit(_: *Self) void {}
44
45 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
46 _ = data;
47 if (std.mem.eql(u8, action, A.get_device_info)) {
48 try self.getDeviceInfo();
49 } else {
50 return bridge_error.BridgeError.UnknownAction;
51 }
52 }
53
54 fn getDeviceInfo(self: *Self) !void {
55 var buf: [512]u8 = undefined;
56 const json = try describeDevice(&buf);
57 bridge_error.sendResultToJS(self.allocator, A.get_device_info, json);
58 }
59};
60
61/// Whether this process is running under the iOS Simulator.
62///
63/// Read from the environment rather than a compile-time target check: a
64/// simulator build and a device build are the same `-ios-simulator` triple to
65/// some toolchains, and the point of reporting this at all is to let a test
66/// distinguish "ran on a simulator" from "was answered by something that never
67/// ran". `SIMULATOR_DEVICE_NAME` is set by simctl in every launched process.
68fn isSimulator() bool {
69 // `std.c.getenv`, not `std.posix.getenv` — the latter does not exist in
70 // this Zig version. `bridge_fs.zig:569` records the same finding.
71 return std.c.getenv("SIMULATOR_DEVICE_NAME") != null;
72}
73
74/// Ask UIKit what device this is, and render it as the JSON the page receives.
75///
76/// Every value here comes from a live `objc_msgSend` into UIKit. That is the
77/// property under test: a wrong answer is possible, but a *fabricated* one is
78/// not, because there is no path to this string that does not go through the
79/// Objective-C runtime in a running application.
80fn describeDevice(buf: []u8) ![]const u8 {
81 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
82
83 const UIDevice = objc.objc_getClass("UIDevice") orelse return error.ClassNotFound;
84 const sel_current = objc.sel_registerName("currentDevice") orelse return error.SelectorNotFound;
85 const device = objc.msgSendId(UIDevice, sel_current);
86 if (device == null) return error.NoCurrentDevice;
87
88 const system_name = try nsStringField(device, "systemName");
89 const system_version = try nsStringField(device, "systemVersion");
90 const model = try nsStringField(device, "model");
91
92 return std.fmt.bufPrint(
93 buf,
94 "",
95 .{ system_name, system_version, model, isSimulator() },
96 );
97}
98
99/// Send a zero-argument selector that returns an NSString, and borrow its UTF-8.
100///
101/// The returned slice points into the NSString's own buffer, which is
102/// autoreleased — fine here because it is formatted into the caller's buffer
103/// before this function's caller returns, and nothing retains it afterwards.
104fn nsStringField(target: objc.id, comptime selector: [:0]const u8) ![]const u8 {
105 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
106 const ns = objc.msgSendId(target, sel);
107 if (ns == null) return error.NilString;
108 const utf8 = objc.getNSStringUTF8(ns) orelse return error.NilString;
109 return std.mem.span(utf8);
110}
111
112const testing = std.testing;
113
114test "the declared action is the one the handler serves" {
115 // A namespace whose table and dispatcher disagree is the failure this
116 // whole effort exists to make impossible, so it is worth asserting even
117 // with one action in the table.
118 try testing.expectEqual(@as(usize, 1), capability_actions.len);
119 try testing.expectEqualStrings(A.get_device_info, capability_actions[0].name);
120 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
121}
122
123test "an action the namespace does not serve is reported, not ignored" {
124 var bridge = MobileBridge.init(testing.allocator);
125 defer bridge.deinit();
126
127 try testing.expectError(
128 bridge_error.BridgeError.UnknownAction,
129 bridge.handleMessage("noSuchAction", "{}"),
130 );
131}
132
133test "device JSON is shaped the way the page expects" {
134 // `describeDevice` needs UIKit, which is absent on the host, so this pins
135 // the contract rather than the values: the fixture page and the simulator
136 // harness both match on these key names.
137 var buf: [512]u8 = undefined;
138 const rendered = try std.fmt.bufPrint(
139 &buf,
140 "",
141 .{ "iOS", "18.0", "iPhone", true },
142 );
143 try testing.expectEqualStrings(
144 "{\"systemName\":\"iOS\",\"systemVersion\":\"18.0\",\"model\":\"iPhone\",\"isSimulator\":true}",
145 rendered,
146 );
147}
packages/zig/src/hotreload_mobile.zigdeleted+0-271
Changes to packages/zig/src/hotreload_mobile.zig
@@ -1,271 +0,0 @@
1const std = ;
2const hotreload = ;
3
4/// Mobile Hot Reload
5/// WebSocket-based hot reload for iOS and Android
6pub const MobileHotReloadServer = struct {
7 allocator: std.mem.Allocator,
8 port: u16,
9 running: bool = false,
10 clients: std.ArrayList(Client),
11
12 const Client = struct {
13 socket: std.net.Stream,
14 platform: []const u8,
15 };
16
17 pub fn init(allocator: std.mem.Allocator, port: u16) MobileHotReloadServer {
18 return .{
19 .allocator = allocator,
20 .port = port,
21 .clients = std.ArrayList(Client).init(allocator),
22 };
23 }
24
25 pub fn deinit(self: *MobileHotReloadServer) void {
26 for (self.clients.items) |client| {
27 client.socket.close();
28 }
29 self.clients.deinit();
30 }
31
32 pub fn start(self: *MobileHotReloadServer) !void {
33 const address = try std.net.Address.parseIp("127.0.0.1", self.port);
34 var server = try address.listen(.{
35 .reuse_address = true,
36 });
37 defer server.deinit();
38
39 self.running = true;
40 std.debug.print("Mobile hot reload server listening on 127.0.0.1:{d}\n", .{self.port});
41
42 while (self.running) {
43 const connection = try server.accept();
44 try self.handleClient(connection);
45 }
46 }
47
48 fn handleClient(self: *MobileHotReloadServer, connection: std.net.Server.Connection) !void {
49 // Simple WebSocket handshake
50 var buffer: [4096]u8 = undefined;
51 const bytes_read = try connection.stream.read(&buffer);
52
53 if (bytes_read == 0) return;
54
55 // Check if it's a WebSocket upgrade request
56 const request = buffer[0..bytes_read];
57 if (std.mem.indexOf(u8, request, "Upgrade: websocket") == null) {
58 connection.stream.close();
59 return;
60 }
61
62 // Send WebSocket handshake response
63 const response =
64 \\HTTP/1.1 101 Switching Protocols
65 \\Upgrade: websocket
66 \\Connection: Upgrade
67 \\Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
68 \\
69 \\
70 ;
71 try connection.stream.writeAll(response);
72
73 // Add client to list
74 try self.clients.append(.{
75 .socket = connection.stream,
76 .platform = "unknown",
77 });
78
79 std.debug.print("Mobile client connected. Total clients: {d}\n", .{self.clients.items.len});
80 }
81
82 pub fn broadcastReload(self: *MobileHotReloadServer, file_path: []const u8) !void {
83 const message = try std.fmt.allocPrint(
84 self.allocator,
85 "",
86 .{file_path},
87 );
88 defer self.allocator.free(message);
89
90 // WebSocket frame for text message
91 var frame = std.ArrayList(u8).init(self.allocator);
92 defer frame.deinit();
93
94 try frame.append(0x81); // FIN + text frame
95 if (message.len < 126) {
96 try frame.append(@as(u8, @intCast(message.len)));
97 } else if (message.len < 65536) {
98 try frame.append(126);
99 try frame.append(@as(u8, @intCast((message.len >> 8) & 0xFF)));
100 try frame.append(@as(u8, @intCast(message.len & 0xFF)));
101 }
102 try frame.appendSlice(message);
103
104 var i: usize = 0;
105 while (i < self.clients.items.len) {
106 const client = self.clients.items[i];
107 client.socket.writeAll(frame.items) catch {
108 // Remove disconnected client
109 _ = self.clients.swapRemove(i);
110 client.socket.close();
111 continue;
112 };
113 i += 1;
114 }
115
116 std.debug.print("Broadcast reload to {d} clients\n", .{self.clients.items.len});
117 }
118
119 pub fn stop(self: *MobileHotReloadServer) void {
120 self.running = false;
121 }
122};
123
124/// Mobile Hot Reload Manager
125/// Integrates file watching with WebSocket broadcast
126pub const MobileHotReloadManager = struct {
127 allocator: std.mem.Allocator,
128 server: MobileHotReloadServer,
129 watcher: hotreload.FileWatcher,
130 running: bool = false,
131
132 pub fn init(allocator: std.mem.Allocator, config: hotreload.HotReloadConfig, port: u16) !MobileHotReloadManager {
133 const server = MobileHotReloadServer.init(allocator, port);
134 const watcher = try hotreload.FileWatcher.init(allocator, config);
135
136 return .{
137 .allocator = allocator,
138 .server = server,
139 .watcher = watcher,
140 };
141 }
142
143 pub fn deinit(self: *MobileHotReloadManager) void {
144 self.server.deinit();
145 self.watcher.deinit();
146 }
147
148 pub fn start(self: *MobileHotReloadManager) !void {
149 self.running = true;
150
151 // Start server in separate thread
152 const server_thread = try std.Thread.spawn(.{}, serverLoop, .{&self.server});
153 defer server_thread.join();
154
155 // Watch for file changes
156 while (self.running) {
157 if (try self.watcher.check()) {
158 try self.server.broadcastReload("*");
159 }
160 std.time.sleep(100 * std.time.ns_per_ms);
161 }
162 }
163
164 fn serverLoop(server: *MobileHotReloadServer) !void {
165 try server.start();
166 }
167
168 pub fn stop(self: *MobileHotReloadManager) void {
169 self.running = false;
170 self.server.stop();
171 }
172};
173
174/// Generate mobile hot reload client script
175pub fn generateClientScript(allocator: std.mem.Allocator, server_url: []const u8) ![]u8 {
176 const script = try std.fmt.allocPrint(
177 allocator,
178 \\(function() )();
246 \\
247 ,
248 .{server_url},
249 );
250
251 return script;
252}
253
254// Tests
255test "Mobile hot reload server init" {
256 const allocator = std.testing.allocator;
257 var server = MobileHotReloadServer.init(allocator, 8765);
258 defer server.deinit();
259
260 try std.testing.expect(server.port == 8765);
261 try std.testing.expect(server.clients.items.len == 0);
262}
263
264test "Generate client script" {
265 const allocator = std.testing.allocator;
266 const script = try generateClientScript(allocator, "ws://localhost:8765");
267 defer allocator.free(script);
268
269 try std.testing.expect(std.mem.indexOf(u8, script, "WebSocket") != null);
270 try std.testing.expect(std.mem.indexOf(u8, script, "localhost:8765") != null);
271}
packages/zig/src/ios.zigmodified+384-511
Changes to packages/zig/src/ios.zig
@@ -1,10 +1,230 @@
11const std = ;
22const objc_runtime = ;
33const mobile = ;
4const ios_dispatch = ;
45
56/// iOS Application Infrastructure
67/// Provides UIApplicationDelegate, UIViewController, and full app lifecycle management
7const objc = objc_runtime.objc;
8/// Re-exported so `test/ios_surface_test.zig` can reach the runtime without
9/// importing `objc_runtime.zig` as a second module — a file may belong to only
10/// one module per compilation, and this one already belongs to the iOS module.
11pub const objc = objc_runtime.objc;
12
13// ============================================================================
14// WKScriptMessageHandler
15// ============================================================================
16
17/// What the page's `webkit.messageHandlers.craft.postMessage` reaches.
18///
19/// This is registered as a real Objective-C method on a class built at runtime,
20/// which is the piece iOS never had. `setupJSBridge` used to fetch the
21/// `WKUserContentController` and then discard it with `_ = content_controller;`
22/// under a comment saying a full implementation would register a handler here.
23/// Because it never did, `webkit.messageHandlers.craft` was `undefined` in
24/// every page craft ever loaded on iOS.
25///
26/// `macos.zig:5017` is the source this is ported from, and it needed no
27/// adaptation beyond the dispatch target: it uses the Objective-C runtime,
28/// Foundation and WebKit only, with nothing from AppKit.
29export fn craftDidReceiveScriptMessage(
30 _: objc.id,
31 _: objc.SEL,
32 _: objc.id,
33 message: objc.id,
34) void {
35 const sel_body = objc.sel_registerName("body") orelse return;
36 const body = objc.msgSendId(message, sel_body);
37 if (body == null) return;
38
39 // Re-serialise the message body with NSJSONSerialization rather than
40 // walking the Objective-C object graph by hand. WebKit has already parsed
41 // the page's object into NSDictionary/NSArray/NSNumber, and asking
42 // Foundation to render it back to JSON is both shorter and correct for
43 // nested values, unicode, and numbers — none of which the substring parser
44 // this replaces could handle.
45 const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse return;
46 const sel_data = objc.sel_registerName("dataWithJSONObject:options:error:") orelse return;
47 const DataFn = *const fn (objc.id, objc.SEL, objc.id, c_ulong, ?*anyopaque) callconv(.c) objc.id;
48 const data_fn: DataFn = @ptrCast(&objc.objc_msgSend);
49 const json_data = data_fn(NSJSONSerialization, sel_data, body, 0, null);
50 if (json_data == null) return;
51
52 const NSString = objc.objc_getClass("NSString") orelse return;
53 const sel_alloc = objc.sel_registerName("alloc") orelse return;
54 const allocated = objc.msgSendId(NSString, sel_alloc);
55 const sel_init = objc.sel_registerName("initWithData:encoding:") orelse return;
56 const NSUTF8StringEncoding: c_ulong = 4;
57 const InitFn = *const fn (objc.id, objc.SEL, objc.id, c_ulong) callconv(.c) objc.id;
58 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
59 const json_string = init_fn(allocated, sel_init, json_data, NSUTF8StringEncoding);
60 if (json_string == null) return;
61 defer objc.release(json_string);
62
63 const utf8 = objc.getNSStringUTF8(json_string) orelse return;
64 const json = std.mem.span(utf8);
65
66 ios_dispatch.handleMessage(std.heap.c_allocator, json) catch |err| {
67 // A message that cannot be routed is logged, not swallowed. The page
68 // has already been answered with an error by the dispatcher where one
69 // could be attributed; this covers the cases where it could not.
70 std.log.warn("ios bridge: could not handle message: {}", .{err});
71 };
72}
73
74/// Build the handler class once and attach it to the content controller under
75/// the name `craft` — which is what `craft-bridge.js` already posts to.
76///
77/// `objc_getClass` first, so a second call is a no-op rather than a duplicate
78/// class registration.
79fn installScriptMessageHandler(content_controller: objc.id) !void {
80 const class_name = "CraftIOSScriptMessageHandler";
81
82 var handler_class = objc.objc_getClass(class_name);
83 if (handler_class == null) {
84 const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound;
85 handler_class = objc.objc_allocateClassPair(NSObject, class_name, 0) orelse
86 return error.ClassAllocationFailed;
87
88 const sel = objc.sel_registerName("userContentController:didReceiveScriptMessage:") orelse
89 return error.SelectorNotFound;
90 const imp: objc.IMP = @ptrCast(@constCast(&craftDidReceiveScriptMessage));
91 // v@:@@ — returns void, takes self, _cmd, and two objects.
92 if (!objc.class_addMethod(handler_class, sel, imp, "v@:@@")) {
93 return error.MethodNotAdded;
94 }
95 objc.objc_registerClassPair(handler_class);
96 }
97
98 const handler = try objc.allocInit(handler_class);
99
100 const sel_add = objc.sel_registerName("addScriptMessageHandler:name:") orelse
101 return error.SelectorNotFound;
102 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
103 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return error.SelectorNotFound;
104 const name = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "craft"));
105
106 const AddFn = *const fn (objc.id, objc.SEL, objc.id, objc.id) callconv(.c) void;
107 const add_fn: AddFn = @ptrCast(&objc.objc_msgSend);
108 add_fn(content_controller, sel_add, handler, name);
109}
110
111/// Install a script that runs before the page's own scripts do.
112///
113/// The previous code called `evaluateJavaScript` *before* `loadInitialContent`,
114/// so the script ran against the empty document and the navigation that
115/// followed discarded it. A `WKUserScript` at `atDocumentStart` is the
116/// mechanism that actually survives navigation, and it must be added to the
117/// content controller before the webview is constructed — which is why webview
118/// creation moved into this file.
119fn installUserScript(content_controller: objc.id, source: [:0]const u8) !void {
120 const WKUserScript = objc.objc_getClass("WKUserScript") orelse return error.ClassNotFound;
121 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
122 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return error.SelectorNotFound;
123 const ns_source = objc.msgSendId1(NSString, sel_string, source.ptr);
124
125 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
126 const allocated = objc.msgSendId(WKUserScript, sel_alloc);
127
128 const sel_init = objc.sel_registerName("initWithSource:injectionTime:forMainFrameOnly:") orelse
129 return error.SelectorNotFound;
130 // 0 == WKUserScriptInjectionTimeAtDocumentStart
131 const InitFn = *const fn (objc.id, objc.SEL, objc.id, c_long, bool) callconv(.c) objc.id;
132 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
133 const user_script = init_fn(allocated, sel_init, ns_source, 0, true);
134 if (user_script == null) return error.UserScriptCreationFailed;
135
136 const sel_add = objc.sel_registerName("addUserScript:") orelse return error.SelectorNotFound;
137 objc.msgSendVoid1(content_controller, sel_add, user_script);
138}
139
140/// The Phase 1 user script.
141///
142/// Deliberately not `craft-bridge.js`: that file defines `craft.window.*`,
143/// `craft.tray.*`, and `craft.menu.*`, surfaces iOS has no business exposing,
144/// and deciding which of them iOS should carry is a larger question than the
145/// first vertical slice needs to answer.
146///
147/// What it does prove is injection *timing*: a page can observe this flag from
148/// its own inline script, which is only possible if the user script ran at
149/// document start. That is the exact property the previous implementation got
150/// wrong.
151const phase1_user_script: [:0]const u8 =
152 \\window.__craftInjectedAtDocumentStart = true;
153;
154
155// ============================================================================
156// UIApplicationMain
157// ============================================================================
158
159/// int UIApplicationMain(int, char *[], NSString *principal, NSString *delegate)
160extern "c" fn UIApplicationMain(
161 argc: c_int,
162 argv: [*][*:0]u8,
163 principal_class_name: objc.id,
164 delegate_class_name: objc.id,
165) c_int;
166
167const app_delegate_class_name = "CraftAppDelegate";
168
169/// The Zig delegate UIKit's instance calls back into.
170///
171/// UIKit constructs the delegate itself, from the class name handed to
172/// `UIApplicationMain`, so the callback receives an Objective-C object that
173/// knows nothing about this struct. A module-level pointer is the bridge
174/// between them. One slot, because an iOS process has one `UIApplication`.
175var g_delegate: ?*CraftAppDelegate = null;
176
177/// `application:didFinishLaunchingWithOptions:`
178///
179/// The first moment UIKit is up and it is legal to touch `UIScreen`,
180/// `UIWindow`, or `makeKeyAndVisible`. Everything `run` used to do inline
181/// happens from here.
182///
183/// Returns `bool` rather than `i8`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL`
184/// is defined, so `BOOL` is C99 `_Bool` and the type encoding is `B`.
185export fn craftAppDidFinishLaunching(
186 _: objc.id,
187 _: objc.SEL,
188 _: objc.id,
189 _: objc.id,
190) bool {
191 const delegate = g_delegate orelse {
192 std.log.err("ios: didFinishLaunching fired with no delegate registered", .{});
193 return false;
194 };
195
196 delegate.didFinishLaunching() catch |err| {
197 // Returning false from here tells UIKit the launch failed, which is
198 // the truthful answer. The alternative — returning true over a window
199 // that was never built — produces a black screen and no diagnostic,
200 // which is the failure mode this whole file is being rewritten to
201 // stop producing.
202 std.log.err("ios: launch failed: {}", .{err});
203 return false;
204 };
205
206 return true;
207}
208
209/// Register the delegate class UIKit will instantiate.
210///
211/// Same runtime class-building the script message handler uses, and the same
212/// `objc_getClass`-first guard so a second call is a no-op.
213pub fn registerAppDelegateClass() !void {
214 if (objc.objc_getClass(app_delegate_class_name) != null) return;
215
216 const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound;
217 const cls = objc.objc_allocateClassPair(NSObject, app_delegate_class_name, 0) orelse
218 return error.ClassAllocationFailed;
219
220 const sel = objc.sel_registerName("application:didFinishLaunchingWithOptions:") orelse
221 return error.SelectorNotFound;
222 const imp: objc.IMP = @ptrCast(@constCast(&craftAppDidFinishLaunching));
223 // B@:@@ — returns BOOL, takes self, _cmd, UIApplication, NSDictionary.
224 if (!objc.class_addMethod(cls, sel, imp, "B@:@@")) return error.MethodNotAdded;
225
226 objc.objc_registerClassPair(cls);
227}
8228
9229// ============================================================================
10230// iOS Application Delegate
@@ -16,7 +236,6 @@ pub const CraftAppDelegate = struct {
16236 window: ?objc.id = null,
17237 root_view_controller: ?objc.id = null,
18238 webview: ?*mobile.iOS.WKWebView = null,
19 js_bridge: ?*JSBridge = null,
20239 allocator: std.mem.Allocator,
21240 config: AppConfig,
22241
@@ -107,160 +326,93 @@ pub const CraftAppDelegate = struct {
107326 self.on_memory_warning = callback;
108327 }
109328
110 /// Register a custom JavaScript handler
111 /// The handler will be called when JS calls: craft.invoke('name', params)
112 pub fn registerJSHandler(self: *Self, name: []const u8, handler: JSBridge.Handler) !void {
113 if (self.js_bridge) |bridge| {
114 try bridge.registerHandler(name, handler);
115 }
116 }
117
118 /// Get the JavaScript bridge for advanced usage
119 pub fn getBridge(self: *Self) ?*JSBridge {
120 return self.js_bridge;
121 }
122
123 /// Start the iOS application
124 /// This should be called from main() and will not return until the app terminates
125 pub fn run(self: *Self) !void {
126 if (!.target.os.tag.isDarwin()) {
127 return error.UnsupportedPlatform;
329 // `registerJSHandler` and `getBridge` used to sit here, handing callers a
330 // `*JSBridge` whose `handleMessage` had no caller anywhere in the tree —
331 // `setupJSBridge` below never registered a `WKScriptMessageHandler`, so
332 // nothing could ever deliver a message to it. They are gone along with the
333 // bridge itself; the replacement routes through `ios_dispatch.zig` and the
334 // same `bridge_*.zig` modules the desktop uses.
335
336 /// Start the iOS application. Does not return.
337 ///
338 /// An iOS app starts by handing control to `UIApplicationMain`, which
339 /// creates the `UIApplication`, instantiates the delegate class it is
340 /// named, and runs the event loop. Everything this file does to UIKit is
341 /// only legal after that has happened: `[UIScreen mainScreen]` is nil
342 /// before it, so a window built beforehand takes its frame from garbage,
343 /// and `makeKeyAndVisible` has no application instance to key against.
344 ///
345 /// So `run` does two things and then stops being in charge: register a
346 /// delegate class whose `application:didFinishLaunchingWithOptions:` is
347 /// `craftAppDidFinishLaunching`, and call `UIApplicationMain`. The window,
348 /// the webview, and the bridge are all built from inside that callback.
349 ///
350 /// The previous version spun `[[NSRunLoop currentRunLoop] run]` on the
351 /// calling thread, which UIKit does not own — no touch delivery, no
352 /// lifecycle notifications, no `UIApplication.sharedApplication`.
353 pub fn run(self: *Self, argc: c_int, argv: [*][*:0]u8) noreturn {
354 // `comptime`, not a runtime check: `UIApplicationMain` lives in UIKit,
355 // which does not exist on macOS. A runtime branch would still leave the
356 // call analysed and the symbol referenced, and the host build — which
357 // is where `test/ios_surface_test.zig` forces analysis of this file —
358 // would fail to link.
359 if (comptime .target.os.tag != .ios) {
360 std.log.err("ios: run() is only callable on iOS", .{});
361 std.process.exit(1);
128362 }
129363
130 // Initialize global object manager for memory tracking
131364 mobile.initGlobalObjectManager(self.allocator);
132 defer mobile.deinitGlobalObjectManager();
133
134 // Initialize JavaScript bridge
135 const bridge = try self.allocator.create(JSBridge);
136 bridge.* = JSBridge.init(self.allocator);
137 bridge.app_delegate = self;
138 self.js_bridge = bridge;
139 defer {
140 bridge.deinit();
141 self.allocator.destroy(bridge);
142 }
143365
144 // Create UIWindow
145 try self.createWindow();
366 // UIKit instantiates the delegate class itself, so the callback below
367 // receives an Objective-C instance rather than this Zig struct. This is
368 // how it finds its way back.
369 g_delegate = self;
146370
147 // Create and configure root view controller with WKWebView
148 try self.createRootViewController();
371 registerAppDelegateClass() catch |err| {
372 std.log.err("ios: could not register the app delegate class: {}", .{err});
373 std.process.exit(1);
374 };
149375
150 // Set up JavaScript bridge with WKWebView
151 try self.setupJSBridge();
376 const NSString = objc.objc_getClass("NSString") orelse std.process.exit(1);
377 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse std.process.exit(1);
378 const delegate_name = objc.msgSendId1(
379 NSString,
380 sel_string,
381 @as([*:0]const u8, app_delegate_class_name),
382 );
152383
153 // Inject craft bridge JavaScript
154 try self.injectBridgeScript();
384 _ = UIApplicationMain(argc, argv, null, delegate_name);
155385
156 // Load initial content
157 try self.loadInitialContent();
386 // `UIApplicationMain` does not return under normal operation.
387 std.process.exit(0);
388 }
158389
159 // Make window visible
390 /// Build the UI. Called by UIKit, once, from
391 /// `application:didFinishLaunchingWithOptions:`.
392 ///
393 /// This is the body `run` used to have, moved to the only point where it is
394 /// legal to execute it.
395 pub fn didFinishLaunching(self: *Self) !void {
396 try self.createWindow();
397 try self.createRootViewController();
398 try self.loadInitialContent();
160399 try self.showWindow();
161400
162 // Dispatch ready event to JavaScript
163 if (self.js_bridge) |bridge_ptr| {
164 bridge_ptr.sendEvent("ready", "{}") catch |err| {
165 std.log.warn("failed to send ready event to JS bridge: {}", .{err});
166 };
167 }
168
169 // Call launch callback
170401 if (self.on_launch) |callback| {
171402 callback();
172403 }
173
174 // Start the run loop (blocks until app terminates)
175 try self.runMainLoop();
176 }
177
178 /// Set up WKScriptMessageHandler for JavaScript bridge
179 fn setupJSBridge(self: *Self) !void {
180 if (self.webview == null) return error.WebViewNotInitialized;
181
182 // Get the webview's configuration
183 const webview_ptr: objc.id = @ptrCast(@alignCast(self.webview.?));
184 const sel_configuration = objc.sel_registerName("configuration") orelse return error.SelectorNotFound;
185 const configuration = objc.msgSendId(webview_ptr, sel_configuration);
186
187 // Get user content controller
188 const sel_userContentController = objc.sel_registerName("userContentController") orelse return error.SelectorNotFound;
189 const content_controller = objc.msgSendId(configuration, sel_userContentController);
190
191 // Note: In a full implementation, we would create a custom Objective-C class
192 // that implements WKScriptMessageHandler protocol and register it here.
193 // For now, the JS bridge will work through evaluateJavaScript polling.
194
195 _ = content_controller;
196404 }
197405
198 /// Inject the craft bridge JavaScript into the webview
199 fn injectBridgeScript(self: *Self) !void {
200 if (self.webview == null) return error.WebViewNotInitialized;
201
202 // This JavaScript sets up the craft object that web apps can use
203 const bridge_script =
204 \\(function() {
205 \\ if (window.craft) return; // Already initialized
206 \\
207 \\ window.craft = {
208 \\ _callbacks: {},
209 \\ _callbackId: 0,
210 \\
211 \\ isNative: function() {
212 \\ return !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft);
213 \\ },
214 \\
215 \\ invoke: function(method, params) {
216 \\ var self = this;
217 \\ params = params || {};
218 \\
219 \\ return new Promise(function(resolve, reject) {
220 \\ var callbackId = String(++self._callbackId);
221 \\
222 \\ // Store callback
223 \\ window['__craftCallback_' + callbackId] = function(result) {
224 \\ delete window['__craftCallback_' + callbackId];
225 \\ if (result && result.error) {
226 \\ reject(new Error(result.error));
227 \\ } else {
228 \\ resolve(result);
229 \\ }
230 \\ };
231 \\
232 \\ // Send to native
233 \\ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft) {
234 \\ window.webkit.messageHandlers.craft.postMessage({
235 \\ method: method,
236 \\ params: params,
237 \\ callbackId: callbackId
238 \\ });
239 \\ } else {
240 \\ // Browser fallback - simulate native response
241 \\ setTimeout(function() {
242 \\ window['__craftCallback_' + callbackId]({ success: true, browser: true });
243 \\ }, 10);
244 \\ }
245 \\
246 \\ // Timeout after 10 seconds
247 \\ setTimeout(function() {
248 \\ if (window['__craftCallback_' + callbackId]) {
249 \\ delete window['__craftCallback_' + callbackId];
250 \\ reject(new Error('Timeout'));
251 \\ }
252 \\ }, 10000);
253 \\ });
254 \\ }
255 \\ };
256 \\
257 \\ // Dispatch ready event
258 \\ window.dispatchEvent(new CustomEvent('craft:ready'));
259 \\})();
260 ;
261
262 try self.evaluateJavaScript(bridge_script, null);
263 }
406 // `setupJSBridge` used to sit here. It fetched the
407 // `WKUserContentController` and then discarded it with
408 // `_ = content_controller;`, under a comment promising that a real
409 // implementation would register a `WKScriptMessageHandler`. It never did,
410 // which is why `webkit.messageHandlers.craft` was undefined in every page
411 // craft loaded on iOS.
412 //
413 // The handler is now installed in `createWebViewWithBridge`, which is the
414 // only place it can be: on the configuration, before the webview is built
415 // from it.
264416
265417 /// Create the main UIWindow
266418 fn createWindow(self: *Self) !void {
@@ -297,21 +449,66 @@ pub const CraftAppDelegate = struct {
297449 }
298450
299451 /// Create root view controller with WKWebView
452 /// Create the WKWebView with the bridge already attached.
453 ///
454 /// Order is the whole point of this function, and it is the order the old
455 /// code could not express: content controller → handler → user script →
456 /// configuration → webview. Anything that attaches to the content
457 /// controller has to happen before the webview is constructed from the
458 /// configuration that holds it.
459 fn createWebViewWithBridge(self: *Self) !objc.id {
460 _ = self;
461
462 const WKWebViewConfiguration = objc.objc_getClass("WKWebViewConfiguration") orelse
463 return error.ClassNotFound;
464 const configuration = try objc.allocInit(WKWebViewConfiguration);
465
466 const WKUserContentController = objc.objc_getClass("WKUserContentController") orelse
467 return error.ClassNotFound;
468 const content_controller = try objc.allocInit(WKUserContentController);
469
470 try installScriptMessageHandler(content_controller);
471 try installUserScript(content_controller, phase1_user_script);
472
473 const sel_set_ucc = objc.sel_registerName("setUserContentController:") orelse
474 return error.SelectorNotFound;
475 objc.msgSendVoid1(configuration, sel_set_ucc, content_controller);
476
477 const WKWebView = objc.objc_getClass("WKWebView") orelse return error.ClassNotFound;
478 const allocated = try objc.alloc(WKWebView);
479
480 // A zero rect: Auto Layout sets the real frame in
481 // `setupWebViewConstraints`, and reading `[UIScreen mainScreen] bounds`
482 // here would be reading it before UIKit is up.
483 const frame = objc.CGRect{
484 .origin = .{ .x = 0, .y = 0 },
485 .size = .{ .width = 0, .height = 0 },
486 };
487
488 const sel_init = objc.sel_registerName("initWithFrame:configuration:") orelse
489 return error.SelectorNotFound;
490 const InitFn = *const fn (objc.id, objc.SEL, objc.CGRect, objc.id) callconv(.c) objc.id;
491 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
492 const webview = init_fn(allocated, sel_init, frame, configuration);
493 if (webview == null) return error.WebViewCreationFailed;
494
495 return webview;
496 }
497
300498 fn createRootViewController(self: *Self) !void {
301499 // Create CraftViewController (our custom UIViewController)
302500 const UIViewControllerClass = objc.objc_getClass("UIViewController") orelse return error.ClassNotFound;
303501 self.root_view_controller = try objc.allocInit(UIViewControllerClass);
304502
305 // Create WKWebView configuration
306 const webview_config = mobile.iOS.WebViewConfig{
307 .allows_inline_media_playback = true,
308 .allows_air_play = true,
309 .allows_back_forward_navigation_gestures = true,
310 };
311
312 // Create WKWebView
313 self.webview = try mobile.iOS.createWebView(self.allocator, webview_config);
314 const webview_obj: objc.id = @ptrCast(@alignCast(self.webview.?));
503 // Build the webview here rather than through `mobile.iOS.createWebView`,
504 // which constructs its own `WKWebViewConfiguration` internally and never
505 // hands it back. Both the script message handler and the user script
506 // attach to the `WKUserContentController`, and that has to be set on the
507 // configuration *before* `initWithFrame:configuration:` runs — a webview
508 // cannot be given a message handler after the fact.
509 const webview_obj = try self.createWebViewWithBridge();
510 self.webview = @ptrCast(@alignCast(webview_obj));
511 ios_dispatch.setWebView(webview_obj);
315512
316513 // Get view controller's view
317514 const sel_view = objc.sel_registerName("view") orelse return error.SelectorNotFound;
@@ -435,7 +632,12 @@ pub const CraftAppDelegate = struct {
435632
436633 // Load HTML: [webview loadHTMLString:html baseURL:baseURL]
437634 const sel_loadHTMLString = objc.sel_registerName("loadHTMLString:baseURL:") orelse return error.SelectorNotFound;
438 const LoadHTMLFn = *const fn (objc.id, objc.SEL, objc.id, ?objc.id) callconv(.c) objc.id;
635 // `objc.id` is already `?*anyopaque`, so `?objc.id` is a double
636 // optional — and a non-pointer optional has no guaranteed in-memory
637 // representation, which makes it illegal in a `callconv(.c)`
638 // signature. A nil `baseURL:` is spelled by passing a null `objc.id`,
639 // not by wrapping the type in another optional.
640 const LoadHTMLFn = *const fn (objc.id, objc.SEL, objc.id, objc.id) callconv(.c) objc.id;
439641 const loadHTMLFn: LoadHTMLFn = @ptrCast(&objc_runtime.objc.objc_msgSend);
440642 _ = loadHTMLFn(webview_ptr, sel_loadHTMLString, html_ns, base_url);
441643 }
@@ -475,18 +677,17 @@ pub const CraftAppDelegate = struct {
475677 objc.msgSend(self.window.?, sel_makeKeyAndVisible);
476678 }
477679
478 /// Run the main event loop
479 fn runMainLoop(self: *Self) !void {
480 _ = self;
481
482 // Get NSRunLoop
483 const NSRunLoopClass = objc.objc_getClass("NSRunLoop") orelse return error.ClassNotFound;
484 const sel_currentRunLoop = objc.sel_registerName("currentRunLoop") orelse return error.SelectorNotFound;
485 const sel_run = objc.sel_registerName("run") orelse return error.SelectorNotFound;
486
487 const runLoop = objc.msgSendId(NSRunLoopClass, sel_currentRunLoop);
488 objc.msgSend(runLoop, sel_run);
489 }
680 // `runMainLoop` used to live here, spinning
681 // `[[NSRunLoop currentRunLoop] run]`. That is not how an iOS app starts.
682 // UIKit owns the run loop, and it only owns it once `UIApplicationMain`
683 // has been called — which is also what makes `UIApplication.sharedApplication`
684 // and `[UIScreen mainScreen]` return anything but nil. Spinning a bare
685 // NSRunLoop meant no touch delivery, no lifecycle notifications, and a
686 // window built against a garbage frame.
687 //
688 // The replacement is not a function; it is a restructure. `run()` registers
689 // a delegate class and calls `UIApplicationMain`, and everything this file
690 // used to do inline moves into `application:didFinishLaunchingWithOptions:`.
490691
491692 /// Execute JavaScript in the webview
492693 pub fn evaluateJavaScript(self: *Self, script: []const u8, callback: ?*const fn ([]const u8) void) !void {
@@ -518,23 +719,16 @@ pub const CraftAppDelegate = struct {
518719 mobile.iOS.triggerHaptic(haptic_type);
519720 }
520721
521 /// Show alert
522 pub fn showAlert(self: *Self, message: []const u8) void {
523 _ = self;
524 mobile.iOS.showAlert(message, true);
525 }
526
527 /// Request permission
528 pub fn requestPermission(self: *Self, permission: mobile.iOS.Permission, callback: *const fn (bool) void) !void {
529 _ = self;
530 try mobile.iOS.requestPermission(permission, callback);
531 }
532
533 /// Check permission status
534 pub fn checkPermission(self: *Self, permission: mobile.iOS.Permission) !mobile.iOS.PermissionStatus {
535 _ = self;
536 return try mobile.iOS.checkPermissionStatus(permission);
537 }
722 // `showAlert`, `requestPermission`, and `checkPermission` were one-line
723 // forwards to `mobile.iOS` functions that have been deleted, for reasons
724 // recorded where they used to live: the alert presented against
725 // `keyWindow`, nil in any scene-based app; the permission request threw
726 // its caller's callback away (`_ = callback;`) and passed null for every
727 // completion handler; and the status check returned `.not_determined` for
728 // notifications no matter what the system said.
729 //
730 // They come back in the permissions phase, against a pending-request table
731 // keyed by the envelope's request id — not a callback that nothing calls.
538732};
539733
540734/// Safe area insets
@@ -553,320 +747,32 @@ const UIEdgeInsets = extern struct {
553747 right: f64,
554748};
555749
556// ============================================================================
557// JavaScript Bridge
558// ============================================================================
559
560/// JavaScript bridge message handler
561/// Handles messages from JavaScript: window.webkit.messageHandlers.craft.postMessage(data)
562///
563/// Expected message format:
564/// {
565/// "method": "methodName",
566/// "params": { ... },
567/// "callbackId": "unique_id"
568/// }
569pub const JSBridge = struct {
570 handlers: std.StringHashMap(Handler),
571 allocator: std.mem.Allocator,
572 app_delegate: ?*CraftAppDelegate = null,
573
574 pub const Handler = *const fn (params: []const u8, bridge: *JSBridge, callback_id: []const u8) void;
575
576 const Self = @This();
577
578 pub fn init(allocator: std.mem.Allocator) JSBridge {
579 var bridge = JSBridge{
580 .handlers = std.StringHashMap(Handler).init(allocator),
581 .allocator = allocator,
582 };
583
584 // Register built-in handlers
585 bridge.registerBuiltinHandlers() catch |err| {
586 std.log.warn("failed to register iOS built-in JS handlers: {}", .{err});
587 };
588
589 return bridge;
590 }
591
592 pub fn deinit(self: *JSBridge) void {
593 self.handlers.deinit();
594 }
595
596 /// Register built-in handlers for common native features
597 fn registerBuiltinHandlers(self: *Self) !void {
598 try self.handlers.put("getPlatform", handleGetPlatform);
599 try self.handlers.put("showAlert", handleShowAlert);
600 try self.handlers.put("haptic", handleHaptic);
601 try self.handlers.put("setClipboard", handleSetClipboard);
602 try self.handlers.put("getClipboard", handleGetClipboard);
603 try self.handlers.put("getNetworkStatus", handleGetNetworkStatus);
604 try self.handlers.put("getSafeArea", handleGetSafeArea);
605 try self.handlers.put("openURL", handleOpenURL);
606 try self.handlers.put("share", handleShare);
607 }
608
609 /// Register a custom handler for a specific method
610 pub fn registerHandler(self: *Self, name: []const u8, handler: Handler) !void {
611 try self.handlers.put(name, handler);
612 }
613
614 /// Handle incoming message from JavaScript
615 /// Parses JSON message and routes to appropriate handler
616 pub fn handleMessage(self: *Self, message: []const u8) void {
617 // Parse the message - expected format:
618 // {"method": "name", "params": {...}, "callbackId": "123"}
619
620 const method = self.extractJsonString(message, "method") orelse return;
621 const callback_id = self.extractJsonString(message, "callbackId") orelse "";
622 const params = self.extractJsonObject(message, "params") orelse "{}";
623
624 if (self.handlers.get(method)) |handler| {
625 handler(params, self, callback_id);
626 } else {
627 // Unknown method - send error response
628 self.sendError(callback_id, "Unknown method") catch |err| {
629 std.log.debug("failed to send unknown method error to JS: {}", .{err});
630 };
631 }
632 }
633
634 /// Extract a string value from JSON
635 fn extractJsonString(self: *Self, json: []const u8, key: []const u8) ?[]const u8 {
636 _ = self;
637
638 // Build search pattern: "key":"
639 var pattern_buf: [64]u8 = undefined;
640 const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":\"", .{key}) catch return null;
641
642 if (std.mem.indexOf(u8, json, pattern)) |start| {
643 const value_start = start + pattern.len;
644 if (value_start < json.len) {
645 // Find closing quote
646 if (std.mem.indexOf(u8, json[value_start..], "\"")) |end| {
647 return json[value_start..][0..end];
648 }
649 }
650 }
651 return null;
652 }
653
654 /// Extract an object value from JSON
655 fn extractJsonObject(self: *Self, json: []const u8, key: []const u8) ?[]const u8 {
656 _ = self;
657
658 // Build search pattern: "key":{
659 var pattern_buf: [64]u8 = undefined;
660 const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":{{", .{key}) catch return null;
661
662 if (std.mem.indexOf(u8, json, pattern)) |start| {
663 const value_start = start + pattern.len - 1; // Include opening brace
664 if (value_start < json.len) {
665 // Find matching closing brace (simple nested brace counting)
666 var depth: usize = 0;
667 var i: usize = value_start;
668 while (i < json.len) : (i += 1) {
669 if (json[i] == '{') {
670 depth += 1;
671 } else if (json[i] == '}') {
672 depth -= 1;
673 if (depth == 0) {
674 return json[value_start .. i + 1];
675 }
676 }
677 }
678 }
679 }
680 return null;
681 }
682
683 /// Send success response to JavaScript callback
684 pub fn sendResponse(self: *Self, callback_id: []const u8, result: []const u8) !void {
685 if (self.app_delegate == null) return error.NoAppDelegate;
686 if (callback_id.len == 0) return;
687
688 var buf: [2048]u8 = undefined;
689 const script = std.fmt.bufPrint(&buf,
690 \\if (window['__craftCallback_{s}']) {{ window['__craftCallback_{s}']({s}); }}
691 , .{ callback_id, callback_id, result }) catch return;
692
693 try self.app_delegate.?.evaluateJavaScript(script, null);
694 }
695
696 /// Send error response to JavaScript callback
697 pub fn sendError(self: *Self, callback_id: []const u8, error_message: []const u8) !void {
698 if (self.app_delegate == null) return error.NoAppDelegate;
699 if (callback_id.len == 0) return;
700
701 var buf: [2048]u8 = undefined;
702 const script = std.fmt.bufPrint(&buf,
703 \\if (window['__craftCallback_{s}']) {{ window['__craftCallback_{s}']({{ error: '{s}' }}); }}
704 , .{ callback_id, callback_id, error_message }) catch return;
705
706 try self.app_delegate.?.evaluateJavaScript(script, null);
707 }
708
709 /// Send event to JavaScript (not a callback response)
710 pub fn sendEvent(self: *Self, event: []const u8, data: []const u8) !void {
711 if (self.app_delegate == null) return error.NoAppDelegate;
712
713 var buf: [2048]u8 = undefined;
714 const script = std.fmt.bufPrint(&buf,
715 \\window.dispatchEvent(new CustomEvent('craft:{s}', {{ detail: {s} }}));
716 , .{ event, data }) catch return;
717
718 try self.app_delegate.?.evaluateJavaScript(script, null);
719 }
720
721 // ========================================================================
722 // Built-in Handlers
723 // ========================================================================
724
725 fn handleGetPlatform(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
726 _ = params;
727
728 const response =
729 \\{"os": "ios", "version": "17.0", "device": "iPhone", "native": true}
730 ;
731
732 bridge.sendResponse(callback_id, response) catch |err| {
733 std.log.debug("failed to send getPlatform response: {}", .{err});
734 };
735 }
736
737 fn handleShowAlert(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
738 // Extract title and message from params
739 const title = bridge.extractJsonString(params, "title") orelse "Alert";
740 const message = bridge.extractJsonString(params, "message") orelse "";
741 _ = title;
742
743 // Show native alert
744 mobile.iOS.showAlert(message, true);
745
746 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
747 std.log.debug("failed to send showAlert response: {}", .{err});
748 };
749 }
750
751 fn handleHaptic(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
752 // Extract haptic type
753 const haptic_type_str = bridge.extractJsonString(params, "type") orelse "light";
754
755 const haptic_type: mobile.iOS.HapticType = blk: {
756 if (std.mem.eql(u8, haptic_type_str, "success")) break :blk .success;
757 if (std.mem.eql(u8, haptic_type_str, "warning")) break :blk .warning;
758 if (std.mem.eql(u8, haptic_type_str, "error")) break :blk .error_haptic;
759 if (std.mem.eql(u8, haptic_type_str, "light")) break :blk .light;
760 if (std.mem.eql(u8, haptic_type_str, "medium")) break :blk .medium;
761 if (std.mem.eql(u8, haptic_type_str, "heavy")) break :blk .heavy;
762 break :blk .light;
763 };
764
765 mobile.iOS.triggerHaptic(haptic_type);
766
767 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
768 std.log.debug("failed to send haptic response: {}", .{err});
769 };
770 }
771
772 fn handleSetClipboard(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
773 const text = bridge.extractJsonString(params, "text") orelse "";
774
775 mobile.iOS.setClipboard(text);
776
777 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
778 std.log.debug("failed to send setClipboard response: {}", .{err});
779 };
780 }
781
782 fn handleGetClipboard(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
783 _ = params;
784
785 const text = mobile.iOS.getClipboard(bridge.allocator) catch "";
786
787 var buf: [1024]u8 = undefined;
788 const response = std.fmt.bufPrint(&buf, "{{ \"text\": \"{s}\" }}", .{text}) catch "{}";
789
790 bridge.sendResponse(callback_id, response) catch |err| {
791 std.log.debug("failed to send getClipboard response: {}", .{err});
792 };
793 }
794
795 fn handleGetNetworkStatus(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
796 _ = params;
797
798 // For now, assume connected - real implementation would check reachability
799 const response = "{ \"connected\": true, \"type\": \"wifi\" }";
800
801 bridge.sendResponse(callback_id, response) catch |err| {
802 std.log.debug("failed to send getNetworkStatus response: {}", .{err});
803 };
804 }
805
806 fn handleGetSafeArea(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
807 _ = params;
808
809 if (bridge.app_delegate) |app| {
810 const insets = app.getSafeAreaInsets() catch SafeAreaInsets{ .top = 0, .bottom = 0, .left = 0, .right = 0 };
811
812 var buf: [256]u8 = undefined;
813 const response = std.fmt.bufPrint(&buf,
814 \\{{ "top": {d}, "bottom": {d}, "left": {d}, "right": {d} }}
815 , .{ insets.top, insets.bottom, insets.left, insets.right }) catch "{}";
816
817 bridge.sendResponse(callback_id, response) catch |err| {
818 std.log.debug("failed to send getSafeArea response: {}", .{err});
819 };
820 } else {
821 bridge.sendResponse(callback_id, "{ \"top\": 0, \"bottom\": 0, \"left\": 0, \"right\": 0 }") catch |err| {
822 std.log.debug("failed to send getSafeArea fallback response: {}", .{err});
823 };
824 }
825 }
826
827 fn handleOpenURL(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
828 const url = bridge.extractJsonString(params, "url") orelse "";
829
830 mobile.iOS.openURL(url);
831
832 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
833 std.log.debug("failed to send openURL response: {}", .{err});
834 };
835 }
836
837 fn handleShare(params: []const u8, bridge: *JSBridge, callback_id: []const u8) void {
838 const text = bridge.extractJsonString(params, "text") orelse "";
839
840 mobile.iOS.share(text);
841
842 bridge.sendResponse(callback_id, "{ \"success\": true }") catch |err| {
843 std.log.debug("failed to send share response: {}", .{err});
844 };
845 }
846};
847
848750// ============================================================================
849751// Convenience Functions
850752// ============================================================================
851753
852/// Quick start function for simple apps
853pub fn quickStart(allocator: std.mem.Allocator, html: []const u8) !void {
754/// Quick start function for simple apps. Does not return.
755///
756/// `argc`/`argv` are forwarded because `UIApplicationMain` wants the real ones:
757/// UIKit reads launch arguments from them, and synthesising a fake pair means
758/// anything passed to the process is silently lost.
759pub fn quickStart(allocator: std.mem.Allocator, html: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn {
854760 var app = CraftAppDelegate.init(allocator, .{
855761 .name = "Craft App",
856762 .initial_content = .{ .html = html },
857763 });
858764
859 try app.run();
765 app.run(argc, argv);
860766}
861767
862/// Quick start with URL
863pub fn quickStartURL(allocator: std.mem.Allocator, url: []const u8) !void {
768/// Quick start with URL. Does not return.
769pub fn quickStartURL(allocator: std.mem.Allocator, url: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn {
864770 var app = CraftAppDelegate.init(allocator, .{
865771 .name = "Craft App",
866772 .initial_content = .{ .url = url },
867773 });
868774
869 try app.run();
775 app.run(argc, argv);
870776}
871777
872778// ============================================================================
@@ -887,39 +793,6 @@ test "CraftAppDelegate initialization" {
887793 // Can't fully test without iOS runtime
888794}
889795
890test "JSBridge initialization" {
891 const allocator = std.testing.allocator;
892
893 var bridge = JSBridge.init(allocator);
894 defer bridge.deinit();
895
896 const handler = struct {
897 fn handle(_: []const u8, _: *JSBridge, _: []const u8) void {}
898 }.handle;
899
900 try bridge.registerHandler("test", handler);
901}
902
903test "JSBridge JSON parsing" {
904 const allocator = std.testing.allocator;
905
906 var bridge = JSBridge.init(allocator);
907 defer bridge.deinit();
908
909 // Test extractJsonString
910 const json =
911 \\{"method":"getPlatform","params":{},"callbackId":"123"}
912 ;
913
914 const method = bridge.extractJsonString(json, "method");
915 try std.testing.expect(method != null);
916 try std.testing.expectEqualStrings("getPlatform", method.?);
917
918 const callback_id = bridge.extractJsonString(json, "callbackId");
919 try std.testing.expect(callback_id != null);
920 try std.testing.expectEqualStrings("123", callback_id.?);
921}
922
923796test "SafeAreaInsets" {
924797 const insets = SafeAreaInsets{
925798 .top = 47.0,
packages/zig/src/ios_dispatch.zigadded+222-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -0,0 +1,222 @@
1const std = ;
2const builtin = ;
3const objc_runtime = ;
4const request_context = ;
5const bridge_error = ;
6const bridge_mobile = ;
7
8const objc = objc_runtime.objc;
9
10/// The iOS end of the bridge: what a page's `postMessage` reaches, and how a
11/// reply gets back to it.
12///
13/// The envelope is the desktop one — `{t, a, d, i}` — and the reply goes out
14/// through `bridge_error.sendResultToJS`, which is the same function every
15/// `bridge_*.zig` module already calls. That is the whole reason for choosing
16/// this envelope over the one the Swift template speaks: reply correlation is
17/// the hardest part of a bridge, and `craft-bridge.js` plus `request_context`
18/// plus `formatResultJS` already implement it, with per-call ids, a timeout,
19/// and a guard so a late reply cannot settle someone else's call.
20///
21/// Before this, `ios.zig` had a bridge that parsed JSON by substring search
22/// and a `handleMessage` with no caller anywhere in the tree — because nothing
23/// ever registered a `WKScriptMessageHandler`. A page's
24/// `webkit.messageHandlers.craft` was undefined, so every call took the
25/// fallback branch and resolved `{success:true, browser:true}`. "The bridge
26/// worked" and "there is no bridge" were the same value.
27/// The webview a reply is evaluated against.
28///
29/// One slot, because iOS has one webview. When multi-window arrives this
30/// becomes a lookup keyed by the same request id the reply already carries —
31/// the id is threaded through `request_context` for exactly that reason.
32var global_webview: ?objc.id = null;
33
34pub fn setWebView(webview: objc.id) void {
35 global_webview = webview;
36}
37
38pub fn getWebView() ?objc.id {
39 return global_webview;
40}
41
42/// Evaluate JavaScript in the page. `bridge.evalJS`'s `.ios` arm.
43///
44/// The completion handler is nil, which is legal and is what a reply wants:
45/// nothing awaits the result of delivering a result. The block machinery that
46/// a *callback-taking* evaluation needs is a separate problem, and the version
47/// that used to sit in `mobile.zig` got it wrong in four ways at once — see
48/// the note there.
49pub fn evalJS(script: []const u8) !void {
50 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
51
52 const webview = global_webview orelse return error.NoWebView;
53
54 // `createNSString` allocates rather than copying into a fixed buffer, which
55 // matters: a reply's size is set by whatever the handler returns, and a
56 // stack ceiling here would truncate JavaScript into a syntax error in the
57 // page with nothing to point at.
58 const ns_script = try objc.createNSString(script, std.heap.c_allocator);
59
60 const sel_eval = objc.sel_registerName("evaluateJavaScript:completionHandler:") orelse return error.SelectorNotFound;
61 const Fn = *const fn (objc.id, objc.SEL, objc.id, ?*anyopaque) callconv(.c) void;
62 const func: Fn = @ptrCast(&objc.objc_msgSend);
63 func(webview, sel_eval, ns_script, null);
64}
65
66/// Route one `{t, a, d, i}` message.
67///
68/// Mirrors `macos.zig:handleBridgeMessageJSON`, including the detail that cost
69/// the desktop a bug: the payload `d` is handed to the namespace, not dropped.
70pub fn handleMessage(allocator: std.mem.Allocator, json_str: []const u8) !void {
71 const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_str, .{}) catch
72 return error.InvalidBridgeMessage;
73 defer parsed.deinit();
74
75 const root = switch (parsed.value) {
76 .object => |obj| obj,
77 else => return error.InvalidBridgeMessage,
78 };
79
80 const msg_type = switch (root.get("t") orelse return error.MissingType) {
81 .string => |value| value,
82 else => return error.InvalidBridgeMessage,
83 };
84 const action = switch (root.get("a") orelse return error.MissingAction) {
85 .string => |value| value,
86 else => return error.InvalidBridgeMessage,
87 };
88
89 // Which call this is, so the reply can name it rather than leaving the page
90 // to guess by action name. Pushed unconditionally: a message with no `i`
91 // pushes null and so shadows any enclosing request instead of inheriting an
92 // id that is not its own. Deferred pop because dispatch can fail anywhere.
93 request_context.push(request_context.fromEnvelope(root));
94 defer request_context.pop();
95
96 // One line per dispatch, naming the call. On a device this is the only
97 // window into the bridge — there is no console to watch — and it is what
98 // lets a harness observe that a message arrived at all, separately from
99 // whether it was answered correctly.
100 std.log.info("craft-bridge dispatch t={s} a={s} i={?d}", .{
101 msg_type,
102 action,
103 request_context.current(),
104 });
105
106 const data = try payloadOf(root);
107
108 try route(allocator, msg_type, action, data);
109}
110
111/// The payload `d`, as a JSON string the namespace can parse.
112///
113/// `craft-bridge.js` sends `d` already stringified — `_post` builds
114/// `{t, a, d: d || ''}` — and omits it for the many actions that need none. So
115/// the string and absent cases are the real ones, and both are handled here.
116///
117/// An absent `d` becomes `{}` rather than `""`. An empty string would clear a
118/// handler's `orelse MissingData` guard and then fail JSON parsing instead,
119/// which reports the wrong cause — the same trap `macos.zig` documents at its
120/// own payload extraction.
121///
122/// A structured `d` (object or array) is rejected rather than quietly dropped.
123/// The desktop re-renders it with a `jsonValueToString` that is private to
124/// `macos.zig`; sharing that is part of extracting the common dispatcher, and
125/// until then an explicit error is the honest answer. Silently substituting
126/// `{}` here would hand the handler an empty payload and let it act on
127/// defaults, which is how `craft.fs.writeFile` came to write empty files.
128fn payloadOf(root: std.json.ObjectMap) ![]const u8 {
129 const data_val = root.get("d") orelse return "{}";
130 return switch (data_val) {
131 .string => |s| if (s.len == 0) "{}" else s,
132 .object, .array => error.StructuredPayloadNotSupported,
133 .null => "{}",
134 else => error.InvalidPayload,
135 };
136}
137
138/// Namespace routing.
139///
140/// One arm today. Every action not served here answers with an error rather
141/// than silence — a page awaiting a reply that never comes is
142/// indistinguishable from a slow one until its timeout fires, and thirty
143/// seconds of nothing is the worst diagnostic a bridge can give.
144fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void {
145 if (std.mem.eql(u8, msg_type, "mobile")) {
146 var bridge = bridge_mobile.MobileBridge.init(allocator);
147 defer bridge.deinit();
148 bridge.handleMessage(action, data) catch |err| {
149 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
150 return err;
151 };
152 return;
153 }
154
155 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
156 return error.UnknownNamespace;
157}
158
159const testing = std.testing;
160
161test "an envelope without a type is rejected" {
162 try testing.expectError(
163 error.MissingType,
164 handleMessage(testing.allocator, "{\"a\":\"getDeviceInfo\"}"),
165 );
166}
167
168test "an envelope without an action is rejected" {
169 try testing.expectError(
170 error.MissingAction,
171 handleMessage(testing.allocator, "{\"t\":\"mobile\"}"),
172 );
173}
174
175test "malformed JSON is rejected rather than parsed by guesswork" {
176 // The bridge this replaces searched for `"method":"` as a substring, which
177 // would happily "parse" this and act on whatever followed.
178 try testing.expectError(
179 error.InvalidBridgeMessage,
180 handleMessage(testing.allocator, "{\"t\":\"mobile\", \"a\":"),
181 );
182}
183
184test "an absent payload becomes an empty object, not an empty string" {
185 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"t\":\"mobile\",\"a\":\"x\"}", .{});
186 defer parsed.deinit();
187 try testing.expectEqualStrings("{}", try payloadOf(parsed.value.object));
188}
189
190test "a string payload is passed through unchanged" {
191 var parsed = try std.json.parseFromSlice(
192 std.json.Value,
193 testing.allocator,
194 "{\"t\":\"mobile\",\"a\":\"x\",\"d\":\"{\\\"k\\\":1}\"}",
195 .{},
196 );
197 defer parsed.deinit();
198 try testing.expectEqualStrings("{\"k\":1}", try payloadOf(parsed.value.object));
199}
200
201test "a structured payload is refused, not silently emptied" {
202 // Substituting `{}` here would hand the handler an empty payload and let it
203 // proceed on defaults. That is precisely the shape of the bug where
204 // `craft.fs.writeFile` wrote empty files: a field the page sent, dropped on
205 // the way in, with success reported anyway.
206 var parsed = try std.json.parseFromSlice(
207 std.json.Value,
208 testing.allocator,
209 "{\"t\":\"mobile\",\"a\":\"x\",\"d\":{\"k\":1}}",
210 .{},
211 );
212 defer parsed.deinit();
213 try testing.expectError(error.StructuredPayloadNotSupported, payloadOf(parsed.value.object));
214}
215
216test "evalJS without a webview reports it rather than crashing" {
217 const saved = global_webview;
218 defer global_webview = saved;
219 global_webview = null;
220
221 try testing.expectError(error.NoWebView, evalJS("void 0"));
222}
packages/zig/src/ios_main.zigmodified+60-43
Changes to packages/zig/src/ios_main.zig
@@ -33,7 +33,7 @@ export fn craft_ios_init() callconv(.c) c_int {
3333}
3434
3535/// Set HTML content to load in the webview
36/// Call this before craft_ios_run() to specify what to display
36/// Call this before craft_ios_main() to specify what to display
3737export fn craft_ios_set_html(html: [*]const u8, len: usize) callconv(.c) void {
3838 if (g_allocator) |allocator| {
3939 // Copy the HTML content
@@ -45,7 +45,7 @@ export fn craft_ios_set_html(html: [*]const u8, len: usize) callconv(.c) void {
4545}
4646
4747/// Set URL to load in the webview
48/// Call this before craft_ios_run() to specify what to display
48/// Call this before craft_ios_main() to specify what to display
4949export fn craft_ios_set_url(url: [*]const u8, len: usize) callconv(.c) void {
5050 if (g_allocator) |allocator| {
5151 const url_slice = url[0..len];
@@ -55,24 +55,34 @@ export fn craft_ios_set_url(url: [*]const u8, len: usize) callconv(.c) void {
5555 }
5656}
5757
58/// Run the Craft iOS application
59/// This creates the UIWindow, WKWebView, and starts the app
60export fn craft_ios_run() callconv(.c) c_int {
61 const allocator = g_allocator orelse return -1;
58/// Run the Craft iOS application. Does not return.
59///
60/// Renamed from `craft_ios_run` and given `argc`/`argv` because
61/// `UIApplicationMain` needs them: UIKit reads launch arguments from that pair,
62/// and it is what an app's `main` is handed. The old signature took nothing and
63/// returned `c_int`, which could not express either fact — it implied the call
64/// returns, and an iOS app's does not.
65///
66/// Call it from `main`:
67///
68/// extern int craft_ios_main(int argc, char **argv);
69/// int main(int argc, char **argv) { return craft_ios_main(argc, argv); }
70export fn craft_ios_main(argc: c_int, argv: [*][*:0]u8) callconv(.c) c_int {
71 const allocator = g_allocator orelse {
72 std.log.err("craft_ios_main called before craft_ios_init", .{});
73 return -1;
74 };
6275
63 // Determine content to load
6476 const content: ios.CraftAppDelegate.AppConfig.Content = blk: {
6577 if (g_html_content) |html| {
6678 break :blk .{ .html = html };
6779 } else if (g_url_content) |url| {
6880 break :blk .{ .url = url };
6981 } else {
70 // Default HTML content
7182 break :blk .{ .html = default_html };
7283 }
7384 };
7485
75 // Create app delegate
7686 const app = allocator.create(ios.CraftAppDelegate) catch return -1;
7787 app.* = ios.CraftAppDelegate.init(allocator, .{
7888 .name = "Craft App",
@@ -88,10 +98,7 @@ export fn craft_ios_run() callconv(.c) c_int {
8898
8999 g_app_delegate = app;
90100
91 // Run the app
92 app.run() catch return -1;
93
94 return 0;
101 app.run(argc, argv);
95102}
96103
97104/// Clean up Craft iOS resources
@@ -131,11 +138,15 @@ export fn craft_ios_haptic(haptic_type: c_int) callconv(.c) void {
131138 mobile.iOS.triggerHaptic(h_type);
132139}
133140
134/// Show native alert
135export fn craft_ios_show_alert(message: [*]const u8, len: usize) callconv(.c) void {
136 const msg = message[0..len];
137 mobile.iOS.showAlert(msg, true);
138}
141// `craft_ios_show_alert` used to be exported here. It forwarded to
142// `mobile.iOS.showAlert`, which presented its UIAlertController against
143// `[UIApplication sharedApplication].keyWindow` — deprecated since iOS 13 and
144// nil in any scene-based app, which is every app the templates generate. It
145// also computed an auto-dismiss delay and then discarded it, so the alert its
146// doc comment described as self-dismissing never dismissed.
147//
148// Nothing outside this file ever called it: no Swift template references any
149// `craft_ios_*` symbol.
139150
140151// ============================================================================
141152// Default HTML Content
@@ -181,43 +192,49 @@ const default_html =
181192// Zig-native API (for pure Zig iOS apps)
182193// ============================================================================
183194
184/// Create and run an iOS app with HTML content
185pub fn runWithHTML(allocator: std.mem.Allocator, html: []const u8, config: ios.CraftAppDelegate.AppConfig) !void {
195/// Create and run an iOS app with HTML content. Does not return.
196pub fn runWithHTML(
197 allocator: std.mem.Allocator,
198 html: []const u8,
199 config: ios.CraftAppDelegate.AppConfig,
200 argc: c_int,
201 argv: [*][*:0]u8,
202) noreturn {
186203 var app_config = config;
187204 app_config.initial_content = .{ .html = html };
188205
189206 var app = ios.CraftAppDelegate.init(allocator, app_config);
190 try app.run();
207 app.run(argc, argv);
191208}
192209
193/// Create and run an iOS app with a URL
194pub fn runWithURL(allocator: std.mem.Allocator, url: []const u8, config: ios.CraftAppDelegate.AppConfig) !void {
210/// Create and run an iOS app with a URL. Does not return.
211pub fn runWithURL(
212 allocator: std.mem.Allocator,
213 url: []const u8,
214 config: ios.CraftAppDelegate.AppConfig,
215 argc: c_int,
216 argv: [*][*:0]u8,
217) noreturn {
195218 var app_config = config;
196219 app_config.initial_content = .{ .url = url };
197220
198221 var app = ios.CraftAppDelegate.init(allocator, app_config);
199 try app.run();
222 app.run(argc, argv);
200223}
201224
202225/// Quick start an iOS app with just HTML
203pub fn quickStart(allocator: std.mem.Allocator, html: []const u8) !void {
204 try ios.quickStart(allocator, html);
226pub fn quickStart(allocator: std.mem.Allocator, html: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn {
227 ios.quickStart(allocator, html, argc, argv);
205228}
206229
207// ============================================================================
208// Tests
209// ============================================================================
210
211test "iOS main exports" {
212 // Just verify the exports compile correctly
213 _ = craft_ios_init;
214 _ = craft_ios_set_html;
215 _ = craft_ios_set_url;
216 _ = craft_ios_run;
217 _ = craft_ios_deinit;
218 _ = craft_ios_haptic;
219 _ = craft_ios_show_alert;
220 _ = craft_ios_set_clipboard;
221 _ = craft_ios_open_url;
222 _ = craft_ios_share;
223}
230// The C API surface used to be asserted by a `test` block here that listed
231// `craft_ios_set_clipboard`, `craft_ios_open_url`, and `craft_ios_share` —
232// three functions this file has never declared. Undeclared identifiers are
233// resolved in AstGen, which runs over the whole file regardless of whether a
234// test is being built, so those three names were three of the nine errors that
235// kept `zig build build-ios-simulator` red from the 0.17 migration onward.
236//
237// It also asserted nothing a compiler would not: `_ = craft_ios_init;` on an
238// `export fn` in the same file cannot fail. `test/ios_surface_test.zig` covers
239// the real property — that every declaration reachable from `ios.zig` and
240// `mobile.iOS` actually compiles — and it does so on the host, in seconds.
packages/zig/src/ios_template.zigdeleted+0-633
Changes to packages/zig/src/ios_template.zig
@@ -1,633 +0,0 @@
1const std = ;
2const io_context = ;
3
4/// iOS Swift Template Generator
5/// Generates complete iOS app templates with Craft integration
6pub const IOSTemplate = struct {
7 allocator: std.mem.Allocator,
8 app_name: []const u8,
9 bundle_id: []const u8,
10 output_dir: []const u8,
11
12 pub fn init(allocator: std.mem.Allocator, app_name: []const u8, bundle_id: []const u8, output_dir: []const u8) IOSTemplate {
13 return .{
14 .allocator = allocator,
15 .app_name = app_name,
16 .bundle_id = bundle_id,
17 .output_dir = output_dir,
18 };
19 }
20
21 pub fn generate(self: *IOSTemplate) !void {
22 // Create directory structure
23 try self.createDirectoryStructure();
24
25 // Generate Swift files
26 try self.generateAppDelegate();
27 try self.generateSceneDelegate();
28 try self.generateViewController();
29 try self.generateCraftBridge();
30 try self.generateARKitBridge();
31 try self.generateMLKitBridge();
32 try self.generateHealthKitBridge();
33
34 // Generate configuration files
35 try self.generateInfoPlist();
36 try self.generateEntitlements();
37 try self.generatePodfile();
38
39 std.debug.print("iOS template generated successfully at: {s}\n", .{self.output_dir});
40 }
41
42 fn createDirectoryStructure(self: *IOSTemplate) !void {
43 const io = io_context.get();
44 const cwd = io_context.cwd();
45
46 const dirs = [_][]const u8{
47 self.output_dir,
48 try std.fmt.allocPrint(self.allocator, "{s}/Sources", .{self.output_dir}),
49 try std.fmt.allocPrint(self.allocator, "{s}/Sources/Bridge", .{self.output_dir}),
50 try std.fmt.allocPrint(self.allocator, "{s}/Sources/Features", .{self.output_dir}),
51 try std.fmt.allocPrint(self.allocator, "{s}/Resources", .{self.output_dir}),
52 try std.fmt.allocPrint(self.allocator, "{s}/Assets.xcassets", .{self.output_dir}),
53 };
54
55 for (dirs) |dir| {
56 cwd.createDir(io, dir, .default_dir) catch |err| {
57 if (err != error.PathAlreadyExists) return err;
58 };
59 }
60 }
61
62 fn generateAppDelegate(self: *IOSTemplate) !void {
63 const content =
64 \\import UIKit
65 \\import WebKit
66 \\
67 \\@main
68 \\class AppDelegate: UIResponder, UIApplicationDelegate {
69 \\ var window: UIWindow?
70 \\ var craftBridge: CraftBridge?
71 \\
72 \\ func application(
73 \\ _ application: UIApplication,
74 \\ didFinishLaunchingWith options: [UIApplication.LaunchOptionsKey: Any]?
75 \\ ) -> Bool {
76 \\ // Initialize Craft Bridge
77 \\ craftBridge = CraftBridge.shared
78 \\
79 \\ // Configure window
80 \\ window = UIWindow(frame: UIScreen.main.bounds)
81 \\ window?.rootViewController = ViewController()
82 \\ window?.makeKeyAndVisible()
83 \\
84 \\ return true
85 \\ }
86 \\
87 \\ func application(_ application: UIApplication,
88 \\ configurationForConnecting connectingSceneSession: UISceneSession,
89 \\ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
90 \\ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
91 \\ }
92 \\}
93 \\
94 ;
95
96 try self.writeFile("Sources/AppDelegate.swift", content);
97 }
98
99 fn generateSceneDelegate(self: *IOSTemplate) !void {
100 const content =
101 \\import UIKit
102 \\
103 \\class SceneDelegate: UIResponder, UIWindowSceneDelegate {
104 \\ var window: UIWindow?
105 \\
106 \\ func scene(_ scene: UIScene,
107 \\ willConnectTo session: UISceneSession,
108 \\ options connectionOptions: UIScene.ConnectionOptions) {
109 \\ guard let windowScene = (scene as? UIWindowScene) else { return }
110 \\
111 \\ window = UIWindow(windowScene: windowScene)
112 \\ window?.rootViewController = ViewController()
113 \\ window?.makeKeyAndVisible()
114 \\ }
115 \\
116 \\ func sceneDidDisconnect(_ scene: UIScene) {}
117 \\ func sceneDidBecomeActive(_ scene: UIScene) {}
118 \\ func sceneWillResignActive(_ scene: UIScene) {}
119 \\ func sceneWillEnterForeground(_ scene: UIScene) {}
120 \\ func sceneDidEnterBackground(_ scene: UIScene) {}
121 \\}
122 \\
123 ;
124
125 try self.writeFile("Sources/SceneDelegate.swift", content);
126 }
127
128 fn generateViewController(self: *IOSTemplate) !void {
129 const content =
130 \\import UIKit
131 \\import WebKit
132 \\
133 \\class ViewController: UIViewController {
134 \\ private var webView: WKWebView!
135 \\ private let craftBridge = CraftBridge.shared
136 \\
137 \\ override func viewDidLoad() {
138 \\ super.viewDidLoad()
139 \\
140 \\ // Configure WKWebView
141 \\ let configuration = WKWebViewConfiguration()
142 \\ configuration.allowsInlineMediaPlayback = true
143 \\ configuration.mediaTypesRequiringUserActionForPlayback = []
144 \\
145 \\ // Ensure WebGL2 + OffscreenCanvas are available for the
146 \\ // interactive map renderer. These are default-on on iOS 15+
147 \\ // but explicit opt-in keeps older OS builds consistent.
148 \\ let preferences = WKPreferences()
149 \\ if #available(iOS 14.0, *) {
150 \\ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
151 \\ } else {
152 \\ preferences.javaScriptEnabled = true
153 \\ }
154 \\ configuration.preferences = preferences
155 \\ configuration.preferences.setValue(true, forKey: "offlineApplicationCacheIsEnabled")
156 \\
157 \\ // Add Craft message handler
158 \\ configuration.userContentController.add(craftBridge, name: "craft")
159 \\
160 \\ // Inject Craft bridge script
161 \\ let bridgeScript = WKUserScript(
162 \\ source: craftBridge.bridgeScript,
163 \\ injectionTime: .atDocumentStart,
164 \\ forMainFrameOnly: true
165 \\ )
166 \\ configuration.userContentController.addUserScript(bridgeScript)
167 \\
168 \\ webView = WKWebView(frame: view.bounds, configuration: configuration)
169 \\ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
170 \\ webView.navigationDelegate = self
171 \\ view.addSubview(webView)
172 \\
173 \\ // Load initial URL
174 \\ if let url = URL(string: "http://localhost:3000") {
175 \\ webView.load(URLRequest(url: url))
176 \\ }
177 \\ }
178 \\}
179 \\
180 \\extension ViewController: WKNavigationDelegate {
181 \\ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
182 \\ print("Page loaded successfully")
183 \\ }
184 \\
185 \\ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
186 \\ print("Navigation failed: \(error.localizedDescription)")
187 \\ }
188 \\}
189 \\
190 ;
191
192 try self.writeFile("Sources/ViewController.swift", content);
193 }
194
195 fn generateCraftBridge(self: *IOSTemplate) !void {
196 const content =
197 \\import Foundation
198 \\import WebKit
199 \\import UIKit
200 \\
201 \\class CraftBridge: NSObject, WKScriptMessageHandler {
202 \\ static let shared = CraftBridge()
203 \\ private weak var webView: WKWebView?
204 \\
205 \\ private override init() {
206 \\ super.init()
207 \\ }
208 \\
209 \\ // Bridge script injected into WebView
210 \\ var bridgeScript: String {
211 \\ return """
212 \\ // This will be replaced with actual JS bridge from js_bridge.zig
213 \\ window.craft = window.craft || {};
214 \\ window.craft.invoke = function(method, params) {
215 \\ return new Promise((resolve, reject) => {
216 \\ const messageId = Date.now() + '_' + Math.random();
217 \\ window.webkit.messageHandlers.craft.postMessage({
218 \\ id: messageId,
219 \\ method: method,
220 \\ params: params
221 \\ });
222 \\ });
223 \\ };
224 \\ """
225 \\ }
226 \\
227 \\ // Handle messages from JavaScript
228 \\ func userContentController(_ userContentController: WKUserContentController,
229 \\ didReceive message: WKScriptMessage) {
230 \\ guard let body = message.body as? [String: Any],
231 \\ let method = body["method"] as? String,
232 \\ let messageId = body["id"] as? String else {
233 \\ return
234 \\ }
235 \\
236 \\ let params = body["params"] as? [String: Any]
237 \\
238 \\ // Route to appropriate handler
239 \\ handleMessage(method: method, params: params) { result in
240 \\ self.sendResponse(messageId: messageId, result: result)
241 \\ }
242 \\ }
243 \\
244 \\ private func handleMessage(method: String, params: [String: Any]?, completion: @escaping ([String: Any]) -> Void) {
245 \\ switch method {
246 \\ case "getPlatform":
247 \\ completion(["platform": "ios", "version": UIDevice.current.systemVersion])
248 \\
249 \\ case "showToast":
250 \\ if let message = params?["message"] as? String {
251 \\ showToast(message: message)
252 \\ completion(["success": true])
253 \\ }
254 \\
255 \\ case "haptic":
256 \\ if let type = params?["type"] as? String {
257 \\ triggerHaptic(type: type)
258 \\ completion(["success": true])
259 \\ }
260 \\
261 \\ case "requestPermission":
262 \\ if let permission = params?["permission"] as? String {
263 \\ requestPermission(permission: permission, completion: completion)
264 \\ }
265 \\
266 \\ default:
267 \\ completion(["error": "Unknown method: \(method)"])
268 \\ }
269 \\ }
270 \\
271 \\ private func sendResponse(messageId: String, result: [String: Any]) {
272 \\ let response: [String: Any] = [
273 \\ "id": messageId,
274 \\ "success": result["error"] == nil,
275 \\ "result": result
276 \\ ]
277 \\
278 \\ if let jsonData = try? JSONSerialization.data(withJSONObject: response),
279 \\ let jsonString = String(data: jsonData, encoding: .utf8) {
280 \\ let script = "window.craftHandleResponse('\(jsonString)')"
281 \\ webView?.evaluateJavaScript(script)
282 \\ }
283 \\ }
284 \\
285 \\ private func showToast(message: String) {
286 \\ // Simple alert for toast
287 \\ DispatchQueue.main.async {
288 \\ if let window = UIApplication.shared.windows.first {
289 \\ let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
290 \\ window.rootViewController?.present(alert, animated: true)
291 \\ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
292 \\ alert.dismiss(animated: true)
293 \\ }
294 \\ }
295 \\ }
296 \\ }
297 \\
298 \\ private func triggerHaptic(type: String) {
299 \\ let generator: UIFeedbackGenerator
300 \\
301 \\ switch type {
302 \\ case "selection":
303 \\ generator = UISelectionFeedbackGenerator()
304 \\ case "impact_light":
305 \\ generator = UIImpactFeedbackGenerator(style: .light)
306 \\ case "impact_medium":
307 \\ generator = UIImpactFeedbackGenerator(style: .medium)
308 \\ case "impact_heavy":
309 \\ generator = UIImpactFeedbackGenerator(style: .heavy)
310 \\ case "notification_success":
311 \\ let notificationGenerator = UINotificationFeedbackGenerator()
312 \\ notificationGenerator.notificationOccurred(.success)
313 \\ return
314 \\ case "notification_warning":
315 \\ let notificationGenerator = UINotificationFeedbackGenerator()
316 \\ notificationGenerator.notificationOccurred(.warning)
317 \\ return
318 \\ case "notification_error":
319 \\ let notificationGenerator = UINotificationFeedbackGenerator()
320 \\ notificationGenerator.notificationOccurred(.error)
321 \\ return
322 \\ default:
323 \\ generator = UISelectionFeedbackGenerator()
324 \\ }
325 \\
326 \\ if let impactGenerator = generator as? UIImpactFeedbackGenerator {
327 \\ impactGenerator.impactOccurred()
328 \\ } else if let selectionGenerator = generator as? UISelectionFeedbackGenerator {
329 \\ selectionGenerator.selectionChanged()
330 \\ }
331 \\ }
332 \\
333 \\ private func requestPermission(permission: String, completion: @escaping ([String: Any]) -> Void) {
334 \\ // Permission handling will be implemented by specific bridge classes
335 \\ completion(["granted": false, "message": "Not implemented"])
336 \\ }
337 \\}
338 \\
339 ;
340
341 try self.writeFile("Sources/Bridge/CraftBridge.swift", content);
342 }
343
344 fn generateARKitBridge(self: *IOSTemplate) !void {
345 const content =
346 \\import Foundation
347 \\import ARKit
348 \\
349 \\@available(iOS 11.0, *)
350 \\class ARKitBridge: NSObject {
351 \\ private var arSession: ARSession?
352 \\ private var configuration: ARConfiguration?
353 \\
354 \\ func startARSession(type: String) -> [String: Any] {
355 \\ guard ARWorldTrackingConfiguration.isSupported else {
356 \\ return ["error": "ARKit not supported"]
357 \\ }
358 \\
359 \\ arSession = ARSession()
360 \\
361 \\ switch type {
362 \\ case "world":
363 \\ configuration = ARWorldTrackingConfiguration()
364 \\ case "face":
365 \\ if ARFaceTrackingConfiguration.isSupported {
366 \\ configuration = ARFaceTrackingConfiguration()
367 \\ }
368 \\ case "image":
369 \\ configuration = ARImageTrackingConfiguration()
370 \\ default:
371 \\ configuration = ARWorldTrackingConfiguration()
372 \\ }
373 \\
374 \\ if let config = configuration {
375 \\ arSession?.run(config)
376 \\ return ["success": true]
377 \\ }
378 \\
379 \\ return ["error": "Failed to start AR session"]
380 \\ }
381 \\
382 \\ func stopARSession() {
383 \\ arSession?.pause()
384 \\ }
385 \\}
386 \\
387 ;
388
389 try self.writeFile("Sources/Features/ARKitBridge.swift", content);
390 }
391
392 fn generateMLKitBridge(self: *IOSTemplate) !void {
393 const content =
394 \\import Foundation
395 \\import Vision
396 \\import CoreML
397 \\
398 \\class MLKitBridge: NSObject {
399 \\ func detectText(in image: UIImage, completion: @escaping ([String: Any]) -> Void) {
400 \\ guard let cgImage = image.cgImage else {
401 \\ completion(["error": "Invalid image"])
402 \\ return
403 \\ }
404 \\
405 \\ let request = VNRecognizeTextRequest { request, error in
406 \\ if let error = error {
407 \\ completion(["error": error.localizedDescription])
408 \\ return
409 \\ }
410 \\
411 \\ guard let observations = request.results as? [VNRecognizedTextObservation] else {
412 \\ completion(["results": []])
413 \\ return
414 \\ }
415 \\
416 \\ let text = observations.compactMap { observation in
417 \\ observation.topCandidates(1).first?.string
418 \\ }
419 \\
420 \\ completion(["results": text])
421 \\ }
422 \\
423 \\ request.recognitionLevel = .accurate
424 \\
425 \\ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
426 \\ try? handler.perform([request])
427 \\ }
428 \\
429 \\ func classifyImage(in image: UIImage, completion: @escaping ([String: Any]) -> Void) {
430 \\ guard let cgImage = image.cgImage else {
431 \\ completion(["error": "Invalid image"])
432 \\ return
433 \\ }
434 \\
435 \\ let request = VNClassifyImageRequest { request, error in
436 \\ if let error = error {
437 \\ completion(["error": error.localizedDescription])
438 \\ return
439 \\ }
440 \\
441 \\ guard let observations = request.results as? [VNClassificationObservation] else {
442 \\ completion(["results": []])
443 \\ return
444 \\ }
445 \\
446 \\ let classifications = observations.prefix(5).map { observation in
447 \\ ["label": observation.identifier, "confidence": observation.confidence]
448 \\ }
449 \\
450 \\ completion(["results": classifications])
451 \\ }
452 \\
453 \\ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
454 \\ try? handler.perform([request])
455 \\ }
456 \\}
457 \\
458 ;
459
460 try self.writeFile("Sources/Features/MLKitBridge.swift", content);
461 }
462
463 fn generateHealthKitBridge(self: *IOSTemplate) !void {
464 const content =
465 \\import Foundation
466 \\import HealthKit
467 \\
468 \\class HealthKitBridge: NSObject {
469 \\ private let healthStore = HKHealthStore()
470 \\
471 \\ func requestAuthorization(completion: @escaping ([String: Any]) -> Void) {
472 \\ guard HKHealthStore.isHealthDataAvailable() else {
473 \\ completion(["error": "HealthKit not available"])
474 \\ return
475 \\ }
476 \\
477 \\ let readTypes: Set<HKObjectType> = [
478 \\ HKObjectType.quantityType(forIdentifier: .stepCount)!,
479 \\ HKObjectType.quantityType(forIdentifier: .heartRate)!,
480 \\ HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
481 \\ ]
482 \\
483 \\ healthStore.requestAuthorization(toShare: nil, read: readTypes) { success, error in
484 \\ if let error = error {
485 \\ completion(["error": error.localizedDescription])
486 \\ } else {
487 \\ completion(["granted": success])
488 \\ }
489 \\ }
490 \\ }
491 \\
492 \\ func getStepCount(completion: @escaping ([String: Any]) -> Void) {
493 \\ guard let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) else {
494 \\ completion(["error": "Step count not available"])
495 \\ return
496 \\ }
497 \\
498 \\ let now = Date()
499 \\ let startOfDay = Calendar.current.startOfDay(for: now)
500 \\ let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
501 \\
502 \\ let query = HKStatisticsQuery(quantityType: stepType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in
503 \\ if let error = error {
504 \\ completion(["error": error.localizedDescription])
505 \\ return
506 \\ }
507 \\
508 \\ guard let sum = result?.sumQuantity() else {
509 \\ completion(["steps": 0])
510 \\ return
511 \\ }
512 \\
513 \\ let steps = sum.doubleValue(for: HKUnit.count())
514 \\ completion(["steps": Int(steps)])
515 \\ }
516 \\
517 \\ healthStore.execute(query)
518 \\ }
519 \\}
520 \\
521 ;
522
523 try self.writeFile("Sources/Features/HealthKitBridge.swift", content);
524 }
525
526 fn generateInfoPlist(self: *IOSTemplate) !void {
527 const content = try std.fmt.allocPrint(
528 self.allocator,
529 \\<?xml version="1.0" encoding="UTF-8"?>
530 \\<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
531 \\<plist version="1.0">
532 \\<dict>
533 \\ <key>CFBundleDevelopmentRegion</key>
534 \\ <string>$(DEVELOPMENT_LANGUAGE)</string>
535 \\ <key>CFBundleDisplayName</key>
536 \\ <string>{s}</string>
537 \\ <key>CFBundleExecutable</key>
538 \\ <string>$(EXECUTABLE_NAME)</string>
539 \\ <key>CFBundleIdentifier</key>
540 \\ <string>{s}</string>
541 \\ <key>CFBundleName</key>
542 \\ <string>$(PRODUCT_NAME)</string>
543 \\ <key>CFBundleVersion</key>
544 \\ <string>1</string>
545 \\ <key>LSRequiresIPhoneOS</key>
546 \\ <true/>
547 \\ <key>UIRequiredDeviceCapabilities</key>
548 \\ <array>
549 \\ <string>armv7</string>
550 \\ </array>
551 \\ <key>UISupportedInterfaceOrientations</key>
552 \\ <array>
553 \\ <string>UIInterfaceOrientationPortrait</string>
554 \\ <string>UIInterfaceOrientationLandscapeLeft</string>
555 \\ <string>UIInterfaceOrientationLandscapeRight</string>
556 \\ </array>
557 \\ <key>NSCameraUsageDescription</key>
558 \\ <string>This app needs camera access</string>
559 \\ <key>NSPhotoLibraryUsageDescription</key>
560 \\ <string>This app needs photo library access</string>
561 \\ <key>NSLocationWhenInUseUsageDescription</key>
562 \\ <string>This app needs location access</string>
563 \\ <key>NSHealthShareUsageDescription</key>
564 \\ <string>This app needs health data access</string>
565 \\ <key>NSHealthUpdateUsageDescription</key>
566 \\ <string>This app needs to update health data</string>
567 \\ <key>NSLocalNetworkUsageDescription</key>
568 \\ <string>This app needs local network access for development</string>
569 \\</dict>
570 \\</plist>
571 \\
572 ,
573 .{ self.app_name, self.bundle_id },
574 );
575 defer self.allocator.free(content);
576
577 try self.writeFile("Info.plist", content);
578 }
579
580 fn generateEntitlements(self: *IOSTemplate) !void {
581 const content =
582 \\<?xml version="1.0" encoding="UTF-8"?>
583 \\<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
584 \\<plist version="1.0">
585 \\<dict>
586 \\ <key>com.apple.developer.healthkit</key>
587 \\ <true/>
588 \\ <key>com.apple.developer.healthkit.access</key>
589 \\ <array/>
590 \\</dict>
591 \\</plist>
592 \\
593 ;
594
595 try self.writeFile("Entitlements.plist", content);
596 }
597
598 fn generatePodfile(self: *IOSTemplate) !void {
599 const content =
600 \\# Podfile for Craft iOS App
601 \\platform :ios, '14.0'
602 \\use_frameworks!
603 \\
604 \\target 'CraftApp' do
605 \\ # Add pods here if needed
606 \\end
607 \\
608 ;
609
610 try self.writeFile("Podfile", content);
611 }
612
613 fn writeFile(self: *IOSTemplate, relative_path: []const u8, content: []const u8) !void {
614 const full_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.output_dir, relative_path });
615 defer self.allocator.free(full_path);
616
617 const io = io_context.get();
618 const cwd = io_context.cwd();
619 const file = try cwd.createFile(io, full_path, .{});
620 defer file.close(io);
621
622 try file.writeStreamingAll(io, content);
623 }
624};
625
626// Test
627test "iOS template generation" {
628 const allocator = std.testing.allocator;
629 const template = IOSTemplate.init(allocator, "TestApp", "com.test.app", "test_ios_output");
630
631 // Would generate files - skipping in test
632 _ = template;
633}
packages/zig/src/js_bridge.zigdeleted+0-635
Changes to packages/zig/src/js_bridge.zig
@@ -1,635 +0,0 @@
1const std = ;
2const io_context = ;
3const mobile = ;
4
5/// Unified JavaScript Bridge API
6/// Provides consistent window.craft API across iOS, Android, and Desktop
7///
8/// Usage:
9/// ```javascript
10/// // Check platform
11/// const platform = await window.craft.getPlatform();
12///
13/// // Navigate
14/// await window.craft.navigate('https://example.com');
15///
16/// // Execute native code
17/// const result = await window.craft.invoke('myMethod', { param: 'value' });
18///
19/// // Listen for events
20/// window.craft.on('ready', () => console.log('App ready'));
21/// ```
22pub const JSBridgeError = error{
23 InvalidJSON,
24 MethodNotFound,
25 InvalidParameters,
26 ExecutionFailed,
27};
28
29/// JavaScript Bridge Message
30pub const JSMessage = struct {
31 id: []const u8,
32 method: []const u8,
33 params: ?std.json.Value = null,
34 callback: ?[]const u8 = null,
35 parsed: std.json.Parsed(std.json.Value),
36
37 pub fn parse(allocator: std.mem.Allocator, json_str: []const u8) !JSMessage {
38 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
39 errdefer parsed.deinit();
40
41 const root = parsed.value;
42 if (root != .object) return JSBridgeError.InvalidJSON;
43
44 const obj = root.object;
45 const id = obj.get("id") orelse return JSBridgeError.InvalidJSON;
46 const method = obj.get("method") orelse return JSBridgeError.InvalidJSON;
47 if (id != .string or method != .string) return JSBridgeError.InvalidJSON;
48 if (obj.get("callback")) |callback| {
49 if (callback != .string) return JSBridgeError.InvalidJSON;
50 }
51
52 return JSMessage{
53 .id = id.string,
54 .method = method.string,
55 .params = if (obj.get("params")) |params_val| params_val else null,
56 .callback = if (obj.get("callback")) |cb_val| cb_val.string else null,
57 .parsed = parsed,
58 };
59 }
60
61 pub fn deinit(self: *JSMessage, allocator: std.mem.Allocator) void {
62 _ = allocator;
63 self.parsed.deinit();
64 }
65};
66
67/// JavaScript Bridge Response
68pub const JSResponse = struct {
69 id: []const u8,
70 success: bool,
71 result: ?std.json.Value = null,
72 error_msg: ?[]const u8 = null,
73
74 pub fn toJSON(self: JSResponse, allocator: std.mem.Allocator) ![]u8 {
75 var string = std.ArrayList(u8).init(allocator);
76 errdefer string.deinit();
77
78 try string.appendSlice("{");
79
80 // ID
81 try string.appendSlice("\"id\":\"");
82 try string.appendSlice(self.id);
83 try string.appendSlice("\",");
84
85 // Success
86 try string.appendSlice("\"success\":");
87 try string.appendSlice(if (self.success) "true" else "false");
88
89 // Result or error
90 if (self.result) |result| {
91 try string.appendSlice(",\"result\":");
92 try std.json.stringify(result, .{}, string.writer());
93 }
94
95 if (self.error_msg) |err| {
96 try string.appendSlice(",\"error\":\"");
97 try string.appendSlice(err);
98 try string.appendSlice("\"");
99 }
100
101 try string.appendSlice("}");
102
103 return string.toOwnedSlice();
104 }
105};
106
107/// JavaScript Bridge Handler
108pub const JSBridge = struct {
109 allocator: std.mem.Allocator,
110 handlers: std.StringHashMap(Handler),
111 event_listeners: std.StringHashMap(std.ArrayList(EventCallback)),
112
113 const Handler = *const fn (allocator: std.mem.Allocator, params: ?std.json.Value) anyerror!std.json.Value;
114 const EventCallback = *const fn (data: std.json.Value) void;
115
116 pub fn init(allocator: std.mem.Allocator) JSBridge {
117 return JSBridge{
118 .allocator = allocator,
119 .handlers = std.StringHashMap(Handler).init(allocator),
120 .event_listeners = std.StringHashMap(std.ArrayList(EventCallback)).init(allocator),
121 };
122 }
123
124 pub fn deinit(self: *JSBridge) void {
125 self.handlers.deinit();
126
127 var it = self.event_listeners.iterator();
128 while (it.next()) |entry| {
129 entry.value_ptr.deinit();
130 }
131 self.event_listeners.deinit();
132 }
133
134 /// Register a method handler
135 pub fn registerMethod(self: *JSBridge, method_name: []const u8, handler: Handler) !void {
136 try self.handlers.put(method_name, handler);
137 }
138
139 /// Handle incoming JavaScript message
140 pub fn handleMessage(self: *JSBridge, json_str: []const u8) ![]u8 {
141 var message = try JSMessage.parse(self.allocator, json_str);
142 defer message.deinit(self.allocator);
143
144 // Look up handler
145 const handler = self.handlers.get(message.method) orelse {
146 const response = JSResponse{
147 .id = message.id,
148 .success = false,
149 .error_msg = "Method not found",
150 };
151 return try response.toJSON(self.allocator);
152 };
153
154 // Execute handler
155 const result = handler(self.allocator, message.params) catch |err| {
156 const err_name = @errorName(err);
157 const response = JSResponse{
158 .id = message.id,
159 .success = false,
160 .error_msg = err_name,
161 };
162 return try response.toJSON(self.allocator);
163 };
164
165 // Return success response
166 const response = JSResponse{
167 .id = message.id,
168 .success = true,
169 .result = result,
170 };
171 return try response.toJSON(self.allocator);
172 }
173
174 /// Emit event to JavaScript
175 pub fn emit(self: *JSBridge, event_name: []const u8, data: std.json.Value) ![]u8 {
176 var string = std.ArrayList(u8).init(self.allocator);
177 errdefer string.deinit();
178
179 try string.appendSlice("{\"event\":\"");
180 try string.appendSlice(event_name);
181 try string.appendSlice("\",\"data\":");
182 try std.json.stringify(data, .{}, string.writer());
183 try string.appendSlice("}");
184
185 return string.toOwnedSlice();
186 }
187};
188
189/// Built-in method handlers
190pub const BuiltInHandlers = struct {
191 /// Get platform information
192 pub fn getPlatform(allocator: std.mem.Allocator, params: ?std.json.Value) !std.json.Value {
193 _ = params;
194 const platform = mobile.Platform.current();
195
196 const platform_str = switch (platform) {
197 .ios => "ios",
198 .android => "android",
199 .unknown => "unknown",
200 };
201
202 var obj = std.json.ObjectMap.init(allocator);
203 try obj.put("platform", .{ .string = platform_str });
204 const build_opts = ;
205 const ver = if (@hasDecl(build_opts, "version")) build_opts.version else "0.0.0";
206 try obj.put("version", .{ .string = ver });
207
208 return std.json.Value{ .object = obj };
209 }
210
211 /// Get device information
212 pub fn getDeviceInfo(allocator: std.mem.Allocator, params: ?std.json.Value) !std.json.Value {
213 _ = params;
214 var obj = std.json.ObjectMap.init(allocator);
215
216 const builtin = ;
217 const platform = mobile.Platform.current();
218
219 try obj.put("platform", .{ .string = switch (platform) {
220 .ios => "ios",
221 .android => "android",
222 .unknown => if (builtin.os.tag == .macos) "macos" else if (builtin.os.tag == .linux) "linux" else if (builtin.os.tag == .windows) "windows" else "unknown",
223 } });
224
225 // Get actual device info from native APIs
226 if (builtin.os.tag == .macos or builtin.os.tag == .ios) {
227 // Use NSProcessInfo for macOS/iOS
228 const device_info = getMacOSDeviceInfo(allocator);
229 try obj.put("model", .{ .string = device_info.model });
230 try obj.put("os_version", .{ .string = device_info.os_version });
231 try obj.put("os_name", .{ .string = device_info.os_name });
232 try obj.put("processor_count", .{ .integer = @intCast(device_info.processor_count) });
233 } else if (builtin.os.tag == .linux) {
234 // Read from /etc/os-release for Linux
235 const linux_info = getLinuxDeviceInfo(allocator);
236 try obj.put("model", .{ .string = linux_info.model });
237 try obj.put("os_version", .{ .string = linux_info.os_version });
238 try obj.put("os_name", .{ .string = linux_info.os_name });
239 } else {
240 try obj.put("model", .{ .string = "Unknown" });
241 try obj.put("os_version", .{ .string = "Unknown" });
242 }
243
244 return std.json.Value{ .object = obj };
245 }
246
247 const MacOSDeviceInfo = struct {
248 model: []const u8,
249 os_version: []const u8,
250 os_name: []const u8,
251 processor_count: u32,
252 };
253
254 fn getMacOSDeviceInfo(allocator: std.mem.Allocator) MacOSDeviceInfo {
255 const builtin = ;
256 if (builtin.os.tag != .macos and builtin.os.tag != .ios) {
257 return .{ .model = "Unknown", .os_version = "Unknown", .os_name = "Unknown", .processor_count = 1 };
258 }
259
260 var model: []const u8 = "Mac";
261 var os_version: []const u8 = "Unknown";
262 var os_name: []const u8 = "macOS";
263 var processor_count: u32 = 1;
264
265 // Get system info using sysctl
266 const CTL_HW = 6;
267 const HW_MODEL = 2;
268 const HW_NCPU = 3;
269
270 // Get model
271 var model_buf: [256]u8 = undefined;
272 var model_size: usize = model_buf.len;
273 var mib = [_]c_int{ CTL_HW, HW_MODEL };
274
275 const sysctl = @extern(*fn ([*]c_int, c_uint, ?*anyopaque, *usize, ?*anyopaque, usize) callconv(.c) c_int, .{ .name = "sysctl" });
276
277 if (sysctl(&mib, 2, &model_buf, &model_size, null, 0) == 0) {
278 model = allocator.dupe(u8, model_buf[0 .. model_size - 1]) catch "Mac";
279 }
280
281 // Get processor count
282 var ncpu: c_int = 1;
283 var ncpu_size: usize = @sizeOf(c_int);
284 mib = [_]c_int{ CTL_HW, HW_NCPU };
285 if (sysctl(&mib, 2, @ptrCast(&ncpu), &ncpu_size, null, 0) == 0) {
286 processor_count = @intCast(ncpu);
287 }
288
289 // Get OS version using sysctlbyname
290 var version_buf: [64]u8 = undefined;
291 var version_size: usize = version_buf.len;
292
293 const sysctlbyname = @extern(*fn ([*:0]const u8, ?*anyopaque, *usize, ?*anyopaque, usize) callconv(.c) c_int, .{ .name = "sysctlbyname" });
294
295 if (sysctlbyname("kern.osproductversion", &version_buf, &version_size, null, 0) == 0) {
296 os_version = allocator.dupe(u8, version_buf[0 .. version_size - 1]) catch "Unknown";
297 }
298
299 // Determine OS name
300 if (builtin.os.tag == .ios) {
301 os_name = "iOS";
302 }
303
304 return .{
305 .model = model,
306 .os_version = os_version,
307 .os_name = os_name,
308 .processor_count = processor_count,
309 };
310 }
311
312 const LinuxDeviceInfo = struct {
313 model: []const u8,
314 os_version: []const u8,
315 os_name: []const u8,
316 };
317
318 fn getLinuxDeviceInfo(allocator: std.mem.Allocator) LinuxDeviceInfo {
319 var model: []const u8 = "Linux PC";
320 var os_version: []const u8 = "Unknown";
321 var os_name: []const u8 = "Linux";
322
323 // Read /etc/os-release
324 const io = io_context.get();
325 const file = io_context.cwd().openFile(io, "/etc/os-release", .{}) catch {
326 return .{ .model = model, .os_version = os_version, .os_name = os_name };
327 };
328 defer file.close(io);
329
330 var buf: [4096]u8 = undefined;
331 const bytes_read = file.readPositional(io, &.{&buf}, 0) catch return .{ .model = model, .os_version = os_version, .os_name = os_name };
332 const content = buf[0..bytes_read];
333
334 var lines = std.mem.splitScalar(u8, content, '\n');
335 while (lines.next()) |line| {
336 if (std.mem.startsWith(u8, line, "PRETTY_NAME=")) {
337 const value = std.mem.trim(u8, line["PRETTY_NAME=".len..], "\"");
338 os_name = allocator.dupe(u8, value) catch "Linux";
339 } else if (std.mem.startsWith(u8, line, "VERSION_ID=")) {
340 const value = std.mem.trim(u8, line["VERSION_ID=".len..], "\"");
341 os_version = allocator.dupe(u8, value) catch "Unknown";
342 }
343 }
344
345 // Try to get hardware model from /sys/class/dmi/id/product_name
346 const model_file = io_context.cwd().openFile(io, "/sys/class/dmi/id/product_name", .{}) catch {
347 return .{ .model = model, .os_version = os_version, .os_name = os_name };
348 };
349 defer model_file.close(io);
350
351 var model_buf: [256]u8 = undefined;
352 const model_len = model_file.readPositional(io, &.{&model_buf}, 0) catch 0;
353 if (model_len > 0) {
354 model = allocator.dupe(u8, std.mem.trim(u8, model_buf[0..model_len], " \n\r\t")) catch "Linux PC";
355 }
356
357 return .{ .model = model, .os_version = os_version, .os_name = os_name };
358 }
359
360 /// Show toast/notification
361 pub fn showToast(allocator: std.mem.Allocator, params: ?std.json.Value) !std.json.Value {
362 if (params == null or params.? != .object) {
363 return JSBridgeError.InvalidParameters;
364 }
365
366 const obj = params.?.object;
367 const message = if (obj.get("message")) |msg| msg.string else return JSBridgeError.InvalidParameters;
368 const duration_str = if (obj.get("duration")) |dur| dur.string else "short";
369
370 const platform = mobile.Platform.current();
371 switch (platform) {
372 .android => {
373 // Call Android toast via mobile module
374 const toast_duration: mobile.Android.ToastDuration = if (std.mem.eql(u8, duration_str, "long"))
375 .long
376 else
377 .short;
378 // Note: Android toast requires Context, which would come from the app's main activity
379 // For now, log that toast was requested - full implementation needs Context access
380 std.debug.print("[JSBridge] Android toast requested: {s}\n", .{message});
381 _ = toast_duration;
382 },
383 .ios => {
384 // Show iOS alert/toast
385 const is_short = std.mem.eql(u8, duration_str, "short");
386 mobile.iOS.showAlert(message, is_short);
387 },
388 .unknown => {},
389 }
390
391 var result = std.json.ObjectMap.init(allocator);
392 try result.put("success", .{ .bool = true });
393
394 return std.json.Value{ .object = result };
395 }
396
397 /// Trigger haptic feedback
398 pub fn haptic(allocator: std.mem.Allocator, params: ?std.json.Value) !std.json.Value {
399 if (params == null or params.? != .object) {
400 return JSBridgeError.InvalidParameters;
401 }
402
403 const obj = params.?.object;
404 const haptic_type = if (obj.get("type")) |t| t.string else "selection";
405
406 const platform = mobile.Platform.current();
407 switch (platform) {
408 .ios => {
409 const haptic_enum: mobile.iOS.HapticType = blk: {
410 if (std.mem.eql(u8, haptic_type, "selection")) break :blk .selection;
411 if (std.mem.eql(u8, haptic_type, "impact_light")) break :blk .impact_light;
412 if (std.mem.eql(u8, haptic_type, "impact_medium")) break :blk .impact_medium;
413 if (std.mem.eql(u8, haptic_type, "impact_heavy")) break :blk .impact_heavy;
414 if (std.mem.eql(u8, haptic_type, "notification_success")) break :blk .notification_success;
415 if (std.mem.eql(u8, haptic_type, "notification_warning")) break :blk .notification_warning;
416 if (std.mem.eql(u8, haptic_type, "notification_error")) break :blk .notification_error;
417 break :blk .selection;
418 };
419 mobile.iOS.triggerHaptic(haptic_enum);
420 },
421 .android => {
422 // Call Android vibration based on haptic_type
423 const duration_ms: u64 = if (std.mem.eql(u8, haptic_type, "impact_heavy"))
424 100
425 else if (std.mem.eql(u8, haptic_type, "impact_medium"))
426 50
427 else
428 25;
429 // Note: Android vibrate requires Context - log for now
430 std.debug.print("[JSBridge] Android haptic requested: {s} ({d}ms)\n", .{ haptic_type, duration_ms });
431 },
432 .unknown => {},
433 }
434
435 var result = std.json.ObjectMap.init(allocator);
436 try result.put("success", .{ .bool = true });
437
438 return std.json.Value{ .object = result };
439 }
440
441 /// Request permission
442 pub fn requestPermission(allocator: std.mem.Allocator, params: ?std.json.Value) !std.json.Value {
443 if (params == null or params.? != .object) {
444 return JSBridgeError.InvalidParameters;
445 }
446
447 const obj = params.?.object;
448 const permission_str = if (obj.get("permission")) |p| p.string else return JSBridgeError.InvalidParameters;
449
450 // Map permission string to platform permission type
451 var granted = false;
452 var message: []const u8 = "Unknown permission";
453
454 const platform = mobile.Platform.current();
455 switch (platform) {
456 .ios => {
457 // iOS permissions are typically requested at first use
458 // For now, log and return pending state
459 std.debug.print("[JSBridge] iOS permission requested: {s}\n", .{permission_str});
460 message = "iOS permission request initiated";
461 },
462 .android => {
463 // Android runtime permissions
464 std.debug.print("[JSBridge] Android permission requested: {s}\n", .{permission_str});
465 message = "Android permission request initiated";
466 },
467 .unknown => {
468 message = "Unknown platform";
469 },
470 }
471
472 var result = std.json.ObjectMap.init(allocator);
473 try result.put("granted", .{ .bool = granted });
474 try result.put("message", .{ .string = message });
475
476 return std.json.Value{ .object = result };
477 }
478};
479
480/// Generate JavaScript bridge initialization code
481pub fn generateBridgeScript(allocator: std.mem.Allocator) ![]u8 {
482 const script =
483 \\(function() {
484 \\ // Create craft namespace
485 \\ window.craft = window.craft || {};
486 \\
487 \\ // Message ID counter
488 \\ let messageId = 0;
489 \\ const callbacks = new Map();
490 \\ const eventListeners = new Map();
491 \\
492 \\ // Send message to native layer
493 \\ function sendMessage(method, params) {
494 \\ return new Promise((resolve, reject) => {
495 \\ const id = `msg_${messageId++}`;
496 \\ callbacks.set(id, { resolve, reject });
497 \\
498 \\ const message = JSON.stringify({
499 \\ id,
500 \\ method,
501 \\ params
502 \\ });
503 \\
504 \\ // Platform-specific message sending
505 \\ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft) {
506 \\ // iOS
507 \\ window.webkit.messageHandlers.craft.postMessage(message);
508 \\ } else if (window.Android && window.Android.postMessage) {
509 \\ // Android
510 \\ window.Android.postMessage(message);
511 \\ } else if (window.chrome && window.chrome.webview) {
512 \\ // Windows WebView2
513 \\ window.chrome.webview.postMessage(message);
514 \\ } else {
515 \\ // Desktop/Other
516 \\ console.warn('No native bridge found, message:', message);
517 \\ reject(new Error('Native bridge not available'));
518 \\ }
519 \\ });
520 \\ }
521 \\
522 \\ // Handle response from native
523 \\ window.craftHandleResponse = function(responseJson) {
524 \\ const response = JSON.parse(responseJson);
525 \\
526 \\ if (response.event) {
527 \\ // Event emission
528 \\ const listeners = eventListeners.get(response.event) || [];
529 \\ listeners.forEach(listener => listener(response.data));
530 \\ return;
531 \\ }
532 \\
533 \\ const callback = callbacks.get(response.id);
534 \\ if (!callback) return;
535 \\
536 \\ callbacks.delete(response.id);
537 \\
538 \\ if (response.success) {
539 \\ callback.resolve(response.result);
540 \\ } else {
541 \\ callback.reject(new Error(response.error || 'Unknown error'));
542 \\ }
543 \\ };
544 \\
545 \\ // Public API
546 \\ window.craft.invoke = sendMessage;
547 \\
548 \\ window.craft.getPlatform = () => sendMessage('getPlatform', {});
549 \\
550 \\ window.craft.getDeviceInfo = () => sendMessage('getDeviceInfo', {});
551 \\
552 \\ window.craft.showToast = (message, duration = 'short') => {
553 \\ return sendMessage('showToast', { message, duration });
554 \\ };
555 \\
556 \\ window.craft.haptic = (type = 'selection') => {
557 \\ return sendMessage('haptic', { type });
558 \\ };
559 \\
560 \\ window.craft.requestPermission = (permission) => {
561 \\ return sendMessage('requestPermission', { permission });
562 \\ };
563 \\
564 \\ window.craft.on = (event, listener) => {
565 \\ if (!eventListeners.has(event)) {
566 \\ eventListeners.set(event, []);
567 \\ }
568 \\ eventListeners.get(event).push(listener);
569 \\ };
570 \\
571 \\ window.craft.off = (event, listener) => {
572 \\ if (!eventListeners.has(event)) return;
573 \\ const listeners = eventListeners.get(event);
574 \\ const index = listeners.indexOf(listener);
575 \\ if (index > -1) {
576 \\ listeners.splice(index, 1);
577 \\ }
578 \\ };
579 \\
580 \\ window.craft.emit = (event, data) => {
581 \\ const listeners = eventListeners.get(event) || [];
582 \\ listeners.forEach(listener => listener(data));
583 \\ };
584 \\
585 \\ // Dispatch ready event
586 \\ window.dispatchEvent(new CustomEvent('craftReady'));
587 \\ window.craft.emit('ready', {});
588 \\
589 \\ console.log('Craft bridge initialized');
590 \\})();
591 ;
592
593 return try allocator.dupe(u8, script);
594}
595
596// Tests
597test "JSMessage parse" {
598 const allocator = std.testing.allocator;
599
600 const json = "{\"id\":\"msg_1\",\"method\":\"getPlatform\",\"params\":{}}";
601 var message = try JSMessage.parse(allocator, json);
602 defer message.deinit(allocator);
603
604 try std.testing.expectEqualStrings("msg_1", message.id);
605 try std.testing.expectEqualStrings("getPlatform", message.method);
606}
607
608test "JSResponse toJSON" {
609 const allocator = std.testing.allocator;
610
611 const response = JSResponse{
612 .id = "msg_1",
613 .success = true,
614 .result = std.json.Value{ .bool = true },
615 };
616
617 const json = try response.toJSON(allocator);
618 defer allocator.free(json);
619
620 try std.testing.expect(std.mem.indexOf(u8, json, "\"success\":true") != null);
621}
622
623test "JSBridge handler" {
624 const allocator = std.testing.allocator;
625 var bridge = JSBridge.init(allocator);
626 defer bridge.deinit();
627
628 try bridge.registerMethod("getPlatform", BuiltInHandlers.getPlatform);
629
630 const request = "{\"id\":\"msg_1\",\"method\":\"getPlatform\",\"params\":{}}";
631 const response = try bridge.handleMessage(request);
632 defer allocator.free(response);
633
634 try std.testing.expect(std.mem.indexOf(u8, response, "\"success\":true") != null);
635}
packages/zig/src/main.zigmodified+7-1
Changes to packages/zig/src/main.zig
@@ -29,7 +29,13 @@ pub const mobile = ;
2929pub const android = ;
3030pub const CraftActivity = android.CraftActivity;
3131pub const AndroidJSBridge = android.JSBridge;
32pub const AndroidFeatures = android.AndroidFeatures;
32// `AndroidFeatures` was re-exported here. It was ten functions — `showToast`,
33// `vibrate`, `requestPermission`, `openURL`, `share`, `setClipboard`,
34// `getClipboard`, `hasPermission`, `isNetworkConnected`, `getDeviceInfo` — and
35// every one had a body of `_ = param;` and a comment naming the JNI call it
36// would have made. `isNetworkConnected` returned true unconditionally and
37// `getDeviceInfo` returned a hardcoded "Android Device" on SDK 34, so a caller
38// asking about the device got a plausible answer that was never true.
3339
3440// Re-export API module
3541pub const api = ;
packages/zig/src/mobile.zigmodified+95-829
Changes to packages/zig/src/mobile.zig
@@ -1,5 +1,6 @@
11const std = ;
22const objc_runtime = ;
3const compat = ;
34
45/// Mobile Platform Support
56/// Provides iOS and Android native integration
@@ -7,204 +8,6 @@ const objc_runtime = ;
78// Use the proper Objective-C runtime wrapper
89const objc = objc_runtime.objc;
910
10// JNI for Android
11const jni = if (.target.os.tag == .linux) struct {
12 // JNI types
13 pub const JNIEnv = opaque {};
14 pub const jobject = ?*anyopaque;
15 pub const jclass = ?*anyopaque;
16 pub const jmethodID = ?*anyopaque;
17 pub const jfieldID = ?*anyopaque;
18 pub const jstring = ?*anyopaque;
19 pub const jboolean = u8;
20 pub const jint = i32;
21 pub const jlong = i64;
22
23 // JNI function pointers (these will be looked up from JNIEnv vtable)
24 // The JNIEnv is actually a pointer to a vtable of function pointers
25 const JNINativeInterface = extern struct {
26 reserved0: ?*anyopaque,
27 reserved1: ?*anyopaque,
28 reserved2: ?*anyopaque,
29 reserved3: ?*anyopaque,
30 GetVersion: ?*const fn (*JNIEnv) callconv(.c) jint,
31 // ... many more function pointers ...
32 FindClass: ?*const fn (*JNIEnv, [*:0]const u8) callconv(.c) jclass,
33 GetMethodID: ?*const fn (*JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID,
34 GetObjectClass: ?*const fn (*JNIEnv, jobject) callconv(.c) jclass,
35 CallObjectMethodV: ?*const fn (*JNIEnv, jobject, jmethodID, ...) callconv(.c) jobject,
36 CallVoidMethodV: ?*const fn (*JNIEnv, jobject, jmethodID, ...) callconv(.c) void,
37 NewStringUTF: ?*const fn (*JNIEnv, [*:0]const u8) callconv(.c) jstring,
38 GetStringUTFChars: ?*const fn (*JNIEnv, jstring, ?*jboolean) callconv(.c) [*:0]const u8,
39 ReleaseStringUTFChars: ?*const fn (*JNIEnv, jstring, [*:0]const u8) callconv(.c) void,
40 };
41
42 // Helper to get the function table from JNIEnv
43 fn getTable(env: *JNIEnv) *JNINativeInterface {
44 const env_ptr: **JNINativeInterface = @ptrCast(@alignCast(env));
45 return env_ptr.*;
46 }
47
48 // Wrapper functions
49 pub fn FindClass(env: *JNIEnv, name: [*:0]const u8) jclass {
50 const table = getTable(env);
51 if (table.FindClass) |func| {
52 return func(env, name);
53 }
54 return null;
55 }
56
57 pub fn GetObjectClass(env: *JNIEnv, obj: jobject) jclass {
58 const table = getTable(env);
59 if (table.GetObjectClass) |func| {
60 return func(env, obj);
61 }
62 return null;
63 }
64
65 pub fn GetMethodID(env: *JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID {
66 const table = getTable(env);
67 if (table.GetMethodID) |func| {
68 return func(env, cls, name, sig);
69 }
70 return null;
71 }
72
73 pub fn CallVoidMethod(env: *JNIEnv, obj: jobject, methodID: jmethodID, args: anytype) void {
74 const table = getTable(env);
75 if (table.CallVoidMethodV) |func| {
76 _ = @call(.auto, func, .{ env, obj, methodID } ++ args);
77 }
78 }
79
80 pub fn CallObjectMethod(env: *JNIEnv, obj: jobject, methodID: jmethodID, args: anytype) jobject {
81 const table = getTable(env);
82 if (table.CallObjectMethodV) |func| {
83 return @call(.auto, func, .{ env, obj, methodID } ++ args);
84 }
85 return null;
86 }
87
88 pub fn NewStringUTF(env: *JNIEnv, bytes: [*:0]const u8) jstring {
89 const table = getTable(env);
90 if (table.NewStringUTF) |func| {
91 return func(env, bytes);
92 }
93 return null;
94 }
95
96 pub fn GetStringUTFChars(env: *JNIEnv, string: jstring, isCopy: ?*jboolean) [*:0]const u8 {
97 const table = getTable(env);
98 if (table.GetStringUTFChars) |func| {
99 return func(env, string, isCopy);
100 }
101 return @ptrCast(&[_]u8{0});
102 }
103
104 pub fn ReleaseStringUTFChars(env: *JNIEnv, string: jstring, utf: [*:0]const u8) void {
105 const table = getTable(env);
106 if (table.ReleaseStringUTFChars) |func| {
107 func(env, string, utf);
108 }
109 }
110} else struct {};
111
112// ============================================================================
113// Android Callback Storage
114// ============================================================================
115
116/// Stored callbacks for Android ValueCallback (JS evaluation) and permissions
117pub const AndroidCallbackStorage = struct {
118 // JavaScript evaluation callbacks - indexed by request ID
119 js_callbacks: [16]?*const fn ([]const u8) void = @splat(null),
120 js_callback_next_id: u32 = 0,
121
122 // Permission callbacks - indexed by request code
123 permission_callbacks: [16]?*const fn (bool) void = @splat(null),
124
125 const Self = @This();
126
127 /// Store a JS callback and return its ID
128 pub fn storeJsCallback(self: *Self, callback: *const fn ([]const u8) void) u32 {
129 const id = self.js_callback_next_id % 16;
130 self.js_callbacks[id] = callback;
131 self.js_callback_next_id +%= 1;
132 return id;
133 }
134
135 /// Invoke and clear a JS callback
136 pub fn invokeJsCallback(self: *Self, id: u32, result: []const u8) void {
137 const idx = id % 16;
138 if (self.js_callbacks[idx]) |callback| {
139 callback(result);
140 self.js_callbacks[idx] = null;
141 }
142 }
143
144 /// Store a permission callback with request code
145 pub fn storePermissionCallback(self: *Self, request_code: u32, callback: *const fn (bool) void) void {
146 const idx = request_code % 16;
147 self.permission_callbacks[idx] = callback;
148 }
149
150 /// Invoke and clear a permission callback
151 pub fn invokePermissionCallback(self: *Self, request_code: u32, granted: bool) void {
152 const idx = request_code % 16;
153 if (self.permission_callbacks[idx]) |callback| {
154 callback(granted);
155 self.permission_callbacks[idx] = null;
156 }
157 }
158};
159
160/// Global callback storage for Android
161var android_callbacks: AndroidCallbackStorage = .{};
162
163// JNI export functions are only compiled on Android (Linux target)
164// On other platforms, these are no-ops
165comptime {
166 if (.target.os.tag == .linux) {
167 @export(&Java_app_craft_CraftValueCallback_nativeOnReceiveValue_impl, .{ .name = "Java_app_craft_CraftValueCallback_nativeOnReceiveValue" });
168 @export(&Java_app_craft_CraftActivity_nativeOnPermissionResult_impl, .{ .name = "Java_app_craft_CraftActivity_nativeOnPermissionResult" });
169 }
170}
171
172/// JNI callback function exported for ValueCallback.onReceiveValue
173/// Called from Java when evaluateJavascript completes
174fn Java_app_craft_CraftValueCallback_nativeOnReceiveValue_impl(
175 env: *jni.JNIEnv,
176 this: jni.jobject,
177 callback_id: jni.jint,
178 result: jni.jstring,
179) callconv(.c) void {
180 _ = this;
181
182 // Convert Java string result to Zig slice
183 const result_chars = jni.GetStringUTFChars(env, result, null);
184 const result_slice = std.mem.span(result_chars);
185
186 // Invoke the stored callback
187 android_callbacks.invokeJsCallback(@intCast(callback_id), result_slice);
188
189 // Release the string
190 jni.ReleaseStringUTFChars(env, result, result_chars);
191}
192
193/// JNI callback function exported for permission results
194/// Called from Activity.onRequestPermissionsResult
195fn Java_app_craft_CraftActivity_nativeOnPermissionResult_impl(
196 env: *jni.JNIEnv,
197 this: jni.jobject,
198 request_code: jni.jint,
199 granted: jni.jboolean,
200) callconv(.c) void {
201 _ = env;
202 _ = this;
203
204 // Invoke the stored callback
205 android_callbacks.invokePermissionCallback(@intCast(request_code), granted != 0);
206}
207
20811pub const Platform = enum {
20912 ios,
21013 android,
@@ -265,7 +68,7 @@ pub const NativeObjectManager = struct {
26568 .ptr = ptr,
26669 .size = size,
26770 .type_name = type_name,
268 .allocation_time = std.time.timestamp(),
71 .allocation_time = compat.timestamp(),
26972 };
27073
27174 try self.tracked_objects.put(addr, info);
@@ -322,7 +125,7 @@ pub const NativeObjectManager = struct {
322125
323126 std.debug.print("Memory Leak Report:\n", .{});
324127 while (it.next()) |entry| {
325 const age = std.time.timestamp() - entry.value.*.allocation_time;
128 const age = compat.timestamp() - entry.value.*.allocation_time;
326129 std.debug.print(" Leaked {s} at 0x{x} (size: {d} bytes, age: {d}s)\n", .{
327130 entry.value.*.type_name,
328131 entry.key_ptr.*,
@@ -469,11 +272,21 @@ pub const iOS = struct {
469272 const allocated = objc.msgSendId(WKWebViewClass, sel_alloc);
470273 const Fn = *const fn (objc.id, objc.SEL, objc.CGRect, objc.id) callconv(.c) objc.id;
471274 const func: Fn = @ptrCast(&.objc.objc_msgSend);
472 const webview = func(allocated, sel_initWithFrame, frame, configObj);
275 // `objc.id` is `?*anyopaque`, so this is optional and a failed
276 // `initWithFrame:configuration:` comes back as null. Unwrap once, here,
277 // rather than `@ptrCast` the optional away further down — a cast cannot
278 // discard optionality, and pretending otherwise is how a nil webview
279 // would have reached callers as a live pointer.
280 const webview = func(allocated, sel_initWithFrame, frame, configObj) orelse
281 return error.WebViewCreationFailed;
473282
474 // Track the webview in memory manager
283 // Track the webview in memory manager.
284 //
285 // The size reported here is `@sizeOf(WKWebView)` as the runtime knows
286 // it, not `@sizeOf(@TypeOf(webview))` — the latter is the width of a
287 // pointer, which made every WKWebView in the accounting cost 8 bytes.
475288 if (getGlobalObjectManager()) |manager| {
476 try manager.trackObject(webview, @sizeOf(@TypeOf(webview)), "WKWebView");
289 try manager.trackObject(webview, objc.classInstanceSize(WKWebViewClass), "WKWebView");
477290 }
478291
479292 // Associate allocator with webview for cleanup
@@ -567,82 +380,35 @@ pub const iOS = struct {
567380 // Create NSString from script
568381 const ns_script = try objc.createNSString(script, allocator.*);
569382
570 // Create completion handler block for JavaScript evaluation
571383 const sel_evaluateJavaScript = objc.sel_registerName("evaluateJavaScript:completionHandler:") orelse return error.SelectorNotFound;
572384
573 // Block structure for completion handler
574 // typedef void (^CompletionHandler)(id result, NSError *error);
575 const BlockDescriptor = extern struct {
576 reserved: c_ulong,
577 size: c_ulong,
578 };
579
580 const Block = extern struct {
581 isa: ?*anyopaque,
582 flags: c_int,
583 reserved: c_int,
584 invoke: ?*const fn (*@This(), ?objc.id, ?objc.id) callconv(.c) void,
585 descriptor: *const BlockDescriptor,
586 callback: ?*const fn (?[]const u8, ?[]const u8) void,
587 };
588
589 // Block invoke function that calls our Zig callback
590 const block_invoke = struct {
591 fn invoke(block: *Block, result: ?objc.id, err: ?objc.id) callconv(.c) void {
592 var result_str: ?[]const u8 = null;
593 var error_str: ?[]const u8 = null;
594
595 // Extract result string if present
596 if (result) |res| {
597 const desc = .objc.objc_msgSend;
598 const desc_fn: *const fn (objc.id, objc.SEL) callconv(.c) objc.id = @ptrCast(&desc);
599 const description = desc_fn(res, objc.sel_registerName("description").?);
600 if (description != null) {
601 const utf8_fn: *const fn (objc.id, objc.SEL) callconv(.c) [*:0]const u8 = @ptrCast(&desc);
602 const utf8 = utf8_fn(description, objc.sel_registerName("UTF8String").?);
603 result_str = std.mem.span(utf8);
604 }
605 }
606
607 // Extract error string if present
608 if (err) |e| {
609 const desc = .objc.objc_msgSend;
610 const desc_fn: *const fn (objc.id, objc.SEL) callconv(.c) objc.id = @ptrCast(&desc);
611 const description = desc_fn(e, objc.sel_registerName("localizedDescription").?);
612 if (description != null) {
613 const utf8_fn: *const fn (objc.id, objc.SEL) callconv(.c) [*:0]const u8 = @ptrCast(&desc);
614 const utf8 = utf8_fn(description, objc.sel_registerName("UTF8String").?);
615 error_str = std.mem.span(utf8);
616 }
617 }
618
619 // Call the user's callback
620 if (block.callback) |cb| {
621 cb(result_str, error_str);
622 }
623 }
624 }.invoke;
625
626 // Static descriptor (must persist)
627 const descriptor = BlockDescriptor{
628 .reserved = 0,
629 .size = @sizeOf(Block),
630 };
631
632 // Create block on stack (valid for duration of call)
633 var block = Block{
634 .isa = @extern(*anyopaque, .{ .name = "_NSConcreteStackBlock" }),
635 .flags = 0,
636 .reserved = 0,
637 .invoke = block_invoke,
638 .descriptor = &descriptor,
639 .callback = callback,
640 };
641
642 // Call evaluateJavaScript with our block
643 const Fn = *const fn (*anyopaque, objc.SEL, objc.id, *Block) callconv(.c) void;
385 // A completion handler is not wired up yet, and asking for one is an
386 // error rather than a silent drop.
387 //
388 // The block that used to live here did not work and could not have.
389 // It was built on the stack and handed to an *asynchronous* API — the
390 // comment beside it read "valid for duration of call", which is not
391 // the property an async callee needs. `_Block_copy` copies the block
392 // body but keeps the descriptor *pointer*, and that descriptor was
393 // also a function local, so both were dead before WebKit ever invoked
394 // them. Its `invoke` signature took `?objc.id`, which is `??*anyopaque`
395 // and illegal under `callconv(.c)`; and the `callback` field it stored
396 // had a different signature from this function's own parameter, so
397 // even the assignment was a type error. None of that was ever noticed
398 // because nothing reachable from the iOS exports called this function —
399 // which is precisely what `test/ios_surface_test.zig` now prevents.
400 //
401 // Doing it correctly means a module-level block with static storage
402 // (see `bridge_permissions.zig:131-164` for the shape that works) plus
403 // a pending-request table so the reply can find its caller. That lands
404 // with the dispatcher, not here.
405 if (callback != null) return error.CompletionHandlerNotImplemented;
406
407 // Passing nil for `completionHandler:` is legal and is what a
408 // fire-and-forget evaluation wants.
409 const Fn = *const fn (*anyopaque, objc.SEL, objc.id, ?*anyopaque) callconv(.c) void;
644410 const func: Fn = @ptrCast(&.objc.objc_msgSend);
645 func(webview_ptr, sel_evaluateJavaScript, ns_script, &block);
411 func(webview_ptr, sel_evaluateJavaScript, ns_script, null);
646412 }
647413
648414 /// Handle Deep Links
@@ -663,78 +429,6 @@ pub const iOS = struct {
663429 reminders,
664430 };
665431
666 /// Check current permission status
667 pub fn checkPermissionStatus(permission: Permission) !PermissionStatus {
668 if (!.target.os.tag.isDarwin()) {
669 return error.UnsupportedPlatform;
670 }
671
672 switch (permission) {
673 .camera, .microphone => {
674 const AVCaptureDeviceClass = objc.objc_getClass("AVCaptureDevice") orelse return error.ClassNotFound;
675 const sel_authorizationStatus = objc.sel_registerName("authorizationStatusForMediaType:") orelse return error.SelectorNotFound;
676
677 // AVMediaTypeVideo or AVMediaTypeAudio
678 const mediaType = if (permission == .camera) "vide" else "soun";
679 const mediaTypeStr = objc.objc_getClass("AVMediaTypeVideo") orelse {
680 // Fallback - create NSString for media type
681 const allocator = std.heap.page_allocator;
682 const ns_media = try objc.createNSString(mediaType, allocator);
683 const Fn = *const fn (objc.Class, objc.SEL, objc.id) callconv(.c) i64;
684 const func: Fn = @ptrCast(&.objc.objc_msgSend);
685 const status = func(AVCaptureDeviceClass, sel_authorizationStatus, ns_media);
686 return statusFromAVAuthorizationStatus(status);
687 };
688
689 const Fn = *const fn (objc.Class, objc.SEL, objc.id) callconv(.c) i64;
690 const func: Fn = @ptrCast(&.objc.objc_msgSend);
691 const status = func(AVCaptureDeviceClass, sel_authorizationStatus, mediaTypeStr);
692 return statusFromAVAuthorizationStatus(status);
693 },
694 .location => {
695 const CLLocationManagerClass = objc.objc_getClass("CLLocationManager") orelse return error.ClassNotFound;
696 const sel_authorizationStatus = objc.sel_registerName("authorizationStatus") orelse return error.SelectorNotFound;
697
698 const Fn = *const fn (objc.Class, objc.SEL) callconv(.c) i32;
699 const func: Fn = @ptrCast(&.objc.objc_msgSend);
700 const status = func(CLLocationManagerClass, sel_authorizationStatus);
701 return statusFromCLAuthorizationStatus(status);
702 },
703 .photos => {
704 const PHPhotoLibraryClass = objc.objc_getClass("PHPhotoLibrary") orelse return error.ClassNotFound;
705 const sel_authorizationStatus = objc.sel_registerName("authorizationStatus") orelse return error.SelectorNotFound;
706
707 const Fn = *const fn (objc.Class, objc.SEL) callconv(.c) i64;
708 const func: Fn = @ptrCast(&.objc.objc_msgSend);
709 const status = func(PHPhotoLibraryClass, sel_authorizationStatus);
710 return statusFromPHAuthorizationStatus(status);
711 },
712 .notifications => {
713 // Notifications require async check - return unknown for sync check
714 return .not_determined;
715 },
716 .contacts => {
717 const CNContactStoreClass = objc.objc_getClass("CNContactStore") orelse return error.ClassNotFound;
718 const sel_authorizationStatus = objc.sel_registerName("authorizationStatusForEntityType:") orelse return error.SelectorNotFound;
719
720 const Fn = *const fn (objc.Class, objc.SEL, i64) callconv(.c) i64;
721 const func: Fn = @ptrCast(&.objc.objc_msgSend);
722 const status = func(CNContactStoreClass, sel_authorizationStatus, 0); // CNEntityTypeContacts = 0
723 return statusFromCNAuthorizationStatus(status);
724 },
725 .calendar, .reminders => {
726 const EKEventStoreClass = objc.objc_getClass("EKEventStore") orelse return error.ClassNotFound;
727 const sel_authorizationStatus = objc.sel_registerName("authorizationStatusForEntityType:") orelse return error.SelectorNotFound;
728
729 const entity_type: i64 = if (permission == .calendar) 0 else 1; // EKEntityTypeEvent = 0, EKEntityTypeReminder = 1
730 const Fn = *const fn (objc.Class, objc.SEL, i64) callconv(.c) i64;
731 const func: Fn = @ptrCast(&.objc.objc_msgSend);
732 const status = func(EKEventStoreClass, sel_authorizationStatus, entity_type);
733 return statusFromEKAuthorizationStatus(status);
734 },
735 }
736 }
737
738432 pub const PermissionStatus = enum {
739433 not_determined,
740434 restricted,
@@ -743,145 +437,27 @@ pub const iOS = struct {
743437 limited, // For photos
744438 };
745439
746 fn statusFromAVAuthorizationStatus(status: i64) PermissionStatus {
747 return switch (status) {
748 0 => .not_determined, // AVAuthorizationStatusNotDetermined
749 1 => .restricted, // AVAuthorizationStatusRestricted
750 2 => .denied, // AVAuthorizationStatusDenied
751 3 => .authorized, // AVAuthorizationStatusAuthorized
752 else => .not_determined,
753 };
754 }
755
756 fn statusFromCLAuthorizationStatus(status: i32) PermissionStatus {
757 return switch (status) {
758 0 => .not_determined, // kCLAuthorizationStatusNotDetermined
759 1 => .restricted, // kCLAuthorizationStatusRestricted
760 2 => .denied, // kCLAuthorizationStatusDenied
761 3, 4 => .authorized, // kCLAuthorizationStatusAuthorizedAlways/WhenInUse
762 else => .not_determined,
763 };
764 }
765
766 fn statusFromPHAuthorizationStatus(status: i64) PermissionStatus {
767 return switch (status) {
768 0 => .not_determined,
769 1 => .restricted,
770 2 => .denied,
771 3 => .authorized,
772 4 => .limited, // PHAuthorizationStatusLimited (iOS 14+)
773 else => .not_determined,
774 };
775 }
776
777 fn statusFromCNAuthorizationStatus(status: i64) PermissionStatus {
778 return switch (status) {
779 0 => .not_determined,
780 1 => .restricted,
781 2 => .denied,
782 3 => .authorized,
783 else => .not_determined,
784 };
785 }
786
787 fn statusFromEKAuthorizationStatus(status: i64) PermissionStatus {
788 return switch (status) {
789 0 => .not_determined,
790 1 => .restricted,
791 2 => .denied,
792 3 => .authorized,
793 else => .not_determined,
794 };
795 }
796
797 pub fn requestPermission(permission: Permission, callback: *const fn (bool) void) !void {
798 if (!.target.os.tag.isDarwin()) {
799 return error.UnsupportedPlatform;
800 }
801
802 // Store callback for later invocation (would need proper callback management)
803 _ = callback;
804
805 // Different permission types require different iOS APIs
806 switch (permission) {
807 .camera, .microphone => {
808 const AVCaptureDeviceClass = objc.objc_getClass("AVCaptureDevice") orelse return error.ClassNotFound;
809 const sel_requestAccessForMediaType = objc.sel_registerName("requestAccessForMediaType:completionHandler:") orelse return error.SelectorNotFound;
810
811 // Create media type string
812 const allocator = std.heap.page_allocator;
813 const mediaType = if (permission == .camera) "vide" else "soun";
814 const ns_media = try objc.createNSString(mediaType, allocator);
815
816 // Request access (completion handler would need block creation)
817 const Fn = *const fn (objc.Class, objc.SEL, objc.id, ?*anyopaque) callconv(.c) void;
818 const func: Fn = @ptrCast(&.objc.objc_msgSend);
819 func(AVCaptureDeviceClass, sel_requestAccessForMediaType, ns_media, null);
820 },
821 .location => {
822 const CLLocationManagerClass = objc.objc_getClass("CLLocationManager") orelse return error.ClassNotFound;
823 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
824 const sel_init = objc.sel_registerName("init") orelse return error.SelectorNotFound;
825 const sel_requestWhenInUseAuthorization = objc.sel_registerName("requestWhenInUseAuthorization") orelse return error.SelectorNotFound;
826
827 // Create location manager and request authorization
828 const allocated = objc.msgSendId(CLLocationManagerClass, sel_alloc);
829 const manager = objc.msgSendId(allocated, sel_init);
830 objc.msgSend(manager, sel_requestWhenInUseAuthorization);
831 },
832 .photos => {
833 const PHPhotoLibraryClass = objc.objc_getClass("PHPhotoLibrary") orelse return error.ClassNotFound;
834 const sel_requestAuthorization = objc.sel_registerName("requestAuthorization:") orelse return error.SelectorNotFound;
835
836 // Request photo library access (completion handler would need block creation)
837 const Fn = *const fn (objc.Class, objc.SEL, ?*anyopaque) callconv(.c) void;
838 const func: Fn = @ptrCast(&.objc.objc_msgSend);
839 func(PHPhotoLibraryClass, sel_requestAuthorization, null);
840 },
841 .notifications => {
842 const UNUserNotificationCenterClass = objc.objc_getClass("UNUserNotificationCenter") orelse return error.ClassNotFound;
843 const sel_currentNotificationCenter = objc.sel_registerName("currentNotificationCenter") orelse return error.SelectorNotFound;
844 const sel_requestAuthorizationWithOptions = objc.sel_registerName("requestAuthorizationWithOptions:completionHandler:") orelse return error.SelectorNotFound;
845
846 // Get notification center
847 const center = objc.msgSendId(UNUserNotificationCenterClass, sel_currentNotificationCenter);
848
849 // Request authorization with alert, badge, sound (7 = alert | badge | sound)
850 const Fn = *const fn (objc.id, objc.SEL, u64, ?*anyopaque) callconv(.c) void;
851 const func: Fn = @ptrCast(&.objc.objc_msgSend);
852 func(center, sel_requestAuthorizationWithOptions, 7, null);
853 },
854 .contacts => {
855 const CNContactStoreClass = objc.objc_getClass("CNContactStore") orelse return error.ClassNotFound;
856 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
857 const sel_init = objc.sel_registerName("init") orelse return error.SelectorNotFound;
858 const sel_requestAccessForEntityType = objc.sel_registerName("requestAccessForEntityType:completionHandler:") orelse return error.SelectorNotFound;
859
860 // Create contact store and request access
861 const allocated = objc.msgSendId(CNContactStoreClass, sel_alloc);
862 const store = objc.msgSendId(allocated, sel_init);
863
864 const Fn = *const fn (objc.id, objc.SEL, i64, ?*anyopaque) callconv(.c) void;
865 const func: Fn = @ptrCast(&.objc.objc_msgSend);
866 func(store, sel_requestAccessForEntityType, 0, null); // CNEntityTypeContacts = 0
867 },
868 .calendar, .reminders => {
869 const EKEventStoreClass = objc.objc_getClass("EKEventStore") orelse return error.ClassNotFound;
870 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
871 const sel_init = objc.sel_registerName("init") orelse return error.SelectorNotFound;
872 const sel_requestAccessToEntityType = objc.sel_registerName("requestAccessToEntityType:completion:") orelse return error.SelectorNotFound;
873
874 // Create event store and request access
875 const allocated = objc.msgSendId(EKEventStoreClass, sel_alloc);
876 const store = objc.msgSendId(allocated, sel_init);
877
878 const entity_type: i64 = if (permission == .calendar) 0 else 1;
879 const Fn = *const fn (objc.id, objc.SEL, i64, ?*anyopaque) callconv(.c) void;
880 const func: Fn = @ptrCast(&.objc.objc_msgSend);
881 func(store, sel_requestAccessToEntityType, entity_type, null);
882 },
883 }
884 }
440 // `checkPermissionStatus`, its five `statusFrom*AuthorizationStatus`
441 // mappers, `requestPermission`, and `showAlert` used to sit between these
442 // two enums. The types stay because they are the vocabulary; the functions
443 // went because each was broken in a way no test could have caught, since
444 // nothing ever called them:
445 //
446 // - `requestPermission` took a `callback` and immediately did
447 // `_ = callback;`, then passed null as the completion handler to all
448 // five system calls. Permissions were write-only: the answer was never
449 // delivered to anyone, and Apple documents a null completion for
450 // `requestAccessForMediaType:` as undefined behaviour.
451 // - `checkPermissionStatus` returned `.not_determined` for notifications
452 // unconditionally, whatever the system actually held.
453 // - `showAlert` presented against `keyWindow`, deprecated since iOS 13
454 // and nil in any scene-based app — which is every app the templates
455 // generate. It also computed an auto-dismiss delay and discarded it,
456 // so the alert its own doc comment called self-dismissing never was.
457 //
458 // They return in the permissions phase, replies correlated by the
459 // envelope's request id through a pending-request table rather than by a
460 // callback pointer that nothing invokes.
885461
886462 /// Haptic Feedback
887463 pub const HapticType = enum {
@@ -966,62 +542,6 @@ pub const iOS = struct {
966542 },
967543 }
968544 }
969
970 /// Show iOS Alert/Toast
971 /// Uses UIAlertController for simple toast-like alerts
972 pub fn showAlert(message: []const u8, duration_short: bool) void {
973 if (!.target.os.tag.isDarwin()) {
974 return;
975 }
976
977 // Get UIAlertController class
978 const UIAlertControllerClass = objc.objc_getClass("UIAlertController") orelse return;
979
980 // Create alert title NSString (nil for toast-like appearance)
981 const NSStringClass = objc.objc_getClass("NSString") orelse return;
982 const sel_stringWithUTF8String = objc.sel_registerName("stringWithUTF8String:") orelse return;
983
984 // Create message string
985 const allocator = std.heap.page_allocator;
986 const msg_z = .dupeZ(allocator, u8, message) catch return;
987 defer allocator.free(msg_z);
988
989 const Fn1 = *const fn (objc.id, objc.SEL, [*:0]const u8) callconv(.c) objc.id;
990 const stringFn: Fn1 = @ptrCast(&.objc.objc_msgSend);
991 const messageStr = stringFn(NSStringClass, sel_stringWithUTF8String, msg_z.ptr);
992
993 // Create UIAlertController with style Alert (1)
994 const sel_alertWithTitle = objc.sel_registerName("alertControllerWithTitle:message:preferredStyle:") orelse return;
995 const Fn2 = *const fn (objc.id, objc.SEL, ?objc.id, objc.id, i64) callconv(.c) objc.id;
996 const alertFn: Fn2 = @ptrCast(&.objc.objc_msgSend);
997 const alert = alertFn(UIAlertControllerClass, sel_alertWithTitle, null, messageStr, 1); // UIAlertControllerStyleAlert = 1
998
999 // Get the key window and root view controller
1000 const UIApplicationClass = objc.objc_getClass("UIApplication") orelse return;
1001 const sel_sharedApplication = objc.sel_registerName("sharedApplication") orelse return;
1002 const app = objc.msgSendId(UIApplicationClass, sel_sharedApplication);
1003
1004 const sel_keyWindow = objc.sel_registerName("keyWindow") orelse return;
1005 const window = objc.msgSendId(app, sel_keyWindow);
1006 if (window == null) return;
1007
1008 const sel_rootViewController = objc.sel_registerName("rootViewController") orelse return;
1009 const rootVC = objc.msgSendId(window, sel_rootViewController);
1010 if (rootVC == null) return;
1011
1012 // Present the alert
1013 const sel_presentViewController = objc.sel_registerName("presentViewController:animated:completion:") orelse return;
1014 const Fn3 = *const fn (objc.id, objc.SEL, objc.id, bool, ?objc.id) callconv(.c) void;
1015 const presentFn: Fn3 = @ptrCast(&.objc.objc_msgSend);
1016 presentFn(rootVC, sel_presentViewController, alert, true, null);
1017
1018 // Auto-dismiss after delay using dispatch_after
1019 const delay_seconds: f64 = if (duration_short) 2.0 else 3.5;
1020 _ = delay_seconds;
1021
1022 // For simplicity, just present the alert - user can tap away
1023 // In production, would use dispatch_after to dismiss automatically
1024 }
1025545};
1026546
1027547/// Android Native Integration
@@ -1079,161 +599,6 @@ pub const Android = struct {
1079599 };
1080600 };
1081601
1082 /// JNI Environment (global reference, must be set by Java/Kotlin code)
1083 var jni_env: ?*jni.JNIEnv = null;
1084
1085 pub fn setJNIEnv(env: *jni.JNIEnv) void {
1086 jni_env = env;
1087 }
1088
1089 /// Create Android WebView
1090 pub fn createWebView(allocator: std.mem.Allocator, context: *Context, config: WebViewConfig) !*WebView {
1091 const builtin = ;
1092 if (builtin.target.os.tag != .linux) {
1093 return error.UnsupportedPlatform;
1094 }
1095
1096 const env = jni_env orelse return error.JNINotInitialized;
1097
1098 // Get WebView class
1099 const webview_class_name = "android/webkit/WebView";
1100 const webview_class = try getJNIClass(env, webview_class_name);
1101
1102 // Get WebView constructor
1103 const constructor_id = try getJNIMethod(env, webview_class, "<init>", "(Landroid/content/Context;)V");
1104
1105 // Create WebView instance
1106 const webview_obj = jni.CallObjectMethod(env, context, constructor_id, .{});
1107 if (webview_obj == null) {
1108 return error.WebViewCreationFailed;
1109 }
1110
1111 // Configure WebView settings
1112 const settings_method = try getJNIMethod(env, webview_class, "getSettings", "()Landroid/webkit/WebSettings;");
1113 const settings = jni.CallObjectMethod(env, webview_obj, settings_method, .{});
1114
1115 if (settings != null) {
1116 const settings_class_name = "android/webkit/WebSettings";
1117 const settings_class = try getJNIClass(env, settings_class_name);
1118
1119 // Enable JavaScript
1120 if (config.javascript_enabled) {
1121 const set_js_enabled = try getJNIMethod(env, settings_class, "setJavaScriptEnabled", "(Z)V");
1122 jni.CallVoidMethod(env, settings, set_js_enabled, .{@as(jni.jboolean, 1)});
1123 }
1124
1125 // Enable DOM storage
1126 if (config.dom_storage_enabled) {
1127 const set_dom_storage = try getJNIMethod(env, settings_class, "setDomStorageEnabled", "(Z)V");
1128 jni.CallVoidMethod(env, settings, set_dom_storage, .{@as(jni.jboolean, 1)});
1129 }
1130
1131 // Enable database
1132 if (config.database_enabled) {
1133 const set_database = try getJNIMethod(env, settings_class, "setDatabaseEnabled", "(Z)V");
1134 jni.CallVoidMethod(env, settings, set_database, .{@as(jni.jboolean, 1)});
1135 }
1136 }
1137
1138 _ = allocator; // Will be used for cleanup tracking
1139 return @ptrCast(@alignCast(webview_obj));
1140 }
1141
1142 /// Load URL in WebView
1143 pub fn loadURL(webview: *WebView, url: []const u8) !void {
1144 const builtin = ;
1145 if (builtin.target.os.tag != .linux) {
1146 return error.UnsupportedPlatform;
1147 }
1148
1149 const env = jni_env orelse return error.JNINotInitialized;
1150
1151 // Get WebView class
1152 const webview_obj: jni.jobject = @ptrCast(@alignCast(webview));
1153 const webview_class = jni.GetObjectClass(env, webview_obj);
1154
1155 // Get loadUrl method
1156 const load_url_method = try getJNIMethod(env, webview_class, "loadUrl", "(Ljava/lang/String;)V");
1157
1158 // Convert URL to Java string (need null-terminated string)
1159 const allocator = std.heap.page_allocator;
1160 const url_z = try .dupeZ(allocator, u8, url);
1161 defer allocator.free(url_z);
1162 const url_jstring = jni.NewStringUTF(env, url_z.ptr);
1163
1164 // Call loadUrl
1165 jni.CallVoidMethod(env, webview_obj, load_url_method, .{url_jstring});
1166 }
1167
1168 /// Execute JavaScript
1169 pub fn evaluateJavaScript(webview: *WebView, script: []const u8, callback: ?*const fn ([]const u8) void) !void {
1170 const builtin = ;
1171 if (builtin.target.os.tag != .linux) {
1172 return error.UnsupportedPlatform;
1173 }
1174
1175 const env = jni_env orelse return error.JNINotInitialized;
1176
1177 // Get WebView class
1178 const webview_obj: jni.jobject = @ptrCast(@alignCast(webview));
1179 const webview_class = jni.GetObjectClass(env, webview_obj);
1180
1181 // Get evaluateJavascript method
1182 const eval_js_method = try getJNIMethod(env, webview_class, "evaluateJavascript", "(Ljava/lang/String;Landroid/webkit/ValueCallback;)V");
1183
1184 // Convert script to Java string (need null-terminated string)
1185 const allocator = std.heap.page_allocator;
1186 const script_z = try .dupeZ(allocator, u8, script);
1187 defer allocator.free(script_z);
1188 const script_jstring = jni.NewStringUTF(env, script_z.ptr);
1189
1190 // Create ValueCallback wrapper if callback is provided
1191 var value_callback: jni.jobject = null;
1192 if (callback) |cb| {
1193 // Store callback and get ID
1194 const callback_id = android_callbacks.storeJsCallback(cb);
1195
1196 // Create CraftValueCallback instance (Java class that implements ValueCallback)
1197 // Java code: new CraftValueCallback(callbackId)
1198 const craft_callback_class = try getJNIClass(env, "app/craft/CraftValueCallback");
1199 const callback_init = try getJNIMethod(env, craft_callback_class, "<init>", "(I)V");
1200
1201 // Allocate new object
1202 const alloc_method = try getJNIMethod(env, craft_callback_class, "<init>", "(I)V");
1203 _ = alloc_method;
1204
1205 // Create instance with callback ID
1206 value_callback = jni.CallObjectMethod(env, craft_callback_class, callback_init, .{@as(jni.jint, @intCast(callback_id))});
1207 }
1208
1209 // Call evaluateJavascript with the ValueCallback wrapper
1210 jni.CallVoidMethod(env, webview_obj, eval_js_method, .{ script_jstring, value_callback });
1211 }
1212
1213 /// Helper function to get JNI class
1214 fn getJNIClass(env: *jni.JNIEnv, class_name: [:0]const u8) !jni.jclass {
1215 const cls = jni.FindClass(env, class_name.ptr);
1216 if (cls == null) {
1217 return error.ClassNotFound;
1218 }
1219 return cls;
1220 }
1221
1222 /// Helper function to get JNI method
1223 fn getJNIMethod(env: *jni.JNIEnv, cls: jni.jclass, method_name: [:0]const u8, signature: [:0]const u8) !jni.jmethodID {
1224 const method_id = jni.GetMethodID(env, cls, method_name.ptr, signature.ptr);
1225 if (method_id == null) {
1226 return error.MethodNotFound;
1227 }
1228 return method_id;
1229 }
1230
1231 /// Handle Deep Links
1232 pub fn handleDeepLink(intent_data: []const u8) !void {
1233 _ = intent_data;
1234 // Parse and handle Android Intent data
1235 }
1236
1237602 /// Request Permissions
1238603 pub const Permission = enum {
1239604 camera,
@@ -1247,133 +612,36 @@ pub const Android = struct {
1247612 record_audio,
1248613 };
1249614
1250 pub fn requestPermission(activity: *Activity, permission: Permission, callback: *const fn (bool) void) !void {
1251 const builtin = ;
1252 if (builtin.target.os.tag != .linux) {
1253 return error.UnsupportedPlatform;
1254 }
1255
1256 const env = jni_env orelse return error.JNINotInitialized;
1257
1258 // Get ActivityCompat class
1259 const activity_compat_class = try getJNIClass(env, "androidx/core/app/ActivityCompat");
1260
1261 // Convert permission to Android permission string
1262 const permission_str = switch (permission) {
1263 .camera => "android.permission.CAMERA",
1264 .microphone => "android.permission.RECORD_AUDIO",
1265 .location_fine => "android.permission.ACCESS_FINE_LOCATION",
1266 .location_coarse => "android.permission.ACCESS_COARSE_LOCATION",
1267 .read_external_storage => "android.permission.READ_EXTERNAL_STORAGE",
1268 .write_external_storage => "android.permission.WRITE_EXTERNAL_STORAGE",
1269 .read_contacts => "android.permission.READ_CONTACTS",
1270 .write_contacts => "android.permission.WRITE_CONTACTS",
1271 .record_audio => "android.permission.RECORD_AUDIO",
1272 };
1273
1274 // Get requestPermissions method
1275 const request_permissions_method = try getJNIMethod(
1276 env,
1277 activity_compat_class,
1278 "requestPermissions",
1279 "(Landroid/app/Activity;[Ljava/lang/String;I)V",
1280 );
1281
1282 // Create string array with single permission
1283 const allocator = std.heap.page_allocator;
1284 const permission_z = try .dupeZ(allocator, u8, permission_str);
1285 defer allocator.free(permission_z);
1286 const permission_jstring = jni.NewStringUTF(env, permission_z.ptr);
1287
1288 // Use a unique request code based on permission type
1289 const request_code: u32 = 1001 + @backingInt(permission);
1290
1291 // Store callback to be invoked in onRequestPermissionsResult
1292 if (callback) |cb| {
1293 android_callbacks.storePermissionCallback(request_code, cb);
1294 }
1295
1296 // Call requestPermissions with unique request code
1297 const activity_obj: jni.jobject = @ptrCast(@alignCast(activity));
1298 jni.CallVoidMethod(env, activity_obj, request_permissions_method, .{ permission_jstring, @as(jni.jint, @intCast(request_code)) });
1299 }
1300
1301 /// Vibration
1302 pub fn vibrate(context: *Context, duration_ms: u64) void {
1303 const builtin = ;
1304 if (builtin.target.os.tag != .linux) {
1305 return;
1306 }
1307
1308 const env = jni_env orelse return;
1309
1310 // Get Vibrator service
1311 const context_obj: jni.jobject = @ptrCast(@alignCast(context));
1312 const context_class = jni.GetObjectClass(env, context_obj);
1313
1314 const get_system_service_method = getJNIMethod(env, context_class, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;") catch return;
1315
1316 // Get VIBRATOR_SERVICE constant
1317 const vibrator_service_str = jni.NewStringUTF(env, "vibrator");
1318 const vibrator_obj = jni.CallObjectMethod(env, context_obj, get_system_service_method, .{vibrator_service_str});
1319
1320 if (vibrator_obj != null) {
1321 const vibrator_class = jni.GetObjectClass(env, vibrator_obj);
1322
1323 // For Android 26+, use VibrationEffect
1324 const vibrate_method = getJNIMethod(env, vibrator_class, "vibrate", "(J)V") catch return;
1325 jni.CallVoidMethod(env, vibrator_obj, vibrate_method, .{@as(jni.jlong, @intCast(duration_ms))});
1326 }
1327 }
1328
1329 /// Toast Notification
1330 pub fn showToast(context: *Context, message: []const u8, duration: ToastDuration) void {
1331 const builtin = ;
1332 if (builtin.target.os.tag != .linux) {
1333 return;
1334 }
1335
1336 const env = jni_env orelse return;
1337
1338 // Get Toast class
1339 const toast_class = getJNIClass(env, "android/widget/Toast") catch return;
1340
1341 // Get makeText method
1342 const make_text_method = getJNIMethod(
1343 env,
1344 toast_class,
1345 "makeText",
1346 "(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;",
1347 ) catch return;
1348
1349 // Convert message to Java string (need null-terminated string)
1350 const allocator = std.heap.page_allocator;
1351 const message_z = .dupeZ(allocator, u8, message) catch return;
1352 defer allocator.free(message_z);
1353 const message_jstring = jni.NewStringUTF(env, message_z.ptr);
1354
1355 // Convert duration
1356 const duration_int: jni.jint = switch (duration) {
1357 .short => 0, // Toast.LENGTH_SHORT
1358 .long => 1, // Toast.LENGTH_LONG
1359 };
1360
1361 // Create toast
1362 const context_obj: jni.jobject = @ptrCast(@alignCast(context));
1363 const toast_obj = jni.CallObjectMethod(env, toast_class, make_text_method, .{ context_obj, message_jstring, duration_int });
1364
1365 if (toast_obj != null) {
1366 // Show toast
1367 const toast_obj_class = jni.GetObjectClass(env, toast_obj);
1368 const show_method = getJNIMethod(env, toast_obj_class, "show", "()V") catch return;
1369 jni.CallVoidMethod(env, toast_obj, show_method, .{});
1370 }
1371 }
1372
1373615 pub const ToastDuration = enum {
1374616 short,
1375617 long,
1376618 };
619
620 // Every function that used to live in this struct — `setJNIEnv`,
621 // `createWebView`, `loadURL`, `evaluateJavaScript`, `getJNIClass`,
622 // `getJNIMethod`, `handleDeepLink`, `requestPermission`, `vibrate`,
623 // `showToast` — has been deleted. The types stay because they are the
624 // vocabulary the rest of the codebase and the test suite use; the
625 // functions went because none of them could ever have worked.
626 //
627 // They all read through a hand-written `JNINativeInterface` that declared
628 // four reserved slots, then `GetVersion`, then jumped straight to
629 // `FindClass` under a comment reading "... many more function pointers
630 // ...". The real JNI vtable has roughly 230 entries in a fixed ABI order —
631 // `FindClass` is index 6, `GetObjectClass` 31, `NewStringUTF` 167 — so
632 // every call read the wrong slot and would have jumped to a garbage
633 // address. That is not a bug you fix in place; the replacement `@cImport`s
634 // the NDK's own `jni.h`, where the layout cannot be got wrong.
635 //
636 // Individually they were also wrong in ways that prove none was ever
637 // compiled, let alone run: `requestPermission` resolved the *static*
638 // `ActivityCompat.requestPermissions` with `GetMethodID` and passed two
639 // varargs where the signature demands three with a `jobjectArray`;
640 // `createWebView` and `evaluateJavaScript` invoked constructors through
641 // `CallObjectMethod`, which cannot construct anything — `NewObject` is the
642 // one that can; and `requestPermission` contained `if (callback) |cb|` on
643 // a non-optional parameter, an unconditional compile error that never
644 // fired because nothing referenced the function.
1377645};
1378646
1379647/// Cross-Platform Mobile Window
@@ -1420,10 +688,11 @@ pub const MobileWindow = struct {
1420688 const webview: *iOS.WKWebView = @ptrCast(@alignCast(self.handle));
1421689 try iOS.loadURL(webview, url);
1422690 },
1423 .android => {
1424 const webview: *Android.WebView = @ptrCast(@alignCast(self.handle));
1425 try Android.loadURL(webview, url);
1426 },
691 // Android has no JNI layer yet. Returning an error is the honest
692 // answer; the deleted `Android.loadURL` would have jumped through
693 // a mis-ordered vtable instead, which is a crash wearing the
694 // costume of an implementation.
695 .android => return error.AndroidBridgeNotImplemented,
1427696 .unknown => return error.UnsupportedPlatform,
1428697 }
1429698 }
@@ -1434,10 +703,7 @@ pub const MobileWindow = struct {
1434703 const webview: *iOS.WKWebView = @ptrCast(@alignCast(self.handle));
1435704 try iOS.evaluateJavaScript(webview, script, null);
1436705 },
1437 .android => {
1438 const webview: *Android.WebView = @ptrCast(@alignCast(self.handle));
1439 try Android.evaluateJavaScript(webview, script, null);
1440 },
706 .android => return error.AndroidBridgeNotImplemented,
1441707 .unknown => return error.UnsupportedPlatform,
1442708 }
1443709 }
packages/zig/src/objc_runtime.zigmodified+23-2
Changes to packages/zig/src/objc_runtime.zig
@@ -15,6 +15,7 @@ pub const objc = if (is_darwin) struct {
1515 pub extern "c" fn class_addMethod(cls: ?*anyopaque, name: ?*anyopaque, imp: *const anyopaque, types: [*:0]const u8) bool;
1616 pub extern "c" fn object_getClass(obj: ?*anyopaque) ?*anyopaque;
1717 pub extern "c" fn class_createInstance(cls: ?*anyopaque, extraBytes: usize) ?*anyopaque;
18 pub extern "c" fn class_getInstanceSize(cls: ?*anyopaque) usize;
1819 pub extern "c" fn objc_setAssociatedObject(obj: ?*anyopaque, key: ?*const anyopaque, value: ?*anyopaque, policy: c_uint) void;
1920 pub extern "c" fn objc_getAssociatedObject(obj: ?*anyopaque, key: ?*const anyopaque) ?*anyopaque;
2021 pub extern "c" fn objc_removeAssociatedObjects(obj: ?*anyopaque) void;
@@ -149,6 +150,26 @@ pub const objc = if (is_darwin) struct {
149150 return msgSendId1(NSURLClass, sel, ns_string);
150151 }
151152
153 /// How many bytes an instance of `class` occupies, as the runtime reports
154 /// it. Useful for memory accounting, where `@sizeOf` on a Zig `opaque{}`
155 /// handle would report zero and `@sizeOf(@TypeOf(ptr))` the width of a
156 /// pointer.
157 pub fn classInstanceSize(class: Class) usize {
158 return class_getInstanceSize(@ptrCast(class));
159 }
160
161 /// Allocate an instance without initialising it.
162 ///
163 /// Separate from `allocInit` because a caller that has its own designated
164 /// initialiser — `initWithFrame:`, `initWithFrame:configuration:` — must
165 /// not have `init` sent first. `ios.zig` needs exactly this half, and
166 /// `mobile.zig` open-coded it rather than reach for a helper that did too
167 /// much.
168 pub fn alloc(class: Class) !id {
169 const sel_alloc = sel_registerName("alloc") orelse return error.SelectorNotFound;
170 return msgSendId(class, sel_alloc);
171 }
172
152173 /// Helper to alloc and init an object
153174 pub fn allocInit(class: Class) !id {
154175 const sel_alloc = sel_registerName("alloc") orelse return error.SelectorNotFound;
@@ -179,7 +200,7 @@ pub const objc = if (is_darwin) struct {
179200
180201// Tests
181202test "NSString creation" {
182 if (!.target.isDarwin()) return error.SkipZigTest;
203 if (!.target.os.tag.isDarwin()) return error.SkipZigTest;
183204
184205 const allocator = std.testing.allocator;
185206 const ns_string = try objc.createNSString("Hello, World!", allocator);
@@ -190,7 +211,7 @@ test "NSString creation" {
190211}
191212
192213test "NSURL creation" {
193 if (!.target.isDarwin()) return error.SkipZigTest;
214 if (!.target.os.tag.isDarwin()) return error.SkipZigTest;
194215
195216 const allocator = std.testing.allocator;
196217 const ns_url = try objc.createNSURL("https://example.com", allocator);
packages/zig/test/android_test.zigdeleted+0-373
Changes to packages/zig/test/android_test.zig
@@ -1,373 +0,0 @@
1const std = ;
2const testing = std.testing;
3const android = ;
4
5// ============================================================================
6// JNI Types Tests
7// ============================================================================
8
9test "JNI - type definitions" {
10 // Verify JNI type sizes match expected values
11 try testing.expectEqual(@as(usize, @sizeOf(i32)), @sizeOf(android.jni.jint));
12 try testing.expectEqual(@as(usize, @sizeOf(i64)), @sizeOf(android.jni.jlong));
13 try testing.expectEqual(@as(usize, @sizeOf(u8)), @sizeOf(android.jni.jboolean));
14 try testing.expectEqual(@as(usize, @sizeOf(f32)), @sizeOf(android.jni.jfloat));
15 try testing.expectEqual(@as(usize, @sizeOf(f64)), @sizeOf(android.jni.jdouble));
16}
17
18// ============================================================================
19// CraftActivity Tests
20// ============================================================================
21
22test "CraftActivity - Theme enum" {
23 try testing.expectEqual(android.CraftActivity.Theme.light, .light);
24 try testing.expectEqual(android.CraftActivity.Theme.dark, .dark);
25 try testing.expectEqual(android.CraftActivity.Theme.system, .system);
26}
27
28test "CraftActivity - Orientation enum" {
29 try testing.expectEqual(android.CraftActivity.Orientation.portrait, .portrait);
30 try testing.expectEqual(android.CraftActivity.Orientation.landscape, .landscape);
31 try testing.expectEqual(android.CraftActivity.Orientation.sensor, .sensor);
32 try testing.expectEqual(android.CraftActivity.Orientation.unspecified, .unspecified);
33}
34
35test "CraftActivity - Content union" {
36 const html_content = android.CraftActivity.Content{ .html = "<h1>Hello</h1>" };
37 const url_content = android.CraftActivity.Content{ .url = "https://example.com" };
38 const asset_content = android.CraftActivity.Content{ .asset = "index.html" };
39
40 switch (html_content) {
41 .html => |h| try testing.expectEqualStrings("<h1>Hello</h1>", h),
42 else => return error.UnexpectedContent,
43 }
44
45 switch (url_content) {
46 .url => |u| try testing.expectEqualStrings("https://example.com", u),
47 else => return error.UnexpectedContent,
48 }
49
50 switch (asset_content) {
51 .asset => |a| try testing.expectEqualStrings("index.html", a),
52 else => return error.UnexpectedContent,
53 }
54}
55
56test "CraftActivity - ActivityConfig defaults" {
57 const config = android.CraftActivity.ActivityConfig{};
58
59 try testing.expectEqualStrings("Craft App", config.name);
60 try testing.expectEqualStrings("com.craft.app", config.package_name);
61 try testing.expectEqual(android.CraftActivity.Theme.light, config.theme);
62 try testing.expectEqual(android.CraftActivity.Orientation.portrait, config.orientation);
63 try testing.expect(!config.fullscreen);
64 try testing.expect(config.hardware_accelerated);
65 try testing.expect(config.enable_javascript);
66 try testing.expect(config.enable_dom_storage);
67 try testing.expect(!config.allow_file_access);
68 try testing.expect(!config.enable_inspector);
69}
70
71test "CraftActivity - ActivityConfig custom" {
72 const config = android.CraftActivity.ActivityConfig{
73 .name = "My App",
74 .package_name = "com.example.myapp",
75 .theme = .dark,
76 .orientation = .landscape,
77 .fullscreen = true,
78 .enable_inspector = true,
79 .allow_file_access = true,
80 };
81
82 try testing.expectEqualStrings("My App", config.name);
83 try testing.expectEqualStrings("com.example.myapp", config.package_name);
84 try testing.expectEqual(android.CraftActivity.Theme.dark, config.theme);
85 try testing.expectEqual(android.CraftActivity.Orientation.landscape, config.orientation);
86 try testing.expect(config.fullscreen);
87 try testing.expect(config.enable_inspector);
88 try testing.expect(config.allow_file_access);
89}
90
91test "CraftActivity - init" {
92 const config = android.CraftActivity.ActivityConfig{
93 .name = "Test App",
94 .package_name = "com.test.app",
95 .initial_content = .{ .html = "<h1>Test</h1>" },
96 };
97
98 const activity = android.CraftActivity.init(testing.allocator, config);
99
100 try testing.expectEqualStrings("Test App", activity.config.name);
101 try testing.expectEqualStrings("com.test.app", activity.config.package_name);
102 try testing.expect(activity.js_bridge == null);
103 try testing.expect(activity.webview == null);
104 try testing.expect(activity.on_create == null);
105 try testing.expect(activity.on_resume == null);
106 try testing.expect(activity.on_pause == null);
107 try testing.expect(activity.on_destroy == null);
108 try testing.expect(activity.on_back_pressed == null);
109}
110
111test "CraftActivity - lifecycle callbacks" {
112 var activity = android.CraftActivity.init(testing.allocator, .{});
113
114 var create_called = false;
115 var resume_called = false;
116 var pause_called = false;
117 var destroy_called = false;
118
119 const Callbacks = struct {
120 var create_flag: *bool = undefined;
121 var resume_flag: *bool = undefined;
122 var pause_flag: *bool = undefined;
123 var destroy_flag: *bool = undefined;
124
125 fn onCreate() void {
126 create_flag.* = true;
127 }
128 fn onResume() void {
129 resume_flag.* = true;
130 }
131 fn onPause() void {
132 pause_flag.* = true;
133 }
134 fn onDestroy() void {
135 destroy_flag.* = true;
136 }
137 };
138
139 Callbacks.create_flag = &create_called;
140 Callbacks.resume_flag = &resume_called;
141 Callbacks.pause_flag = &pause_called;
142 Callbacks.destroy_flag = &destroy_called;
143
144 activity.onCreate(Callbacks.onCreate);
145 activity.onResume(Callbacks.onResume);
146 activity.onPause(Callbacks.onPause);
147 activity.onDestroy(Callbacks.onDestroy);
148
149 try testing.expect(activity.on_create != null);
150 try testing.expect(activity.on_resume != null);
151 try testing.expect(activity.on_pause != null);
152 try testing.expect(activity.on_destroy != null);
153
154 // Invoke callbacks
155 if (activity.on_create) |cb| cb();
156 if (activity.on_resume) |cb| cb();
157 if (activity.on_pause) |cb| cb();
158 if (activity.on_destroy) |cb| cb();
159
160 try testing.expect(create_called);
161 try testing.expect(resume_called);
162 try testing.expect(pause_called);
163 try testing.expect(destroy_called);
164}
165
166test "CraftActivity - onBackPressed callback" {
167 var activity = android.CraftActivity.init(testing.allocator, .{});
168
169 const Callback = struct {
170 fn onBackPressed() bool {
171 return true; // Consume event
172 }
173 };
174
175 activity.onBackPressed(Callback.onBackPressed);
176 try testing.expect(activity.on_back_pressed != null);
177
178 if (activity.on_back_pressed) |cb| {
179 const consumed = cb();
180 try testing.expect(consumed);
181 }
182}
183
184// ============================================================================
185// JSBridge Tests
186// ============================================================================
187
188test "JSBridge - init and deinit" {
189 var bridge = android.JSBridge.init(testing.allocator);
190 defer bridge.deinit();
191
192 try testing.expect(bridge.activity == null);
193 // Built-in handlers should be registered
194 try testing.expect(bridge.handlers.get("getPlatform") != null);
195 try testing.expect(bridge.handlers.get("showToast") != null);
196 try testing.expect(bridge.handlers.get("vibrate") != null);
197 try testing.expect(bridge.handlers.get("setClipboard") != null);
198 try testing.expect(bridge.handlers.get("getClipboard") != null);
199 try testing.expect(bridge.handlers.get("share") != null);
200 try testing.expect(bridge.handlers.get("openURL") != null);
201 try testing.expect(bridge.handlers.get("getNetworkStatus") != null);
202 try testing.expect(bridge.handlers.get("showAlert") != null);
203}
204
205test "JSBridge - registerHandler" {
206 var bridge = android.JSBridge.init(testing.allocator);
207 defer bridge.deinit();
208
209 const handler = struct {
210 fn handle(_: []const u8, _: *android.JSBridge, _: []const u8) void {}
211 }.handle;
212
213 try bridge.registerHandler("customHandler", handler);
214 try testing.expect(bridge.handlers.get("customHandler") != null);
215}
216
217test "JSBridge - registerHandler overwrites" {
218 var bridge = android.JSBridge.init(testing.allocator);
219 defer bridge.deinit();
220
221 const handler1 = struct {
222 fn handle(_: []const u8, _: *android.JSBridge, _: []const u8) void {}
223 }.handle;
224
225 const handler2 = struct {
226 fn handle(_: []const u8, _: *android.JSBridge, _: []const u8) void {}
227 }.handle;
228
229 try bridge.registerHandler("test", handler1);
230 try bridge.registerHandler("test", handler2);
231
232 // Should not error, just overwrite
233 try testing.expect(bridge.handlers.get("test") != null);
234}
235
236test "JSBridge - handleMessage with unknown method" {
237 var bridge = android.JSBridge.init(testing.allocator);
238 defer bridge.deinit();
239
240 // This should not crash, just silently fail (no activity to send error)
241 bridge.handleMessage("{\"method\":\"unknownMethod\",\"callbackId\":\"123\",\"params\":{}}");
242}
243
244test "JSBridge - handleMessage with getPlatform" {
245 var bridge = android.JSBridge.init(testing.allocator);
246 defer bridge.deinit();
247
248 // This should not crash (no activity to send response, but handler runs)
249 bridge.handleMessage("{\"method\":\"getPlatform\",\"callbackId\":\"456\",\"params\":{}}");
250}
251
252// ============================================================================
253// AndroidFeatures Tests
254// ============================================================================
255
256test "AndroidFeatures - DeviceInfo struct" {
257 const info = android.AndroidFeatures.DeviceInfo{
258 .manufacturer = "Samsung",
259 .model = "Galaxy S21",
260 .os_version = "13",
261 .sdk_version = 33,
262 };
263
264 try testing.expectEqualStrings("Samsung", info.manufacturer);
265 try testing.expectEqualStrings("Galaxy S21", info.model);
266 try testing.expectEqualStrings("13", info.os_version);
267 try testing.expectEqual(@as(i32, 33), info.sdk_version);
268}
269
270test "AndroidFeatures - getDeviceInfo" {
271 const info = android.AndroidFeatures.getDeviceInfo();
272
273 // Returns default values in non-Android environment
274 try testing.expectEqualStrings("Unknown", info.manufacturer);
275 try testing.expectEqualStrings("Android Device", info.model);
276 try testing.expectEqualStrings("14", info.os_version);
277 try testing.expectEqual(@as(i32, 34), info.sdk_version);
278}
279
280test "AndroidFeatures - isNetworkConnected" {
281 // Returns true by default in non-Android environment
282 const connected = android.AndroidFeatures.isNetworkConnected();
283 try testing.expect(connected);
284}
285
286test "AndroidFeatures - hasPermission" {
287 // Returns false by default in non-Android environment
288 const has_camera = android.AndroidFeatures.hasPermission("android.permission.CAMERA");
289 try testing.expect(!has_camera);
290}
291
292test "AndroidFeatures - getClipboard" {
293 // Returns empty string in non-Android environment
294 const text = try android.AndroidFeatures.getClipboard(testing.allocator);
295 try testing.expectEqualStrings("", text);
296}
297
298// ============================================================================
299// Quick Start Helper Tests
300// ============================================================================
301
302test "quickStart function exists" {
303 // Just verify the function signature compiles
304 const func = android.quickStart;
305 try testing.expect(@TypeOf(func) == fn (std.mem.Allocator, []const u8) anyerror!void);
306}
307
308// ============================================================================
309// Integration Tests
310// ============================================================================
311
312test "CraftActivity - full lifecycle simulation" {
313 var activity = android.CraftActivity.init(testing.allocator, .{
314 .name = "Integration Test App",
315 .package_name = "com.test.integration",
316 .initial_content = .{ .html = "<h1>Test</h1>" },
317 .theme = .system,
318 .orientation = .sensor,
319 });
320 defer activity.deinit();
321
322 // Set up all lifecycle callbacks
323 var lifecycle_order = std.ArrayList([]const u8).init(testing.allocator);
324 defer lifecycle_order.deinit();
325
326 // Note: We can't easily test the actual callback invocation order
327 // without modifying the activity internals, so we just verify setup
328 try testing.expectEqualStrings("Integration Test App", activity.config.name);
329 try testing.expectEqual(android.CraftActivity.Theme.system, activity.config.theme);
330 try testing.expectEqual(android.CraftActivity.Orientation.sensor, activity.config.orientation);
331}
332
333test "JSBridge - message parsing" {
334 var bridge = android.JSBridge.init(testing.allocator);
335 defer bridge.deinit();
336
337 // Test with various JSON formats
338 const messages = [_][]const u8{
339 "{\"method\":\"getPlatform\",\"callbackId\":\"1\",\"params\":{}}",
340 "{\"method\":\"showToast\",\"callbackId\":\"2\",\"params\":{\"message\":\"Hello\"}}",
341 "{\"method\":\"vibrate\",\"callbackId\":\"3\",\"params\":{\"duration\":100}}",
342 };
343
344 for (messages) |msg| {
345 // Should not crash
346 bridge.handleMessage(msg);
347 }
348}
349
350test "Activity with JSBridge integration" {
351 var activity = android.CraftActivity.init(testing.allocator, .{
352 .name = "Bridge Test",
353 });
354 defer activity.deinit();
355
356 // Run initializes the bridge
357 try activity.run();
358
359 // Bridge should now be available
360 try testing.expect(activity.js_bridge != null);
361
362 if (activity.getBridge()) |bridge| {
363 // Register custom handler
364 const handler = struct {
365 fn handle(_: []const u8, b: *android.JSBridge, callback_id: []const u8) void {
366 b.sendResponse(callback_id, "{}") catch {};
367 }
368 }.handle;
369
370 try bridge.registerHandler("customTest", handler);
371 try testing.expect(bridge.handlers.get("customTest") != null);
372 }
373}
packages/zig/test/ios_conformance_test.zigadded+209-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -0,0 +1,209 @@
1const std = ;
2const testing = std.testing;
3
4/// What iOS owes the page, and who currently owes it.
5///
6/// The Swift template is the specification for the Zig-native migration: its
7/// dispatcher is the list of actions a craft iOS app answers today, and Zig has
8/// to end up answering all of them. This file is what stops an action being
9/// dropped on the way across.
10///
11/// It exists because the repo already demonstrated the failure. `CraftApp.swift`
12/// disagreed with itself: its injected JavaScript offered five `ota*` methods
13/// that its own `switch` never handled, and because those methods bypassed the
14/// `_createCallback` path that owns the timeout, calling one returned a promise
15/// that never settled — not a rejection, not a timeout, nothing. One file, two
16/// halves, no mechanism to notice.
17///
18/// Embedded with `@embedFile` rather than read from disk, so the test is
19/// hermetic and reads exactly the bytes the build saw.
20const swift_spec = @embedFile("CraftApp.swift");
21const zig_actions = @embedFile("src/bridge_mobile.zig");
22
23/// The action list lives in the `switch action` block, and nowhere else.
24///
25/// Bounding the scan matters more than it looks. `CraftApp.swift` contains 139
26/// `case "..."` labels, but only 106 are actions — the rest are sub-switches
27/// over haptic styles, permission names, orientations, AR primitives. A whole
28/// file scan would count `case "box"` as a bridge action and then fail forever
29/// on a list nobody can satisfy.
30const dispatch_begin = "func userContentController(";
31const dispatch_end = "func webView(";
32
33/// How many spec actions Zig does not serve yet.
34///
35/// A ratchet, in the shape `capabilities_test.zig` already uses for
36/// `max_undeclared`: the number may only go down, and lowering it is what a
37/// migration phase costs. It starts at the full spec minus the vertical slice.
38///
39/// If this ever needs raising, something has been removed from Zig without
40/// being removed from the spec, and that is the conversation this constant
41/// exists to force.
42const max_not_yet_migrated: usize = 105;
43
44fn dispatcherRegion() []const u8 {
45 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
46 const rest = swift_spec[begin..];
47 const end = std.mem.indexOf(u8, rest, dispatch_end) orelse return rest;
48 return rest[0..end];
49}
50
51/// Every `case "name":` in a region, as a set.
52fn collectCases(allocator: std.mem.Allocator, region: []const u8) !std.StringHashMap(void) {
53 var set = std.StringHashMap(void).init(allocator);
54 errdefer set.deinit();
55
56 var search: usize = 0;
57 while (std.mem.indexOfPos(u8, region, search, "case \"")) |at| {
58 const name_start = at + "case \"".len;
59 const name_end = std.mem.indexOfScalarPos(u8, region, name_start, '"') orelse break;
60 try set.put(region[name_start..name_end], {});
61 search = name_end;
62 }
63 return set;
64}
65
66/// Every action the injected JavaScript posts, as a set.
67fn collectPostedActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
68 var set = std.StringHashMap(void).init(allocator);
69 errdefer set.deinit();
70
71 const needle = "action: '";
72 var search: usize = 0;
73 while (std.mem.indexOfPos(u8, swift_spec, search, needle)) |at| {
74 const name_start = at + needle.len;
75 const name_end = std.mem.indexOfScalarPos(u8, swift_spec, name_start, '\'') orelse break;
76 try set.put(swift_spec[name_start..name_end], {});
77 search = name_end;
78 }
79 return set;
80}
81
82/// Every action name declared in `bridge_mobile.zig`'s `A` block.
83fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
84 var set = std.StringHashMap(void).init(allocator);
85 errdefer set.deinit();
86
87 var it = std.mem.splitScalar(u8, zig_actions, '\n');
88 var in_block = false;
89 while (it.next()) |line| {
90 const trimmed = std.mem.trim(u8, line, " \t\r");
91 if (std.mem.startsWith(u8, trimmed, "pub const A = struct {")) {
92 in_block = true;
93 continue;
94 }
95 if (in_block and std.mem.eql(u8, trimmed, "};")) break;
96 if (!in_block) continue;
97
98 // pub const get_device_info = "getDeviceInfo";
99 const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue;
100 const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"') orelse continue;
101 try set.put(trimmed[open + 1 .. close], {});
102 }
103 return set;
104}
105
106test "the spec scan finds the dispatcher, and finds actions in it" {
107 // Non-vacuity, first. Every assertion below is a subset check, and a scan
108 // that silently matched nothing would satisfy all of them. This is the
109 // guard that makes the rest mean something: rename the Swift dispatcher and
110 // this fails, rather than the suite going quietly green on an empty set.
111 const region = dispatcherRegion();
112 try testing.expect(region.len > 1000);
113
114 var cases = try collectCases(testing.allocator, region);
115 defer cases.deinit();
116 try testing.expect(cases.count() >= 100);
117}
118
119test "every action the page can call is one the spec handles" {
120 // The `ota*` check. Five methods in the injected JavaScript posted actions
121 // the switch never handled, and because they bypassed `_createCallback`
122 // they had no timeout either — so calling one returned a promise that never
123 // settled. There is deliberately no allow-list here: a page-callable method
124 // with nothing behind it is always a bug, and "documented exception" is how
125 // it would come back.
126 var handled = try collectCases(testing.allocator, dispatcherRegion());
127 defer handled.deinit();
128
129 var posted = try collectPostedActions(testing.allocator);
130 defer posted.deinit();
131
132 var checked: usize = 0;
133 var it = posted.keyIterator();
134 while (it.next()) |name| {
135 if (!handled.contains(name.*)) {
136 std.debug.print(
137 "craft.{s}() posts action '{s}', which no dispatcher case handles.\n" ++
138 " The promise it returns can never settle.\n",
139 .{ name.*, name.* },
140 );
141 return error.PageCanCallAnActionNothingHandles;
142 }
143 checked += 1;
144 }
145 try testing.expect(checked >= 50);
146}
147
148test "nothing has been dropped on the way from Swift to Zig" {
149 // The migration guard. Every action the spec answers is either served by
150 // Zig or explicitly still owed, and the number still owed only goes down.
151 //
152 // Without this, moving an action across is indistinguishable from losing
153 // it: both leave a page calling something nothing answers, which is exactly
154 // the state iOS was already in.
155 var spec = try collectCases(testing.allocator, dispatcherRegion());
156 defer spec.deinit();
157
158 var zig = try collectZigActions(testing.allocator);
159 defer zig.deinit();
160
161 try testing.expect(zig.count() >= 1);
162
163 var not_yet: usize = 0;
164 var it = spec.keyIterator();
165 while (it.next()) |name| {
166 if (!zig.contains(name.*)) not_yet += 1;
167 }
168
169 if (not_yet > max_not_yet_migrated) {
170 std.debug.print(
171 "{d} spec actions are not served by Zig, but the ratchet allows {d}.\n" ++
172 " An action has left Zig without leaving the spec.\n",
173 .{ not_yet, max_not_yet_migrated },
174 );
175 return error.MigrationWentBackwards;
176 }
177
178 // The ratchet is only a ratchet if it is tightened. A phase that migrates
179 // actions without lowering the constant leaves the guard slack, so say so.
180 if (not_yet < max_not_yet_migrated) {
181 std.debug.print(
182 "note: {d} spec actions remain unmigrated; max_not_yet_migrated is {d} and can be lowered.\n",
183 .{ not_yet, max_not_yet_migrated },
184 );
185 }
186}
187
188test "every action Zig declares is one the spec actually has" {
189 // The other direction. A Zig action the spec does not list is either a
190 // typo — `getDeviceinfo` for `getDeviceInfo`, which a page would never
191 // reach — or a surface invented on the Zig side that no page knows to call.
192 var spec = try collectCases(testing.allocator, dispatcherRegion());
193 defer spec.deinit();
194
195 var zig = try collectZigActions(testing.allocator);
196 defer zig.deinit();
197
198 var it = zig.keyIterator();
199 while (it.next()) |name| {
200 if (!spec.contains(name.*)) {
201 std.debug.print(
202 "bridge_mobile.zig declares '{s}', which the Swift spec does not handle.\n" ++
203 " Either it is misspelled, or it is a surface no page can reach.\n",
204 .{name.*},
205 );
206 return error.ZigServesAnActionTheSpecDoesNotHave;
207 }
208 }
209}
packages/zig/test/ios_surface_test.zigadded+101-0
Changes to packages/zig/test/ios_surface_test.zig
@@ -0,0 +1,101 @@
1const std = ;
2const ios = ;
3const objc = ios.objc;
4
5// `std.testing.refAllDeclsRecursive` is gone in 0.17 — only the shallow
6// `refAllDecls` survives, and shallow is not enough here. Everything that
7// matters on the iOS surface lives one level down, inside `CraftAppDelegate`,
8// so a top-level-only sweep would walk straight past the code that broke.
9//
10// `decl_names` reports only public declarations, which is what keeps this from
11// recursing forever on the `const Self = @This();` that most of these structs
12// carry.
13fn refAllDeclsRecursive(comptime T: type) void {
14 if (!.is_test) return;
15 inline for (comptime std.meta.declarations(T)) |decl_name| {
16 if (@TypeOf(@field(T, decl_name)) == type) {
17 switch (@typeInfo(@field(T, decl_name))) {
18 .@"struct", .@"enum", .@"union", .@"opaque" => refAllDeclsRecursive(@field(T, decl_name)),
19 else => {},
20 }
21 }
22 _ = &@field(T, decl_name);
23 }
24}
25
26// iOS compiled for nobody, for three months, and the test suite stayed green.
27//
28// The reason is worth stating, because it is the whole point of this file.
29// Zig analyses function bodies lazily: a declaration nothing references is
30// never checked. `test/mobile_test.zig` touches enums and struct literals —
31// `HapticType.selection`, an `AppConfig{...}` — and never calls a function
32// that reaches the Objective-C runtime. So `zig build test` passed while
33// `zig build build-ios-simulator` failed with nine errors, and neither
34// result contradicted the other.
35//
36// `refAllDeclsRecursive` forces analysis of every declaration reachable from
37// the root. That turns "does iOS compile" into a host build that takes
38// seconds, instead of a cross-compilation nobody runs locally.
39//
40// Cross-compiling remains the source of truth — this cannot catch a target
41// specific ABI problem, and two of the nine original errors were exactly
42// that. It catches the other seven, and it catches them in the loop you are
43// already running.
44test "every declaration reachable from ios.zig compiles" {
45 refAllDeclsRecursive(ios);
46}
47
48// `CraftAppDelegate.run` is the startup path, and every private helper that
49// survives into the real one is reachable only through it. Referencing the
50// type explicitly means a helper cannot be quietly orphaned — dropped from
51// `run` and left to rot uncompiled — without this failing.
52test "the app delegate and its startup helpers compile" {
53 refAllDeclsRecursive(ios.CraftAppDelegate);
54}
55
56// `run()` is now `noreturn` — it hands control to `UIApplicationMain` and
57// never comes back — so it cannot be called from a test. What can be checked
58// on the host is the part that used to be inline in `run` and is now the
59// launch callback's body.
60//
61// On macOS this fails at `createWindow`, because `objc_getClass("UIScreen")`
62// is null: UIScreen is UIKit, and this is AppKit's platform. The specific
63// error is not asserted — that would pin the host rather than the property.
64// What is asserted is that it reports *something*. A startup path that builds
65// no window and says nothing is the failure this file exists to prevent, and
66// it is what shipped here for three months.
67test "the launch callback never reports success without building anything" {
68 if (!.target.os.tag.isDarwin()) return error.SkipZigTest;
69
70 var app = ios.CraftAppDelegate.init(std.testing.allocator, .{
71 .name = "Surface Test",
72 .initial_content = .{ .html = "<h1>hi</h1>" },
73 });
74
75 if (app.didFinishLaunching()) |_| {
76 return error.LaunchClaimedSuccessOnAHostWithoutUIKit;
77 } else |_| {
78 // Any error is correct. Silence is not.
79 }
80}
81
82// The delegate class UIKit is asked to instantiate must actually exist by the
83// time `UIApplicationMain` is told its name. Registering it is pure
84// Objective-C runtime work — `objc_allocateClassPair`, `class_addMethod`,
85// `objc_registerClassPair` — with nothing from UIKit in it, so it can be
86// exercised on the host even though the app it serves cannot launch here.
87//
88// A silent failure here would surface on a device as an app that launches to
89// a black screen with no delegate callbacks and no error, which is among the
90// least diagnosable failures iOS has.
91test "the app delegate class registers, and registering twice is harmless" {
92 if (!.target.os.tag.isDarwin()) return error.SkipZigTest;
93
94 try ios.registerAppDelegateClass();
95 try std.testing.expect(objc.objc_getClass("CraftAppDelegate") != null);
96
97 // Idempotent: `UIApplicationMain` is called once, but nothing in the
98 // runtime stops a second registration attempt, and `objc_allocateClassPair`
99 // returns null for a name already taken rather than erroring usefully.
100 try ios.registerAppDelegateClass();
101}