ReviewOS

also looking at this

craft-native/craft

iOS Phase 3: Tier-0 actions in Zig — 19 migrated, ratchet 105 → 86

#85
Merged glennmichael123 wants to merge feat/ios-phase3-tier0 into main
16 files +5086 -78
packages/ios/fixtures/zig-slice/build-and-run.shmodified+17-2
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -44,7 +44,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
4444 "$FIXTURE/shim.swift" \
4545 "$OUT/main.o" "$OUT/page.o" \
4646 "$ROOT/packages/zig/zig-out/lib/$LIB" \
47 -framework UIKit -framework WebKit -framework Foundation \
47 -framework UIKit -framework WebKit -framework Foundation -framework Security \
4848 -o "$APP/CraftSlice"
4949
5050cp "$FIXTURE/Info.plist" "$APP/Info.plist"
@@ -66,7 +66,7 @@ LAUNCH_PID=$!
6666# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
6767# fixed amount, so a slow boot does not read as a failure.
6868for _ in $(seq 1 60); do
69 if grep -q 'i=5' "$LOG" 2>/dev/null && grep -q 'i=3' "$LOG" 2>/dev/null; then sleep 1; break; fi
69 if grep -q 'i=7' "$LOG" 2>/dev/null && grep -q 'i=8' "$LOG" 2>/dev/null; then sleep 1; break; fi
7070 sleep 1
7171done
7272kill "$LAUNCH_PID" 2>/dev/null || true
@@ -111,4 +111,19 @@ echo "ok: unserved action reached the dispatcher (i=4 seen ${C4}x)"
111111[ "$C5" -ge 1 ] || { echo "FAIL: the host shim never answered, or its answer never reached the page"; exit 1; }
112112echo "ok: hand-off to host shim closed (i=5 seen ${C5}x)"
113113
114C6="$(count 6)"; C7="$(count 7)"
115
116[ "$C6" -ge 1 ] || { echo "FAIL: the rejecting action never reached the dispatcher"; exit 1; }
117echo "ok: rejecting action reached the dispatcher (i=6 seen ${C6}x)"
118
119# i=7 requires the page's __craftBridgeError handler to have seen error:true,
120# the HOST_DECLINED code, id 6, and a message whose backslash, quote and
121# newline survived Zig's escaping. Any of those wrong and this never fires.
122[ "$C7" -ge 1 ] || { echo "FAIL: the shim's rejection never reached the page intact"; exit 1; }
123echo "ok: error route closed with escaping intact (i=7 seen ${C7}x)"
124
125C8="$(count 8)"
126[ "$C8" -ge 1 ] || { echo "FAIL: the Tier-0 action's reply never carried a real app state"; exit 1; }
127echo "ok: Tier-0 action served by Zig with a live UIKit value (i=8 seen ${C8}x)"
128
114129echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+28-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -11,6 +11,11 @@
1111// i=3 the user script had already run when the page's own script executed
1212// i=4 the page called an action Zig does *not* serve
1313// i=5 that reply came back, having been answered by the host shim
14// i=8 the page called a Tier-0 action Zig newly serves (getAppState) and
15// its reply named a real UIKit application state
16// i=6 the page called an action the shim answers by *rejecting*
17// i=7 the rejection arrived via __craftBridgeError with its id, code, and
18// an unmangled message — proving the error route and Zig-side escaping
1419//
1520// i=2 and i=5 are the load-bearing ones, and they prove different things.
1621// i=2 cannot be produced by a stub: only a real UIKit process reports
@@ -22,7 +27,7 @@ const char craft_slice_page[] =
2227 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
2328 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
2429 "</head><body><h1 id=\"s\">craft slice</h1><script>\n"
25 "var sentRoundTrip = false, sentHandOff = false;\n"
30 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false;\n"
2631 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
2732 "\n"
2833 "// Read before anything else touches it: the user script is installed at\n"
@@ -33,6 +38,18 @@ const char craft_slice_page[] =
3338 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n"
3439 "}\n"
3540 "\n"
41 "var sentErrorAck = false;\n"
42 "window.__craftBridgeError = function (ctx) {\n"
43 " // The message crossed Zig's escaping with a backslash, quote, and\n"
44 " // newline intact; anything mangled and this equality fails, i=7 never\n"
45 " // fires, and the harness reports the error route broken.\n"
46 " var messageIntact = ctx && ctx.message === 'declined \\\\ \"on purpose\"\\nsecond line';\n"
47 " if (!sentErrorAck && ctx && ctx.error === true && ctx.code === 'HOST_DECLINED'\n"
48 " && ctx.id === 6 && messageIntact) {\n"
49 " sentErrorAck = true;\n"
50 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:7});\n"
51 " }\n"
52 "};\n"
3653 "window.__craftBridgeResult = function (action, payload, id) {\n"
3754 " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n"
3855 " document.getElementById('s').textContent = window.__craftSliceAck;\n"
@@ -47,6 +64,12 @@ const char craft_slice_page[] =
4764 " // Zig routed the call out, Swift answered through\n"
4865 " // craft_ios_deliver_result, and Zig delivered it here over the same\n"
4966 " // protocol it uses for its own actions.\n"
67 " // A Tier-0 action served by Zig itself: the reply carries the UIKit\n"
68 " // application state, which only a live foregrounded process reports.\n"
69 " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n"
70 " sentTierZero = true;\n"
71 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n"
72 " }\n"
5073 " if (!sentHandOff && payload && payload.servedBy === 'host-shim'\n"
5174 " && payload.language === 'swift') {\n"
5275 " sentHandOff = true;\n"
@@ -58,6 +81,10 @@ const char craft_slice_page[] =
5881 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n"
5982 " // An action Zig does not serve, so it must reach the host shim.\n"
6083 " bridge.postMessage({t:'mobile', a:'hostOnlyPing', i:4});\n"
84 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
85 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
86 " // And one the shim answers by rejecting, to prove the error route.\n"
87 " bridge.postMessage({t:'mobile', a:'hostOnlyFail', i:6});\n"
6188 "} else {\n"
6289 " document.getElementById('s').textContent = 'NO BRIDGE';\n"
6390 "}\n"
packages/ios/fixtures/zig-slice/shim.swiftmodified+61-40
Changes to packages/ios/fixtures/zig-slice/shim.swift
@@ -1,57 +1,78 @@
1// The host shim, in Swift, presenting the contract `CraftApp.swift` will.
1// The host shim, mirroring the design `CraftApp.swift`'s CraftSwiftShim uses —
2// including the part most likely to fail silently in production: discovering
3// Zig's delivery exports with `dlsym` rather than linking them.
24//
3// This replaces an Objective-C stand-in. The stand-in proved the runtime
4// mechanism — `objc_getClass` plus a message send — but not that *Swift*
5// presents that mechanism the way Zig expects: that `@objc(CraftSwiftShim)`
6// really does register under that name, that a `static func` becomes a class
7// method reachable by selector, that `String` parameters bridge from NSString,
8// and that `Bool` comes back as the ObjC BOOL Zig reads.
5// That discovery is the template's load-bearing assumption. A pure-Swift app
6// has no Zig runtime, so the template cannot use `@_silgen_name` (a hard link
7// dependency); it resolves the symbols at runtime and declines when absent.
8// Whether `dlsym(dlopen(nil, RTLD_NOW), ...)` finds a symbol that was
9// *statically linked* into the executable is exactly the kind of fact that
10// must be proven on a simulator, because if a build setting strips it from the
11// dynamic symbol table the seam degrades to "shim declines everything" with no
12// error anywhere.
913//
10// Those are exactly the assumptions the real wiring will rest on, so they are
11// worth testing against the real compiler rather than assumed from a language
12// that shares an ABI with it.
14// Two actions, proving the two reply routes:
15// hostOnlyPing -> craft_ios_deliver_result (the page's promise resolves)
16// hostOnlyFail -> craft_ios_deliver_error (the page's promise rejects)
17// The error route matters independently: delivering a rejection as a result
18// would run the app's then-branch with an error-shaped object.
1319import Foundation
1420
15// Zig owns the reply path. The shim decides *what* the answer is and hands it
16// back; the wire format, the request id and the escaping stay on the other
17// side. `@_silgen_name` reaches the exported symbol without needing a bridging
18// header, which keeps the harness to one swiftc invocation.
19@_silgen_name("craft_ios_deliver_result")
20func craft_ios_deliver_result(
21 _ action: UnsafePointer<CChar>, _ actionLen: UInt,
22 _ json: UnsafePointer<CChar>, _ jsonLen: UInt,
23 _ requestId: Int64
24)
21private typealias DeliverResultFn = @convention(c) (
22 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
23) -> Void
24private typealias DeliverErrorFn = @convention(c) (
25 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt,
26 UnsafePointer<CChar>, UInt, Int64
27) -> Void
28
29private let deliverResult: DeliverResultFn? = {
30 guard let sym = dlsym(dlopen(nil, RTLD_NOW), "craft_ios_deliver_result") else { return nil }
31 return unsafeBitCast(sym, to: DeliverResultFn.self)
32}()
33
34private let deliverError: DeliverErrorFn? = {
35 guard let sym = dlsym(dlopen(nil, RTLD_NOW), "craft_ios_deliver_error") else { return nil }
36 return unsafeBitCast(sym, to: DeliverErrorFn.self)
37}()
2538
2639@objc(CraftSwiftShim)
2740public class CraftSwiftShim: NSObject {
28 // Selector: handleAction:payload:requestId: — which is what
29 // `ios_dispatch.handOffToHost` sends. A mismatch here is not a compile
30 // error on either side, so the simulator assertion is what catches it.
3141 @objc public static func handleAction(
3242 _ action: String,
3343 payload: String,
3444 requestId: Int64
3545 ) -> Bool {
36 // Exactly one action, to prove the seam. A real shim dispatches the
37 // ~105 the Swift template still owns.
38 guard action == "hostOnlyPing" else {
39 // Not ours. Zig answers UnknownAction, which is the same path a
40 // fully-migrated app with no shim at all takes.
41 return false
42 }
43
44 let json = "{\"servedBy\":\"host-shim\",\"language\":\"swift\",\"payloadBytes\":\(payload.utf8.count)}"
46 switch action {
47 case "hostOnlyPing":
48 guard let deliverResult else { return false }
49 let json = "{\"servedBy\":\"host-shim\",\"language\":\"swift\",\"payloadBytes\":\(payload.utf8.count)}"
50 action.withCString { a in
51 json.withCString { j in
52 deliverResult(a, UInt(strlen(a)), j, UInt(strlen(j)), requestId)
53 }
54 }
55 return true
4556
46 action.withCString { a in
47 json.withCString { j in
48 craft_ios_deliver_result(
49 a, UInt(strlen(a)),
50 j, UInt(strlen(j)),
51 requestId
52 )
57 case "hostOnlyFail":
58 guard let deliverError else { return false }
59 // The message deliberately contains the characters Swift's old
60 // hand-escaping mangled: a backslash, a quote, and a newline. If
61 // they reach the page intact, escaping lives on the Zig side where
62 // it belongs.
63 let message = "declined \\ \"on purpose\"\nsecond line"
64 action.withCString { a in
65 message.withCString { m in
66 "HOST_DECLINED".withCString { c in
67 deliverError(a, UInt(strlen(a)), m, UInt(strlen(m)),
68 c, UInt(strlen(c)), requestId)
69 }
70 }
5371 }
72 return true
73
74 default:
75 return false
5476 }
55 return true
5677 }
5778}
packages/ios/scripts/typecheck-template.shadded+26-0
Changes to packages/ios/scripts/typecheck-template.sh
@@ -0,0 +1,26 @@
1#!/usr/bin/env bash
2# Type-check CraftApp.swift without generating a project.
3#
4# The template cannot be compiled as-is: it carries placeholders
5# and top-level code, and it references CraftActivityAttributes from a sibling
6# template. This substitutes a dummy id, adds -parse-as-library, and includes
7# the sibling — which is exactly what the generated app does, minus xcodegen.
8#
9# Detecting failure by exit code, not by grepping for "error:": the file is
10# full of `rejectCallback(callbackId, error: ...)` argument labels, and a grep
11# happily counts those as compiler errors. That mistake already cost a cycle.
12set -euo pipefail
13
14ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
15TPL="$ROOT/packages/ios/templates"
16TMP="$(mktemp -d)"
17trap 'rm -rf "$TMP"' EXIT
18
19sed 's//app.craft.typecheck/g' "$TPL/CraftApp.swift" > "$TMP/CraftApp.swift"
20
21SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
22swiftc -typecheck -parse-as-library \
23 -sdk "$SDK" -target arm64-apple-ios18.0-simulator \
24 "$TMP/CraftApp.swift" "$TPL/CraftActivityAttributes.swift"
25
26echo "CraftApp.swift typechecks"
packages/ios/templates/CraftApp.swiftmodified+128-3
Changes to packages/ios/templates/CraftApp.swift
@@ -364,7 +364,12 @@ struct CraftWebView: UIViewRepresentable {
364364 func updateUIView(_ webView: WKWebView, context: Context) {}
365365
366366 func makeCoordinator() -> Coordinator {
367 Coordinator(config: config)
367 let coordinator = Coordinator(config: config)
368 // The Zig dispatcher finds CraftSwiftShim by class name; the shim finds
369 // its way back to the live coordinator through this. Weak, because
370 // SwiftUI owns the coordinator's lifetime.
371 CraftSwiftShim.coordinator = coordinator
372 return coordinator
368373 }
369374
370375 // MARK: - Coordinator (Native Bridge)
@@ -511,8 +516,19 @@ struct CraftWebView: UIViewRepresentable {
511516 guard let body = message.body as? [String: Any],
512517 let action = body["action"] as? String else { return }
513518
514 let callbackId = body["callbackId"] as? String
519 dispatch(action: action, body: body, callbackId: body["callbackId"] as? String)
520 }
515521
522 /// The action switch, reachable from two directions: the
523 /// WKScriptMessageHandler above, and `CraftSwiftShim.handleAction` when
524 /// the Zig dispatcher hands over an action it has not migrated yet.
525 ///
526 /// A hand-off call arrives with a synthetic callbackId of the form
527 /// "zig:<requestId>". The reply helpers recognise that prefix and route
528 /// the answer back through the Zig runtime's exported
529 /// `craft_ios_deliver_result` instead of evaluating JavaScript here —
530 /// one reply path, owned by whichever side received the page's message.
531 func dispatch(action: String, body: [String: Any], callbackId: String?) {
516532 switch action {
517533 case "startListening":
518534 if config.enableSpeechRecognition { startSpeechRecognition() }
@@ -2426,6 +2442,10 @@ struct CraftWebView: UIViewRepresentable {
24262442 rejectCallback(callbackId, error: "Native result could not be serialized", code: "SERIALIZATION_ERROR")
24272443 return
24282444 }
2445 // A hand-off from the Zig dispatcher replies through Zig, which
2446 // owns the wire format, the request id, and the escaping. Replying
2447 // by JavaScript here as well would give the page two answers.
2448 if CraftSwiftShim.deliverResultIfHandOff(id, json: resultStr) { return }
24292449 let script = "window.craft._resolveCallback('\(id)', \(resultStr));"
24302450 DispatchQueue.main.async { self.webView?.evaluateJavaScript(script, completionHandler: nil) }
24312451 }
@@ -2436,7 +2456,17 @@ struct CraftWebView: UIViewRepresentable {
24362456
24372457 private func rejectCallback(_ callbackId: String?, error: String, code: String = "CRAFT_ERROR") {
24382458 guard let id = callbackId else { return }
2439 let escapedError = error.replacingOccurrences(of: "'", with: "\\'").replacingOccurrences(of: "\n", with: "\\n")
2459 // A hand-off rejection goes back through Zig's error route, so the
2460 // page's promise *rejects*. Delivering it as a result would run the
2461 // app's then-branch with an error-shaped object — fabricated
2462 // success wearing a different hat.
2463 if CraftSwiftShim.deliverErrorIfHandOff(id, message: error, code: code) { return }
2464 // Backslashes first: escaping ' and \n but not \ let an error
2465 // message containing a backslash break out of the string literal.
2466 let escapedError = error
2467 .replacingOccurrences(of: "\\", with: "\\\\")
2468 .replacingOccurrences(of: "'", with: "\\'")
2469 .replacingOccurrences(of: "\n", with: "\\n")
24402470 let script = "window.craft._rejectCallback('\(id)', '\(escapedError)', '\(code)');"
24412471 DispatchQueue.main.async { self.webView?.evaluateJavaScript(script, completionHandler: nil) }
24422472 }
@@ -5159,3 +5189,98 @@ extension CraftWebView.Coordinator: WCSessionDelegate {
51595189 }
51605190 }
51615191}
5192
5193
5194// MARK: - Zig hand-off shim
5195
5196/// The seam the Zig dispatcher hands unmigrated actions through.
5197///
5198/// Discovery is symmetric and both directions are runtime-only. Zig finds this
5199/// class with `objc_getClass("CraftSwiftShim")`; this class finds Zig's
5200/// delivery exports with `dlsym`. Neither side links the other, so a
5201/// pure-Swift app (no Zig runtime) and a fully-migrated Zig app (no shim work
5202/// left) both build and run without dead dependencies.
5203///
5204/// The shim decides *what* the answer is, never *how* it reaches the page.
5205/// Replies go back through Zig's `craft_ios_deliver_result` /
5206/// `craft_ios_deliver_error`, which own the wire format, the request id, and
5207/// the escaping. Two components replying to one page by different routes is
5208/// how this codebase accumulated five envelopes.
5209@objc(CraftSwiftShim)
5210final class CraftSwiftShim: NSObject {
5211 /// The coordinator serving hand-offs. Set by `makeCoordinator`.
5212 static weak var coordinator: CraftWebView.Coordinator?
5213
5214 private typealias DeliverResultFn = @convention(c) (
5215 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
5216 ) -> Void
5217 private typealias DeliverErrorFn = @convention(c) (
5218 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt,
5219 UnsafePointer<CChar>, UInt, Int64
5220 ) -> Void
5221
5222 private static let deliverResult: DeliverResultFn? = {
5223 guard let sym = dlsym(dlopen(nil, RTLD_NOW), "craft_ios_deliver_result") else { return nil }
5224 return unsafeBitCast(sym, to: DeliverResultFn.self)
5225 }()
5226
5227 private static let deliverError: DeliverErrorFn? = {
5228 guard let sym = dlsym(dlopen(nil, RTLD_NOW), "craft_ios_deliver_error") else { return nil }
5229 return unsafeBitCast(sym, to: DeliverErrorFn.self)
5230 }()
5231
5232 /// Entry point for the Zig dispatcher. Selector: handleAction:payload:requestId:
5233 ///
5234 /// Returns false when this shim cannot serve the call — no live
5235 /// coordinator, or no Zig runtime to reply through — so Zig answers the
5236 /// page with UnknownAction instead of the call vanishing.
5237 @objc static func handleAction(_ action: String, payload: String, requestId: Int64) -> Bool {
5238 guard let coordinator, deliverResult != nil else { return false }
5239
5240 let body = ((try? JSONSerialization.jsonObject(with: Data(payload.utf8))) as? [String: Any]) ?? [:]
5241
5242 // The synthetic callbackId routes this call's reply back through Zig.
5243 // It carries the action too, because the reply helpers do not otherwise
5244 // know it, and Zig's reply names the action for the page's
5245 // action-matching fallback.
5246 coordinator.dispatch(action: action, body: body, callbackId: "zig:\(requestId):\(action)")
5247 return true
5248 }
5249
5250 /// Route a resolve through Zig when the callbackId marks a hand-off.
5251 /// Returns true when the reply has been (or could only be) handled here.
5252 static func deliverResultIfHandOff(_ callbackId: String, json: String) -> Bool {
5253 guard let (requestId, action) = parseHandOffId(callbackId) else { return false }
5254 guard let deliverResult else { return true } // hand-off id but no runtime: drop, never eval JS
5255 action.withCString { a in
5256 json.withCString { j in
5257 deliverResult(a, UInt(strlen(a)), j, UInt(strlen(j)), requestId)
5258 }
5259 }
5260 return true
5261 }
5262
5263 /// Route a rejection through Zig's error path, so the page's promise
5264 /// rejects rather than resolving with an error-shaped object.
5265 static func deliverErrorIfHandOff(_ callbackId: String, message: String, code: String) -> Bool {
5266 guard let (requestId, action) = parseHandOffId(callbackId) else { return false }
5267 guard let deliverError else { return true }
5268 action.withCString { a in
5269 message.withCString { m in
5270 code.withCString { c in
5271 deliverError(a, UInt(strlen(a)), m, UInt(strlen(m)), c, UInt(strlen(c)), requestId)
5272 }
5273 }
5274 }
5275 return true
5276 }
5277
5278 /// "zig:<requestId>:<action>" -> (requestId, action). Nil for ordinary
5279 /// page-issued callback ids, which keep their JavaScript reply path.
5280 private static func parseHandOffId(_ callbackId: String) -> (Int64, String)? {
5281 guard callbackId.hasPrefix("zig:") else { return nil }
5282 let rest = callbackId.dropFirst(4)
5283 guard let sep = rest.firstIndex(of: ":"), let id = Int64(rest[..<sep]) else { return nil }
5284 return (id, String(rest[rest.index(after: sep)...]))
5285 }
5286}
packages/zig/build.zigmodified+70-0
Changes to packages/zig/build.zig
@@ -339,6 +339,40 @@ pub fn build(b: *std.Build) void {
339339 ios_surface_tests.root_module.linkFramework("CoreFoundation", .{});
340340 ios_surface_tests.root_module.linkFramework("CoreGraphics", .{});
341341 ios_surface_tests.root_module.linkFramework("CoreMIDI", .{});
342 // The storage module is Keychain: SecItemAdd and the kSec* constants
343 // live in Security.framework.
344 ios_surface_tests.root_module.linkFramework("Security", .{});
345 }
346
347 // Tests are collected from the ROOT module only — a named-module import is
348 // a different module, so `test { _ = ios; }` in the surface gate enrolls
349 // nothing. This artifact makes src/ios.zig itself the root, which is the
350 // only arrangement that runs the tests inside ios_dispatch.zig and the
351 // mobile modules. Proven with a canary: a deliberately failing module test
352 // stayed green under every other wiring.
353 // A fresh module object rather than reusing `ios_module`: an artifact root
354 // needs a resolved target, and the one-module-per-file rule only applies
355 // within a single compilation — this artifact is its own.
356 const ios_module_tests = b.addTest(.{
357 .root_module = b.createModule(.{
358 .root_source_file = b.path("src/ios.zig"),
359 .target = target,
360 .optimize = optimize,
361 }),
362 });
363 if (target.result.os.tag.isDarwin()) {
364 ios_module_tests.root_module.link_libc = true;
365 ios_module_tests.root_module.linkSystemLibrary("objc", .{});
366 ios_module_tests.root_module.linkFramework("Cocoa", .{});
367 ios_module_tests.root_module.linkFramework("WebKit", .{});
368 ios_module_tests.root_module.linkFramework("CoreFoundation", .{});
369 ios_module_tests.root_module.linkFramework("CoreGraphics", .{});
370 ios_module_tests.root_module.linkFramework("CoreMIDI", .{});
371 ios_module_tests.root_module.linkFramework("Security", .{});
372 // Rooting at ios.zig compiles its exports, whose reply path reaches the
373 // desktop bridge — including the Carbon hotkey machinery. Same linkage
374 // macos_hotkey_tests already carries.
375 ios_module_tests.root_module.linkFramework("Carbon", .{});
342376 }
343377
344378 // The iOS conformance gate. It embeds the Swift template as the migration
@@ -357,6 +391,24 @@ pub fn build(b: *std.Build) void {
357391 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
358392 .root_source_file = b.path("src/bridge_mobile.zig"),
359393 });
394 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_clipboard.zig", .{
395 .root_source_file = b.path("src/bridge_mobile_clipboard.zig"),
396 });
397 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_haptics.zig", .{
398 .root_source_file = b.path("src/bridge_mobile_haptics.zig"),
399 });
400 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_device.zig", .{
401 .root_source_file = b.path("src/bridge_mobile_device.zig"),
402 });
403 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_system.zig", .{
404 .root_source_file = b.path("src/bridge_mobile_system.zig"),
405 });
406 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_display.zig", .{
407 .root_source_file = b.path("src/bridge_mobile_display.zig"),
408 });
409 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_storage.zig", .{
410 .root_source_file = b.path("src/bridge_mobile_storage.zig"),
411 });
360412
361413 const menubar_tests = b.addTest(.{
362414 .root_module = b.createModule(.{
@@ -1204,6 +1256,7 @@ pub fn build(b: *std.Build) void {
12041256 const run_mobile_tests = b.addRunArtifact(mobile_tests);
12051257 const run_ios_surface_tests = b.addRunArtifact(ios_surface_tests);
12061258 const run_ios_conformance_tests = b.addRunArtifact(ios_conformance_tests);
1259 const run_ios_module_tests = b.addRunArtifact(ios_module_tests);
12071260 const run_menubar_tests = b.addRunArtifact(menubar_tests);
12081261 const run_components_tests = b.addRunArtifact(components_tests);
12091262 const run_gpu_tests = b.addRunArtifact(gpu_tests);
@@ -1339,6 +1392,7 @@ pub fn build(b: *std.Build) void {
13391392 // touches no platform types, so it runs everywhere.
13401393 if (target.result.os.tag.isDarwin()) {
13411394 test_step.dependOn(&run_ios_surface_tests.step);
1395 test_step.dependOn(&run_ios_module_tests.step);
13421396 }
13431397 test_step.dependOn(&run_ios_conformance_tests.step);
13441398 test_step.dependOn(&run_menubar_tests.step);
@@ -1422,6 +1476,7 @@ pub fn build(b: *std.Build) void {
14221476 const test_ios_step = b.step("test:ios", "Run the iOS surface gate");
14231477 if (target.result.os.tag.isDarwin()) {
14241478 test_ios_step.dependOn(&run_ios_surface_tests.step);
1479 test_ios_step.dependOn(&run_ios_module_tests.step);
14251480 }
14261481 test_ios_step.dependOn(&run_ios_conformance_tests.step);
14271482
@@ -1681,6 +1736,11 @@ pub fn build(b: *std.Build) void {
16811736 },
16821737 }),
16831738 });
1739 // Linked by clang/swiftc, not by Zig, so the compiler-rt intrinsics Zig
1740 // code needs (f128 soft-float from std.json's number parsing, among
1741 // others) must travel inside the archive — no later link step provides
1742 // them, and the failure is undefined symbols in whoever consumes the lib.
1743 ios_device_lib.bundle_compiler_rt = true;
16841744 // Frameworks are linked at the app level in Xcode, not in the static library
16851745 const ios_device_install = b.addInstallArtifact(ios_device_lib, .{});
16861746 build_ios.dependOn(&ios_device_install.step);
@@ -1705,6 +1765,11 @@ pub fn build(b: *std.Build) void {
17051765 },
17061766 }),
17071767 });
1768 // Linked by clang/swiftc, not by Zig, so the compiler-rt intrinsics Zig
1769 // code needs (f128 soft-float from std.json's number parsing, among
1770 // others) must travel inside the archive — no later link step provides
1771 // them, and the failure is undefined symbols in whoever consumes the lib.
1772 ios_sim_arm64_lib.bundle_compiler_rt = true;
17081773 const ios_sim_arm64_install = b.addInstallArtifact(ios_sim_arm64_lib, .{});
17091774 build_ios_simulator.dependOn(&ios_sim_arm64_install.step);
17101775 build_ios_all.dependOn(&ios_sim_arm64_install.step);
@@ -1728,6 +1793,11 @@ pub fn build(b: *std.Build) void {
17281793 },
17291794 }),
17301795 });
1796 // Linked by clang/swiftc, not by Zig, so the compiler-rt intrinsics Zig
1797 // code needs (f128 soft-float from std.json's number parsing, among
1798 // others) must travel inside the archive — no later link step provides
1799 // them, and the failure is undefined symbols in whoever consumes the lib.
1800 ios_sim_x64_lib.bundle_compiler_rt = true;
17311801 const ios_sim_x64_install = b.addInstallArtifact(ios_sim_x64_lib, .{});
17321802 build_ios_simulator.dependOn(&ios_sim_x64_install.step);
17331803 build_ios_all.dependOn(&ios_sim_x64_install.step);
packages/zig/src/bridge_mobile_clipboard.zigadded+602-0
Changes to packages/zig/src/bridge_mobile_clipboard.zig
@@ -0,0 +1,602 @@
1//! The clipboard actions of the `mobile` namespace: `clipboardRead`,
2//! `clipboardWrite`.
3//!
4//! Ported from the two inline arms in `CraftApp.swift`'s dispatcher. The Swift
5//! is eleven lines, and three of its behaviours are deliberately not carried
6//! across:
7//!
8//! 1. **It can leave the caller waiting forever.** Both arms are an `if` with
9//! no `else`, and clipboard is one of the few actions whose promise is built
10//! inline instead of through `_createCallback` — which is the only thing
11//! that installs the 30-second rejection. So a false `enableClipboard`, or a
12//! `text` that is not a string, replies with nothing at all: not resolved,
13//! not rejected, ever. Every path in this file ends in a reply or an error.
14//! 2. **`UIPasteboard.string ?? ""` conflates three different facts.** See
15//! `readClipboard`.
16//! 3. **It reports success for a write it never checked.** See `putString`.
17//!
18//! ## The payload moved, and defaulting it would erase the clipboard
19//!
20//! The Swift-injected `window.craft` posts `text` at the *top level*, a sibling
21//! of `action`. `ios_dispatch` never looks there — it hands a namespace only
22//! the stringified `d`. A page still posting the flat legacy shape therefore
23//! arrives here with `d` absent, which `payloadOf` turns into `{}`. That is why
24//! an absent `text` is `MissingData` rather than a defaulted `""`: defaulting is
25//! precisely how `craft.fs.writeFile` came to write empty files for months, and
26//! here the same mistake would silently *wipe* the user's clipboard and report
27//! success. The page-side call must be
28//! `{t:'mobile', a:'clipboardWrite', d:'{"text":"…"}', i:N}`.
29//!
30//! ## `enableClipboard` is a deliberate behaviour change, recorded here
31//!
32//! The Swift gate reads `config.enableClipboard`, which originates in
33//! TypeScript (`packages/ios/src/index.ts`, defaulting to `false`), is
34//! templated into the Swift `CraftConfig`, and never reaches Zig. Rather than
35//! guess at it or drop it, it is left plumbable: a host can call
36//! `craft_ios_set_clipboard_enabled(false)` at startup. It defaults to
37//! *enabled*, because a default of off would answer every app with
38//! PERMISSION_DENIED until a host that does not exist yet is written. This is a
39//! real change: an app that left `enableClipboard: false` now gets a working
40//! clipboard while the capability object the page reads still says
41//! `clipboard: false`.
42
43const std = ;
44const builtin = ;
45const capabilities = ;
46const bridge_error = ;
47const objc_runtime = ;
48
49const objc = objc_runtime.objc;
50
51/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
52///
53/// `objc_runtime.objc` is an empty struct off Darwin, and a function
54/// *signature* is analysed even when a comptime platform guard makes its body
55/// unreachable. Naming `objc.id` in the signatures below would therefore break
56/// the host build, which is exactly what the guards exist to prevent. It stays
57/// a single optional pointer, never `?objc.id`, because a double optional is
58/// illegal in a `callconv(.c)` type.
59const Id = ?*anyopaque;
60
61/// The action names, spelled exactly as the Swift `case` labels spell them.
62///
63/// `test/ios_conformance_test.zig` matches the two lists by string, so a
64/// tidier spelling here does not read as "migrated" — it reads as "Zig serves
65/// an action the spec does not have", and the Swift arm stays live forever.
66pub const A = struct {
67 pub const clipboard_read = "clipboardRead";
68 pub const clipboard_write = "clipboardWrite";
69};
70
71/// Both `.result`. `clipboardWrite` hands nothing back but is not
72/// fire-and-forget: Swift resolves it with `true`, and `craft.clipboard.write()`
73/// returns a promise the page awaits. Declaring it `.none` would strand that
74/// caller for the whole request timeout. Desktop's `clipboard/writeText` is
75/// `.none` for a reason that does not apply here — `craft-bridge.js` posts it
76/// through `_send`, which resolves on post rather than on an answer.
77pub const capability_actions = [_]capabilities.ActionDecl{
78 .{ .name = A.clipboard_read, .reply = .result },
79 .{ .name = A.clipboard_write, .reply = .result },
80};
81
82/// The two literal replies. `clipboardRead` answers a *bare* JSON string and
83/// `clipboardWrite` a bare `true`; `formatResultJS` interpolates whatever it is
84/// given straight into `__craftBridgeResult(...)`, so neither needs wrapping.
85const reply_true = "true";
86const reply_no_text = "\"\"";
87
88/// NSUTF8StringEncoding, for `-lengthOfBytesUsingEncoding:`.
89const ns_utf8_string_encoding: usize = 4;
90
91/// Whether the app permits clipboard access — the `enableClipboard` gate the
92/// module comment describes.
93///
94/// A plain `bool`, not an atomic: WebKit delivers
95/// `userContentController:didReceiveScriptMessage:` on the main thread and
96/// `ios_dispatch.handleMessage` runs synchronously from there, so every reader
97/// is the main thread, and the only writer is a host doing this once at startup.
98var clipboard_enabled: bool = true;
99
100pub fn setEnabled(value: bool) void {
101 clipboard_enabled = value;
102}
103
104/// Startup hook for a host that wants the Swift gate back, alongside
105/// `craft_ios_deliver_result`. Turning the capability off makes the handlers
106/// *reject* with `PermissionDenied`; it must never reproduce the Swift gate's
107/// actual failure mode, which was silence.
108export fn craft_ios_set_clipboard_enabled(value: bool) callconv(.c) void {
109 clipboard_enabled = value;
110}
111
112pub const ClipboardBridge = struct {
113 allocator: std.mem.Allocator,
114
115 const Self = @This();
116
117 pub fn init(allocator: std.mem.Allocator) Self {
118 return .{ .allocator = allocator };
119 }
120
121 pub fn deinit(_: *Self) void {}
122
123 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
124 if (std.mem.eql(u8, action, A.clipboard_read)) {
125 try self.readClipboard();
126 } else if (std.mem.eql(u8, action, A.clipboard_write)) {
127 try self.writeClipboard(data);
128 } else {
129 return bridge_error.BridgeError.UnknownAction;
130 }
131 }
132
133 /// Read the pasteboard, and say *which* of the "no text" cases happened.
134 ///
135 /// The reply is a bare JSON string — `"hello"`, not `{"text":"hello"}`.
136 /// That is what the page consumes: `test-bridges.html` does
137 /// `'Clipboard: ' + await craft.clipboard.read()`, and an object reply
138 /// renders as `[object Object]`. It diverges from desktop
139 /// `clipboard/readText`, which answers `{"text":…}`; unifying the two is a
140 /// contract change the page has to be rewritten for, so it is not made here
141 /// by accident.
142 ///
143 /// `-hasStrings` is asked first because `-string` on its own cannot tell an
144 /// empty pasteboard from a refusal. On iOS 16+ reading contents another app
145 /// wrote presents a system confirmation, and a denial yields nil with no
146 /// API that reports why. So: no strings at all is answered `""`, which is
147 /// true; strings present but `-string` nil is `PermissionDenied`, because
148 /// something was there and we were not given it. Swift answered `""` to
149 /// both, and to a pasteboard holding an image.
150 ///
151 /// **This action can put an alert on the screen.** A page that calls it on
152 /// load shows the user an unexplained paste prompt. Two things I could not
153 /// verify and that want confirming on a device (the simulator never prompts,
154 /// because a same-app write never does): that `-hasStrings` is itself
155 /// prompt-free, and whether `-string` blocks until the user answers — if it
156 /// does, it blocks this whole synchronous dispatch, and the page's
157 /// 30-second request timeout can fire before the tap.
158 fn readClipboard(self: *Self) !void {
159 if (!clipboard_enabled) return bridge_error.BridgeError.PermissionDenied;
160
161 const pasteboard = try generalPasteboard();
162 if (!try hasStrings(pasteboard)) {
163 bridge_error.sendResultToJS(self.allocator, A.clipboard_read, reply_no_text);
164 return;
165 }
166
167 const text = try pasteboardString(pasteboard) orelse
168 return bridge_error.BridgeError.PermissionDenied;
169
170 const json = try quoteAsJsonString(self.allocator, text);
171 defer self.allocator.free(json);
172 bridge_error.sendResultToJS(self.allocator, A.clipboard_read, json);
173 }
174
175 /// Write `text` to the pasteboard. Requires the field; does not invent it.
176 ///
177 /// An explicitly-sent empty `text` is written and acknowledged. Clearing the
178 /// clipboard is a legitimate request, and `bridge_clipboard.zig`'s
179 /// `if (text.len == 0) return;` is a silent drop the caller cannot tell from
180 /// a success. Only an *absent* `text` is an error.
181 ///
182 /// A non-string `text` is `InvalidParameter`, not coerced. The legacy JS
183 /// posts whatever the caller passed without a `String(...)` — unlike
184 /// `craft-bridge.js:565` — so a page handing in a number reaches here, and
185 /// guessing what it meant would put a stringified guess on the user's
186 /// clipboard.
187 fn writeClipboard(self: *Self, data: []const u8) !void {
188 if (!clipboard_enabled) return bridge_error.BridgeError.PermissionDenied;
189
190 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch |err| switch (err) {
191 // An allocation failure is not a malformed payload, and telling the
192 // page INVALID_JSON about its own perfectly good JSON sends whoever
193 // debugs it to the wrong side of the bridge.
194 error.OutOfMemory => return err,
195 else => return bridge_error.BridgeError.InvalidJSON,
196 };
197 defer parsed.deinit();
198
199 const text = try requiredText(parsed.value);
200
201 // `putString` builds its NSString with `+stringWithUTF8String:`, which
202 // reads a NUL-terminated C string and stops at the first zero byte. A
203 // `text` carrying one — JSON spells it `\u0000` and std.json decodes
204 // it to a real byte — would reach the pasteboard truncated, bump the
205 // change count, pass the verification below, and be acknowledged
206 // `true`: a write reported as successful that stored something the
207 // caller never asked for. Refusing is the honest answer. Writing it
208 // properly is possible (`+stringWithBytes:length:encoding:` takes an
209 // explicit length) and is a deliberate non-goal here, because that is a
210 // new capability rather than the removal of a false report.
211 if (std.mem.indexOfScalar(u8, text, 0) != null) {
212 return bridge_error.BridgeError.InvalidParameter;
213 }
214
215 try putString(self.allocator, text);
216 bridge_error.sendResultToJS(self.allocator, A.clipboard_write, reply_true);
217 }
218};
219
220/// The `text` field, or the reason it cannot be used. Pure, so the host tests
221/// can pin the three outcomes that the Swift `as? String` collapsed into one
222/// silent fall-through.
223fn requiredText(payload: std.json.Value) ![]const u8 {
224 const object = switch (payload) {
225 .object => |o| o,
226 else => return bridge_error.BridgeError.InvalidJSON,
227 };
228 const field = object.get("text") orelse return bridge_error.BridgeError.MissingData;
229 return switch (field) {
230 .string => |s| s,
231 else => bridge_error.BridgeError.InvalidParameter,
232 };
233}
234
235/// Render `s` as a complete JSON string literal, quotes included.
236///
237/// `bridge_error.appendJsonEscaped` rather than the hand-rolled loop in
238/// `bridge_clipboard.zig:200`, which misses every control byte below 0x20.
239/// Clipboard contents are arbitrary user bytes and this string is interpolated
240/// into JavaScript that `evaluateJavaScript:` then parses as source.
241///
242/// Allocated rather than built in a stack buffer: unlike a device description,
243/// clipboard text has no ceiling, and a fixed buffer would truncate the paste
244/// into a syntax error in the page.
245fn quoteAsJsonString(allocator: std.mem.Allocator, s: []const u8) ![]u8 {
246 var out: std.ArrayListUnmanaged(u8) = .empty;
247 errdefer out.deinit(allocator);
248
249 try out.append(allocator, '"');
250 try bridge_error.appendJsonEscaped(allocator, &out, s);
251 try out.append(allocator, '"');
252
253 return out.toOwnedSlice(allocator);
254}
255
256/// `+[UIPasteboard generalPasteboard]`.
257///
258/// Both nulls are real conditions and neither is skipped: `objc_getClass`
259/// answers null when UIKit is not in the process, and `generalPasteboard`
260/// answers nil in an app extension, where `UIPasteboard` is unavailable
261/// outright.
262fn generalPasteboard() !Id {
263 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
264
265 const UIPasteboard = objc.objc_getClass("UIPasteboard") orelse return error.ClassNotFound;
266 const sel = objc.sel_registerName("generalPasteboard") orelse return error.SelectorNotFound;
267 const pasteboard = objc.msgSendId(UIPasteboard, sel);
268 if (pasteboard == null) return error.NoPasteboard;
269 return pasteboard;
270}
271
272/// `-[UIPasteboard hasStrings]` — whether there is any text to ask for.
273fn hasStrings(pasteboard: Id) !bool {
274 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
275
276 const sel = objc.sel_registerName("hasStrings") orelse return error.SelectorNotFound;
277 return objc.msgSendBool(pasteboard, sel);
278}
279
280/// `-[UIPasteboard changeCount]`.
281///
282/// No helper for this in `objc_runtime.zig`, which stops at id/bool/stret, so
283/// the cast is local. `NSInteger` is 64-bit on every supported iOS target,
284/// hence `isize`.
285fn changeCount(pasteboard: Id) !isize {
286 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
287
288 const sel = objc.sel_registerName("changeCount") orelse return error.SelectorNotFound;
289 const Fn = *const fn (objc.id, objc.SEL) callconv(.c) isize;
290 const func: Fn = @ptrCast(&objc.objc_msgSend);
291 return func(pasteboard, sel);
292}
293
294/// `-[UIPasteboard string]`, borrowed as UTF-8. Null means the pasteboard
295/// returned nil — which the caller, not this function, decides the meaning of.
296///
297/// The bytes belong to the autoreleased NSString and live until the pool drains
298/// at the end of this run-loop turn; every caller here copies or compares
299/// before returning.
300fn pasteboardString(pasteboard: Id) !?[]const u8 {
301 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
302
303 const sel = objc.sel_registerName("string") orelse return error.SelectorNotFound;
304 const ns = objc.msgSendId(pasteboard, sel);
305 if (ns == null) return null;
306 return try nsStringUTF8(ns);
307}
308
309/// The UTF-8 bytes of an NSString.
310///
311/// The length comes from `-lengthOfBytesUsingEncoding:` rather than from
312/// scanning for the terminator: an NSString may legitimately contain U+0000,
313/// and `std.mem.span` would truncate there without saying so — the same quiet
314/// data loss as a dropped payload field, on bytes the user chose. A zero length
315/// means empty or not representable as UTF-8, and both answer `""`.
316fn nsStringUTF8(ns: Id) ![]const u8 {
317 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
318
319 const utf8 = objc.getNSStringUTF8(ns) orelse return error.NilString;
320
321 const sel_len = objc.sel_registerName("lengthOfBytesUsingEncoding:") orelse
322 return error.SelectorNotFound;
323 const Fn = *const fn (objc.id, objc.SEL, usize) callconv(.c) usize;
324 const func: Fn = @ptrCast(&objc.objc_msgSend);
325 const len = func(ns, sel_len, ns_utf8_string_encoding);
326
327 return utf8[0..len];
328}
329
330/// `-[UIPasteboard setString:]`, then evidence that it landed.
331///
332/// `setString:` returns void, so "the call did not crash" is not evidence, and
333/// replying `true` on that alone is the fabricated success this migration
334/// exists to remove. `-changeCount` is the one real observation available: a
335/// bump means the pasteboard genuinely changed.
336///
337/// A stationary count is not immediately a failure, though. I could not confirm
338/// that writing text identical to the current contents still bumps the count,
339/// and rejecting a correct "copy pressed twice" would be its own false report —
340/// so no bump falls back to reading the contents back and comparing bytes. Only
341/// *unchanged count and contents that are not what we asked for* is reported as
342/// `NativeCallFailed`. A nil read-back counts as "there is no text on the
343/// pasteboard", which matches a request to write an empty string and nothing
344/// else.
345///
346/// The fallback read can itself raise the iOS 16+ paste prompt, in the one case
347/// where it runs: the write appears not to have landed, so another app may
348/// still own the contents. That is rare enough to be worth the certainty, and
349/// the alternative is answering with a guess.
350fn putString(allocator: std.mem.Allocator, text: []const u8) !void {
351 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
352
353 const pasteboard = try generalPasteboard();
354 const before = try changeCount(pasteboard);
355
356 // Autoreleased by `+stringWithUTF8String:`; releasing it here would
357 // over-release something the pasteboard has retained.
358 //
359 // Nil is a real answer, not a can't-happen: `+stringWithUTF8String:` returns
360 // nil for bytes that are not valid UTF-8, and `text` comes from a JSON
361 // string that may hold an unpaired surrogate. Passing that nil on would send
362 // `-[UIPasteboard setString:nil]`, which raises an Objective-C exception
363 // that Zig has no way to catch — the process dies with the page's promise
364 // still pending, which is a worse answer than any error code.
365 const ns_text = try objc.createNSString(text, allocator) orelse
366 return bridge_error.BridgeError.InvalidParameter;
367 const sel_set = objc.sel_registerName("setString:") orelse return error.SelectorNotFound;
368 objc.msgSendVoid1(pasteboard, sel_set, ns_text);
369
370 if (try changeCount(pasteboard) != before) return;
371
372 const current = try pasteboardString(pasteboard);
373 const landed = if (current) |c| std.mem.eql(u8, c, text) else text.len == 0;
374 if (!landed) return bridge_error.BridgeError.NativeCallFailed;
375}
376
377const testing = std.testing;
378
379test "the declared actions are the ones the handler serves" {
380 try testing.expectEqual(@as(usize, 2), capability_actions.len);
381 try testing.expectEqualStrings(A.clipboard_read, capability_actions[0].name);
382 try testing.expectEqualStrings(A.clipboard_write, capability_actions[1].name);
383
384 // `.result` for both, including the write. A `.none` write would let a
385 // page's `await craft.clipboard.write(x)` sit until the request timeout.
386 for (capability_actions) |decl| {
387 try testing.expectEqual(capabilities.Reply.result, decl.reply);
388 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
389 }
390}
391
392test "the action names match the Swift case labels exactly" {
393 // The conformance ratchet compares these strings against the labels in
394 // `CraftApp.swift`. A prettier spelling here would leave the Swift arm live
395 // and register the migration as having served an action nobody asked for.
396 try testing.expectEqualStrings("clipboardRead", A.clipboard_read);
397 try testing.expectEqualStrings("clipboardWrite", A.clipboard_write);
398}
399
400test "every declared action dispatches to something" {
401 // It may well fail for want of UIKit on the host — that is fine and is not
402 // what this asserts. What it rules out is a name in the table that
403 // `handleMessage` does not compare against, which is the drift the
404 // capabilities mechanism exists to prevent.
405 var bridge = ClipboardBridge.init(testing.allocator);
406 defer bridge.deinit();
407
408 for (capability_actions) |decl| {
409 bridge.handleMessage(decl.name, "{}") catch |err| {
410 try testing.expect(err != bridge_error.BridgeError.UnknownAction);
411 };
412 }
413}
414
415test "an action the namespace does not serve is reported, not ignored" {
416 var bridge = ClipboardBridge.init(testing.allocator);
417 defer bridge.deinit();
418
419 try testing.expectError(
420 bridge_error.BridgeError.UnknownAction,
421 bridge.handleMessage("clipboardClear", "{}"),
422 );
423 // Near-misses too: the desktop spelling is a different action on a
424 // different namespace and must not be quietly accepted here.
425 try testing.expectError(
426 bridge_error.BridgeError.UnknownAction,
427 bridge.handleMessage("writeText", "{\"text\":\"x\"}"),
428 );
429}
430
431test "an absent text field is refused rather than defaulted to empty" {
432 // The single highest-risk case in this migration: a page still posting the
433 // flat legacy shape (`text` beside `action`, no `d`) arrives as `{}`. A
434 // default would wipe the user's clipboard and report success.
435 const allocator = testing.allocator;
436 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{}", .{});
437 defer parsed.deinit();
438
439 try testing.expectError(bridge_error.BridgeError.MissingData, requiredText(parsed.value));
440}
441
442test "a legacy top-level text field does not count as the payload" {
443 const allocator = testing.allocator;
444 var parsed = try std.json.parseFromSlice(
445 std.json.Value,
446 allocator,
447 "{\"action\":\"clipboardWrite\",\"callbackId\":\"cb_1\"}",
448 .{},
449 );
450 defer parsed.deinit();
451
452 try testing.expectError(bridge_error.BridgeError.MissingData, requiredText(parsed.value));
453}
454
455test "text is read from the field the page actually sends" {
456 const allocator = testing.allocator;
457 var parsed = try std.json.parseFromSlice(
458 std.json.Value,
459 allocator,
460 "{\"text\":\"hello \\\"world\\\"\"}",
461 .{},
462 );
463 defer parsed.deinit();
464
465 try testing.expectEqualStrings("hello \"world\"", try requiredText(parsed.value));
466}
467
468test "an explicitly empty text is a value, not a missing field" {
469 // Clearing the clipboard is a legitimate request. `bridge_clipboard.zig`
470 // returns early on a zero-length string and acknowledges nothing, which the
471 // caller cannot tell from success.
472 const allocator = testing.allocator;
473 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"text\":\"\"}", .{});
474 defer parsed.deinit();
475
476 try testing.expectEqualStrings("", try requiredText(parsed.value));
477}
478
479test "a non-string text is refused rather than coerced" {
480 // The legacy JS posts whatever the caller passed, with no `String(...)`.
481 const allocator = testing.allocator;
482 for ([_][]const u8{
483 "{\"text\":42}",
484 "{\"text\":null}",
485 "{\"text\":true}",
486 "{\"text\":{\"a\":1}}",
487 "{\"text\":[\"a\"]}",
488 }) |payload| {
489 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{});
490 defer parsed.deinit();
491 try testing.expectError(bridge_error.BridgeError.InvalidParameter, requiredText(parsed.value));
492 }
493}
494
495test "a text carrying a NUL is refused rather than written truncated" {
496 // `objc.createNSString` goes through `+stringWithUTF8String:`, which stops
497 // at the first zero byte. Writing the prefix would still bump the change
498 // count and so would be acknowledged `true` — the caller told a write
499 // succeeded that stored something it never asked for. This runs on every
500 // host because the refusal happens before any UIKit call.
501 var bridge = ClipboardBridge.init(testing.allocator);
502 defer bridge.deinit();
503
504 // `\u0000` is what `JSON.stringify` emits for a NUL, and std.json decodes
505 // it back to one, so this is the exact payload a page can produce.
506 try testing.expectError(
507 bridge_error.BridgeError.InvalidParameter,
508 bridge.handleMessage(A.clipboard_write, "{\"text\":\"a\\u0000b\"}"),
509 );
510
511 // Guard against the check being written as `startsWith`/`endsWith` or as a
512 // length test: the byte is refused wherever it sits, including alone.
513 try testing.expectError(
514 bridge_error.BridgeError.InvalidParameter,
515 bridge.handleMessage(A.clipboard_write, "{\"text\":\"\\u0000\"}"),
516 );
517 try testing.expectError(
518 bridge_error.BridgeError.InvalidParameter,
519 bridge.handleMessage(A.clipboard_write, "{\"text\":\"trailing\\u0000\"}"),
520 );
521}
522
523test "a payload that is not an object is refused" {
524 const allocator = testing.allocator;
525 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "\"hello\"", .{});
526 defer parsed.deinit();
527
528 try testing.expectError(bridge_error.BridgeError.InvalidJSON, requiredText(parsed.value));
529}
530
531test "malformed payload JSON is reported as such, not as a missing field" {
532 var bridge = ClipboardBridge.init(testing.allocator);
533 defer bridge.deinit();
534
535 try testing.expectError(
536 bridge_error.BridgeError.InvalidJSON,
537 bridge.handleMessage(A.clipboard_write, "{\"text\":"),
538 );
539}
540
541test "the read reply is a bare JSON string, not an object" {
542 // `test-bridges.html` concatenates the result: `'Clipboard: ' + result`.
543 // An object would render as `[object Object]`.
544 const allocator = testing.allocator;
545 const json = try quoteAsJsonString(allocator, "hello");
546 defer allocator.free(json);
547 try testing.expectEqualStrings("\"hello\"", json);
548}
549
550test "read replies survive quotes, backslashes, newlines, and control bytes" {
551 // Clipboard contents are arbitrary user bytes and this lands inside
552 // JavaScript source. The escape loop in `bridge_clipboard.zig` passes
553 // control bytes through untouched, which produces invalid JSON.
554 const allocator = testing.allocator;
555 const json = try quoteAsJsonString(allocator, "a\"b\\c\nd\te\x01f");
556 defer allocator.free(json);
557 try testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\te\\u0001f\"", json);
558
559 // And it is still parseable as the string it started as.
560 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
561 defer parsed.deinit();
562 try testing.expectEqualStrings("a\"b\\c\nd\te\x01f", parsed.value.string);
563}
564
565test "an empty clipboard is reported as an empty string, and it parses" {
566 const allocator = testing.allocator;
567 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, reply_no_text, .{});
568 defer parsed.deinit();
569 try testing.expectEqualStrings("", parsed.value.string);
570}
571
572test "the write reply is bare true, which is what the page resolves with" {
573 const allocator = testing.allocator;
574 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, reply_true, .{});
575 defer parsed.deinit();
576 try testing.expect(parsed.value.bool);
577}
578
579test "a disabled clipboard rejects both actions rather than falling silent" {
580 // The Swift gate's failure mode was a promise that never settled. Whatever
581 // is decided about `enableClipboard`, that must not come back.
582 var bridge = ClipboardBridge.init(testing.allocator);
583 defer bridge.deinit();
584
585 setEnabled(false);
586 defer setEnabled(true);
587
588 try testing.expectError(
589 bridge_error.BridgeError.PermissionDenied,
590 bridge.handleMessage(A.clipboard_read, "{}"),
591 );
592 try testing.expectError(
593 bridge_error.BridgeError.PermissionDenied,
594 bridge.handleMessage(A.clipboard_write, "{\"text\":\"x\"}"),
595 );
596}
597
598test "UIKit work is refused explicitly off Darwin instead of being skipped" {
599 if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
600 try testing.expectError(error.UnsupportedPlatform, generalPasteboard());
601 try testing.expectError(error.UnsupportedPlatform, putString(testing.allocator, "x"));
602}
packages/zig/src/bridge_mobile_device.zigadded+642-0
Changes to packages/zig/src/bridge_mobile_device.zig
@@ -0,0 +1,642 @@
1const std = ;
2const builtin = ;
3const capabilities = ;
4const bridge_error = ;
5const bridge_log = ;
6const objc_runtime = ;
7
8const objc = objc_runtime.objc;
9
10/// What a page can ask about the process it is running inside: how much memory
11/// it holds, whether it is foregrounded, whether it has a network, and a way to
12/// get a line into the host's log.
13///
14/// The four are grouped because they share a property the migration cares
15/// about: each one has an answer that can be *checked*. `usedMB` moves when the
16/// app allocates, `getAppState` flips when the home button is pressed, a log
17/// line appears in `simctl launch --console-pty` or it does not. None of them
18/// can be satisfied by a stub that resolves and means nothing — which is what
19/// the fallback path did before any of this existed, and what nine deleted
20/// Android handlers did after it.
21///
22/// `getNetworkStatus` is the one that cannot be answered honestly from Zig in
23/// this build, and it is declared `.unavailable` rather than guessed at. See
24/// `network_status_reason`.
25pub const A = struct {
26 pub const get_memory_usage = "getMemoryUsage";
27 pub const get_app_state = "getAppState";
28 pub const get_network_status = "getNetworkStatus";
29 pub const log = "log";
30};
31
32/// Why `getNetworkStatus` is reachable and does not work.
33///
34/// There is no synchronous, non-deprecated iOS API that answers "is there a
35/// network". `NWPathMonitor` is the one Swift uses and the one to port, but it
36/// needs `-framework Network` linked into every consuming app — the iOS Zig
37/// target is a static library, so an unlinked framework is an undefined symbol
38/// at *app* link time, not a Zig build failure — plus a hand-built Objective-C
39/// block and a mutex-guarded cache, because the path handler runs on a
40/// background queue where `evaluateJavaScript:` is illegal and
41/// `request_context` (thread-local) is empty.
42///
43/// `SCNetworkReachability` would avoid the block and is deprecated since 17.4,
44/// cannot see wired ethernet, and on the simulator reports the *host Mac's*
45/// reachability. `getifaddrs` can say which interfaces are up but not whether
46/// anything is reachable, so it cannot fill a field named `isConnected`.
47///
48/// The alternative to this declaration is not "a working handler", it is
49/// `{"isConnected":true,"type":"unknown"}` — which is both what Swift returns
50/// before its first path callback lands and what `bridge_network.zig:81`
51/// hardcodes. A caller cannot tell that apart from a real reading, which makes
52/// it strictly worse than an error.
53pub const network_status_reason =
54 "NWPathMonitor needs -framework Network linked into the app; no synchronous " ++
55 "iOS API can answer isConnected without fabricating it";
56
57pub const capability_actions = [_]capabilities.ActionDecl{
58 .{ .name = A.get_memory_usage, .reply = .result },
59 .{ .name = A.get_app_state, .reply = .result },
60 // `.reply` stays `.result` because that is the contract the page's promise
61 // is written against; `.status` is the field that carries the bad news. A
62 // `.none` here would tell an app this action is fire-and-forget, which is a
63 // different and equally wrong claim.
64 .{
65 .name = A.get_network_status,
66 .reply = .result,
67 .status = .unavailable,
68 .reason = network_status_reason,
69 },
70 .{ .name = A.log, .reply = .result },
71};
72
73/// Which handler an action selects, or null for one this namespace does not
74/// serve.
75///
76/// Split out from `handleMessage` so the table-versus-dispatch agreement can be
77/// asserted on a host, where `task_info` would answer about the test runner and
78/// `UIApplication` does not exist at all. A test that had to *call* the
79/// handlers to discover which names route could only run on a device, which is
80/// to say it would never run.
81const Route = enum { memory_usage, app_state, network_status, log };
82
83fn routeFor(action: []const u8) ?Route {
84 if (std.mem.eql(u8, action, A.get_memory_usage)) return .memory_usage;
85 if (std.mem.eql(u8, action, A.get_app_state)) return .app_state;
86 if (std.mem.eql(u8, action, A.get_network_status)) return .network_status;
87 if (std.mem.eql(u8, action, A.log)) return .log;
88 return null;
89}
90
91pub const DeviceBridge = struct {
92 allocator: std.mem.Allocator,
93
94 const Self = @This();
95
96 pub fn init(allocator: std.mem.Allocator) Self {
97 return .{ .allocator = allocator };
98 }
99
100 pub fn deinit(_: *Self) void {}
101
102 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
103 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
104 // Switched exhaustively rather than with an `else`, so adding a `Route`
105 // without a handler is a compile error instead of a silently
106 // unreachable action.
107 return switch (route) {
108 .memory_usage => self.getMemoryUsage(),
109 .app_state => self.getAppState(),
110 .network_status => getNetworkStatus(),
111 .log => self.log(data),
112 };
113 }
114
115 fn getMemoryUsage(self: *Self) !void {
116 const info = try readTaskBasicInfo();
117 var buf: [160]u8 = undefined;
118 const json = try formatMemoryUsage(&buf, info);
119 bridge_error.sendResultToJS(self.allocator, A.get_memory_usage, json);
120 }
121
122 fn getAppState(self: *Self) !void {
123 const json = try appStateJson(try readApplicationState());
124 bridge_error.sendResultToJS(self.allocator, A.get_app_state, json);
125 }
126
127 /// Refuse, with the reason the capability table already carries.
128 ///
129 /// `PlatformNotSupported` rather than `NativeCallFailed`: no native call
130 /// was attempted, and saying one failed would send an app looking for a
131 /// device problem that is really a missing `-framework Network`.
132 ///
133 /// A dispatcher note, because this error is load-bearing on the way out:
134 /// `ios_dispatch.route` hands an action to the Swift shim only when a Zig
135 /// handler returns `UnknownAction`. `DeviceBridge` is in that router's
136 /// `mobile_bridges`, so this refusal takes `getNetworkStatus` away from the
137 /// shim, which does answer it — with `isConnected` hardcoded `true` and
138 /// `type` `"unknown"` (CraftApp.swift:399) until `NWPathMonitor`'s first
139 /// callback lands, and correctly after. That is the whole trade: an error
140 /// always, against a fabricated reading in the startup window and a real
141 /// one afterwards. `bridge_mobile_display.zig` makes the same call for
142 /// `lockOrientation`. Undoing it means linking Network and making this
143 /// live, or dropping the action from `A` so the shim keeps serving it.
144 fn getNetworkStatus() !void {
145 return bridge_error.BridgeError.PlatformNotSupported;
146 }
147
148 /// The page's own log line, forwarded to the host sink.
149 ///
150 /// Delegated to `bridge_log` rather than reimplemented: it already owns the
151 /// level table, the case-insensitive matching that keeps an unrecognised
152 /// level audible instead of dropped, the `[page]` tag, and the 1024-byte
153 /// clamp. A second copy here is the one that would drift.
154 ///
155 /// It also replies `{"ok":true}` under this same action name, which is why
156 /// the table says `.result`. Swift's `case "log"` replies with nothing and
157 /// `craft-bridge.js`'s `_send` registers no pending call, so the reply is
158 /// dropped at the page — but the declaration has to describe what the code
159 /// does, not what the page happens to do with it.
160 ///
161 /// There is exactly one payload shape to accept: `{level, message}` inside
162 /// the envelope's `d`. The Swift template's `craft.log(msg)` posts a flat
163 /// `{action:'log', message:msg}` with no `t` and no `d` at all, so it never
164 /// reaches a handler — `ios_dispatch.handleMessage` rejects it at
165 /// `MissingType`. Treating that as a second shape to support here would
166 /// mean reading `message` off the envelope, which this bridge never sees.
167 ///
168 /// `bridge_log` defaults a missing `message` to `""`, so a payload without
169 /// one records an empty `[page]` line and still acks. That is the desktop
170 /// contract, shared deliberately rather than forked; `craft-bridge.js`
171 /// always sends `String(m)`, so only a hand-rolled `_post` can hit it.
172 fn log(self: *Self, data: []const u8) !void {
173 var host = bridge_log.LogBridge.init(self.allocator);
174 defer host.deinit();
175 try host.handleMessage(A.log, data);
176 }
177};
178
179// =============================================================================
180// getMemoryUsage
181// =============================================================================
182
183/// `mach_task_basic_info`, from `<mach/task_info.h>`.
184///
185/// Written out rather than `@cImport`ed because the iOS static library builds
186/// without an SDK sysroot. The layout is load-bearing in a way that is easy to
187/// miss: `task_info` validates the caller's element count against the flavour's
188/// own *before* writing anything, so a struct that is one field short does not
189/// come back truncated — it comes back `KERN_INVALID_ARGUMENT` with the buffer
190/// untouched. `system_enhancements.zig:932` passes 10 for this 48-byte struct
191/// and has therefore reported zero bytes used on every call it has ever made.
192const MachTimeValue = extern struct {
193 seconds: i32,
194 microseconds: i32,
195};
196
197const MachTaskBasicInfo = extern struct {
198 virtual_size: u64,
199 resident_size: u64,
200 resident_size_max: u64,
201 user_time: MachTimeValue,
202 system_time: MachTimeValue,
203 policy: i32,
204 suspend_count: i32,
205};
206
207const MACH_TASK_BASIC_INFO: c_uint = 20;
208
209/// Counted in 4-byte `integer_t` units, derived from the struct rather than
210/// written as 12, so a field added or removed above cannot leave the count
211/// behind.
212const MACH_TASK_BASIC_INFO_COUNT: c_uint = @sizeOf(MachTaskBasicInfo) / @sizeOf(i32);
213
214const KERN_SUCCESS: c_int = 0;
215
216const mach = struct {
217 /// `mach_task_self()` is a macro over this variable in `<mach/mach_init.h>`.
218 /// Reading the variable rather than calling the function form keeps this
219 /// off a libSystem symbol that only exists because the macro predates it.
220 extern "c" var mach_task_self_: c_uint;
221
222 /// `kern_return_t task_info(task_name_t, task_flavor_t, task_info_t, mach_msg_type_number_t *)`.
223 /// `task_info_t` is `integer_t *`; the flavour decides what is actually
224 /// written there, which is why the count argument exists.
225 extern "c" fn task_info(
226 target_task: c_uint,
227 flavor: c_uint,
228 task_info_out: *anyopaque,
229 task_info_count: *c_uint,
230 ) c_int;
231};
232
233/// Ask the kernel how much memory this process holds.
234///
235/// This is mach, not UIKit — there is no Objective-C on this path and no main
236/// thread requirement.
237///
238/// Deliberately not `NSProcessInfo.processInfo.physicalMemory`: that is total
239/// device RAM, not process usage, and substituting it would put a number in
240/// `usedMB` that is right by units and wrong by three orders of magnitude.
241fn readTaskBasicInfo() !MachTaskBasicInfo {
242 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
243
244 // Zeroed rather than `undefined`: if a future flavour revision fills fewer
245 // fields than this struct has, the tail reads as zero instead of as stack
246 // garbage that would look like a plausible byte count.
247 var info: MachTaskBasicInfo = std.mem.zeroes(MachTaskBasicInfo);
248 var count: c_uint = MACH_TASK_BASIC_INFO_COUNT;
249
250 const kr = mach.task_info(
251 mach.mach_task_self_,
252 MACH_TASK_BASIC_INFO,
253 @ptrCast(&info),
254 &count,
255 );
256 // Swift answers a failure with `["usedMB": 0, "error": ...]`, which
257 // *resolves* the page's promise carrying a fabricated zero. An error is the
258 // only answer that lets a caller tell "this app uses no memory" from "the
259 // reading could not be taken".
260 if (kr != KERN_SUCCESS) return bridge_error.BridgeError.NativeCallFailed;
261
262 return info;
263}
264
265/// `resident_size` in hundredths of a megabyte, rounded half up.
266///
267/// Integer arithmetic rather than `round(usedMB * 100) / 100` on a float, so
268/// the rounding is a property of this function — assertable in a host test —
269/// rather than of whatever the formatter does at the second decimal place.
270/// 1 MiB is 1048576 bytes and half of it is 524288, which is the bias added
271/// before the divide.
272fn residentHundredthsMB(resident_size: u64) u64 {
273 // A resident set would have to reach 1.8e17 bytes for this multiply to
274 // overflow u64, which is about six orders of magnitude past any device.
275 return (resident_size * 100 + 524288) / 1048576;
276}
277
278/// Render the reading as the JSON the page receives.
279///
280/// `usedMB` is the only field anything reads by name today
281/// (`test-bridges.html`); `startProfiling`/`stopProfiling` take the whole
282/// object opaquely and diff it. The raw byte counts are kept anyway because
283/// they are what was actually measured — `usedMB` is a rounded view of
284/// `residentSize`, and a caller that wants the exact number should not have to
285/// un-round it.
286fn formatMemoryUsage(buf: []u8, info: MachTaskBasicInfo) ![]const u8 {
287 const hundredths = residentHundredthsMB(info.resident_size);
288 return std.fmt.bufPrint(
289 buf,
290 "",
291 .{ hundredths / 100, hundredths % 100, info.resident_size, info.virtual_size },
292 );
293}
294
295// =============================================================================
296// getAppState
297// =============================================================================
298
299/// `UIApplicationState`, from `UIKit/UIApplication.h`.
300const UIApplicationStateActive: c_long = 0;
301const UIApplicationStateInactive: c_long = 1;
302const UIApplicationStateBackground: c_long = 2;
303
304/// The reply for a `UIApplicationState` raw value.
305///
306/// A bare JSON string, not an object: `craft.d.ts` declares
307/// `getAppState(): 'active' | 'inactive' | 'background'`, and Swift resolves
308/// the callback with the bare string too. Wrapping it in `{"state":...}` would
309/// be a nicer shape and would break every existing caller.
310///
311/// An unrecognised value is an error rather than a default. Swift's ternary
312/// (`active ? ... : (background ? ... : "inactive")`) folds anything new into
313/// `"inactive"`, so a fourth state added by a future iOS would be reported as a
314/// real, wrong reading instead of as something craft does not understand.
315fn appStateJson(state: c_long) ![]const u8 {
316 return switch (state) {
317 UIApplicationStateActive => "\"active\"",
318 UIApplicationStateInactive => "\"inactive\"",
319 UIApplicationStateBackground => "\"background\"",
320 else => error.UnknownApplicationState,
321 };
322}
323
324/// `[[UIApplication sharedApplication] applicationState]`.
325///
326/// Called straight from the dispatcher and not hopped onto a queue, which is
327/// the non-obvious part: `applicationState` is main-thread-only, and this is
328/// already on it. `ios.zig:craftDidReceiveScriptMessage` is a
329/// `WKScriptMessageHandler` callback, WebKit delivers those on the main thread,
330/// and `ios_dispatch.handleMessage` runs synchronously from there. A
331/// `dispatch_async` here would be the bug, not the fix: it would move the reply
332/// off the frame that holds this call's id, and `request_context` is
333/// thread-local.
334fn readApplicationState() !c_long {
335 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
336
337 const UIApplication = objc.objc_getClass("UIApplication") orelse return error.ClassNotFound;
338 const sel_shared = objc.sel_registerName("sharedApplication") orelse return error.SelectorNotFound;
339
340 // Nil here is a real condition, not a failure to check for form's sake: an
341 // app extension has no shared application, and neither does a process that
342 // has not reached `UIApplicationMain` yet (`ios.zig:683` records the same).
343 // Reporting "active" in either case would be a reading nobody took.
344 const app = objc.msgSendId(UIApplication, sel_shared) orelse return error.NoSharedApplication;
345
346 const sel_state = objc.sel_registerName("applicationState") orelse return error.SelectorNotFound;
347 // `UIApplicationState` is an `NSInteger`, so `c_long` on every 64-bit Apple
348 // target. A narrower return type here would not fail to compile; it would
349 // read the low half of the register and be right by luck for 0, 1 and 2.
350 const Fn = *const fn (objc.id, objc.SEL) callconv(.c) c_long;
351 const func: Fn = @ptrCast(&objc.objc_msgSend);
352 return func(app, sel_state);
353}
354
355// =============================================================================
356// Tests
357//
358// Host-only, and that is the constraint that shaped the code above: the two
359// readings that need a device are behind `readTaskBasicInfo` and
360// `readApplicationState`, and everything that decides what the page sees —
361// routing, rounding, formatting, state mapping — is a pure function beside
362// them.
363// =============================================================================
364
365const testing = std.testing;
366
367test "every declared action is one the dispatcher routes" {
368 // The failure this whole mechanism exists to prevent: a name in the table
369 // that `handleMessage` has never heard of, which the manifest then promises
370 // to an app.
371 for (capability_actions) |decl| {
372 if (routeFor(decl.name) == null) {
373 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
374 return error.DeclaredActionDoesNotRoute;
375 }
376 }
377}
378
379test "every route the dispatcher has is a declared action" {
380 // The other direction. `handleMessage` switches exhaustively over `Route`,
381 // so a route with no handler is a compile error; this is what catches a
382 // route with a handler and no declaration, which would be an action the
383 // page can call and the manifest denies exists.
384 //
385 // Counting alone would not: two table rows naming the same action still
386 // total four while leaving one route undeclared. So each declaration has to
387 // claim a *distinct* route, and every route has to be claimed.
388 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
389 for (capability_actions) |decl| {
390 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
391 const slot = @backingInt(route);
392 if (claimed[slot]) {
393 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
394 return error.TwoDeclarationsShareARoute;
395 }
396 claimed[slot] = true;
397 }
398 for (claimed, 0..) |taken, slot| {
399 if (!taken) {
400 std.debug.print(
401 "route {s} has a handler but no capability_actions row\n",
402 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
403 );
404 return error.RouteNotDeclared;
405 }
406 }
407}
408
409test "an action the namespace does not serve is reported, not ignored" {
410 var bridge = DeviceBridge.init(testing.allocator);
411 defer bridge.deinit();
412
413 try testing.expectError(
414 bridge_error.BridgeError.UnknownAction,
415 bridge.handleMessage("noSuchAction", "{}"),
416 );
417 // Near misses too — casing and namespacing are how a real typo arrives.
418 try testing.expectError(
419 bridge_error.BridgeError.UnknownAction,
420 bridge.handleMessage("getmemoryusage", "{}"),
421 );
422}
423
424test "getNetworkStatus refuses rather than answering" {
425 // The whole point of declaring it. `{"isConnected":true}` would resolve the
426 // caller's promise with something nothing measured.
427 var bridge = DeviceBridge.init(testing.allocator);
428 defer bridge.deinit();
429
430 try testing.expectError(
431 bridge_error.BridgeError.PlatformNotSupported,
432 bridge.handleMessage(A.get_network_status, "{}"),
433 );
434}
435
436test "an unavailable action carries the reason with it" {
437 // `capabilities.ActionDecl` requires it, and the reason is the entire value
438 // of the declaration: "getNetworkStatus is unavailable" sends an app
439 // hunting its own bug, the framework note does not.
440 for (capability_actions) |decl| {
441 if (decl.status == .unavailable) {
442 try testing.expect(decl.reason != null);
443 try testing.expect(decl.reason.?.len > 0);
444 }
445 }
446}
447
448test "the task_info struct is the size the flavour expects" {
449 // 48 bytes, so 12 `integer_t` elements. The count is what `task_info`
450 // validates before it writes anything, and getting it wrong is silent:
451 // `system_enhancements.zig` passes 10 and has returned zeros ever since.
452 try testing.expectEqual(@as(usize, 48), @sizeOf(MachTaskBasicInfo));
453 try testing.expectEqual(@as(c_uint, 12), MACH_TASK_BASIC_INFO_COUNT);
454}
455
456test "the kernel accepts the flavour and count, and answers with a real reading" {
457 // The size check above is a proxy, and `system_enhancements.zig` would pass
458 // it too — its struct is also 48 bytes; it is the *count* it gets wrong, so
459 // `task_info` rejects the call and leaves the buffer as it found it. Only
460 // making the call tells the two apart.
461 //
462 // It runs here because this path is mach, not UIKit: no shared application,
463 // no main thread, no device. The answer is about the test runner, and that
464 // is enough — what is under test is that the declarations link, the
465 // argument types are right, and the kernel does not refuse.
466 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
467
468 const info = try readTaskBasicInfo();
469 // A process far enough along to run a test holds pages. Zero is the exact
470 // signature of a rejected count, which is the failure this exists to catch.
471 try testing.expect(info.resident_size > 0);
472 try testing.expect(info.virtual_size >= info.resident_size);
473}
474
475test "a missing UIApplication is reported rather than read past" {
476 // macOS has no `UIApplication`, which is the same shape as the two cases
477 // that matter on a device: an app extension, and a process that has not
478 // reached `UIApplicationMain`. Without this the null check is code nothing
479 // has ever taken, and the alternative to taking it is reporting a state
480 // nobody read.
481 //
482 // Pinned to macOS rather than Darwin: on iOS the class is present and the
483 // right answer is a state, not an error.
484 if (builtin.target.os.tag != .macos) return error.SkipZigTest;
485
486 try testing.expectError(error.ClassNotFound, readApplicationState());
487}
488
489test "megabytes are rounded to two places, half up" {
490 try testing.expectEqual(@as(u64, 0), residentHundredthsMB(0));
491 try testing.expectEqual(@as(u64, 100), residentHundredthsMB(1024 * 1024));
492 try testing.expectEqual(@as(u64, 150), residentHundredthsMB(1536 * 1024));
493 // 123456789 / 1048576 = 117.7376…, so 11773.76 hundredths, rounding up.
494 try testing.expectEqual(@as(u64, 11774), residentHundredthsMB(123456789));
495 // Half a hundredth, exactly at the boundary, goes up.
496 try testing.expectEqual(@as(u64, 1), residentHundredthsMB(5243));
497 // And a hair under it does not.
498 try testing.expectEqual(@as(u64, 0), residentHundredthsMB(5242));
499}
500
501test "the memory reply is shaped the way the page reads it" {
502 var buf: [160]u8 = undefined;
503 const json = try formatMemoryUsage(&buf, .{
504 .virtual_size = 400000000,
505 .resident_size = 123456789,
506 .resident_size_max = 123456789,
507 .user_time = .{ .seconds = 0, .microseconds = 0 },
508 .system_time = .{ .seconds = 0, .microseconds = 0 },
509 .policy = 0,
510 .suspend_count = 0,
511 });
512 try testing.expectEqualStrings(
513 "{\"usedMB\":117.74,\"residentSize\":123456789,\"virtualSize\":400000000}",
514 json,
515 );
516
517 // And it is JSON, not just the right characters — `usedMB` in particular
518 // has to survive as a number, which a zero-padded fraction can break.
519 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
520 defer parsed.deinit();
521 try testing.expectEqual(@as(f64, 117.74), parsed.value.object.get("usedMB").?.float);
522 try testing.expectEqual(@as(i64, 123456789), parsed.value.object.get("residentSize").?.integer);
523}
524
525test "a fraction below ten keeps its leading zero" {
526 // `{d}.{d}` would render 4 hundredths as `1.4` — a tenfold error that no
527 // parser would reject.
528 var buf: [160]u8 = undefined;
529 const json = try formatMemoryUsage(&buf, .{
530 .virtual_size = 0,
531 .resident_size = 1024 * 1024 + 41943, // ~1.04 MiB
532 .resident_size_max = 0,
533 .user_time = .{ .seconds = 0, .microseconds = 0 },
534 .system_time = .{ .seconds = 0, .microseconds = 0 },
535 .policy = 0,
536 .suspend_count = 0,
537 });
538 try testing.expect(std.mem.startsWith(u8, json, "{\"usedMB\":1.04,"));
539}
540
541test "app states map to the three strings the SDK type declares" {
542 try testing.expectEqualStrings("\"active\"", try appStateJson(0));
543 try testing.expectEqualStrings("\"inactive\"", try appStateJson(1));
544 try testing.expectEqualStrings("\"background\"", try appStateJson(2));
545}
546
547test "an app state UIKit has not defined yet is not folded into inactive" {
548 // Swift's ternary reports any unknown value as "inactive", which is a real
549 // answer that happens to be wrong. This says it does not know.
550 try testing.expectError(error.UnknownApplicationState, appStateJson(3));
551 try testing.expectError(error.UnknownApplicationState, appStateJson(-1));
552}
553
554test "the app state reply is a bare JSON string, not an object" {
555 // `craft.d.ts` declares `getAppState(): 'active' | 'inactive' | 'background'`
556 // and `craft-bridge.js` resolves with `payload || {}`, so the reply has to
557 // parse as a string and be non-empty.
558 const parsed = try std.json.parseFromSlice(
559 std.json.Value,
560 testing.allocator,
561 try appStateJson(2),
562 .{},
563 );
564 defer parsed.deinit();
565 try testing.expectEqualStrings("background", parsed.value.string);
566}
567
568test "a page's message and level survive the trip to the host sink" {
569 // The field-name trap, checked where it can actually fail.
570 //
571 // The version this replaces re-declared `bridge_log`'s parse shape locally
572 // and asserted that `std.json` filled it in — which is true of any two
573 // matching structs and says nothing about the code under test. Rename
574 // `message` inside `bridge_log` and that test stayed green while every page
575 // log became an empty line, which is the exact bug it claimed to guard.
576 //
577 // So: drive the real dispatcher, point the real sink at a file, and read
578 // the record back.
579 const host_log = ;
580 const io = .get();
581 const path = "craft-device-log-test.txt";
582 std.Io.Dir.cwd().deleteFile(io, path) catch {};
583 defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
584 // Put the sink back for whatever runs next in this binary; the config is
585 // process-global, and leaving it muted would hide other tests' output.
586 defer host_log.init(.{}) catch {};
587
588 try host_log.init(.{
589 .output_file = path,
590 .min_level = .Debug,
591 .enable_colors = false,
592 .enable_timestamps = false,
593 .mirror_to_stderr = false,
594 });
595
596 var bridge = DeviceBridge.init(testing.allocator);
597 defer bridge.deinit();
598 try bridge.handleMessage(A.log,
599 \\{"level":"warn","message":"a page said this"}
600 );
601 // Closes the file, so the read below sees everything written.
602 host_log.deinit();
603
604 var read_buf: [4096]u8 = undefined;
605 const file = try std.Io.Dir.cwd().openFile(io, path, .{});
606 defer file.close(io);
607 const contents = read_buf[0..try file.readPositionalAll(io, &read_buf, 0)];
608
609 const at = std.mem.indexOf(u8, contents, "[page] a page said this") orelse {
610 std.debug.print("the page's message never reached the sink:\n{s}\n", .{contents});
611 return error.PageMessageNotRecorded;
612 };
613 // The level, on that same record rather than anywhere in the file — a
614 // stray WARN from an unrelated line would otherwise pass this for free.
615 const line_start = if (std.mem.lastIndexOfScalar(u8, contents[0..at], '\n')) |nl| nl + 1 else 0;
616 try testing.expect(std.mem.indexOf(u8, contents[line_start..at], "WARN") != null);
617}
618
619test "a log payload that will not parse is refused, not acknowledged" {
620 // Also proves the delegation is real: `InvalidJSON` is `bridge_log`'s
621 // answer, so seeing it here means `A.log` reached `LogBridge` rather than
622 // something local that shrugged and replied ok.
623 var bridge = DeviceBridge.init(testing.allocator);
624 defer bridge.deinit();
625
626 try testing.expectError(
627 bridge_error.BridgeError.InvalidJSON,
628 bridge.handleMessage(A.log, "not json"),
629 );
630}
631
632test "the action names are the ones the Swift dispatcher answers" {
633 // Spelled out because these strings are the wire contract, and a rename
634 // here is invisible until a page stops being answered.
635 // `ios_conformance_test` scans this file's `A` block against
636 // `CraftApp.swift` and would catch a name the spec does not have; it would
637 // not catch all four being renamed in step, which is what this holds.
638 try testing.expectEqualStrings("getMemoryUsage", A.get_memory_usage);
639 try testing.expectEqualStrings("getAppState", A.get_app_state);
640 try testing.expectEqualStrings("getNetworkStatus", A.get_network_status);
641 try testing.expectEqualStrings("log", A.log);
642}
packages/zig/src/bridge_mobile_display.zigadded+660-0
Changes to packages/zig/src/bridge_mobile_display.zig
@@ -0,0 +1,660 @@
1const std = ;
2const builtin = ;
3const capabilities = ;
4const bridge_error = ;
5const objc_runtime = ;
6
7const objc = objc_runtime.objc;
8
9/// The `mobile` display group: the app icon's badge, the idle timer, and the
10/// two orientation actions.
11///
12/// These five arrived together because they are the ones whose Swift arms are
13/// *reachable but not reliable*, and the difference between them is worth
14/// stating up front.
15///
16/// `setBadge` / `clearBadge` / `setKeepAwake` are ported and live. Each ends by
17/// reading the value back out of UIKit and replying with what UIKit reports, so
18/// a reply here is an observation, not an assertion — the property
19/// `bridge_mobile.zig` chose `getDeviceInfo` for, applied to a setter.
20///
21/// `lockOrientation` / `unlockOrientation` are declared `.unavailable` and
22/// return an error. Swift does not lock anything: it writes `lockedOrientation`
23/// that nothing reads, and `supportedInterfaceOrientations` is overridden
24/// nowhere in the template, so `requestGeometryUpdate` is a one-shot nudge the
25/// system re-evaluates on the next rotation. A real lock needs a root view
26/// controller subclass overriding `supportedInterfaceOrientations`, and
27/// `ios.zig` instantiates a stock `UIViewController`. That is a startup-path
28/// change, not a bridge action, so this file says so instead of replying
29/// `true` to a request it did not carry out.
30///
31/// ## Three divergences from Swift, all deliberate
32///
33/// **No config gate.** Swift's dispatcher guards `setKeepAwake` and both
34/// orientation actions on `config.enableKeepAwake` / `enableOrientationLock`,
35/// which default to `false`, and there is no `else` — so in a default-built app
36/// today those calls reply *nothing at all* and the page's promise hangs to
37/// timeout. No config is plumbed into the Zig bridge, so these serve
38/// unconditionally. That is a behavior change; it is also the only one of the
39/// two options that answers the caller.
40///
41/// **No thread hop.** Every Swift helper here wraps its UIKit work in
42/// `DispatchQueue.main.async`. `ios_dispatch.handleMessage` is already called
43/// from `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback,
44/// which WebKit delivers on the main thread. The hop is redundant, and there is
45/// no dispatch-block machinery in this repo to reproduce it with.
46///
47/// **No badge authorization.** Swift calls
48/// `requestAuthorizationWithOptions:` and rejects with "Badge permission
49/// denied". Reporting that from Zig needs a *capturing* block — every block in
50/// this repo is static and non-capturing — so it is not attempted here. The
51/// consequence is stated rather than hidden: Zig sets the number and proves
52/// UIKit stored it, which is **not** proof that the springboard draws it. An
53/// unauthorized app gets `{"count":N}` and an invisible badge, and will never
54/// see "Badge permission denied" or the first-run prompt Swift triggers.
55pub const A = struct {
56 pub const set_badge = "setBadge";
57 pub const clear_badge = "clearBadge";
58 pub const set_keep_awake = "setKeepAwake";
59 pub const lock_orientation = "lockOrientation";
60 pub const unlock_orientation = "unlockOrientation";
61};
62
63pub const capability_actions = [_]capabilities.ActionDecl{
64 .{ .name = A.set_badge, .reply = .result },
65 .{ .name = A.clear_badge, .reply = .result },
66 .{ .name = A.set_keep_awake, .reply = .result },
67 .{
68 .name = A.lock_orientation,
69 .reply = .result,
70 .status = .unavailable,
71 .reason = "needs a root view controller overriding supportedInterfaceOrientations; craft's is a stock UIViewController",
72 },
73 .{
74 .name = A.unlock_orientation,
75 .reply = .result,
76 .status = .unavailable,
77 .reason = "nothing to unlock: no orientation lock is ever installed",
78 },
79};
80
81pub const DisplayBridge = struct {
82 allocator: std.mem.Allocator,
83
84 const Self = @This();
85
86 pub fn init(allocator: std.mem.Allocator) Self {
87 return .{ .allocator = allocator };
88 }
89
90 pub fn deinit(_: *Self) void {}
91
92 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
93 if (std.mem.eql(u8, action, A.set_badge)) {
94 try self.setBadge(data);
95 } else if (std.mem.eql(u8, action, A.clear_badge)) {
96 try self.clearBadge();
97 } else if (std.mem.eql(u8, action, A.set_keep_awake)) {
98 try self.setKeepAwake(data);
99 } else if (std.mem.eql(u8, action, A.lock_orientation)) {
100 try self.lockOrientation(data);
101 } else if (std.mem.eql(u8, action, A.unlock_orientation)) {
102 try self.unlockOrientation();
103 } else {
104 return bridge_error.BridgeError.UnknownAction;
105 }
106 }
107
108 /// `{"count":N}` in, `{"count":<what UIKit now reports>}` out.
109 ///
110 /// The reply is an object, not the bare `true` Swift resolves:
111 /// `craft-bridge.js` settles with `payload || {}`, so any falsy scalar
112 /// arrives at the page as `{}` and carries nothing. An object also lets the
113 /// value be checked, which a `true` never could.
114 fn setBadge(self: *Self, data: []const u8) !void {
115 const requested = try badgeCountFrom(self.allocator, data);
116 try self.replyBadge(A.set_badge, try applyBadge(requested));
117 }
118
119 /// The same path as `setBadge` with a fixed 0, so the two cannot drift.
120 ///
121 /// `clearBadge` sends no payload at all, so there is nothing to parse:
122 /// `payloadOf` hands this `{}` and the count is not the caller's to choose.
123 fn clearBadge(self: *Self) !void {
124 try self.replyBadge(A.clear_badge, try applyBadge(0));
125 }
126
127 fn replyBadge(self: *Self, action: []const u8, observed: i64) !void {
128 var buf: [48]u8 = undefined;
129 bridge_error.sendResultToJS(self.allocator, action, try renderBadgeReply(&buf, observed));
130 }
131
132 /// `{"enabled":<bool>}` in, `{"enabled":<what UIKit now reports>}` out.
133 ///
134 /// Swift also stores `isKeepingAwake`, which nothing in the template ever
135 /// reads. It is not ported: a variable with no reader cannot be wrong, and
136 /// carrying it would suggest something restores it later. Nothing does —
137 /// `idleTimerDisabled` is per-application state UIKit drops when the app
138 /// backgrounds or relaunches, and neither implementation reinstates it.
139 fn setKeepAwake(self: *Self, data: []const u8) !void {
140 const requested = try keepAwakeFrom(self.allocator, data);
141 const observed = try applyKeepAwake(requested);
142
143 var buf: [32]u8 = undefined;
144 bridge_error.sendResultToJS(
145 self.allocator,
146 A.set_keep_awake,
147 try renderKeepAwakeReply(&buf, observed),
148 );
149 }
150
151 /// Validates the orientation, then refuses.
152 ///
153 /// The validation is not ceremony. Erroring on an unrecognized name gives
154 /// the caller `InvalidParameter` and erroring on a good one gives
155 /// `PlatformNotSupported`, which is the difference between "you asked for
156 /// something that does not exist" and "craft cannot do this at all". Swift
157 /// conflates them: its `default:` arm turns any unknown string into `.all`
158 /// and then reports success.
159 ///
160 /// `PlatformNotSupported` matters beyond the message. `ios_dispatch.route`
161 /// passes only `UnknownAction` to the host shim, so returning a real error
162 /// here stops the Swift arm from being asked to try the same thing — which
163 /// is right, because what it would do is either nothing observable or, when
164 /// `connectedScenes` is empty (the fixture has no scene manifest and uses
165 /// the legacy `UIApplicationMain` lifecycle), its `guard ... else { return }`
166 /// sits above `resolveCallback` and the promise hangs.
167 fn lockOrientation(self: *Self, data: []const u8) !void {
168 _ = try requestedOrientation(self.allocator, data);
169 return bridge_error.BridgeError.PlatformNotSupported;
170 }
171
172 /// Refuses, with nothing to validate: the page sends no payload.
173 fn unlockOrientation(self: *Self) !void {
174 _ = self;
175 return bridge_error.BridgeError.PlatformNotSupported;
176 }
177};
178
179// =============================================================================
180// Reply bodies
181// =============================================================================
182
183/// The reply shapes, in one place so the handlers and the tests cannot disagree.
184///
185/// Split out because a test that re-typed the format string would pass no
186/// matter what the handler sent — it would be asserting that `std.fmt` works.
187/// These are the strings the page actually receives.
188fn renderBadgeReply(buf: []u8, observed: i64) ![]const u8 {
189 return std.fmt.bufPrint(buf, "", .{observed});
190}
191
192fn renderKeepAwakeReply(buf: []u8, observed: bool) ![]const u8 {
193 return std.fmt.bufPrint(buf, "", .{observed});
194}
195
196// =============================================================================
197// Payload parsing
198// =============================================================================
199
200/// The `count` the page sent, or an error naming why it could not be used.
201///
202/// Never defaults. A missing `count` is `MissingData`, not 0, because 0 is
203/// `clearBadge` — a dropped field would quietly perform a *different action*
204/// than the one asked for. That is the `bridge_app.zig` badge bug exactly: the
205/// desktop accepted only `{badge:"..."}` while every caller sent `{count:N}`,
206/// and the dock tile never updated while the call reported success.
207///
208/// Swift's `body["count"] as? Int` returns nil for a fractional number and its
209/// dispatcher has no `else`, so `setBadge(3.7)` today replies nothing and hangs
210/// the promise. Here a whole `3.0` is accepted — JSON has one number type and a
211/// page that computed its count with arithmetic legitimately produces it — and
212/// anything else is refused out loud.
213fn badgeCountFrom(allocator: std.mem.Allocator, data: []const u8) !i64 {
214 var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
215 return bridge_error.BridgeError.InvalidJSON;
216 defer parsed.deinit();
217
218 const root = switch (parsed.value) {
219 .object => |obj| obj,
220 else => return bridge_error.BridgeError.InvalidJSON,
221 };
222
223 const count: i64 = switch (root.get("count") orelse return bridge_error.BridgeError.MissingData) {
224 .integer => |n| n,
225 .float => |f| blk: {
226 // Beyond 2^53 an f64 no longer names one integer, so a value out
227 // there is not a count anyone can have meant.
228 if (f != @trunc(f) or f < 0 or f > 9007199254740991.0)
229 return bridge_error.BridgeError.InvalidParameter;
230 break :blk @intFromFloat(f);
231 },
232 // `std.json` emits `.number_string` for integers too large for i64.
233 else => return bridge_error.BridgeError.InvalidParameter,
234 };
235
236 // UIKit's behavior for a negative badge is undefined, and the page's own
237 // type says `number`, not "any number".
238 if (count < 0) return bridge_error.BridgeError.InvalidParameter;
239 return count;
240}
241
242/// The `enabled` the page sent.
243///
244/// Booleans only — no truthiness coercion. `setKeepAwake(1)` is a caller bug
245/// worth reporting, and guessing what a non-boolean meant is how a handler ends
246/// up doing the opposite of what was asked.
247fn keepAwakeFrom(allocator: std.mem.Allocator, data: []const u8) !bool {
248 var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
249 return bridge_error.BridgeError.InvalidJSON;
250 defer parsed.deinit();
251
252 const root = switch (parsed.value) {
253 .object => |obj| obj,
254 else => return bridge_error.BridgeError.InvalidJSON,
255 };
256
257 return switch (root.get("enabled") orelse return bridge_error.BridgeError.MissingData) {
258 .bool => |b| b,
259 else => return bridge_error.BridgeError.InvalidParameter,
260 };
261}
262
263/// The orientations Swift's switch recognizes.
264///
265/// Five, not the two `packages/typescript/types/craft.d.ts:528` publishes —
266/// the TS surface and the Swift switch already disagree, and the wire is what a
267/// page can actually reach.
268pub const Orientation = enum {
269 portrait,
270 portrait_upside_down,
271 landscape_left,
272 landscape_right,
273 landscape,
274
275 pub fn fromName(name: []const u8) ?Orientation {
276 const table = .{
277 .{ "portrait", Orientation.portrait },
278 .{ "portraitUpsideDown", Orientation.portrait_upside_down },
279 .{ "landscapeLeft", Orientation.landscape_left },
280 .{ "landscapeRight", Orientation.landscape_right },
281 .{ "landscape", Orientation.landscape },
282 };
283 inline for (table) |entry| {
284 if (std.mem.eql(u8, name, entry[0])) return entry[1];
285 }
286 return null;
287 }
288
289 /// `UIInterfaceOrientationMask`, which is `1 << UIInterfaceOrientation`.
290 ///
291 /// Spelled out because craft imports no UIKit headers, and kept even though
292 /// nothing sends it yet: it is the table the eventual implementation needs,
293 /// and the two easy ways to get it wrong — `landscapeLeft` is 4 and
294 /// `landscapeRight` is 3, so their masks are 16 and 8, the reverse of the
295 /// order they read in — are pinned by a test below rather than left to be
296 /// rediscovered.
297 pub fn mask(self: Orientation) c_ulong {
298 return switch (self) {
299 .portrait => 2,
300 .portrait_upside_down => 4,
301 .landscape_right => 8,
302 .landscape_left => 16,
303 .landscape => 24,
304 };
305 }
306};
307
308fn requestedOrientation(allocator: std.mem.Allocator, data: []const u8) !Orientation {
309 var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
310 return bridge_error.BridgeError.InvalidJSON;
311 defer parsed.deinit();
312
313 const root = switch (parsed.value) {
314 .object => |obj| obj,
315 else => return bridge_error.BridgeError.InvalidJSON,
316 };
317
318 const name = switch (root.get("orientation") orelse return bridge_error.BridgeError.MissingData) {
319 .string => |s| s,
320 else => return bridge_error.BridgeError.InvalidParameter,
321 };
322
323 return Orientation.fromName(name) orelse bridge_error.BridgeError.InvalidParameter;
324}
325
326// =============================================================================
327// UIKit
328// =============================================================================
329
330/// Set the icon badge, then report what UIKit says it is.
331///
332/// Two setters, on purpose. `setBadgeCount:withCompletionHandler:` (iOS 16+) is
333/// the supported route and is sent whenever `UNUserNotificationCenter` is in
334/// the process; `setApplicationIconBadgeNumber:` is deprecated as of iOS 17 but
335/// still present and, unlike the modern one, is synchronous — which is why it
336/// is the one whose effect can be read back on the next line. Both are handed
337/// the same number, so the asynchronous one landing later changes nothing.
338///
339/// The read-back is the point. Without it this function would be asserting that
340/// the badge is set; with it, the reply carries a number that came back out of
341/// UIKit. It still does not prove the badge is *visible* — that needs the
342/// authorization Swift requests and this does not (see the note on `A`).
343///
344/// If the deprecated pair ever disappears, this returns an error rather than an
345/// unverified success. Fixing that properly means a capturing block for the
346/// modern setter's completion handler, which is new machinery and should be a
347/// decision, not a side effect.
348fn applyBadge(count: i64) !i64 {
349 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
350
351 const wanted = std.math.cast(c_long, count) orelse return bridge_error.BridgeError.InvalidParameter;
352
353 const UIApplicationClass = objc.objc_getClass("UIApplication") orelse return error.ClassNotFound;
354 const sel_shared = objc.sel_registerName("sharedApplication") orelse return error.SelectorNotFound;
355 const app = objc.msgSendId(UIApplicationClass, sel_shared);
356 if (app == null) return error.NoSharedApplication;
357
358 const modern_sent = setBadgeThroughNotificationCenter(wanted);
359
360 const can_set = responds(app, "setApplicationIconBadgeNumber:");
361 if (!can_set and !modern_sent) return error.BadgeApiUnavailable;
362
363 if (can_set) {
364 const sel_set = objc.sel_registerName("setApplicationIconBadgeNumber:") orelse
365 return error.SelectorNotFound;
366 const SetFn = *const fn (objc.id, objc.SEL, c_long) callconv(.c) void;
367 const set: SetFn = @ptrCast(&objc.objc_msgSend);
368 set(app, sel_set, wanted);
369 }
370
371 if (!responds(app, "applicationIconBadgeNumber")) return error.BadgeNotVerifiable;
372
373 const sel_get = objc.sel_registerName("applicationIconBadgeNumber") orelse
374 return error.SelectorNotFound;
375 const GetFn = *const fn (objc.id, objc.SEL) callconv(.c) c_long;
376 const get: GetFn = @ptrCast(&objc.objc_msgSend);
377
378 const observed = get(app, sel_get);
379 if (observed != wanted) return error.BadgeNotApplied;
380 return @intCast(observed);
381}
382
383/// Send `setBadgeCount:withCompletionHandler:`. False means it was not sent.
384///
385/// `objc_getClass` returning null here is a real and expected condition, not a
386/// failure: `packages/ios/fixtures/zig-slice/build-and-run.sh` links only
387/// UIKit, WebKit and Foundation, so on that fixture UserNotifications is not in
388/// the process at all. The caller decides what to do about it — this returns
389/// what happened rather than swallowing it.
390///
391/// The completion handler is `nullable` in the header, so nil is legal and no
392/// block is needed. Note that `requestAuthorizationWithOptions:`, which Swift
393/// also calls, is *not* nullable there — passing nil to that one crashes, which
394/// is part of why authorization is not attempted here.
395fn setBadgeThroughNotificationCenter(count: c_long) bool {
396 const UNCenterClass = objc.objc_getClass("UNUserNotificationCenter") orelse return false;
397 const sel_current = objc.sel_registerName("currentNotificationCenter") orelse return false;
398 const center = objc.msgSendId(UNCenterClass, sel_current);
399 if (center == null) return false;
400
401 if (!responds(center, "setBadgeCount:withCompletionHandler:")) return false;
402
403 const sel_set = objc.sel_registerName("setBadgeCount:withCompletionHandler:") orelse return false;
404 const Fn = *const fn (objc.id, objc.SEL, c_long, ?*anyopaque) callconv(.c) void;
405 const func: Fn = @ptrCast(&objc.objc_msgSend);
406 func(center, sel_set, count, null);
407 return true;
408}
409
410/// Disable or re-enable the idle timer, then report what UIKit says it is.
411///
412/// The getter is `isIdleTimerDisabled`, not `idleTimerDisabled`: the property
413/// declares `getter=isIdleTimerDisabled`. A wrong selector is not a compile
414/// error — it reaches `doesNotRecognizeSelector:` at runtime — so it is worth
415/// the line to say why this one looks asymmetric with its setter.
416fn applyKeepAwake(enabled: bool) !bool {
417 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
418
419 const UIApplicationClass = objc.objc_getClass("UIApplication") orelse return error.ClassNotFound;
420 const sel_shared = objc.sel_registerName("sharedApplication") orelse return error.SelectorNotFound;
421 const app = objc.msgSendId(UIApplicationClass, sel_shared);
422 if (app == null) return error.NoSharedApplication;
423
424 const sel_set = objc.sel_registerName("setIdleTimerDisabled:") orelse return error.SelectorNotFound;
425 const SetFn = *const fn (objc.id, objc.SEL, bool) callconv(.c) void;
426 const set: SetFn = @ptrCast(&objc.objc_msgSend);
427 set(app, sel_set, enabled);
428
429 const sel_get = objc.sel_registerName("isIdleTimerDisabled") orelse return error.SelectorNotFound;
430 const GetFn = *const fn (objc.id, objc.SEL) callconv(.c) bool;
431 const get: GetFn = @ptrCast(&objc.objc_msgSend);
432
433 const observed = get(app, sel_get);
434 if (observed != enabled) return error.KeepAwakeNotApplied;
435 return observed;
436}
437
438/// `-[NSObject respondsToSelector:]`.
439///
440/// The version gate craft uses everywhere instead of parsing an OS version —
441/// `macos.zig:582` records why: the question is always whether *this* selector
442/// exists, and asking that directly cannot drift from what the SDK did.
443/// `objc_runtime.zig` has no one-argument BOOL sender, hence the local type.
444fn responds(target: objc.id, selector_name: [:0]const u8) bool {
445 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse return false;
446 const selector = objc.sel_registerName(selector_name) orelse return false;
447 const Fn = *const fn (objc.id, objc.SEL, objc.SEL) callconv(.c) bool;
448 const func: Fn = @ptrCast(&objc.objc_msgSend);
449 return func(target, sel_responds, selector);
450}
451
452const testing = std.testing;
453
454test "the declared table and the dispatcher serve the same actions" {
455 // A table and a dispatch chain that disagree is the failure the whole
456 // capabilities mechanism exists to make impossible, and it is cheapest to
457 // catch here rather than from a page whose promise never settles.
458 try testing.expectEqual(@as(usize, 5), capability_actions.len);
459
460 const payloads = [_][]const u8{
461 "{\"count\":3}",
462 "{}",
463 "{\"enabled\":true}",
464 "{\"orientation\":\"portrait\"}",
465 "{}",
466 };
467 try testing.expectEqual(capability_actions.len, payloads.len);
468
469 var bridge = DisplayBridge.init(testing.allocator);
470 defer bridge.deinit();
471
472 for (capability_actions, payloads) |decl, data| {
473 // The host has no UIKit, so the live actions fail here. What matters is
474 // that they fail *having been dispatched*: `UnknownAction` is the one
475 // answer that means the arm is missing.
476 if (bridge.handleMessage(decl.name, data)) |_| {} else |err| {
477 try testing.expect(err != bridge_error.BridgeError.UnknownAction);
478 }
479 try testing.expectEqual(capabilities.Reply.result, decl.reply);
480 }
481}
482
483test "an action the namespace does not serve is reported, not ignored" {
484 var bridge = DisplayBridge.init(testing.allocator);
485 defer bridge.deinit();
486
487 try testing.expectError(
488 bridge_error.BridgeError.UnknownAction,
489 bridge.handleMessage("noSuchAction", "{}"),
490 );
491 // Near-misses too: a page calling `setbadge` must be told, not served.
492 try testing.expectError(
493 bridge_error.BridgeError.UnknownAction,
494 bridge.handleMessage("setbadge", "{\"count\":1}"),
495 );
496}
497
498test "every unavailable row explains itself" {
499 // `.unavailable` without a reason tells an app that something is broken and
500 // nothing about what, which is barely better than not declaring it.
501 for (capability_actions) |decl| {
502 if (decl.status != .unavailable) continue;
503 try testing.expect(decl.reason != null);
504 try testing.expect(decl.reason.?.len > 0);
505 }
506}
507
508test "a badge count is read from the field the page actually sends" {
509 try testing.expectEqual(@as(i64, 5), try badgeCountFrom(testing.allocator, "{\"count\":5}"));
510 try testing.expectEqual(@as(i64, 0), try badgeCountFrom(testing.allocator, "{\"count\":0}"));
511
512 // Whole floats are that integer. JSON has one number type, and a page that
513 // computed its count with arithmetic hands over `3.0`.
514 try testing.expectEqual(@as(i64, 3), try badgeCountFrom(testing.allocator, "{\"count\":3.0}"));
515}
516
517test "a missing count is refused rather than defaulted to zero" {
518 // The whole reason this is not `orelse 0`: zero is `clearBadge`. Defaulting
519 // would turn a dropped field into a different action, performed silently
520 // and reported as success.
521 try testing.expectError(
522 bridge_error.BridgeError.MissingData,
523 badgeCountFrom(testing.allocator, "{}"),
524 );
525 try testing.expectError(
526 bridge_error.BridgeError.MissingData,
527 badgeCountFrom(testing.allocator, "{\"badge\":7}"),
528 );
529}
530
531test "a count that is not a count is refused, not rounded or dropped" {
532 // Swift's `as? Int` returns nil for each of these and its dispatcher has no
533 // `else`, so today they reply nothing at all and hang the caller.
534 for ([_][]const u8{
535 "{\"count\":3.7}",
536 "{\"count\":\"5\"}",
537 "{\"count\":true}",
538 "{\"count\":null}",
539 "{\"count\":-1}",
540 "{\"count\":[1]}",
541 }) |data| {
542 try testing.expectError(
543 bridge_error.BridgeError.InvalidParameter,
544 badgeCountFrom(testing.allocator, data),
545 );
546 }
547}
548
549test "a payload that is not an object is a JSON error, not a missing field" {
550 // Reporting `MissingData` for `[1,2]` would send the caller looking for a
551 // field in something that has no fields.
552 try testing.expectError(
553 bridge_error.BridgeError.InvalidJSON,
554 badgeCountFrom(testing.allocator, "[1,2]"),
555 );
556 try testing.expectError(
557 bridge_error.BridgeError.InvalidJSON,
558 badgeCountFrom(testing.allocator, "{\"count\":"),
559 );
560}
561
562test "keep-awake reads a boolean and only a boolean" {
563 try testing.expectEqual(true, try keepAwakeFrom(testing.allocator, "{\"enabled\":true}"));
564 try testing.expectEqual(false, try keepAwakeFrom(testing.allocator, "{\"enabled\":false}"));
565
566 try testing.expectError(
567 bridge_error.BridgeError.MissingData,
568 keepAwakeFrom(testing.allocator, "{}"),
569 );
570 // No truthiness. Guessing what `1` meant is how a handler ends up doing the
571 // opposite of what was asked.
572 try testing.expectError(
573 bridge_error.BridgeError.InvalidParameter,
574 keepAwakeFrom(testing.allocator, "{\"enabled\":1}"),
575 );
576 try testing.expectError(
577 bridge_error.BridgeError.InvalidParameter,
578 keepAwakeFrom(testing.allocator, "{\"enabled\":\"true\"}"),
579 );
580}
581
582test "the orientation names are the ones the Swift switch recognizes" {
583 try testing.expectEqual(Orientation.portrait, Orientation.fromName("portrait").?);
584 try testing.expectEqual(Orientation.portrait_upside_down, Orientation.fromName("portraitUpsideDown").?);
585 try testing.expectEqual(Orientation.landscape_left, Orientation.fromName("landscapeLeft").?);
586 try testing.expectEqual(Orientation.landscape_right, Orientation.fromName("landscapeRight").?);
587 try testing.expectEqual(Orientation.landscape, Orientation.fromName("landscape").?);
588
589 // Swift's `default:` turns anything else into `.all` and reports success.
590 try testing.expectEqual(@as(?Orientation, null), Orientation.fromName("sideways"));
591 try testing.expectEqual(@as(?Orientation, null), Orientation.fromName("Portrait"));
592}
593
594test "the orientation masks are shifts of UIInterfaceOrientation, not their reading order" {
595 // `landscapeLeft` is orientation 4 and `landscapeRight` is 3, so the masks
596 // are 16 and 8 — the reverse of the order the names suggest. This is the
597 // pair that gets transposed.
598 try testing.expectEqual(@as(c_ulong, 2), Orientation.portrait.mask());
599 try testing.expectEqual(@as(c_ulong, 4), Orientation.portrait_upside_down.mask());
600 try testing.expectEqual(@as(c_ulong, 8), Orientation.landscape_right.mask());
601 try testing.expectEqual(@as(c_ulong, 16), Orientation.landscape_left.mask());
602 try testing.expectEqual(
603 Orientation.landscape_left.mask() | Orientation.landscape_right.mask(),
604 Orientation.landscape.mask(),
605 );
606}
607
608test "the orientation actions refuse, and say which kind of refusal it is" {
609 var bridge = DisplayBridge.init(testing.allocator);
610 defer bridge.deinit();
611
612 // A well-formed request craft cannot carry out.
613 try testing.expectError(
614 bridge_error.BridgeError.PlatformNotSupported,
615 bridge.handleMessage(A.lock_orientation, "{\"orientation\":\"landscape\"}"),
616 );
617 try testing.expectError(
618 bridge_error.BridgeError.PlatformNotSupported,
619 bridge.handleMessage(A.unlock_orientation, "{}"),
620 );
621
622 // A malformed one. Distinct on purpose: the caller can fix this one.
623 try testing.expectError(
624 bridge_error.BridgeError.InvalidParameter,
625 bridge.handleMessage(A.lock_orientation, "{\"orientation\":\"sideways\"}"),
626 );
627 try testing.expectError(
628 bridge_error.BridgeError.MissingData,
629 bridge.handleMessage(A.lock_orientation, "{}"),
630 );
631}
632
633test "replies are objects carrying a value, not bare scalars" {
634 // `craft-bridge.js` settles with `payload || {}`. A bare `false` — which
635 // `setKeepAwake(false)` would resolve, following Swift — arrives at the
636 // page as `{}` and loses the answer entirely.
637 //
638 // These call the same renderers the handlers do, so changing a key name or
639 // dropping the object wrapper fails here instead of reading as coverage.
640 var buf: [48]u8 = undefined;
641
642 try testing.expectEqualStrings("{\"count\":7}", try renderBadgeReply(&buf, 7));
643 try testing.expectEqualStrings("{\"count\":0}", try renderBadgeReply(&buf, 0));
644 try testing.expectEqualStrings("{\"enabled\":false}", try renderKeepAwakeReply(&buf, false));
645 try testing.expectEqualStrings("{\"enabled\":true}", try renderKeepAwakeReply(&buf, true));
646
647 // The stack buffers the handlers hand these are 48 and 32 bytes; the widest
648 // reply either can produce has to fit, or a legitimate count turns into a
649 // `NoSpaceLeft` the page reads as a native failure.
650 var badge_buf: [48]u8 = undefined;
651 try testing.expect((try renderBadgeReply(&badge_buf, std.math.minInt(i64))).len <= badge_buf.len);
652 var keep_buf: [32]u8 = undefined;
653 try testing.expect((try renderKeepAwakeReply(&keep_buf, false)).len <= keep_buf.len);
654}
655
656test "UIKit work is refused off Darwin rather than attempted" {
657 if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
658 try testing.expectError(error.UnsupportedPlatform, applyBadge(1));
659 try testing.expectError(error.UnsupportedPlatform, applyKeepAwake(true));
660}
packages/zig/src/bridge_mobile_haptics.zigadded+669-0
Changes to packages/zig/src/bridge_mobile_haptics.zig
@@ -0,0 +1,669 @@
1//! The haptics half of the `mobile` namespace: `haptic` and `vibrate`.
2//!
3//! Split out of `bridge_mobile.zig` because the two actions have opposite
4//! contracts — `haptic` is fire-and-forget and gated on a config flag, while
5//! `vibrate` replies and is gated on nothing — and because one of them cannot
6//! be served honestly from Zig yet. Keeping that admission next to the working
7//! action, rather than folded into the namespace's main file, is the point.
8
9const std = ;
10const builtin = ;
11const capabilities = ;
12const bridge_error = ;
13const objc_runtime = ;
14const mobile = ;
15
16const objc = objc_runtime.objc;
17
18/// Reused rather than redefined: `mobile.iOS.HapticType` is already the
19/// vocabulary the rest of the tree speaks, and a second spelling of the same
20/// seven cases is a second thing to keep in step.
21const HapticType = mobile.iOS.HapticType;
22
23pub const A = struct {
24 pub const haptic = "haptic";
25 pub const vibrate = "vibrate";
26};
27
28/// `haptic` is declared `unavailable`, and that is a decision, not an oversight.
29///
30/// The Swift it replaces does not run `triggerHaptic` unconditionally — it runs
31/// it `if config.enableHaptics` (`CraftApp.swift:537`), and that flag defaults
32/// to **false** in both the template (`CraftApp.swift:190`) and the SDK
33/// (`packages/ios/src/index.ts:118`). It reaches Zig through nothing: no
34/// `craft_ios_*` export carries it, no build option encodes it. So serving
35/// `haptic` from here means serving a *different action* from the one the spec
36/// defines — every app on the default config would start buzzing on
37/// `craft.haptics.impact()`, which is the entire shipped haptics surface, and
38/// no test in this repo could see it because the Taptic Engine has no readback.
39///
40/// The three ways to make that go away are all worse than saying so:
41/// - fire anyway → a behaviour change that is invisible in CI and obvious on
42/// a device;
43/// - hardcode the gate false → a permanent no-op that reports nothing, which
44/// is the shape of the nine Android handlers that were deleted;
45/// - reply `{"success":true}` → fabricated success, the thing this migration
46/// exists to stop.
47///
48/// So the handler refuses and the manifest says why. Unblocking it is small and
49/// deliberate: plumb the flag in (a `craft_ios_set_capabilities` export or a
50/// build option), then swap the refusal in `haptic()` for the mapping and the
51/// trigger below, both of which are written and pinned by the tests at the
52/// bottom of this file. What must not happen is the flip happening silently.
53///
54/// Note the inconsistency this preserves: `case "vibrate"` has no
55/// `enableHaptics` gate in the spec either, so `craft.vibrate([100])` already
56/// fires impacts on an app with haptics "disabled". `vibrate` stays ungated to
57/// match; the asymmetry is the spec's, not ours.
58const haptic_unavailable_reason =
59 "iOS gates haptic on config.enableHaptics, which defaults to false and is " ++
60 "not visible to Zig; serving it here would enable haptics for every app " ++
61 "that never opted in";
62
63pub const capability_actions = [_]capabilities.ActionDecl{
64 // `.none` is the action's contract regardless of status: the page's
65 // `craft.haptic()` returns undefined and the spec's `case "haptic"` never
66 // touches `resolveCallback`. An error still reaches the page — with no
67 // pending entry to settle, `craft-bridge.js` reports it to the console —
68 // so a refusal is logged rather than swallowed.
69 .{
70 .name = A.haptic,
71 .reply = .none,
72 .status = .unavailable,
73 .reason = haptic_unavailable_reason,
74 },
75 // `.result` carrying the JSON literal `true`. Not a shape worth improving:
76 // the hand-off path already delivers exactly this today, and `.none` would
77 // park any `_req`-based caller for the full 30s timeout.
78 .{ .name = A.vibrate, .reply = .result },
79};
80
81/// The reply `vibrate` sends, byte-identical to what the hand-off path delivers
82/// today. A bare `true`, not an object — the spec serialises with
83/// `.fragmentsAllowed`. It stays truthy on purpose: `craft-bridge.js` resolves
84/// with `payload || {}`, so `false` or `0` would arrive as `{}`.
85const vibrate_reply = "true";
86
87pub const HapticsBridge = struct {
88 allocator: std.mem.Allocator,
89
90 const Self = @This();
91
92 pub fn init(allocator: std.mem.Allocator) Self {
93 return .{ .allocator = allocator };
94 }
95
96 pub fn deinit(_: *Self) void {}
97
98 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
99 if (std.mem.eql(u8, action, A.haptic)) {
100 return self.haptic(data);
101 } else if (std.mem.eql(u8, action, A.vibrate)) {
102 return self.vibrate(data);
103 }
104 return bridge_error.BridgeError.UnknownAction;
105 }
106
107 /// Refuses, for the reason recorded on `haptic_unavailable_reason`.
108 ///
109 /// The payload is not parsed first. Reporting a malformed `style` would
110 /// imply the style was going to be used, and it is not — a refusal that
111 /// sometimes reports a different cause is harder to act on than one that
112 /// always says the same thing.
113 ///
114 /// The error name is what lands in the device log; the page sees
115 /// `NATIVE_CALL_FAILED`, `ios_dispatch.asBridgeError`'s catch-all, because
116 /// the wire protocol has no code for "declared unavailable". The manifest
117 /// is where an app reads the actual reason.
118 fn haptic(_: *Self, _: []const u8) !void {
119 return error.HapticsGateNotVisibleToZig;
120 }
121
122 /// `case "vibrate"` (`CraftApp.swift:685-691`).
123 ///
124 /// Two paths, and which one runs is decided exactly the way Swift's
125 /// `body["pattern"] as? [Int]` decides it — see `patternArray`. A
126 /// well-formed array schedules impacts (possibly none, for `[]`); anything
127 /// else fires a single medium impact immediately.
128 ///
129 /// The `true` goes out only if the work succeeded. The spec replies `true`
130 /// unconditionally, but its work cannot visibly fail; ours can — a missing
131 /// `UIImpactFeedbackGenerator` is a real condition — and answering `true`
132 /// to a call that did nothing is the failure mode this migration exists to
133 /// remove. A caller that gets an error can retry or degrade; one that gets
134 /// `true` cannot.
135 fn vibrate(self: *Self, data: []const u8) !void {
136 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
137 return bridge_error.BridgeError.InvalidJSON;
138 defer parsed.deinit();
139
140 const root = switch (parsed.value) {
141 .object => |obj| obj,
142 else => return bridge_error.BridgeError.InvalidJSON,
143 };
144
145 if (patternArray(root.get("pattern"))) |items| {
146 _ = try schedulePattern(items);
147 } else {
148 // Swift's else arm. Deliberately *not* `MissingData`: `craft.vibrate()`
149 // with no argument posts no `pattern` at all, and rejecting that
150 // would be stricter than the surface the page is written against.
151 try triggerHapticChecked(.impact_medium);
152 }
153
154 bridge_error.sendResultToJS(self.allocator, A.vibrate, vibrate_reply);
155 }
156};
157
158/// Swift's `if let pattern = body["pattern"] as? [Int]`, as a decision.
159///
160/// Returns the elements when the cast would succeed and null when it would
161/// fail — absent key, not an array, or an array holding anything that is not an
162/// integer. Note that an *empty* array is a successful cast, so `[]` returns an
163/// empty slice rather than null: `craft.haptics.vibrate()` sends `[]`, and it
164/// must schedule nothing rather than take the single-impact fallback.
165///
166/// Non-integer numbers fail the cast, matching `NSNumber`'s exact-representability
167/// rule. In practice `JSON.stringify` renders every integral JS number without a
168/// fraction, so a `.float` on the wire is always genuinely non-integral —
169/// `[100.5]` is the case this rejects, and it rejects it the way Swift does.
170///
171/// The returned slice points into the caller's `std.json.Parsed`, so it is only
172/// valid until that is deinitialised.
173fn patternArray(value: ?std.json.Value) ?[]const std.json.Value {
174 const v = value orelse return null;
175 const items = switch (v) {
176 .array => |a| a.items,
177 else => return null,
178 };
179 for (items) |item| {
180 if (item != .integer) return null;
181 }
182 return items;
183}
184
185/// How far from *now* entry `index` of a pattern lands, or null if it does
186/// nothing. Three facts from `vibratePattern` (`CraftApp.swift:3210-3220`),
187/// carried across deliberately:
188///
189/// 1. Even indices only. The Web Vibration API reads odd entries as pauses;
190/// the spec reads them and then ignores them entirely, not even as timing.
191/// 2. Only `duration > 0` fires. Zero and negative entries are skipped.
192/// 3. **The delay is measured from now, not accumulated.** For
193/// `[100, 50, 100, 50, 200]` that puts indices 0 and 2 on the *same
194/// instant*, so the canonical five-element example produces two impacts,
195/// not three.
196///
197/// (3) is almost certainly not what "pattern" is meant to mean, and it is kept
198/// anyway: this is a migration, the Swift is the specification, and a running
199/// offset would be a silent behaviour change dressed as a port. It is written
200/// down here so the next reader knows it was a decision and not a transcription
201/// slip — changing it is a product call, not a cleanup.
202///
203/// The multiply saturates rather than overflowing: `duration` is whatever
204/// integer the page put in the array, and an overflowing multiply is a panic in
205/// a safe build. A clamped delay is a distant one, which is the same nothing an
206/// enormous delay would have been.
207fn impactDelayNs(index: usize, duration_ms: i64) ?i64 {
208 if (index % 2 != 0) return null;
209 if (duration_ms <= 0) return null;
210 return std.math.mul(i64, duration_ms, std.time.ns_per_ms) catch std.math.maxInt(i64);
211}
212
213/// Queue a medium impact for each firing entry, and report how many were
214/// queued.
215///
216/// The class and both selectors are resolved *before* anything is scheduled.
217/// The blocks run long after this function returns, by which point the reply is
218/// already on its way to the page and a failure has nowhere to be reported — so
219/// the one check that can still reach the caller is made while it still can.
220fn schedulePattern(items: []const std.json.Value) !usize {
221 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
222
223 _ = objc.objc_getClass("UIImpactFeedbackGenerator") orelse return error.ClassNotFound;
224 _ = objc.sel_registerName("initWithStyle:") orelse return error.SelectorNotFound;
225 _ = objc.sel_registerName("impactOccurred") orelse return error.SelectorNotFound;
226
227 var scheduled: usize = 0;
228 for (items, 0..) |item, index| {
229 const duration_ms = switch (item) {
230 .integer => |n| n,
231 // `patternArray` guarantees this cannot happen; an explicit error
232 // beats `unreachable` if that guarantee is ever loosened.
233 else => return bridge_error.BridgeError.InvalidParameter,
234 };
235 const delay_ns = impactDelayNs(index, duration_ms) orelse continue;
236
237 const when = dispatch.dispatch_time(dispatch.DISPATCH_TIME_NOW, delay_ns);
238 dispatch.dispatch_after(when, &dispatch._dispatch_main_q, &medium_impact_block);
239 scheduled += 1;
240 }
241 return scheduled;
242}
243
244/// `mobile.iOS.triggerHaptic` with the failures reported instead of swallowed.
245///
246/// The existing one is `fn (HapticType) void` whose every failure path is
247/// `orelse return`, so a missing class and a fired haptic are the same value to
248/// a caller. That is fine for its two fire-and-forget callers and not fine for
249/// a bridge, which has to decide between replying and erroring. Same ObjC, same
250/// enum, same constants — only the return type differs.
251///
252/// No dispatch hop. `UIFeedbackGenerator` is main-thread-only, and the only
253/// path here starts in `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler`
254/// callback that WebKit already delivers on the main thread.
255///
256/// What this still cannot tell you: whether anything was *felt*. The Taptic
257/// Engine is absent on the simulator and on iPad, and System Haptics can be off
258/// in Settings — all three are silent no-ops with no API that reports them. So
259/// "returned without error" means "UIKit accepted the call", never "the device
260/// buzzed", which is the other half of why `haptic` has no success reply to
261/// fabricate.
262fn triggerHapticChecked(haptic_type: HapticType) !void {
263 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
264
265 switch (haptic_type) {
266 .selection => {
267 const class = objc.objc_getClass("UISelectionFeedbackGenerator") orelse
268 return error.ClassNotFound;
269 const sel_selection_changed = objc.sel_registerName("selectionChanged") orelse
270 return error.SelectorNotFound;
271
272 const generator = try objc.allocInit(class);
273 if (generator == null) return error.GeneratorNotCreated;
274 defer objc.release(generator);
275
276 objc.msgSend(generator, sel_selection_changed);
277 },
278 .impact_light, .impact_medium, .impact_heavy => {
279 const class = objc.objc_getClass("UIImpactFeedbackGenerator") orelse
280 return error.ClassNotFound;
281 const sel_init_with_style = objc.sel_registerName("initWithStyle:") orelse
282 return error.SelectorNotFound;
283 const sel_impact_occurred = objc.sel_registerName("impactOccurred") orelse
284 return error.SelectorNotFound;
285
286 // UIImpactFeedbackStyle. Light/Medium/Heavy; Soft(3) and Rigid(4)
287 // exist but nothing maps to them — see `hapticTypeForStyle`.
288 const style: i64 = switch (haptic_type) {
289 .impact_light => 0,
290 .impact_heavy => 2,
291 else => 1,
292 };
293
294 const allocated = try objc.alloc(class);
295 if (allocated == null) return error.GeneratorNotCreated;
296
297 // `initWithStyle:` takes an NSInteger, so the cast must say i64 —
298 // an object-argument msgSend here would pass the enum as a pointer
299 // and get a garbage style, silently.
300 const InitWithStyle = *const fn (objc.id, objc.SEL, i64) callconv(.c) objc.id;
301 const init_with_style: InitWithStyle = @ptrCast(&objc.objc_msgSend);
302 const generator = init_with_style(allocated, sel_init_with_style, style);
303 if (generator == null) {
304 objc.release(allocated);
305 return error.GeneratorNotCreated;
306 }
307 defer objc.release(generator);
308
309 objc.msgSend(generator, sel_impact_occurred);
310 },
311 .notification_success, .notification_warning, .notification_error => {
312 const class = objc.objc_getClass("UINotificationFeedbackGenerator") orelse
313 return error.ClassNotFound;
314 const sel_notification_occurred = objc.sel_registerName("notificationOccurred:") orelse
315 return error.SelectorNotFound;
316
317 const generator = try objc.allocInit(class);
318 if (generator == null) return error.GeneratorNotCreated;
319 defer objc.release(generator);
320
321 // UINotificationFeedbackType: Success/Warning/Error.
322 const feedback_type: i64 = switch (haptic_type) {
323 .notification_warning => 1,
324 .notification_error => 2,
325 else => 0,
326 };
327
328 const NotificationOccurred = *const fn (objc.id, objc.SEL, i64) callconv(.c) void;
329 const notification_occurred: NotificationOccurred = @ptrCast(&objc.objc_msgSend);
330 notification_occurred(generator, sel_notification_occurred, feedback_type);
331 },
332 }
333}
334
335/// `triggerHaptic(style:)` (`CraftApp.swift:2562-2579`) as a mapping.
336///
337/// Written, tested, and — while `haptic` is refused — not yet reachable from a
338/// handler. It is here because it is the finished half of the blocked action:
339/// once `enableHaptics` is visible to Zig, `haptic()` becomes a parse of
340/// `style` plus a call to this plus a call to `triggerHapticChecked`, with the
341/// mapping already pinned against the spec rather than re-derived under time
342/// pressure.
343///
344/// Two quirks are reproduced rather than fixed, because fixing either here
345/// would be a behaviour change hidden inside a migration:
346///
347/// - **`"soft"` maps to a medium impact, not a selection haptic.**
348/// `craft.haptics.selection()` posts `'soft'` (`CraftApp.swift:2334`), which
349/// hits the spec's `default:` arm — so the shipped `selection()` has never
350/// produced a selection haptic, and `.selection` is reachable only through a
351/// direct `craft.haptic('selection')`. `HapticType` has no `impact_soft`
352/// tag, so honouring `'soft'` as `UIImpactFeedbackStyleSoft` would mean
353/// widening the enum *and* changing what the shipped API does.
354/// - **An unknown style is absorbed, not rejected.** A typo is indistinguishable
355/// from `"medium"`. With no reply channel there is nowhere to report it, and
356/// the spec's `default:` swallows it too.
357///
358/// The field is `style`. Not `type` — `examples/web_to_native/app.html` posts
359/// `{type: ...}` but through a local `invoke` shim that never reaches this
360/// bridge, and `craft-bridge.d.ts`'s `triggerHaptic({style})` is a different
361/// action name in a different namespace.
362fn hapticTypeForStyle(style: []const u8) HapticType {
363 if (std.mem.eql(u8, style, "light")) return .impact_light;
364 if (std.mem.eql(u8, style, "heavy")) return .impact_heavy;
365 if (std.mem.eql(u8, style, "success")) return .notification_success;
366 if (std.mem.eql(u8, style, "warning")) return .notification_warning;
367 if (std.mem.eql(u8, style, "error")) return .notification_error;
368 if (std.mem.eql(u8, style, "selection")) return .selection;
369 return .impact_medium;
370}
371
372// =============================================================================
373// libdispatch, and the one block this file needs.
374//
375// Declared inside a struct so they stay lazily analysed: off Darwin the only
376// reference to any of them sits behind a comptime-false `isDarwin()` guard, so
377// a Linux host build never asks the linker for a symbol libSystem owns.
378// =============================================================================
379
380const dispatch = struct {
381 const DISPATCH_TIME_NOW: u64 = 0;
382
383 extern "c" fn dispatch_time(when: u64, delta: i64) u64;
384 extern "c" fn dispatch_after(when: u64, queue: *anyopaque, block: *const anyopaque) void;
385
386 /// `dispatch_get_main_queue()` is a macro over this symbol, not a function
387 /// call — the main queue *is* the address of this global.
388 extern var _dispatch_main_q: anyopaque;
389};
390
391const BlockDescriptor = extern struct {
392 reserved: usize = 0,
393 size: usize,
394};
395
396/// `dispatch_block_t` is `void (^)(void)`, so `invoke` takes one argument — the
397/// block itself — unlike the two-argument completion blocks in
398/// `bridge_permissions.zig`.
399const DispatchBlock = extern struct {
400 isa: *anyopaque,
401 flags: c_int,
402 reserved: c_int,
403 invoke: *const fn (*const anyopaque) callconv(.c) void,
404 descriptor: *const BlockDescriptor,
405};
406
407/// A *global* block, not a stack one — and that is the whole reason this is
408/// spelled out rather than copied from `bridge_permissions.zig`.
409///
410/// Those blocks are handed to `requestAccess…`, which consumes them
411/// synchronously, so `_NSConcreteStackBlock` is fine there. `dispatch_after`
412/// instead calls `Block_copy` on its argument and holds it until the block
413/// fires. `Block_copy` on a global block is the identity function; on a stack
414/// block it heap-copies and later releases, which happens to work for a
415/// non-capturing block but leans on an implementation detail of libclosure for
416/// a lifetime that outlives this call.
417///
418/// It captures nothing because the spec's block captures nothing: every
419/// scheduled entry is the same medium impact, so one file-scope constant serves
420/// an entire pattern.
421extern var _NSConcreteGlobalBlock: anyopaque;
422
423const BLOCK_IS_GLOBAL: c_int = 1 << 28;
424
425/// Runs on the main queue, after the reply has already gone out. There is no
426/// caller left to tell, so a failure is logged — the alternative is a silent
427/// nothing, which is how you end up unable to tell a broken generator from a
428/// device with System Haptics switched off.
429fn mediumImpactInvoke(_: *const anyopaque) callconv(.c) void {
430 triggerHapticChecked(.impact_medium) catch |err| {
431 std.log.warn("craft-bridge: scheduled vibrate impact did not fire: {}", .{err});
432 };
433}
434
435const medium_impact_descriptor = BlockDescriptor{ .size = @sizeOf(DispatchBlock) };
436
437const medium_impact_block = DispatchBlock{
438 .isa = &_NSConcreteGlobalBlock,
439 .flags = BLOCK_IS_GLOBAL,
440 .reserved = 0,
441 .invoke = mediumImpactInvoke,
442 .descriptor = &medium_impact_descriptor,
443};
444
445const testing = std.testing;
446
447test "the table declares exactly the two actions the dispatcher serves" {
448 try testing.expectEqual(@as(usize, 2), capability_actions.len);
449 try testing.expectEqualStrings(A.haptic, capability_actions[0].name);
450 try testing.expectEqualStrings(A.vibrate, capability_actions[1].name);
451
452 // The replies are the halves that go wrong invisibly: a `.result` that
453 // never replies parks the caller for 30s, and a `.none` that is awaited
454 // resolves immediately and means nothing.
455 try testing.expectEqual(capabilities.Reply.none, capability_actions[0].reply);
456 try testing.expectEqual(capabilities.Reply.result, capability_actions[1].reply);
457}
458
459test "an unavailable action carries the reason with it" {
460 // Without a reason, `unavailable` tells an app that something is broken and
461 // nothing about what to do next — which is most of the value of declaring
462 // it at all.
463 for (capability_actions) |decl| {
464 if (decl.status != .unavailable) continue;
465 try testing.expect(decl.reason != null);
466 try testing.expect(decl.reason.?.len > 0);
467 }
468 try testing.expectEqual(capabilities.ActionStatus.unavailable, capability_actions[0].status);
469 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[1].status);
470}
471
472test "every declared action dispatches — none of them falls through as unknown" {
473 // The drift this whole effort exists to make impossible: a name in the
474 // table that the chain does not compare against would be reported to the
475 // page as served, and reach the host shim instead.
476 var bridge = HapticsBridge.init(testing.allocator);
477 defer bridge.deinit();
478
479 for (capability_actions) |decl| {
480 bridge.handleMessage(decl.name, "{}") catch |err| {
481 try testing.expect(err != bridge_error.BridgeError.UnknownAction);
482 continue;
483 };
484 }
485}
486
487test "an action the namespace does not serve is reported, not ignored" {
488 var bridge = HapticsBridge.init(testing.allocator);
489 defer bridge.deinit();
490
491 try testing.expectError(
492 bridge_error.BridgeError.UnknownAction,
493 bridge.handleMessage("noSuchAction", "{}"),
494 );
495
496 // Near-misses too: the shipped surface has a `craft.haptics` namespace
497 // object, and `haptics` is not `haptic`.
498 try testing.expectError(
499 bridge_error.BridgeError.UnknownAction,
500 bridge.handleMessage("haptics", "{}"),
501 );
502}
503
504test "haptic refuses rather than firing an ungated haptic" {
505 // The refusal is the migration's honest answer while `config.enableHaptics`
506 // has no Zig representation. If this test starts failing because someone
507 // implemented the action, the flag has to have been plumbed through first —
508 // that is the conversation this assertion exists to force.
509 var bridge = HapticsBridge.init(testing.allocator);
510 defer bridge.deinit();
511
512 try testing.expectError(
513 error.HapticsGateNotVisibleToZig,
514 bridge.handleMessage(A.haptic, "{\"style\":\"medium\"}"),
515 );
516}
517
518test "every style the shipped surface posts maps the way the spec's switch does" {
519 try testing.expectEqual(HapticType.impact_light, hapticTypeForStyle("light"));
520 try testing.expectEqual(HapticType.impact_heavy, hapticTypeForStyle("heavy"));
521 try testing.expectEqual(HapticType.notification_success, hapticTypeForStyle("success"));
522 try testing.expectEqual(HapticType.notification_warning, hapticTypeForStyle("warning"));
523 try testing.expectEqual(HapticType.notification_error, hapticTypeForStyle("error"));
524 try testing.expectEqual(HapticType.selection, hapticTypeForStyle("selection"));
525 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("medium"));
526}
527
528test "the spec's default arm absorbs everything else, including 'soft'" {
529 // `craft.haptics.selection()` posts 'soft', which lands here — so the
530 // shipped selection() has never produced a selection haptic. Pinned as a
531 // known quirk so it is not rediscovered as a bug, and not fixed here
532 // because fixing it is a behaviour change, not a port.
533 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("soft"));
534 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("rigid"));
535 // A typo is indistinguishable from "medium". There is no reply channel to
536 // say otherwise on, which is exactly what the spec does too.
537 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("mediuim"));
538 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle(""));
539}
540
541/// Parse a payload and classify its `pattern` the way `vibrate` does.
542fn classify(json: []const u8) !?[]const std.json.Value {
543 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
544 defer parsed.deinit();
545 // The slice would dangle past `parsed.deinit()`, so tests assert on length
546 // and on the null/non-null decision rather than holding the elements.
547 const items = patternArray(parsed.value.object.get("pattern")) orelse return null;
548 return items[0..0];
549}
550
551test "a well-formed integer pattern takes the loop path" {
552 const parsed = try std.json.parseFromSlice(
553 std.json.Value,
554 testing.allocator,
555 "{\"pattern\":[100,50,100,50,200]}",
556 .{},
557 );
558 defer parsed.deinit();
559
560 const items = patternArray(parsed.value.object.get("pattern")).?;
561 try testing.expectEqual(@as(usize, 5), items.len);
562 try testing.expectEqual(@as(i64, 100), items[0].integer);
563}
564
565test "an empty pattern is a successful cast, so it schedules nothing" {
566 // The asymmetry that matters: `craft.haptics.vibrate()` sends `[]`, which
567 // must fire nothing, while a *missing* pattern fires one medium impact.
568 // Collapsing the two would make the common call buzz.
569 const parsed = try std.json.parseFromSlice(
570 std.json.Value,
571 testing.allocator,
572 "{\"pattern\":[]}",
573 .{},
574 );
575 defer parsed.deinit();
576
577 const items = patternArray(parsed.value.object.get("pattern"));
578 try testing.expect(items != null);
579 try testing.expectEqual(@as(usize, 0), items.?.len);
580}
581
582test "an absent, non-array, or non-integer pattern takes the single-impact arm" {
583 // All three are `as? [Int]` failures in the spec, and all three still reply
584 // `true`. Returning MissingData for the absent case would be stricter than
585 // the surface: `craft.vibrate()` with no argument posts no `pattern` at all.
586 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{}"));
587 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":null}"));
588 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":100}"));
589 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":\"100\"}"));
590 // One bad element spoils the cast, exactly as `[Any] as? [Int]` does.
591 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":[100.5]}"));
592 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":[100,\"x\"]}"));
593 try testing.expectEqual(@as(?[]const std.json.Value, null), try classify("{\"pattern\":[100,null]}"));
594}
595
596test "odd entries are pauses that do nothing at all, not even to timing" {
597 try testing.expectEqual(@as(?i64, null), impactDelayNs(1, 50));
598 try testing.expectEqual(@as(?i64, null), impactDelayNs(3, 5000));
599 try testing.expect(impactDelayNs(0, 50) != null);
600 try testing.expect(impactDelayNs(2, 50) != null);
601}
602
603test "zero and negative durations are skipped" {
604 try testing.expectEqual(@as(?i64, null), impactDelayNs(0, 0));
605 try testing.expectEqual(@as(?i64, null), impactDelayNs(0, -100));
606}
607
608test "delays are measured from now, so the canonical pattern collapses to two" {
609 // [100, 50, 100, 50, 200] fires at +100ms, +100ms and +200ms — indices 0
610 // and 2 land on the same instant. This is the spec's semantics and it is
611 // almost certainly not what "pattern" means; it is reproduced on purpose,
612 // and this assertion is here so a future reader can tell the difference
613 // between a bug and a decision before changing it.
614 const pattern = [_]i64{ 100, 50, 100, 50, 200 };
615 var delays: [3]i64 = undefined;
616 var count: usize = 0;
617 for (pattern, 0..) |duration_ms, index| {
618 if (impactDelayNs(index, duration_ms)) |delay| {
619 delays[count] = delay;
620 count += 1;
621 }
622 }
623
624 try testing.expectEqual(@as(usize, 3), count);
625 try testing.expectEqual(@as(i64, 100 * std.time.ns_per_ms), delays[0]);
626 try testing.expectEqual(delays[0], delays[1]);
627 try testing.expectEqual(@as(i64, 200 * std.time.ns_per_ms), delays[2]);
628}
629
630test "an absurd duration saturates instead of panicking" {
631 // The page controls these integers. An overflowing multiply is a panic in a
632 // safe build, which would turn a malformed payload into a dead app.
633 try testing.expectEqual(
634 @as(?i64, std.math.maxInt(i64)),
635 impactDelayNs(0, std.math.maxInt(i64)),
636 );
637}
638
639test "the vibrate reply is the bare JSON literal the hand-off path already sends" {
640 // Not `{"success":true}`. The spec serialises with `.fragmentsAllowed`, so
641 // a page on the hand-off path receives `true` today, and it must keep
642 // receiving `true`. Truthy also matters: `craft-bridge.js` resolves with
643 // `payload || {}`, which would turn `false` into an empty object.
644 try testing.expectEqualStrings("true", vibrate_reply);
645
646 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, vibrate_reply, .{});
647 defer parsed.deinit();
648 try testing.expectEqual(true, parsed.value.bool);
649}
650
651test "the scheduled block is global, so dispatch_after's Block_copy is a no-op" {
652 // A stack block would be heap-copied and later released by libclosure —
653 // workable for a non-capturing block, but a lifetime that outlives the call
654 // resting on an implementation detail. The flag is what makes Block_copy
655 // return the same pointer.
656 try testing.expectEqual(@as(c_int, 0x10000000), BLOCK_IS_GLOBAL);
657 try testing.expectEqual(@sizeOf(DispatchBlock), medium_impact_descriptor.size);
658
659 // Asserted on the block, not only on the constants it is assembled from.
660 // Without these two the test name is a claim nothing checks: swapping in
661 // the `_NSConcreteStackBlock` spelling that five other files in this tree
662 // use leaves `BLOCK_IS_GLOBAL` and the descriptor size both correct and the
663 // block no longer global. Guarded because naming `_NSConcreteGlobalBlock`
664 // is what pulls the symbol into the link.
665 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
666 try testing.expectEqual(BLOCK_IS_GLOBAL, medium_impact_block.flags);
667 try testing.expectEqual(&_NSConcreteGlobalBlock, medium_impact_block.isa);
668 try testing.expectEqual(&medium_impact_descriptor, medium_impact_block.descriptor);
669}
packages/zig/src/bridge_mobile_storage.zigadded+950-0
Changes to packages/zig/src/bridge_mobile_storage.zig
@@ -0,0 +1,950 @@
1//! The shared-storage actions of the `mobile` namespace: `setSharedItem`,
2//! `getSharedItem`, `removeSharedItem`.
3//!
4//! **These are Keychain Services, not `NSUserDefaults`.** The Swift helpers are
5//! `setSharedKeychainItem` / `getSharedKeychainItem` / `removeSharedKeychainItem`
6//! and they call `SecItemAdd` / `SecItemCopyMatching` / `SecItemDelete`. There
7//! is no suite name and no key namespacing anywhere in the iOS path: an item is
8//! identified by `kSecClass` + `kSecAttrAccount` (+ `kSecAttrAccessGroup`), and
9//! `kSecAttrService` is never set. Android is the platform that uses
10//! preferences — `CraftBridge.kt.template` does `getSharedPreferences(...)` —
11//! and `UserDefaults(suiteName:)` in `CraftApp.swift` belongs to `updateWidget`,
12//! a different action. Adding a service attribute or a key prefix here would
13//! make items written by Zig invisible to the Swift shim and vice versa, which
14//! is the one thing a partial migration must not do.
15//!
16//! ## What is carried across exactly, because it is the observable contract
17//!
18//! - **`remove` treats `errSecItemNotFound` as success**, and `get` treats it
19//! as `{"value":null}`. Only `set` has no not-found case.
20//! - **Accessibility is `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`** —
21//! device-local, never iCloud-synced, unreadable while the device is locked.
22//! Set on the add only; `get` and `remove` do not mention it.
23//! - **The reply shapes are the Swift dictionary literals verbatim**:
24//! `{"success":true,"key":…}` for set/remove, `{"value":…,"key":…}` for get.
25//! `packages/typescript/types/craft.d.ts:626` claims `{set:boolean}` and
26//! `{removed:boolean}`; no implementation has ever sent either field —
27//! Swift and Android both send `success`/`key`. The runtime is the truth and
28//! the `.d.ts` is a separate, pre-existing bug, not something to satisfy by
29//! inventing fields here.
30//!
31//! ## What is deliberately not carried across
32//!
33//! **Swift's silent hang.** Each dispatcher arm is an `if let` with no `else`,
34//! so a missing `key` — or a `value` that is not a string — falls out of the
35//! `switch` having replied nothing at all. The page's promise then never
36//! settles (the injected `_callbacks` have no timeout of their own). Every path
37//! in this file ends in a reply or an error.
38//!
39//! **A `key` or `group` with an embedded NUL.** Swift stores it whole; this
40//! file refuses it, because the route Zig has to the keychain
41//! (`stringWithUTF8String:`) truncates at the NUL and would file the item under
42//! a shorter name than the caller gave. See `requireNulFree`.
43//!
44//! **Swift's shared delete/add dictionary.** `setSharedKeychainItem` hands the
45//! *same* dictionary — `kSecValueData` and `kSecAttrAccessible` included — to
46//! both `SecItemDelete` and `SecItemAdd`, and discards the delete's status. If
47//! `SecItemDelete` rejects a query carrying `kSecValueData`, the discarded
48//! status hides it and the following add returns `errSecDuplicateItem`, i.e.
49//! overwriting an existing key fails. Nothing in this repo exercises `set`
50//! twice, so which way it actually goes is unproven. `keychainSet` below builds
51//! the delete-scoped query first (class + account + access group, the same shape
52//! `remove` already uses successfully) and only then adds the value attributes.
53//! That is behaviourally identical wherever the Swift already works, and works
54//! in the case where it may not.
55//!
56//! ## Two gaps that are real and are not papered over
57//!
58//! 1. **`group` cannot work on a signed device build from this toolchain.**
59//! `renderEntitlements` in `packages/ios/src/index.ts` emits
60//! `associated-domains`, `application-groups`, `healthkit` and
61//! `aps-environment` — never `keychain-access-groups`. So passing a `group`
62//! on hardware returns `errSecMissingEntitlement` (-34018) no matter which
63//! language serves the action. The simulator does not enforce keychain
64//! entitlements, so it will appear to work there and fail on device. This is
65//! a pre-existing spec gap; `failStatus` surfaces it and names it in the log
66//! rather than hiding it. Closing it means a `keychainAccessGroups` config
67//! key and an entitlements entry, which is out of scope here.
68//! 2. **`packages/zig/src/keychain.zig` is not reused, on purpose.** Its Apple
69//! arms are `_ = account; return;` for set and `return null` for get, under
70//! an `if (os.tag == .macos or os.tag == .ios)` — so on Apple platforms every
71//! write silently succeeds and every read is empty. That is precisely the
72//! fabricated-success class this migration exists to remove, and building on
73//! it would inherit it. The Security calls here are written fresh.
74//!
75//! ## Build note
76//!
77//! These are the first Zig-side references to Security.framework. A generated
78//! app is fine — `CraftApp.swift` does `import Security`, which autolinks it —
79//! and `packages/ios/fixtures/zig-slice/build-and-run.sh` now passes
80//! `-framework Security` explicitly, which it must: nothing else in the fixture
81//! links it. `packages/ios/templates/project.yml.template` declares no frameworks
82//! at all and relies entirely on Swift autolinking, so an app template that ever
83//! drops `import Security` breaks this handler too.
84
85const std = ;
86const builtin = ;
87const capabilities = ;
88const bridge_error = ;
89const objc_runtime = ;
90
91const objc = objc_runtime.objc;
92
93/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
94///
95/// `objc_runtime.objc` is an empty struct off Darwin, and a function
96/// *signature* is analysed even when a comptime platform guard makes its body
97/// unreachable, so naming `objc.id` in the signatures below would break the
98/// host build. It stays a single optional pointer, never `?objc.id`, because a
99/// double optional is illegal in a `callconv(.c)` type.
100const Id = ?*anyopaque;
101
102/// `CFTypeRef` is the same `?*anyopaque`, but the distinction is worth keeping
103/// in the signatures: the `kSec*` values below are CoreFoundation globals that
104/// happen to be toll-free bridged, not objects Zig created or owns.
105const CFTypeRef = ?*anyopaque;
106
107/// `SInt32`. Not `c_int` — the width of an `OSStatus` is fixed by the ABI, and
108/// every keychain status of interest is negative.
109const OSStatus = i32;
110
111// Security.framework and CoreFoundation. C functions and CFString globals —
112// there is no Objective-C messaging in `SecItem*`; the only ObjC work in this
113// file is building the query dictionary and moving bytes out of the result.
114// Modelled on `prefs_macos.zig`, which declares CoreFoundation the same way.
115extern "c" fn SecItemAdd(attributes: CFTypeRef, result: ?*CFTypeRef) OSStatus;
116extern "c" fn SecItemCopyMatching(query: CFTypeRef, result: ?*CFTypeRef) OSStatus;
117extern "c" fn SecItemDelete(query: CFTypeRef) OSStatus;
118extern "c" fn CFRelease(cf: CFTypeRef) void;
119
120extern "c" const kSecClass: CFTypeRef;
121extern "c" const kSecClassGenericPassword: CFTypeRef;
122extern "c" const kSecAttrAccount: CFTypeRef;
123extern "c" const kSecAttrAccessGroup: CFTypeRef;
124extern "c" const kSecAttrAccessible: CFTypeRef;
125extern "c" const kSecAttrAccessibleWhenUnlockedThisDeviceOnly: CFTypeRef;
126extern "c" const kSecValueData: CFTypeRef;
127extern "c" const kSecReturnData: CFTypeRef;
128extern "c" const kSecMatchLimit: CFTypeRef;
129extern "c" const kSecMatchLimitOne: CFTypeRef;
130extern "c" const kCFBooleanTrue: CFTypeRef;
131
132/// Only the two statuses this file *branches* on, plus the one it maps to a
133/// distinct `BridgeError`.
134///
135/// Deliberately not a full transcription of `SecBase.h`: a mis-typed constant
136/// would silently reclassify a failure as some other failure, which is strictly
137/// worse than leaving it unmapped. Everything else is reported by its raw
138/// number in the log and as `NativeCallFailed` to the page.
139const errSecSuccess: OSStatus = 0;
140const errSecItemNotFound: OSStatus = -25300;
141const errSecMissingEntitlement: OSStatus = -34018;
142
143/// The action names, spelled exactly as the Swift `case` labels spell them.
144///
145/// `test/ios_conformance_test.zig` matches the two lists by string in both
146/// directions, so a tidier spelling here does not read as "migrated" — it reads
147/// as "Zig serves an action the spec does not have", and the Swift arm stays
148/// live forever.
149pub const A = struct {
150 pub const set_shared_item = "setSharedItem";
151 pub const get_shared_item = "getSharedItem";
152 pub const remove_shared_item = "removeSharedItem";
153};
154
155/// All three `.result`. Each Swift path terminates in exactly one
156/// `resolveCallback` or `rejectCallback`, and each injected JS method returns a
157/// promise the page awaits — `craft.sharedKeychain.get(...)` is consumed as
158/// `result.value`. Declaring any of them `.none` would strand that caller for
159/// the whole request timeout.
160///
161/// All three are `.live`, including the `group` argument: the no-group path
162/// works everywhere, and the entitlement gap described in the module comment is
163/// a property of one optional argument on device builds, not of the action. It
164/// is reported, per call, when it bites.
165pub const capability_actions = [_]capabilities.ActionDecl{
166 .{ .name = A.set_shared_item, .reply = .result },
167 .{ .name = A.get_shared_item, .reply = .result },
168 .{ .name = A.remove_shared_item, .reply = .result },
169};
170
171/// One request's fields, after validation.
172///
173/// `group` is `?[]const u8` where null means "no access group" — which JSON
174/// `null` and an absent field both mean, because the injected JS sends
175/// `group || null` and Swift's `body["group"] as? String` turns `NSNull` into
176/// `nil`. An *empty string* is not null: it is passed to the keychain verbatim
177/// and the OSStatus is allowed to speak. Mapping `""` to "no group" would be a
178/// silently-dropped payload field of exactly the `craft.fs.writeFile` shape.
179const Request = struct {
180 key: []const u8,
181 value: ?[]const u8 = null,
182 group: ?[]const u8 = null,
183};
184
185pub const StorageBridge = struct {
186 allocator: std.mem.Allocator,
187
188 const Self = @This();
189
190 pub fn init(allocator: std.mem.Allocator) Self {
191 return .{ .allocator = allocator };
192 }
193
194 pub fn deinit(_: *Self) void {}
195
196 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
197 if (std.mem.eql(u8, action, A.set_shared_item)) {
198 try self.setSharedItem(data);
199 } else if (std.mem.eql(u8, action, A.get_shared_item)) {
200 try self.getSharedItem(data);
201 } else if (std.mem.eql(u8, action, A.remove_shared_item)) {
202 try self.removeSharedItem(data);
203 } else {
204 return bridge_error.BridgeError.UnknownAction;
205 }
206 }
207
208 /// Store `value` under `key`, replacing whatever was there.
209 fn setSharedItem(self: *Self, data: []const u8) !void {
210 var parsed = try parsePayload(self.allocator, data);
211 defer parsed.deinit();
212
213 const request = try parseRequest(parsed.value, .value_required);
214 // Not `request.value.?`: `parseRequest` guarantees it, but an unwrap
215 // that only holds because of an argument two frames up is the kind of
216 // invariant that survives exactly until someone reorders the callers.
217 const value = request.value orelse return bridge_error.BridgeError.MissingData;
218
219 try keychainSet(self.allocator, request.key, value, request.group);
220
221 const json = try successReply(self.allocator, request.key);
222 defer self.allocator.free(json);
223 bridge_error.sendResultToJS(self.allocator, A.set_shared_item, json);
224 }
225
226 /// Read `key`, or report that there is nothing under it.
227 ///
228 /// A missing item is a *success* with `"value":null`, not an error — that
229 /// is what the page distinguishes, and what `packages/ios/README.md`
230 /// documents (`console.log(result.value)`).
231 fn getSharedItem(self: *Self, data: []const u8) !void {
232 var parsed = try parsePayload(self.allocator, data);
233 defer parsed.deinit();
234
235 const request = try parseRequest(parsed.value, .value_absent);
236
237 const found = try keychainGet(self.allocator, request.key, request.group);
238 defer if (found) |bytes| self.allocator.free(bytes);
239
240 const json = try valueReply(self.allocator, request.key, found);
241 defer self.allocator.free(json);
242 bridge_error.sendResultToJS(self.allocator, A.get_shared_item, json);
243 }
244
245 /// Delete `key`. "It was not there" is success, exactly as in Swift —
246 /// `remove` is idempotent and a caller cannot act on the difference.
247 fn removeSharedItem(self: *Self, data: []const u8) !void {
248 var parsed = try parsePayload(self.allocator, data);
249 defer parsed.deinit();
250
251 const request = try parseRequest(parsed.value, .value_absent);
252
253 try keychainRemove(self.allocator, request.key, request.group);
254
255 const json = try successReply(self.allocator, request.key);
256 defer self.allocator.free(json);
257 bridge_error.sendResultToJS(self.allocator, A.remove_shared_item, json);
258 }
259};
260
261/// Whether the action being served needs a `value` field.
262const ValueRule = enum { value_required, value_absent };
263
264/// Parse `d`, distinguishing a bad payload from a failed allocation.
265///
266/// Telling the page INVALID_JSON about its own perfectly good JSON sends
267/// whoever debugs it to the wrong side of the bridge, so `OutOfMemory`
268/// propagates as itself.
269fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
270 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
271 error.OutOfMemory => return err,
272 else => return bridge_error.BridgeError.InvalidJSON,
273 };
274}
275
276/// The `key`/`value`/`group` triple, or the reason it cannot be used.
277///
278/// Pure, so the host tests can pin every outcome that Swift's `as? String`
279/// collapsed into one silent fall-through. The field names are the three the
280/// page posts and are pinned on both sides of the migration: `handleAction` in
281/// `CraftApp.swift` parses `d` straight into `body`, and the un-migrated shim
282/// reads `body["key"]`, `body["value"]`, `body["group"]`. A prettier spelling
283/// here would make the Zig handler and the fallback read different payloads.
284///
285/// A `group` that is present but not a string is `InvalidParameter` rather than
286/// ignored. Swift's `as? String` would drop it and quietly operate on the
287/// *default* access group — a different item than the caller named, reported as
288/// success.
289fn parseRequest(payload: std.json.Value, rule: ValueRule) !Request {
290 const object = switch (payload) {
291 .object => |o| o,
292 else => return bridge_error.BridgeError.InvalidJSON,
293 };
294
295 const key_field = object.get("key") orelse return bridge_error.BridgeError.MissingData;
296 const key = switch (key_field) {
297 .string => |s| s,
298 else => return bridge_error.BridgeError.InvalidParameter,
299 };
300 try requireNulFree(key);
301
302 var request = Request{ .key = key };
303
304 if (rule == .value_required) {
305 const value_field = object.get("value") orelse return bridge_error.BridgeError.MissingData;
306 request.value = switch (value_field) {
307 .string => |s| s,
308 else => return bridge_error.BridgeError.InvalidParameter,
309 };
310 }
311
312 if (object.get("group")) |group_field| {
313 request.group = switch (group_field) {
314 // `group || null` in the injected JS, so `null` is the normal way
315 // for a page to say "no access group" — as common as omitting it.
316 .null => null,
317 .string => |s| blk: {
318 try requireNulFree(s);
319 break :blk s;
320 },
321 else => return bridge_error.BridgeError.InvalidParameter,
322 };
323 }
324
325 return request;
326}
327
328/// Refuse a `key` or `group` carrying the one byte it cannot survive.
329///
330/// Both reach the keychain as NSStrings built by `objc.createNSString`, i.e.
331/// `+[NSString stringWithUTF8String:]`, which stops at the first NUL. A page can
332/// reach that: `\u0000` is a legal JSON escape and `std.json` decodes it to the
333/// byte rather than rejecting it. Without the check,
334/// `set("a\u0000b", secret)` would write the item named `"a"` and reply
335/// `{"success":true,"key":"a\u0000b"}` over the top of it, and a later
336/// `get("a")` would hand back a secret filed under a different page key. That
337/// is a silently-altered payload field reported as success: the two failures
338/// this migration exists to remove, at once.
339///
340/// Swift keeps the NUL (`body["key"] as? String` bridges the whole string), so
341/// refusing is a deliberate divergence. It is the only answer available here
342/// that is not "a different item than you named, reported as success"; storing
343/// it faithfully would mean a `stringWithBytes:length:encoding:` helper this
344/// file does not otherwise need.
345///
346/// `value` needs no equivalent: `makeData` passes bytes and a length, and the
347/// NSData round-trip test below pins that a NUL survives it.
348fn requireNulFree(s: []const u8) !void {
349 if (std.mem.indexOfScalar(u8, s, 0) != null) return bridge_error.BridgeError.InvalidParameter;
350}
351
352/// `{"success":true,"key":"…"}` — the set/remove reply.
353///
354/// Built with `appendJsonEscaped` rather than `bufPrint`, unlike
355/// `bridge_mobile.describeDevice`: `key` is page-controlled and is echoed back,
356/// so a `"` or `\` in it would produce broken JavaScript at `formatResultJS`.
357/// Device fields come from UIKit and cannot contain a quote; a keychain key can.
358fn successReply(allocator: std.mem.Allocator, key: []const u8) ![]u8 {
359 var out: std.ArrayListUnmanaged(u8) = .empty;
360 errdefer out.deinit(allocator);
361
362 try out.appendSlice(allocator, "{\"success\":true,\"key\":\"");
363 try bridge_error.appendJsonEscaped(allocator, &out, key);
364 try out.appendSlice(allocator, "\"}");
365
366 return out.toOwnedSlice(allocator);
367}
368
369/// `{"value":"…","key":"…"}`, or `{"value":null,"key":"…"}` when absent.
370///
371/// JSON `null` and the empty string are different answers and stay different:
372/// a stored `""` is a value the page wrote and must read back as `""`.
373fn valueReply(allocator: std.mem.Allocator, key: []const u8, value: ?[]const u8) ![]u8 {
374 var out: std.ArrayListUnmanaged(u8) = .empty;
375 errdefer out.deinit(allocator);
376
377 try out.appendSlice(allocator, "{\"value\":");
378 if (value) |v| {
379 try out.append(allocator, '"');
380 try bridge_error.appendJsonEscaped(allocator, &out, v);
381 try out.append(allocator, '"');
382 } else {
383 try out.appendSlice(allocator, "null");
384 }
385 try out.appendSlice(allocator, ",\"key\":\"");
386 try bridge_error.appendJsonEscaped(allocator, &out, key);
387 try out.appendSlice(allocator, "\"}");
388
389 return out.toOwnedSlice(allocator);
390}
391
392/// The nearest `BridgeError` to an OSStatus. Pure, so the mapping is testable.
393///
394/// The mapping is lossy and there is no route that is not: `sendErrorToJS`
395/// takes a `BridgeError` enum, so the number cannot ride along and the page
396/// cannot be told Swift's `"Keychain error: -34018"`. `ios_dispatch.zig` has a
397/// free-text error route (`craft_ios_deliver_error`), but it is the shim's
398/// entry point and `ios_dispatch.zig` imports the mobile bridges — reaching
399/// back for it would be a circular import bought for a nicer string. So the raw
400/// status goes to the log instead; see `failStatus`.
401fn errorForStatus(status: OSStatus) bridge_error.BridgeError {
402 return switch (status) {
403 errSecItemNotFound => bridge_error.BridgeError.NotFound,
404 errSecMissingEntitlement => bridge_error.BridgeError.PermissionDenied,
405 else => bridge_error.BridgeError.NativeCallFailed,
406 };
407}
408
409/// Log a failed keychain call and return the error the page will see.
410///
411/// The log line is the whole diagnostic on a device — there is no console to
412/// watch — and it carries the one thing the page cannot be given, the raw
413/// OSStatus. The `key` is logged because it names the failing call; the
414/// **value is never logged**, here or anywhere else in this file, because it is
415/// the secret the caller chose the keychain for.
416fn failStatus(op: []const u8, key: []const u8, group: ?[]const u8, status: OSStatus) bridge_error.BridgeError {
417 std.log.warn("keychain {s} failed for key '{s}': OSStatus {d}", .{ op, key, status });
418 if (group) |g| {
419 std.log.warn(
420 " ...with access group '{s}'. `renderEntitlements` (packages/ios/src/index.ts) never emits " ++
421 "`keychain-access-groups`, so a signed device build gets -34018 here whatever the language; " ++
422 "the simulator does not enforce it and will appear to work.",
423 .{g},
424 );
425 }
426 return errorForStatus(status);
427}
428
429/// The query every one of the three operations starts from: class + account,
430/// plus the access group when the caller named one.
431///
432/// No `kSecAttrService`, matching the Swift exactly — identity is class +
433/// account (+ group). Adding one would partition the store so that items
434/// written by Zig and by the un-migrated Swift shim could not see each other.
435///
436/// The dictionary is an autoreleased `NSMutableDictionary`, drained at the end
437/// of this run-loop turn; the whole handler completes inside the
438/// `WKScriptMessageHandler` callback, so there is always a pool. It is handed to
439/// `SecItem*` as a `CFDictionaryRef` — toll-free bridging, which is what
440/// `query as CFDictionary` does in Swift.
441fn newQuery(allocator: std.mem.Allocator, key: []const u8, group: ?[]const u8) !Id {
442 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
443
444 const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse return error.ClassNotFound;
445 const sel_dictionary = objc.sel_registerName("dictionary") orelse return error.SelectorNotFound;
446 const query = objc.msgSendId(NSMutableDictionary, sel_dictionary);
447 if (query == null) return error.QueryAllocationFailed;
448
449 try setQueryValue(query, kSecClass, kSecClassGenericPassword);
450
451 const account = try objc.createNSString(key, allocator);
452 try setQueryValue(query, kSecAttrAccount, account);
453
454 if (group) |g| {
455 const ns_group = try objc.createNSString(g, allocator);
456 try setQueryValue(query, kSecAttrAccessGroup, ns_group);
457 }
458
459 return query;
460}
461
462/// `-[NSMutableDictionary setObject:forKey:]`, with the nil check that keeps a
463/// missing constant from becoming a crash.
464///
465/// `setObject:forKey:` raises an `NSInvalidArgumentException` on a nil key or
466/// value. An ObjC exception cannot be caught from Zig, so it would take the app
467/// down with a stack that names AppKit rather than this file. A null `kSec*`
468/// global means Security.framework is not in the process — a real condition, and
469/// one worth naming rather than discovering as a crash.
470///
471/// The key is a `CFStringRef` passed as an `id`: `CFString` is toll-free bridged
472/// to `NSString` and so conforms to `NSCopying`, which is all `forKey:` asks.
473fn setQueryValue(dict: Id, cf_key: CFTypeRef, value: Id) !void {
474 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
475
476 if (cf_key == null or value == null) return error.MissingKeychainConstant;
477
478 const sel_set = objc.sel_registerName("setObject:forKey:") orelse return error.SelectorNotFound;
479 objc.msgSendVoid2(dict, sel_set, value, cf_key);
480}
481
482/// `+[NSData dataWithBytes:length:]`, autoreleased.
483///
484/// The payload is `value`'s bytes as-is: `std.json` has already validated them
485/// as UTF-8, so there is no conversion step and nothing that can fail the way
486/// Swift's `value.data(using: .utf8)!` force-unwrap could.
487fn makeData(bytes: []const u8) !Id {
488 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
489
490 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
491 const sel_data = objc.sel_registerName("dataWithBytes:length:") orelse return error.SelectorNotFound;
492
493 // The length must be an explicit `c_ulong` (`NSUInteger`): the variadic
494 // `objc_msgSend` cast takes the argument type from what is passed, and a
495 // Zig `usize` literal would be a different type in the signature.
496 const data = objc.msgSendId2(NSData, sel_data, bytes.ptr, @as(c_ulong, @intCast(bytes.len)));
497 if (data == null) return error.NSDataCreationFailed;
498 return data;
499}
500
501/// Store `value` under `key`, replacing any existing item.
502///
503/// Delete-then-add, as in Swift, but with the delete given the *scoped* query —
504/// see the module comment for why. The delete's status is discarded exactly as
505/// Swift discards it: "there was nothing to replace" is the common case and is
506/// not a failure. The add's status is never discarded.
507fn keychainSet(allocator: std.mem.Allocator, key: []const u8, value: []const u8, group: ?[]const u8) !void {
508 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
509
510 const query = try newQuery(allocator, key, group);
511
512 _ = SecItemDelete(query);
513
514 try setQueryValue(query, kSecValueData, try makeData(value));
515 // Device-local and unreadable while locked. On the add only — naming it in
516 // a delete or a match query would filter by it rather than set it.
517 try setQueryValue(query, kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly);
518
519 const status = SecItemAdd(query, null);
520 if (status != errSecSuccess) return failStatus("set", key, group, status);
521}
522
523/// Read `key`, returning an owned copy of its bytes, or null when absent.
524///
525/// Copied out rather than borrowed because the result of
526/// `SecItemCopyMatching` arrives **+1 retained** — ARC released it for Swift,
527/// Zig must not — and the copy is what lets the `CFRelease` sit next to the
528/// read on every path. Forgetting it leaks a page-sized allocation per `get`.
529fn keychainGet(allocator: std.mem.Allocator, key: []const u8, group: ?[]const u8) !?[]u8 {
530 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
531
532 const query = try newQuery(allocator, key, group);
533 // `kCFBooleanTrue`, not an `NSNumber` built here. Swift's `true` bridges to
534 // the `__NSCFBoolean` singleton, and `prefs_macos.zig` already records what
535 // confusing the two costs.
536 try setQueryValue(query, kSecReturnData, kCFBooleanTrue);
537 try setQueryValue(query, kSecMatchLimit, kSecMatchLimitOne);
538
539 var result: CFTypeRef = null;
540 const status = SecItemCopyMatching(query, &result);
541
542 if (status == errSecItemNotFound) return null;
543 if (status != errSecSuccess) return failStatus("get", key, group, status);
544
545 const data = result orelse {
546 // errSecSuccess with no object back should be impossible; if it happens
547 // it is not "the key is empty" and must not be answered as one.
548 std.log.warn("keychain get for key '{s}' returned errSecSuccess with no data", .{key});
549 return bridge_error.BridgeError.NativeCallFailed;
550 };
551 defer CFRelease(data);
552
553 // Swift's `result as? Data`. With `kSecReturnData` + `kSecMatchLimitOne`
554 // this is always a `CFDataRef`, and the type check costs one message send
555 // to be sure it is not something that would be read as raw bytes anyway.
556 if (!try isData(data)) {
557 std.log.warn("keychain get for key '{s}' returned a non-CFData result", .{key});
558 return bridge_error.BridgeError.NativeCallFailed;
559 }
560
561 const len = try dataLength(data);
562 const bytes: []const u8 = if (len == 0)
563 // `-bytes` is documented to return nil for empty data, so a zero length
564 // is answered without dereferencing anything.
565 &[_]u8{}
566 else
567 (try dataBytes(data) orelse {
568 std.log.warn("keychain get for key '{s}' returned {d} bytes at a nil pointer", .{ key, len });
569 return bridge_error.BridgeError.NativeCallFailed;
570 })[0..len];
571
572 if (!std.unicode.utf8ValidateSlice(bytes)) {
573 // Swift's `String(data:encoding:.utf8)` failing falls into the `else`
574 // and rejects with `"Keychain error: 0"` — a status that means success.
575 // Neither `BridgeError` fits well: the native call did succeed, so
576 // `NativeCallFailed` is wrong, and the caller's parameters were fine, so
577 // `InvalidParameter` is only right in the sense that the item under this
578 // key is not something a string-valued API can return. The log line is
579 // the part that is actually diagnostic.
580 std.log.warn("keychain item for key '{s}' is not valid UTF-8; this API only returns strings", .{key});
581 return bridge_error.BridgeError.InvalidParameter;
582 }
583
584 return try allocator.dupe(u8, bytes);
585}
586
587/// Delete `key`. Absent is success, as in Swift.
588fn keychainRemove(allocator: std.mem.Allocator, key: []const u8, group: ?[]const u8) !void {
589 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
590
591 const query = try newQuery(allocator, key, group);
592 const status = SecItemDelete(query);
593 if (status == errSecSuccess or status == errSecItemNotFound) return;
594 return failStatus("remove", key, group, status);
595}
596
597/// `-[NSObject isKindOfClass:[NSData class]]`. `CFData` is toll-free bridged, so
598/// this is true for the `CFDataRef` the keychain hands back.
599fn isData(obj: Id) !bool {
600 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
601
602 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
603 const sel_kind = objc.sel_registerName("isKindOfClass:") orelse return error.SelectorNotFound;
604 const Fn = *const fn (objc.id, objc.SEL, objc.id) callconv(.c) bool;
605 const func: Fn = @ptrCast(&objc.objc_msgSend);
606 return func(obj, sel_kind, NSData);
607}
608
609/// `-[NSData length]`. `NSUInteger`, hence `c_ulong`.
610fn dataLength(data: Id) !usize {
611 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
612
613 const sel_length = objc.sel_registerName("length") orelse return error.SelectorNotFound;
614 const Fn = *const fn (objc.id, objc.SEL) callconv(.c) c_ulong;
615 const func: Fn = @ptrCast(&objc.objc_msgSend);
616 return @intCast(func(data, sel_length));
617}
618
619/// `-[NSData bytes]`. Not `-UTF8String`: the keychain returns a `CFDataRef`,
620/// which has no such selector — sending it would be a runtime crash rather than
621/// a compile error, which is why the selector strings here are worth reading
622/// twice.
623///
624/// The pointer is valid until the data is released, which the caller does after
625/// copying.
626fn dataBytes(data: Id) !?[*]const u8 {
627 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
628
629 const sel_bytes = objc.sel_registerName("bytes") orelse return error.SelectorNotFound;
630 const Fn = *const fn (objc.id, objc.SEL) callconv(.c) ?[*]const u8;
631 const func: Fn = @ptrCast(&objc.objc_msgSend);
632 return func(data, sel_bytes);
633}
634
635const testing = std.testing;
636
637test "the declared actions are the ones the handler serves" {
638 try testing.expectEqual(@as(usize, 3), capability_actions.len);
639 try testing.expectEqualStrings(A.set_shared_item, capability_actions[0].name);
640 try testing.expectEqualStrings(A.get_shared_item, capability_actions[1].name);
641 try testing.expectEqualStrings(A.remove_shared_item, capability_actions[2].name);
642
643 for (capability_actions) |decl| {
644 try testing.expectEqual(capabilities.Reply.result, decl.reply);
645 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
646 }
647}
648
649test "the action names match the Swift case labels exactly" {
650 // The conformance ratchet compares these strings against the `case "…":`
651 // labels in `CraftApp.swift`, in both directions. A prettier spelling would
652 // register as Zig serving an action the spec does not have.
653 try testing.expectEqualStrings("setSharedItem", A.set_shared_item);
654 try testing.expectEqualStrings("getSharedItem", A.get_shared_item);
655 try testing.expectEqualStrings("removeSharedItem", A.remove_shared_item);
656}
657
658test "every declared action dispatches to something" {
659 // `{}` has no `key`, so all three fail validation *before* any Security
660 // call — which is what makes this safe to run on a developer's machine: the
661 // host keychain is never touched. What it rules out is a name in the table
662 // that `handleMessage` does not compare against.
663 var bridge = StorageBridge.init(testing.allocator);
664 defer bridge.deinit();
665
666 for (capability_actions) |decl| {
667 try testing.expectError(
668 bridge_error.BridgeError.MissingData,
669 bridge.handleMessage(decl.name, "{}"),
670 );
671 }
672}
673
674test "an action the namespace does not serve is reported, not ignored" {
675 var bridge = StorageBridge.init(testing.allocator);
676 defer bridge.deinit();
677
678 try testing.expectError(
679 bridge_error.BridgeError.UnknownAction,
680 bridge.handleMessage("clearSharedItems", "{}"),
681 );
682 // The Swift *helper* names, which are not action names. Accepting one would
683 // mean the conformance scan sees an action the spec's dispatcher lacks.
684 try testing.expectError(
685 bridge_error.BridgeError.UnknownAction,
686 bridge.handleMessage("setSharedKeychainItem", "{\"key\":\"k\",\"value\":\"v\"}"),
687 );
688 // The desktop keychain namespace's spelling, which is a different bridge.
689 try testing.expectError(
690 bridge_error.BridgeError.UnknownAction,
691 bridge.handleMessage("setPassword", "{\"key\":\"k\",\"value\":\"v\"}"),
692 );
693}
694
695test "a malformed payload is reported as bad JSON, not as a missing field" {
696 var bridge = StorageBridge.init(testing.allocator);
697 defer bridge.deinit();
698
699 try testing.expectError(
700 bridge_error.BridgeError.InvalidJSON,
701 bridge.handleMessage(A.get_shared_item, "{not json"),
702 );
703}
704
705/// Parse a literal payload and hand the `Request` to `check` while the backing
706/// `std.json.Parsed` is still alive — the slices in a `Request` point into it.
707fn expectRequest(
708 json: []const u8,
709 rule: ValueRule,
710 check: fn (Request) anyerror!void,
711) !void {
712 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
713 defer parsed.deinit();
714 try check(try parseRequest(parsed.value, rule));
715}
716
717fn expectRequestError(json: []const u8, rule: ValueRule, expected: anyerror) !void {
718 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
719 defer parsed.deinit();
720 try testing.expectError(expected, parseRequest(parsed.value, rule));
721}
722
723test "the three field names the page sends are the three that are read" {
724 try expectRequest(
725 "{\"key\":\"token\",\"value\":\"abc123\",\"group\":\"group.app.shared\"}",
726 .value_required,
727 struct {
728 fn check(request: Request) !void {
729 try testing.expectEqualStrings("token", request.key);
730 try testing.expectEqualStrings("abc123", request.value.?);
731 try testing.expectEqualStrings("group.app.shared", request.group.?);
732 }
733 }.check,
734 );
735}
736
737test "an absent key is refused rather than defaulted" {
738 try expectRequestError("{\"value\":\"v\"}", .value_required, bridge_error.BridgeError.MissingData);
739 try expectRequestError("{}", .value_absent, bridge_error.BridgeError.MissingData);
740}
741
742test "an absent value is refused rather than stored as empty" {
743 // Swift's `if let value = body["value"] as? String` fails here and replies
744 // nothing at all. A default of `""` would be worse still: it would silently
745 // overwrite a real secret with an empty string and report success.
746 try expectRequestError("{\"key\":\"k\"}", .value_required, bridge_error.BridgeError.MissingData);
747}
748
749test "a non-string key or value is refused, not coerced" {
750 try expectRequestError("{\"key\":7,\"value\":\"v\"}", .value_required, bridge_error.BridgeError.InvalidParameter);
751 try expectRequestError("{\"key\":\"k\",\"value\":7}", .value_required, bridge_error.BridgeError.InvalidParameter);
752 try expectRequestError("{\"key\":null}", .value_absent, bridge_error.BridgeError.InvalidParameter);
753}
754
755test "an explicitly empty value is a value, and is stored" {
756 // Distinct from an absent one. Writing `""` is a legitimate request and the
757 // page can read it back; only the absent case is an error.
758 try expectRequest(
759 "{\"key\":\"k\",\"value\":\"\"}",
760 .value_required,
761 struct {
762 fn check(request: Request) !void {
763 try testing.expectEqualStrings("", request.value.?);
764 }
765 }.check,
766 );
767}
768
769test "null and absent group both mean no access group" {
770 // The injected JS sends `group || null`, so `null` is how a page normally
771 // says it wants none — as common as omitting the field.
772 const cases = [_][]const u8{
773 "{\"key\":\"k\",\"group\":null}",
774 "{\"key\":\"k\"}",
775 };
776 for (cases) |json| {
777 try expectRequest(json, .value_absent, struct {
778 fn check(request: Request) !void {
779 try testing.expect(request.group == null);
780 }
781 }.check);
782 }
783}
784
785test "an empty group is passed through, not turned into no group" {
786 // The dropped-field regression. `""` is not `null`: silently promoting it
787 // would search the default access group instead of the one the caller named
788 // and report the answer as if it came from theirs.
789 try expectRequest(
790 "{\"key\":\"k\",\"group\":\"\"}",
791 .value_absent,
792 struct {
793 fn check(request: Request) !void {
794 try testing.expect(request.group != null);
795 try testing.expectEqualStrings("", request.group.?);
796 }
797 }.check,
798 );
799}
800
801test "a key or group with an embedded NUL is refused, not truncated" {
802 // `createNSString` goes through `stringWithUTF8String:`, which stops at the
803 // first NUL, so an unchecked "a\u0000b" would address the keychain item
804 // named "a" while the reply echoed the full key back. Two distinct page
805 // keys would then share one item, and `set` would report success for a key
806 // it did not use. `\u0000` is a legal JSON escape and `std.json` decodes it
807 // to the byte, so a page can reach this.
808 try expectRequestError(
809 "{\"key\":\"a\\u0000b\",\"value\":\"v\"}",
810 .value_required,
811 bridge_error.BridgeError.InvalidParameter,
812 );
813 // The access group takes the same route into the query and so takes the
814 // same check: a truncated group names a different access group.
815 try expectRequestError(
816 "{\"key\":\"k\",\"group\":\"g\\u0000x\"}",
817 .value_absent,
818 bridge_error.BridgeError.InvalidParameter,
819 );
820
821 // The *value* keeps its NUL and is not refused: `makeData` passes bytes and
822 // a length, so there is nothing to truncate. Refusing it would be dropping
823 // a payload field the handler can carry perfectly well.
824 try expectRequest(
825 "{\"key\":\"k\",\"value\":\"a\\u0000b\"}",
826 .value_required,
827 struct {
828 fn check(request: Request) !void {
829 try testing.expectEqualSlices(u8, &[_]u8{ 'a', 0, 'b' }, request.value.?);
830 }
831 }.check,
832 );
833}
834
835test "a group that is not a string is refused rather than ignored" {
836 // Swift's `as? String` drops it and quietly operates on the default access
837 // group — a different item than the caller named, reported as success.
838 try expectRequestError("{\"key\":\"k\",\"group\":42}", .value_absent, bridge_error.BridgeError.InvalidParameter);
839}
840
841test "a payload that is not an object is bad JSON, not a missing field" {
842 try expectRequestError("[]", .value_absent, bridge_error.BridgeError.InvalidJSON);
843 try expectRequestError("\"key\"", .value_absent, bridge_error.BridgeError.InvalidJSON);
844}
845
846test "the set and remove reply is the Swift dictionary verbatim" {
847 const json = try successReply(testing.allocator, "token");
848 defer testing.allocator.free(json);
849 try testing.expectEqualStrings("{\"success\":true,\"key\":\"token\"}", json);
850}
851
852test "a found value and a missing one are different replies" {
853 const found = try valueReply(testing.allocator, "token", "abc123");
854 defer testing.allocator.free(found);
855 try testing.expectEqualStrings("{\"value\":\"abc123\",\"key\":\"token\"}", found);
856
857 const missing = try valueReply(testing.allocator, "token", null);
858 defer testing.allocator.free(missing);
859 try testing.expectEqualStrings("{\"value\":null,\"key\":\"token\"}", missing);
860
861 // A stored empty string is not a missing item, and the page must be able to
862 // tell them apart.
863 const empty = try valueReply(testing.allocator, "token", "");
864 defer testing.allocator.free(empty);
865 try testing.expectEqualStrings("{\"value\":\"\",\"key\":\"token\"}", empty);
866}
867
868test "a page-controlled key or value cannot break the reply" {
869 // Both are echoed into JavaScript that `evaluateJavaScript:` parses as
870 // source. `bufPrint` with `{s}`, which `describeDevice` can safely use for
871 // UIKit-sourced strings, would produce a syntax error in the page here.
872 const json = try valueReply(testing.allocator, "we\"ird\\key", "line\none\ttwo\x01");
873 defer testing.allocator.free(json);
874
875 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
876 defer parsed.deinit();
877 try testing.expectEqualStrings("we\"ird\\key", parsed.value.object.get("key").?.string);
878 try testing.expectEqualStrings("line\none\ttwo\x01", parsed.value.object.get("value").?.string);
879}
880
881test "an OSStatus maps to the nearest error, and nothing else is guessed at" {
882 try testing.expectEqual(bridge_error.BridgeError.NotFound, errorForStatus(errSecItemNotFound));
883 // The entitlement gap: a `group` on a signed device build lands here.
884 try testing.expectEqual(bridge_error.BridgeError.PermissionDenied, errorForStatus(errSecMissingEntitlement));
885 // errSecDuplicateItem, errSecParam, errSecInteractionNotAllowed and the
886 // rest are deliberately unmapped — a mis-transcribed constant that
887 // reclassifies one failure as another is worse than a generic one, and the
888 // raw number reaches the log either way.
889 try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, errorForStatus(-25299));
890 try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, errorForStatus(-50));
891 try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, errorForStatus(-25308));
892}
893
894test "the constants that are branched on are the documented ones" {
895 // These three numbers are load-bearing: `errSecItemNotFound` is the
896 // difference between `{"value":null}` and a rejection, and a wrong
897 // `errSecSuccess` would make every call look like a failure.
898 try testing.expectEqual(@as(OSStatus, 0), errSecSuccess);
899 try testing.expectEqual(@as(OSStatus, -25300), errSecItemNotFound);
900 try testing.expectEqual(@as(OSStatus, -34018), errSecMissingEntitlement);
901}
902
903// The remaining tests exercise the Objective-C half against the live runtime.
904// They need no device, no UIKit and no keychain — only libobjc and Foundation,
905// which a macOS host has — and they are skipped off Darwin. They are here
906// because a wrong selector string is not a compile error: it is a runtime crash
907// or a silent no-op, and nothing else in this file would catch one.
908//
909// The keychain calls themselves stay untested. `SecItemCopyMatching` for an
910// absent key was run by hand during development and answered
911// `errSecItemNotFound` — which proves the `kSec*` globals resolve, the
912// dictionary bridges to `CFDictionaryRef`, and the not-found branch is reached —
913// but a headless CI runner may have no login keychain at all, and a test that
914// depends on that is a flake, not a gate. A write/read/delete round trip needs
915// the simulator.
916
917test "the NSData selectors round-trip bytes, embedded NUL included" {
918 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
919
920 // The NUL is the point: `length`/`bytes` are used rather than
921 // `UTF8String` + `std.mem.span`, which would truncate a stored value at the
922 // first zero byte and report the truncation as the value.
923 const payload = "he\x00llo";
924 const data = try makeData(payload);
925 try testing.expect(try isData(data));
926
927 const len = try dataLength(data);
928 try testing.expectEqual(@as(usize, payload.len), len);
929
930 const bytes = (try dataBytes(data)).?;
931 try testing.expectEqualSlices(u8, payload, bytes[0..len]);
932}
933
934test "an empty value is empty data, and is never dereferenced" {
935 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
936
937 const data = try makeData("");
938 try testing.expect(try isData(data));
939 try testing.expectEqual(@as(usize, 0), try dataLength(data));
940}
941
942test "the CFData type check rejects something that is not data" {
943 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
944
945 // Swift's `result as? Data`. An NSString stands in for "the keychain
946 // returned something else", which is the case that must not be read as raw
947 // bytes and handed to the page as a value.
948 const ns = try objc.createNSString("not data", testing.allocator);
949 try testing.expect(!try isData(ns));
950}
packages/zig/src/bridge_mobile_system.zigadded+1043-0
Changes to packages/zig/src/bridge_mobile_system.zig
@@ -0,0 +1,1043 @@
1const std = ;
2const builtin = ;
3const capabilities = ;
4const bridge_error = ;
5const objc_runtime = ;
6const ios_dispatch = ;
7const request_context = ;
8
9const objc = objc_runtime.objc;
10
11/// Whether this build can talk to UIKit at all.
12///
13/// `objc_runtime.zig` degrades `objc` to an empty struct off Darwin, so a
14/// reference to `objc.objc_getClass` on a Linux host is a compile error, not a
15/// runtime one. Every UIKit call in this file therefore sits behind a
16/// *comptime* `if`/`else` — a bare `if (!is_darwin) return error.X;` would not
17/// help, because Zig still analyses the code that follows it.
18const is_darwin = builtin.target.os.tag.isDarwin();
19
20/// Three system actions the page can ask the OS to perform on its behalf.
21///
22/// They share one property that separates them from `getDeviceInfo`: none of
23/// them can answer immediately and truthfully. `openURL` only learns whether
24/// the URL opened when iOS calls back; `share` only learns whether the user
25/// picked an activity when the sheet closes; `requestReview` never learns
26/// anything at all. The Swift implementations all resolved `true` at the point
27/// of asking, before iOS had decided anything — which is the fabricated
28/// success this migration exists to stop.
29///
30/// Two porting decisions are deliberate and are not accidents of translation:
31///
32/// **`openURL` applies no scheme filter**, matching Swift. `external_link.zig`
33/// allows only `http:`/`https:`, but it governs *page-initiated* navigation on
34/// content the app does not control; `craft.openURL` is an app-facing call
35/// where the app chose the URL, and `tel:`, `mailto:`, `shortcuts://` and
36/// custom app schemes are its main use. Filtering here would silently break
37/// them.
38///
39/// **`share` is served unconditionally.** Swift gates it on
40/// `config.enableShare` and replies `CAPABILITY_DISABLED`; `ios.zig`'s
41/// `AppConfig` has no such field, so Zig cannot reproduce the gate. An app that
42/// set `enableShare: false` will now get a share sheet. Restoring the gate
43/// means adding the field to `AppConfig`, not adding a guess here.
44pub const A = struct {
45 pub const open_url = "openURL";
46 pub const share = "share";
47 pub const request_review = "requestReview";
48};
49
50pub const capability_actions = [_]capabilities.ActionDecl{
51 .{ .name = A.open_url, .reply = .result },
52 .{ .name = A.share, .reply = .result },
53 // Live, but only in a host app that links StoreKit: the framework is
54 // resolved by name at runtime, and `objc_getClass` returning null produces
55 // an explicit error rather than a silent success. The fixture's link line
56 // (`packages/ios/fixtures/zig-slice/build-and-run.sh`) does not carry
57 // `-framework StoreKit`, so the simulator harness will see that error until
58 // it does.
59 .{ .name = A.request_review, .reply = .result },
60};
61
62/// The exact reply bodies this file puts on the wire.
63///
64/// Named rather than written inline at each `sendResultToJS`, so the test that
65/// pins their shape reads the same bytes the handlers send instead of a copy
66/// of them that can drift. Every one is a JSON *object* carrying a boolean:
67/// `craft-bridge.js` resolves with `payload || {}`, so a bare `false` and an
68/// empty reply are the same value to the page — a fabricated success wearing
69/// the shape of an honest one.
70const R = struct {
71 const opened = "{\"opened\":true}";
72 const not_opened = "{\"opened\":false}";
73 const completed = "{\"completed\":true}";
74 const not_completed = "{\"completed\":false}";
75 const requested = "{\"requested\":true}";
76
77 const all = [_][]const u8{ opened, not_opened, completed, not_completed, requested };
78};
79
80pub const SystemBridge = struct {
81 allocator: std.mem.Allocator,
82
83 const Self = @This();
84
85 pub fn init(allocator: std.mem.Allocator) Self {
86 return .{ .allocator = allocator };
87 }
88
89 pub fn deinit(_: *Self) void {}
90
91 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
92 if (std.mem.eql(u8, action, A.open_url)) {
93 try self.openURL(data);
94 } else if (std.mem.eql(u8, action, A.share)) {
95 try self.share(data);
96 } else if (std.mem.eql(u8, action, A.request_review)) {
97 try self.requestReview();
98 } else {
99 return bridge_error.BridgeError.UnknownAction;
100 }
101 }
102
103 /// `craft.openURL(url)` — hand a URL to iOS and report what iOS did with it.
104 ///
105 /// The reply is sent from the completion handler, not from here, so this
106 /// function returning is *not* the call being answered. That is the whole
107 /// difference from the Swift version, which resolved `true` before
108 /// `open:` had decided anything and so reported success for an unhandled
109 /// scheme.
110 fn openURL(self: *Self, data: []const u8) !void {
111 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
112 return bridge_error.BridgeError.InvalidJSON;
113 defer parsed.deinit();
114
115 const root = switch (parsed.value) {
116 .object => |obj| obj,
117 else => return bridge_error.BridgeError.InvalidJSON,
118 };
119
120 // `url`, top level — the page's `openURL` posts it flat, not under
121 // `options`. Swift's `if let` fell through when it was absent and never
122 // settled the callback at all, leaving the page's promise to time out
123 // with nothing to point at.
124 const url = try stringField(root, "url");
125 if (url.len == 0) return bridge_error.BridgeError.MissingData;
126
127 try uikit.openURL(self.allocator, url, capturedRequestId());
128 }
129
130 /// `craft.share(text)` and `craft.share.share(options)` — the same action,
131 /// two payload shapes, both of which have live callers.
132 fn share(self: *Self, data: []const u8) !void {
133 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
134 return bridge_error.BridgeError.InvalidJSON;
135 defer parsed.deinit();
136
137 const root = switch (parsed.value) {
138 .object => |obj| obj,
139 else => return bridge_error.BridgeError.InvalidJSON,
140 };
141
142 // The strings borrow `parsed`'s arena, which outlives this call: the
143 // sheet is built and presented before `parsed.deinit()` runs, and
144 // UIKit has copied everything it needs into NSStrings by then.
145 var request = try parseShareRequest(self.allocator, root);
146 defer request.deinit(self.allocator);
147
148 try uikit.share(self.allocator, request, capturedRequestId());
149 }
150
151 /// `craft.requestReview()` — ask StoreKit to consider showing the rating
152 /// prompt.
153 ///
154 /// Takes no payload; `ios_dispatch.payloadOf` hands this handler `"{}"` and
155 /// there is nothing in it to read.
156 fn requestReview(self: *Self) !void {
157 try uikit.requestReview(self.allocator);
158 }
159};
160
161/// The id of the call being served, frozen as a plain integer.
162///
163/// `request_context` is a threadlocal stack that `ios_dispatch.handleMessage`
164/// pops on return, so a completion handler firing later sees it empty and
165/// `sendResultToJS` would stamp the reply `id = null`. A null id sends the
166/// reply down `craft-bridge.js`'s action-name FIFO instead of to the waiting
167/// promise, which hands one caller's answer to another whenever two shares are
168/// in flight. `handOffToHost` solves the same problem the same way, including
169/// the negative sentinel for "the page sent no id".
170fn capturedRequestId() i64 {
171 return if (request_context.current()) |id| @intCast(id) else -1;
172}
173
174/// A string field, or `""` when the page did not send one.
175///
176/// A wrong *type* is an error rather than a miss: quietly treating
177/// `{"url": 42}` as absent is how a payload field goes missing and the caller
178/// is told everything went fine.
179fn stringField(obj: std.json.ObjectMap, name: []const u8) ![]const u8 {
180 const v = obj.get(name) orelse return "";
181 return switch (v) {
182 .string => |s| s,
183 // `JSON.stringify` keeps an explicit null where it drops an undefined,
184 // so null is the page saying "no value", not a type error.
185 .null => "",
186 else => bridge_error.BridgeError.InvalidParameter,
187 };
188}
189
190/// What the page wants to share, in the order `UIActivityViewController` must
191/// receive it.
192///
193/// The order is load-bearing: an activity decides what it gets from the type
194/// and position of each item, so title-then-text-then-url-then-files is the
195/// behaviour being ported, not an arbitrary field ordering.
196pub const ShareRequest = struct {
197 title: []const u8 = "",
198 text: []const u8 = "",
199 url: []const u8 = "",
200 files: []const []const u8 = &.{},
201
202 /// Frees the `files` slice. The strings themselves belong to the JSON
203 /// arena the request was parsed from.
204 pub fn deinit(self: *ShareRequest, allocator: std.mem.Allocator) void {
205 allocator.free(self.files);
206 self.files = &.{};
207 }
208};
209
210/// Read a `share` payload in either of the two shapes that reach it.
211///
212/// `craft.share.share(options)` goes through `_invoke`, which nests everything
213/// one level under `options`. The legacy `craft.share(text)` posts `text` flat
214/// and is still live in the injected script. Reading only one of them breaks
215/// the other half of the callers — the same field-name mismatch that had
216/// `craft.fs.writeFile` writing empty files.
217///
218/// Where this is deliberately wider than Swift: when there is no `options`,
219/// Swift rebuilds the payload as `["text": text]` and drops any `url`, `title`
220/// or `files` that were sent alongside. Here the top-level object is read
221/// whole. Nothing that works today stops working, and a field the page sent is
222/// not thrown away.
223///
224/// Empty strings are skipped rather than shared, so `share({text: ''})` is
225/// "nothing to share" and not an empty sheet.
226pub fn parseShareRequest(allocator: std.mem.Allocator, root: std.json.ObjectMap) !ShareRequest {
227 const source: std.json.ObjectMap = if (root.get("options")) |v| switch (v) {
228 .object => |o| o,
229 else => root,
230 } else root;
231
232 var request = ShareRequest{
233 .title = try stringField(source, "title"),
234 .text = try stringField(source, "text"),
235 .url = try stringField(source, "url"),
236 };
237
238 if (source.get("files")) |v| switch (v) {
239 .array => |entries| {
240 var list: std.ArrayListUnmanaged([]const u8) = .empty;
241 errdefer list.deinit(allocator);
242 for (entries.items) |entry| switch (entry) {
243 .string => |s| if (s.len > 0) try list.append(allocator, s),
244 // `files` is declared `string[]`. A non-string element is the
245 // caller getting the type wrong, and saying so beats sharing a
246 // shorter list than was asked for.
247 else => return bridge_error.BridgeError.InvalidParameter,
248 };
249 request.files = try list.toOwnedSlice(allocator);
250 },
251 .null => {},
252 else => return bridge_error.BridgeError.InvalidParameter,
253 };
254 errdefer request.deinit(allocator);
255
256 // Swift's "Nothing to share" / INVALID_ARGUMENT. Checked here so an
257 // obviously empty request fails before any UIKit object is built; the
258 // authoritative check is on the assembled item array, because a `files`
259 // entry that names nothing on disk contributes no item.
260 if (request.title.len == 0 and request.text.len == 0 and
261 request.url.len == 0 and request.files.len == 0)
262 {
263 return bridge_error.BridgeError.InvalidParameter;
264 }
265
266 return request;
267}
268
269// =============================================================================
270// Objective-C block layouts
271//
272// `{ isa, flags, reserved, invoke, descriptor }` is the ABI every block on
273// Apple platforms has; `_NSConcreteStackBlock` is the symbol the isa points at
274// for a block that starts life on the stack. Same shape `bridge_permissions.zig`
275// and `macos.zig` already hand to AppKit, with one addition: a trailing capture.
276//
277// The existing two are capture-free statics, which cannot carry a request id —
278// and without the id an async reply cannot name the call it answers. A capture
279// is just a field appended after `descriptor`, with `descriptor.size` grown to
280// match: `_Block_copy` memcpys exactly that many bytes onto the heap and
281// retargets `isa` to `_NSConcreteMallocBlock`. `flags = 0` (no
282// `BLOCK_HAS_COPY_DISPOSE`) is right for a plain integer capture, which needs
283// no copy/dispose helpers.
284//
285// The blocks are built on the handler's stack because both callees —
286// `openURL:options:completionHandler:` and `setCompletionWithItemsHandler:` —
287// copy the block before returning.
288//
289// The pointer fields are written `?*anyopaque` rather than `objc.id` even
290// though `objc_runtime.zig:32` defines them as the same type, so these layouts
291// still compile on a host where `objc` is an empty struct.
292// =============================================================================
293
294const BlockDescriptor = extern struct {
295 reserved: usize = 0,
296 size: usize,
297};
298
299extern var _NSConcreteStackBlock: anyopaque;
300
301/// `void (^)(BOOL success)` — what `openURL:options:completionHandler:` calls.
302const OpenURLBlock = extern struct {
303 isa: ?*anyopaque,
304 flags: c_int,
305 reserved: c_int,
306 invoke: *const fn (*const anyopaque, bool) callconv(.c) void,
307 descriptor: *const BlockDescriptor,
308 /// The captured `i` of the call this answers; negative for "the page sent
309 /// none". `invoke` is handed a pointer to the block itself, so it reads
310 /// this back out of its own storage.
311 request_id: i64,
312};
313
314const open_url_block_descriptor = BlockDescriptor{ .size = @sizeOf(OpenURLBlock) };
315
316/// `void (^)(UIActivityType, BOOL completed, NSArray *, NSError *)` — what
317/// `UIActivityViewController.completionWithItemsHandler` calls.
318const ShareBlock = extern struct {
319 isa: ?*anyopaque,
320 flags: c_int,
321 reserved: c_int,
322 invoke: *const fn (*const anyopaque, ?*anyopaque, bool, ?*anyopaque, ?*anyopaque) callconv(.c) void,
323 descriptor: *const BlockDescriptor,
324 request_id: i64,
325};
326
327const share_block_descriptor = BlockDescriptor{ .size = @sizeOf(ShareBlock) };
328
329/// Deliver `json` as the answer to `request_id`, from outside any dispatch.
330///
331/// The dispatch frame is long gone by the time a completion handler runs, so
332/// the id is pushed back before replying and popped after — the shape
333/// `craft_ios_deliver_result` already uses. The allocator is
334/// `std.heap.c_allocator` and not the bridge's, for the same reason: the
335/// `SystemBridge` that started this call was destroyed when `handleMessage`
336/// returned.
337fn replyFromCallback(request_id: i64, action: []const u8, json: []const u8) void {
338 request_context.push(if (request_id < 0) null else @intCast(request_id));
339 defer request_context.pop();
340 bridge_error.sendResultToJS(std.heap.c_allocator, action, json);
341}
342
343fn failFromCallback(request_id: i64, action: []const u8, err: bridge_error.BridgeError) void {
344 request_context.push(if (request_id < 0) null else @intCast(request_id));
345 defer request_context.pop();
346 bridge_error.sendErrorToJS(std.heap.c_allocator, action, err);
347}
348
349// =============================================================================
350// UIKit
351// =============================================================================
352
353/// Everything that touches UIKit, selected at comptime.
354///
355/// The non-Darwin arm is not a stub that pretends to work — it returns
356/// `error.UnsupportedPlatform`, which `ios_dispatch.asBridgeError` turns into
357/// `PLATFORM_NOT_SUPPORTED` for the page.
358const uikit = if (is_darwin) struct {
359 /// `[[UIApplication sharedApplication] openURL:options:completionHandler:]`
360 ///
361 /// The modern three-argument selector, never the deprecated one-argument
362 /// `openURL:` — that one returns `BOOL` synchronously and casting
363 /// `objc_msgSend` to the wrong shape is a runtime crash, not a compile
364 /// error.
365 ///
366 /// No `canOpenURL:` pre-check: it answers `NO` for any custom scheme the
367 /// app has not listed in `LSApplicationQueriesSchemes`, so gating on it
368 /// would refuse URLs that `open:` would in fact have opened. The completion
369 /// handler's BOOL is the only truthful answer available.
370 fn openURL(allocator: std.mem.Allocator, url_string: []const u8, request_id: i64) !void {
371 const UIApplication = objc.objc_getClass("UIApplication") orelse return error.ClassNotFound;
372 const sel_shared = objc.sel_registerName("sharedApplication") orelse return error.SelectorNotFound;
373 const app = objc.msgSendId(UIApplication, sel_shared);
374 if (app == null) return error.NoApplication;
375
376 // `+[NSURL URLWithString:]` returns nil for a string it cannot parse.
377 // Swift hit the same nil and then settled nothing at all.
378 const url = try objc.createNSURL(url_string, allocator);
379 if (url == null) return bridge_error.BridgeError.InvalidParameter;
380
381 // `options:` is declared nonnull; an empty dictionary is the documented
382 // way to pass no options.
383 const NSDictionary = objc.objc_getClass("NSDictionary") orelse return error.ClassNotFound;
384 const sel_dictionary = objc.sel_registerName("dictionary") orelse return error.SelectorNotFound;
385 const options = objc.msgSendId(NSDictionary, sel_dictionary);
386 if (options == null) return error.NativeCallFailed;
387
388 const sel_open = objc.sel_registerName("openURL:options:completionHandler:") orelse
389 return error.SelectorNotFound;
390
391 var block = OpenURLBlock{
392 .isa = &_NSConcreteStackBlock,
393 .flags = 0,
394 .reserved = 0,
395 .invoke = openURLDidFinish,
396 .descriptor = &open_url_block_descriptor,
397 .request_id = request_id,
398 };
399
400 const Fn = *const fn (objc.id, objc.SEL, objc.id, objc.id, objc.id) callconv(.c) void;
401 const func: Fn = @ptrCast(&objc.objc_msgSend);
402 func(app, sel_open, url, options, @as(objc.id, @ptrCast(&block)));
403 }
404
405 fn openURLDidFinish(block: *const anyopaque, opened: bool) callconv(.c) void {
406 const self: *const OpenURLBlock = @ptrCast(@alignCast(block));
407 replyFromCallback(self.request_id, A.open_url, if (opened) R.opened else R.not_opened);
408 }
409
410 /// Build the activity items, present the sheet, and answer when it closes.
411 fn share(allocator: std.mem.Allocator, request: ShareRequest, request_id: i64) !void {
412 const NSMutableArray = objc.objc_getClass("NSMutableArray") orelse return error.ClassNotFound;
413 const sel_array = objc.sel_registerName("array") orelse return error.SelectorNotFound;
414 const sel_add = objc.sel_registerName("addObject:") orelse return error.SelectorNotFound;
415
416 const items = objc.msgSendId(NSMutableArray, sel_array);
417 if (items == null) return error.NativeCallFailed;
418
419 if (request.title.len > 0) try addString(allocator, items, sel_add, request.title);
420 if (request.text.len > 0) try addString(allocator, items, sel_add, request.text);
421 if (request.url.len > 0) {
422 // The same condition `openURL` refuses, refused the same way. Swift
423 // dropped an unparseable `url` and shared whatever else was in the
424 // request, so a caller who asked to share a link got a sheet with
425 // only its text and was told the share completed — a payload field
426 // silently discarded under a success.
427 const url = try objc.createNSURL(request.url, allocator);
428 if (url == null) return bridge_error.BridgeError.InvalidParameter;
429 objc.msgSendVoid1(items, sel_add, url);
430 }
431 if (request.files.len > 0) try appendFiles(allocator, items, sel_add, request.files);
432
433 // The authoritative "Nothing to share": a `files` entry that is neither
434 // a file URL nor an existing path contributes no item, so a request
435 // that looked non-empty on the way in can still assemble to nothing.
436 // An empty sheet is not the answer — an error is.
437 const sel_count = objc.sel_registerName("count") orelse return error.SelectorNotFound;
438 const CountFn = *const fn (objc.id, objc.SEL) callconv(.c) c_ulong;
439 const count_fn: CountFn = @ptrCast(&objc.objc_msgSend);
440 if (count_fn(items, sel_count) == 0) return bridge_error.BridgeError.InvalidParameter;
441
442 // Resolved before the controller is built so a missing presenter is an
443 // error rather than a leaked, never-presented view controller.
444 const presenter = try topmostViewController();
445
446 const UIActivityViewController = objc.objc_getClass("UIActivityViewController") orelse
447 return error.ClassNotFound;
448 const allocated = try objc.alloc(UIActivityViewController);
449 const sel_init = objc.sel_registerName("initWithActivityItems:applicationActivities:") orelse
450 return error.SelectorNotFound;
451 const InitFn = *const fn (objc.id, objc.SEL, objc.id, objc.id) callconv(.c) objc.id;
452 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
453 const activity_vc = init_fn(allocated, sel_init, items, null);
454 if (activity_vc == null) return error.NativeCallFailed;
455 // `alloc` gave us +1. The presenting controller retains the sheet for
456 // the life of the presentation, so ours is the reference to drop —
457 // and on the error paths below it is the only one there is.
458 defer objc.release(activity_vc);
459
460 var block = ShareBlock{
461 .isa = &_NSConcreteStackBlock,
462 .flags = 0,
463 .reserved = 0,
464 .invoke = shareDidFinish,
465 .descriptor = &share_block_descriptor,
466 .request_id = request_id,
467 };
468 const sel_set_handler = objc.sel_registerName("setCompletionWithItemsHandler:") orelse
469 return error.SelectorNotFound;
470 objc.msgSendVoid1(activity_vc, sel_set_handler, @as(objc.id, @ptrCast(&block)));
471
472 try anchorPopover(activity_vc, presenter);
473
474 const sel_present = objc.sel_registerName("presentViewController:animated:completion:") orelse
475 return error.SelectorNotFound;
476 const PresentFn = *const fn (objc.id, objc.SEL, objc.id, bool, objc.id) callconv(.c) void;
477 const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend);
478 present_fn(presenter, sel_present, activity_vc, true, null);
479 }
480
481 /// `[items addObject:@"…"]`, refusing rather than passing nil.
482 ///
483 /// `+[NSString stringWithUTF8String:]` answers nil for bytes that are not
484 /// valid UTF-8, and `-[NSMutableArray addObject:]` raises
485 /// `NSInvalidArgumentException` on nil. That is an ObjC exception, so from
486 /// Zig it is an uncatchable crash rather than something the page can be
487 /// told about — which is why this is a check and not a comment.
488 fn addString(allocator: std.mem.Allocator, items: objc.id, sel_add: objc.SEL, s: []const u8) !void {
489 const ns = try objc.createNSString(s, allocator);
490 if (ns == null) return bridge_error.BridgeError.InvalidParameter;
491 objc.msgSendVoid1(items, sel_add, ns);
492 }
493
494 /// Turn each `files` entry into an NSURL the way Swift does: a string that
495 /// parses as a file URL is used as-is, otherwise a file that exists at that
496 /// literal path becomes a `fileURLWithPath:`.
497 ///
498 /// An entry that is neither is dropped, matching Swift. Unlike a `url` the
499 /// page sent that will not parse, which is refused, this is a fact about
500 /// the filesystem rather than about the payload — the path is well-formed
501 /// and simply names nothing. The caller finds out only when *every* item
502 /// was dropped and there was nothing else to share, because that is what
503 /// the count check below catches. A files-plus-text request that loses its
504 /// files still reports the share completed, which is the one place this
505 /// file knowingly keeps Swift's silence; changing it means deciding whether
506 /// a stale path should fail an otherwise valid share.
507 fn appendFiles(
508 allocator: std.mem.Allocator,
509 items: objc.id,
510 sel_add: objc.SEL,
511 files: []const []const u8,
512 ) !void {
513 const NSURL = objc.objc_getClass("NSURL") orelse return error.ClassNotFound;
514 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound;
515 const sel_default_manager = objc.sel_registerName("defaultManager") orelse return error.SelectorNotFound;
516 const sel_exists = objc.sel_registerName("fileExistsAtPath:") orelse return error.SelectorNotFound;
517 const sel_is_file_url = objc.sel_registerName("isFileURL") orelse return error.SelectorNotFound;
518 const sel_file_url = objc.sel_registerName("fileURLWithPath:") orelse return error.SelectorNotFound;
519
520 const file_manager = objc.msgSendId(NSFileManager, sel_default_manager);
521 if (file_manager == null) return error.NativeCallFailed;
522
523 const ExistsFn = *const fn (objc.id, objc.SEL, objc.id) callconv(.c) bool;
524 const exists_fn: ExistsFn = @ptrCast(&objc.objc_msgSend);
525
526 for (files) |path| {
527 const as_url: objc.id = objc.createNSURL(path, allocator) catch null;
528 if (as_url != null and objc.msgSendBool(as_url, sel_is_file_url)) {
529 objc.msgSendVoid1(items, sel_add, as_url);
530 continue;
531 }
532
533 const ns_path = try objc.createNSString(path, allocator);
534 if (!exists_fn(file_manager, sel_exists, ns_path)) continue;
535
536 const file_url = objc.msgSendId1(NSURL, sel_file_url, ns_path);
537 if (file_url != null) objc.msgSendVoid1(items, sel_add, file_url);
538 }
539 }
540
541 /// Give the sheet an anchor before it is presented.
542 ///
543 /// On a regular size class — every iPad, and iPhone in some multitasking
544 /// configurations — `UIActivityViewController` presents as a popover, and
545 /// UIKit raises `NSGenericException` from `-viewWillAppear:` if neither
546 /// `sourceView` nor `barButtonItem` is set. That is an ObjC exception, so
547 /// from Zig it is an uncatchable crash. The Swift implementation sets
548 /// neither and crashes on iPad today.
549 ///
550 /// `popoverPresentationController` is nil when UIKit did not choose a
551 /// popover (iPhone), so the whole block is skipped there rather than
552 /// guessed at. The rect is centred and `permittedArrowDirections` is set to
553 /// 0 — the *empty* `UIPopoverArrowDirection` option set, Swift's `[]`, which
554 /// draws no arrow. It is emphatically not `UIPopoverArrowDirectionUnknown`,
555 /// which is `NSUIntegerMax`: passing that would permit every direction and
556 /// point an arrow at the middle of the screen.
557 fn anchorPopover(activity_vc: objc.id, presenter: objc.id) !void {
558 const sel_popover = objc.sel_registerName("popoverPresentationController") orelse
559 return error.SelectorNotFound;
560 const popover = objc.msgSendId(activity_vc, sel_popover);
561 if (popover == null) return;
562
563 const sel_view = objc.sel_registerName("view") orelse return error.SelectorNotFound;
564 const host_view = objc.msgSendId(presenter, sel_view);
565 if (host_view == null) return error.NoPresenterView;
566
567 const sel_set_source_view = objc.sel_registerName("setSourceView:") orelse
568 return error.SelectorNotFound;
569 objc.msgSendVoid1(popover, sel_set_source_view, host_view);
570
571 const sel_bounds = objc.sel_registerName("bounds") orelse return error.SelectorNotFound;
572 const BoundsFn = *const fn (objc.id, objc.SEL) callconv(.c) objc.CGRect;
573 const bounds_fn: BoundsFn = @ptrCast(&objc.objc_msgSend);
574 const bounds = bounds_fn(host_view, sel_bounds);
575
576 const sel_set_source_rect = objc.sel_registerName("setSourceRect:") orelse
577 return error.SelectorNotFound;
578 const RectFn = *const fn (objc.id, objc.SEL, objc.CGRect) callconv(.c) void;
579 const rect_fn: RectFn = @ptrCast(&objc.objc_msgSend);
580 rect_fn(popover, sel_set_source_rect, .{
581 .origin = .{ .x = bounds.size.width / 2, .y = bounds.size.height / 2 },
582 .size = .{ .width = 0, .height = 0 },
583 });
584
585 const sel_set_arrows = objc.sel_registerName("setPermittedArrowDirections:") orelse
586 return error.SelectorNotFound;
587 const ArrowFn = *const fn (objc.id, objc.SEL, c_ulong) callconv(.c) void;
588 const arrow_fn: ArrowFn = @ptrCast(&objc.objc_msgSend);
589 arrow_fn(popover, sel_set_arrows, 0);
590 }
591
592 fn shareDidFinish(
593 block: *const anyopaque,
594 _: ?*anyopaque,
595 completed: bool,
596 _: ?*anyopaque,
597 activity_error: ?*anyopaque,
598 ) callconv(.c) void {
599 const self: *const ShareBlock = @ptrCast(@alignCast(block));
600
601 // An NSError from the activity is a failure, not a `completed:false`.
602 // Resolving it would make the page's promise succeed with a
603 // share that did not happen, and the app's catch block never runs.
604 if (activity_error != null) {
605 failFromCallback(self.request_id, A.share, bridge_error.BridgeError.NativeCallFailed);
606 return;
607 }
608
609 replyFromCallback(self.request_id, A.share, if (completed) R.completed else R.not_completed);
610 }
611
612 /// `+[SKStoreReviewController requestReviewInScene:]`
613 ///
614 /// The reply says `requested`, not `shown`, because "shown" is a question
615 /// the API cannot answer: the selector returns void and calls nothing back,
616 /// and iOS suppresses the prompt on its own terms — roughly three per user
617 /// per year, never in TestFlight, and off entirely if the user disabled it
618 /// in Settings. `{"requested":true}` is the strongest true statement
619 /// available. Anything that fails before the call is an error, never
620 /// `{"requested":false}` dressed up as a success — which is what Swift did,
621 /// resolving `true` from outside the `DispatchQueue.main.async` block that
622 /// had not run yet and might have found no scene when it did.
623 fn requestReview(allocator: std.mem.Allocator) !void {
624 // StoreKit first, scene second. Both are required and either can be the
625 // reason this cannot run, but only one of them is a *build* problem: an
626 // app that has not linked the framework has to be told that, not sent
627 // looking for a window scene it already has. Ordering is the only thing
628 // that decides which of the two errors the page is given.
629 const SKStoreReviewController = objc.objc_getClass("SKStoreReviewController") orelse
630 return error.StoreKitNotLinked;
631
632 // `sel_registerName` registers a selector whether or not anything
633 // implements it, so it is not a guard. `requestReviewInScene:` is
634 // deprecated as of iOS 18 in favour of `AppStore.requestReview(in:)`,
635 // which is a SwiftUI environment action with no Objective-C
636 // counterpart and so is unreachable from here. When a future SDK drops
637 // the deprecated method this must fail loudly, not crash.
638 const sel_request = objc.sel_registerName("requestReviewInScene:") orelse
639 return error.SelectorNotFound;
640 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse
641 return error.SelectorNotFound;
642 const RespondsFn = *const fn (objc.id, objc.SEL, objc.SEL) callconv(.c) bool;
643 const responds_fn: RespondsFn = @ptrCast(&objc.objc_msgSend);
644 if (!responds_fn(SKStoreReviewController, sel_responds, sel_request)) {
645 return error.ReviewPromptUnavailable;
646 }
647
648 const scene = try keyWindowScene();
649
650 const Fn = *const fn (objc.id, objc.SEL, objc.id) callconv(.c) void;
651 const func: Fn = @ptrCast(&objc.objc_msgSend);
652 func(SKStoreReviewController, sel_request, scene);
653
654 // Synchronous: there is no callback to wait for, so the dispatch frame
655 // is still live and `sendResultToJS` stamps the right id by itself.
656 bridge_error.sendResultToJS(allocator, A.request_review, R.requested);
657 }
658
659 /// The view controller a modal should be presented from.
660 ///
661 /// Reached through the webview rather than `connectedScenes`: the webview
662 /// is already held by `ios_dispatch`, `[[webview window] rootViewController]`
663 /// is two messages instead of six, and it works the same in a pure-Zig app
664 /// and in a Swift-hosted app that registered its webview. Every nil on the
665 /// way is a real condition and gets its own error — a skipped presentation
666 /// would leave the page's promise unsettled forever.
667 ///
668 /// The walk up `presentedViewController` is the part Swift is missing:
669 /// presenting on a controller that is already presenting something logs
670 /// "which is already presenting" and silently does nothing, so the
671 /// completion handler never fires and the caller waits out its timeout.
672 fn topmostViewController() !objc.id {
673 const webview = ios_dispatch.getWebView() orelse
674 return bridge_error.BridgeError.WebViewHandleNotSet;
675 if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet;
676
677 const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound;
678 const window = objc.msgSendId(webview, sel_window);
679 if (window == null) return error.NoWindow;
680
681 const sel_root = objc.sel_registerName("rootViewController") orelse return error.SelectorNotFound;
682 var vc = objc.msgSendId(window, sel_root);
683 if (vc == null) return error.NoRootViewController;
684
685 const sel_presented = objc.sel_registerName("presentedViewController") orelse
686 return error.SelectorNotFound;
687 // Bounded: a cycle in the presentation chain should hang nothing.
688 var hops: usize = 0;
689 while (hops < 32) : (hops += 1) {
690 const next = objc.msgSendId(vc, sel_presented);
691 if (next == null) break;
692 vc = next;
693 }
694 return vc;
695 }
696
697 /// The `UIWindowScene` the app's window belongs to.
698 ///
699 /// craft builds a classic `UIWindow` with `initWithFrame:` and declares no
700 /// `UIApplicationSceneManifest`, so whether iOS attaches an implicit scene
701 /// to it has not been verified on a device. If it did not, `windowScene` is
702 /// nil and that is reported — it is not a reason to fall back to the
703 /// pre-iOS-14 `+requestReview`, which is deprecated and may be gone, and it
704 /// is certainly not a reason to reply success having done nothing.
705 fn keyWindowScene() !objc.id {
706 const webview = ios_dispatch.getWebView() orelse
707 return bridge_error.BridgeError.WebViewHandleNotSet;
708 if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet;
709
710 const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound;
711 const window = objc.msgSendId(webview, sel_window);
712 if (window == null) return error.NoWindow;
713
714 const sel_scene = objc.sel_registerName("windowScene") orelse return error.SelectorNotFound;
715 const scene = objc.msgSendId(window, sel_scene);
716 if (scene == null) return error.NoWindowScene;
717 return scene;
718 }
719} else struct {
720 fn openURL(_: std.mem.Allocator, _: []const u8, _: i64) !void {
721 return error.UnsupportedPlatform;
722 }
723 fn share(_: std.mem.Allocator, _: ShareRequest, _: i64) !void {
724 return error.UnsupportedPlatform;
725 }
726 fn requestReview(_: std.mem.Allocator) !void {
727 return error.UnsupportedPlatform;
728 }
729};
730
731// =============================================================================
732// Tests
733//
734// Host-only. Nothing here presents a sheet, opens a URL, or asks for a review;
735// the payload shapes and the action table are what a host can check, and they
736// are where the bugs being ported around actually lived.
737// =============================================================================
738
739const testing = std.testing;
740
741/// Parse `json` and hand its root object to `parseShareRequest`.
742///
743/// The arena has to outlive the request, whose strings borrow it, so the caller
744/// gets both back and frees them together.
745fn shareRequestFrom(json: []const u8) !struct {
746 parsed: std.json.Parsed(std.json.Value),
747 request: ShareRequest,
748
749 fn deinit(self: *@This()) void {
750 self.request.deinit(testing.allocator);
751 self.parsed.deinit();
752 }
753} {
754 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
755 errdefer parsed.deinit();
756 return .{ .parsed = parsed, .request = try parseShareRequest(testing.allocator, parsed.value.object) };
757}
758
759test "every declared action is one the handler serves" {
760 try testing.expectEqual(@as(usize, 3), capability_actions.len);
761 try testing.expectEqualStrings(A.open_url, capability_actions[0].name);
762 try testing.expectEqualStrings(A.share, capability_actions[1].name);
763 try testing.expectEqualStrings(A.request_review, capability_actions[2].name);
764 for (capability_actions) |decl| {
765 // All three answer a waiting caller. An action declared `.result` whose
766 // handler never replies parks the page until its timeout, and a `.none`
767 // that a caller awaits resolves immediately and means nothing.
768 try testing.expectEqual(capabilities.Reply.result, decl.reply);
769 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
770 }
771
772 // And the dispatcher agrees. The payloads are ones that fail before any
773 // UIKit object is touched, so this is a routing check and not a device
774 // test: `openURL` has no url, `share` has nothing to share, and
775 // `requestReview` finds no webview registered on the host.
776 var bridge = SystemBridge.init(testing.allocator);
777 defer bridge.deinit();
778 for (capability_actions) |decl| {
779 bridge.handleMessage(decl.name, "{}") catch |err| {
780 try testing.expect(err != bridge_error.BridgeError.UnknownAction);
781 continue;
782 };
783 // None of these can succeed on a host, so reaching here means a handler
784 // reported success without doing anything.
785 try testing.expect(false);
786 }
787}
788
789test "an action the namespace does not serve is reported, not ignored" {
790 var bridge = SystemBridge.init(testing.allocator);
791 defer bridge.deinit();
792
793 for ([_][]const u8{ "openurl", "openUrl", "sharing", "review", "" }) |action| {
794 try testing.expectError(
795 bridge_error.BridgeError.UnknownAction,
796 bridge.handleMessage(action, "{}"),
797 );
798 }
799}
800
801test "openURL without a url reports it rather than settling nothing" {
802 // Swift's `if let` fell through here and never resolved or rejected the
803 // callback, so the page waited out a 30-second timeout with no cause to
804 // report. Silence is the one answer ruled out.
805 var bridge = SystemBridge.init(testing.allocator);
806 defer bridge.deinit();
807
808 try testing.expectError(bridge_error.BridgeError.MissingData, bridge.handleMessage(A.open_url, "{}"));
809 try testing.expectError(
810 bridge_error.BridgeError.MissingData,
811 bridge.handleMessage(A.open_url, "{\"url\":\"\"}"),
812 );
813 try testing.expectError(
814 bridge_error.BridgeError.InvalidParameter,
815 bridge.handleMessage(A.open_url, "{\"url\":42}"),
816 );
817 try testing.expectError(
818 bridge_error.BridgeError.InvalidJSON,
819 bridge.handleMessage(A.open_url, "not json"),
820 );
821}
822
823test "the url is read from the top level, which is where the page puts it" {
824 const parsed = try std.json.parseFromSlice(
825 std.json.Value,
826 testing.allocator,
827 "{\"url\":\"tel:+15551234\",\"callbackId\":\"cb_1\"}",
828 .{},
829 );
830 defer parsed.deinit();
831 try testing.expectEqualStrings("tel:+15551234", try stringField(parsed.value.object, "url"));
832}
833
834test "share reads the nested options the SDK sends" {
835 var got = try shareRequestFrom(
836 \\{"options":{"title":"T","text":"X","url":"https://e.com","files":["/a","/b"]}}
837 );
838 defer got.deinit();
839
840 try testing.expectEqualStrings("T", got.request.title);
841 try testing.expectEqualStrings("X", got.request.text);
842 try testing.expectEqualStrings("https://e.com", got.request.url);
843 try testing.expectEqual(@as(usize, 2), got.request.files.len);
844 try testing.expectEqualStrings("/a", got.request.files[0]);
845 try testing.expectEqualStrings("/b", got.request.files[1]);
846}
847
848test "share still reads the flat text the legacy call sends" {
849 // `craft.share('hello')` is live in the injected script and posts `text` at
850 // the top level with no `options` wrapper. Reading only the nested shape
851 // would break it silently, which is the `craft.fs.writeFile` failure.
852 var got = try shareRequestFrom("{\"text\":\"hello\"}");
853 defer got.deinit();
854
855 try testing.expectEqualStrings("hello", got.request.text);
856 try testing.expectEqualStrings("", got.request.title);
857 try testing.expectEqual(@as(usize, 0), got.request.files.len);
858}
859
860test "a flat payload keeps every field it sent, not just text" {
861 // Wider than Swift, which rebuilds the payload as `["text": text]` and
862 // throws the rest away. Nothing that works today changes; a field the page
863 // sent is not dropped on the floor.
864 var got = try shareRequestFrom("{\"text\":\"X\",\"url\":\"https://e.com\"}");
865 defer got.deinit();
866
867 try testing.expectEqualStrings("X", got.request.text);
868 try testing.expectEqualStrings("https://e.com", got.request.url);
869}
870
871test "an empty string is not something to share" {
872 // `!title.isEmpty` / `!text.isEmpty` in Swift. Without it, `share({text:''})`
873 // presents an empty sheet instead of telling the caller it asked for
874 // nothing.
875 const parsed = try std.json.parseFromSlice(
876 std.json.Value,
877 testing.allocator,
878 "{\"options\":{\"text\":\"\",\"title\":\"\",\"url\":\"\",\"files\":[]}}",
879 .{},
880 );
881 defer parsed.deinit();
882
883 try testing.expectError(
884 bridge_error.BridgeError.InvalidParameter,
885 parseShareRequest(testing.allocator, parsed.value.object),
886 );
887}
888
889test "an empty options object is nothing to share, not an empty sheet" {
890 const parsed = try std.json.parseFromSlice(
891 std.json.Value,
892 testing.allocator,
893 "{\"options\":{}}",
894 .{},
895 );
896 defer parsed.deinit();
897
898 try testing.expectError(
899 bridge_error.BridgeError.InvalidParameter,
900 parseShareRequest(testing.allocator, parsed.value.object),
901 );
902}
903
904test "a files entry of the wrong type is refused, not quietly skipped" {
905 // `files` is declared `string[]`. Sharing a shorter list than was asked for
906 // and calling it success is the bug class this file is being written to
907 // avoid.
908 const parsed = try std.json.parseFromSlice(
909 std.json.Value,
910 testing.allocator,
911 "{\"options\":{\"files\":[\"/a\",7]}}",
912 .{},
913 );
914 defer parsed.deinit();
915
916 try testing.expectError(
917 bridge_error.BridgeError.InvalidParameter,
918 parseShareRequest(testing.allocator, parsed.value.object),
919 );
920}
921
922test "an explicit null is the page sending no value, not a type error" {
923 var got = try shareRequestFrom("{\"options\":{\"text\":\"X\",\"url\":null,\"files\":null}}");
924 defer got.deinit();
925
926 try testing.expectEqualStrings("X", got.request.text);
927 try testing.expectEqualStrings("", got.request.url);
928 try testing.expectEqual(@as(usize, 0), got.request.files.len);
929}
930
931test "files-only is a valid request" {
932 // Nothing textual, but plenty to share. The final emptiness check happens
933 // on the assembled item array, because a path that names nothing on disk
934 // contributes no item.
935 var got = try shareRequestFrom("{\"options\":{\"files\":[\"/tmp/a.png\"]}}");
936 defer got.deinit();
937
938 try testing.expectEqual(@as(usize, 1), got.request.files.len);
939 try testing.expectEqualStrings("", got.request.text);
940}
941
942test "a url the page sent that iOS cannot parse is refused, not left out of the sheet" {
943 // Swift shared the text, dropped the link, and reported the share
944 // completed — the caller asked for a link and got a sheet without one, with
945 // nothing to indicate anything was missing. `openURL` already refuses this
946 // exact condition; `share` refuses it the same way.
947 //
948 // Runs on any Darwin host: `+[NSURL URLWithString:]` is Foundation, and
949 // `share` reaches it before it needs anything from UIKit.
950 if (!is_darwin) return error.SkipZigTest;
951
952 var bridge = SystemBridge.init(testing.allocator);
953 defer bridge.deinit();
954
955 try testing.expectError(
956 bridge_error.BridgeError.InvalidParameter,
957 bridge.handleMessage(A.share, "{\"options\":{\"text\":\"hi\",\"url\":\"http://exa mple.com\"}}"),
958 );
959}
960
961test "a request id survives being captured for a callback that fires later" {
962 // The completion handlers run after `ios_dispatch.handleMessage` has popped
963 // the dispatch frame, so the id has to be read while the frame is live and
964 // pushed back before replying. Without it the reply is stamped `null` and
965 // goes down the action-name FIFO, which hands one caller's answer to
966 // another whenever two shares are in flight.
967 request_context.resetForTesting();
968
969 request_context.push(31);
970 const captured = capturedRequestId();
971 request_context.pop();
972
973 try testing.expectEqual(@as(i64, 31), captured);
974 try testing.expectEqual(@as(?u64, null), request_context.current());
975
976 request_context.push(if (captured < 0) null else @intCast(captured));
977 defer request_context.pop();
978 try testing.expectEqual(@as(?u64, 31), request_context.current());
979}
980
981test "a message the page sent without an id captures the absent-id sentinel" {
982 request_context.resetForTesting();
983 request_context.push(null);
984 defer request_context.pop();
985 try testing.expectEqual(@as(i64, -1), capturedRequestId());
986}
987
988test "a block carries its capture without disturbing the ABI prefix" {
989 // Pinned as absolute offsets, not as `descriptor.size == @sizeOf(TheBlock)`
990 // — that compares the constant against the expression that defines it and
991 // so cannot fail. These can: they catch a reordered field, a widened
992 // `flags`, and an `extern` that went missing and let Zig pick its own
993 // layout, each of which hands `_Block_copy` and `objc_msgSend` a block
994 // whose `invoke` and `descriptor` are not where the runtime looks.
995 //
996 // The offsets are the 64-bit ABI. iOS is 64-bit only and so is every host
997 // that runs this suite; a 32-bit host would pad `request_id` differently
998 // and is skipped rather than asserted about wrongly.
999 if (@sizeOf(usize) != 8) return error.SkipZigTest;
1000
1001 inline for (.{ OpenURLBlock, ShareBlock }) |Block| {
1002 try testing.expectEqual(@as(usize, 0), @offsetOf(Block, "isa"));
1003 try testing.expectEqual(@as(usize, 8), @offsetOf(Block, "flags"));
1004 try testing.expectEqual(@as(usize, 12), @offsetOf(Block, "reserved"));
1005 try testing.expectEqual(@as(usize, 16), @offsetOf(Block, "invoke"));
1006 try testing.expectEqual(@as(usize, 24), @offsetOf(Block, "descriptor"));
1007 // The capture, after the five fields the ABI reserves.
1008 try testing.expectEqual(@as(usize, 32), @offsetOf(Block, "request_id"));
1009 }
1010
1011 // `_Block_copy` memcpys exactly `descriptor->size` bytes onto the heap, so
1012 // a size that stopped at the ABI prefix would leave the callback reading a
1013 // truncated id out of uninitialised memory.
1014 try testing.expect(open_url_block_descriptor.size >=
1015 @offsetOf(OpenURLBlock, "request_id") + @sizeOf(i64));
1016 try testing.expect(share_block_descriptor.size >=
1017 @offsetOf(ShareBlock, "request_id") + @sizeOf(i64));
1018}
1019
1020test "every reply is a JSON object naming what happened, never a bare boolean" {
1021 // Reads `R`, which is what the handlers actually send. Asserting against
1022 // copies of those literals would pass unchanged if a handler started
1023 // replying `true` — coverage that cannot fail is worse than none.
1024 //
1025 // `craft-bridge.js` resolves with `payload || {}`, so a bare `false` and an
1026 // empty reply are the same value to the page.
1027 for (R.all) |json| {
1028 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1029 defer parsed.deinit();
1030 try testing.expect(parsed.value == .object);
1031 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
1032
1033 // And the member is a boolean the caller can branch on, not a string or
1034 // a number that reads as truthy whatever it says.
1035 var it = parsed.value.object.iterator();
1036 try testing.expect(it.next().?.value_ptr.* == .bool);
1037 }
1038
1039 // Non-vacuity: every assertion above is satisfied by an empty list.
1040 try testing.expectEqual(@as(usize, 5), R.all.len);
1041 try testing.expect(!std.mem.eql(u8, R.opened, R.not_opened));
1042 try testing.expect(!std.mem.eql(u8, R.completed, R.not_completed));
1043}
packages/zig/src/ios.zigmodified+9-0
Changes to packages/zig/src/ios.zig
@@ -3,6 +3,15 @@ const objc_runtime = ;
33const mobile = ;
44const ios_dispatch = ;
55
6// Test collection, not a test. Zig only collects `test` blocks from
7// containers the test root *references*, and `ios_dispatch` is a non-pub
8// import — so without this, its tests and every mobile module's tests were
9// silently absent from `zig build test:ios`. That was found with a canary:
10// a deliberately failing test in a module, and a green run.
11test {
12 _ = ios_dispatch;
13}
14
615/// iOS Application Infrastructure
716/// Provides UIApplicationDelegate, UIViewController, and full app lifecycle management
817/// Re-exported so `test/ios_surface_test.zig` can reach the runtime without
packages/zig/src/ios_dispatch.zigmodified+135-15
Changes to packages/zig/src/ios_dispatch.zig
@@ -4,6 +4,12 @@ const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
66const bridge_mobile = ;
7const bridge_mobile_clipboard = ;
8const bridge_mobile_haptics = ;
9const bridge_mobile_device = ;
10const bridge_mobile_system = ;
11const bridge_mobile_display = ;
12const bridge_mobile_storage = ;
713
814const objc = objc_runtime.objc;
915
@@ -149,21 +155,27 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 {
149155/// diagnostic a bridge can give.
150156fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void {
151157 if (std.mem.eql(u8, msg_type, "mobile")) {
152 var bridge = bridge_mobile.MobileBridge.init(allocator);
153 defer bridge.deinit();
154
155 if (bridge.handleMessage(action, data)) |_| {
156 return;
157 } else |err| switch (err) {
158 // The only error worth handing on. Anything else means a handler
159 // ran and failed, and it has already decided what the real answer
160 // is — asking the shim to try the same action again would replace
161 // a specific failure with whatever it happens to say.
162 bridge_error.BridgeError.UnknownAction => {},
163 else => {
164 bridge_error.sendErrorToJS(allocator, action, asBridgeError(err));
165 return err;
166 },
158 // First module that recognises the action wins. UnknownAction means
159 // "not mine, ask the next"; any other error means a handler ran and
160 // failed, and its answer is final — retrying the same action against
161 // another module (or the shim) would replace a specific failure with
162 // whatever the next thing happens to say.
163 //
164 // The conformance test guarantees no action appears in two modules'
165 // tables, so first-match is deterministic rather than order-dependent.
166 inline for (mobile_bridges) |Bridge| {
167 var bridge = Bridge.init(allocator);
168 defer bridge.deinit();
169
170 if (bridge.handleMessage(action, data)) |_| {
171 return;
172 } else |err| switch (err) {
173 bridge_error.BridgeError.UnknownAction => {},
174 else => {
175 bridge_error.sendErrorToJS(allocator, action, asBridgeError(err));
176 return err;
177 },
178 }
167179 }
168180
169181 if (try handOffToHost(action, data)) return;
@@ -176,6 +188,22 @@ fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8,
176188 return error.UnknownNamespace;
177189}
178190
191/// Every Zig module serving the `mobile` namespace, in dispatch order.
192///
193/// Growing this list is what a migration phase does. The conformance test in
194/// `test/ios_conformance_test.zig` embeds the same files and holds the ratchet,
195/// so adding a module here without listing it there — or vice versa — fails
196/// the build rather than silently narrowing the served surface.
197const mobile_bridges = .{
198 bridge_mobile.MobileBridge,
199 bridge_mobile_clipboard.ClipboardBridge,
200 bridge_mobile_haptics.HapticsBridge,
201 bridge_mobile_device.DeviceBridge,
202 bridge_mobile_system.SystemBridge,
203 bridge_mobile_display.DisplayBridge,
204 bridge_mobile_storage.StorageBridge,
205};
206
179207/// Narrow an arbitrary handler error to one the page's error codes can express.
180208///
181209/// A handler's error set is wider than `BridgeError` — it picks up allocation
@@ -259,6 +287,74 @@ export fn craft_ios_deliver_result(
259287 bridge_error.sendResultToJS(std.heap.c_allocator, action, json);
260288}
261289
290/// Deliver an error the host shim produced.
291///
292/// A separate export from `craft_ios_deliver_result` because results and
293/// errors reach the page by different routes — `__craftBridgeResult` resolves
294/// the pending promise, `__craftBridgeError` rejects it. Delivering a shim
295/// rejection as a result would make the caller's promise *resolve* with an
296/// error-shaped object, which is the fabricated-success bug wearing a
297/// different hat: the catch block the app wrote never runs.
298///
299/// The message and code are free text from the shim, escaped here with the
300/// same `appendJsonEscaped` every native error already goes through — the
301/// Swift side must not hand-escape (its own attempt replaced only `'` and
302/// let backslashes break out of the string literal).
303export fn craft_ios_deliver_error(
304 action_ptr: [*]const u8,
305 action_len: usize,
306 message_ptr: [*]const u8,
307 message_len: usize,
308 code_ptr: [*]const u8,
309 code_len: usize,
310 request_id: i64,
311) callconv(.c) void {
312 const allocator = std.heap.c_allocator;
313 const action = action_ptr[0..action_len];
314 const message = message_ptr[0..message_len];
315 const code = code_ptr[0..code_len];
316
317 var json: std.ArrayListUnmanaged(u8) = .empty;
318 defer json.deinit(allocator);
319
320 buildShimError(allocator, &json, action, message, code, request_id) catch return;
321
322 const js = std.fmt.allocPrint(
323 allocator,
324 "if(window.__craftBridgeError)window.__craftBridgeError({s});",
325 .{json.items},
326 ) catch return;
327 defer allocator.free(js);
328
329 evalJS(js) catch |err| {
330 std.log.warn("ios bridge: could not deliver shim error for '{s}': {}", .{ action, err });
331 };
332}
333
334/// The `__craftBridgeError` payload for a shim-raised error. Split out so the
335/// host tests can pin the exact shape without a webview.
336fn buildShimError(
337 allocator: std.mem.Allocator,
338 out: *std.ArrayListUnmanaged(u8),
339 action: []const u8,
340 message: []const u8,
341 code: []const u8,
342 request_id: i64,
343) !void {
344 try out.appendSlice(allocator, "{\"error\":true,\"code\":\"");
345 try bridge_error.appendJsonEscaped(allocator, out, code);
346 try out.appendSlice(allocator, "\",\"action\":\"");
347 try bridge_error.appendJsonEscaped(allocator, out, action);
348 try out.appendSlice(allocator, "\",\"message\":\"");
349 try bridge_error.appendJsonEscaped(allocator, out, message);
350 try out.append(allocator, '"');
351 if (request_id >= 0) {
352 try out.appendSlice(allocator, ",\"id\":");
353 try out.print(allocator, "{d}", .{request_id});
354 }
355 try out.append(allocator, '}');
356}
357
262358const testing = std.testing;
263359
264360test "an envelope without a type is rejected" {
@@ -376,3 +472,27 @@ test "a request id survives the trip out to the host and back" {
376472 defer request_context.pop();
377473 try testing.expectEqual(@as(?u64, 7), request_context.current());
378474}
475
476test "a shim error carries its code, message, and id, correctly escaped" {
477 var out: std.ArrayListUnmanaged(u8) = .empty;
478 defer out.deinit(testing.allocator);
479
480 // The message contains the three characters Swift's hand-escaping got
481 // wrong: a backslash, a quote, and a newline.
482 try buildShimError(testing.allocator, &out, "share", "path \\ \"x\"\ngone", "NOT_FOUND", 9);
483 try testing.expectEqualStrings(
484 "{\"error\":true,\"code\":\"NOT_FOUND\",\"action\":\"share\",\"message\":\"path \\\\ \\\"x\\\"\\ngone\",\"id\":9}",
485 out.items,
486 );
487}
488
489test "a shim error with no request id omits the id field entirely" {
490 // Omitted, not null or -1: the page's error handler treats a present id as
491 // a promise to reject, and a sentinel would reject nothing — or worse,
492 // someone else's call.
493 var out: std.ArrayListUnmanaged(u8) = .empty;
494 defer out.deinit(testing.allocator);
495
496 try buildShimError(testing.allocator, &out, "share", "m", "E", -1);
497 try testing.expect(std.mem.indexOf(u8, out.items, "\"id\"") == null);
498}
packages/zig/test/ios_conformance_test.zigmodified+35-17
Changes to packages/zig/test/ios_conformance_test.zig
@@ -18,7 +18,20 @@ const testing = std.testing;
1818/// Embedded with `@embedFile` rather than read from disk, so the test is
1919/// hermetic and reads exactly the bytes the build saw.
2020const swift_spec = @embedFile("CraftApp.swift");
21const zig_actions = @embedFile("src/bridge_mobile.zig");
21
22/// Every Zig module that serves part of the `mobile` namespace. A module
23/// migrating actions out of the Swift spec adds itself here — and the ratchet
24/// below is what forces that to happen, because migrated actions the scan
25/// cannot see would read as "dropped" and fail the build.
26const zig_sources = [_][]const u8{
27 @embedFile("src/bridge_mobile.zig"),
28 @embedFile("src/bridge_mobile_clipboard.zig"),
29 @embedFile("src/bridge_mobile_haptics.zig"),
30 @embedFile("src/bridge_mobile_device.zig"),
31 @embedFile("src/bridge_mobile_system.zig"),
32 @embedFile("src/bridge_mobile_display.zig"),
33 @embedFile("src/bridge_mobile_storage.zig"),
34};
2235
2336/// The action list lives in the `switch action` block, and nowhere else.
2437///
@@ -39,7 +52,10 @@ const dispatch_end = "func webView(";
3952/// If this ever needs raising, something has been removed from Zig without
4053/// being removed from the spec, and that is the conversation this constant
4154/// exists to force.
42const max_not_yet_migrated: usize = 105;
55///
56/// History: 105 after the vertical slice (getDeviceInfo); 86 after Tier 0
57/// landed clipboard, haptics, device, system, display, and storage.
58const max_not_yet_migrated: usize = 86;
4359
4460fn dispatcherRegion() []const u8 {
4561 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
@@ -79,26 +95,28 @@ fn collectPostedActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
7995 return set;
8096}
8197
82/// Every action name declared in `bridge_mobile.zig`'s `A` block.
98/// Every action name declared in the `A` blocks of every mobile module.
8399fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
84100 var set = std.StringHashMap(void).init(allocator);
85101 errdefer set.deinit();
86102
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;
103 for (zig_sources) |source| {
104 var it = std.mem.splitScalar(u8, source, '\n');
105 var in_block = false;
106 while (it.next()) |line| {
107 const trimmed = std.mem.trim(u8, line, " \t\r");
108 if (std.mem.startsWith(u8, trimmed, "pub const A = struct {")) {
109 in_block = true;
110 continue;
111 }
112 if (in_block and std.mem.eql(u8, trimmed, "};")) break;
113 if (!in_block) continue;
114
115 // pub const get_device_info = "getDeviceInfo";
116 const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue;
117 const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"') orelse continue;
118 try set.put(trimmed[open + 1 .. close], {});
94119 }
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], {});
102120 }
103121 return set;
104122}
packages/zig/test/ios_surface_test.zigmodified+11-0
Changes to packages/zig/test/ios_surface_test.zig
@@ -1,5 +1,16 @@
11const std = ;
22const ios = ;
3
4// Test *collection*, distinct from the analysis the gate below forces.
5// `refAllDeclsRecursive` makes the compiler check every declaration, which is
6// what catches compile errors — but it does not enroll imported files' `test`
7// blocks. Only this idiom does. Without it, every test in ios.zig,
8// ios_dispatch.zig, and the mobile modules was silently absent from the run:
9// found with a canary — a deliberately failing test in a module, and a green
10// build.
11test {
12 _ = ios;
13}
314const objc = ios.objc;
415
516// `std.testing.refAllDeclsRecursive` is gone in 0.17 — only the shallow