ReviewOS

also looking at this

craft-native/craft

iOS: the capability gate, and the two actions it unblocks — ratchet 48 → 46

#92
Merged glennmichael123 wants to merge feat/ios-capability-gate into main
9 files +1526 -62
packages/ios/fixtures/zig-slice/build-and-run.shmodified+39-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -68,6 +68,13 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6868
6969cp "$FIXTURE/Info.plist" "$APP/Info.plist"
7070
71# The capability configuration, bundled exactly where a generated app puts it:
72# `project.yml.template` copies craft.config.json into the app as a resource,
73# and both runtimes read it from `[NSBundle mainBundle]`. Without it every
74# capability is off — which is Swift's own fallback, and would take the
75# Keychain, SQLite and notification assertions below down with it.
76cp "$FIXTURE/craft.config.json" "$APP/craft.config.json"
77
7178# Ad-hoc signature. The identity entitlements live in the __entitlements
7279# section above; signing them in instead makes the launch be denied.
7380codesign --force --sign - "$APP"
@@ -90,7 +97,8 @@ LAUNCH_PID=$!
9097# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
9198# fixed amount, so a slow boot does not read as a failure.
9299for _ in $(seq 1 60); do
93 if grep -q 'i=18' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi
100 if grep -q 'i=22' "$LOG" 2>/dev/null && grep -q 'i=24' "$LOG" 2>/dev/null \
101 && grep -q 'i=20' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi
94102 sleep 1
95103done
96104kill "$LAUNCH_PID" 2>/dev/null || true
@@ -181,4 +189,34 @@ C18="$(count 18)"
181189[ "$C18" -ge 1 ] || { echo "FAIL: the refusal did not name its own cause"; exit 1; }
182190echo "ok: unavailable hardware refused with a specific code (i=18 seen ${C18}x)"
183191
192C80="$(count 80)"
193[ "$C80" -ge 1 ] || { echo "FAIL: the disabled-capability call never reached the dispatcher"; exit 1; }
194echo "ok: gated action reached the dispatcher (i=80 seen ${C80}x)"
195
196C20="$(count 20)"
197# The whole point of the capability gate. clipboardRead is served by Zig and
198# gated on enableClipboard, which this fixture's config sets to false. The
199# Swift spec drops the callback on 60 of its 65 gated cases, so the page would
200# wait out its timeout with nothing to show for it; here an answer comes back,
201# and its code names the configuration rather than a permission a user could
202# grant by answering a prompt.
203[ "$C20" -ge 1 ] || { echo "FAIL: a disabled capability hung instead of replying CAPABILITY_DISABLED"; exit 1; }
204echo "ok: disabled capability refused with CAPABILITY_DISABLED (i=20 seen ${C20}x)"
205
206# And the gate is not simply refusing everything: the Keychain, SQLite and
207# notification assertions above all ran through actions the same config
208# *enables*, so i=10, i=12 and i=14 passing is the other half of this check.
209echo "ok: enabled capabilities still served (i=10, i=12, i=14 above)"
210
211C22="$(count 22)"
212# i=22 requires the reply to have been a bare JSON string, carried the data URL
213# prefix, base64-decoded to the PNG signature, and exceeded a size floor. Only
214# a real render of the window layer satisfies all four.
215[ "$C22" -ge 1 ] || { echo "FAIL: takeScreenshot did not return a decodable PNG data URL"; exit 1; }
216echo "ok: screenshot rendered the live window to a real PNG (i=22 seen ${C22}x)"
217
218C24="$(count 24)"
219[ "$C24" -ge 1 ] || { echo "FAIL: the deep-link handshake did not reply Swift's bare true"; exit 1; }
220echo "ok: deep-link handshake replied true behind its own gate (i=24 seen ${C24}x)"
221
184222echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonadded+42-0
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -0,0 +1,42 @@
1{
2 "appName": "CraftSlice",
3 "bundleId": "app.craft.slice",
4 "darkMode": false,
5 "backgroundColor": "#0b1712",
6 "enableSpeechRecognition": false,
7 "enableHaptics": false,
8 "enableShare": false,
9 "enableCamera": true,
10 "enableBiometric": false,
11 "enablePushNotifications": false,
12 "enableSecureStorage": true,
13 "enableGeolocation": false,
14 "enableClipboard": false,
15 "enableContacts": false,
16 "enableCalendar": false,
17 "enableLocalNotifications": true,
18 "enableInAppPurchase": false,
19 "enableKeepAwake": false,
20 "enableOrientationLock": false,
21 "enableDeepLinks": true,
22 "enableQRScanner": false,
23 "enableFilePicker": false,
24 "enableFileDownload": false,
25 "enableSocialAuth": false,
26 "enableAudioRecording": false,
27 "enableVideoRecording": false,
28 "enableMotionSensors": true,
29 "enableLocalDatabase": true,
30 "enableBluetooth": false,
31 "enableNFC": false,
32 "enableHealthKit": false,
33 "enableLiveActivities": false,
34 "enableWatchApp": false,
35 "enableBackgroundLocation": false,
36 "enableBackgroundTasks": false,
37 "enableScreenCapture": true,
38 "enablePDFViewer": false,
39 "enableAR": false,
40 "enableMLKit": false,
41 "trustedOrigins": []
42}
packages/ios/fixtures/zig-slice/page.mmodified+36-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -45,7 +45,8 @@ const char craft_slice_page[] =
4545 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n"
4646 "}\n"
4747 "\n"
48 "var sentErrorAck = false, sentCode = false;\n"
48 "var sentErrorAck = false, sentCode = false, sentDisabled = false;\n"
49 "var sentShot = false, sentDeepLink = false;\n"
4950 "window.__craftBridgeError = function (ctx) {\n"
5051 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5152 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -54,6 +55,16 @@ const char craft_slice_page[] =
5455 " sentCode = true;\n"
5556 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:18});\n"
5657 " }\n"
58 " // clipboardRead is served by Zig and gated on enableClipboard, which\n"
59 " // this fixture's craft.config.json sets to false. The Swift spec would\n"
60 " // drop the callback here and leave the promise pending forever; the\n"
61 " // assertion is that an answer arrives at all, and that it names the\n"
62 " // configuration rather than a permission the user could grant.\n"
63 " if (!sentDisabled && ctx && ctx.error === true\n"
64 " && ctx.code === 'CAPABILITY_DISABLED' && ctx.id === 80) {\n"
65 " sentDisabled = true;\n"
66 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:20});\n"
67 " }\n"
5768 " var messageIntact = ctx && ctx.message === 'declined \\\\ \"on purpose\"\\nsecond line';\n"
5869 " if (!sentErrorAck && ctx && ctx.error === true && ctx.code === 'HOST_DECLINED'\n"
5970 " && ctx.id === 6 && messageIntact) {\n"
@@ -78,6 +89,10 @@ const char craft_slice_page[] =
7889 " // protocol it uses for its own actions.\n"
7990 " // A Tier-0 action served by Zig itself: the reply carries the UIKit\n"
8091 " // application state, which only a live foregrounded process reports.\n"
92 " if (!sentDeepLink && action === 'registerDeepLinkHandler' && payload === true) {\n"
93 " sentDeepLink = true;\n"
94 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:24});\n"
95 " }\n"
8196 " if (action === 'secureSet' && payload === true) {\n"
8297 " bridge.postMessage({t:'mobile', a:'secureGet', d: JSON.stringify({key:'slice-key'}), i:31});\n"
8398 " }\n"
@@ -101,6 +116,20 @@ const char craft_slice_page[] =
101116 " sentNotif = true;\n"
102117 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:14});\n"
103118 " }\n"
119 " // takeScreenshot answers a bare JSON string. Checking the prefix alone\n"
120 " // would pass for a hardcoded constant, so the base64 is decoded and the\n"
121 " // PNG signature checked byte by byte: 0x89 'P' 'N' 'G'. Those bytes come\n"
122 " // from UIImagePNGRepresentation over a rendered layer, and a stub has no\n"
123 " // way to produce them. The length floor rules out a 1x1 placeholder.\n"
124 " if (!sentShot && typeof payload === 'string'\n"
125 " && payload.indexOf('data:image/png;base64,') === 0) {\n"
126 " var raw = atob(payload.slice('data:image/png;base64,'.length));\n"
127 " if (raw.charCodeAt(0) === 0x89 && raw.slice(1, 4) === 'PNG'\n"
128 " && raw.length > 1000) {\n"
129 " sentShot = true;\n"
130 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:22});\n"
131 " }\n"
132 " }\n"
104133 " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n"
105134 " sentTierZero = true;\n"
106135 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n"
@@ -130,6 +159,12 @@ const char craft_slice_page[] =
130159 " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n" " // No camera on a simulator. The refusal must name its own cause —\n"
131160 " // a specific code, not the generic native-failure catch-all.\n"
132161 " bridge.postMessage({t:'mobile', a:'openCamera', i:70});\n"
162 " // A capability the config disables. Zig serves clipboardRead, so this\n"
163 " // reaches the gate rather than the hand-off.\n"
164 " bridge.postMessage({t:'mobile', a:'clipboardRead', i:80});\n"
165 " // Two capabilities this phase migrates, both enabled in the config.\n"
166 " bridge.postMessage({t:'mobile', a:'takeScreenshot', i:90});\n"
167 " bridge.postMessage({t:'mobile', a:'registerDeepLinkHandler', i:91});\n"
133168 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
134169 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
135170 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -398,6 +398,9 @@ pub fn build(b: *std.Build) void {
398398 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
399399 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
400400 });
401 ios_conformance_tests.root_module.addAnonymousImport("src/ios_config.zig", .{
402 .root_source_file = b.path("src/ios_config.zig"),
403 });
401404 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
402405 .root_source_file = b.path("src/bridge_mobile.zig"),
403406 });
packages/zig/src/bridge_error.zigmodified+10-0
Changes to packages/zig/src/bridge_error.zig
@@ -34,6 +34,14 @@ pub const BridgeError = error{
3434 Timeout,
3535 /// Command contains unsafe shell metacharacters
3636 UnsafeCommand,
37 /// The app's configuration did not enable this capability.
38 ///
39 /// Distinct from `PermissionDenied`, which is the *user* or the system
40 /// refusing at request time. This one is decided before anything is
41 /// requested, by the `enable*` flag the app was generated with, and no
42 /// prompt the user could answer would change it — so a page that retries
43 /// on `PERMISSION_DENIED` must not retry on this.
44 CapabilityDisabled,
3745};
3846
3947/// Result type for bridge operations that return data
@@ -152,6 +160,7 @@ pub fn errorCodeString(err: BridgeError) []const u8 {
152160 BridgeError.PermissionDenied => "PERMISSION_DENIED",
153161 BridgeError.Timeout => "TIMEOUT",
154162 BridgeError.UnsafeCommand => "UNSAFE_COMMAND",
163 BridgeError.CapabilityDisabled => "CAPABILITY_DISABLED",
155164 };
156165}
157166
@@ -173,6 +182,7 @@ pub fn errorMessage(err: BridgeError) []const u8 {
173182 BridgeError.PermissionDenied => "Permission denied",
174183 BridgeError.Timeout => "Operation timed out",
175184 BridgeError.UnsafeCommand => "Command contains unsafe shell metacharacters",
185 BridgeError.CapabilityDisabled => "Capability is disabled",
176186 };
177187}
178188
packages/zig/src/bridge_mobile_misc.zigmodified+341-59
Changes to packages/zig/src/bridge_mobile_misc.zig
@@ -1,59 +1,55 @@
1//! The `mobile` namespace's flashlight action — and the recorded decision that
2//! the other three actions scoped to this module (`getInitialURL`,
3//! `registerDeepLinkHandler`, `takeScreenshot`) stay *unserved*, so that
4//! `ios_dispatch.route` falls through to the Swift shim that answers them
5//! correctly today. See `deliberately_unserved`.
1//! The `mobile` namespace's odds and ends: the flashlight, a window
2//! screenshot, and the deep-link handshake — plus the recorded reason
3//! `getInitialURL` stays *unserved*, so `ios_dispatch.route` falls through to
4//! the Swift shim that answers it correctly today. See `deliberately_unserved`.
65//!
7//! ## Why `setFlashlight` is the only arm
6//! ## What changed, and what it unblocked
87//!
9//! It is the only one of the four with no `CraftConfig` gate in front of it:
10//! the Swift dispatcher calls `setFlashlight(enabled:)` unconditionally, and
11//! the injected capabilities blob hardcodes `flashlight: true`. Every step is
12//! a synchronous AVFoundation call — no completion handler, no `ios_async`
13//! ticket — so the whole exchange happens inside the dispatch frame that holds
14//! the request id, on the main thread WebKit delivered it on.
8//! Three of these four actions used to be unserved, and two of them for the
9//! same reason: their Swift arms are conditional on `CraftConfig` flags
10//! (`enableScreenCapture`, `enableDeepLinks`) that existed only as Swift
11//! properties. Serving them regardless would have switched on capabilities the
12//! app author turned off — fabricated authorization, the class of bug this
13//! migration exists to remove.
1514//!
16//! The other three founder on one missing channel: their Swift arms are
17//! conditional on `CraftConfig` flags (`enableDeepLinks`,
18//! `enableScreenCapture`) that exist only as Swift properties. Nothing carries
19//! them to Zig — no export, no shared defaults, nothing to grep — so a Zig
20//! handler cannot know whether the app author enabled the feature. Each action
21//! adds its own reason on top:
15//! `ios_config.zig` is that missing channel. It reads the same bundled
16//! `craft.config.json` Swift decodes, and `ios_dispatch.route` refuses a gated
17//! action before this module runs, so neither handler below has to know a flag
18//! exists. Both are now exactly what Swift does once its own guard passes.
2219//!
23//! - **`getInitialURL`** cannot be answered from Zig at all. The value lives
24//! in `DeepLinkManager.shared.initialURL` — a private property of a
25//! pure-Swift class that derives from nothing and declares no `@objc`, so
20//! Each action's own shape:
21//!
22//! - **`setFlashlight`** was always servable: no gate at all in the Swift
23//! dispatcher, and every step a synchronous AVFoundation call — no
24//! completion handler, no `ios_async` ticket — so the exchange happens
25//! inside the dispatch frame that holds the request id.
26//! - **`takeScreenshot`** renders the key window's layer into an image
27//! context and answers a `data:` URL. Also synchronous: Swift wraps its
28//! body in `DispatchQueue.main.async`, but `didReceiveScriptMessage`
29//! already runs on the main thread, so the hop would defer the same work to
30//! the next runloop turn and nothing else.
31//! - **`registerDeepLinkHandler`** acknowledges and does nothing else,
32//! because there is nothing native to register — Swift's whole arm is
33//! `resolveCallback(callbackId, result: true)`, and the real registration
34//! is the page's own `addEventListener('craftDeepLink', …)`. Worth stating
35//! plainly: this action is a handshake, and porting it faithfully means
36//! porting a handshake.
37//! - **`getInitialURL`** cannot be answered from Zig at all, and the config
38//! channel does not change that. The value lives in
39//! `DeepLinkManager.shared.initialURL` — a private property of a pure-Swift
40//! class that derives from nothing and declares no `@objc`, so
2641//! `objc_getClass("DeepLinkManager")` cannot find it and neither `shared`
2742//! nor `getInitialURL()` has an ObjC entry point. It is fed by SwiftUI's
2843//! `.onOpenURL`, which Zig has no path to, and the launch URL is not
2944//! re-derivable afterwards (not in the environment, not in NSUserDefaults).
30//! - **`registerDeepLinkHandler`** does nothing but acknowledge: Swift
31//! replies the bare boolean `true` iff `config.enableDeepLinks`; the real
32//! registration is the page's own `addEventListener('craftDeepLink', …)`.
33//! An unconditional `true` from Zig would report "registered" in apps where
34//! the event can never fire. No JS anywhere in the repo posts the action —
35//! the injected script, `test-bridges.html`, `craft-bridge.js` and the TS
36//! SDK are all silent — so there is also nothing to migrate *for*.
37//! - **`takeScreenshot`**'s capture is fully doable from Zig
38//! (`UIGraphicsBeginImageContextWithOptions` and friends, then
39//! `UIImagePNGRepresentation`), but `enableScreenCapture` defaults to
40//! *false* and Swift's arm skips the handler entirely when it is off.
41//! Serving it unconditionally would switch on a capability the app author
42//! turned off — fabricated authorization, the exact class of bug this
43//! migration exists to remove. It stays with the shim until a config
44//! channel exists.
4545//!
4646//! ## Why unserved rather than `.unavailable`
4747//!
4848//! `ios_dispatch.route` hands an action to `CraftSwiftShim` only when every
4949//! module answers `UnknownAction`. A declared-`.unavailable` action
5050//! *dispatches and refuses* — `bridge_mobile_display.lockOrientation` is the
51//! precedent — so declaring these three would take them away from the shim,
52//! replacing answers that are correct today with rejections. In a Zig-only
53//! build with no shim, the fall-through degrades to an `UnknownAction` error
54//! at the page, which is still the honest outcome: absent the config, craft
55//! does not know whether these features are enabled, and an error a page can
56//! see beats a guess it cannot.
51//! precedent — so declaring `getInitialURL` would take it away from the arm
52//! that answers it correctly today.
5753//!
5854//! ## Build note
5955//!
@@ -65,6 +61,11 @@
6561//! check reports as "no flashlight", the same rejection Swift's guard gives a
6662//! simulator. Honest either way, but the fixture needs the flag before the
6763//! torch can actually light.
64//!
65//! `takeScreenshot` needs no such flag: UIKit is already linked there, and its
66//! five drawing entry points are resolved by `dlsym` at call time rather than
67//! declared `extern "c"`, so the host test binaries — which link Cocoa, not
68//! UIKit — get an honest refusal instead of a link error.
6869
6970const std = ;
7071const builtin = ;
@@ -98,6 +99,8 @@ const AVCaptureTorchModeOn: c_long = 1;
9899/// is still the thing answering it.
99100pub const A = struct {
100101 pub const set_flashlight = "setFlashlight";
102 pub const take_screenshot = "takeScreenshot";
103 pub const register_deep_link_handler = "registerDeepLinkHandler";
101104};
102105
103106/// The actions this module was scoped to and, on purpose, does not serve.
@@ -109,8 +112,6 @@ pub const A = struct {
109112/// its name from this list into `A` and updates the module comment.
110113pub const deliberately_unserved = [_][]const u8{
111114 "getInitialURL",
112 "registerDeepLinkHandler",
113 "takeScreenshot",
114115};
115116
116117/// `.result`: every Swift path terminates in `resolveCallback(…, true)` or a
@@ -120,6 +121,8 @@ pub const deliberately_unserved = [_][]const u8{
120121/// device the call found, not of the action.
121122pub const capability_actions = [_]capabilities.ActionDecl{
122123 .{ .name = A.set_flashlight, .reply = .result },
124 .{ .name = A.take_screenshot, .reply = .result },
125 .{ .name = A.register_deep_link_handler, .reply = .result },
123126};
124127
125128/// Swift resolves with `resolveCallback(callbackId, result: true)`, serialized
@@ -143,14 +146,57 @@ pub const MiscBridge = struct {
143146 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
144147 if (std.mem.eql(u8, action, A.set_flashlight)) {
145148 try self.setFlashlight(data);
149 } else if (std.mem.eql(u8, action, A.take_screenshot)) {
150 try self.takeScreenshot();
151 } else if (std.mem.eql(u8, action, A.register_deep_link_handler)) {
152 try self.registerDeepLinkHandler();
146153 } else {
147 // `getInitialURL`, `registerDeepLinkHandler` and `takeScreenshot`
148 // land here on purpose: `UnknownAction` is what routes an action
149 // onward to the Swift shim. See the module comment.
154 // `getInitialURL` lands here on purpose: `UnknownAction` is what
155 // routes an action onward to the Swift shim. See the module
156 // comment.
150157 return BridgeError.UnknownAction;
151158 }
152159 }
153160
161 /// The key window's pixels, as a `data:` URL, exactly as Swift spells it.
162 ///
163 /// No `ios_async` ticket and no queue hop. Swift wraps its body in
164 /// `DispatchQueue.main.async`, but `didReceiveScriptMessage` already runs
165 /// on the main thread, so the hop would only defer the same work to the
166 /// next runloop turn — and doing it inline keeps the whole exchange inside
167 /// the dispatch frame that holds the request id, which is where a reply is
168 /// cheapest to correlate.
169 fn takeScreenshot(self: *Self) !void {
170 const png = try captureKeyWindowPng(self.allocator);
171 defer self.allocator.free(png);
172
173 // Swift resolves with a Swift `String` under `.fragmentsAllowed`, so
174 // the page receives a bare JSON string rather than an object. Base64
175 // and the prefix contain nothing JSON escapes, but the shared escaper
176 // runs anyway: a hand-argued "these bytes are safe" is how an escaping
177 // bug gets introduced later by someone changing the prefix.
178 const scratch = try self.allocator.alloc(u8, png.len * 2 + 2);
179 defer self.allocator.free(scratch);
180 const escaped = try bridge_error.escapeJsonString(scratch[1..], png);
181
182 scratch[0] = '"';
183 scratch[1 + escaped.len] = '"';
184 bridge_error.sendResultToJS(self.allocator, A.take_screenshot, scratch[0 .. escaped.len + 2]);
185 }
186
187 /// Acknowledge that the page may listen for `craftDeepLink`.
188 ///
189 /// Swift's whole arm is `resolveCallback(callbackId, result: true)` behind
190 /// the `enableDeepLinks` gate — the real registration is the page's own
191 /// `addEventListener`, and there is nothing native to register. What made
192 /// this unservable was that Zig could not see the gate, so its `true` would
193 /// have claimed a registration in apps that had deep links switched off;
194 /// `ios_config` now refuses the action before this runs, and the remaining
195 /// behaviour is Swift's, unchanged.
196 fn registerDeepLinkHandler(self: *Self) !void {
197 bridge_error.sendResultToJS(self.allocator, A.register_deep_link_handler, success_reply);
198 }
199
154200 /// Turn the torch on or off, and say so with Swift's bare `true`.
155201 fn setFlashlight(self: *Self, data: []const u8) !void {
156202 var parsed = try parsePayload(self.allocator, data);
@@ -292,13 +338,221 @@ fn setTorch(allocator: std.mem.Allocator, enabled: bool) !void {
292338// shape — are pure functions above, and are pinned below.
293339// =============================================================================
294340
341/// The `data:` URL prefix Swift concatenates ahead of the base64.
342///
343/// Part of the wire contract, not decoration: `CraftApp.swift:4177` builds
344/// `"data:image/png;base64," + imageData.base64EncodedString()`, and a page
345/// that assigns the reply straight to `img.src` needs it.
346const png_data_url_prefix = "data:image/png;base64,";
347
348// UIKit's C drawing entry points. Chosen over `UIGraphicsImageRenderer`,
349// which is what Swift uses, for one reason: `imageWithActions:` takes an
350// Objective-C block, and a *global* block cannot capture the layer it has to
351// render. Reaching the layer would mean stashing it in a module-level var
352// across the call — real shared mutable state, on the main thread, to avoid a
353// pair of C calls that do the same thing.
354//
355// The pixels are the same. `UIGraphicsImageRenderer`'s default format is
356// opaque = false at the screen's scale, which is exactly
357// `UIGraphicsBeginImageContextWithOptions(size, NO, 0.0)` — 0.0 meaning "the
358// main screen's scale". These are deprecated as of iOS 17 and still present;
359// the deprecation is a compiler diagnostic for ObjC callers, and nothing here
360// compiles ObjC.
361// Resolved with `dlsym` rather than declared `extern "c"`, the route
362// `bridge_mobile_imagepicker.resolve` already takes for
363// `UIImageJPEGRepresentation`: the host test binaries link Cocoa and not
364// UIKit, and `refAllDecls` forces analysis of every declaration, so a direct
365// `extern` would be five undefined symbols at host link time.
366const BeginContextFn = *const fn (objc.CGSize, bool, objc.CGFloat) callconv(.c) void;
367const GetContextFn = *const fn () callconv(.c) ?*anyopaque;
368const GetImageFn = *const fn () callconv(.c) Id;
369const EndContextFn = *const fn () callconv(.c) void;
370const PngDataFn = *const fn (Id) callconv(.c) Id;
371
372/// The five UIKit drawing entry points, resolved together.
373///
374/// Together rather than one at a time so a UIKit that carries some but not all
375/// of them refuses before beginning an image context — the one call here with
376/// a cleanup obligation. Resolving `UIImagePNGRepresentation` lazily after
377/// `UIGraphicsBeginImageContextWithOptions` had already run would mean either
378/// leaking the context or unwinding it from an error path that exists only for
379/// this.
380const Graphics = struct {
381 begin: BeginContextFn,
382 current: GetContextFn,
383 image: GetImageFn,
384 end: EndContextFn,
385 png: PngDataFn,
386
387 fn resolve() !Graphics {
388 return .{
389 .begin = @ptrCast(@alignCast(try symbol("UIGraphicsBeginImageContextWithOptions"))),
390 .current = @ptrCast(@alignCast(try symbol("UIGraphicsGetCurrentContext"))),
391 .image = @ptrCast(@alignCast(try symbol("UIGraphicsGetImageFromCurrentImageContext"))),
392 .end = @ptrCast(@alignCast(try symbol("UIGraphicsEndImageContext"))),
393 .png = @ptrCast(@alignCast(try symbol("UIImagePNGRepresentation"))),
394 };
395 }
396
397 fn symbol(comptime name: [*:0]const u8) !*anyopaque {
398 return dlsym(RTLD_DEFAULT, name) orelse {
399 std.log.warn(
400 "takeScreenshot refused: {s} is not in this process, so the window " ++
401 "could not be rendered",
402 .{name},
403 );
404 return BridgeError.PlatformNotSupported;
405 };
406 }
407};
408
409extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
410
411/// `RTLD_DEFAULT` — search every image already loaded into the process.
412const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
413
414/// `data:image/png;base64,…` for the key window, or the reason there is none.
415///
416/// Follows Swift step for step, including which window: `connectedScenes`
417/// is a `Set`, and Swift takes `.first` of it, which is an arbitrary element
418/// rather than a defined one. `anyObject` is the same choice made explicit.
419/// Apps with one scene — every app this template generates — cannot tell the
420/// difference.
421fn captureKeyWindowPng(allocator: std.mem.Allocator) ![]u8 {
422 if (!builtin.target.os.tag.isDarwin()) return BridgeError.PlatformNotSupported;
423
424 const gfx = try Graphics.resolve();
425 const window = try keyWindow();
426
427 const sel_bounds = objc.sel_registerName("bounds") orelse return BridgeError.NativeCallFailed;
428 const BoundsFn = *const fn (Id, objc.SEL) callconv(.c) objc.CGRect;
429 const boundsFn: BoundsFn = @ptrCast(&objc.objc_msgSend);
430 const bounds = boundsFn(window, sel_bounds);
431
432 // A zero-sized context yields a nil image rather than an empty PNG, and
433 // the nil would surface as "Failed to capture screenshot" several steps
434 // later. Refusing here names the actual condition.
435 if (bounds.size.width <= 0 or bounds.size.height <= 0) {
436 std.log.warn(
437 "takeScreenshot: the key window is {d}x{d}; there is nothing to capture",
438 .{ bounds.size.width, bounds.size.height },
439 );
440 return BridgeError.NativeCallFailed;
441 }
442
443 const sel_layer = objc.sel_registerName("layer") orelse return BridgeError.NativeCallFailed;
444 const layer = objc.msgSendId(window, sel_layer) orelse return BridgeError.NativeCallFailed;
445
446 // 0.0 scale means the main screen's, matching UIGraphicsImageRenderer's
447 // default format; `false` for opaque keeps the alpha channel Swift's
448 // renderer also keeps.
449 gfx.begin(bounds.size, false, 0.0);
450 // Paired with the Begin above on every path out of this block, including
451 // the error returns: leaving a context on the stack corrupts the next
452 // capture rather than this one, which is the hard kind of bug to trace
453 // back.
454 defer gfx.end();
455
456 const context = gfx.current() orelse {
457 std.log.warn("takeScreenshot: no image context after beginning one", .{});
458 return BridgeError.NativeCallFailed;
459 };
460
461 const sel_render = objc.sel_registerName("renderInContext:") orelse
462 return BridgeError.NativeCallFailed;
463 objc.msgSendVoid1(layer, sel_render, context);
464
465 const image = gfx.image() orelse {
466 std.log.warn("takeScreenshot: the image context produced no image", .{});
467 return BridgeError.NativeCallFailed;
468 };
469
470 const png = gfx.png(image) orelse {
471 // Swift's own failure branch: `image.pngData()` returning nil.
472 std.log.warn("takeScreenshot: the captured image has no PNG representation", .{});
473 return BridgeError.NativeCallFailed;
474 };
475
476 const sel_base64 = objc.sel_registerName("base64EncodedStringWithOptions:") orelse
477 return BridgeError.NativeCallFailed;
478 const Base64Fn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) Id;
479 const base64Fn: Base64Fn = @ptrCast(&objc.objc_msgSend);
480 const ns_base64 = base64Fn(png, sel_base64, 0) orelse return BridgeError.NativeCallFailed;
481
482 const utf8 = objc.getNSStringUTF8(ns_base64) orelse return BridgeError.NativeCallFailed;
483 const encoded = std.mem.span(utf8);
484
485 return std.mem.concat(allocator, u8, &.{ png_data_url_prefix, encoded });
486}
487
488/// The window Swift's `connectedScenes.first`/`windows.first` pair reaches.
489///
490/// Every step can legitimately be nil — an app in the background has scenes
491/// but no attached window, and a scene that is not a `UIWindowScene` has no
492/// `windows` at all. Swift collapses the lot into one "No window available"
493/// rejection; the log lines below keep them apart, because a missing scene and
494/// a scene with no windows are different states of the app.
495fn keyWindow() !Id {
496 const UIApplication = objc.objc_getClass("UIApplication") orelse
497 return BridgeError.NativeCallFailed;
498 const sel_shared = objc.sel_registerName("sharedApplication") orelse
499 return BridgeError.NativeCallFailed;
500 const app = objc.msgSendId(UIApplication, sel_shared) orelse {
501 std.log.warn("takeScreenshot: no UIApplication instance", .{});
502 return BridgeError.NativeCallFailed;
503 };
504
505 const sel_scenes = objc.sel_registerName("connectedScenes") orelse
506 return BridgeError.NativeCallFailed;
507 const scenes = objc.msgSendId(app, sel_scenes) orelse {
508 std.log.warn("takeScreenshot: the application has no connected scenes", .{});
509 return BridgeError.NativeCallFailed;
510 };
511
512 const sel_any = objc.sel_registerName("anyObject") orelse return BridgeError.NativeCallFailed;
513 const scene = objc.msgSendId(scenes, sel_any) orelse {
514 std.log.warn("takeScreenshot: the connected-scene set is empty", .{});
515 return BridgeError.NativeCallFailed;
516 };
517
518 // Swift's `as? UIWindowScene` is a conditional cast; sending `windows` to
519 // a scene that is not one would be an unrecognised selector, and that is a
520 // SIGABRT rather than an error this function could return.
521 const UIWindowScene = objc.objc_getClass("UIWindowScene") orelse
522 return BridgeError.NativeCallFailed;
523 const sel_is_kind = objc.sel_registerName("isKindOfClass:") orelse
524 return BridgeError.NativeCallFailed;
525 const IsKindFn = *const fn (Id, objc.SEL, objc.Class) callconv(.c) bool;
526 const isKind: IsKindFn = @ptrCast(&objc.objc_msgSend);
527 if (!isKind(scene, sel_is_kind, UIWindowScene)) {
528 std.log.warn("takeScreenshot: the connected scene is not a UIWindowScene", .{});
529 return BridgeError.NativeCallFailed;
530 }
531
532 const sel_windows = objc.sel_registerName("windows") orelse return BridgeError.NativeCallFailed;
533 const windows = objc.msgSendId(scene, sel_windows) orelse
534 return BridgeError.NativeCallFailed;
535
536 const sel_first = objc.sel_registerName("firstObject") orelse
537 return BridgeError.NativeCallFailed;
538 return objc.msgSendId(windows, sel_first) orelse {
539 std.log.warn("takeScreenshot: the window scene has no windows", .{});
540 return BridgeError.NativeCallFailed;
541 };
542}
543
295544const testing = std.testing;
296545
297test "the declared action is the one the handler serves" {
298 try testing.expectEqual(@as(usize, 1), capability_actions.len);
546test "the declared actions are the ones the handler serves" {
547 try testing.expectEqual(@as(usize, 3), capability_actions.len);
299548 try testing.expectEqualStrings(A.set_flashlight, capability_actions[0].name);
300 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
301 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status);
549 try testing.expectEqualStrings(A.take_screenshot, capability_actions[1].name);
550 try testing.expectEqualStrings(A.register_deep_link_handler, capability_actions[2].name);
551
552 for (capability_actions) |decl| {
553 try testing.expectEqual(capabilities.Reply.result, decl.reply);
554 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
555 }
302556}
303557
304558test "the action name matches the Swift case label exactly" {
@@ -306,24 +560,52 @@ test "the action name matches the Swift case label exactly" {
306560 // labels in `CraftApp.swift`, in both directions; a prettier spelling
307561 // would register as Zig serving an action the spec does not have.
308562 try testing.expectEqualStrings("setFlashlight", A.set_flashlight);
563 try testing.expectEqualStrings("takeScreenshot", A.take_screenshot);
564 try testing.expectEqualStrings("registerDeepLinkHandler", A.register_deep_link_handler);
309565}
310566
311567test "every declared action dispatches to something" {
312 // `{}` has no `enabled`, so validation fails *before* any AVFoundation
313 // call — which is what makes this safe on a host with a real camera. What
314 // it rules out is a name in the table `handleMessage` never compares
315 // against.
568 // What this rules out is a name in the table `handleMessage` never
569 // compares against — which would reach the shim as `UnknownAction` while
570 // the capability manifest claimed Zig served it.
571 //
572 // `UnknownAction` is the only forbidden outcome, not any error.
573 // `setFlashlight` fails validation on `{}` before touching AVFoundation,
574 // and `takeScreenshot` refuses on a host whose UIKit symbols `dlsym`
575 // cannot find; both are the handler running, which is what is being
576 // asserted.
316577 var bridge = MiscBridge.init(testing.allocator);
317578 defer bridge.deinit();
318579
319580 for (capability_actions) |decl| {
320 try testing.expectError(
321 BridgeError.MissingData,
322 bridge.handleMessage(decl.name, "{}"),
323 );
581 bridge.handleMessage(decl.name, "{}") catch |err| {
582 try testing.expect(err != BridgeError.UnknownAction);
583 continue;
584 };
324585 }
325586}
326587
588test "the screenshot reply is a bare JSON string carrying a data URL" {
589 // Swift resolves with a `String` under `.fragmentsAllowed`, so the page
590 // gets `"data:image/png;base64,…"` and not `{"image":…}`. A page that
591 // assigns the reply to `img.src` depends on both the quoting and the
592 // prefix.
593 try testing.expectEqualStrings("data:image/png;base64,", png_data_url_prefix);
594}
595
596test "a screenshot on a host without UIKit refuses rather than inventing one" {
597 // The five drawing entry points are UIKit's, and the host test binaries
598 // link Cocoa. `dlsym` finding nothing has to be a refusal — a module that
599 // answered with an empty or placeholder PNG here would be fabricating the
600 // one thing this action exists to return.
601 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
602
603 try testing.expectError(
604 BridgeError.PlatformNotSupported,
605 captureKeyWindowPng(testing.allocator),
606 );
607}
608
327609test "the unserved actions stay unserved, which is what routes them to the shim" {
328610 var bridge = MiscBridge.init(testing.allocator);
329611 defer bridge.deinit();
packages/zig/src/ios_config.zigadded+670-0
Changes to packages/zig/src/ios_config.zig
@@ -0,0 +1,670 @@
1//! What the app was configured to allow, read from the file Swift reads.
2//!
3//! Every capability in `CraftApp.swift`'s dispatcher is gated on a
4//! `config.enable*` flag, and until now Zig had none of them. That gap was
5//! recorded twice as it was hit — `bridge_mobile_system.zig` for `enableShare`,
6//! `bridge_mobile_location.zig` for `enableGeolocation` — and worked around
7//! once, by reading an Info.plist key the generator writes from the same flag.
8//! This file closes it properly: 32 actions Zig already serves are gated in the
9//! spec and were being served unconditionally, and 33 of the 48 still on the
10//! shim are behind a flag rather than behind any Objective-C difficulty.
11//!
12//! ## One file, two readers, so the parse has to match
13//!
14//! `craft.config.json` is written by `packages/ios/src/index.ts:366` and
15//! bundled as an optional resource by `project.yml.template`. Swift decodes it
16//! out of the bundle at `CraftApp.swift:172-178`. Zig now decodes the same
17//! bytes — and it has to reach the *same verdict*, because the hand-off table
18//! means both runtimes serve actions in one process from one file. Two
19//! opinions about whether clipboard is enabled is one app that both allows and
20//! refuses the same capability depending on which arm answered.
21//!
22//! That is why the decode below is all-or-nothing rather than per-key, which
23//! looks like a bug until you check what Swift does. `CraftConfig`'s properties
24//! carry default values, but a synthesized `Decodable` does not use them: it
25//! calls `decode(Bool.self, forKey:)`, which *throws* when the key is missing.
26//! `try?` turns the throw into nil, and line 178 falls back to `CraftConfig()`
27//! — every flag false. So a config missing one of its 40 required keys disables
28//! all 35 capabilities, not the one key. Reading it per-key here would be more
29//! useful and would disagree with Swift on the same file.
30//!
31//! Each of these was verified against a real `swiftc` decode rather than
32//! assumed, because "faithful" is a claim about another language's behaviour:
33//!
34//! - a missing required key throws -> all false
35//! - `1` for a `Bool` throws -> all false (not coerced)
36//! - `null` for a `Bool` throws -> all false
37//! - `"true"` for a `Bool` throws -> all false
38//! - a non-string in `trustedOrigins` throws-> all false
39//! - an unknown extra key is ignored
40//! - `devServerURL` absent or null is fine (it is the one optional)
41//!
42//! ## What a disabled capability answers, and why it is not what Swift answers
43//!
44//! `CAPABILITY_DISABLED`, through the ordinary error path.
45//!
46//! Swift replies that on 5 of its 65 gated cases — `share`, `secureClear`,
47//! `getCurrentPosition`, `watchPosition`, `startLocationRecording`. The other
48//! **60** have no `else` at all: the guard fails, the case falls out of the
49//! switch, and the callback is never settled. The page's promise stays pending
50//! until `craft-bridge.js`'s timeout fires, thirty seconds later, with no
51//! indication that a configuration flag is why.
52//!
53//! Those 60 are not a contract to port. They are the same defect as the five
54//! `ota*` actions the conformance test already catches, at twelve times the
55//! scale, and the 5 correct sites say plainly what the intended answer was. So
56//! this diverges from the spec deliberately and in one direction: an action
57//! that Swift would hang, Zig refuses with a code naming the reason.
58//!
59//! ## Absent means off
60//!
61//! No bundled config file is every flag false, matching Swift's fallback. It is
62//! also the safe direction for what is really a permissions surface: a
63//! capability nobody configured is one nobody asked for. The cost is that a
64//! host app which never generated a config serves nothing, which is why the
65//! read logs once at warn rather than failing silently.
66
67const std = ;
68const builtin = ;
69const objc_runtime = ;
70const compat_mutex = ;
71const io_context = ;
72
73const objc = objc_runtime.objc;
74const is_darwin = builtin.target.os.tag.isDarwin();
75
76/// A capability flag in `craft.config.json`.
77///
78/// One member per `Bool` in Swift's `CraftConfig` that gates an action.
79/// `darkMode` is a `Bool` too but gates nothing, so it is absent here and
80/// present in `required_keys` — the decode needs it, the gate does not.
81pub const Feature = enum {
82 ar,
83 audio_recording,
84 background_location,
85 background_tasks,
86 biometric,
87 bluetooth,
88 calendar,
89 camera,
90 clipboard,
91 contacts,
92 deep_links,
93 file_download,
94 file_picker,
95 geolocation,
96 haptics,
97 health_kit,
98 in_app_purchase,
99 keep_awake,
100 live_activities,
101 local_database,
102 local_notifications,
103 ml_kit,
104 motion_sensors,
105 nfc,
106 orientation_lock,
107 pdf_viewer,
108 push_notifications,
109 qr_scanner,
110 screen_capture,
111 secure_storage,
112 share,
113 social_auth,
114 speech_recognition,
115 video_recording,
116 watch_app,
117
118 /// The exact key in `craft.config.json`.
119 ///
120 /// Spelled out rather than derived from the member name: `enableNFC`,
121 /// `enableAR`, `enablePDFViewer` and `enableMLKit` are not the
122 /// camel-casing of `nfc`, `ar`, `pdf_viewer` or `ml_kit`, and a derivation
123 /// that got them wrong would look up a key that is not there — which, under
124 /// the all-or-nothing rule above, disables the entire app rather than
125 /// failing visibly.
126 pub fn jsonKey(self: Feature) []const u8 {
127 return switch (self) {
128 .ar => "enableAR",
129 .audio_recording => "enableAudioRecording",
130 .background_location => "enableBackgroundLocation",
131 .background_tasks => "enableBackgroundTasks",
132 .biometric => "enableBiometric",
133 .bluetooth => "enableBluetooth",
134 .calendar => "enableCalendar",
135 .camera => "enableCamera",
136 .clipboard => "enableClipboard",
137 .contacts => "enableContacts",
138 .deep_links => "enableDeepLinks",
139 .file_download => "enableFileDownload",
140 .file_picker => "enableFilePicker",
141 .geolocation => "enableGeolocation",
142 .haptics => "enableHaptics",
143 .health_kit => "enableHealthKit",
144 .in_app_purchase => "enableInAppPurchase",
145 .keep_awake => "enableKeepAwake",
146 .live_activities => "enableLiveActivities",
147 .local_database => "enableLocalDatabase",
148 .local_notifications => "enableLocalNotifications",
149 .ml_kit => "enableMLKit",
150 .motion_sensors => "enableMotionSensors",
151 .nfc => "enableNFC",
152 .orientation_lock => "enableOrientationLock",
153 .pdf_viewer => "enablePDFViewer",
154 .push_notifications => "enablePushNotifications",
155 .qr_scanner => "enableQRScanner",
156 .screen_capture => "enableScreenCapture",
157 .secure_storage => "enableSecureStorage",
158 .share => "enableShare",
159 .social_auth => "enableSocialAuth",
160 .speech_recognition => "enableSpeechRecognition",
161 .video_recording => "enableVideoRecording",
162 .watch_app => "enableWatchApp",
163 };
164 }
165};
166
167/// Which flags are on.
168pub const Flags = std.EnumSet(Feature);
169
170/// The keys Swift's synthesized `init(from:)` calls `decode` for.
171///
172/// Missing any one of them throws, and the throw disables everything. Only
173/// `devServerURL` is optional (`String?` -> `decodeIfPresent`), so it is not
174/// listed.
175const string_keys = [_][]const u8{ "appName", "bundleId", "backgroundColor" };
176const extra_bool_keys = [_][]const u8{"darkMode"};
177const array_of_string_keys = [_][]const u8{"trustedOrigins"};
178const optional_string_keys = [_][]const u8{"devServerURL"};
179
180/// Decode `json` the way Swift decodes it.
181///
182/// Returns the empty set for anything Swift's `JSONDecoder` would throw on,
183/// which is the same value line 178 falls back to. Pure and allocator-taking so
184/// the faithfulness above is testable without a bundle, a simulator, or a run
185/// loop.
186pub fn parse(allocator: std.mem.Allocator, json: []const u8) Flags {
187 var parsed = std.json.parseFromSlice(std.json.Value, allocator, json, .{
188 // Swift keeps the *first* of a duplicated key and does not throw —
189 // verified, because Zig's default here is `.error`, which would have
190 // made a duplicate disable the whole app where Swift shrugs and reads
191 // the first value. A hand-edited config that pastes a flag twice is
192 // exactly the case this covers.
193 .duplicate_field_behavior = .use_first,
194 }) catch {
195 return Flags.empty;
196 };
197 defer parsed.deinit();
198
199 const root = switch (parsed.value) {
200 .object => |o| o,
201 // A top-level array or scalar is not a `CraftConfig`.
202 else => return Flags.empty,
203 };
204
205 inline for (string_keys) |key| {
206 const v = root.get(key) orelse return Flags.empty;
207 if (v != .string) return Flags.empty;
208 }
209
210 inline for (extra_bool_keys) |key| {
211 const v = root.get(key) orelse return Flags.empty;
212 if (v != .bool) return Flags.empty;
213 }
214
215 inline for (array_of_string_keys) |key| {
216 const v = root.get(key) orelse return Flags.empty;
217 if (v != .array) return Flags.empty;
218 for (v.array.items) |item| {
219 if (item != .string) return Flags.empty;
220 }
221 }
222
223 inline for (optional_string_keys) |key| {
224 // Absent is fine; so is an explicit null. Present and not a string is
225 // a `typeMismatch`, which throws like any other.
226 if (root.get(key)) |v| {
227 if (v != .string and v != .null) return Flags.empty;
228 }
229 }
230
231 var flags_out = Flags.empty;
232 inline for (comptime std.enums.values(Feature)) |feature| {
233 const v = root.get(feature.jsonKey()) orelse return Flags.empty;
234 // `.bool` only. A JSON `1` is `.integer` here and `NSNumber`-bridged in
235 // Swift, and Swift throws on it rather than coercing — verified, not
236 // assumed, because coercing would be the obvious thing to write.
237 if (v != .bool) return Flags.empty;
238 if (v.bool) flags_out.insert(feature);
239 }
240 return flags_out;
241}
242
243var cache_mutex: compat_mutex.Mutex = .{};
244var cached: ?Flags = null;
245
246/// The bundled configuration, read once.
247///
248/// Swift reads it once in `Coordinator.init` and holds it for the life of the
249/// process; a file that changed underneath would give the two runtimes
250/// different answers, which is exactly what this file exists to prevent.
251pub fn flags() Flags {
252 cache_mutex.lock();
253 defer cache_mutex.unlock();
254 if (cached) |f| return f;
255 const f = readFromBundle();
256 cached = f;
257 return f;
258}
259
260/// Whether `feature` was enabled for this app.
261pub fn isEnabled(feature: Feature) bool {
262 return flags().contains(feature);
263}
264
265/// A config larger than this is not one we wrote.
266///
267/// The generator's output is about 1.5 KiB. The cap exists so a resource that
268/// happens to be named `craft.config.json` and is actually a video cannot be
269/// read into memory before being rejected as unparseable.
270const max_config_bytes = 1 << 20;
271
272fn readFromBundle() Flags {
273 if (!is_darwin) return Flags.empty;
274
275 const path = bundleResourcePath() orelse {
276 std.log.warn(
277 "ios config: no craft.config.json in the main bundle; " ++
278 "every capability is disabled, matching Swift's fallback",
279 .{},
280 );
281 return Flags.empty;
282 };
283
284 const allocator = std.heap.c_allocator;
285 const bytes = readConfigFile(allocator, std.mem.span(path)) catch {
286 // Bundled but unreadable. Swift's `try?` flattens this into the same
287 // all-false as "no file", and so does this — but it says which one
288 // happened, because they call for different fixes.
289 return Flags.empty;
290 } orelse return Flags.empty;
291 defer allocator.free(bytes);
292
293 const f = parse(allocator, bytes);
294 if (f.count() == 0) {
295 // Not necessarily an error — a config with every flag off is legal and
296 // is the generator's default. Said once, at info, because the
297 // alternative reading (a malformed file disabling everything) is
298 // otherwise indistinguishable from it in the field.
299 std.log.info(
300 "ios config: craft.config.json enables no capabilities; " ++
301 "if that is unexpected, check every required key is present, " ++
302 "since one missing key disables all of them",
303 .{},
304 );
305 }
306 return f;
307}
308
309/// The config file's bytes, or null if there is no file there.
310///
311/// Takes the path rather than finding it, so the host tests can drive the read
312/// against a real file on any platform instead of only against an app bundle
313/// that exists on exactly one.
314fn readConfigFile(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
315 const io = io_context.get();
316
317 const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
318 error.FileNotFound => return null,
319 else => {
320 std.log.warn("ios config: could not open '{s}': {}", .{ path, err });
321 return error.ConfigUnreadable;
322 },
323 };
324 defer file.close(io);
325
326 const info = file.stat(io) catch |err| {
327 std.log.warn("ios config: could not stat '{s}': {}", .{ path, err });
328 return error.ConfigUnreadable;
329 };
330
331 if (info.size > max_config_bytes) {
332 std.log.warn(
333 "ios config: craft.config.json is {d} bytes, over the {d}-byte ceiling; " ++
334 "refusing to read it",
335 .{ info.size, max_config_bytes },
336 );
337 return error.ConfigUnreadable;
338 }
339
340 const buf = try allocator.alloc(u8, @intCast(info.size));
341 errdefer allocator.free(buf);
342
343 var read: usize = 0;
344 while (read < buf.len) {
345 const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) {
346 error.EndOfStream => break,
347 else => {
348 std.log.warn("ios config: could not read '{s}': {}", .{ path, err });
349 return error.ConfigUnreadable;
350 },
351 };
352 if (n == 0) break;
353 read += n;
354 }
355
356 // A short read is not a truncated config to parse leniently: JSON that
357 // stops early is either invalid (all-false, correctly) or — worse —
358 // valid-looking with the tail of the flags missing, which under the
359 // all-or-nothing rule is also all-false. Refusing here says why.
360 if (read != buf.len) {
361 std.log.warn(
362 "ios config: read {d} of {d} bytes from '{s}'",
363 .{ read, buf.len, path },
364 );
365 allocator.free(buf);
366 return error.ConfigUnreadable;
367 }
368
369 return buf;
370}
371
372/// `[[NSBundle mainBundle] pathForResource:@"craft.config" ofType:@"json"]`.
373fn bundleResourcePath() ?[*:0]const u8 {
374 const NSBundle = objc.objc_getClass("NSBundle") orelse return null;
375 const sel_main = objc.sel_registerName("mainBundle") orelse return null;
376 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return null;
377
378 const NSString = objc.objc_getClass("NSString") orelse return null;
379 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return null;
380 const name = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "craft.config")) orelse
381 return null;
382 const ext = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "json")) orelse
383 return null;
384
385 const sel_path = objc.sel_registerName("pathForResource:ofType:") orelse return null;
386 const path = objc.msgSendId2(bundle, sel_path, name, ext) orelse return null;
387
388 return objc.getNSStringUTF8(path);
389}
390
391/// The feature an action is gated on, or null if Zig serving it is ungated.
392///
393/// Null means one of two different things, and the conformance test in
394/// `test/ios_config_gate_test.zig` is what keeps them from being confused:
395/// either the spec gates the action on nothing, or Zig does not serve it and
396/// the shim applies its own gate. A Zig-served action that the spec gates and
397/// this table omits fails that test rather than shipping ungated, which is the
398/// state all 32 entries below were in before this file existed.
399pub fn gateFor(action: []const u8) ?Feature {
400 const table = comptime [_]struct { []const u8, Feature }{
401 .{ "addContact", .contacts },
402 .{ "cancelAllBackgroundTasks", .background_tasks },
403 .{ "cancelAllNotifications", .local_notifications },
404 .{ "cancelBackgroundTask", .background_tasks },
405 .{ "cancelNotification", .local_notifications },
406 .{ "clipboardRead", .clipboard },
407 .{ "clipboardWrite", .clipboard },
408 .{ "createCalendarEvent", .calendar },
409 .{ "dbExecute", .local_database },
410 .{ "dbQuery", .local_database },
411 .{ "deleteCalendarEvent", .calendar },
412 .{ "getCalendarEvents", .calendar },
413 .{ "getContacts", .contacts },
414 .{ "getCurrentPosition", .geolocation },
415 .{ "getPendingNotifications", .local_notifications },
416 .{ "haptic", .haptics },
417 .{ "lockOrientation", .orientation_lock },
418 .{ "openCamera", .camera },
419 .{ "pickContact", .contacts },
420 .{ "pickFile", .file_picker },
421 .{ "pickImage", .camera },
422 .{ "registerDeepLinkHandler", .deep_links },
423 .{ "saveFile", .file_download },
424 .{ "scheduleBackgroundTask", .background_tasks },
425 .{ "secureClear", .secure_storage },
426 .{ "secureGet", .secure_storage },
427 .{ "secureRemove", .secure_storage },
428 .{ "secureSet", .secure_storage },
429 .{ "setKeepAwake", .keep_awake },
430 .{ "share", .share },
431 .{ "startMotionUpdates", .motion_sensors },
432 .{ "takeScreenshot", .screen_capture },
433 .{ "unlockOrientation", .orientation_lock },
434 .{ "watchPosition", .geolocation },
435 };
436 inline for (table) |entry| {
437 if (std.mem.eql(u8, action, entry[0])) return entry[1];
438 }
439 return null;
440}
441
442const testing = std.testing;
443
444/// A config with every required key present, so a test can remove exactly one
445/// thing and see the effect of removing that one thing.
446fn completeConfig(allocator: std.mem.Allocator, comptime overrides: []const u8) ![]u8 {
447 @setEvalBranchQuota(200_000);
448 var buf: std.ArrayListUnmanaged(u8) = .empty;
449 errdefer buf.deinit(allocator);
450
451 try buf.append(allocator, '{');
452 if (overrides.len != 0) {
453 try buf.appendSlice(allocator, overrides);
454 try buf.append(allocator, ',');
455 }
456
457 // Every required key, except one the caller already spelled. Emitting it
458 // twice and relying on which duplicate wins would make each test depend on
459 // `duplicate_field_behavior`, so a change to that option would quietly
460 // rewrite what these tests assert instead of failing one of them.
461 inline for (comptime string_keys ++ extra_bool_keys ++ array_of_string_keys) |key| {
462 if (comptime !mentions(overrides, key)) {
463 try buf.appendSlice(allocator, "\"" ++ key ++ "\":" ++ comptime placeholderFor(key) ++ ",");
464 }
465 }
466 inline for (comptime std.enums.values(Feature)) |feature| {
467 if (comptime !mentions(overrides, feature.jsonKey())) {
468 try buf.appendSlice(allocator, "\"");
469 try buf.appendSlice(allocator, feature.jsonKey());
470 try buf.appendSlice(allocator, "\":true,");
471 }
472 }
473 if (buf.items[buf.items.len - 1] == ',') _ = buf.pop();
474 try buf.append(allocator, '}');
475 return buf.toOwnedSlice(allocator);
476}
477
478/// Whether `overrides` already spells `"key":`.
479fn mentions(comptime overrides: []const u8, comptime key: []const u8) bool {
480 return std.mem.indexOf(u8, overrides, "\"" ++ key ++ "\":") != null;
481}
482
483fn placeholderFor(comptime key: []const u8) []const u8 {
484 if (std.mem.eql(u8, key, "trustedOrigins")) return "[]";
485 if (std.mem.eql(u8, key, "darkMode")) return "true";
486 return "\"T\"";
487}
488
489test "a complete config enables exactly the flags it sets" {
490 const json = try completeConfig(testing.allocator, "\"enableShare\":false,\"enableNFC\":false");
491 defer testing.allocator.free(json);
492
493 const f = parse(testing.allocator, json);
494
495 // The two overrides land last, and `std.json` keeps the last value for a
496 // duplicated key, so these are the false ones.
497 try testing.expect(!f.contains(.share));
498 try testing.expect(!f.contains(.nfc));
499 try testing.expect(f.contains(.clipboard));
500 try testing.expect(f.contains(.ml_kit));
501 try testing.expectEqual(std.enums.values(Feature).len - 2, f.count());
502}
503
504test "one missing key disables every capability, as Swift's decoder does" {
505 // The behaviour this whole file is shaped around, and the one that looks
506 // like a bug: `CraftConfig`'s properties have defaults, but a synthesized
507 // `Decodable` never uses them — it throws `keyNotFound`, `try?` swallows
508 // it, and `CraftApp.swift:178` falls back to an all-false `CraftConfig()`.
509 // Verified against a real swiftc decode before being written here.
510 const complete = try completeConfig(testing.allocator, "");
511 defer testing.allocator.free(complete);
512 try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, complete).count());
513
514 // Drop `enableHaptics` alone. Per-key decoding would lose one capability.
515 const needle = ",\"enableHaptics\":true";
516 const at = std.mem.indexOf(u8, complete, needle).?;
517 const without = try std.mem.concat(testing.allocator, u8, &.{
518 complete[0..at], complete[at + needle.len ..],
519 });
520 defer testing.allocator.free(without);
521
522 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, without).count());
523}
524
525test "a required non-capability key is required too" {
526 // `darkMode`, `appName`, `bundleId`, `backgroundColor` and `trustedOrigins`
527 // gate nothing, so it is tempting to ignore them. Swift decodes all five,
528 // and throwing on them is how a config that is merely *stale* — written by
529 // an older generator that had fewer keys — disables the app rather than
530 // half-enabling it.
531 inline for (.{ "\"darkMode\":true,", "\"appName\":\"T\",", "\"trustedOrigins\":[]," }) |needle| {
532 const complete = try completeConfig(testing.allocator, "");
533 defer testing.allocator.free(complete);
534 const at = std.mem.indexOf(u8, complete, needle).?;
535 const without = try std.mem.concat(testing.allocator, u8, &.{
536 complete[0..at], complete[at + needle.len ..],
537 });
538 defer testing.allocator.free(without);
539 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, without).count());
540 }
541}
542
543test "a wrongly typed flag is a throw, not a coercion" {
544 // Each of these was run through `swiftc` before being pinned. `1` is the
545 // one worth stating: JSON has no bool/number distinction in many writers,
546 // Foundation bridges both to `NSNumber`, and coercing would be the
547 // reasonable-looking choice — but Swift throws, so an app whose config was
548 // hand-edited to `"enableCamera": 1` has *no* capabilities, not camera.
549 inline for (.{ "1", "0", "null", "\"true\"", "[]" }) |bad| {
550 const json = try completeConfig(testing.allocator, "\"enableCamera\":" ++ bad);
551 defer testing.allocator.free(json);
552 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, json).count());
553 }
554}
555
556test "an unknown key is ignored, and devServerURL may be absent or null" {
557 const with_extra = try completeConfig(testing.allocator, "\"somethingNewer\":42");
558 defer testing.allocator.free(with_extra);
559 try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, with_extra).count());
560
561 // `devServerURL` is the one `String?` in the struct, so it is the one key
562 // whose absence is not a throw.
563 const with_null = try completeConfig(testing.allocator, "\"devServerURL\":null");
564 defer testing.allocator.free(with_null);
565 try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, with_null).count());
566
567 const with_bad = try completeConfig(testing.allocator, "\"devServerURL\":7");
568 defer testing.allocator.free(with_bad);
569 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, with_bad).count());
570}
571
572test "trustedOrigins is checked element by element" {
573 // `[String]` throws on the first non-string element, so an array that is
574 // mostly strings still disables the app.
575 const bad = try completeConfig(testing.allocator, "\"trustedOrigins\":[\"https://a\",3]");
576 defer testing.allocator.free(bad);
577 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bad).count());
578
579 const good = try completeConfig(testing.allocator, "\"trustedOrigins\":[\"https://a\"]");
580 defer testing.allocator.free(good);
581 try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, good).count());
582}
583
584test "malformed or non-object JSON is all-false rather than a crash" {
585 inline for (.{ "", "{", "[]", "null", "\"a string\"", "{\"appName\":}" }) |bad| {
586 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bad).count());
587 }
588}
589
590test "every capability key is spelled the way Swift spells it" {
591 // A key that does not exist in the file reads as missing, and a missing key
592 // disables all 35 — so a single typo here is not a one-flag bug, it is an
593 // app that serves nothing. The four irregular ones are the risk:
594 // camel-casing the enum member would produce `enableNfc`, `enableAr`,
595 // `enablePdfViewer` and `enableMlKit`, none of which are in the file.
596 try testing.expectEqualStrings("enableNFC", Feature.nfc.jsonKey());
597 try testing.expectEqualStrings("enableAR", Feature.ar.jsonKey());
598 try testing.expectEqualStrings("enablePDFViewer", Feature.pdf_viewer.jsonKey());
599 try testing.expectEqualStrings("enableMLKit", Feature.ml_kit.jsonKey());
600
601 @setEvalBranchQuota(10_000);
602 inline for (comptime std.enums.values(Feature)) |feature| {
603 const key = feature.jsonKey();
604 try testing.expect(std.mem.startsWith(u8, key, "enable"));
605 // Distinctness: two members sharing a key would silently make one of
606 // them read the other's flag.
607 inline for (comptime std.enums.values(Feature)) |other| {
608 if (feature != other) try testing.expect(!std.mem.eql(u8, key, other.jsonKey()));
609 }
610 }
611}
612
613test "gateFor answers for a served action and stays quiet for the rest" {
614 try testing.expectEqual(Feature.clipboard, gateFor("clipboardRead").?);
615 try testing.expectEqual(Feature.camera, gateFor("pickImage").?);
616 try testing.expectEqual(Feature.secure_storage, gateFor("secureSet").?);
617
618 // `openURL` is served and ungated in the spec; `startAR` is gated but not
619 // served here, so the shim's own gate applies and this must not answer.
620 try testing.expect(gateFor("openURL") == null);
621 try testing.expect(gateFor("startAR") == null);
622 try testing.expect(gateFor("") == null);
623}
624
625test "a duplicated key keeps the first value, as Swift does" {
626 // Zig's default for a repeated field is `error.DuplicateField`, which would
627 // have disabled every capability over a config that pastes one flag twice.
628 // Swift reads the first and carries on — verified with swiftc — so the
629 // parse opts into `.use_first`. Without that option this test fails, which
630 // is the point of having it.
631 const json = try completeConfig(testing.allocator, "\"enableShare\":false");
632 defer testing.allocator.free(json);
633
634 const at = std.mem.indexOf(u8, json, "\"enableShare\":false").?;
635 const with_dup = try std.mem.concat(testing.allocator, u8, &.{
636 json[0 .. at + "\"enableShare\":false".len],
637 ",\"enableShare\":true",
638 json[at + "\"enableShare\":false".len ..],
639 });
640 defer testing.allocator.free(with_dup);
641
642 const f = parse(testing.allocator, with_dup);
643 try testing.expect(!f.contains(.share)); // first wins, not last
644 try testing.expectEqual(std.enums.values(Feature).len - 1, f.count());
645}
646
647test "the config file read distinguishes absent from unreadable" {
648 // Swift's `try?` flattens both into the same all-false. This does too, at
649 // the gate — but the read itself keeps them apart, because "no config was
650 // generated" and "the config is there and I could not open it" call for
651 // different fixes and only one of them is normal.
652 const io = io_context.get();
653 const missing = try readConfigFile(testing.allocator, "craft-config-does-not-exist.json");
654 try testing.expect(missing == null);
655
656 const name = "craft-config-read-probe.json";
657 const file = try std.Io.Dir.cwd().createFile(io, name, .{});
658 defer std.Io.Dir.cwd().deleteFile(io, name) catch {};
659 {
660 defer file.close(io);
661 try file.writeStreamingAll(io, "{\"appName\":\"T\"}");
662 }
663
664 const bytes = (try readConfigFile(testing.allocator, name)).?;
665 defer testing.allocator.free(bytes);
666 try testing.expectEqualStrings("{\"appName\":\"T\"}", bytes);
667
668 // And a real file that is not a config is all-false rather than a crash.
669 try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bytes).count());
670}
packages/zig/src/ios_dispatch.zigmodified+23-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -6,6 +6,7 @@ const bridge_error = ;
66pub const ios_async = ;
77pub const ios_events = ;
88pub const ios_delegate = ;
9pub const ios_config = ;
910const bridge_mobile = ;
1011const bridge_mobile_clipboard = ;
1112const bridge_mobile_haptics = ;
@@ -183,6 +184,28 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 {
183184/// diagnostic a bridge can give.
184185fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void {
185186 if (std.mem.eql(u8, msg_type, "mobile")) {
187 // The app's own configuration, before anything else. An action the
188 // spec gates on a `config.enable*` flag is refused here when that flag
189 // is off, rather than served — which is what every one of these did
190 // until `ios_config.zig` existed, because Zig had no way to read the
191 // flag.
192 //
193 // Only actions this file's modules serve are gated. `gateFor` returns
194 // null for the rest, so an action still on the shim keeps whatever
195 // answer the shim gives it: Zig refusing on Swift's behalf would mean
196 // deciding, from Zig's parse of the config, a question the arm that
197 // owns the action is about to decide from its own.
198 if (ios_config.gateFor(action)) |feature| {
199 if (!ios_config.isEnabled(feature)) {
200 std.log.info(
201 "ios: refusing {s}; {s} is not enabled in craft.config.json",
202 .{ action, feature.jsonKey() },
203 );
204 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.CapabilityDisabled);
205 return bridge_error.BridgeError.CapabilityDisabled;
206 }
207 }
208
186209 // First module that recognises the action wins. UnknownAction means
187210 // "not mine, ask the next"; any other error means a handler ran and
188211 // failed, and its answer is final — retrying the same action against
packages/zig/test/ios_conformance_test.zigmodified+362-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -93,7 +93,7 @@ const dispatch_end = "func webView(";
9393/// 48 after calendar and contacts. Speech was researched in full and left
9494/// with the shim: neither action replies, so unlike the earlier deferrals
9595/// there is no promise to strand and falling through costs the page nothing.
96const max_not_yet_migrated: usize = 48;
96const max_not_yet_migrated: usize = 46;
9797
9898fn dispatcherRegion() []const u8 {
9999 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
@@ -277,3 +277,364 @@ test "every action Zig declares is one the spec actually has" {
277277 }
278278 }
279279}
280
281// ---------------------------------------------------------------------------
282// The capability gate
283//
284// Every action in the spec's dispatcher is guarded by a `config.enable*` flag,
285// and until `src/ios_config.zig` existed Zig could not read one — so 32 actions
286// it already served were served unconditionally, whatever the app had been
287// configured to allow. The table in `gateFor` closes that, and the four tests
288// below are what stop it from being a hand-written list that drifts.
289//
290// The scans are textual, which is the same shape as the action scan above and
291// carries the same hazard: a needle that stops matching turns a real check into
292// a vacuous one. Each has an explicit floor for that reason.
293// ---------------------------------------------------------------------------
294
295const zig_config_source = @embedFile("src/ios_config.zig");
296
297/// `case "action":` -> the `config.X` flag guarding it, for the whole
298/// dispatcher.
299///
300/// A case block runs to the next `case "` at any indent, so the flag found is
301/// the one inside *this* case rather than the next one's. Reading a fixed
302/// number of following lines instead would attribute `stopListening`, which is
303/// ungated, to the `enableHaptics` of the `haptic` case below it.
304fn collectSpecGates(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) {
305 var map = std.StringHashMap([]const u8).init(allocator);
306 errdefer map.deinit();
307
308 const region = dispatcherRegion();
309 var search: usize = 0;
310 while (std.mem.indexOfPos(u8, region, search, "case \"")) |at| {
311 const name_start = at + "case \"".len;
312 const name_end = std.mem.indexOfScalarPos(u8, region, name_start, '"') orelse break;
313 const action = region[name_start..name_end];
314 search = name_end;
315
316 const block_end = if (std.mem.indexOfPos(u8, region, name_end, "case \"")) |next|
317 next
318 else
319 region.len;
320 const block = region[name_end..block_end];
321
322 const needle = "if config.";
323 if (std.mem.indexOf(u8, block, needle)) |flag_at| {
324 const flag_start = flag_at + needle.len;
325 var flag_end = flag_start;
326 while (flag_end < block.len and (std.ascii.isAlphanumeric(block[flag_end]) or
327 block[flag_end] == '_')) : (flag_end += 1)
328 {}
329 try map.put(action, block[flag_start..flag_end]);
330 }
331 }
332 return map;
333}
334
335/// `.{ "action", .feature },` from `gateFor`'s table, paired with the JSON key
336/// that `feature` maps to in `jsonKey`.
337fn collectZigGates(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) {
338 var map = std.StringHashMap([]const u8).init(allocator);
339 errdefer map.deinit();
340
341 const table_start = std.mem.indexOf(u8, zig_config_source, "const table = comptime") orelse
342 return error.GateTableNotFound;
343 const table_end = std.mem.indexOfPos(u8, zig_config_source, table_start, "\n };") orelse
344 return error.GateTableNotFound;
345 const table = zig_config_source[table_start..table_end];
346
347 var it = std.mem.splitScalar(u8, table, '\n');
348 while (it.next()) |line| {
349 const trimmed = std.mem.trim(u8, line, " \t\r");
350 if (!std.mem.startsWith(u8, trimmed, ".{ \"")) continue;
351
352 const name_start = std.mem.indexOfScalar(u8, trimmed, '"').? + 1;
353 const name_end = std.mem.indexOfScalarPos(u8, trimmed, name_start, '"').?;
354 const action = trimmed[name_start..name_end];
355
356 const dot = std.mem.indexOfScalarPos(u8, trimmed, name_end, '.') orelse continue;
357 var member_end = dot + 1;
358 while (member_end < trimmed.len and (std.ascii.isAlphanumeric(trimmed[member_end]) or
359 trimmed[member_end] == '_')) : (member_end += 1)
360 {}
361 const member = trimmed[dot + 1 .. member_end];
362
363 try map.put(action, try jsonKeyOf(member));
364 }
365 return map;
366}
367
368/// The `.member => "enableX",` arm of `jsonKey`, read from the source.
369fn jsonKeyOf(member: []const u8) ![]const u8 {
370 const body_start = std.mem.indexOf(u8, zig_config_source, "pub fn jsonKey(") orelse
371 return error.JsonKeyFnNotFound;
372 const body = zig_config_source[body_start..];
373
374 var it = std.mem.splitScalar(u8, body, '\n');
375 while (it.next()) |line| {
376 const trimmed = std.mem.trim(u8, line, " \t\r");
377 if (!std.mem.startsWith(u8, trimmed, ".")) continue;
378 const arrow = std.mem.indexOf(u8, trimmed, " => \"") orelse continue;
379 if (!std.mem.eql(u8, trimmed[1..arrow], member)) continue;
380
381 const key_start = arrow + " => \"".len;
382 const key_end = std.mem.indexOfScalarPos(u8, trimmed, key_start, '"') orelse continue;
383 return trimmed[key_start..key_end];
384 }
385 return error.MemberHasNoJsonKey;
386}
387
388/// Every `var name: Bool` in `struct CraftConfig`.
389fn collectSpecConfigBools(allocator: std.mem.Allocator) !std.StringHashMap(void) {
390 var set = std.StringHashMap(void).init(allocator);
391 errdefer set.deinit();
392
393 const at = std.mem.indexOf(u8, swift_spec, "struct CraftConfig: Codable {") orelse
394 return error.CraftConfigNotFound;
395 const rest = swift_spec[at..];
396 const end = std.mem.indexOf(u8, rest, "\n}") orelse rest.len;
397
398 var it = std.mem.splitScalar(u8, rest[0..end], '\n');
399 while (it.next()) |line| {
400 const trimmed = std.mem.trim(u8, line, " \t\r");
401 if (!std.mem.startsWith(u8, trimmed, "var ")) continue;
402 const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
403 const type_part = std.mem.trim(u8, trimmed[colon + 1 ..], " \t\r");
404 if (!std.mem.startsWith(u8, type_part, "Bool")) continue;
405 try set.put(std.mem.trim(u8, trimmed["var ".len..colon], " \t\r"), {});
406 }
407 return set;
408}
409
410test "the gate scans find something, so the subset checks below mean something" {
411 // The vacuity floor. Every assertion after this is "for each X, ..."; a
412 // scan that matched nothing would satisfy all of them silently. The numbers
413 // are floors rather than equalities so migrating an action does not have to
414 // edit this test — but a rename that breaks a needle drops the count to
415 // zero and fails here.
416 var spec_gates = try collectSpecGates(testing.allocator);
417 defer spec_gates.deinit();
418 var zig_gates = try collectZigGates(testing.allocator);
419 defer zig_gates.deinit();
420 var config_bools = try collectSpecConfigBools(testing.allocator);
421 defer config_bools.deinit();
422
423 try testing.expect(spec_gates.count() >= 60);
424 try testing.expect(zig_gates.count() >= 30);
425 try testing.expect(config_bools.count() >= 35);
426
427 // And the scans read what they claim to read, rather than something that
428 // merely has the right shape.
429 try testing.expectEqualStrings("enableClipboard", spec_gates.get("clipboardRead").?);
430 try testing.expectEqualStrings("enableClipboard", zig_gates.get("clipboardRead").?);
431 try testing.expect(config_bools.contains("enableClipboard"));
432}
433
434test "every gated action Zig serves consults the flag the spec gates it on" {
435 // The check the 32 table entries exist for. Before `ios_config.zig`, every
436 // one of these was served whatever the app's configuration said; a new
437 // migration that forgets its gate lands back in that state, and this is
438 // what refuses it.
439 var spec_gates = try collectSpecGates(testing.allocator);
440 defer spec_gates.deinit();
441 var zig_gates = try collectZigGates(testing.allocator);
442 defer zig_gates.deinit();
443 var zig_actions = try collectZigActions(testing.allocator);
444 defer zig_actions.deinit();
445
446 var missing: usize = 0;
447 var disagreeing: usize = 0;
448
449 var it = zig_actions.keyIterator();
450 while (it.next()) |action| {
451 const spec_flag = spec_gates.get(action.*) orelse continue;
452 const zig_flag = zig_gates.get(action.*) orelse {
453 std.debug.print(
454 "'{s}' is gated on config.{s} in the spec, and Zig serves it ungated.\n" ++
455 " Add it to ios_config.gateFor's table.\n",
456 .{ action.*, spec_flag },
457 );
458 missing += 1;
459 continue;
460 };
461 if (!std.mem.eql(u8, spec_flag, zig_flag)) {
462 std.debug.print(
463 "'{s}' is gated on config.{s} in the spec but on {s} in Zig.\n",
464 .{ action.*, spec_flag, zig_flag },
465 );
466 disagreeing += 1;
467 }
468 }
469
470 try testing.expectEqual(@as(usize, 0), missing);
471 try testing.expectEqual(@as(usize, 0), disagreeing);
472}
473
474test "the gate table has no entry for an action Zig does not serve" {
475 // A stale entry is not harmless. `route` consults `gateFor` before the
476 // module chain, so an entry for an action that has moved back to the shim
477 // would have Zig refuse it on the shim's behalf — deciding, from Zig's
478 // parse of the config, a question the arm that owns the action is about to
479 // decide from its own.
480 var zig_gates = try collectZigGates(testing.allocator);
481 defer zig_gates.deinit();
482 var zig_actions = try collectZigActions(testing.allocator);
483 defer zig_actions.deinit();
484 var spec_gates = try collectSpecGates(testing.allocator);
485 defer spec_gates.deinit();
486
487 var it = zig_gates.keyIterator();
488 while (it.next()) |action| {
489 if (!zig_actions.contains(action.*)) {
490 std.debug.print("gateFor has '{s}', which no mobile module serves.\n", .{action.*});
491 return error.StaleGateEntry;
492 }
493 if (!spec_gates.contains(action.*)) {
494 std.debug.print(
495 "gateFor gates '{s}', which the spec does not gate — Zig would refuse " ++
496 "a call the Swift app allows.\n",
497 .{action.*},
498 );
499 return error.GateNotInSpec;
500 }
501 }
502}
503
504test "every flag Zig can read is a flag the spec's config actually has" {
505 // A misspelled key is not a one-flag bug. A key that is not in the file
506 // reads as missing, and one missing key makes the whole decode throw, so
507 // `enableMlKit` for `enableMLKit` would disable all 35 capabilities at once
508 // — in an app whose config is perfectly valid.
509 var config_bools = try collectSpecConfigBools(testing.allocator);
510 defer config_bools.deinit();
511
512 const marker = "pub fn jsonKey(";
513 const body_start = std.mem.indexOf(u8, zig_config_source, marker).?;
514 const body = zig_config_source[body_start..];
515 const body_end = std.mem.indexOf(u8, body, "\n }").?;
516
517 var checked: usize = 0;
518 var it = std.mem.splitScalar(u8, body[0..body_end], '\n');
519 while (it.next()) |line| {
520 const trimmed = std.mem.trim(u8, line, " \t\r");
521 if (!std.mem.startsWith(u8, trimmed, ".")) continue;
522 if (std.mem.indexOf(u8, trimmed, " => \"") == null) continue;
523
524 const key_start = std.mem.indexOfScalar(u8, trimmed, '"').? + 1;
525 const key_end = std.mem.indexOfScalarPos(u8, trimmed, key_start, '"').?;
526 const key = trimmed[key_start..key_end];
527
528 if (!config_bools.contains(key)) {
529 std.debug.print(
530 "ios_config spells '{s}', which is not a Bool in the spec's CraftConfig.\n" ++
531 " A key that is not in the file disables every capability, not this one.\n",
532 .{key},
533 );
534 return error.UnknownConfigKey;
535 }
536 checked += 1;
537 }
538 try testing.expect(checked >= 35);
539}
540
541/// Every key `ios_config.zig` will decode: the three literal lists plus every
542/// `Feature`'s json key.
543///
544/// Built from the declarations rather than by searching the whole file, so a
545/// key that appears only in a doc comment does not count as mirrored. That
546/// distinction is the whole value of this scan: the header prose names most of
547/// these keys, so a whole-file search would pass for a key nothing decodes.
548fn collectZigRequiredKeys(allocator: std.mem.Allocator) !std.StringHashMap(void) {
549 var set = std.StringHashMap(void).init(allocator);
550 errdefer set.deinit();
551
552 inline for (.{ "const string_keys", "const extra_bool_keys", "const array_of_string_keys" }) |decl| {
553 const at = std.mem.indexOf(u8, zig_config_source, decl) orelse return error.KeyListNotFound;
554 const line_end = std.mem.indexOfScalarPos(u8, zig_config_source, at, '\n').?;
555 const line = zig_config_source[at..line_end];
556
557 var search: usize = 0;
558 while (std.mem.indexOfScalarPos(u8, line, search, '"')) |open| {
559 const close = std.mem.indexOfScalarPos(u8, line, open + 1, '"') orelse break;
560 try set.put(line[open + 1 .. close], {});
561 search = close + 1;
562 }
563 }
564
565 const body_start = std.mem.indexOf(u8, zig_config_source, "pub fn jsonKey(") orelse
566 return error.JsonKeyFnNotFound;
567 const body = zig_config_source[body_start..];
568 const body_end = std.mem.indexOf(u8, body, "\n }").?;
569 var it = std.mem.splitScalar(u8, body[0..body_end], '\n');
570 while (it.next()) |line| {
571 const trimmed = std.mem.trim(u8, line, " \t\r");
572 if (!std.mem.startsWith(u8, trimmed, ".")) continue;
573 if (std.mem.indexOf(u8, trimmed, " => \"") == null) continue;
574 const open = std.mem.indexOfScalar(u8, trimmed, '"').?;
575 const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"').?;
576 try set.put(trimmed[open + 1 .. close], {});
577 }
578 return set;
579}
580
581test "Zig requires exactly the keys the spec's decoder requires" {
582 // The faithfulness the all-or-nothing rule depends on. Swift's synthesized
583 // `init(from:)` calls `decode` for every non-optional stored property and
584 // throws when one is missing, so the two runtimes agree about a given file
585 // only while they require the same set. When `CraftConfig` grows a key this
586 // fails until `ios_config.zig` grows it too — otherwise Swift refuses a
587 // config Zig accepts, in one process, from one file.
588 var zig_keys = try collectZigRequiredKeys(testing.allocator);
589 defer zig_keys.deinit();
590
591 const at = std.mem.indexOf(u8, swift_spec, "struct CraftConfig: Codable {").?;
592 const rest = swift_spec[at..];
593 const end = std.mem.indexOf(u8, rest, "\n}").?;
594
595 var required: usize = 0;
596 var optional_seen: usize = 0;
597 var it = std.mem.splitScalar(u8, rest[0..end], '\n');
598 while (it.next()) |line| {
599 const trimmed = std.mem.trim(u8, line, " \t\r");
600 if (!std.mem.startsWith(u8, trimmed, "var ")) continue;
601 const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
602 const name = std.mem.trim(u8, trimmed["var ".len..colon], " \t\r");
603
604 const after_colon = trimmed[colon + 1 ..];
605 const type_end = std.mem.indexOf(u8, after_colon, " =") orelse after_colon.len;
606 const type_name = std.mem.trim(u8, after_colon[0..type_end], " \t\r");
607
608 // An optional is decoded with `decodeIfPresent`, so its absence is not
609 // a throw and Zig must NOT require it.
610 if (std.mem.endsWith(u8, type_name, "?")) {
611 optional_seen += 1;
612 if (zig_keys.contains(name)) {
613 std.debug.print(
614 "ios_config requires '{s}', which is optional in the spec — Zig would " ++
615 "refuse a config Swift accepts.\n",
616 .{name},
617 );
618 return error.OptionalKeyTreatedAsRequired;
619 }
620 continue;
621 }
622
623 required += 1;
624 if (!zig_keys.contains(name)) {
625 std.debug.print(
626 "the spec's CraftConfig requires '{s}' and ios_config.zig does not decode it.\n" ++
627 " Swift throws on the missing key and falls back to every flag false; " ++
628 "Zig would accept the same file.\n",
629 .{name},
630 );
631 return error.RequiredKeyNotMirrored;
632 }
633 }
634
635 // Both directions: a key Zig requires that the spec does not have would
636 // make Zig refuse every config the generator writes.
637 try testing.expectEqual(required, zig_keys.count());
638 try testing.expect(required >= 40);
639 try testing.expect(optional_seen >= 1);
640}