also looking at this
Zig-native mobile: iOS compiles, serves a bridge call on a simulator, and is gated against drift
#83
28 files
+2460
-6557
docs/android-development.mdadded+503-0
| @@ -0,0 +1,503 @@ | ||
| 1 | # Android Development | |
| 2 | ||
| 3 | Making 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 | ||
| 6 | This document is the working plan. It is meant to be checked off, and the phase gates are | |
| 7 | meant 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 ' | |
| 25 | 0 | |
| 26 | ``` | |
| 27 | ||
| 28 | An 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`, | |
| 31 | which has **zero `export fn`**. Zig only emits code reachable from an export — `pub` is | |
| 32 | visibility, not linkage. | |
| 33 | ||
| 34 | Because 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_*` | |
| 36 | symbols in the codebase. And even if they had survived, they bind to | |
| 37 | `app.craft.CraftValueCallback` and `app.craft.CraftActivity` — **classes that exist nowhere | |
| 38 | in this repo and that no template could generate.** | |
| 39 | ||
| 40 | Compare 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 | ||
| 51 | Good 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 | ||
| 56 | Three 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 | ||
| 64 | So `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 | |
| 74 | and routes `evalJS` into `linux.zig` — which holds **44** `extern "c"` GTK/WebKit2GTK | |
| 75 | declarations. | |
| 76 | ||
| 77 | Undefined symbols cost nothing in a *static archive*, which is precisely why this has never | |
| 78 | surfaced. **The moment we emit a `.so`, the linker demands `gtk_init` and 43 friends.** | |
| 79 | ||
| 80 | The `.linux` arm needs an `.abi == .android` guard, landed **in the same commit** as the | |
| 81 | linkage change. `build.zig:1940-1942`'s `linkPlatformLibraries` has the same landmine — | |
| 82 | its `.linux` arm links `gtk+-3.0` and `webkit2gtk-4.1`, and the Android libs must never | |
| 83 | start 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 | |
| 88 | chosen id. JNI symbol mangling embeds the package, so a fixed export name cannot exist. | |
| 89 | ||
| 90 | **Fix, in two parts:** | |
| 91 | ||
| 92 | 1. **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 | ||
| 98 | 2. **Bind via `JNI_OnLoad` + `RegisterNatives`, exporting zero `Java_*` symbols.** | |
| 99 | ||
| 100 | Why `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 | ||
| 120 | The 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 | |
| 122 | struct reads the wrong slot.** Even if it linked, every call would jump to a garbage | |
| 123 | address. | |
| 124 | ||
| 125 | There is also a *second*, incompatible `jni` namespace at `android.zig:20-30` — opaque | |
| 126 | aliases 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 {};` | |
| 131 | so on a macOS host `jni` is an **empty struct** and `jni.JNIEnv` does not exist. | |
| 132 | ||
| 133 | Proof 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 | |
| 135 | unconditional compile error that has never fired. | |
| 136 | ||
| 137 | Other never-executed bugs in the same region: `requestPermission` (`:1275-1298`) resolves | |
| 138 | the **static** `ActivityCompat.requestPermissions` via `GetMethodID` and passes 2 varargs | |
| 139 | where 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 | ``` | |
| 148 | page ── 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 | ||
| 174 | And `request_context.zig` is `threadlocal` — a frame pushed on JavaBridge is invisible from | |
| 175 | the UI thread. | |
| 176 | ||
| 177 | **Therefore: format the reply on the JavaBridge thread, where the context frame lives, then | |
| 178 | hand the finished string across.** Three lines of Kotlin: | |
| 179 | ||
| 180 | ```kotlin | |
| 181 | fun evaluateOnUi(script: String) = webView.post { | |
| 182 | webView.evaluateJavascript(script, null) | |
| 183 | } | |
| 184 | ``` | |
| 185 | ||
| 186 | Do 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 | |
| 213 | Zig version's one real hazard with variadic function-pointer fields. As a bonus, they | |
| 214 | structurally 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 | |
| 220 | helpers that: | |
| 221 | ||
| 222 | 1. always use `*A`, | |
| 223 | 2. 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, | |
| 226 | 3. 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 | ||
| 231 | Gated 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 | ||
| 241 | That 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 | ||
| 248 | Delete rather than fix. Every one of these is unreachable or actively misleading, and | |
| 249 | leaving 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 | |
| 267 | for `android_dispatch.zig`, and `packages/android/templates/test-bridges.html` as the | |
| 268 | Phase 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 | ||
| 278 | It **cannot be faked past its own assertion**. CI compares the value the *page* printed | |
| 279 | against `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 | ||
| 282 | It also exercises the full vtable surface in one call — `FindClass("android/os/Build$VERSION")` | |
| 283 | → `GetStaticFieldID` → `GetStaticIntField`, plus `GetStringUTFChars`/`ReleaseStringUTFChars`. | |
| 284 | If 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 | |
| 287 | means 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 | |
| 324 | regress 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 | |
| 338 | zig build build-android-x86 -Dandroid-ndk=$ANDROID_NDK_HOME | |
| 339 | llvm-nm -D --defined-only …/libcraft_android.so | grep -q ' T JNI_OnLoad' | |
| 340 | llvm-nm -D --undefined-only …/libcraft_android.so | grep -c 'gtk_\|webkit_' # must be 0 | |
| 341 | llvm-readelf -l …/libcraft_android.so | grep LOAD | grep -q 0x4000 # 16KiB, Android 15+ | |
| 342 | craft android init E2E --output /tmp/e2e && craft android build --output /tmp/e2e | |
| 343 | unzip -l …/app-debug.apk | grep -q 'lib/x86_64/libcraft_android.so' | |
| 344 | adb install -r … && adb shell am start -n …/.MainActivity | |
| 345 | adb logcat -d | grep CRAFT_RT > out | |
| 346 | test "$(jq -r .sdkInt <out)" = "$(adb shell getprop ro.build.version.sdk)" | |
| 347 | test "$(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` | |
| 351 | to logcat. | |
| 352 | ||
| 353 | ### Deferred | |
| 354 | ||
| 355 | 102 of the 103 Kotlin methods (Kotlin keeps serving them); permissions; `onActivityResult`; | |
| 356 | event 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 | |
| 382 | turns 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 | |
| 392 | the best worked example of what not to do. | |
| 393 | ||
| 394 | ### A6: one declared escape hatch, not a JNI slog | |
| 395 | ||
| 396 | BillingClient, ML Kit, Play Review, Firebase, Health Connect, and ARCore are listener-heavy, | |
| 397 | generic-heavy builder APIs. Reaching them from JNI means hand-writing anonymous inner | |
| 398 | classes, which JNI cannot do at all without a Java shim. There is no Zig-purity win | |
| 399 | available; there is only a large, fragile, unreviewable surface. | |
| 400 | ||
| 401 | Instead: Zig calls `CraftNative.callHost(namespace, action, dataJson, requestId)`, and | |
| 402 | Kotlin answers through the **same** `nativeDeliverResult(requestId, json)` every other path | |
| 403 | uses. Zig still owns dispatch, correlation, capability truth, and the wire format. Kotlin | |
| 404 | becomes a leaf. | |
| 405 | ||
| 406 | Add `host_delegated` as a fifth `NamespaceStatus` beside `declared`, `undeclared`, | |
| 407 | `unavailable`, and `unrouted` (`capabilities.zig:64-73`), so `craft.capabilities()` tells | |
| 408 | apps honestly which surfaces are native-Zig and which are host-delegated. That is the same | |
| 409 | ethos as the existing "a namespace craft hasn't audited says so", extended one step. | |
| 410 | ||
| 411 | Only 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 | |
| 419 | starts from an empty tree and has nothing to grandfather, so there is no reason to ever | |
| 420 | permit an undeclared Android namespace. This is the cheapest place to hold the line, and it | |
| 421 | is only cheap *now*. | |
| 422 | ||
| 423 | Three gates: | |
| 424 | ||
| 425 | 1. **`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. | |
| 428 | 2. **`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. | |
| 431 | 3. **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 | ||
| 441 | After `zig build test` on the Linux leg: | |
| 442 | ||
| 443 | ```bash | |
| 444 | sdkmanager --install "ndk;26.1.10909125" | |
| 445 | zig build build-android-all -Dandroid-ndk=$ANDROID_NDK_HOME -Doptimize=ReleaseSafe | |
| 446 | llvm-nm -D --defined-only …/libcraft_android.so | grep -q ' T JNI_OnLoad' | |
| 447 | llvm-nm -D --undefined-only …/libcraft_android.so | grep -c 'gtk_\|webkit_' | grep -q '^0$' | |
| 448 | llvm-readelf -l …/libcraft_android.so | grep LOAD | grep -q 0x4000 | |
| 449 | test "$(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 | |
| 453 | fail the build.** The `gtk_` check is the standing guard on the `bridge.zig` regression. | |
| 454 | ||
| 455 | ### `mobile-e2e.yml` — currently proves nothing | |
| 456 | ||
| 457 | The Android job boots an AVD, takes a screenshot, and asserts nothing, because its only test | |
| 458 | step is behind `if [ -x "packages/android/gradlew" ]` — a path that **can never exist**, | |
| 459 | since 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 | ||
| 470 | 1. `init()` materializes the Gradle wrapper and fails loudly there — a project is either | |
| 471 | complete or absent | |
| 472 | 2. `syncNativeLibs()` copies the `.so` per ABI | |
| 473 | 3. Writes `CraftNative.kt` with **no** `` substitution | |
| 474 | 4. `build()` fails when the `.so` is missing, rather than producing an APK that | |
| 475 | `UnsatisfiedLinkError`s at launch | |
| 476 | 5. `index.test.ts` asserts `jniLibs/`, `gradlew`, `CraftNative.kt`, and the `"CraftBridge"` | |
| 477 | registration | |
| 478 | ||
| 479 | Per `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 | ||
| 491 | Zig is pinned at `0.17.0-dev.1509+bb296ab9b`. | |
| 492 | ||
| 493 | --- | |
| 494 | ||
| 495 | ## Honest accounting | |
| 496 | ||
| 497 | Android is further from working than iOS: iOS has nine concrete compile errors, while | |
| 498 | Android has no JNI layer at all. But Android has one advantage — everything it needs to | |
| 499 | reach is plain Java API, with no Kotlin-only surface in the way. | |
| 500 | ||
| 501 | The `host_delegated` escape hatch in A6 exists so the last 28 actions don't become an | |
| 502 | open-ended JNI slog. And `CraftBridge.kt` keeps serving every action Zig hasn't taken over, | |
| 503 | so no phase ships a broken app. | |
| @@ -0,0 +1,400 @@ | ||
| 1 | # iOS Development | |
| 2 | ||
| 3 | Making iOS a Zig-native platform: Zig drives UIKit through `objc_msgSend`, exactly as | |
| 4 | `packages/zig/src/macos.zig` drives AppKit today. | |
| 5 | ||
| 6 | This document is the working plan. It is meant to be checked off, and the phase gates are | |
| 7 | meant to be run. | |
| 8 | ||
| 9 | --- | |
| 10 | ||
| 11 | ## Where iOS actually stands | |
| 12 | ||
| 13 | Three facts, each verified, that together explain why "iOS is broken" has been true for | |
| 14 | months 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` | |
| 19 | fails 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 | |
| 24 | green Zig build would be dead weight. | |
| 25 | ||
| 26 | **3. What actually ships is Swift.** `packages/ios/templates/CraftApp.swift` is 5,169 lines | |
| 27 | with **106** dispatcher actions and a properly registered `WKScriptMessageHandler`. It | |
| 28 | works. `craft ios init/build/open/run` shells out to `xcodegen`, `xcodebuild`, and `simctl`, | |
| 29 | and those work too. | |
| 30 | ||
| 31 | So the goal is not "repair the Zig build." It is to move the native layer from Swift to Zig | |
| 32 | without 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, | |
| 39 | because the OTA methods bypass the `_createCallback` path that owns the timeout. | |
| 40 | ||
| 41 | A further 33 handled actions are unreachable from the injected JS. | |
| 42 | ||
| 43 | That is one file disagreeing with itself. The conformance gate in Phase 2 exists to make | |
| 44 | this class of bug impossible rather than merely fixed. | |
| 45 | ||
| 46 | --- | |
| 47 | ||
| 48 | ## Counting actions correctly | |
| 49 | ||
| 50 | The dispatcher region (`CraftApp.swift:516-1078`) holds exactly **106** distinct actions. | |
| 51 | ||
| 52 | A naive `grep 'case "'` over the whole file returns **139**, because sub-switches use string | |
| 53 | cases 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 | |
| 63 | page ── 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 | ||
| 80 | There are currently **five** incompatible bridge envelopes in the repo. iOS adopts the | |
| 81 | desktop one. | |
| 82 | ||
| 83 | Reply correlation is the hardest part of a bridge, and the desktop implementation is | |
| 84 | already written and tested — dual pending tables, per-call timeout, and a `_forget` so a | |
| 85 | late 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 | ||
| 89 | Adopting Swift's envelope instead would mean rewriting all of that, including its reply | |
| 90 | escaping at `CraftApp.swift:2447`, which replaces only `'` — so any payload containing a | |
| 91 | backslash or a newline breaks out of the JS string literal. `bridge_error.escapeJsonString` | |
| 92 | already handles this correctly. | |
| 93 | ||
| 94 | Compatibility for page code written against `window.craft.getDeviceInfo()` comes from a JS | |
| 95 | shim, **not** from a second wire protocol. Shipping a second reply protocol "temporarily" | |
| 96 | is 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 | ||
| 118 | Most of the nine reported errors live in code that should not exist, so deletion comes | |
| 119 | before 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` | |
| 138 | sites 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 | ||
| 151 | New `packages/zig/test/ios_surface_test.zig`, wired into `test_step` like `mobile_tests`: | |
| 152 | ||
| 153 | ```zig | |
| 154 | test "every iOS decl compiles" { | |
| 155 | std.testing.refAllDeclsRecursive(@import("../src/ios.zig")); | |
| 156 | std.testing.refAllDeclsRecursive(@import("../src/mobile.zig").iOS); | |
| 157 | } | |
| 158 | ``` | |
| 159 | ||
| 160 | This builds for the **host** in seconds. `mobile_test.zig` is green today only because it | |
| 161 | touches enums and structs and Zig analyses function bodies lazily — this forces analysis, | |
| 162 | and every one of the nine errors plus the five latent ones becomes a host-visible compile | |
| 163 | error. 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 | |
| 166 | one and watching it fail. `zig build build-ios-simulator` and `zig build build-ios` both | |
| 167 | compile. | |
| 168 | ||
| 169 | --- | |
| 170 | ||
| 171 | ## Phase 1 — Vertical slice: `getDeviceInfo` on a simulator | |
| 172 | ||
| 173 | **Needs Xcode.** | |
| 174 | ||
| 175 | One 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 | ||
| 186 | Rejected, and worth recording why: `haptic` is fire-and-forget *and* a silent no-op on the | |
| 187 | simulator, so it would pass while doing nothing. `clipboardRead` triggers iOS 16+'s "Allow | |
| 188 | Paste?" prompt — a flake generator in CI. `showAlert` needs someone to dismiss it. | |
| 189 | ||
| 190 | ### What lands | |
| 191 | ||
| 192 | 1. **`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 | ||
| 202 | 2. **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 | ||
| 206 | 3. **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 | ||
| 213 | 4. **`.ios` arm on `bridge.evalJS`**, plus `ios.evalJS` mirroring `macos.zig:4485-4496`. | |
| 214 | ||
| 215 | 5. **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 | ||
| 224 | 6. **`src/bridge_mobile.zig`** with exactly one action, plus a `t == "mobile"` arm in a new | |
| 225 | `ios_dispatch.zig`. | |
| 226 | ||
| 227 | 7. **`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 | ||
| 232 | A **closed** loop, not a half loop: page posts → Zig serves → Zig replies → **Zig then | |
| 233 | evaluates `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 | |
| 235 | in 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. | |
| 239 | It **skips rather than fails** when `xcode-select -p` reports Command Line Tools, so the | |
| 240 | repo stays green on machines without Xcode. | |
| 241 | ||
| 242 | Belt 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 | |
| 248 | plumbing (`AppConfig.orientations`, `status_bar_style` stay inert fields); and the other 105 | |
| 249 | actions — `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 | |
| 265 | actions, `request_context` push/pop around every dispatch. Note `macos.zig:4646-4665` — the | |
| 266 | desktop 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 | |
| 269 | not serve, `ios_dispatch` looks up `objc_getClass("CraftSwiftShim")` and sends | |
| 270 | `handleAction:payload:callbackId:`. One app, one message handler, one page-visible surface. | |
| 271 | Each later phase moves actions from the Swift arm to the Zig arm and the page notices | |
| 272 | nothing. | |
| 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 | |
| 276 | page calling `craft.tray.create()` on iOS should get a clear error, not silence). Extract | |
| 277 | the shared transport into `craft-bridge-core.js` and `@embedFile` it into both, so there is | |
| 278 | one transport rather than two. | |
| 279 | ||
| 280 | Also here: the Swift shim's reply helpers get rewritten to call `__craftBridgeResult`, | |
| 281 | which 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 | |
| 298 | therefore 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 | |
| 304 | produced *outside* the dispatch that requested it. `request_context.current()` is a | |
| 305 | per-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 | |
| 307 | hands caller B's answer to caller A whenever two are in flight. | |
| 308 | ||
| 309 | This phase must land a **pending-request table** keyed by the `i` captured at dispatch. Do | |
| 310 | Keychain 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` | |
| 318 | is SceneKit node-graph manipulation; through `objc_msgSend` it would be miserable and | |
| 319 | low-value. Zig-native is a means, not an end, and five actions of SceneKit glue is where it | |
| 320 | stops paying. Declare the boundary in `capability_registry.zig` with a `reason`. | |
| 321 | ||
| 322 | ### What stays Swift, permanently | |
| 323 | ||
| 324 | Thirteen 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 | ||
| 347 | Use `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 | |
| 349 | substring scan invites. | |
| 350 | ||
| 351 | Bound the spec scan to the dispatcher region, and assert a floor, so a refactor that renames | |
| 352 | the 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 | |
| 357 | side has ever provided. `packages/react/src/index.ts:6` declares `os_version` where Swift | |
| 358 | returns `systemVersion`. | |
| 359 | ||
| 360 | A 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 | |
| 362 | contract 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 | |
| 369 | gated 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 | ||
| 388 | Zig is pinned at `0.17.0-dev.1509+bb296ab9b`. Clear `.zig-cache` between heavy rounds. | |
| 389 | ||
| 390 | --- | |
| 391 | ||
| 392 | ## Honest accounting | |
| 393 | ||
| 394 | This replaces working Swift with Zig. The payoff — one implementation instead of three, | |
| 395 | enforced by a compiler and a conformance test rather than by convention — does not arrive | |
| 396 | until Phase 8. | |
| 397 | ||
| 398 | The hand-off table exists so that every phase before then ships a **working app**, not a | |
| 399 | half-migrated one. If this effort stalls at any point, the app still runs: Swift keeps | |
| 400 | serving everything Zig hasn't taken over yet. | |
| @@ -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> | |
| @@ -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. | |
| 7 | set -euo pipefail | |
| 8 | ||
| 9 | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" | |
| 10 | ZIG="${CRAFT_ZIG:-$HOME/.cache/craft-ci-local/zig-aarch64-macos-0.17.0-dev.1509+bb296ab9b/zig}" | |
| 11 | FIXTURE="$ROOT/packages/ios/fixtures/zig-slice" | |
| 12 | OUT="${1:-$FIXTURE/build}" | |
| 13 | BUNDLE_ID="app.craft.slice" | |
| 14 | ||
| 15 | ARCH="$(uname -m)" | |
| 16 | if [ "$ARCH" = "arm64" ]; then LIB="libcraft-ios-simulator-arm64.a"; TRIPLE="arm64-apple-ios15.0-simulator" | |
| 17 | else LIB="libcraft-ios-simulator-x64.a"; TRIPLE="x86_64-apple-ios15.0-simulator"; fi | |
| 18 | ||
| 19 | echo "==> building the craft iOS simulator library" | |
| 20 | (cd "$ROOT/packages/zig" && "$ZIG" build build-ios-simulator -Doptimize=ReleaseSafe) | |
| 21 | ||
| 22 | SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)" | |
| 23 | APP="$OUT/CraftSlice.app" | |
| 24 | rm -rf "$OUT"; mkdir -p "$APP" | |
| 25 | ||
| 26 | echo "==> linking CraftSlice" | |
| 27 | clang -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 | ||
| 34 | cp "$FIXTURE/Info.plist" "$APP/Info.plist" | |
| 35 | ||
| 36 | echo "==> booting a simulator" | |
| 37 | UDID="$(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"]))')" | |
| 39 | xcrun simctl boot "$UDID" 2>/dev/null || true | |
| 40 | xcrun simctl bootstatus "$UDID" -b | |
| 41 | ||
| 42 | echo "==> installing and launching" | |
| 43 | xcrun simctl uninstall "$UDID" "$BUNDLE_ID" 2>/dev/null || true | |
| 44 | xcrun simctl install "$UDID" "$APP" | |
| 45 | ||
| 46 | LOG="$OUT/console.log" | |
| 47 | xcrun simctl launch --console-pty "$UDID" "$BUNDLE_ID" > "$LOG" 2>&1 & | |
| 48 | LAUNCH_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. | |
| 52 | for _ 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 | |
| 55 | done | |
| 56 | kill "$LAUNCH_PID" 2>/dev/null || true | |
| 57 | ||
| 58 | echo "==> console" | |
| 59 | cat "$LOG" || true | |
| 60 | ||
| 61 | echo | |
| 62 | echo "==> assertions" | |
| 63 | ||
| 64 | # Strip the log colouring before matching, so a grep failure means the line is | |
| 65 | # absent rather than differently decorated. | |
| 66 | PLAIN="$OUT/console.plain" | |
| 67 | sed -e $'s/\x1b\[[0-9;]*m//g' -e 's/\r$//' "$LOG" > "$PLAIN" | |
| 68 | ||
| 69 | count() { grep -c "craft-bridge dispatch t=mobile a=getDeviceInfo i=$1" "$PLAIN" || true; } | |
| 70 | ||
| 71 | C1="$(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; } | |
| 74 | echo "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; } | |
| 77 | echo "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; } | |
| 83 | echo "ok: no feedback loop" | |
| 84 | ||
| 85 | [ "$C3" -ge 1 ] || { echo "FAIL: the user script had not run when the page executed"; exit 1; } | |
| 86 | echo "ok: user script ran at document start" | |
| 87 | ||
| 88 | echo "PASS" | |
| @@ -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`. | |
| 7 | extern int craft_ios_init(void); | |
| 8 | extern void craft_ios_set_html(const char *html, unsigned long len); | |
| 9 | extern int craft_ios_main(int argc, char **argv); | |
| 10 | ||
| 11 | extern const char craft_slice_page[]; | |
| 12 | extern const unsigned long craft_slice_page_len; | |
| 13 | ||
| 14 | int 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 | } | |
| @@ -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. | |
| 16 | const 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 | ||
| 47 | const unsigned long craft_slice_page_len = sizeof(craft_slice_page) - 1; | |
| @@ -2222,41 +2222,33 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2222 | 2222 | _progressCallbacks: [], |
| 2223 | 2223 | _statusCallbacks: [], |
| 2224 | 2224 | |
| 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 | }, | |
| 2225 | 2237 | configure: function(options) { |
| 2226 | 2238 | 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'); | |
| 2228 | 2240 | }, |
| 2229 | 2241 | 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'); | |
| 2236 | 2243 | }, |
| 2237 | 2244 | 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'); | |
| 2244 | 2246 | }, |
| 2245 | 2247 | 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'); | |
| 2252 | 2249 | }, |
| 2253 | 2250 | 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'); | |
| 2260 | 2252 | }, |
| 2261 | 2253 | getCurrentBundle: function() { |
| 2262 | 2254 | // This returns synchronously from stored data |
packages/typescript/src/bridge/android.tsdeleted+0-1099
| @@ -1,1099 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Craft Android Bridge | |
| 3 | * Complete Android native bridge with WebView, permissions, and platform APIs | |
| 4 | */ | |
| 5 | ||
| 6 | import { secureId } from './ids.js' | |
| 7 | ||
| 8 | // Types | |
| 9 | export 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 | ||
| 25 | export 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 | ||
| 52 | export interface AndroidBiometricConfig { | |
| 53 | title: string | |
| 54 | subtitle?: string | |
| 55 | description?: string | |
| 56 | negativeButtonText: string | |
| 57 | allowedAuthenticators?: ('biometricStrong' | 'biometricWeak' | 'deviceCredential')[] | |
| 58 | } | |
| 59 | ||
| 60 | export 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 | ||
| 74 | export 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 | ||
| 93 | export 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 | ||
| 120 | export interface AndroidWidget { | |
| 121 | widgetId: string | |
| 122 | className: string | |
| 123 | updatePeriodMillis?: number | |
| 124 | initialLayout: string | |
| 125 | } | |
| 126 | ||
| 127 | export 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 | |
| 143 | export 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 | } | |
| 236 | catch { | |
| 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 | |
| 249 | export 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 | } | |
| 296 | catch { | |
| 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 | |
| 308 | export 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 | } | |
| 341 | catch { | |
| 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 | |
| 353 | export 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 | } | |
| 414 | catch { | |
| 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 | |
| 426 | export 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 | } | |
| 461 | catch { | |
| 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 | |
| 473 | export 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 | } | |
| 513 | catch { | |
| 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 | |
| 525 | export 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 | } | |
| 579 | catch { | |
| 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 | |
| 591 | export 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 | } | |
| 628 | catch { | |
| 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 | |
| 640 | export 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 | } | |
| 694 | catch { | |
| 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 | |
| 706 | export 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 | } | |
| 739 | catch { | |
| 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 | |
| 751 | export 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 | } | |
| 784 | catch { | |
| 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 | |
| 796 | export 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 | } | |
| 868 | catch { | |
| 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 | |
| 880 | export 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 | } | |
| 965 | catch { | |
| 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 | |
| 977 | export 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 | } | |
| 1055 | catch { | |
| 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 | |
| 1067 | export 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 | ||
| 1099 | export default android | |
packages/typescript/src/bridge/ios.tsdeleted+0-1004
| @@ -1,1004 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Craft iOS Bridge | |
| 3 | * Complete iOS native bridge with WebView, permissions, haptics, and platform APIs | |
| 4 | */ | |
| 5 | ||
| 6 | import { secureId } from './ids.js' | |
| 7 | ||
| 8 | // Types | |
| 9 | export 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 | ||
| 22 | export 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 | ||
| 44 | export interface IOSHapticFeedback { | |
| 45 | type: 'impact' | 'notification' | 'selection' | |
| 46 | style?: 'light' | 'medium' | 'heavy' | 'soft' | 'rigid' | |
| 47 | notificationType?: 'success' | 'warning' | 'error' | |
| 48 | } | |
| 49 | ||
| 50 | export interface IOSAppClipConfig { | |
| 51 | invocationURL: string | |
| 52 | experienceType: 'default' | 'locationBased' | |
| 53 | businessCategory?: string | |
| 54 | subtitle?: string | |
| 55 | } | |
| 56 | ||
| 57 | export interface IOSSharePlayActivity { | |
| 58 | activityIdentifier: string | |
| 59 | fallbackURL?: string | |
| 60 | title: string | |
| 61 | subtitle?: string | |
| 62 | image?: string | |
| 63 | } | |
| 64 | ||
| 65 | export interface IOSLiveActivity { | |
| 66 | activityId: string | |
| 67 | contentState: Record<string, unknown> | |
| 68 | staleDate?: Date | |
| 69 | relevanceScore?: number | |
| 70 | } | |
| 71 | ||
| 72 | export interface IOSFocusFilter { | |
| 73 | identifier: string | |
| 74 | name: string | |
| 75 | iconSystemName?: string | |
| 76 | parameters: Record<string, unknown> | |
| 77 | } | |
| 78 | ||
| 79 | export 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 | ||
| 90 | export interface IOSTipKitTip { | |
| 91 | identifier: string | |
| 92 | title: string | |
| 93 | message: string | |
| 94 | imageName?: string | |
| 95 | actions?: Array<{ identifier: string; title: string }> | |
| 96 | } | |
| 97 | ||
| 98 | export 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 | ||
| 107 | export 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 | |
| 114 | export 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 | |
| 220 | export 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 | |
| 270 | export 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 | |
| 304 | export 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 | |
| 350 | export 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 | |
| 411 | export 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 | |
| 464 | export 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 | |
| 503 | export 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 | |
| 552 | export 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 | |
| 605 | export 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 | |
| 679 | export 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 | |
| 761 | export 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 | |
| 823 | export 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 | |
| 898 | export 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 | |
| 972 | export 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 | ||
| 1004 | export default ios | |
| @@ -161,6 +161,13 @@ pub fn build(b: *std.Build) void { | ||
| 161 | 161 | .root_source_file = b.path("src/mobile.zig"), |
| 162 | 162 | }); |
| 163 | 163 | |
| 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 | ||
| 164 | 171 | const menubar_module = b.createModule(.{ |
| 165 | 172 | .root_source_file = b.path("src/menubar.zig"), |
| 166 | 173 | }); |
| @@ -292,6 +299,65 @@ pub fn build(b: *std.Build) void { | ||
| 292 | 299 | }), |
| 293 | 300 | }); |
| 294 | 301 | |
| 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 | ||
| 295 | 361 | const menubar_tests = b.addTest(.{ |
| 296 | 362 | .root_module = b.createModule(.{ |
| 297 | 363 | .root_source_file = b.path("test/menubar_test.zig"), |
| @@ -1136,6 +1202,8 @@ pub fn build(b: *std.Build) void { | ||
| 1136 | 1202 | |
| 1137 | 1203 | const run_api_tests = b.addRunArtifact(api_tests); |
| 1138 | 1204 | 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); | |
| 1139 | 1207 | const run_menubar_tests = b.addRunArtifact(menubar_tests); |
| 1140 | 1208 | const run_components_tests = b.addRunArtifact(components_tests); |
| 1141 | 1209 | const run_gpu_tests = b.addRunArtifact(gpu_tests); |
| @@ -1261,6 +1329,18 @@ pub fn build(b: *std.Build) void { | ||
| 1261 | 1329 | if (js_test_run) |run| test_step.dependOn(&run.step); |
| 1262 | 1330 | test_step.dependOn(&run_api_tests.step); |
| 1263 | 1331 | 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); | |
| 1264 | 1344 | test_step.dependOn(&run_menubar_tests.step); |
| 1265 | 1345 | test_step.dependOn(&run_components_tests.step); |
| 1266 | 1346 | test_step.dependOn(&run_gpu_tests.step); |
| @@ -1339,6 +1419,12 @@ pub fn build(b: *std.Build) void { | ||
| 1339 | 1419 | const test_mobile_step = b.step("test:mobile", "Run Mobile tests"); |
| 1340 | 1420 | test_mobile_step.dependOn(&run_mobile_tests.step); |
| 1341 | 1421 | |
| 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 | ||
| 1342 | 1428 | const test_menubar_step = b.step("test:menubar", "Run Menubar tests"); |
| 1343 | 1429 | test_menubar_step.dependOn(&run_menubar_tests.step); |
| 1344 | 1430 | |
| @@ -1792,11 +1878,6 @@ pub fn build(b: *std.Build) void { | ||
| 1792 | 1878 | const build_android_x86 = b.step("build-android-x86", "Build for Android (x86_64)"); |
| 1793 | 1879 | const build_android_all = b.step("build-android-all", "Build for Android (all architectures)"); |
| 1794 | 1880 | |
| 1795 | // Android module | |
| 1796 | const android_module = b.createModule(.{ | |
| 1797 | .root_source_file = b.path("src/android.zig"), | |
| 1798 | }); | |
| 1799 | ||
| 1800 | 1881 | // Android Device (arm64) |
| 1801 | 1882 | const android_arm64_target = b.resolveTargetQuery(.{ |
| 1802 | 1883 | .cpu_arch = .aarch64, |
| @@ -1847,46 +1928,13 @@ pub fn build(b: *std.Build) void { | ||
| 1847 | 1928 | build_android_x86.dependOn(&android_x86_install.step); |
| 1848 | 1929 | build_android_all.dependOn(&android_x86_install.step); |
| 1849 | 1930 | |
| 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. | |
| 1890 | 1938 | |
| 1891 | 1939 | // ======================================================================== |
| 1892 | 1940 | // Android Tests |
packages/zig/examples/android/main.zigdeleted+0-281
| @@ -1,281 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const 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 | ||
| 11 | pub 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 | |
| 80 | fn onAppCreate() void { | |
| 81 | std.debug.print(" [LIFECYCLE] onCreate called\n", .{}); | |
| 82 | } | |
| 83 | ||
| 84 | fn onAppResume() void { | |
| 85 | std.debug.print(" [LIFECYCLE] onResume called\n", .{}); | |
| 86 | } | |
| 87 | ||
| 88 | fn onAppPause() void { | |
| 89 | std.debug.print(" [LIFECYCLE] onPause called\n", .{}); | |
| 90 | } | |
| 91 | ||
| 92 | fn onAppDestroy() void { | |
| 93 | std.debug.print(" [LIFECYCLE] onDestroy called\n", .{}); | |
| 94 | } | |
| 95 | ||
| 96 | fn 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 | |
| 102 | fn 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 | ||
| 107 | fn 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 | |
| 116 | const 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 | |
| 269 | pub 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 | } | |
| @@ -13,22 +13,6 @@ const mobile = ; | ||
| 13 | 13 | /// }); |
| 14 | 14 | /// try app.run(); |
| 15 | 15 | |
| 16 | // ============================================================================ | |
| 17 | // JNI Types (for cross-compilation) | |
| 18 | // ============================================================================ | |
| 19 | ||
| 20 | pub 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 | ||
| 32 | 16 | // ============================================================================ |
| 33 | 17 | // CraftActivity - Main Android Activity |
| 34 | 18 | // ============================================================================ |
| @@ -42,8 +26,13 @@ pub const CraftActivity = struct { | ||
| 42 | 26 | webview: ?*mobile.Android.WebView = null, |
| 43 | 27 | |
| 44 | 28 | // 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, | |
| 47 | 36 | |
| 48 | 37 | // Callbacks |
| 49 | 38 | on_create: ?*const fn () void = null, |
| @@ -209,91 +198,27 @@ pub const JSBridge = struct { | ||
| 209 | 198 | const Self = @This(); |
| 210 | 199 | |
| 211 | 200 | 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 .{ | |
| 213 | 209 | .handlers = std.StringHashMap(Handler).init(allocator), |
| 214 | 210 | .allocator = allocator, |
| 215 | 211 | }; |
| 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; | |
| 223 | 212 | } |
| 224 | 213 | |
| 225 | 214 | pub fn deinit(self: *Self) void { |
| 226 | 215 | self.handlers.deinit(); |
| 227 | 216 | } |
| 228 | 217 | |
| 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 | ||
| 241 | 218 | pub fn registerHandler(self: *Self, name: []const u8, handler: Handler) !void { |
| 242 | 219 | try self.handlers.put(name, handler); |
| 243 | 220 | } |
| 244 | 221 | |
| 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 | ||
| 297 | 222 | pub fn sendResponse(self: *Self, callback_id: []const u8, result: []const u8) !void { |
| 298 | 223 | if (self.activity == null) return error.NoActivity; |
| 299 | 224 | if (callback_id.len == 0) return; |
| @@ -328,168 +253,6 @@ pub const JSBridge = struct { | ||
| 328 | 253 | |
| 329 | 254 | try self.activity.?.evaluateJavaScript(script, null); |
| 330 | 255 | } |
| 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 | ||
| 419 | pub 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 | }; | |
| 493 | 256 | }; |
| 494 | 257 | |
| 495 | 258 | // ============================================================================ |
packages/zig/src/android_template.zigdeleted+0-553
| @@ -1,553 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const io_context = ; | |
| 3 | ||
| 4 | /// Android Kotlin Template Generator | |
| 5 | /// Generates complete Android app templates with Craft integration | |
| 6 | pub 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 | |
| 547 | test "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 | } | |
| @@ -9,7 +9,19 @@ pub fn evalJS(script: []const u8) !void { | ||
| 9 | 9 | const macos = ; |
| 10 | 10 | try macos.tryEvalJS(script); |
| 11 | 11 | }, |
| 12 | .ios => { | |
| 13 | const ios_dispatch = ; | |
| 14 | try ios_dispatch.evalJS(script); | |
| 15 | }, | |
| 12 | 16 | .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 | ||
| 13 | 25 | const linux = ; |
| 14 | 26 | try linux.evalJS(script); |
| 15 | 27 | }, |
| @@ -0,0 +1,147 @@ | ||
| 1 | const std = ; | |
| 2 | const builtin = ; | |
| 3 | const capabilities = ; | |
| 4 | const bridge_error = ; | |
| 5 | const objc_runtime = ; | |
| 6 | ||
| 7 | const 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. | |
| 26 | pub const A = struct { | |
| 27 | pub const get_device_info = "getDeviceInfo"; | |
| 28 | }; | |
| 29 | ||
| 30 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 31 | .{ .name = A.get_device_info, .reply = .result }, | |
| 32 | }; | |
| 33 | ||
| 34 | pub 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. | |
| 68 | fn 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. | |
| 80 | fn 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. | |
| 104 | fn 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 | ||
| 112 | const testing = std.testing; | |
| 113 | ||
| 114 | test "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 | ||
| 123 | test "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 | ||
| 133 | test "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
| @@ -1,271 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const hotreload = ; | |
| 3 | ||
| 4 | /// Mobile Hot Reload | |
| 5 | /// WebSocket-based hot reload for iOS and Android | |
| 6 | pub 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 | |
| 126 | pub 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 | |
| 175 | pub 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 | |
| 255 | test "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 | ||
| 264 | test "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
| @@ -1,10 +1,230 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const objc_runtime = ; |
| 3 | 3 | const mobile = ; |
| 4 | const ios_dispatch = ; | |
| 4 | 5 | |
| 5 | 6 | /// iOS Application Infrastructure |
| 6 | 7 | /// Provides UIApplicationDelegate, UIViewController, and full app lifecycle management |
| 7 | const 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. | |
| 11 | pub 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. | |
| 29 | export 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. | |
| 79 | fn 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. | |
| 119 | fn 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. | |
| 151 | const 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) | |
| 160 | extern "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 | ||
| 167 | const 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`. | |
| 175 | var 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`. | |
| 185 | export 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. | |
| 213 | pub 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 | } | |
| 8 | 228 | |
| 9 | 229 | // ============================================================================ |
| 10 | 230 | // iOS Application Delegate |
| @@ -16,7 +236,6 @@ pub const CraftAppDelegate = struct { | ||
| 16 | 236 | window: ?objc.id = null, |
| 17 | 237 | root_view_controller: ?objc.id = null, |
| 18 | 238 | webview: ?*mobile.iOS.WKWebView = null, |
| 19 | js_bridge: ?*JSBridge = null, | |
| 20 | 239 | allocator: std.mem.Allocator, |
| 21 | 240 | config: AppConfig, |
| 22 | 241 | |
| @@ -107,160 +326,93 @@ pub const CraftAppDelegate = struct { | ||
| 107 | 326 | self.on_memory_warning = callback; |
| 108 | 327 | } |
| 109 | 328 | |
| 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); | |
| 128 | 362 | } |
| 129 | 363 | |
| 130 | // Initialize global object manager for memory tracking | |
| 131 | 364 | 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 | } | |
| 143 | 365 | |
| 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; | |
| 146 | 370 | |
| 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 | }; | |
| 149 | 375 | |
| 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 | ); | |
| 152 | 383 | |
| 153 | // Inject craft bridge JavaScript | |
| 154 | try self.injectBridgeScript(); | |
| 384 | _ = UIApplicationMain(argc, argv, null, delegate_name); | |
| 155 | 385 | |
| 156 | // Load initial content | |
| 157 | try self.loadInitialContent(); | |
| 386 | // `UIApplicationMain` does not return under normal operation. | |
| 387 | std.process.exit(0); | |
| 388 | } | |
| 158 | 389 | |
| 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(); | |
| 160 | 399 | try self.showWindow(); |
| 161 | 400 | |
| 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 | |
| 170 | 401 | if (self.on_launch) |callback| { |
| 171 | 402 | callback(); |
| 172 | 403 | } |
| 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; | |
| 196 | 404 | } |
| 197 | 405 | |
| 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. | |
| 264 | 416 | |
| 265 | 417 | /// Create the main UIWindow |
| 266 | 418 | fn createWindow(self: *Self) !void { |
| @@ -297,21 +449,66 @@ pub const CraftAppDelegate = struct { | ||
| 297 | 449 | } |
| 298 | 450 | |
| 299 | 451 | /// 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 | ||
| 300 | 498 | fn createRootViewController(self: *Self) !void { |
| 301 | 499 | // Create CraftViewController (our custom UIViewController) |
| 302 | 500 | const UIViewControllerClass = objc.objc_getClass("UIViewController") orelse return error.ClassNotFound; |
| 303 | 501 | self.root_view_controller = try objc.allocInit(UIViewControllerClass); |
| 304 | 502 | |
| 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); | |
| 315 | 512 | |
| 316 | 513 | // Get view controller's view |
| 317 | 514 | const sel_view = objc.sel_registerName("view") orelse return error.SelectorNotFound; |
| @@ -435,7 +632,12 @@ pub const CraftAppDelegate = struct { | ||
| 435 | 632 | |
| 436 | 633 | // Load HTML: [webview loadHTMLString:html baseURL:baseURL] |
| 437 | 634 | 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; | |
| 439 | 641 | const loadHTMLFn: LoadHTMLFn = @ptrCast(&objc_runtime.objc.objc_msgSend); |
| 440 | 642 | _ = loadHTMLFn(webview_ptr, sel_loadHTMLString, html_ns, base_url); |
| 441 | 643 | } |
| @@ -475,18 +677,17 @@ pub const CraftAppDelegate = struct { | ||
| 475 | 677 | objc.msgSend(self.window.?, sel_makeKeyAndVisible); |
| 476 | 678 | } |
| 477 | 679 | |
| 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:`. | |
| 490 | 691 | |
| 491 | 692 | /// Execute JavaScript in the webview |
| 492 | 693 | pub fn evaluateJavaScript(self: *Self, script: []const u8, callback: ?*const fn ([]const u8) void) !void { |
| @@ -518,23 +719,16 @@ pub const CraftAppDelegate = struct { | ||
| 518 | 719 | mobile.iOS.triggerHaptic(haptic_type); |
| 519 | 720 | } |
| 520 | 721 | |
| 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. | |
| 538 | 732 | }; |
| 539 | 733 | |
| 540 | 734 | /// Safe area insets |
| @@ -553,320 +747,32 @@ const UIEdgeInsets = extern struct { | ||
| 553 | 747 | right: f64, |
| 554 | 748 | }; |
| 555 | 749 | |
| 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 | /// } | |
| 569 | pub 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 | ||
| 848 | 750 | // ============================================================================ |
| 849 | 751 | // Convenience Functions |
| 850 | 752 | // ============================================================================ |
| 851 | 753 | |
| 852 | /// Quick start function for simple apps | |
| 853 | pub 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. | |
| 759 | pub fn quickStart(allocator: std.mem.Allocator, html: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn { | |
| 854 | 760 | var app = CraftAppDelegate.init(allocator, .{ |
| 855 | 761 | .name = "Craft App", |
| 856 | 762 | .initial_content = .{ .html = html }, |
| 857 | 763 | }); |
| 858 | 764 | |
| 859 | try app.run(); | |
| 765 | app.run(argc, argv); | |
| 860 | 766 | } |
| 861 | 767 | |
| 862 | /// Quick start with URL | |
| 863 | pub fn quickStartURL(allocator: std.mem.Allocator, url: []const u8) !void { | |
| 768 | /// Quick start with URL. Does not return. | |
| 769 | pub fn quickStartURL(allocator: std.mem.Allocator, url: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn { | |
| 864 | 770 | var app = CraftAppDelegate.init(allocator, .{ |
| 865 | 771 | .name = "Craft App", |
| 866 | 772 | .initial_content = .{ .url = url }, |
| 867 | 773 | }); |
| 868 | 774 | |
| 869 | try app.run(); | |
| 775 | app.run(argc, argv); | |
| 870 | 776 | } |
| 871 | 777 | |
| 872 | 778 | // ============================================================================ |
| @@ -887,39 +793,6 @@ test "CraftAppDelegate initialization" { | ||
| 887 | 793 | // Can't fully test without iOS runtime |
| 888 | 794 | } |
| 889 | 795 | |
| 890 | test "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 | ||
| 903 | test "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 | ||
| 923 | 796 | test "SafeAreaInsets" { |
| 924 | 797 | const insets = SafeAreaInsets{ |
| 925 | 798 | .top = 47.0, |
| @@ -0,0 +1,222 @@ | ||
| 1 | const std = ; | |
| 2 | const builtin = ; | |
| 3 | const objc_runtime = ; | |
| 4 | const request_context = ; | |
| 5 | const bridge_error = ; | |
| 6 | const bridge_mobile = ; | |
| 7 | ||
| 8 | const 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. | |
| 32 | var global_webview: ?objc.id = null; | |
| 33 | ||
| 34 | pub fn setWebView(webview: objc.id) void { | |
| 35 | global_webview = webview; | |
| 36 | } | |
| 37 | ||
| 38 | pub 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. | |
| 49 | pub 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. | |
| 70 | pub 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. | |
| 128 | fn 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. | |
| 144 | fn 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 | ||
| 159 | const testing = std.testing; | |
| 160 | ||
| 161 | test "an envelope without a type is rejected" { | |
| 162 | try testing.expectError( | |
| 163 | error.MissingType, | |
| 164 | handleMessage(testing.allocator, "{\"a\":\"getDeviceInfo\"}"), | |
| 165 | ); | |
| 166 | } | |
| 167 | ||
| 168 | test "an envelope without an action is rejected" { | |
| 169 | try testing.expectError( | |
| 170 | error.MissingAction, | |
| 171 | handleMessage(testing.allocator, "{\"t\":\"mobile\"}"), | |
| 172 | ); | |
| 173 | } | |
| 174 | ||
| 175 | test "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 | ||
| 184 | test "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 | ||
| 190 | test "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 | ||
| 201 | test "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 | ||
| 216 | test "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 | } | |
| @@ -33,7 +33,7 @@ export fn craft_ios_init() callconv(.c) c_int { | ||
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | /// 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 | |
| 37 | 37 | export fn craft_ios_set_html(html: [*]const u8, len: usize) callconv(.c) void { |
| 38 | 38 | if (g_allocator) |allocator| { |
| 39 | 39 | // Copy the HTML content |
| @@ -45,7 +45,7 @@ export fn craft_ios_set_html(html: [*]const u8, len: usize) callconv(.c) void { | ||
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | /// 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 | |
| 49 | 49 | export fn craft_ios_set_url(url: [*]const u8, len: usize) callconv(.c) void { |
| 50 | 50 | if (g_allocator) |allocator| { |
| 51 | 51 | const url_slice = url[0..len]; |
| @@ -55,24 +55,34 @@ export fn craft_ios_set_url(url: [*]const u8, len: usize) callconv(.c) void { | ||
| 55 | 55 | } |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | /// Run the Craft iOS application | |
| 59 | /// This creates the UIWindow, WKWebView, and starts the app | |
| 60 | export 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); } | |
| 70 | export 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 | }; | |
| 62 | 75 | |
| 63 | // Determine content to load | |
| 64 | 76 | const content: ios.CraftAppDelegate.AppConfig.Content = blk: { |
| 65 | 77 | if (g_html_content) |html| { |
| 66 | 78 | break :blk .{ .html = html }; |
| 67 | 79 | } else if (g_url_content) |url| { |
| 68 | 80 | break :blk .{ .url = url }; |
| 69 | 81 | } else { |
| 70 | // Default HTML content | |
| 71 | 82 | break :blk .{ .html = default_html }; |
| 72 | 83 | } |
| 73 | 84 | }; |
| 74 | 85 | |
| 75 | // Create app delegate | |
| 76 | 86 | const app = allocator.create(ios.CraftAppDelegate) catch return -1; |
| 77 | 87 | app.* = ios.CraftAppDelegate.init(allocator, .{ |
| 78 | 88 | .name = "Craft App", |
| @@ -88,10 +98,7 @@ export fn craft_ios_run() callconv(.c) c_int { | ||
| 88 | 98 | |
| 89 | 99 | g_app_delegate = app; |
| 90 | 100 | |
| 91 | // Run the app | |
| 92 | app.run() catch return -1; | |
| 93 | ||
| 94 | return 0; | |
| 101 | app.run(argc, argv); | |
| 95 | 102 | } |
| 96 | 103 | |
| 97 | 104 | /// Clean up Craft iOS resources |
| @@ -131,11 +138,15 @@ export fn craft_ios_haptic(haptic_type: c_int) callconv(.c) void { | ||
| 131 | 138 | mobile.iOS.triggerHaptic(h_type); |
| 132 | 139 | } |
| 133 | 140 | |
| 134 | /// Show native alert | |
| 135 | export 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. | |
| 139 | 150 | |
| 140 | 151 | // ============================================================================ |
| 141 | 152 | // Default HTML Content |
| @@ -181,43 +192,49 @@ const default_html = | ||
| 181 | 192 | // Zig-native API (for pure Zig iOS apps) |
| 182 | 193 | // ============================================================================ |
| 183 | 194 | |
| 184 | /// Create and run an iOS app with HTML content | |
| 185 | pub 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. | |
| 196 | pub 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 { | |
| 186 | 203 | var app_config = config; |
| 187 | 204 | app_config.initial_content = .{ .html = html }; |
| 188 | 205 | |
| 189 | 206 | var app = ios.CraftAppDelegate.init(allocator, app_config); |
| 190 | try app.run(); | |
| 207 | app.run(argc, argv); | |
| 191 | 208 | } |
| 192 | 209 | |
| 193 | /// Create and run an iOS app with a URL | |
| 194 | pub 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. | |
| 211 | pub 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 { | |
| 195 | 218 | var app_config = config; |
| 196 | 219 | app_config.initial_content = .{ .url = url }; |
| 197 | 220 | |
| 198 | 221 | var app = ios.CraftAppDelegate.init(allocator, app_config); |
| 199 | try app.run(); | |
| 222 | app.run(argc, argv); | |
| 200 | 223 | } |
| 201 | 224 | |
| 202 | 225 | /// Quick start an iOS app with just HTML |
| 203 | pub fn quickStart(allocator: std.mem.Allocator, html: []const u8) !void { | |
| 204 | try ios.quickStart(allocator, html); | |
| 226 | pub fn quickStart(allocator: std.mem.Allocator, html: []const u8, argc: c_int, argv: [*][*:0]u8) noreturn { | |
| 227 | ios.quickStart(allocator, html, argc, argv); | |
| 205 | 228 | } |
| 206 | 229 | |
| 207 | // ============================================================================ | |
| 208 | // Tests | |
| 209 | // ============================================================================ | |
| 210 | ||
| 211 | test "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
| @@ -1,633 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const io_context = ; | |
| 3 | ||
| 4 | /// iOS Swift Template Generator | |
| 5 | /// Generates complete iOS app templates with Craft integration | |
| 6 | pub 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 | |
| 627 | test "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
| @@ -1,635 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const io_context = ; | |
| 3 | const 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 | /// ``` | |
| 22 | pub const JSBridgeError = error{ | |
| 23 | InvalidJSON, | |
| 24 | MethodNotFound, | |
| 25 | InvalidParameters, | |
| 26 | ExecutionFailed, | |
| 27 | }; | |
| 28 | ||
| 29 | /// JavaScript Bridge Message | |
| 30 | pub 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 | |
| 68 | pub 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 | |
| 108 | pub 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 | |
| 190 | pub 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 | |
| 481 | pub 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 | |
| 597 | test "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 | ||
| 608 | test "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 | ||
| 623 | test "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 | } | |
| @@ -29,7 +29,13 @@ pub const mobile = ; | ||
| 29 | 29 | pub const android = ; |
| 30 | 30 | pub const CraftActivity = android.CraftActivity; |
| 31 | 31 | pub const AndroidJSBridge = android.JSBridge; |
| 32 | pub 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. | |
| 33 | 39 | |
| 34 | 40 | // Re-export API module |
| 35 | 41 | pub const api = ; |
packages/zig/src/mobile.zigmodified+95-829
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const objc_runtime = ; |
| 3 | const compat = ; | |
| 3 | 4 | |
| 4 | 5 | /// Mobile Platform Support |
| 5 | 6 | /// Provides iOS and Android native integration |
| @@ -7,204 +8,6 @@ const objc_runtime = ; | ||
| 7 | 8 | // Use the proper Objective-C runtime wrapper |
| 8 | 9 | const objc = objc_runtime.objc; |
| 9 | 10 | |
| 10 | // JNI for Android | |
| 11 | const 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 | |
| 117 | pub 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 | |
| 161 | var android_callbacks: AndroidCallbackStorage = .{}; | |
| 162 | ||
| 163 | // JNI export functions are only compiled on Android (Linux target) | |
| 164 | // On other platforms, these are no-ops | |
| 165 | comptime { | |
| 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 | |
| 174 | fn 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 | |
| 195 | fn 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 | ||
| 208 | 11 | pub const Platform = enum { |
| 209 | 12 | ios, |
| 210 | 13 | android, |
| @@ -265,7 +68,7 @@ pub const NativeObjectManager = struct { | ||
| 265 | 68 | .ptr = ptr, |
| 266 | 69 | .size = size, |
| 267 | 70 | .type_name = type_name, |
| 268 | .allocation_time = std.time.timestamp(), | |
| 71 | .allocation_time = compat.timestamp(), | |
| 269 | 72 | }; |
| 270 | 73 | |
| 271 | 74 | try self.tracked_objects.put(addr, info); |
| @@ -322,7 +125,7 @@ pub const NativeObjectManager = struct { | ||
| 322 | 125 | |
| 323 | 126 | std.debug.print("Memory Leak Report:\n", .{}); |
| 324 | 127 | while (it.next()) |entry| { |
| 325 | const age = std.time.timestamp() - entry.value.*.allocation_time; | |
| 128 | const age = compat.timestamp() - entry.value.*.allocation_time; | |
| 326 | 129 | std.debug.print(" Leaked {s} at 0x{x} (size: {d} bytes, age: {d}s)\n", .{ |
| 327 | 130 | entry.value.*.type_name, |
| 328 | 131 | entry.key_ptr.*, |
| @@ -469,11 +272,21 @@ pub const iOS = struct { | ||
| 469 | 272 | const allocated = objc.msgSendId(WKWebViewClass, sel_alloc); |
| 470 | 273 | const Fn = *const fn (objc.id, objc.SEL, objc.CGRect, objc.id) callconv(.c) objc.id; |
| 471 | 274 | 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; | |
| 473 | 282 | |
| 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. | |
| 475 | 288 | if (getGlobalObjectManager()) |manager| { |
| 476 | try manager.trackObject(webview, @sizeOf(@TypeOf(webview)), "WKWebView"); | |
| 289 | try manager.trackObject(webview, objc.classInstanceSize(WKWebViewClass), "WKWebView"); | |
| 477 | 290 | } |
| 478 | 291 | |
| 479 | 292 | // Associate allocator with webview for cleanup |
| @@ -567,82 +380,35 @@ pub const iOS = struct { | ||
| 567 | 380 | // Create NSString from script |
| 568 | 381 | const ns_script = try objc.createNSString(script, allocator.*); |
| 569 | 382 | |
| 570 | // Create completion handler block for JavaScript evaluation | |
| 571 | 383 | const sel_evaluateJavaScript = objc.sel_registerName("evaluateJavaScript:completionHandler:") orelse return error.SelectorNotFound; |
| 572 | 384 | |
| 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; | |
| 644 | 410 | 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); | |
| 646 | 412 | } |
| 647 | 413 | |
| 648 | 414 | /// Handle Deep Links |
| @@ -663,78 +429,6 @@ pub const iOS = struct { | ||
| 663 | 429 | reminders, |
| 664 | 430 | }; |
| 665 | 431 | |
| 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 | ||
| 738 | 432 | pub const PermissionStatus = enum { |
| 739 | 433 | not_determined, |
| 740 | 434 | restricted, |
| @@ -743,145 +437,27 @@ pub const iOS = struct { | ||
| 743 | 437 | limited, // For photos |
| 744 | 438 | }; |
| 745 | 439 | |
| 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. | |
| 885 | 461 | |
| 886 | 462 | /// Haptic Feedback |
| 887 | 463 | pub const HapticType = enum { |
| @@ -966,62 +542,6 @@ pub const iOS = struct { | ||
| 966 | 542 | }, |
| 967 | 543 | } |
| 968 | 544 | } |
| 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 | } | |
| 1025 | 545 | }; |
| 1026 | 546 | |
| 1027 | 547 | /// Android Native Integration |
| @@ -1079,161 +599,6 @@ pub const Android = struct { | ||
| 1079 | 599 | }; |
| 1080 | 600 | }; |
| 1081 | 601 | |
| 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 | ||
| 1237 | 602 | /// Request Permissions |
| 1238 | 603 | pub const Permission = enum { |
| 1239 | 604 | camera, |
| @@ -1247,133 +612,36 @@ pub const Android = struct { | ||
| 1247 | 612 | record_audio, |
| 1248 | 613 | }; |
| 1249 | 614 | |
| 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 | ||
| 1373 | 615 | pub const ToastDuration = enum { |
| 1374 | 616 | short, |
| 1375 | 617 | long, |
| 1376 | 618 | }; |
| 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. | |
| 1377 | 645 | }; |
| 1378 | 646 | |
| 1379 | 647 | /// Cross-Platform Mobile Window |
| @@ -1420,10 +688,11 @@ pub const MobileWindow = struct { | ||
| 1420 | 688 | const webview: *iOS.WKWebView = @ptrCast(@alignCast(self.handle)); |
| 1421 | 689 | try iOS.loadURL(webview, url); |
| 1422 | 690 | }, |
| 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, | |
| 1427 | 696 | .unknown => return error.UnsupportedPlatform, |
| 1428 | 697 | } |
| 1429 | 698 | } |
| @@ -1434,10 +703,7 @@ pub const MobileWindow = struct { | ||
| 1434 | 703 | const webview: *iOS.WKWebView = @ptrCast(@alignCast(self.handle)); |
| 1435 | 704 | try iOS.evaluateJavaScript(webview, script, null); |
| 1436 | 705 | }, |
| 1437 | .android => { | |
| 1438 | const webview: *Android.WebView = @ptrCast(@alignCast(self.handle)); | |
| 1439 | try Android.evaluateJavaScript(webview, script, null); | |
| 1440 | }, | |
| 706 | .android => return error.AndroidBridgeNotImplemented, | |
| 1441 | 707 | .unknown => return error.UnsupportedPlatform, |
| 1442 | 708 | } |
| 1443 | 709 | } |
| @@ -15,6 +15,7 @@ pub const objc = if (is_darwin) struct { | ||
| 15 | 15 | pub extern "c" fn class_addMethod(cls: ?*anyopaque, name: ?*anyopaque, imp: *const anyopaque, types: [*:0]const u8) bool; |
| 16 | 16 | pub extern "c" fn object_getClass(obj: ?*anyopaque) ?*anyopaque; |
| 17 | 17 | pub extern "c" fn class_createInstance(cls: ?*anyopaque, extraBytes: usize) ?*anyopaque; |
| 18 | pub extern "c" fn class_getInstanceSize(cls: ?*anyopaque) usize; | |
| 18 | 19 | pub extern "c" fn objc_setAssociatedObject(obj: ?*anyopaque, key: ?*const anyopaque, value: ?*anyopaque, policy: c_uint) void; |
| 19 | 20 | pub extern "c" fn objc_getAssociatedObject(obj: ?*anyopaque, key: ?*const anyopaque) ?*anyopaque; |
| 20 | 21 | pub extern "c" fn objc_removeAssociatedObjects(obj: ?*anyopaque) void; |
| @@ -149,6 +150,26 @@ pub const objc = if (is_darwin) struct { | ||
| 149 | 150 | return msgSendId1(NSURLClass, sel, ns_string); |
| 150 | 151 | } |
| 151 | 152 | |
| 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 | ||
| 152 | 173 | /// Helper to alloc and init an object |
| 153 | 174 | pub fn allocInit(class: Class) !id { |
| 154 | 175 | const sel_alloc = sel_registerName("alloc") orelse return error.SelectorNotFound; |
| @@ -179,7 +200,7 @@ pub const objc = if (is_darwin) struct { | ||
| 179 | 200 | |
| 180 | 201 | // Tests |
| 181 | 202 | test "NSString creation" { |
| 182 | if (!.target.isDarwin()) return error.SkipZigTest; | |
| 203 | if (!.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 183 | 204 | |
| 184 | 205 | const allocator = std.testing.allocator; |
| 185 | 206 | const ns_string = try objc.createNSString("Hello, World!", allocator); |
| @@ -190,7 +211,7 @@ test "NSString creation" { | ||
| 190 | 211 | } |
| 191 | 212 | |
| 192 | 213 | test "NSURL creation" { |
| 193 | if (!.target.isDarwin()) return error.SkipZigTest; | |
| 214 | if (!.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 194 | 215 | |
| 195 | 216 | const allocator = std.testing.allocator; |
| 196 | 217 | const ns_url = try objc.createNSURL("https://example.com", allocator); |
packages/zig/test/android_test.zigdeleted+0-373
| @@ -1,373 +0,0 @@ | ||
| 1 | const std = ; | |
| 2 | const testing = std.testing; | |
| 3 | const android = ; | |
| 4 | ||
| 5 | // ============================================================================ | |
| 6 | // JNI Types Tests | |
| 7 | // ============================================================================ | |
| 8 | ||
| 9 | test "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 | ||
| 22 | test "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 | ||
| 28 | test "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 | ||
| 35 | test "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 | ||
| 56 | test "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 | ||
| 71 | test "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 | ||
| 91 | test "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 | ||
| 111 | test "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 | ||
| 166 | test "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 | ||
| 188 | test "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 | ||
| 205 | test "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 | ||
| 217 | test "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 | ||
| 236 | test "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 | ||
| 244 | test "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 | ||
| 256 | test "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 | ||
| 270 | test "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 | ||
| 280 | test "AndroidFeatures - isNetworkConnected" { | |
| 281 | // Returns true by default in non-Android environment | |
| 282 | const connected = android.AndroidFeatures.isNetworkConnected(); | |
| 283 | try testing.expect(connected); | |
| 284 | } | |
| 285 | ||
| 286 | test "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 | ||
| 292 | test "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 | ||
| 302 | test "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 | ||
| 312 | test "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 | ||
| 333 | test "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 | ||
| 350 | test "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 | } | |
| @@ -0,0 +1,209 @@ | ||
| 1 | const std = ; | |
| 2 | const 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. | |
| 20 | const swift_spec = @embedFile("CraftApp.swift"); | |
| 21 | const 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. | |
| 30 | const dispatch_begin = "func userContentController("; | |
| 31 | const 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. | |
| 42 | const max_not_yet_migrated: usize = 105; | |
| 43 | ||
| 44 | fn 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. | |
| 52 | fn 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. | |
| 67 | fn 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. | |
| 83 | fn 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 | ||
| 106 | test "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 | ||
| 119 | test "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 | ||
| 148 | test "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 | ||
| 188 | test "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 | } | |
| @@ -0,0 +1,101 @@ | ||
| 1 | const std = ; | |
| 2 | const ios = ; | |
| 3 | const 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. | |
| 13 | fn 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. | |
| 44 | test "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. | |
| 52 | test "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. | |
| 67 | test "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. | |
| 91 | test "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 | } | |