also looking at this
feat(ios): haptic, whose reason expired when ios_config landed
#134
18 files
+562
-163
| @@ -1145,13 +1145,33 @@ export interface PickedFile { | ||
| 1145 | 1145 | mimeType?: string; |
| 1146 | 1146 | } |
| 1147 | 1147 | |
| 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 | */ | |
| 1148 | 1161 | export interface Position { |
| 1149 | 1162 | latitude: number; |
| 1150 | 1163 | longitude: number; |
| 1164 | /** Horizontal accuracy in metres. */ | |
| 1151 | 1165 | 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; | |
| 1155 | 1175 | } |
| 1156 | 1176 | |
| 1157 | 1177 | export interface Contact { |
| @@ -1378,12 +1398,29 @@ export interface CraftARPlaneEvent extends CustomEvent { | ||
| 1378 | 1398 | detail: ARPlaneEvent; |
| 1379 | 1399 | } |
| 1380 | 1400 | |
| 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 | */ | |
| 1381 | 1410 | export interface CraftShortcutEvent extends CustomEvent { |
| 1382 | 1411 | detail: { |
| 1383 | 1412 | type: string; |
| 1384 | 1413 | }; |
| 1385 | 1414 | } |
| 1386 | 1415 | |
| 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 | */ | |
| 1387 | 1424 | export interface CraftSiriShortcutEvent extends CustomEvent { |
| 1388 | 1425 | detail: SiriInvocationEvent; |
| 1389 | 1426 | } |
| @@ -1402,13 +1439,38 @@ export interface CraftWatchContextEvent extends CustomEvent { | ||
| 1402 | 1439 | detail: Record<string, any>; |
| 1403 | 1440 | } |
| 1404 | 1441 | |
| 1405 | export interface CraftVoiceActionEvent extends CustomEvent { | |
| 1442 | export interface CraftLocationUpdateEvent extends CustomEvent { | |
| 1443 | detail: Position; | |
| 1444 | } | |
| 1445 | ||
| 1446 | export interface CraftLocationErrorEvent extends CustomEvent { | |
| 1406 | 1447 | detail: { |
| 1407 | action: string; | |
| 1408 | data?: string; | |
| 1448 | message: string; | |
| 1409 | 1449 | }; |
| 1410 | 1450 | } |
| 1411 | 1451 | |
| 1452 | export interface CraftNetworkChangeEvent extends CustomEvent { | |
| 1453 | detail: NetworkStatus; | |
| 1454 | } | |
| 1455 | ||
| 1456 | export 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 | */ | |
| 1466 | export interface CraftNotificationResponseEvent extends CustomEvent { | |
| 1467 | detail: Record<string, any>; | |
| 1468 | } | |
| 1469 | ||
| 1470 | export interface CraftWatchUserInfoEvent extends CustomEvent { | |
| 1471 | detail: Record<string, any>; | |
| 1472 | } | |
| 1473 | ||
| 1412 | 1474 | export interface CraftErrorEvent extends CustomEvent { |
| 1413 | 1475 | detail: CraftError; |
| 1414 | 1476 | } |
| @@ -1428,14 +1490,24 @@ declare global { | ||
| 1428 | 1490 | craftMotionUpdate: CraftMotionUpdateEvent; |
| 1429 | 1491 | craftBluetoothDevice: CraftBluetoothDeviceEvent; |
| 1430 | 1492 | craftARPlane: CraftARPlaneEvent; |
| 1431 | craftShortcut: CraftShortcutEvent; | |
| 1432 | craftSiriShortcut: CraftSiriShortcutEvent; | |
| 1493 | craftLocationUpdate: CraftLocationUpdateEvent; | |
| 1494 | craftLocationError: CraftLocationErrorEvent; | |
| 1495 | craftNetworkChange: CraftNetworkChangeEvent; | |
| 1496 | craftPushToken: CraftPushTokenEvent; | |
| 1497 | craftNotificationResponse: CraftNotificationResponseEvent; | |
| 1433 | 1498 | craftWatchMessage: CraftWatchMessageEvent; |
| 1434 | 1499 | craftWatchReachability: CraftWatchReachabilityEvent; |
| 1435 | 1500 | craftWatchContext: CraftWatchContextEvent; |
| 1436 | craftVoiceAction: CraftVoiceActionEvent; | |
| 1501 | craftWatchUserInfo: CraftWatchUserInfoEvent; | |
| 1437 | 1502 | craftError: CraftErrorEvent; |
| 1438 | 1503 | 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; | |
| 1439 | 1511 | } |
| 1440 | 1512 | } |
| 1441 | 1513 | |
| @@ -408,6 +408,9 @@ pub fn build(b: *std.Build) void { | ||
| 408 | 408 | ios_conformance_tests.root_module.addAnonymousImport("src/ios_events.zig", .{ |
| 409 | 409 | .root_source_file = b.path("src/ios_events.zig"), |
| 410 | 410 | }); |
| 411 | ios_conformance_tests.root_module.addAnonymousImport("craft.d.ts", .{ | |
| 412 | .root_source_file = b.path("../typescript/types/craft.d.ts"), | |
| 413 | }); | |
| 411 | 414 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{ |
| 412 | 415 | .root_source_file = b.path("src/bridge_mobile_vision.zig"), |
| 413 | 416 | }); |
| @@ -103,10 +103,13 @@ | ||
| 103 | 103 | //! posting); a field that is **present with the wrong type** is refused, never |
| 104 | 104 | //! coerced. |
| 105 | 105 | //! |
| 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 | |
| 110 | 113 | //! hang into an answer: `schedule` now reaches the scheduler and reports |
| 111 | 114 | //! whatever it says (a rejection, until the plist gains |
| 112 | 115 | //! `BGTaskSchedulerPermittedIdentifiers`), and the cancels report the no-op |
| @@ -139,10 +139,17 @@ | ||
| 139 | 139 | //! `deleteCalendarEvent` — which requests nothing (3350) — the key is only |
| 140 | 140 | //! evidence of `config.enableCalendar`, and it is checked for that reason. |
| 141 | 141 | //! |
| 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. | |
| 146 | 153 | //! |
| 147 | 154 | //! **Behaviour changes on every disabled or malformed path, in Zig's favour.** |
| 148 | 155 | //! Swift's three cases have no `else` (717-731): an app with |
| @@ -83,12 +83,20 @@ | ||
| 83 | 83 | //! Until then, "the native call failed" is what a cancel says, and saying so |
| 84 | 84 | //! here is the point. |
| 85 | 85 | //! |
| 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. | |
| 92 | 100 | //! |
| 93 | 101 | //! Two things about that gate have to be said plainly. First, the key is **not |
| 94 | 102 | //! a precondition of the API**: `CNContactPickerViewController` runs out of |
| @@ -74,11 +74,12 @@ | ||
| 74 | 74 | //! Info.plist key to read instead: `renderUsageDescriptions` |
| 75 | 75 | //! (`packages/ios/src/index.ts:183-199`) writes nothing from either flag. |
| 76 | 76 | //! |
| 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. | |
| 82 | 83 | //! |
| 83 | 84 | //! Falling through to the shim is not the safe option it was for |
| 84 | 85 | //! `takeScreenshot`, because the shim does not answer these correctly today: |
| @@ -1,10 +1,34 @@ | ||
| 1 | 1 | //! The haptics half of the `mobile` namespace: `haptic` and `vibrate`. |
| 2 | 2 | //! |
| 3 | 3 | //! Split out of `bridge_mobile.zig` because the two actions have opposite |
| 4 | //! contracts — `haptic` is fire-and-forget and gated on a config flag, while | |
| 5 | //! `vibrate` replies and is gated on nothing — and because one of them cannot | |
| 6 | //! be served honestly from Zig yet. Keeping that admission next to the working | |
| 7 | //! action, rather than folded into the namespace's main file, is the point. | |
| 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. | |
| 8 | 32 | |
| 9 | 33 | const std = ; |
| 10 | 34 | const builtin = ; |
| @@ -25,53 +49,46 @@ pub const A = struct { | ||
| 25 | 49 | pub const vibrate = "vibrate"; |
| 26 | 50 | }; |
| 27 | 51 | |
| 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. | |
| 39 | 54 | /// |
| 40 | /// The three ways to make that go away are all worse than saying so: | |
| 41 | /// - fire anyway → a behaviour change that is invisible in CI and obvious on | |
| 42 | /// a device; | |
| 43 | /// - hardcode the gate false → a permanent no-op that reports nothing, which | |
| 44 | /// is the shape of the nine Android handlers that were deleted; | |
| 45 | /// - reply `{"success":true}` → fabricated success, the thing this migration | |
| 46 | /// exists to stop. | |
| 47 | /// | |
| 48 | /// So the handler refuses and the manifest says why. Unblocking it is small and | |
| 49 | /// deliberate: plumb the flag in (a `craft_ios_set_capabilities` export or a | |
| 50 | /// build option), then swap the refusal in `haptic()` for the mapping and the | |
| 51 | /// trigger below, both of which are written and pinned by the tests at the | |
| 52 | /// bottom of this file. What must not happen is the flip happening silently. | |
| 53 | /// | |
| 54 | /// Note the inconsistency this preserves: `case "vibrate"` has no | |
| 55 | /// `enableHaptics` gate in the spec either, so `craft.vibrate([100])` already | |
| 56 | /// fires impacts on an app with haptics "disabled". `vibrate` stays ungated to | |
| 57 | /// match; the asymmetry is the spec's, not ours. | |
| 58 | const haptic_unavailable_reason = | |
| 59 | "iOS gates haptic on config.enableHaptics, which defaults to false and is " ++ | |
| 60 | "not visible to Zig; serving it here would enable haptics for every app " ++ | |
| 61 | "that never opted in"; | |
| 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. | |
| 61 | fn 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 | }; | |
| 62 | 70 | |
| 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. | |
| 63 | 85 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 64 | // `.none` is the action's contract regardless of status: the page's | |
| 65 | // `craft.haptic()` returns undefined and the spec's `case "haptic"` never | |
| 66 | // touches `resolveCallback`. An error still reaches the page — with no | |
| 67 | // pending entry to settle, `craft-bridge.js` reports it to the console — | |
| 68 | // so a refusal is logged rather than swallowed. | |
| 69 | .{ | |
| 70 | .name = A.haptic, | |
| 71 | .reply = .none, | |
| 72 | .status = .unavailable, | |
| 73 | .reason = haptic_unavailable_reason, | |
| 74 | }, | |
| 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 }, | |
| 75 | 92 | // `.result` carrying the JSON literal `true`. Not a shape worth improving: |
| 76 | 93 | // the hand-off path already delivers exactly this today, and `.none` would |
| 77 | 94 | // park any `_req`-based caller for the full 30s timeout. |
| @@ -104,19 +121,27 @@ pub const HapticsBridge = struct { | ||
| 104 | 121 | return bridge_error.BridgeError.UnknownAction; |
| 105 | 122 | } |
| 106 | 123 | |
| 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. | |
| 108 | 130 | /// |
| 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. | |
| 113 | 138 | /// |
| 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)); | |
| 120 | 145 | } |
| 121 | 146 | |
| 122 | 147 | /// `case "vibrate"` (`CraftApp.swift:685-691`). |
| @@ -472,7 +497,9 @@ test "an unavailable action carries the reason with it" { | ||
| 472 | 497 | try testing.expect(decl.reason != null); |
| 473 | 498 | try testing.expect(decl.reason.?.len > 0); |
| 474 | 499 | } |
| 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); | |
| 476 | 503 | try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[1].status); |
| 477 | 504 | } |
| 478 | 505 | |
| @@ -508,18 +535,36 @@ test "an action the namespace does not serve is reported, not ignored" { | ||
| 508 | 535 | ); |
| 509 | 536 | } |
| 510 | 537 | |
| 511 | test "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 | ); | |
| 538 | test "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]")); | |
| 523 | 568 | } |
| 524 | 569 | |
| 525 | 570 | test "every style the shipped surface posts maps the way the spec's switch does" { |
| @@ -78,23 +78,30 @@ | ||
| 78 | 78 | //! **false** (`CraftApp.swift:192`, `packages/ios/src/index.ts:120`). When it |
| 79 | 79 | //! is false the `case` falls off the end and replies *nothing at all*, while |
| 80 | 80 | //! `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. | |
| 85 | 88 | //! |
| 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: | |
| 88 | 91 | //! |
| 89 | 92 | //! - **`NSCameraUsageDescription` is an extra gate on `openCamera` only, and |
| 90 | 93 | //! it is a crash guard rather than a config proxy.** Presenting a `.camera` picker in a |
| 91 | 94 | //! process without that key is a TCC termination, not an error anything |
| 92 | 95 | //! could be told about. It is written for |
| 93 | 96 | //! `enableCamera || enableVideoRecording || enableQRScanner || enableAR`. |
| 94 | //! - **`NSPhotoLibraryUsageDescription` stands in for `config.enableCamera`, | |
| 97 | //! - **`NSPhotoLibraryUsageDescription` stood in for `config.enableCamera`, | |
| 95 | 98 | //! 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: | |
| 98 | 105 | //! `UIImagePickerController` with `.photoLibrary` has run out-of-process |
| 99 | 106 | //! since iOS 11 and neither prompts nor requires the key. Refusing on it is |
| 100 | 107 | //! a statement about how the app was configured, never about what the |
| @@ -1236,16 +1236,20 @@ const key_always = "NSLocationAlwaysAndWhenInUseUsageDescription"; | ||
| 1236 | 1236 | /// Decide, from evidence in the running process, whether geolocation was |
| 1237 | 1237 | /// configured and which prompt to ask for. |
| 1238 | 1238 | /// |
| 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 | |
| 1247 | 1251 | /// 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. | |
| 1249 | 1253 | /// |
| 1250 | 1254 | /// Two divergences, both real and neither hideable: |
| 1251 | 1255 | /// |
| @@ -79,15 +79,21 @@ | ||
| 79 | 79 | //! `else`**, so on the Swift path with the flag off `startMotionUpdates` |
| 80 | 80 | //! replies nothing at all while `CraftSwiftShim.handleAction` still returns |
| 81 | 81 | //! 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. | |
| 84 | 88 | //! |
| 85 | 89 | //! `NSMotionUsageDescription` is an *exact* proxy for it — |
| 86 | 90 | //! `packages/ios/src/index.ts:192` writes that Info.plist key from |
| 87 | 91 | //! `config.enableMotionSensors` and from nothing else, with no `||` (unlike the |
| 88 | 92 | //! location keys, where `bridge_mobile_location.zig` had to document a residual |
| 89 | 93 | //! 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: | |
| 91 | 97 | //! |
| 92 | 98 | //! - a hang becomes a nameable refusal, which is strictly better than the |
| 93 | 99 | //! shim's silence; |
| @@ -249,8 +255,9 @@ pub const default_interval_ms: f64 = 100; | ||
| 249 | 255 | const reply_true = "true"; |
| 250 | 256 | |
| 251 | 257 | /// 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. | |
| 254 | 261 | const key_motion_usage = "NSMotionUsageDescription"; |
| 255 | 262 | |
| 256 | 263 | /// Which handler an action selects, split out from `handleMessage` so the |
| @@ -43,11 +43,11 @@ | ||
| 43 | 43 | //! with no `else`: a missing `id`, a non-string `id`, or the gate at its |
| 44 | 44 | //! default `false` replies nothing at all, and the untimed promise never |
| 45 | 45 | //! 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. | |
| 51 | 51 | //! |
| 52 | 52 | //! **An `id` with an embedded NUL.** `\u0000` is a legal JSON escape and a |
| 53 | 53 | //! page can send it; the route to the UN center is `stringWithUTF8String:`, |
| @@ -40,11 +40,10 @@ | ||
| 40 | 40 | //! **Swift's silent hang.** The dispatcher arm is |
| 41 | 41 | //! `if config.enableLocalNotifications { … }` with no `else`, and the gate |
| 42 | 42 | //! 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. | |
| 48 | 47 | //! |
| 49 | 48 | //! **A truncated string reported as the whole one.** `-[NSString UTF8String]` |
| 50 | 49 | //! is NUL-terminated, so a stored title carrying an embedded U+0000 — which |
| @@ -40,18 +40,27 @@ | ||
| 40 | 40 | //! **Swift's silent hang.** The set/get/remove dispatcher arms are `if let`s |
| 41 | 41 | //! with no `else`: a missing `key`, a non-string `value`, or a disabled |
| 42 | 42 | //! `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. | |
| 55 | 64 | //! |
| 56 | 65 | //! **`get`'s collapse of every failure into `null`.** Swift returns `nil` for |
| 57 | 66 | //! *any* non-success status, so a page cannot tell "nothing stored" from "the |
| @@ -56,13 +56,24 @@ | ||
| 56 | 56 | //! **The plist guard replaces the nil-recognizer guard.** Swift builds its |
| 57 | 57 | //! `SFSpeechRecognizer` only when `config.enableSpeechRecognition` is on |
| 58 | 58 | //! (`: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 | |
| 61 | 61 | //! `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 | |
| 63 | 63 | //! reads. So a missing key means the flag was off, and the message emitted is |
| 64 | 64 | //! the one Swift would have emitted for the same configuration. |
| 65 | 65 | //! |
| 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 | //! | |
| 66 | 77 | //! The check has to run *before* `requestAuthorization:`, where Swift's runs |
| 67 | 78 | //! after, because asking for speech authorization without the key does not |
| 68 | 79 | //! fail — iOS terminates the process. Order diverges; the page-visible |
| @@ -36,11 +36,13 @@ const is_darwin = builtin.target.os.tag.isDarwin(); | ||
| 36 | 36 | /// custom app schemes are its main use. Filtering here would silently break |
| 37 | 37 | /// them. |
| 38 | 38 | /// |
| 39 | /// **`share` is served unconditionally.** Swift gates it on | |
| 40 | /// `config.enableShare` and replies `CAPABILITY_DISABLED`; `ios.zig`'s | |
| 41 | /// `AppConfig` has no such field, so Zig cannot reproduce the gate. An app that | |
| 42 | /// set `enableShare: false` will now get a share sheet. Restoring the gate | |
| 43 | /// means adding the field to `AppConfig`, not adding a guess here. | |
| 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. | |
| 44 | 46 | pub const A = struct { |
| 45 | 47 | pub const open_url = "openURL"; |
| 46 | 48 | pub const share = "share"; |
| @@ -76,10 +76,13 @@ | ||
| 76 | 76 | //! `localizedDescription` are all logged before the enum is chosen. |
| 77 | 77 | //! |
| 78 | 78 | //! **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: | |
| 83 | 86 | //! with the gate off Swift never activated a session, so `defaultSession`'s |
| 84 | 87 | //! `activationState` is `NotActivated` and both actions answer honestly — |
| 85 | 88 | //! `{"reachable":false}` and a refusal to update. |
| @@ -382,8 +382,10 @@ comptime { | ||
| 382 | 382 | /// |
| 383 | 383 | /// `.status = .unavailable` in a module's manifest means the handler is |
| 384 | 384 | /// 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. | |
| 387 | 389 | /// |
| 388 | 390 | /// In the Zig-hosted app that refusal is the honest end of the line: `route` |
| 389 | 391 | /// tries `handOffToHost` next, and the shim is what actually serves it. The |
| @@ -391,8 +393,9 @@ comptime { | ||
| 391 | 393 | /// deliberately does not consult `handOffToHost`, because there the host *is* |
| 392 | 394 | /// the caller. So claiming one of these would not be a refusal instead of an |
| 393 | 395 | /// 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. | |
| 396 | 399 | /// |
| 397 | 400 | /// `test/ios_conformance_test.zig` already treats falling through as better |
| 398 | 401 | /// 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" { | ||
| 904 | 907 | // the difference, linking the runtime replaced the working answer with a |
| 905 | 908 | // rejection. `false` here is what sends the call back to Swift's switch. |
| 906 | 909 | const declared_unavailable = [_][]const u8{ |
| 907 | "haptic", | |
| 908 | 910 | "getNetworkStatus", |
| 909 | 911 | "lockOrientation", |
| 910 | 912 | "unlockOrientation", |
| @@ -929,7 +931,7 @@ test "the fall-through is read from the manifests, not from a list kept beside t | ||
| 929 | 931 | } |
| 930 | 932 | } |
| 931 | 933 | } |
| 932 | try testing.expectEqual(@as(usize, 4), declared); | |
| 934 | try testing.expectEqual(@as(usize, 3), declared); | |
| 933 | 935 | } |
| 934 | 936 | |
| 935 | 937 | test "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" { | ||
| 937 | 939 | // `.live`, so the seam must keep claiming it. A fall-through that widened |
| 938 | 940 | // to whole modules would silently un-migrate everything beside a refusal. |
| 939 | 941 | 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")); | |
| 940 | 946 | try testing.expect(!hostServesItself("setKeepAwake")); |
| 941 | 947 | try testing.expect(!hostServesItself("getDeviceInfo")); |
| 942 | 948 | try testing.expect(!hostServesItself("noSuchAction")); |
| @@ -643,6 +643,113 @@ test "the gate table has no entry for an action Zig does not serve" { | ||
| 643 | 643 | } |
| 644 | 644 | } |
| 645 | 645 | |
| 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. | |
| 653 | fn 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 | ||
| 714 | test "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 | ||
| 646 | 753 | test "every flag Zig can read is a flag the spec's config actually has" { |
| 647 | 754 | // A misspelled key is not a one-flag bug. A key that is not in the file |
| 648 | 755 | // 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" { | ||
| 1462 | 1569 | |
| 1463 | 1570 | try testing.expect(deliberate_reply_divergences.len >= 3); |
| 1464 | 1571 | } |
| 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 | ||
| 1593 | const sdk_types = @embedFile("craft.d.ts"); | |
| 1594 | ||
| 1595 | /// The keys of `interface WindowEventMap { … }`. | |
| 1596 | fn 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 | ||
| 1615 | test "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 | ||
| 1652 | test "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 | } | |