ReviewOS

also looking at this

craft-native/craft

fix(sdk): restore craftVoiceAction — the event map is cross-platform

#135
Merged glennmichael123 wants to merge fix/sdk-event-map-is-cross-platform into main
3 files +96 -12
packages/typescript/types/craft.d.tsmodified+27-0
Changes to packages/typescript/types/craft.d.ts
@@ -1471,6 +1471,29 @@ export interface CraftWatchUserInfoEvent extends CustomEvent {
14711471 detail: Record<string, any>;
14721472}
14731473
1474/**
1475 * A registered voice action being spoken. **Android only** —
1476 * `craft.voice.register()` has no iOS counterpart, and the iOS bridge never
1477 * dispatches this.
1478 */
1479export interface CraftVoiceActionEvent extends CustomEvent {
1480 detail: {
1481 action: string;
1482 data?: string;
1483 };
1484}
1485
1486/**
1487 * Foreground/background transitions. **Android only** — dispatched by
1488 * `startAppStateMonitoring`, which iOS answers through `getAppState` polling
1489 * instead.
1490 */
1491export interface CraftAppStateChangeEvent extends CustomEvent {
1492 detail: {
1493 state: string;
1494 };
1495}
1496
14741497export interface CraftErrorEvent extends CustomEvent {
14751498 detail: CraftError;
14761499}
@@ -1499,6 +1522,10 @@ declare global {
14991522 craftWatchReachability: CraftWatchReachabilityEvent;
15001523 craftWatchContext: CraftWatchContextEvent;
15011524 craftWatchUserInfo: CraftWatchUserInfoEvent;
1525 // Android-only. This file is the cross-platform surface, so it carries the
1526 // union of what either bridge dispatches, not one platform's set.
1527 craftVoiceAction: CraftVoiceActionEvent;
1528 craftAppStateChange: CraftAppStateChangeEvent;
15021529 craftError: CraftErrorEvent;
15031530 craftDeepLink: CraftDeepLinkEvent;
15041531 // Kept, and kept last, because nothing dispatches either one: the app
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -411,6 +411,9 @@ pub fn build(b: *std.Build) void {
411411 ios_conformance_tests.root_module.addAnonymousImport("craft.d.ts", .{
412412 .root_source_file = b.path("../typescript/types/craft.d.ts"),
413413 });
414 ios_conformance_tests.root_module.addAnonymousImport("CraftBridge.kt", .{
415 .root_source_file = b.path("../android/templates/CraftBridge.kt.template"),
416 });
414417 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{
415418 .root_source_file = b.path("src/bridge_mobile_vision.zig"),
416419 });
packages/zig/test/ios_conformance_test.zigmodified+66-12
Changes to packages/zig/test/ios_conformance_test.zig
@@ -1579,18 +1579,73 @@ test "every recorded reply divergence is real, and still a divergence" {
15791579// the spec's `sendToWeb` calls and `ios_events.Event`, and it was the only one
15801580// nothing checked — so it had drifted in both directions at once.
15811581//
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//
15861582// It omitted six events that do fire — `craftLocationUpdate`,
15871583// `craftLocationError`, `craftNetworkChange`, `craftPushToken`,
15881584// `craftNotificationResponse` and `craftWatchUserInfo` — so the most-used
15891585// stream in the whole surface reached a page as an untyped `Event` whose
15901586// `detail` was `any`.
1587//
1588// ## This file is cross-platform, and the first version of this check was not
1589//
1590// `craft.d.ts` is the SDK's whole type surface, not iOS's. Checking it against
1591// the iOS spec alone got a live type deleted: `craftVoiceAction` is dispatched
1592// by `CraftBridge.kt`, and a scan that never opened a `.kt.template` reported
1593// it as fiction. The check that replaced it would then have rejected the fix,
1594// because a correct Android-only entry fails an iOS-only rule — a guard whose
1595// own error message argues for the bug.
1596//
1597// So the vocabulary here is the union of what either bridge dispatches. The
1598// Android scan below is the same shape as the Swift one: `sendEvent("craftX"`
1599// for the helper, plus the `CustomEvent('craftX'` literals the injected JS
1600// emits directly. Android's `window._craftXResolve` handles are deliberately
1601// not matched — those are promise plumbing, not events.
15911602// ---------------------------------------------------------------------------
15921603
15931604const sdk_types = @embedFile("craft.d.ts");
1605const android_spec = @embedFile("CraftBridge.kt");
1606
1607/// Every event `CraftBridge.kt` dispatches to the page.
1608fn collectAndroidDispatchedEvents(allocator: std.mem.Allocator) !std.StringHashMap(void) {
1609 var set = std.StringHashMap(void).init(allocator);
1610 errdefer set.deinit();
1611
1612 const Needle = struct { text: []const u8, quote: u8 };
1613 const needles = [_]Needle{
1614 .{ .text = "sendEvent(\"", .quote = '"' },
1615 .{ .text = "CustomEvent('", .quote = '\'' },
1616 };
1617
1618 for (needles) |needle| {
1619 var search: usize = 0;
1620 while (std.mem.indexOfPos(u8, android_spec, search, needle.text)) |at| {
1621 const start = at + needle.text.len;
1622 const end = std.mem.indexOfScalarPos(u8, android_spec, start, needle.quote) orelse break;
1623 search = end;
1624
1625 const name = android_spec[start..end];
1626 if (!std.mem.startsWith(u8, name, "craft")) continue;
1627 var ok = true;
1628 for (name) |c| {
1629 if (!std.ascii.isAlphanumeric(c)) ok = false;
1630 }
1631 if (ok) try set.put(name, {});
1632 }
1633 }
1634 return set;
1635}
1636
1637/// What either bridge dispatches — the vocabulary `craft.d.ts` describes.
1638fn collectAllDispatchedEvents(allocator: std.mem.Allocator) !std.StringHashMap(void) {
1639 var set = try collectSpecDispatchedEvents(allocator);
1640 errdefer set.deinit();
1641
1642 var android = try collectAndroidDispatchedEvents(allocator);
1643 defer android.deinit();
1644
1645 var it = android.keyIterator();
1646 while (it.next()) |name| try set.put(name.*, {});
1647 return set;
1648}
15941649
15951650/// The keys of `interface WindowEventMap { … }`.
15961651fn collectSdkEventMap(allocator: std.mem.Allocator) !std.StringHashMap(void) {
@@ -1613,17 +1668,16 @@ fn collectSdkEventMap(allocator: std.mem.Allocator) !std.StringHashMap(void) {
16131668}
16141669
16151670test "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.
1671 // The map is the most authoritative-looking statement of the vocabulary —
1672 // it is what an editor autocompletes from — and it was the one with no
1673 // check behind it.
16201674 var map = try collectSdkEventMap(testing.allocator);
16211675 defer map.deinit();
16221676
16231677 // Non-vacuity: a renamed interface would empty this set.
1624 try testing.expect(map.count() >= 19);
1678 try testing.expect(map.count() >= 21);
16251679
1626 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1680 var dispatched = try collectAllDispatchedEvents(testing.allocator);
16271681 defer dispatched.deinit();
16281682
16291683 var it = map.keyIterator();
@@ -1658,9 +1712,9 @@ test "the SDK's event map declares everything that does fire" {
16581712 var map = try collectSdkEventMap(testing.allocator);
16591713 defer map.deinit();
16601714
1661 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1715 var dispatched = try collectAllDispatchedEvents(testing.allocator);
16621716 defer dispatched.deinit();
1663 try testing.expect(dispatched.count() >= 17);
1717 try testing.expect(dispatched.count() >= 20);
16641718
16651719 var it = dispatched.keyIterator();
16661720 while (it.next()) |name| {