ReviewOS

also looking at this

craft-native/craft

fix(sdk): the event map typed one event that never fires and missed six that do

#133
Closed glennmichael123 wants to merge fix/sdk-event-map into docs/ios-gate-claims-expired
18 files +562 -163
packages/typescript/types/craft.d.tsmodified+81-9
Changes to packages/typescript/types/craft.d.ts
@@ -1145,13 +1145,33 @@ export interface PickedFile {
11451145 mimeType?: string;
11461146}
11471147
1148/**
1149 * A location fix, exactly as the bridge sends it.
1150 *
1151 * All eight fields are always present. The native dictionary is built
1152 * unconditionally from `CLLocation`'s non-optional properties, so there is no
1153 * path that omits one — and this type previously declared six of them, three
1154 * of those optional, which left `altitudeAccuracy` and `timestamp` unreachable
1155 * from TypeScript even though every fix carries them.
1156 *
1157 * `heading` and `speed` are `-1` when the device has no fix for them, which is
1158 * CoreLocation's own sentinel rather than an absent key. Android has no
1159 * geolocation surface, so nothing else fills this in.
1160 */
11481161export interface Position {
11491162 latitude: number;
11501163 longitude: number;
1164 /** Horizontal accuracy in metres. */
11511165 accuracy: number;
1152 altitude?: number;
1153 speed?: number;
1154 heading?: number;
1166 altitude: number;
1167 /** Vertical accuracy in metres. */
1168 altitudeAccuracy: number;
1169 /** Course over ground in degrees, or `-1` when unavailable. */
1170 heading: number;
1171 /** Metres per second, or `-1` when unavailable. */
1172 speed: number;
1173 /** Milliseconds since the Unix epoch. */
1174 timestamp: number;
11551175}
11561176
11571177export interface Contact {
@@ -1378,12 +1398,29 @@ export interface CraftARPlaneEvent extends CustomEvent {
13781398 detail: ARPlaneEvent;
13791399}
13801400
1401/**
1402 * A home-screen quick action being tapped.
1403 *
1404 * **Never dispatched today.** `craft.shortcuts.set()` really does install the
1405 * items and they appear in the long-press menu, but no template implements
1406 * `application(_:performActionFor:completionHandler:)` or a scene-delegate
1407 * equivalent, so a tap launches the app and stops there. A listener registered
1408 * for this is never called.
1409 */
13811410export interface CraftShortcutEvent extends CustomEvent {
13821411 detail: {
13831412 type: string;
13841413 };
13851414}
13861415
1416/**
1417 * A donated Siri shortcut being invoked.
1418 *
1419 * **Never dispatched today.** `craft.siri.register()` donates an
1420 * `NSUserActivity`, but the only `onContinueUserActivity` in the template is
1421 * bound to `NSUserActivityTypeBrowsingWeb` and routes to the deep-link
1422 * handler, so invoking a donated shortcut relaunches the app and is dropped.
1423 */
13871424export interface CraftSiriShortcutEvent extends CustomEvent {
13881425 detail: SiriInvocationEvent;
13891426}
@@ -1402,13 +1439,38 @@ export interface CraftWatchContextEvent extends CustomEvent {
14021439 detail: Record<string, any>;
14031440}
14041441
1405export interface CraftVoiceActionEvent extends CustomEvent {
1442export interface CraftLocationUpdateEvent extends CustomEvent {
1443 detail: Position;
1444}
1445
1446export interface CraftLocationErrorEvent extends CustomEvent {
14061447 detail: {
1407 action: string;
1408 data?: string;
1448 message: string;
14091449 };
14101450}
14111451
1452export interface CraftNetworkChangeEvent extends CustomEvent {
1453 detail: NetworkStatus;
1454}
1455
1456export interface CraftPushTokenEvent extends CustomEvent {
1457 detail: {
1458 token: string;
1459 };
1460}
1461
1462/**
1463 * The `userInfo` of the notification the user acted on, forwarded verbatim.
1464 * The bridge does not reshape it, so its keys are whatever was scheduled.
1465 */
1466export interface CraftNotificationResponseEvent extends CustomEvent {
1467 detail: Record<string, any>;
1468}
1469
1470export interface CraftWatchUserInfoEvent extends CustomEvent {
1471 detail: Record<string, any>;
1472}
1473
14121474export interface CraftErrorEvent extends CustomEvent {
14131475 detail: CraftError;
14141476}
@@ -1428,14 +1490,24 @@ declare global {
14281490 craftMotionUpdate: CraftMotionUpdateEvent;
14291491 craftBluetoothDevice: CraftBluetoothDeviceEvent;
14301492 craftARPlane: CraftARPlaneEvent;
1431 craftShortcut: CraftShortcutEvent;
1432 craftSiriShortcut: CraftSiriShortcutEvent;
1493 craftLocationUpdate: CraftLocationUpdateEvent;
1494 craftLocationError: CraftLocationErrorEvent;
1495 craftNetworkChange: CraftNetworkChangeEvent;
1496 craftPushToken: CraftPushTokenEvent;
1497 craftNotificationResponse: CraftNotificationResponseEvent;
14331498 craftWatchMessage: CraftWatchMessageEvent;
14341499 craftWatchReachability: CraftWatchReachabilityEvent;
14351500 craftWatchContext: CraftWatchContextEvent;
1436 craftVoiceAction: CraftVoiceActionEvent;
1501 craftWatchUserInfo: CraftWatchUserInfoEvent;
14371502 craftError: CraftErrorEvent;
14381503 craftDeepLink: CraftDeepLinkEvent;
1504 // Kept, and kept last, because nothing dispatches either one: the app
1505 // installs the shortcuts and donates the activities, and no template
1506 // implements the callback that would deliver a tap. Typed so the listener
1507 // an app writes today compiles against the shape it will receive if that
1508 // lands; see the note on each interface.
1509 craftShortcut: CraftShortcutEvent;
1510 craftSiriShortcut: CraftSiriShortcutEvent;
14391511 }
14401512}
14411513
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -408,6 +408,9 @@ pub fn build(b: *std.Build) void {
408408 ios_conformance_tests.root_module.addAnonymousImport("src/ios_events.zig", .{
409409 .root_source_file = b.path("src/ios_events.zig"),
410410 });
411 ios_conformance_tests.root_module.addAnonymousImport("craft.d.ts", .{
412 .root_source_file = b.path("../typescript/types/craft.d.ts"),
413 });
411414 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{
412415 .root_source_file = b.path("src/bridge_mobile_vision.zig"),
413416 });
packages/zig/src/bridge_mobile_bgtasks.zigmodified+7-4
Changes to packages/zig/src/bridge_mobile_bgtasks.zig
@@ -103,10 +103,13 @@
103103//! posting); a field that is **present with the wrong type** is refused, never
104104//! coerced.
105105//!
106//! **The `config.enableBackgroundTasks` gate has no Zig mirror.** It appears
107//! nowhere in `packages/zig/src`, so the three actions here are served
108//! unconditionally — the securestore and notifcancel modules document the same
109//! choice. In an app that left the gate at its default `false` this changes a
106//! **The `config.enableBackgroundTasks` gate is read, not skipped.**
107//! `ios_config.gateFor` maps all three actions to `.background_tasks`, so
108//! `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` before this
109//! module is asked. This paragraph used to say the flag "appears nowhere in
110//! `packages/zig/src`" and that the actions were served unconditionally — true
111//! when written, false since `ios_config.zig`, and repeated in eight other
112//! module headers that were equally true and are equally false. In an app that left the gate at its default `false` this changes a
110113//! hang into an answer: `schedule` now reaches the scheduler and reports
111114//! whatever it says (a rejection, until the plist gains
112115//! `BGTaskSchedulerPermittedIdentifiers`), and the cancels report the no-op
packages/zig/src/bridge_mobile_calendar.zigmodified+11-4
Changes to packages/zig/src/bridge_mobile_calendar.zig
@@ -139,10 +139,17 @@
139139//! `deleteCalendarEvent` — which requests nothing (3350) — the key is only
140140//! evidence of `config.enableCalendar`, and it is checked for that reason.
141141//!
142//! **`config.enableCalendar` has no Zig mirror**, the same gap
143//! `bridge_mobile_contactpicker.zig` records for `enableContacts`. The plist key
144//! `packages/ios/src/index.ts:190` writes iff that flag is the stand-in, and the
145//! mapping is exact — nothing else writes or shares it.
142//! **`config.enableCalendar` is read directly now.** `ios_config.gateFor` maps
143//! all three actions to `.calendar`, so `ios_dispatch.offerToModules` answers
144//! `CAPABILITY_DISABLED` before this module is asked. This used to say the flag
145//! had no Zig mirror, which was true until `ios_config.zig` read
146//! `craft.config.json`.
147//!
148//! The plist key `packages/ios/src/index.ts:190` writes iff that flag was the
149//! stand-in, and on the one path where it is *only* evidence of the flag rather
150//! than a precondition of the API it is now redundant with the real gate.
151//! Tracked in issue #131; the paths where the key is a genuine precondition are
152//! separated below and stay.
146153//!
147154//! **Behaviour changes on every disabled or malformed path, in Zig's favour.**
148155//! Swift's three cases have no `else` (717-731): an app with
packages/zig/src/bridge_mobile_contactpicker.zigmodified+14-6
Changes to packages/zig/src/bridge_mobile_contactpicker.zig
@@ -83,12 +83,20 @@
8383//! Until then, "the native call failed" is what a cancel says, and saying so
8484//! here is the point.
8585//!
86//! **`config.enableContacts` has no Zig mirror.** `ios.zig`'s `AppConfig` has
87//! no such field — the same gap `bridge_mobile_system.zig` records for
88//! `enableShare`. The Info.plist is read instead, and for this flag the mapping
89//! is exact: `packages/ios/src/index.ts:189` writes `NSContactsUsageDescription`
90//! if and only if `config.enableContacts`, with none of the sharing that made
91//! the location keys ambiguous.
86//! **`config.enableContacts` is read directly now, and the plist proxy below
87//! is what is left over.** `ios_config.gateFor` maps this action to
88//! `.contacts`, so `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED`
89//! before this module is asked. The paragraph here used to say the flag had no
90//! Zig mirror, which was true until `ios_config.zig` read
91//! `craft.config.json`.
92//!
93//! The `NSContactsUsageDescription` check further down was the stand-in for
94//! that flag — `packages/ios/src/index.ts:189` writes the key if and only if
95//! `config.enableContacts`, with none of the sharing that made the location
96//! keys ambiguous. It is now a second, weaker gate behind the real one, and it
97//! can only differ in the direction that refuses an action the config allows:
98//! an app whose Info.plist was not written by the SDK. Tracked in issue #131
99//! along with three more of the same shape.
92100//!
93101//! Two things about that gate have to be said plainly. First, the key is **not
94102//! a precondition of the API**: `CNContactPickerViewController` runs out of
packages/zig/src/bridge_mobile_filepicker.zigmodified+6-5
Changes to packages/zig/src/bridge_mobile_filepicker.zig
@@ -74,11 +74,12 @@
7474//! Info.plist key to read instead: `renderUsageDescriptions`
7575//! (`packages/ios/src/index.ts:183-199`) writes nothing from either flag.
7676//!
77//! Both are therefore served unconditionally, and an app that set
78//! `enableFilePicker: false` or `enableFileDownload: false` now gets the
79//! behaviour anyway. Restoring the gate means adding the fields to
80//! `AppConfig`, not adding a guess here — the same trade
81//! `bridge_mobile_system.zig` records word for word for `share`.
77//! Both flags are read directly now: `ios_config.gateFor` maps `pickFile` to
78//! `.file_picker` and `saveFile` to `.file_download`, so
79//! `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` before this
80//! module is asked. This used to say both were served unconditionally and that
81//! restoring the gate meant adding fields to `AppConfig` — true when written,
82//! and answered by `ios_config.zig` reading `craft.config.json` instead.
8283//!
8384//! Falling through to the shim is not the safe option it was for
8485//! `takeScreenshot`, because the shim does not answer these correctly today:
packages/zig/src/bridge_mobile_haptics.zigmodified+117-72
Changes to packages/zig/src/bridge_mobile_haptics.zig
@@ -1,10 +1,34 @@
11//! The haptics half of the `mobile` namespace: `haptic` and `vibrate`.
22//!
33//! Split out of `bridge_mobile.zig` because the two actions have opposite
4//! contracts — `haptic` is fire-and-forget and gated on a config flag, while
5//! `vibrate` replies and is gated on nothing — and because one of them cannot
6//! be served honestly from Zig yet. Keeping that admission next to the working
7//! action, rather than folded into the namespace's main file, is the point.
4//! contracts: `haptic` is fire-and-forget and gated on `config.enableHaptics`,
5//! `vibrate` replies and is gated on nothing.
6//!
7//! ## The refusal that used to live here
8//!
9//! `haptic` was declared `.status = .unavailable` for one reason, quoted from
10//! the manifest it carried: the flag "is not visible to Zig; serving it here
11//! would enable haptics for every app that never opted in". That was exactly
12//! right when it was written. The Taptic Engine has no readback, so a flip
13//! would have been invisible in CI and obvious on a device — and the default
14//! config has `enableHaptics` false, so the blast radius was every app that
15//! had not opted in.
16//!
17//! The condition that reason set for itself has since been met, by work that
18//! was not looking at this file. `ios_config.zig` reads `craft.config.json`
19//! out of the bundle, `gateFor("haptic")` maps the action to `.haptics`, and
20//! `ios_dispatch.zig` refuses any gated action whose flag is off *before* the
21//! module chain runs. The precise hazard the refusal was protecting against —
22//! an app on the default config buzzing on `craft.haptics.impact()` — is now
23//! the one case that cannot happen: the gate rejects it with
24//! `CAPABILITY_DISABLED`, which is what the spec's `else` arm sends too.
25//!
26//! Nothing re-checked that. The reason sat here, correct on the day it was
27//! written and wrong for every day after `ios_config.zig` landed, and the only
28//! way it surfaced was someone reading it again. `ios_conformance_test.zig`
29//! now fails the build for a gated action declared `.unavailable`, because a
30//! module claiming both "Zig can enforce this gate" and "Zig cannot read this
31//! gate" is stating a contradiction that a test can see.
832
933const std = ;
1034const builtin = ;
@@ -25,53 +49,46 @@ pub const A = struct {
2549 pub const vibrate = "vibrate";
2650};
2751
28/// `haptic` is declared `unavailable`, and that is a decision, not an oversight.
29///
30/// The Swift it replaces does not run `triggerHaptic` unconditionally — it runs
31/// it `if config.enableHaptics` (`CraftApp.swift:537`), and that flag defaults
32/// to **false** in both the template (`CraftApp.swift:190`) and the SDK
33/// (`packages/ios/src/index.ts:118`). It reaches Zig through nothing: no
34/// `craft_ios_*` export carries it, no build option encodes it. So serving
35/// `haptic` from here means serving a *different action* from the one the spec
36/// defines — every app on the default config would start buzzing on
37/// `craft.haptics.impact()`, which is the entire shipped haptics surface, and
38/// no test in this repo could see it because the Taptic Engine has no readback.
52/// The whole of `haptic`'s decision: `body["style"] as? String ?? "medium"`,
53/// then the spec's switch.
3954///
40/// The three ways to make that go away are all worse than saying so:
41/// - fire anyway → a behaviour change that is invisible in CI and obvious on
42/// a device;
43/// - hardcode the gate false → a permanent no-op that reports nothing, which
44/// is the shape of the nine Android handlers that were deleted;
45/// - reply `{"success":true}` → fabricated success, the thing this migration
46/// exists to stop.
47///
48/// So the handler refuses and the manifest says why. Unblocking it is small and
49/// deliberate: plumb the flag in (a `craft_ios_set_capabilities` export or a
50/// build option), then swap the refusal in `haptic()` for the mapping and the
51/// trigger below, both of which are written and pinned by the tests at the
52/// bottom of this file. What must not happen is the flip happening silently.
53///
54/// Note the inconsistency this preserves: `case "vibrate"` has no
55/// `enableHaptics` gate in the spec either, so `craft.vibrate([100])` already
56/// fires impacts on an app with haptics "disabled". `vibrate` stays ungated to
57/// match; the asymmetry is the spec's, not ours.
58const haptic_unavailable_reason =
59 "iOS gates haptic on config.enableHaptics, which defaults to false and is " ++
60 "not visible to Zig; serving it here would enable haptics for every app " ++
61 "that never opted in";
55/// Split out from `haptic` so it can be asserted on a host. The trigger cannot
56/// — `UIImpactFeedbackGenerator` is not in a test runner's process — so a test
57/// calling `haptic` end-to-end can only observe `ClassNotFound`, which says
58/// nothing about whether the right style was chosen. This is the half where a
59/// mistake would be silent on a device too: the Taptic Engine has no readback,
60/// so a `heavy` served as `medium` feels like a device being a device.
61fn hapticTypeIn(allocator: std.mem.Allocator, data: []const u8) !HapticType {
62 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
63 return bridge_error.BridgeError.InvalidJSON;
64 defer parsed.deinit();
65
66 const root = switch (parsed.value) {
67 .object => |obj| obj,
68 else => return bridge_error.BridgeError.InvalidJSON,
69 };
6270
71 // Swift's `as? String` fails for every non-string, and `?? "medium"` sends
72 // all of them to the same place: absent, null, number, bool, array, object.
73 const style = switch (root.get("style") orelse std.json.Value{ .null = {} }) {
74 .string => |text| text,
75 else => "medium",
76 };
77 return hapticTypeForStyle(style);
78}
79
80/// `vibrate` stays ungated, and that is the spec's asymmetry rather than ours.
81///
82/// `case "vibrate"` has no `enableHaptics` check in the spec, so
83/// `craft.vibrate([100])` fires impacts on an app with haptics "disabled".
84/// Adding a gate here would be a divergence dressed as a tidy-up.
6385pub const capability_actions = [_]capabilities.ActionDecl{
64 // `.none` is the action's contract regardless of status: the page's
65 // `craft.haptic()` returns undefined and the spec's `case "haptic"` never
66 // touches `resolveCallback`. An error still reaches the page — with no
67 // pending entry to settle, `craft-bridge.js` reports it to the console —
68 // so a refusal is logged rather than swallowed.
69 .{
70 .name = A.haptic,
71 .reply = .none,
72 .status = .unavailable,
73 .reason = haptic_unavailable_reason,
74 },
86 // `.none` is the contract: the page's `craft.haptic()` returns undefined
87 // and the spec's `case "haptic"` never touches `resolveCallback`. An error
88 // still reaches the page — with no pending entry to settle,
89 // `craft-bridge.js` reports it to the console — so a refusal is logged
90 // rather than swallowed.
91 .{ .name = A.haptic, .reply = .none },
7592 // `.result` carrying the JSON literal `true`. Not a shape worth improving:
7693 // the hand-off path already delivers exactly this today, and `.none` would
7794 // park any `_req`-based caller for the full 30s timeout.
@@ -104,19 +121,27 @@ pub const HapticsBridge = struct {
104121 return bridge_error.BridgeError.UnknownAction;
105122 }
106123
107 /// Refuses, for the reason recorded on `haptic_unavailable_reason`.
124 /// `case "haptic"` (`CraftApp.swift:576-582`).
125 ///
126 /// The gate is not checked here. `ios_dispatch.route` refuses every gated
127 /// action whose flag is off before any module is asked, so reaching this
128 /// function already means `enableHaptics` is true — and checking twice
129 /// would put a second copy of the rule where the two could disagree.
108130 ///
109 /// The payload is not parsed first. Reporting a malformed `style` would
110 /// imply the style was going to be used, and it is not — a refusal that
111 /// sometimes reports a different cause is harder to act on than one that
112 /// always says the same thing.
131 /// `style` is read the way Swift reads it: `body["style"] as? String ??
132 /// "medium"`. A missing key, a number, an object — every failed cast takes
133 /// the same default, so this reads the string when there is one and hands
134 /// `hapticTypeForStyle` the literal `"medium"` otherwise. That is one
135 /// spelling of the default rather than two: `hapticTypeForStyle` maps an
136 /// unrecognised style to `.impact_medium` as well, matching the spec's
137 /// `default:` arm, and this path exercises the same branch.
113138 ///
114 /// The error name is what lands in the device log; the page sees
115 /// `NATIVE_CALL_FAILED`, `ios_dispatch.asBridgeError`'s catch-all, because
116 /// the wire protocol has no code for "declared unavailable". The manifest
117 /// is where an app reads the actual reason.
118 fn haptic(_: *Self, _: []const u8) !void {
119 return error.HapticsGateNotVisibleToZig;
139 /// No reply on success, matching `.none` and the spec. The error from
140 /// `triggerHapticChecked` is the one thing that can come back, and it means
141 /// UIKit was not there to accept the call — never that the device stayed
142 /// still, which no iOS API reports.
143 fn haptic(self: *Self, data: []const u8) !void {
144 try triggerHapticChecked(try hapticTypeIn(self.allocator, data));
120145 }
121146
122147 /// `case "vibrate"` (`CraftApp.swift:685-691`).
@@ -472,7 +497,9 @@ test "an unavailable action carries the reason with it" {
472497 try testing.expect(decl.reason != null);
473498 try testing.expect(decl.reason.?.len > 0);
474499 }
475 try testing.expectEqual(capabilities.ActionStatus.unavailable, capability_actions[0].status);
500 // Both live now. `haptic` was `.unavailable` until `ios_config.zig` made
501 // the flag its reason blamed readable; see the module header.
502 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status);
476503 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[1].status);
477504}
478505
@@ -508,18 +535,36 @@ test "an action the namespace does not serve is reported, not ignored" {
508535 );
509536}
510537
511test "haptic refuses rather than firing an ungated haptic" {
512 // The refusal is the migration's honest answer while `config.enableHaptics`
513 // has no Zig representation. If this test starts failing because someone
514 // implemented the action, the flag has to have been plumbed through first —
515 // that is the conversation this assertion exists to force.
516 var bridge = HapticsBridge.init(testing.allocator);
517 defer bridge.deinit();
518
519 try testing.expectError(
520 error.HapticsGateNotVisibleToZig,
521 bridge.handleMessage(A.haptic, "{\"style\":\"medium\"}"),
522 );
538test "haptic reads the style the way the spec's cast reads it" {
539 // What replaced the refusal. The old test asserted
540 // `error.HapticsGateNotVisibleToZig` and existed to force a conversation if
541 // anyone implemented the action; the flag is readable now
542 // (`ios_config.gateFor("haptic")`), the conversation happened, and this is
543 // what the action actually decides.
544 const alloc = testing.allocator;
545
546 try testing.expectEqual(HapticType.impact_heavy, try hapticTypeIn(alloc, "{\"style\":\"heavy\"}"));
547 try testing.expectEqual(HapticType.selection, try hapticTypeIn(alloc, "{\"style\":\"selection\"}"));
548
549 // `as? String` fails for each of these, and `?? "medium"` catches them all.
550 // A page posting `{style: 2}` gets a medium impact, not an error — matching
551 // the spec, which cannot report one either: `case "haptic"` has no reply.
552 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{}"));
553 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":null}"));
554 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":2}"));
555 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":true}"));
556 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":[\"heavy\"]}"));
557 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":{\"v\":\"heavy\"}}"));
558
559 // And an unrecognised string takes the spec's `default:` arm rather than
560 // failing — `craft.haptics.selection()` posts 'soft'.
561 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":\"soft\"}"));
562
563 // Malformed input is the one thing that is not a style: there is no
564 // dictionary for Swift's subscript to read, so this cannot take the
565 // default and call itself faithful.
566 try testing.expectError(bridge_error.BridgeError.InvalidJSON, hapticTypeIn(alloc, "not json"));
567 try testing.expectError(bridge_error.BridgeError.InvalidJSON, hapticTypeIn(alloc, "[1,2]"));
523568}
524569
525570test "every style the shipped surface posts maps the way the spec's switch does" {
packages/zig/src/bridge_mobile_imagepicker.zigmodified+16-9
Changes to packages/zig/src/bridge_mobile_imagepicker.zig
@@ -78,23 +78,30 @@
7878//! **false** (`CraftApp.swift:192`, `packages/ios/src/index.ts:120`). When it
7979//! is false the `case` falls off the end and replies *nothing at all*, while
8080//! `CraftSwiftShim.handleAction` still returns `true` (`:5247`) so Zig
81//! believes the hand-off succeeded. The injected promise is the hand-built
82//! kind with no timeout. **In a default-config app, `openCamera` and
83//! `pickImage` hang the page forever today.** The "working Swift shim" is
84//! only working for an app that set `enableCamera: true`.
81//! believes the hand-off succeeded, and the injected promise is the hand-built
82//! kind with no timeout — so on the shim a default-config app hangs the page
83//! forever. `ios_config.gateFor` maps both actions to `.camera` now, so
84//! `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` first and that
85//! hang is unreachable while Zig is linked. This paragraph asserted the hang
86//! in the present tense until then, and the next one said `enableCamera` had
87//! no Zig-reachable channel at all.
8588//!
86//! `enableCamera` has no Zig-reachable channel, so this module gates on the
87//! Info.plist keys `packages/ios/src/index.ts:185-186` writes:
89//! The Info.plist keys `packages/ios/src/index.ts:185-186` writes are still
90//! read, for two different reasons:
8891//!
8992//! - **`NSCameraUsageDescription` is an extra gate on `openCamera` only, and
9093//! it is a crash guard rather than a config proxy.** Presenting a `.camera` picker in a
9194//! process without that key is a TCC termination, not an error anything
9295//! could be told about. It is written for
9396//! `enableCamera || enableVideoRecording || enableQRScanner || enableAR`.
94//! - **`NSPhotoLibraryUsageDescription` stands in for `config.enableCamera`,
97//! - **`NSPhotoLibraryUsageDescription` stood in for `config.enableCamera`,
9598//! and it is over-broad by `enableVideoRecording`** — it is written for
96//! `enableCamera || enableVideoRecording`, which is the closest proxy
97//! available. It is emphatically **not** a photo-library permission gate:
99//! `enableCamera || enableVideoRecording`, which was the closest proxy
100//! available while the flag could not be read. It is now a second, weaker
101//! gate behind the real one, and the only one of these proxies inexact even
102//! in an SDK-generated app. Tracked in issue #131 with three more.
103//!
104//! It is emphatically **not** a photo-library permission gate:
98105//! `UIImagePickerController` with `.photoLibrary` has run out-of-process
99106//! since iOS 11 and neither prompts nor requires the key. Refusing on it is
100107//! a statement about how the app was configured, never about what the
packages/zig/src/bridge_mobile_location.zigmodified+13-9
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -1236,16 +1236,20 @@ const key_always = "NSLocationAlwaysAndWhenInUseUsageDescription";
12361236/// Decide, from evidence in the running process, whether geolocation was
12371237/// configured and which prompt to ask for.
12381238///
1239/// Neither `config.enableGeolocation` nor `config.enableBackgroundLocation` has
1240/// a Zig mirror — they appear nowhere in `packages/zig/src` outside a comment —
1241/// which is the same gap `bridge_mobile_watch.zig` documents for
1242/// `enableWatchApp`. Rather than guess, this reads the Info.plist keys the
1243/// generator writes from exactly those flags. Apple requires the usage
1244/// description before authorization may be requested at all, so its absence is
1245/// both the honest signal that the app was never built for location and the
1246/// condition under which requesting would be illegitimate; the refusal happens
1239/// Both `config.enableGeolocation` and `config.enableBackgroundLocation` are
1240/// read directly now — `ios_config.gateFor` maps `getCurrentPosition`,
1241/// `watchPosition` and `startLocationRecording` to `.geolocation`, and
1242/// `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` before this
1243/// module is asked. This used to say neither had a Zig mirror and that they
1244/// appeared nowhere in `packages/zig/src`; `ios_config.zig`'s own header names
1245/// this file as one of the workarounds it replaced.
1246///
1247/// The plist read below stays, and not as a config proxy. Apple requires the
1248/// usage description before authorization may be requested at all, so its
1249/// absence is the condition under which requesting would be illegitimate — a
1250/// precondition of the API rather than evidence of a flag. The refusal happens
12471251/// before any request is made, so what the system would otherwise do never
1248/// arises.
1252/// arises, and which of the two keys is present still selects the prompt.
12491253///
12501254/// Two divergences, both real and neither hideable:
12511255///
packages/zig/src/bridge_mobile_motion.zigmodified+12-5
Changes to packages/zig/src/bridge_mobile_motion.zig
@@ -79,15 +79,21 @@
7979//! `else`**, so on the Swift path with the flag off `startMotionUpdates`
8080//! replies nothing at all while `CraftSwiftShim.handleAction` still returns
8181//! true: the page's promise never settles. The flag defaults to `false`
82//! (`packages/ios/src/index.ts:139`) and has no mirror anywhere under
83//! `packages/zig/src`.
82//! (`packages/ios/src/index.ts:139`). It is read directly now:
83//! `ios_config.gateFor` maps `startMotionUpdates` to `.motion_sensors`, so
84//! `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` before this
85//! module is asked. Until `ios_config.zig` read `craft.config.json` the flag
86//! had no mirror anywhere under `packages/zig/src`, and the paragraph below is
87//! what this module did about that.
8488//!
8589//! `NSMotionUsageDescription` is an *exact* proxy for it —
8690//! `packages/ios/src/index.ts:192` writes that Info.plist key from
8791//! `config.enableMotionSensors` and from nothing else, with no `||` (unlike the
8892//! location keys, where `bridge_mobile_location.zig` had to document a residual
8993//! gap). So `requireMotionConfigured` reads the key and refuses when it is
90//! absent. Two things that buys, and one it costs:
94//! absent — now a second, weaker gate behind the real one, which can only
95//! differ by refusing an action the config allows. Tracked in issue #131 with
96//! three more of the same shape. Two things it bought, and one it cost:
9197//!
9298//! - a hang becomes a nameable refusal, which is strictly better than the
9399//! shim's silence;
@@ -249,8 +255,9 @@ pub const default_interval_ms: f64 = 100;
249255const reply_true = "true";
250256
251257/// Emitted into Info.plist by `packages/ios/src/index.ts:192` iff
252/// `config.enableMotionSensors` — the flag with no Zig mirror. See the module
253/// comment for what this proxy claims and what it does not.
258/// `config.enableMotionSensors`, which `ios_config.gateFor` now reads directly.
259/// See the module comment for what this proxy claims, what it does not, and why
260/// it is redundant with the real gate.
254261const key_motion_usage = "NSMotionUsageDescription";
255262
256263/// Which handler an action selects, split out from `handleMessage` so the
packages/zig/src/bridge_mobile_notifcancel.zigmodified+5-5
Changes to packages/zig/src/bridge_mobile_notifcancel.zig
@@ -43,11 +43,11 @@
4343//! with no `else`: a missing `id`, a non-string `id`, or the gate at its
4444//! default `false` replies nothing at all, and the untimed promise never
4545//! settles. Malformed input errors here instead (`MissingData` /
46//! `InvalidParameter`). The `enableLocalNotifications` gate has no Zig mirror
47//! (it appears nowhere in `packages/zig/src`), so the actions are served
48//! unconditionally — the securestore module documents the same choice, and
49//! cancelling notifications grants a page strictly less than the wipe that
50//! precedent already serves ungated.
46//! `InvalidParameter`), and the gate itself is read rather than skipped:
47//! `ios_config.gateFor` maps all three actions to `.local_notifications`, so
48//! `ios_dispatch.offerToModules` answers `CAPABILITY_DISABLED` before this
49//! module is asked. A settled rejection rather than a hang is the divergence,
50//! and it is the whole of it.
5151//!
5252//! **An `id` with an embedded NUL.** `\u0000` is a legal JSON escape and a
5353//! page can send it; the route to the UN center is `stringWithUTF8String:`,
packages/zig/src/bridge_mobile_notifications.zigmodified+4-5
Changes to packages/zig/src/bridge_mobile_notifications.zig
@@ -40,11 +40,10 @@
4040//! **Swift's silent hang.** The dispatcher arm is
4141//! `if config.enableLocalNotifications { … }` with no `else`, and the gate
4242//! defaults to `false` — so with notifications disabled the untimed promise
43//! never settles. `enableLocalNotifications` has no mirror anywhere in
44//! `packages/zig/src`, so this action is served unconditionally, the same call
45//! `bridge_mobile_securestore.zig` and `bridge_mobile_notifcancel.zig` made.
46//! Listing an app's *own* pending notifications grants a page strictly less
47//! than the cancel-everything that precedent already serves ungated.
43//! never settles. Here the flag is read: `ios_config.gateFor` maps this action
44//! to `.local_notifications` and `ios_dispatch.offerToModules` answers
45//! `CAPABILITY_DISABLED` before this module is asked. A settled rejection
46//! rather than a hang is the divergence, and it is the whole of it.
4847//!
4948//! **A truncated string reported as the whole one.** `-[NSString UTF8String]`
5049//! is NUL-terminated, so a stored title carrying an embedded U+0000 — which
packages/zig/src/bridge_mobile_securestore.zigmodified+21-12
Changes to packages/zig/src/bridge_mobile_securestore.zig
@@ -40,18 +40,27 @@
4040//! **Swift's silent hang.** The set/get/remove dispatcher arms are `if let`s
4141//! with no `else`: a missing `key`, a non-string `value`, or a disabled
4242//! `enableSecureStorage` config replies nothing at all, and the legacy promise
43//! never settles. Malformed input errors here instead. The capability gate has
44//! no Zig mirror (`enableSecureStorage` appears nowhere in `packages/zig/src`),
45//! so the actions are served unconditionally. For set/get/remove that grants a
46//! page nothing new: the *ungated* sharedItems actions next door already
47//! read and write the same items (one namespace — see above). The two gated
48//! modules that came before chose differently — clipboard kept its gate
49//! plumbable (`craft_ios_set_clipboard_enabled`, rejecting `PermissionDenied`
50//! when off) and haptics declared its gated action `.unavailable` — and
51//! `secureClear` is where the difference is real: under Swift's default-false
52//! gate a page got CAPABILITY_DISABLED instead of a wipe, and here the wipe is
53//! always live. If that gate is wanted back, clipboard's hook is the pattern;
54//! what must not come back is Swift's silent hang on the other three.
43//! never settles. Malformed input errors here instead, and the gate is read
44//! rather than skipped: `ios_config.gateFor` maps all four actions to
45//! `.secure_storage`, so `ios_dispatch.offerToModules` answers
46//! `CAPABILITY_DISABLED` before this module is asked. A settled rejection
47//! rather than a hang is the divergence, and it is the whole of it.
48//!
49//! This paragraph argued the opposite case at length — that
50//! `enableSecureStorage` "appears nowhere in `packages/zig/src`", that the
51//! actions were therefore served unconditionally, and that `secureClear` was
52//! where it mattered because a page got a wipe where Swift's default-false
53//! gate would have refused. Every sentence of it was true when written and
54//! none of it survived `ios_config.zig`, which read the flags out of
55//! `craft.config.json` and closed the gap for thirty-odd actions at once.
56//! Worth knowing while reading any other module's account of a missing gate:
57//! the same paragraph was written in eight other files.
58//!
59//! What has not changed is that `secureClear` is the one action where the gate
60//! is load-bearing. The *ungated* sharedItems actions next door read and write
61//! the same items (one namespace — see above), so set/get/remove being gated
62//! grants a page nothing it could not reach anyway; a wipe has no such
63//! equivalent.
5564//!
5665//! **`get`'s collapse of every failure into `null`.** Swift returns `nil` for
5766//! *any* non-success status, so a page cannot tell "nothing stored" from "the
packages/zig/src/bridge_mobile_speech.zigmodified+14-3
Changes to packages/zig/src/bridge_mobile_speech.zig
@@ -56,13 +56,24 @@
5656//! **The plist guard replaces the nil-recognizer guard.** Swift builds its
5757//! `SFSpeechRecognizer` only when `config.enableSpeechRecognition` is on
5858//! (`:439-440`), and `beginRecording`'s `guard let speechRecognizer` then
59//! emits `"Speech recognizer unavailable"` when it is off. Zig cannot see the
60//! ivar, but `packages/ios/src/index.ts:183` writes
59//! emits `"Speech recognizer unavailable"` when it is off. Zig cannot see that
60//! ivar — still true — but `packages/ios/src/index.ts:183` writes
6161//! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else,
62//! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` already
62//! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` also
6363//! reads. So a missing key means the flag was off, and the message emitted is
6464//! the one Swift would have emitted for the same configuration.
6565//!
66//! The flag itself is no longer out of reach: `ios_config.gateFor` maps
67//! `startListening` to `.speech_recognition`, so `ios_dispatch` answers
68//! `CAPABILITY_DISABLED` before this module is asked. That does **not** make
69//! the plist read redundant, and it is worth being precise about why, because
70//! the four other modules reading a key as a flag proxy *are* now redundant
71//! (#131) and this one looks identical at a glance. Here the key is also a
72//! precondition: `requestAuthorization:` without it does not fail, iOS
73//! terminates the process — so the check has to happen whatever the config
74//! says. The gate takes over the reason for refusing a disabled app; the key
75//! still owns not being killed, and the message.
76//!
6677//! The check has to run *before* `requestAuthorization:`, where Swift's runs
6778//! after, because asking for speech authorization without the key does not
6879//! fail — iOS terminates the process. Order diverges; the page-visible
packages/zig/src/bridge_mobile_system.zigmodified+7-5
Changes to packages/zig/src/bridge_mobile_system.zig
@@ -36,11 +36,13 @@ const is_darwin = builtin.target.os.tag.isDarwin();
3636/// custom app schemes are its main use. Filtering here would silently break
3737/// them.
3838///
39/// **`share` is served unconditionally.** Swift gates it on
40/// `config.enableShare` and replies `CAPABILITY_DISABLED`; `ios.zig`'s
41/// `AppConfig` has no such field, so Zig cannot reproduce the gate. An app that
42/// set `enableShare: false` will now get a share sheet. Restoring the gate
43/// means adding the field to `AppConfig`, not adding a guess here.
39/// **`share` is gated, and not here.** Swift replies `CAPABILITY_DISABLED`
40/// when `config.enableShare` is off, and `ios_dispatch.offerToModules` sends
41/// the same code before this module is asked — `ios_config.gateFor("share")`
42/// maps it. This paragraph used to say the opposite, that `AppConfig` had no
43/// such field and an app setting `enableShare: false` would get a share sheet
44/// anyway; that was true until `ios_config.zig` read the flags out of
45/// `craft.config.json`, and false from then on with nothing to notice.
4446pub const A = struct {
4547 pub const open_url = "openURL";
4648 pub const share = "share";
packages/zig/src/bridge_mobile_watch.zigmodified+7-4
Changes to packages/zig/src/bridge_mobile_watch.zig
@@ -76,10 +76,13 @@
7676//! `localizedDescription` are all logged before the enum is chosen.
7777//!
7878//! **The `enableWatchApp` config gate.** Swift only ever calls
79//! `setupWatchConnectivity` under `if config.enableWatchApp`, and that flag has
80//! no Zig mirror — it appears nowhere in `packages/zig/src`. Both actions are
81//! therefore served unconditionally, exactly as `securestore` and `notifcancel`
82//! document for their own gates. This grants a page nothing it should not have:
79//! `setupWatchConnectivity` under `if config.enableWatchApp` — a setup-time
80//! gate rather than a per-action one, so no `case` arm here carries an
81//! `if config.` and `ios_config.gateFor` correctly maps none of these actions.
82//! Both are therefore served unconditionally, and this is the one module whose
83//! version of that sentence is still true: `securestore` and `notifcancel` said
84//! the same until `ios_config.zig` read the flags their arms *do* carry.
85//! Serving these ungated grants a page nothing it should not have:
8386//! with the gate off Swift never activated a session, so `defaultSession`'s
8487//! `activationState` is `NotActivated` and both actions answer honestly —
8588//! `{"reachable":false}` and a refusal to update.
packages/zig/src/ios_dispatch.zigmodified+12-6
Changes to packages/zig/src/ios_dispatch.zig
@@ -382,8 +382,10 @@ comptime {
382382///
383383/// `.status = .unavailable` in a module's manifest means the handler is
384384/// reachable and refuses, deliberately, because Zig cannot do the thing
385/// correctly — `haptic` cannot see `config.enableHaptics`, and the rest say
386/// why in their own `reason`.
385/// correctly; each one says why in its own `reason`. `haptic` was the example
386/// this comment used to give, and is now the example of a reason expiring:
387/// `ios_config.zig` made `config.enableHaptics` readable and the refusal
388/// outlived it.
387389///
388390/// In the Zig-hosted app that refusal is the honest end of the line: `route`
389391/// tries `handOffToHost` next, and the shim is what actually serves it. The
@@ -391,8 +393,9 @@ comptime {
391393/// deliberately does not consult `handOffToHost`, because there the host *is*
392394/// the caller. So claiming one of these would not be a refusal instead of an
393395/// answer, it would be a refusal instead of the *host's working answer*: an app
394/// that has haptics today would stop buzzing the moment the runtime was linked,
395/// and the page would get a rejection where it used to get silence-and-a-buzz.
396/// that had haptics would have stopped buzzing the moment the runtime was
397/// linked, and the page would have got a rejection where it used to get
398/// silence-and-a-buzz.
396399///
397400/// `test/ios_conformance_test.zig` already treats falling through as better
398401/// than `.unavailable`. This is that rule, applied to the one path that had no
@@ -904,7 +907,6 @@ test "an action declared unavailable is left to the host, not claimed" {
904907 // the difference, linking the runtime replaced the working answer with a
905908 // rejection. `false` here is what sends the call back to Swift's switch.
906909 const declared_unavailable = [_][]const u8{
907 "haptic",
908910 "getNetworkStatus",
909911 "lockOrientation",
910912 "unlockOrientation",
@@ -929,7 +931,7 @@ test "the fall-through is read from the manifests, not from a list kept beside t
929931 }
930932 }
931933 }
932 try testing.expectEqual(@as(usize, 4), declared);
934 try testing.expectEqual(@as(usize, 3), declared);
933935}
934936
935937test "a live action is still claimed, unavailable is not a blanket hand-back" {
@@ -937,6 +939,10 @@ test "a live action is still claimed, unavailable is not a blanket hand-back" {
937939 // `.live`, so the seam must keep claiming it. A fall-through that widened
938940 // to whole modules would silently un-migrate everything beside a refusal.
939941 try testing.expect(!hostServesItself("vibrate"));
942 // `haptic` was the fourth entry above until `ios_config.zig` made the gate
943 // it could not read readable. It is served here now, so handing it back
944 // would hand back a working action.
945 try testing.expect(!hostServesItself("haptic"));
940946 try testing.expect(!hostServesItself("setKeepAwake"));
941947 try testing.expect(!hostServesItself("getDeviceInfo"));
942948 try testing.expect(!hostServesItself("noSuchAction"));
packages/zig/test/ios_conformance_test.zigmodified+212-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -643,6 +643,113 @@ test "the gate table has no entry for an action Zig does not serve" {
643643 }
644644}
645645
646/// The `.reason` text of every `.status = .unavailable` entry, by action.
647///
648/// The reason is what a reader trusts, so it is what has to be checked. A
649/// declaration can be resolved two ways here: `.reason = "..."` inline, and
650/// `.reason = some_const` naming a constant elsewhere in the file — both are
651/// in use, and a scan that handled only the first would silently skip the
652/// second rather than fail.
653fn collectUnavailableReasons(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) {
654 var map = std.StringHashMap([]const u8).init(allocator);
655 errdefer map.deinit();
656
657 for (zig_sources) |source| {
658 // Bounded to the manifest array, not the whole file: prose quotes
659 // `.status = .unavailable` — `bridge_mobile_haptics.zig`'s header does,
660 // describing the declaration it removed — and a scan that read comments
661 // would attribute that sentence to whichever entry preceded it.
662 const table_start = std.mem.indexOf(u8, source, "pub const capability_actions = [_]capabilities.ActionDecl{") orelse
663 continue;
664 const table_end = std.mem.indexOfPos(u8, source, table_start, "\n};") orelse continue;
665 const table = source[table_start..table_end];
666
667 var search: usize = 0;
668 while (std.mem.indexOfPos(u8, table, search, ".status = .unavailable")) |at| {
669 search = at + 1;
670
671 // The entry names its action as `A.some_const`; resolve it back
672 // through the `A` block to the string the spec spells.
673 const name_at = std.mem.lastIndexOf(u8, table[0..at], ".name = A.") orelse continue;
674 const const_start = name_at + ".name = A.".len;
675 var const_end = const_start;
676 while (const_end < table.len and (std.ascii.isAlphanumeric(table[const_end]) or
677 table[const_end] == '_')) : (const_end += 1)
678 {}
679
680 var needle_buf: [96]u8 = undefined;
681 const member = table[const_start..const_end];
682 if (member.len + 16 > needle_buf.len) continue;
683 const decl = try std.fmt.bufPrint(&needle_buf, "const {s} = \"", .{member});
684 const decl_at = std.mem.indexOf(u8, source, decl) orelse continue;
685 const value_start = decl_at + decl.len;
686 const value_end = std.mem.indexOfScalarPos(u8, source, value_start, '"') orelse continue;
687 const action = source[value_start..value_end];
688
689 // The reason, inline or by name.
690 const reason_at = std.mem.indexOfPos(u8, table, at, ".reason = ") orelse continue;
691 const reason_start = reason_at + ".reason = ".len;
692 if (table[reason_start] == '"') {
693 const reason_end = std.mem.indexOfScalarPos(u8, table, reason_start + 1, '"') orelse continue;
694 try map.put(action, table[reason_start + 1 .. reason_end]);
695 } else {
696 var ident_end = reason_start;
697 while (ident_end < table.len and (std.ascii.isAlphanumeric(table[ident_end]) or
698 table[ident_end] == '_')) : (ident_end += 1)
699 {}
700 var const_buf: [96]u8 = undefined;
701 const ident = table[reason_start..ident_end];
702 if (ident.len + 10 > const_buf.len) continue;
703 const const_decl = try std.fmt.bufPrint(&const_buf, "const {s} =", .{ident});
704 const body_at = std.mem.indexOf(u8, source, const_decl) orelse continue;
705 const body_end = std.mem.indexOfScalarPos(u8, source, body_at, ';') orelse continue;
706 // The whole initialiser, `++` concatenation included.
707 try map.put(action, source[body_at..body_end]);
708 }
709 }
710 }
711 return map;
712}
713
714test "no refusal blames a config flag Zig demonstrably reads" {
715 // The rot this exists to catch, in the exact shape it took. `haptic` was
716 // declared `.status = .unavailable` because `config.enableHaptics` "is not
717 // visible to Zig"; `gateFor`'s table said Zig reads that same flag and
718 // refuses the action when it is off. Both were written honestly, months
719 // apart, and nothing put them side by side — so the refusal outlived its
720 // reason and a working implementation sat unused two hundred lines below it.
721 //
722 // Deliberately narrower than "gated and unavailable", which is not a
723 // contradiction: `lockOrientation` is both, and its reason is about a root
724 // view controller rather than about reading a flag. What cannot stand is a
725 // refusal *naming* a flag the gate table proves Zig reads.
726 var gates = try collectZigGates(testing.allocator);
727 defer gates.deinit();
728
729 var reasons = try collectUnavailableReasons(testing.allocator);
730 defer reasons.deinit();
731
732 // Non-vacuity: the scan reads each manifest through two indirections — the
733 // `A.` constant and its declaration — and either could stop matching.
734 try testing.expect(reasons.count() >= 3);
735
736 var it = reasons.iterator();
737 while (it.next()) |entry| {
738 const flag = gates.get(entry.key_ptr.*) orelse continue;
739 if (std.mem.indexOf(u8, entry.value_ptr.*, flag) != null) {
740 std.debug.print(
741 "`{s}` is declared unavailable and its reason names `{s}`, which `gateFor` " ++
742 "maps it to.\n" ++
743 " The manifest says Zig cannot read that flag; the gate table says it does " ++
744 "and refuses the action when it is off.\n" ++
745 " One of them is out of date.\n",
746 .{ entry.key_ptr.*, flag },
747 );
748 return error.RefusalBlamesAFlagZigReads;
749 }
750 }
751}
752
646753test "every flag Zig can read is a flag the spec's config actually has" {
647754 // A misspelled key is not a one-flag bug. A key that is not in the file
648755 // reads as missing, and one missing key makes the whole decode throw, so
@@ -1462,3 +1569,108 @@ test "every recorded reply divergence is real, and still a divergence" {
14621569
14631570 try testing.expect(deliberate_reply_divergences.len >= 3);
14641571}
1572
1573// ---------------------------------------------------------------------------
1574// The SDK's event map
1575//
1576// `craft.d.ts` augments `WindowEventMap`, which is what gives
1577// `window.addEventListener('craftLocationUpdate', …)` a typed `detail` instead
1578// of a bare `Event`. It is a third statement of the event vocabulary, after
1579// the spec's `sendToWeb` calls and `ios_events.Event`, and it was the only one
1580// nothing checked — so it had drifted in both directions at once.
1581//
1582// It declared `craftVoiceAction`, which appears nowhere else in the repository:
1583// no dispatch, no subscription, no implementation. A listener written against
1584// it type-checks and never fires.
1585//
1586// It omitted six events that do fire — `craftLocationUpdate`,
1587// `craftLocationError`, `craftNetworkChange`, `craftPushToken`,
1588// `craftNotificationResponse` and `craftWatchUserInfo` — so the most-used
1589// stream in the whole surface reached a page as an untyped `Event` whose
1590// `detail` was `any`.
1591// ---------------------------------------------------------------------------
1592
1593const sdk_types = @embedFile("craft.d.ts");
1594
1595/// The keys of `interface WindowEventMap { … }`.
1596fn collectSdkEventMap(allocator: std.mem.Allocator) !std.StringHashMap(void) {
1597 var set = std.StringHashMap(void).init(allocator);
1598 errdefer set.deinit();
1599
1600 const start = std.mem.indexOf(u8, sdk_types, "interface WindowEventMap {") orelse
1601 return error.EventMapNotFound;
1602 const body = sdk_types[start..];
1603 const end = std.mem.indexOf(u8, body, "\n }") orelse return error.EventMapNotFound;
1604
1605 var it = std.mem.splitScalar(u8, body[0..end], '\n');
1606 while (it.next()) |line| {
1607 const trimmed = std.mem.trim(u8, line, " \t\r");
1608 if (!std.mem.startsWith(u8, trimmed, "craft")) continue;
1609 const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
1610 try set.put(trimmed[0..colon], {});
1611 }
1612 return set;
1613}
1614
1615test "the SDK's event map declares nothing that never fires" {
1616 // `craftVoiceAction` was the case: a name in this map and nowhere else in
1617 // the repository. The map is the most authoritative-looking of the three
1618 // statements of the vocabulary — it is what an editor autocompletes from —
1619 // and it was the one with no check behind it.
1620 var map = try collectSdkEventMap(testing.allocator);
1621 defer map.deinit();
1622
1623 // Non-vacuity: a renamed interface would empty this set.
1624 try testing.expect(map.count() >= 19);
1625
1626 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1627 defer dispatched.deinit();
1628
1629 var it = map.keyIterator();
1630 while (it.next()) |name| {
1631 if (dispatched.contains(name.*)) continue;
1632
1633 // The two recorded dead subscriptions are typed on purpose: the page
1634 // subscribes to them today, and a listener written now should compile
1635 // against the shape it will receive when the missing half lands. Any
1636 // other name here is a type with nothing behind it.
1637 var recorded = false;
1638 for (dead_subscriptions) |d| {
1639 if (std.mem.eql(u8, d.event, name.*)) recorded = true;
1640 }
1641 if (recorded) continue;
1642
1643 std.debug.print(
1644 "craft.d.ts types `{s}` in WindowEventMap, and nothing dispatches it.\n" ++
1645 " A listener written against it type-checks and is never called.\n",
1646 .{name.*},
1647 );
1648 return error.SdkTypesAnEventNothingEmits;
1649 }
1650}
1651
1652test "the SDK's event map declares everything that does fire" {
1653 // The other direction, and the one that had six holes in it. An event
1654 // missing from the map is not a compile error for the page — the
1655 // `addEventListener` overload falls back to `Event`, whose `detail` is
1656 // `any` — so the cost is silent: no autocomplete, no shape, no check that
1657 // the field being read exists.
1658 var map = try collectSdkEventMap(testing.allocator);
1659 defer map.deinit();
1660
1661 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1662 defer dispatched.deinit();
1663 try testing.expect(dispatched.count() >= 17);
1664
1665 var it = dispatched.keyIterator();
1666 while (it.next()) |name| {
1667 if (!map.contains(name.*)) {
1668 std.debug.print(
1669 "the spec dispatches `{s}` and craft.d.ts does not type it.\n" ++
1670 " A page listening for it gets a bare Event and a `detail` of any.\n",
1671 .{name.*},
1672 );
1673 return error.SdkDoesNotTypeAnEventThatFires;
1674 }
1675 }
1676}