ReviewOS

also looking at this

craft-native/craft

feat(ios): the Zig runtime, offered to a Swift-hosted app

#103
Merged glennmichael123 wants to merge feat/ios-swift-host-seam into main
7 files +868 -59
packages/ios/src/index.tsmodified+112-0
Changes to packages/ios/src/index.ts
@@ -262,6 +262,36 @@ export function renderWatchEntitlements(config: CraftConfig): string {
262262 return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n${appGroups}</dict>\n</plist>\n`
263263}
264264
265/**
266 * The build settings that link the Zig runtime into the app target.
267 *
268 * Two things are load-bearing here:
269 *
270 * `LIBRARY_SEARCH_PATHS` is written twice, once per SDK, so `-lcraft-ios`
271 * resolves to the device archive for a device build and the simulator archive
272 * for a simulator one without the flag itself changing. Xcode applies the
273 * `[sdk=...]` condition; xcodegen passes these keys through verbatim.
274 *
275 * The four `-u` flags are the reason anything works at all. Nothing in the
276 * Swift source *references* these symbols — `CraftZigRuntime` and
277 * `CraftSwiftShim` both find them with `dlsym` at runtime — so a static
278 * archive contributes no objects for them and the linker drops the entire
279 * runtime as unreachable. `-u` names them as undefined so the objects are
280 * pulled in and the symbols end up in the binary for `dlsym` to find.
281 *
282 * `-u` rather than `-force_load`: forcing the whole archive would pull in
283 * every object whether reachable or not, and these four entry points already
284 * reach everything the bridge actually uses.
285 */
286export function renderRuntimeSettings(): string {
287 return [
288 ' LIBRARY_SEARCH_PATHS[sdk=iphoneos*]: "$(PROJECT_DIR)/Runtime/device"',
289 ' LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]: "$(PROJECT_DIR)/Runtime/simulator"',
290 ' OTHER_LDFLAGS: "-lcraft-ios -Wl,-u,_craft_ios_handle_action -Wl,-u,_craft_ios_set_webview '
291 + '-Wl,-u,_craft_ios_deliver_result -Wl,-u,_craft_ios_deliver_error"',
292 ].join('\n')
293}
294
265295export function renderPrivacyManifest(config: CraftConfig): string {
266296 const privacy = config.privacy ?? {}
267297 const collected = privacy.collectedDataTypes ?? []
@@ -334,6 +364,81 @@ export function syncWebAssets(source: string, output: string): void {
334364/**
335365 * Initialize a new iOS project
336366 */
367/**
368 * Where the Zig runtime archives live, or null when this build has none.
369 *
370 * `CRAFT_IOS_RUNTIME` points at a directory holding the archives `zig build
371 * build-ios-all` produces. It is opt-in and there is deliberately no fallback
372 * search: a generated project with no runtime is the shipping default today,
373 * and it works — `CraftZigRuntime.offer` is a `dlsym` miss, every action
374 * answers false, and the Swift switch serves the whole surface exactly as it
375 * always has. Guessing at a path would turn "no runtime" into "some runtime,
376 * from somewhere", which is the failure mode the pantry contract exists to
377 * rule out.
378 *
379 * This is the same shape as `CRAFT_BIN`: an explicit override for the monorepo
380 * dev loop, not a lookup path. Shipping the runtime to real apps means putting
381 * these archives in the pantry package beside the `craft` binary, which is a
382 * distribution decision this function does not make.
383 */
384export function resolveRuntimeDir(): string | null {
385 const dir = process.env.CRAFT_IOS_RUNTIME
386 if (!dir) return null
387 if (!existsSync(dir)) {
388 throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`)
389 }
390 return dir
391}
392
393/** The archives a runtime directory must contain, by Xcode SDK. */
394const RUNTIME_ARCHIVES = {
395 device: ['libcraft-ios.a'],
396 // Both simulator slices, merged into one archive below. A Mac on Apple
397 // silicon runs arm64 simulators and an Intel one runs x86_64; shipping a
398 // single-slice archive makes the project build on one machine and fail to
399 // link on the other, which is the kind of break that only shows up on
400 // someone else's laptop.
401 simulator: ['libcraft-ios-simulator-arm64.a', 'libcraft-ios-simulator-x64.a'],
402} as const
403
404/**
405 * Copy the Zig archives into the generated project as `Runtime/<sdk>/libcraft-ios.a`.
406 *
407 * One name for both SDKs, in two directories, because that is what lets a
408 * single `-lcraft-ios` in OTHER_LDFLAGS work for device and simulator builds:
409 * Xcode picks the directory by SDK and the flag never changes.
410 *
411 * Returns true when a runtime was installed.
412 */
413export async function installRuntime(output: string, runtimeDir: string): Promise<boolean> {
414 const dest = join(output, 'Runtime')
415 rmSync(dest, { recursive: true, force: true })
416
417 for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) {
418 const present = archives.filter(a => existsSync(join(runtimeDir, a)))
419 if (present.length === 0) {
420 throw new Error(
421 `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. `
422 + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`,
423 )
424 }
425
426 const sdkDir = join(dest, sdk)
427 mkdirSync(sdkDir, { recursive: true })
428 const target = join(sdkDir, 'libcraft-ios.a')
429
430 if (present.length === 1) {
431 cpSync(join(runtimeDir, present[0]!), target)
432 }
433 else {
434 // `lipo -create` rather than picking one slice: see RUNTIME_ARCHIVES.
435 await $`lipo -create ${present.map(a => join(runtimeDir, a))} -output ${target}`.quiet()
436 }
437 }
438
439 return true
440}
441
337442export async function init(options: InitOptions): Promise<void> {
338443 const { name, bundleId, teamId, output } = options
339444
@@ -426,7 +531,14 @@ export async function init(options: InitOptions): Promise<void> {
426531 SKIP_INSTALL: YES`)
427532 }
428533
534 // The Zig runtime, when this build has one. Installed before the project is
535 // rendered because the settings below name the directories it creates.
536 const runtimeDir = resolveRuntimeDir()
537 const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false
538 if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir)
539
429540 const projectYml = projectYmlTemplate
541 .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '')
430542 .replace(/\{\{APP_NAME\}\}/g, name)
431543 .replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId)
432544 .replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix)
packages/ios/templates/CraftApp.swiftmodified+169-1
Changes to packages/ios/templates/CraftApp.swift
@@ -338,6 +338,12 @@ struct CraftWebView: UIViewRepresentable {
338338 webView.navigationDelegate = context.coordinator
339339 webView.isOpaque = false
340340
341 // Before any message can be offered. Zig evaluates every reply against
342 // this webview, and without it an action would run, succeed, and reach
343 // `error.NoWebView` on the way back — the page hearing nothing while
344 // the work happened.
345 CraftZigRuntime.attach(webView)
346
341347 // Register with DeepLinkManager
342348 DeepLinkManager.shared.setWebView(webView)
343349
@@ -363,6 +369,13 @@ struct CraftWebView: UIViewRepresentable {
363369
364370 func updateUIView(_ webView: WKWebView, context: Context) {}
365371
372 /// The teardown half of `CraftZigRuntime.attach`, called by SwiftUI when
373 /// this representable goes away. Without it Zig keeps an unretained
374 /// pointer to a deallocated webview and answers into freed memory.
375 static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
376 CraftZigRuntime.detach(uiView)
377 }
378
366379 func makeCoordinator() -> Coordinator {
367380 let coordinator = Coordinator(config: config)
368381 // The Zig dispatcher finds CraftSwiftShim by class name; the shim finds
@@ -516,7 +529,24 @@ struct CraftWebView: UIViewRepresentable {
516529 guard let body = message.body as? [String: Any],
517530 let action = body["action"] as? String else { return }
518531
519 dispatch(action: action, body: body, callbackId: body["callbackId"] as? String)
532 let callbackId = body["callbackId"] as? String
533
534 // The Zig runtime first, when there is one.
535 //
536 // `offer` returns true when Zig has taken responsibility for the
537 // call — served it, or refused it and settled the page's promise
538 // on the way. Either way the answer is on its way and this method
539 // must not produce a second one. False means no Zig module
540 // recognised the action, which is every action still listed in the
541 // switch below and nothing else.
542 //
543 // In an app with no Zig runtime linked, `offer` is a `dlsym` miss
544 // that answers false for everything, and the switch serves the
545 // whole surface exactly as it did before. That is why this is one
546 // line and not a build flag.
547 if CraftZigRuntime.offer(action: action, body: body, callbackId: callbackId) { return }
548
549 dispatch(action: action, body: body, callbackId: callbackId)
520550 }
521551
522552 /// The action switch, reachable from two directions: the
@@ -2421,6 +2451,27 @@ struct CraftWebView: UIViewRepresentable {
24212451 }
24222452 })(window.craft);
24232453
2454 // The reply route for actions the Zig runtime serves.
2455 //
2456 // Zig owns one wire format and calls these two functions by name;
2457 // this page owns `_callbacks`, keyed by the 'cb_<n>' ids `_invoke`
2458 // hands out. The whole adaptation is turning the numeric id Zig
2459 // carries back into that key. Nothing else is translated, because
2460 // nothing else differs: `craftSpeechStart` and friends already
2461 // arrive as plain CustomEvents from both sides.
2462 //
2463 // A null id means the page sent no callback — the tray-style
2464 // fire-and-forget posts — so there is nothing to settle and
2465 // dropping it is correct rather than lossy.
2466 window.__craftBridgeResult = function (action, result, id) {
2467 if (id === null || id === undefined) return;
2468 window.craft._resolveCallback('cb_' + id, result);
2469 };
2470 window.__craftBridgeError = function (ctx) {
2471 if (!ctx || ctx.id === null || ctx.id === undefined) return;
2472 window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
2473 };
2474
24242475 // Dispatch ready event
24252476 window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
24262477 console.log('Craft iOS bridge initialized');
@@ -5193,6 +5244,123 @@ extension CraftWebView.Coordinator: WCSessionDelegate {
51935244
51945245// MARK: - Zig hand-off shim
51955246
5247/// The seam the page's messages reach the Zig runtime through.
5248///
5249/// The opposite direction from `CraftSwiftShim` below, and the two together
5250/// are the whole hand-off: this class offers each message to Zig, Zig serves
5251/// what it has migrated and declines the rest, and for anything Zig *did*
5252/// take that needs Swift work, `CraftSwiftShim` is how it comes back.
5253///
5254/// Discovery is `dlsym`, exactly as the shim's is, and for the same reason:
5255/// neither side links the other. An app built without the Zig static library
5256/// finds nothing here, `offer` answers false for every action, and the
5257/// coordinator's switch serves the entire surface as it always has. There is
5258/// no build flag and no second code path — the absence of a symbol is the
5259/// off switch.
5260@objc(CraftZigRuntime)
5261final class CraftZigRuntime: NSObject {
5262 private typealias SetWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
5263 private typealias ClearWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
5264 private typealias HandleActionFn = @convention(c) (
5265 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
5266 ) -> Bool
5267
5268 private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
5269
5270 private static let setWebViewFn: SetWebViewFn? = {
5271 guard let sym = dlsym(image, "craft_ios_set_webview") else { return nil }
5272 return unsafeBitCast(sym, to: SetWebViewFn.self)
5273 }()
5274
5275 private static let clearWebViewFn: ClearWebViewFn? = {
5276 guard let sym = dlsym(image, "craft_ios_clear_webview") else { return nil }
5277 return unsafeBitCast(sym, to: ClearWebViewFn.self)
5278 }()
5279
5280 private static let handleActionFn: HandleActionFn? = {
5281 guard let sym = dlsym(image, "craft_ios_handle_action") else { return nil }
5282 return unsafeBitCast(sym, to: HandleActionFn.self)
5283 }()
5284
5285 /// Whether this build has a Zig runtime at all. Read by nothing here; kept
5286 /// because "is Zig linked" is the first question to ask when an action
5287 /// behaves like the Swift one after a migration was supposed to move it.
5288 @objc static var isLinked: Bool { handleActionFn != nil }
5289
5290 /// Give Zig the webview its replies are evaluated against.
5291 ///
5292 /// Unretained on purpose, on both sides: this is the app's own root view
5293 /// and it outlives the runtime. A retain here would be a cycle with
5294 /// nothing to break it.
5295 static func attach(_ webView: WKWebView) {
5296 setWebViewFn?(Unmanaged.passUnretained(webView).toOpaque())
5297 }
5298
5299 /// Forget this webview, if Zig is still holding it.
5300 ///
5301 /// `attach` hands over an unretained pointer, so the moment SwiftUI
5302 /// deallocates the view Zig is holding freed memory and every later reply
5303 /// is an `objc_msgSend` into it. Nothing called this before, and nothing
5304 /// had to while the app had exactly one webview for its whole life — but
5305 /// a `UIViewRepresentable` is rebuilt whenever its identity changes, and
5306 /// the second rebuild is where that assumption stops holding.
5307 ///
5308 /// The webview is passed rather than implied: Zig clears only if this is
5309 /// still the pointer it has, so a rebuild that makes the replacement
5310 /// before dismantling the original cannot blank the live one.
5311 static func detach(_ webView: WKWebView) {
5312 clearWebViewFn?(Unmanaged.passUnretained(webView).toOpaque())
5313 }
5314
5315 /// Offer one page message to the Zig dispatcher.
5316 ///
5317 /// Returns true when Zig has taken responsibility — the caller must not
5318 /// answer as well. False when no Zig module recognised the action, or when
5319 /// there is no runtime to ask.
5320 static func offer(action: String, body: [String: Any], callbackId: String?) -> Bool {
5321 guard let handleActionFn else { return false }
5322
5323 // `_invoke` flattens the payload into the message —
5324 // `Object.assign({}, payload, {action, callbackId})` — so the payload
5325 // Zig's handlers parse is the message minus the two envelope keys.
5326 // This is the exact inverse of what `CraftSwiftShim.handleAction` does
5327 // when a call travels the other way, and the two must stay inverses:
5328 // leaving `action` in would put a key in the payload no handler
5329 // expects, and dropping a real field would hand a handler defaults the
5330 // page never asked for.
5331 var payload = body
5332 payload.removeValue(forKey: "action")
5333 payload.removeValue(forKey: "callbackId")
5334
5335 // A payload that will not serialise is not offered. Falling through to
5336 // the Swift switch is the safe direction: it reads `body` directly and
5337 // never needs the round trip through JSON that just failed.
5338 guard JSONSerialization.isValidJSONObject(payload),
5339 let data = try? JSONSerialization.data(withJSONObject: payload),
5340 let json = String(data: data, encoding: .utf8)
5341 else { return false }
5342
5343 return action.withCString { a in
5344 json.withCString { p in
5345 handleActionFn(a, UInt(strlen(a)), p, UInt(strlen(p)), requestId(from: callbackId))
5346 }
5347 }
5348 }
5349
5350 /// "cb_7" -> 7, and -1 for a message with no callback waiting on it.
5351 ///
5352 /// -1 rather than 0, matching what `craft_ios_deliver_result` already
5353 /// treats as "no id": zero is a perfectly good callback number, and a
5354 /// sentinel that collides with a real id would deliver one caller's answer
5355 /// to another.
5356 private static func requestId(from callbackId: String?) -> Int64 {
5357 guard let callbackId, callbackId.hasPrefix("cb_"),
5358 let n = Int64(callbackId.dropFirst(3))
5359 else { return -1 }
5360 return n
5361 }
5362}
5363
51965364/// The seam the Zig dispatcher hands unmigrated actions through.
51975365///
51985366/// Discovery is symmetric and both directions are runtime-only. Zig finds this
packages/ios/templates/project.yml.templatemodified+1-0
Changes to packages/ios/templates/project.yml.template
@@ -32,5 +32,6 @@ targets:
3232 TARGETED_DEVICE_FAMILY: ""
3333 SWIFT_VERSION: "5.0"
3434 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
35
3536
3637
packages/zig/src/bridge_mobile.zigmodified+131-3
Changes to packages/zig/src/bridge_mobile.zig
@@ -52,7 +52,7 @@ pub const MobileBridge = struct {
5252 }
5353
5454 fn getDeviceInfo(self: *Self) !void {
55 var buf: [512]u8 = undefined;
55 var buf: [4096]u8 = undefined;
5656 const json = try describeDevice(&buf);
5757 bridge_error.sendResultToJS(self.allocator, A.get_device_info, json);
5858 }
@@ -88,14 +88,142 @@ fn describeDevice(buf: []u8) ![]const u8 {
8888 const system_name = try nsStringField(device, "systemName");
8989 const system_version = try nsStringField(device, "systemVersion");
9090 const model = try nsStringField(device, "model");
91 // The one field a user can set, so the one that can carry a quote or a
92 // backslash: "Glenn\"s iPhone" is a legal device name and an illegal JSON
93 // string. Everything here goes through `escapeJsonString` for that reason,
94 // not just this one — a locale identifier is tame today and the escaping
95 // costs nothing.
96 const device_name = try nsStringField(device, "name");
97
98 // `identifierForVendor` is nil before the device is unlocked once after
99 // boot. The spec answers `""` there (`device.identifierForVendor?.uuidString
100 // ?? ""`), so this does too: a missing id and an empty one are the same
101 // thing to a page, and inventing a UUID would be worse than either.
102 const vendor_id: []const u8 = blk: {
103 const sel_ifv = objc.sel_registerName("identifierForVendor") orelse break :blk "";
104 const uuid = objc.msgSendId(device, sel_ifv);
105 if (uuid == null) break :blk "";
106 break :blk nsStringField(uuid, "UUIDString") catch "";
107 };
108
109 // UIScreen, for the three screen fields. A nil mainScreen is not a case
110 // that happens in an app with a window, but reading `bounds` off nil would
111 // answer a zero rect rather than fail, and a page cannot tell a 0x0 screen
112 // from a broken one.
113 const UIScreen = objc.objc_getClass("UIScreen") orelse return error.ClassNotFound;
114 const sel_main = objc.sel_registerName("mainScreen") orelse return error.SelectorNotFound;
115 const screen = objc.msgSendId(UIScreen, sel_main);
116 if (screen == null) return error.NoMainScreen;
117
118 const bounds = try msgSendRect(screen, "bounds");
119 const scale = try msgSendCGFloat(screen, "scale");
120
121 // `batteryLevel` is -1 unless battery monitoring is enabled, and neither
122 // the spec nor this enables it. Reporting the same -1 is the parity that
123 // matters: a page that special-cases it keeps working, and turning
124 // monitoring on here would be a behaviour change the spec never made.
125 const battery_level = try msgSendFloat(device, "batteryLevel");
126 const battery_state = batteryStateName(try msgSendInteger(device, "batteryState"));
127
128 const NSLocale = objc.objc_getClass("NSLocale") orelse return error.ClassNotFound;
129 const sel_curloc = objc.sel_registerName("currentLocale") orelse return error.SelectorNotFound;
130 const locale_obj = objc.msgSendId(NSLocale, sel_curloc);
131 const locale = if (locale_obj != null) nsStringField(locale_obj, "localeIdentifier") catch "" else "";
132
133 const NSTimeZone = objc.objc_getClass("NSTimeZone") orelse return error.ClassNotFound;
134 const sel_localtz = objc.sel_registerName("localTimeZone") orelse return error.SelectorNotFound;
135 const tz_obj = objc.msgSendId(NSTimeZone, sel_localtz);
136 const timezone = if (tz_obj != null) nsStringField(tz_obj, "name") catch "" else "";
137
138 var esc: [5][512]u8 = undefined;
91139
92140 return std.fmt.bufPrint(
93141 buf,
94 "",
95 .{ system_name, system_version, model, isSimulator() },
142 "",
158 .{
159 try bridge_error.escapeJsonString(&esc[0], model),
160 try bridge_error.escapeJsonString(&esc[1], device_name),
161 system_name,
162 system_version,
163 vendor_id,
164 isSimulator(),
165 bounds.size.width,
166 bounds.size.height,
167 scale,
168 battery_level,
169 battery_state,
170 try bridge_error.escapeJsonString(&esc[2], locale),
171 try bridge_error.escapeJsonString(&esc[3], timezone),
172 },
96173 );
97174}
98175
176/// `UIDevice.BatteryState` as the spec spells it (`getBatteryState`,
177/// `CraftApp.swift:3204`). The raw values are UIKit's: unknown 0, unplugged 1,
178/// charging 2, full 3. Anything else is "unknown", matching the spec's
179/// `default` rather than inventing a fifth name for a value UIKit does not
180/// currently produce.
181fn batteryStateName(raw: isize) []const u8 {
182 return switch (raw) {
183 1 => "unplugged",
184 2 => "charging",
185 3 => "full",
186 else => "unknown",
187 };
188}
189
190/// A zero-argument selector returning `CGRect`.
191///
192/// A plain `objc_msgSend` cast, deliberately — **not**
193/// `objc_runtime.msgSendStret`, which picks `objc_msgSend_stret` for anything
194/// over 16 bytes. `CGRect` is 32, and `objc_msgSend_stret` does not exist on
195/// arm64: the struct return travels in x8 through ordinary `objc_msgSend`.
196/// `bridge_mobile_motion.zig:124` records the same trap, and `macos.zig`'s
197/// `msgSendRect` is the shape this copies.
198fn msgSendRect(target: objc.id, comptime selector: [:0]const u8) !objc.CGRect {
199 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
200 const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGRect = @ptrCast(&objc.objc_msgSend);
201 return func(target, sel);
202}
203
204/// A zero-argument selector returning `CGFloat` (`double` on 64-bit).
205fn msgSendCGFloat(target: objc.id, comptime selector: [:0]const u8) !objc.CGFloat {
206 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
207 const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGFloat = @ptrCast(&objc.objc_msgSend);
208 return func(target, sel);
209}
210
211/// A zero-argument selector returning `float`. `batteryLevel` is a `float`,
212/// not a `CGFloat` — reading it as a double returns garbage, because the two
213/// come back in different halves of the same register.
214fn msgSendFloat(target: objc.id, comptime selector: [:0]const u8) !f32 {
215 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
216 const func: *const fn (objc.id, objc.SEL) callconv(.c) f32 = @ptrCast(&objc.objc_msgSend);
217 return func(target, sel);
218}
219
220/// A zero-argument selector returning `NSInteger`.
221fn msgSendInteger(target: objc.id, comptime selector: [:0]const u8) !isize {
222 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
223 const func: *const fn (objc.id, objc.SEL) callconv(.c) isize = @ptrCast(&objc.objc_msgSend);
224 return func(target, sel);
225}
226
99227/// Send a zero-argument selector that returns an NSString, and borrow its UTF-8.
100228///
101229/// The returned slice points into the NSString's own buffer, which is
packages/zig/src/bridge_mobile_system.zigmodified+8-2
Changes to packages/zig/src/bridge_mobile_system.zig
@@ -670,9 +670,12 @@ pub const uikit = if (is_darwin) struct {
670670 /// "which is already presenting" and silently does nothing, so the
671671 /// completion handler never fires and the caller waits out its timeout.
672672 pub fn topmostViewController() !objc.id {
673 // One `orelse`, not an `orelse` plus a null check. `getWebView` used to
674 // answer a double optional, so the `orelse` unwrapped the outer level
675 // and the second line was the guard that actually ran; now the slot is
676 // a single `objc.id` and this catches both spellings of "no webview".
673677 const webview = ios_dispatch.getWebView() orelse
674678 return bridge_error.BridgeError.WebViewHandleNotSet;
675 if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet;
676679
677680 const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound;
678681 const window = objc.msgSendId(webview, sel_window);
@@ -703,9 +706,12 @@ pub const uikit = if (is_darwin) struct {
703706 /// pre-iOS-14 `+requestReview`, which is deprecated and may be gone, and it
704707 /// is certainly not a reason to reply success having done nothing.
705708 fn keyWindowScene() !objc.id {
709 // One `orelse`, not an `orelse` plus a null check. `getWebView` used to
710 // answer a double optional, so the `orelse` unwrapped the outer level
711 // and the second line was the guard that actually ran; now the slot is
712 // a single `objc.id` and this catches both spellings of "no webview".
706713 const webview = ios_dispatch.getWebView() orelse
707714 return bridge_error.BridgeError.WebViewHandleNotSet;
708 if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet;
709715
710716 const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound;
711717 const window = objc.msgSendId(webview, sel_window);
packages/zig/src/ios_dispatch.zigmodified+399-53
Changes to packages/zig/src/ios_dispatch.zig
@@ -3,6 +3,7 @@ const builtin = ;
33const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
6const capabilities = ;
67pub const ios_async = ;
78pub const ios_events = ;
89pub const ios_delegate = ;
@@ -65,13 +66,24 @@ const objc = objc_runtime.objc;
6566/// One slot, because iOS has one webview. When multi-window arrives this
6667/// becomes a lookup keyed by the same request id the reply already carries —
6768/// the id is threaded through `request_context` for exactly that reason.
68var global_webview: ?objc.id = null;
69/// `objc.id`, not `?objc.id`. The extra optional was a real defect, not
70/// redundancy: `objc.id` is already `?*anyopaque`, so a double optional made
71/// `setWebView(null)` store a *present* outer value wrapping a nil webview,
72/// and `global_webview orelse return error.NoWebView` below then unwrapped
73/// only the outer one and sailed past. Every reply after such a call went to
74/// `objc_msgSend(nil, …)`, which is a silent no-op — the action ran, it
75/// worked, and the page heard nothing, with no error anywhere to say so.
76///
77/// One optional means "no webview" has one spelling and `orelse` catches it.
78/// (`macos.zig:4075` still holds the two-level version. It is unreachable
79/// there today because nothing passes nil, but it is the same shape.)
80var global_webview: objc.id = null;
6981
7082pub fn setWebView(webview: objc.id) void {
7183 global_webview = webview;
7284}
7385
74pub fn getWebView() ?objc.id {
86pub fn getWebView() objc.id {
7587 return global_webview;
7688}
7789
@@ -184,62 +196,86 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 {
184196/// the page's timeout fires, and thirty seconds of nothing is the worst
185197/// diagnostic a bridge can give.
186198fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void {
187 if (std.mem.eql(u8, msg_type, "mobile")) {
188 // The app's own configuration, before anything else. An action the
189 // spec gates on a `config.enable*` flag is refused here when that flag
190 // is off, rather than served — which is what every one of these did
191 // until `ios_config.zig` existed, because Zig had no way to read the
192 // flag.
193 //
194 // Only actions this file's modules serve are gated. `gateFor` returns
195 // null for the rest, so an action still on the shim keeps whatever
196 // answer the shim gives it: Zig refusing on Swift's behalf would mean
197 // deciding, from Zig's parse of the config, a question the arm that
198 // owns the action is about to decide from its own.
199 if (ios_config.gateFor(action)) |feature| {
200 if (!ios_config.isEnabled(feature)) {
201 std.log.info(
202 "ios: refusing {s}; {s} is not enabled in craft.config.json",
203 .{ action, feature.jsonKey() },
204 );
205 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.CapabilityDisabled);
206 return bridge_error.BridgeError.CapabilityDisabled;
207 }
208 }
199 if (!std.mem.eql(u8, msg_type, "mobile")) {
200 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
201 return error.UnknownNamespace;
202 }
209203
210 // First module that recognises the action wins. UnknownAction means
211 // "not mine, ask the next"; any other error means a handler ran and
212 // failed, and its answer is final — retrying the same action against
213 // another module (or the shim) would replace a specific failure with
214 // whatever the next thing happens to say.
215 //
216 // `test/ios_conformance_test.zig` fails the build if one action is
217 // declared by two modules, so first-match is deterministic rather than
218 // dependent on the order of this tuple. That check was claimed here
219 // before it existed; it exists now.
220 inline for (mobile_bridges) |Bridge| {
221 var bridge = Bridge.init(allocator);
222 defer bridge.deinit();
223
224 if (bridge.handleMessage(action, data)) |_| {
225 return;
226 } else |err| switch (err) {
227 bridge_error.BridgeError.UnknownAction => {},
228 else => {
229 bridge_error.sendErrorToJS(allocator, action, asBridgeError(err));
230 return err;
231 },
232 }
233 }
204 switch (try offerToModules(allocator, action, data)) {
205 .answered => return,
206 .not_ours => {},
207 }
234208
235 if (try handOffToHost(action, data)) return;
209 if (try handOffToHost(action, data)) return;
236210
237 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
238 return error.UnknownAction;
211 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
212 return error.UnknownAction;
213}
214
215/// Whether the Zig modules took responsibility for an action.
216///
217/// The distinction the hand-off is built on, and the one a Swift-hosted app
218/// needs too: `answered` means the page will hear back from Zig and nobody
219/// else may reply, `not_ours` means nothing here recognised the action and
220/// whoever asked must serve it themselves.
221///
222/// A refusal is `answered`. Every path that calls `sendErrorToJS` has settled
223/// the page's promise, so treating "refused" as "not mine" would let a second
224/// arm answer the same call — the double-reply this envelope exists to
225/// prevent.
226const Offer = enum { answered, not_ours };
227
228/// The capability gate, then the module chain. Shared by the two entry points
229/// so they cannot drift: `route` for a message Zig received itself, and
230/// `craft_ios_handle_action` for one a Swift host received and passed over.
231fn offerToModules(allocator: std.mem.Allocator, action: []const u8, data: []const u8) !Offer {
232 // The app's own configuration, before anything else. An action the spec
233 // gates on a `config.enable*` flag is refused here when that flag is off,
234 // rather than served — which is what every one of these did until
235 // `ios_config.zig` existed, because Zig had no way to read the flag.
236 //
237 // Only actions this file's modules serve are gated. `gateFor` returns null
238 // for the rest, so an action still on the shim keeps whatever answer the
239 // shim gives it: Zig refusing on Swift's behalf would mean deciding, from
240 // Zig's parse of the config, a question the arm that owns the action is
241 // about to decide from its own.
242 if (ios_config.gateFor(action)) |feature| {
243 if (!ios_config.isEnabled(feature)) {
244 std.log.info(
245 "ios: refusing {s}; {s} is not enabled in craft.config.json",
246 .{ action, feature.jsonKey() },
247 );
248 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.CapabilityDisabled);
249 return bridge_error.BridgeError.CapabilityDisabled;
250 }
239251 }
240252
241 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
242 return error.UnknownNamespace;
253 // First module that recognises the action wins. UnknownAction means
254 // "not mine, ask the next"; any other error means a handler ran and
255 // failed, and its answer is final — retrying the same action against
256 // another module (or the shim) would replace a specific failure with
257 // whatever the next thing happens to say.
258 //
259 // `test/ios_conformance_test.zig` fails the build if one action is
260 // declared by two modules, so first-match is deterministic rather than
261 // dependent on the order of this tuple. That check was claimed here
262 // before it existed; it exists now.
263 inline for (mobile_bridges) |Bridge| {
264 var bridge = Bridge.init(allocator);
265 defer bridge.deinit();
266
267 if (bridge.handleMessage(action, data)) |_| {
268 return .answered;
269 } else |err| switch (err) {
270 bridge_error.BridgeError.UnknownAction => {},
271 else => {
272 bridge_error.sendErrorToJS(allocator, action, asBridgeError(err));
273 return err;
274 },
275 }
276 }
277
278 return .not_ours;
243279}
244280
245281/// Every Zig module serving the `mobile` namespace, in dispatch order.
@@ -284,6 +320,83 @@ const mobile_bridges = .{
284320 bridge_mobile_speech.SpeechBridge,
285321};
286322
323/// The same modules again, as their capability manifests.
324///
325/// A second spelling of `mobile_bridges` is not something to want, but the two
326/// cannot be one: `mobile_bridges` holds bridge *types* and `capability_actions`
327/// is declared at module scope, so there is no way to reach a module's manifest
328/// from its struct. The comptime length check below is what keeps them in step
329/// — a module added to one list and not the other fails the build.
330const mobile_manifests = [_][]const capabilities.ActionDecl{
331 &bridge_mobile.capability_actions,
332 &bridge_mobile_clipboard.capability_actions,
333 &bridge_mobile_haptics.capability_actions,
334 &bridge_mobile_device.capability_actions,
335 &bridge_mobile_system.capability_actions,
336 &bridge_mobile_display.capability_actions,
337 &bridge_mobile_storage.capability_actions,
338 &bridge_mobile_misc.capability_actions,
339 &bridge_mobile_shortcuts.capability_actions,
340 &bridge_mobile_securestore.capability_actions,
341 &bridge_mobile_biometric.capability_actions,
342 &bridge_mobile_permissions.capability_actions,
343 &bridge_mobile_db.capability_actions,
344 &bridge_mobile_notifcancel.capability_actions,
345 &bridge_mobile_notifications.capability_actions,
346 &bridge_mobile_bgtasks.capability_actions,
347 &bridge_mobile_watch.capability_actions,
348 &bridge_mobile_location.capability_actions,
349 &bridge_mobile_locrecording.capability_actions,
350 &bridge_mobile_motion.capability_actions,
351 &bridge_mobile_imagepicker.capability_actions,
352 &bridge_mobile_filepicker.capability_actions,
353 &bridge_mobile_contactpicker.capability_actions,
354 &bridge_mobile_calendar.capability_actions,
355 &bridge_mobile_contacts.capability_actions,
356 &bridge_mobile_vision.capability_actions,
357 &bridge_mobile_auth.capability_actions,
358 &bridge_mobile_siri.capability_actions,
359 &bridge_mobile_pdf.capability_actions,
360 &bridge_mobile_bluetooth.capability_actions,
361 &bridge_mobile_audiorec.capability_actions,
362 &bridge_mobile_health.capability_actions,
363 &bridge_mobile_speech.capability_actions,
364};
365
366comptime {
367 if (mobile_manifests.len != mobile_bridges.len) {
368 @compileError("mobile_manifests and mobile_bridges must list the same modules");
369 }
370}
371
372/// Whether a host that offered this action has to serve it itself.
373///
374/// `.status = .unavailable` in a module's manifest means the handler is
375/// reachable and refuses, deliberately, because Zig cannot do the thing
376/// correctly — `haptic` cannot see `config.enableHaptics`, and the rest say
377/// why in their own `reason`.
378///
379/// In the Zig-hosted app that refusal is the honest end of the line: `route`
380/// tries `handOffToHost` next, and the shim is what actually serves it. The
381/// host-offered path has no such fallback — `craft_ios_handle_action`
382/// deliberately does not consult `handOffToHost`, because there the host *is*
383/// the caller. So claiming one of these would not be a refusal instead of an
384/// answer, it would be a refusal instead of the *host's working answer*: an app
385/// that has haptics today would stop buzzing the moment the runtime was linked,
386/// and the page would get a rejection where it used to get silence-and-a-buzz.
387///
388/// `test/ios_conformance_test.zig` already treats falling through as better
389/// than `.unavailable`. This is that rule, applied to the one path that had no
390/// way to fall through.
391fn hostServesItself(action: []const u8) bool {
392 for (mobile_manifests) |manifest| {
393 for (manifest) |decl| {
394 if (decl.status == .unavailable and std.mem.eql(u8, decl.name, action)) return true;
395 }
396 }
397 return false;
398}
399
287400/// Narrow an arbitrary handler error to one the page's error codes can express.
288401///
289402/// A handler's error set is wider than `BridgeError` — it picks up allocation
@@ -340,6 +453,110 @@ fn handOffToHost(action: []const u8, data: []const u8) !bool {
340453 return func(shim, sel, ns_action, ns_payload, request_id);
341454}
342455
456/// Hand the Zig runtime the webview its replies are evaluated against.
457///
458/// The one thing a Swift-hosted app must do before any message is offered
459/// below. In the Zig-hosted app `ios.zig` sets this itself while building the
460/// webview; when SwiftUI owns the `WKWebView`, Zig never sees it and every
461/// reply would reach `error.NoWebView` — an action that ran, worked, and told
462/// nobody.
463///
464/// Not retained. The caller owns the webview and outlives the runtime: it is
465/// the app's own root view. Retaining it here would create a cycle with
466/// nothing to break it, since this pointer is never cleared except by a second
467/// call.
468export fn craft_ios_set_webview(webview: ?*anyopaque) callconv(.c) void {
469 setWebView(webview);
470}
471
472/// Forget the webview, if it is still the one the caller installed.
473///
474/// The other half of `craft_ios_set_webview`, and the reason it is needed:
475/// Zig holds the webview unretained, so once SwiftUI tears the view down the
476/// pointer is dangling and every later reply is an `objc_msgSend` into freed
477/// memory. `UIViewRepresentable` has a `dismantleUIView` hook for exactly this
478/// moment, and this is what it calls.
479///
480/// Compare-and-clear, not an unconditional reset. SwiftUI is free to build the
481/// replacement view *before* dismantling the old one — during a state-driven
482/// rebuild it usually does — and a blind clear on dismantle would blank the
483/// pointer the new view had already installed, leaving a live app whose
484/// replies all reach `error.NoWebView`. Clearing only when the pointer still
485/// matches makes the two orders equivalent.
486export fn craft_ios_clear_webview(webview: ?*anyopaque) callconv(.c) void {
487 if (global_webview == webview) global_webview = null;
488}
489
490/// Offer one already-parsed page message to the Zig dispatcher.
491///
492/// The entry point for a **Swift-hosted** app, where `WKScriptMessageHandler`
493/// belongs to SwiftUI and Zig is a library rather than the thing that owns
494/// `UIApplicationMain`. The caller has already parsed the page's message, so
495/// this takes the three fields routing needs directly instead of re-parsing an
496/// envelope Zig never saw.
497///
498/// Returns **true when Zig has taken responsibility** — the page will hear
499/// back from Zig and the caller must not answer. Returns **false only when no
500/// Zig module recognised the action**, which is the caller's signal to serve
501/// it themselves.
502///
503/// A refusal returns true. `offerToModules` calls `sendErrorToJS` before it
504/// reports a failure, so the promise is already settled; answering again from
505/// the Swift side would resolve a callback that has been deleted at best, and
506/// deliver two contradictory answers to one call at worst.
507///
508/// `handOffToHost` is deliberately **not** consulted here. In this direction
509/// the host is the caller, so handing back to `CraftSwiftShim` would take a
510/// round trip through the Objective-C runtime to reach the same `dispatch`
511/// method the caller is about to call anyway — and would do it under a
512/// synthetic `zig:` callback id, routing the reply back through Zig for no
513/// reason. `false` says the same thing in one word.
514///
515/// `request_id` is negative for a page message with no callback waiting on it,
516/// matching the null the `{t,a,d,i}` envelope carries in that case.
517export fn craft_ios_handle_action(
518 action_ptr: [*]const u8,
519 action_len: usize,
520 payload_ptr: [*]const u8,
521 payload_len: usize,
522 request_id: i64,
523) callconv(.c) bool {
524 const action = action_ptr[0..action_len];
525 const payload = payload_ptr[0..payload_len];
526
527 // Pushed for the whole call, exactly as `handleMessage` does: a reply
528 // formatted after this frame is gone reads `request_context.current()` and
529 // would stamp null, falling back to action-name correlation and handing
530 // one caller's answer to another.
531 request_context.push(if (request_id < 0) null else @intCast(request_id));
532 defer request_context.pop();
533
534 // The same line `handleMessage` logs, and for the same reason: on a device
535 // this is the only window into the bridge. `t=mobile` is spelled out
536 // rather than taken from an envelope, because a host-offered message has
537 // no envelope — this path is only ever the mobile namespace.
538 std.log.info("craft-bridge dispatch t=mobile a={s} i={?d}", .{
539 action,
540 request_context.current(),
541 });
542
543 // Before the gate and the module chain, because this is not a question
544 // about what the config allows or what a handler would do — it is a
545 // question about which arm owns the action at all, and the answer is the
546 // caller's own.
547 if (hostServesItself(action)) {
548 std.log.info("ios: {s} is declared unavailable; leaving it to the host", .{action});
549 return false;
550 }
551
552 const offer = offerToModules(std.heap.c_allocator, action, payload) catch {
553 // A module ran and failed, and answered the page on its way out. That
554 // is a served action with an unhappy result, not an unserved one.
555 return true;
556 };
557 return offer == .answered;
558}
559
343560/// Deliver a result the host shim produced.
344561///
345562/// Exported for the shim to call. Everything after this point is the same path
@@ -582,3 +799,132 @@ test "a shim error with no request id omits the id field entirely" {
582799 try buildShimError(testing.allocator, &out, "share", "m", "E", -1);
583800 try testing.expect(std.mem.indexOf(u8, out.items, "\"id\"") == null);
584801}
802
803test "a host offer that no module recognises comes back as the host's problem" {
804 // The whole contract of `craft_ios_handle_action` in one assertion. A
805 // Swift-hosted app calls this first and serves the action itself when the
806 // answer is false; if an unrecognised action ever returned true, that
807 // action would silently stop working in every generated app — the page's
808 // promise would hang with nobody having agreed to answer it.
809 try testing.expect(!craft_ios_handle_action("noSuchAction", "noSuchAction".len, "{}", 2, 7));
810}
811
812test "a host offer for a served action is claimed, not handed back" {
813 // `getDeviceInfo` is served by `bridge_mobile_device.zig` on every
814 // platform this test runs on. The reply cannot arrive — there is no
815 // webview in a test binary, so `sendResultToJS` reaches
816 // `error.NoWebView` — but claiming is the half under test: true means Zig
817 // has taken responsibility, and a caller that also answered would be the
818 // double-reply this return value exists to prevent.
819 //
820 // Off Darwin the action still routes; it just fails inside the handler,
821 // which is `answered` too. Either way the answer is true, and that is the
822 // point: "claimed" is about ownership, never about success.
823 try testing.expect(craft_ios_handle_action("getDeviceInfo", "getDeviceInfo".len, "{}", 2, 1));
824}
825
826test "a host offer for a disabled capability is claimed and refused, not handed back" {
827 // The trap this return value is shaped around. `clipboardRead` is served
828 // by Zig and gated on `enableClipboard`; a test binary has no bundled
829 // craft.config.json, so `isEnabled` is false and the gate refuses.
830 //
831 // The refusal already called `sendErrorToJS`, so the page's promise is
832 // settled. Returning false here would invite the Swift arm to answer the
833 // same call a second time — and Swift's own arm for a disabled capability
834 // has no `else`, so the page would get one rejection and one silence.
835 try testing.expect(craft_ios_handle_action("clipboardRead", "clipboardRead".len, "{}", 2, 3));
836}
837
838test "a negative request id means no callback is waiting" {
839 // Mirrors `craft_ios_deliver_result`'s reading of the same sentinel. The
840 // id is pushed for the duration of the call and popped after, so a stack
841 // that leaked a frame would be visible here as a non-empty context once
842 // the call has returned.
843 try testing.expect(request_context.current() == null);
844 _ = craft_ios_handle_action("noSuchAction", "noSuchAction".len, "{}", 2, -1);
845 try testing.expect(request_context.current() == null);
846}
847
848test "setting the webview from a host is what makes replies reachable" {
849 // The one call a Swift-hosted app must make before offering anything.
850 // Without it every reply lands on `error.NoWebView`: the action runs, it
851 // works, and the page never hears. Restored afterwards so the ordering of
852 // tests in this file cannot matter.
853 const saved = getWebView();
854 defer setWebView(saved);
855
856 setWebView(null);
857 try testing.expectError(error.NoWebView, evalJS("1"));
858
859 var fake: u8 = 0;
860 craft_ios_set_webview(&fake);
861 try testing.expect(getWebView() == @as(objc.id, @ptrCast(&fake)));
862
863 // And clearing it is reachable again, which is the half that was broken:
864 // a host that hands over nil must leave the runtime saying "no webview"
865 // rather than quietly addressing every reply to it.
866 craft_ios_set_webview(null);
867 try testing.expectError(error.NoWebView, evalJS("1"));
868}
869
870test "an action declared unavailable is left to the host, not claimed" {
871 // The regression this guards. Each of these has a working Swift arm and a
872 // Zig handler that refuses on purpose; before the host-offered path knew
873 // the difference, linking the runtime replaced the working answer with a
874 // rejection. `false` here is what sends the call back to Swift's switch.
875 const declared_unavailable = [_][]const u8{
876 "haptic",
877 "getNetworkStatus",
878 "lockOrientation",
879 "unlockOrientation",
880 };
881 for (declared_unavailable) |action| {
882 try testing.expect(hostServesItself(action));
883 try testing.expect(!craft_ios_handle_action(action.ptr, action.len, "{}", 2, 11));
884 }
885}
886
887test "the fall-through is read from the manifests, not from a list kept beside them" {
888 // `hostServesItself` must answer for exactly the actions the modules
889 // declare `.unavailable` — no more, or a working Zig action is handed away;
890 // no fewer, or the regression comes back for whichever one was missed. So
891 // count the declarations rather than trusting the test above's literals.
892 var declared: usize = 0;
893 for (mobile_manifests) |manifest| {
894 for (manifest) |decl| {
895 if (decl.status == .unavailable) {
896 declared += 1;
897 try testing.expect(hostServesItself(decl.name));
898 }
899 }
900 }
901 try testing.expectEqual(@as(usize, 4), declared);
902}
903
904test "a live action is still claimed, unavailable is not a blanket hand-back" {
905 // The other half: `vibrate` sits in the same module as `haptic` and is
906 // `.live`, so the seam must keep claiming it. A fall-through that widened
907 // to whole modules would silently un-migrate everything beside a refusal.
908 try testing.expect(!hostServesItself("vibrate"));
909 try testing.expect(!hostServesItself("setKeepAwake"));
910 try testing.expect(!hostServesItself("getDeviceInfo"));
911 try testing.expect(!hostServesItself("noSuchAction"));
912}
913
914test "clearing the webview only forgets the one the caller named" {
915 // The rebuild order that makes a blind clear wrong: SwiftUI builds the
916 // replacement, then dismantles the original. If dismantling cleared
917 // unconditionally, the live view installed a moment earlier would be
918 // dropped and every reply after it would reach `error.NoWebView`.
919 const first: *anyopaque = @ptrFromInt(0x1000);
920 const second: *anyopaque = @ptrFromInt(0x2000);
921 defer setWebView(null);
922
923 craft_ios_set_webview(first);
924 craft_ios_set_webview(second); // the replacement arrives first
925 craft_ios_clear_webview(first); // then the original is dismantled
926 try testing.expect(getWebView() == second);
927
928 craft_ios_clear_webview(second);
929 try testing.expect(getWebView() == null);
930}
packages/zig/test/ios_conformance_test.zigmodified+48-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -649,3 +649,51 @@ test "Zig requires exactly the keys the spec's decoder requires" {
649649 try testing.expect(required >= 40);
650650 try testing.expect(optional_seen >= 1);
651651}
652
653test "getDeviceInfo answers every field the spec answers" {
654 // The regression that made this test necessary. Zig's `getDeviceInfo` is
655 // `.live`, so the Swift-hosted seam prefers it over the spec's arm — and
656 // it answered four of the spec's fourteen fields. A page reading
657 // `screenWidth` or `locale` got `undefined` from an action that reported
658 // success, which is the exact failure `.unavailable` exists to prevent and
659 // could not catch, because the action does work; it just works less.
660 //
661 // Keys are read out of the spec rather than listed here, so a field added
662 // to `CraftApp.swift` fails this test until Zig answers it too.
663 // Anchored to the function, not to `let info:` — the spec has more than
664 // one dictionary spelled that way (`getMemoryUsage` has another), and the
665 // first match is not this one.
666 const fn_start = std.mem.indexOf(u8, swift_spec, "private func getDeviceInfo(callbackId:") orelse
667 return error.SpecShapeChanged;
668 const body = swift_spec[fn_start..];
669 const start = std.mem.indexOf(u8, body, "let info: [String: Any] = [") orelse
670 return error.SpecShapeChanged;
671 const after = body[start..];
672 const end = std.mem.indexOf(u8, after, "\n ]") orelse return error.SpecShapeChanged;
673 const block = after[0..end];
674
675 const zig_src = @embedFile("src/bridge_mobile.zig");
676
677 var it = std.mem.splitScalar(u8, block, '\n');
678 var checked: usize = 0;
679 while (it.next()) |line| {
680 const q1 = std.mem.indexOfScalar(u8, line, '"') orelse continue;
681 const rest = line[q1 + 1 ..];
682 const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse continue;
683 const key = rest[0..q2];
684 if (key.len == 0) continue;
685
686 // Zig builds the reply as a format string, so the key appears in the
687 // source as the escaped `\"key\":` it will emit.
688 var needle_buf: [64]u8 = undefined;
689 const needle = try std.fmt.bufPrint(&needle_buf, "\\\"{s}\\\":", .{key});
690 if (std.mem.indexOf(u8, zig_src, needle) == null) {
691 std.debug.print("getDeviceInfo: the spec answers `{s}` and Zig does not\n", .{key});
692 return error.FieldMissingFromZig;
693 }
694 checked += 1;
695 }
696
697 // A shape change that silently matched nothing would otherwise pass.
698 try testing.expectEqual(@as(usize, 14), checked);
699}