also looking at this
iOS Phase 3: Tier-0 actions in Zig — 19 migrated, ratchet 105 → 86
#85
16 files
+5086
-78
| @@ -44,7 +44,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \ | ||
| 44 | 44 | "$FIXTURE/shim.swift" \ |
| 45 | 45 | "$OUT/main.o" "$OUT/page.o" \ |
| 46 | 46 | "$ROOT/packages/zig/zig-out/lib/$LIB" \ |
| 47 | -framework UIKit -framework WebKit -framework Foundation \ | |
| 47 | -framework UIKit -framework WebKit -framework Foundation -framework Security \ | |
| 48 | 48 | -o "$APP/CraftSlice" |
| 49 | 49 | |
| 50 | 50 | cp "$FIXTURE/Info.plist" "$APP/Info.plist" |
| @@ -66,7 +66,7 @@ LAUNCH_PID=$! | ||
| 66 | 66 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 67 | 67 | # fixed amount, so a slow boot does not read as a failure. |
| 68 | 68 | for _ 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 | |
| 70 | 70 | sleep 1 |
| 71 | 71 | done |
| 72 | 72 | kill "$LAUNCH_PID" 2>/dev/null || true |
| @@ -111,4 +111,19 @@ echo "ok: unserved action reached the dispatcher (i=4 seen ${C4}x)" | ||
| 111 | 111 | [ "$C5" -ge 1 ] || { echo "FAIL: the host shim never answered, or its answer never reached the page"; exit 1; } |
| 112 | 112 | echo "ok: hand-off to host shim closed (i=5 seen ${C5}x)" |
| 113 | 113 | |
| 114 | C6="$(count 6)"; C7="$(count 7)" | |
| 115 | ||
| 116 | [ "$C6" -ge 1 ] || { echo "FAIL: the rejecting action never reached the dispatcher"; exit 1; } | |
| 117 | echo "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; } | |
| 123 | echo "ok: error route closed with escaping intact (i=7 seen ${C7}x)" | |
| 124 | ||
| 125 | C8="$(count 8)" | |
| 126 | [ "$C8" -ge 1 ] || { echo "FAIL: the Tier-0 action's reply never carried a real app state"; exit 1; } | |
| 127 | echo "ok: Tier-0 action served by Zig with a live UIKit value (i=8 seen ${C8}x)" | |
| 128 | ||
| 114 | 129 | echo "PASS" |
| @@ -11,6 +11,11 @@ | ||
| 11 | 11 | // i=3 the user script had already run when the page's own script executed |
| 12 | 12 | // i=4 the page called an action Zig does *not* serve |
| 13 | 13 | // 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 | |
| 14 | 19 | // |
| 15 | 20 | // i=2 and i=5 are the load-bearing ones, and they prove different things. |
| 16 | 21 | // i=2 cannot be produced by a stub: only a real UIKit process reports |
| @@ -22,7 +27,7 @@ const char craft_slice_page[] = | ||
| 22 | 27 | "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
| 23 | 28 | "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" |
| 24 | 29 | "</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" | |
| 26 | 31 | "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n" |
| 27 | 32 | "\n" |
| 28 | 33 | "// Read before anything else touches it: the user script is installed at\n" |
| @@ -33,6 +38,18 @@ const char craft_slice_page[] = | ||
| 33 | 38 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n" |
| 34 | 39 | "}\n" |
| 35 | 40 | "\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" | |
| 36 | 53 | "window.__craftBridgeResult = function (action, payload, id) {\n" |
| 37 | 54 | " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n" |
| 38 | 55 | " document.getElementById('s').textContent = window.__craftSliceAck;\n" |
| @@ -47,6 +64,12 @@ const char craft_slice_page[] = | ||
| 47 | 64 | " // Zig routed the call out, Swift answered through\n" |
| 48 | 65 | " // craft_ios_deliver_result, and Zig delivered it here over the same\n" |
| 49 | 66 | " // 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" | |
| 50 | 73 | " if (!sentHandOff && payload && payload.servedBy === 'host-shim'\n" |
| 51 | 74 | " && payload.language === 'swift') {\n" |
| 52 | 75 | " sentHandOff = true;\n" |
| @@ -58,6 +81,10 @@ const char craft_slice_page[] = | ||
| 58 | 81 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n" |
| 59 | 82 | " // An action Zig does not serve, so it must reach the host shim.\n" |
| 60 | 83 | " 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" | |
| 61 | 88 | "} else {\n" |
| 62 | 89 | " document.getElementById('s').textContent = 'NO BRIDGE';\n" |
| 63 | 90 | "}\n" |
| @@ -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. | |
| 2 | 4 | // |
| 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. | |
| 9 | 13 | // |
| 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. | |
| 13 | 19 | import Foundation |
| 14 | 20 | |
| 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") | |
| 20 | func craft_ios_deliver_result( | |
| 21 | _ action: UnsafePointer<CChar>, _ actionLen: UInt, | |
| 22 | _ json: UnsafePointer<CChar>, _ jsonLen: UInt, | |
| 23 | _ requestId: Int64 | |
| 24 | ) | |
| 21 | private typealias DeliverResultFn = @convention(c) ( | |
| 22 | UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64 | |
| 23 | ) -> Void | |
| 24 | private typealias DeliverErrorFn = @convention(c) ( | |
| 25 | UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, | |
| 26 | UnsafePointer<CChar>, UInt, Int64 | |
| 27 | ) -> Void | |
| 28 | ||
| 29 | private 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 | ||
| 34 | private 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 | }() | |
| 25 | 38 | |
| 26 | 39 | @objc(CraftSwiftShim) |
| 27 | 40 | public 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. | |
| 31 | 41 | @objc public static func handleAction( |
| 32 | 42 | _ action: String, |
| 33 | 43 | payload: String, |
| 34 | 44 | requestId: Int64 |
| 35 | 45 | ) -> 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 | |
| 45 | 56 | |
| 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 | } | |
| 53 | 71 | } |
| 72 | return true | |
| 73 | ||
| 74 | default: | |
| 75 | return false | |
| 54 | 76 | } |
| 55 | return true | |
| 56 | 77 | } |
| 57 | 78 | } |
| @@ -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. | |
| 12 | set -euo pipefail | |
| 13 | ||
| 14 | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" | |
| 15 | TPL="$ROOT/packages/ios/templates" | |
| 16 | TMP="$(mktemp -d)" | |
| 17 | trap 'rm -rf "$TMP"' EXIT | |
| 18 | ||
| 19 | sed 's//app.craft.typecheck/g' "$TPL/CraftApp.swift" > "$TMP/CraftApp.swift" | |
| 20 | ||
| 21 | SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)" | |
| 22 | swiftc -typecheck -parse-as-library \ | |
| 23 | -sdk "$SDK" -target arm64-apple-ios18.0-simulator \ | |
| 24 | "$TMP/CraftApp.swift" "$TPL/CraftActivityAttributes.swift" | |
| 25 | ||
| 26 | echo "CraftApp.swift typechecks" | |
| @@ -364,7 +364,12 @@ struct CraftWebView: UIViewRepresentable { | ||
| 364 | 364 | func updateUIView(_ webView: WKWebView, context: Context) {} |
| 365 | 365 | |
| 366 | 366 | 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 | |
| 368 | 373 | } |
| 369 | 374 | |
| 370 | 375 | // MARK: - Coordinator (Native Bridge) |
| @@ -511,8 +516,19 @@ struct CraftWebView: UIViewRepresentable { | ||
| 511 | 516 | guard let body = message.body as? [String: Any], |
| 512 | 517 | let action = body["action"] as? String else { return } |
| 513 | 518 | |
| 514 | let callbackId = body["callbackId"] as? String | |
| 519 | dispatch(action: action, body: body, callbackId: body["callbackId"] as? String) | |
| 520 | } | |
| 515 | 521 | |
| 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?) { | |
| 516 | 532 | switch action { |
| 517 | 533 | case "startListening": |
| 518 | 534 | if config.enableSpeechRecognition { startSpeechRecognition() } |
| @@ -2426,6 +2442,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2426 | 2442 | rejectCallback(callbackId, error: "Native result could not be serialized", code: "SERIALIZATION_ERROR") |
| 2427 | 2443 | return |
| 2428 | 2444 | } |
| 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 } | |
| 2429 | 2449 | let script = "window.craft._resolveCallback('\(id)', \(resultStr));" |
| 2430 | 2450 | DispatchQueue.main.async { self.webView?.evaluateJavaScript(script, completionHandler: nil) } |
| 2431 | 2451 | } |
| @@ -2436,7 +2456,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2436 | 2456 | |
| 2437 | 2457 | private func rejectCallback(_ callbackId: String?, error: String, code: String = "CRAFT_ERROR") { |
| 2438 | 2458 | 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") | |
| 2440 | 2470 | let script = "window.craft._rejectCallback('\(id)', '\(escapedError)', '\(code)');" |
| 2441 | 2471 | DispatchQueue.main.async { self.webView?.evaluateJavaScript(script, completionHandler: nil) } |
| 2442 | 2472 | } |
| @@ -5159,3 +5189,98 @@ extension CraftWebView.Coordinator: WCSessionDelegate { | ||
| 5159 | 5189 | } |
| 5160 | 5190 | } |
| 5161 | 5191 | } |
| 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) | |
| 5210 | final 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 | } | |
| @@ -339,6 +339,40 @@ pub fn build(b: *std.Build) void { | ||
| 339 | 339 | ios_surface_tests.root_module.linkFramework("CoreFoundation", .{}); |
| 340 | 340 | ios_surface_tests.root_module.linkFramework("CoreGraphics", .{}); |
| 341 | 341 | 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", .{}); | |
| 342 | 376 | } |
| 343 | 377 | |
| 344 | 378 | // The iOS conformance gate. It embeds the Swift template as the migration |
| @@ -357,6 +391,24 @@ pub fn build(b: *std.Build) void { | ||
| 357 | 391 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{ |
| 358 | 392 | .root_source_file = b.path("src/bridge_mobile.zig"), |
| 359 | 393 | }); |
| 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 | }); | |
| 360 | 412 | |
| 361 | 413 | const menubar_tests = b.addTest(.{ |
| 362 | 414 | .root_module = b.createModule(.{ |
| @@ -1204,6 +1256,7 @@ pub fn build(b: *std.Build) void { | ||
| 1204 | 1256 | const run_mobile_tests = b.addRunArtifact(mobile_tests); |
| 1205 | 1257 | const run_ios_surface_tests = b.addRunArtifact(ios_surface_tests); |
| 1206 | 1258 | const run_ios_conformance_tests = b.addRunArtifact(ios_conformance_tests); |
| 1259 | const run_ios_module_tests = b.addRunArtifact(ios_module_tests); | |
| 1207 | 1260 | const run_menubar_tests = b.addRunArtifact(menubar_tests); |
| 1208 | 1261 | const run_components_tests = b.addRunArtifact(components_tests); |
| 1209 | 1262 | const run_gpu_tests = b.addRunArtifact(gpu_tests); |
| @@ -1339,6 +1392,7 @@ pub fn build(b: *std.Build) void { | ||
| 1339 | 1392 | // touches no platform types, so it runs everywhere. |
| 1340 | 1393 | if (target.result.os.tag.isDarwin()) { |
| 1341 | 1394 | test_step.dependOn(&run_ios_surface_tests.step); |
| 1395 | test_step.dependOn(&run_ios_module_tests.step); | |
| 1342 | 1396 | } |
| 1343 | 1397 | test_step.dependOn(&run_ios_conformance_tests.step); |
| 1344 | 1398 | test_step.dependOn(&run_menubar_tests.step); |
| @@ -1422,6 +1476,7 @@ pub fn build(b: *std.Build) void { | ||
| 1422 | 1476 | const test_ios_step = b.step("test:ios", "Run the iOS surface gate"); |
| 1423 | 1477 | if (target.result.os.tag.isDarwin()) { |
| 1424 | 1478 | test_ios_step.dependOn(&run_ios_surface_tests.step); |
| 1479 | test_ios_step.dependOn(&run_ios_module_tests.step); | |
| 1425 | 1480 | } |
| 1426 | 1481 | test_ios_step.dependOn(&run_ios_conformance_tests.step); |
| 1427 | 1482 | |
| @@ -1681,6 +1736,11 @@ pub fn build(b: *std.Build) void { | ||
| 1681 | 1736 | }, |
| 1682 | 1737 | }), |
| 1683 | 1738 | }); |
| 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; | |
| 1684 | 1744 | // Frameworks are linked at the app level in Xcode, not in the static library |
| 1685 | 1745 | const ios_device_install = b.addInstallArtifact(ios_device_lib, .{}); |
| 1686 | 1746 | build_ios.dependOn(&ios_device_install.step); |
| @@ -1705,6 +1765,11 @@ pub fn build(b: *std.Build) void { | ||
| 1705 | 1765 | }, |
| 1706 | 1766 | }), |
| 1707 | 1767 | }); |
| 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; | |
| 1708 | 1773 | const ios_sim_arm64_install = b.addInstallArtifact(ios_sim_arm64_lib, .{}); |
| 1709 | 1774 | build_ios_simulator.dependOn(&ios_sim_arm64_install.step); |
| 1710 | 1775 | build_ios_all.dependOn(&ios_sim_arm64_install.step); |
| @@ -1728,6 +1793,11 @@ pub fn build(b: *std.Build) void { | ||
| 1728 | 1793 | }, |
| 1729 | 1794 | }), |
| 1730 | 1795 | }); |
| 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; | |
| 1731 | 1801 | const ios_sim_x64_install = b.addInstallArtifact(ios_sim_x64_lib, .{}); |
| 1732 | 1802 | build_ios_simulator.dependOn(&ios_sim_x64_install.step); |
| 1733 | 1803 | build_ios_all.dependOn(&ios_sim_x64_install.step); |
packages/zig/src/bridge_mobile_clipboard.zigadded+602-0
| @@ -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 | ||
| 43 | const std = ; | |
| 44 | const builtin = ; | |
| 45 | const capabilities = ; | |
| 46 | const bridge_error = ; | |
| 47 | const objc_runtime = ; | |
| 48 | ||
| 49 | const 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. | |
| 59 | const 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. | |
| 66 | pub 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. | |
| 77 | pub 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. | |
| 85 | const reply_true = "true"; | |
| 86 | const reply_no_text = "\"\""; | |
| 87 | ||
| 88 | /// NSUTF8StringEncoding, for `-lengthOfBytesUsingEncoding:`. | |
| 89 | const 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. | |
| 98 | var clipboard_enabled: bool = true; | |
| 99 | ||
| 100 | pub 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. | |
| 108 | export fn craft_ios_set_clipboard_enabled(value: bool) callconv(.c) void { | |
| 109 | clipboard_enabled = value; | |
| 110 | } | |
| 111 | ||
| 112 | pub 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. | |
| 223 | fn 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. | |
| 245 | fn 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. | |
| 262 | fn 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. | |
| 273 | fn 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`. | |
| 285 | fn 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. | |
| 300 | fn 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 `""`. | |
| 316 | fn 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. | |
| 350 | fn 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 | ||
| 377 | const testing = std.testing; | |
| 378 | ||
| 379 | test "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 | ||
| 392 | test "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 | ||
| 400 | test "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 | ||
| 415 | test "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 | ||
| 431 | test "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 | ||
| 442 | test "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 | ||
| 455 | test "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 | ||
| 468 | test "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 | ||
| 479 | test "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 | ||
| 495 | test "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 | ||
| 523 | test "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 | ||
| 531 | test "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 | ||
| 541 | test "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 | ||
| 550 | test "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 | ||
| 565 | test "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 | ||
| 572 | test "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 | ||
| 579 | test "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 | ||
| 598 | test "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
| @@ -0,0 +1,642 @@ | ||
| 1 | const std = ; | |
| 2 | const builtin = ; | |
| 3 | const capabilities = ; | |
| 4 | const bridge_error = ; | |
| 5 | const bridge_log = ; | |
| 6 | const objc_runtime = ; | |
| 7 | ||
| 8 | const 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`. | |
| 25 | pub 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. | |
| 53 | pub 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 | ||
| 57 | pub 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. | |
| 81 | const Route = enum { memory_usage, app_state, network_status, log }; | |
| 82 | ||
| 83 | fn 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 | ||
| 91 | pub 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. | |
| 192 | const MachTimeValue = extern struct { | |
| 193 | seconds: i32, | |
| 194 | microseconds: i32, | |
| 195 | }; | |
| 196 | ||
| 197 | const 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 | ||
| 207 | const 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. | |
| 212 | const MACH_TASK_BASIC_INFO_COUNT: c_uint = @sizeOf(MachTaskBasicInfo) / @sizeOf(i32); | |
| 213 | ||
| 214 | const KERN_SUCCESS: c_int = 0; | |
| 215 | ||
| 216 | const 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. | |
| 241 | fn 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. | |
| 272 | fn 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. | |
| 286 | fn 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`. | |
| 300 | const UIApplicationStateActive: c_long = 0; | |
| 301 | const UIApplicationStateInactive: c_long = 1; | |
| 302 | const 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. | |
| 315 | fn 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. | |
| 334 | fn 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 | ||
| 365 | const testing = std.testing; | |
| 366 | ||
| 367 | test "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 | ||
| 379 | test "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 | ||
| 409 | test "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 | ||
| 424 | test "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 | ||
| 436 | test "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 | ||
| 448 | test "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 | ||
| 456 | test "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 | ||
| 475 | test "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 | ||
| 489 | test "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 | ||
| 501 | test "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 | ||
| 525 | test "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 | ||
| 541 | test "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 | ||
| 547 | test "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 | ||
| 554 | test "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 | ||
| 568 | test "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 | ||
| 619 | test "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 | ||
| 632 | test "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
| @@ -0,0 +1,660 @@ | ||
| 1 | const std = ; | |
| 2 | const builtin = ; | |
| 3 | const capabilities = ; | |
| 4 | const bridge_error = ; | |
| 5 | const objc_runtime = ; | |
| 6 | ||
| 7 | const objc = objc_runtime.objc; | |
| 8 | ||
| 9 | /// The `mobile` 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. | |
| 55 | pub 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 | ||
| 63 | pub 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 | ||
| 81 | pub 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. | |
| 188 | fn renderBadgeReply(buf: []u8, observed: i64) ![]const u8 { | |
| 189 | return std.fmt.bufPrint(buf, "", .{observed}); | |
| 190 | } | |
| 191 | ||
| 192 | fn 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. | |
| 213 | fn 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. | |
| 247 | fn 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. | |
| 268 | pub 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 | ||
| 308 | fn 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. | |
| 348 | fn 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. | |
| 395 | fn 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. | |
| 416 | fn 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. | |
| 444 | fn 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 | ||
| 452 | const testing = std.testing; | |
| 453 | ||
| 454 | test "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 | ||
| 483 | test "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 | ||
| 498 | test "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 | ||
| 508 | test "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 | ||
| 517 | test "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 | ||
| 531 | test "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 | ||
| 549 | test "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 | ||
| 562 | test "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 | ||
| 582 | test "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 | ||
| 594 | test "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 | ||
| 608 | test "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 | ||
| 633 | test "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 | ||
| 656 | test "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
| @@ -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 | ||
| 9 | const std = ; | |
| 10 | const builtin = ; | |
| 11 | const capabilities = ; | |
| 12 | const bridge_error = ; | |
| 13 | const objc_runtime = ; | |
| 14 | const mobile = ; | |
| 15 | ||
| 16 | const 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. | |
| 21 | const HapticType = mobile.iOS.HapticType; | |
| 22 | ||
| 23 | pub 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. | |
| 58 | const 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 | ||
| 63 | pub 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 `{}`. | |
| 85 | const vibrate_reply = "true"; | |
| 86 | ||
| 87 | pub 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. | |
| 173 | fn 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. | |
| 207 | fn 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. | |
| 220 | fn 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. | |
| 262 | fn 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. | |
| 362 | fn 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 | ||
| 380 | const 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 | ||
| 391 | const 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`. | |
| 399 | const 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. | |
| 421 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 422 | ||
| 423 | const 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. | |
| 429 | fn 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 | ||
| 435 | const medium_impact_descriptor = BlockDescriptor{ .size = @sizeOf(DispatchBlock) }; | |
| 436 | ||
| 437 | const 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 | ||
| 445 | const testing = std.testing; | |
| 446 | ||
| 447 | test "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 | ||
| 459 | test "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 | ||
| 472 | test "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 | ||
| 487 | test "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 | ||
| 504 | test "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 | ||
| 518 | test "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 | ||
| 528 | test "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. | |
| 542 | fn 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 | ||
| 551 | test "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 | ||
| 565 | test "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 | ||
| 582 | test "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 | ||
| 596 | test "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 | ||
| 603 | test "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 | ||
| 608 | test "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 | ||
| 630 | test "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 | ||
| 639 | test "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 | ||
| 651 | test "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
| @@ -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 | ||
| 85 | const std = ; | |
| 86 | const builtin = ; | |
| 87 | const capabilities = ; | |
| 88 | const bridge_error = ; | |
| 89 | const objc_runtime = ; | |
| 90 | ||
| 91 | const 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. | |
| 100 | const 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. | |
| 105 | const 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. | |
| 109 | const 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. | |
| 115 | extern "c" fn SecItemAdd(attributes: CFTypeRef, result: ?*CFTypeRef) OSStatus; | |
| 116 | extern "c" fn SecItemCopyMatching(query: CFTypeRef, result: ?*CFTypeRef) OSStatus; | |
| 117 | extern "c" fn SecItemDelete(query: CFTypeRef) OSStatus; | |
| 118 | extern "c" fn CFRelease(cf: CFTypeRef) void; | |
| 119 | ||
| 120 | extern "c" const kSecClass: CFTypeRef; | |
| 121 | extern "c" const kSecClassGenericPassword: CFTypeRef; | |
| 122 | extern "c" const kSecAttrAccount: CFTypeRef; | |
| 123 | extern "c" const kSecAttrAccessGroup: CFTypeRef; | |
| 124 | extern "c" const kSecAttrAccessible: CFTypeRef; | |
| 125 | extern "c" const kSecAttrAccessibleWhenUnlockedThisDeviceOnly: CFTypeRef; | |
| 126 | extern "c" const kSecValueData: CFTypeRef; | |
| 127 | extern "c" const kSecReturnData: CFTypeRef; | |
| 128 | extern "c" const kSecMatchLimit: CFTypeRef; | |
| 129 | extern "c" const kSecMatchLimitOne: CFTypeRef; | |
| 130 | extern "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. | |
| 139 | const errSecSuccess: OSStatus = 0; | |
| 140 | const errSecItemNotFound: OSStatus = -25300; | |
| 141 | const 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. | |
| 149 | pub 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. | |
| 165 | pub 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. | |
| 179 | const Request = struct { | |
| 180 | key: []const u8, | |
| 181 | value: ?[]const u8 = null, | |
| 182 | group: ?[]const u8 = null, | |
| 183 | }; | |
| 184 | ||
| 185 | pub 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. | |
| 262 | const 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. | |
| 269 | fn 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. | |
| 289 | fn 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. | |
| 348 | fn 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. | |
| 358 | fn 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 `""`. | |
| 373 | fn 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`. | |
| 401 | fn 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. | |
| 416 | fn 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. | |
| 441 | fn 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. | |
| 473 | fn 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. | |
| 487 | fn 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. | |
| 507 | fn 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`. | |
| 529 | fn 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. | |
| 588 | fn 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. | |
| 599 | fn 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`. | |
| 610 | fn 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. | |
| 626 | fn 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 | ||
| 635 | const testing = std.testing; | |
| 636 | ||
| 637 | test "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 | ||
| 649 | test "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 | ||
| 658 | test "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 | ||
| 674 | test "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 | ||
| 695 | test "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. | |
| 707 | fn 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 | ||
| 717 | fn 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 | ||
| 723 | test "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 | ||
| 737 | test "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 | ||
| 742 | test "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 | ||
| 749 | test "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 | ||
| 755 | test "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 | ||
| 769 | test "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 | ||
| 785 | test "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 | ||
| 801 | test "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 | ||
| 835 | test "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 | ||
| 841 | test "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 | ||
| 846 | test "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 | ||
| 852 | test "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 | ||
| 868 | test "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 | ||
| 881 | test "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 | ||
| 894 | test "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 | ||
| 917 | test "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 | ||
| 934 | test "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 | ||
| 942 | test "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
| @@ -0,0 +1,1043 @@ | ||
| 1 | const std = ; | |
| 2 | const builtin = ; | |
| 3 | const capabilities = ; | |
| 4 | const bridge_error = ; | |
| 5 | const objc_runtime = ; | |
| 6 | const ios_dispatch = ; | |
| 7 | const request_context = ; | |
| 8 | ||
| 9 | const 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. | |
| 18 | const 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. | |
| 44 | pub const A = struct { | |
| 45 | pub const open_url = "openURL"; | |
| 46 | pub const share = "share"; | |
| 47 | pub const request_review = "requestReview"; | |
| 48 | }; | |
| 49 | ||
| 50 | pub 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. | |
| 70 | const 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 | ||
| 80 | pub 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". | |
| 170 | fn 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. | |
| 179 | fn 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. | |
| 196 | pub 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. | |
| 226 | pub 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 | ||
| 294 | const BlockDescriptor = extern struct { | |
| 295 | reserved: usize = 0, | |
| 296 | size: usize, | |
| 297 | }; | |
| 298 | ||
| 299 | extern var _NSConcreteStackBlock: anyopaque; | |
| 300 | ||
| 301 | /// `void (^)(BOOL success)` — what `openURL:options:completionHandler:` calls. | |
| 302 | const 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 | ||
| 314 | const open_url_block_descriptor = BlockDescriptor{ .size = @sizeOf(OpenURLBlock) }; | |
| 315 | ||
| 316 | /// `void (^)(UIActivityType, BOOL completed, NSArray *, NSError *)` — what | |
| 317 | /// `UIActivityViewController.completionWithItemsHandler` calls. | |
| 318 | const 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 | ||
| 327 | const 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. | |
| 337 | fn 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 | ||
| 343 | fn 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. | |
| 358 | const 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 | ||
| 739 | const 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. | |
| 745 | fn 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 | ||
| 759 | test "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 | ||
| 789 | test "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 | ||
| 801 | test "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 | ||
| 823 | test "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 | ||
| 834 | test "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 | ||
| 848 | test "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 | ||
| 860 | test "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 | ||
| 871 | test "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 | ||
| 889 | test "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 | ||
| 904 | test "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 | ||
| 922 | test "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 | ||
| 931 | test "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 | ||
| 942 | test "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 | ||
| 961 | test "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 | ||
| 981 | test "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 | ||
| 988 | test "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 | ||
| 1020 | test "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 | } | |
| @@ -3,6 +3,15 @@ const objc_runtime = ; | ||
| 3 | 3 | const mobile = ; |
| 4 | 4 | const ios_dispatch = ; |
| 5 | 5 | |
| 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. | |
| 11 | test { | |
| 12 | _ = ios_dispatch; | |
| 13 | } | |
| 14 | ||
| 6 | 15 | /// iOS Application Infrastructure |
| 7 | 16 | /// Provides UIApplicationDelegate, UIViewController, and full app lifecycle management |
| 8 | 17 | /// Re-exported so `test/ios_surface_test.zig` can reach the runtime without |
| @@ -4,6 +4,12 @@ const objc_runtime = ; | ||
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | 6 | const bridge_mobile = ; |
| 7 | const bridge_mobile_clipboard = ; | |
| 8 | const bridge_mobile_haptics = ; | |
| 9 | const bridge_mobile_device = ; | |
| 10 | const bridge_mobile_system = ; | |
| 11 | const bridge_mobile_display = ; | |
| 12 | const bridge_mobile_storage = ; | |
| 7 | 13 | |
| 8 | 14 | const objc = objc_runtime.objc; |
| 9 | 15 | |
| @@ -149,21 +155,27 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 { | ||
| 149 | 155 | /// diagnostic a bridge can give. |
| 150 | 156 | fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void { |
| 151 | 157 | 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 | } | |
| 167 | 179 | } |
| 168 | 180 | |
| 169 | 181 | if (try handOffToHost(action, data)) return; |
| @@ -176,6 +188,22 @@ fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, | ||
| 176 | 188 | return error.UnknownNamespace; |
| 177 | 189 | } |
| 178 | 190 | |
| 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. | |
| 197 | const 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 | ||
| 179 | 207 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| 180 | 208 | /// |
| 181 | 209 | /// A handler's error set is wider than `BridgeError` — it picks up allocation |
| @@ -259,6 +287,74 @@ export fn craft_ios_deliver_result( | ||
| 259 | 287 | bridge_error.sendResultToJS(std.heap.c_allocator, action, json); |
| 260 | 288 | } |
| 261 | 289 | |
| 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). | |
| 303 | export 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. | |
| 336 | fn 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 | ||
| 262 | 358 | const testing = std.testing; |
| 263 | 359 | |
| 264 | 360 | test "an envelope without a type is rejected" { |
| @@ -376,3 +472,27 @@ test "a request id survives the trip out to the host and back" { | ||
| 376 | 472 | defer request_context.pop(); |
| 377 | 473 | try testing.expectEqual(@as(?u64, 7), request_context.current()); |
| 378 | 474 | } |
| 475 | ||
| 476 | test "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 | ||
| 489 | test "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 | } | |
| @@ -18,7 +18,20 @@ const testing = std.testing; | ||
| 18 | 18 | /// Embedded with `@embedFile` rather than read from disk, so the test is |
| 19 | 19 | /// hermetic and reads exactly the bytes the build saw. |
| 20 | 20 | const swift_spec = @embedFile("CraftApp.swift"); |
| 21 | const 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. | |
| 26 | const 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 | }; | |
| 22 | 35 | |
| 23 | 36 | /// The action list lives in the `switch action` block, and nowhere else. |
| 24 | 37 | /// |
| @@ -39,7 +52,10 @@ const dispatch_end = "func webView("; | ||
| 39 | 52 | /// If this ever needs raising, something has been removed from Zig without |
| 40 | 53 | /// being removed from the spec, and that is the conversation this constant |
| 41 | 54 | /// exists to force. |
| 42 | const 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. | |
| 58 | const max_not_yet_migrated: usize = 86; | |
| 43 | 59 | |
| 44 | 60 | fn dispatcherRegion() []const u8 { |
| 45 | 61 | 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) { | ||
| 79 | 95 | return set; |
| 80 | 96 | } |
| 81 | 97 | |
| 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. | |
| 83 | 99 | fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) { |
| 84 | 100 | var set = std.StringHashMap(void).init(allocator); |
| 85 | 101 | errdefer set.deinit(); |
| 86 | 102 | |
| 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], {}); | |
| 94 | 119 | } |
| 95 | if (in_block and std.mem.eql(u8, trimmed, "};")) break; | |
| 96 | if (!in_block) continue; | |
| 97 | ||
| 98 | // pub const get_device_info = "getDeviceInfo"; | |
| 99 | const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue; | |
| 100 | const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"') orelse continue; | |
| 101 | try set.put(trimmed[open + 1 .. close], {}); | |
| 102 | 120 | } |
| 103 | 121 | return set; |
| 104 | 122 | } |
| @@ -1,5 +1,16 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const 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. | |
| 11 | test { | |
| 12 | _ = ios; | |
| 13 | } | |
| 3 | 14 | const objc = ios.objc; |
| 4 | 15 | |
| 5 | 16 | // `std.testing.refAllDeclsRecursive` is gone in 0.17 — only the shallow |