ReviewOS

also looking at this

craft-native/craft

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

#103
Merged glennmichael123 wants to merge feat/ios-swift-host-seam into main
7 files +868 -59

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig/src/bridge_mobile.zigmodified+131-3
Changes to packages/zig/src/bridge_mobile.zig
@@ -52,7 +52,7 @@ pub const MobileBridge = struct {
5252 }
5353
5454 fn getDeviceInfo(self: *Self) !void {
55 var buf: [512]u8 = undefined;
55 var buf: [4096]u8 = undefined;
5656 const json = try describeDevice(&buf);
5757 bridge_error.sendResultToJS(self.allocator, A.get_device_info, json);
5858 }
@@ -88,14 +88,142 @@ fn describeDevice(buf: []u8) ![]const u8 {
8888 const system_name = try nsStringField(device, "systemName");
8989 const system_version = try nsStringField(device, "systemVersion");
9090 const model = try nsStringField(device, "model");
91 // The one field a user can set, so the one that can carry a quote or a
92 // backslash: "Glenn\"s iPhone" is a legal device name and an illegal JSON
93 // string. Everything here goes through `escapeJsonString` for that reason,
94 // not just this one — a locale identifier is tame today and the escaping
95 // costs nothing.
96 const device_name = try nsStringField(device, "name");
97
98 // `identifierForVendor` is nil before the device is unlocked once after
99 // boot. The spec answers `""` there (`device.identifierForVendor?.uuidString
100 // ?? ""`), so this does too: a missing id and an empty one are the same
101 // thing to a page, and inventing a UUID would be worse than either.
102 const vendor_id: []const u8 = blk: {
103 const sel_ifv = objc.sel_registerName("identifierForVendor") orelse break :blk "";
104 const uuid = objc.msgSendId(device, sel_ifv);
105 if (uuid == null) break :blk "";
106 break :blk nsStringField(uuid, "UUIDString") catch "";
107 };
108
109 // UIScreen, for the three screen fields. A nil mainScreen is not a case
110 // that happens in an app with a window, but reading `bounds` off nil would
111 // answer a zero rect rather than fail, and a page cannot tell a 0x0 screen
112 // from a broken one.
113 const UIScreen = objc.objc_getClass("UIScreen") orelse return error.ClassNotFound;
114 const sel_main = objc.sel_registerName("mainScreen") orelse return error.SelectorNotFound;
115 const screen = objc.msgSendId(UIScreen, sel_main);
116 if (screen == null) return error.NoMainScreen;
117
118 const bounds = try msgSendRect(screen, "bounds");
119 const scale = try msgSendCGFloat(screen, "scale");
120
121 // `batteryLevel` is -1 unless battery monitoring is enabled, and neither
122 // the spec nor this enables it. Reporting the same -1 is the parity that
123 // matters: a page that special-cases it keeps working, and turning
124 // monitoring on here would be a behaviour change the spec never made.
125 const battery_level = try msgSendFloat(device, "batteryLevel");
126 const battery_state = batteryStateName(try msgSendInteger(device, "batteryState"));
127
128 const NSLocale = objc.objc_getClass("NSLocale") orelse return error.ClassNotFound;
129 const sel_curloc = objc.sel_registerName("currentLocale") orelse return error.SelectorNotFound;
130 const locale_obj = objc.msgSendId(NSLocale, sel_curloc);
131 const locale = if (locale_obj != null) nsStringField(locale_obj, "localeIdentifier") catch "" else "";
132
133 const NSTimeZone = objc.objc_getClass("NSTimeZone") orelse return error.ClassNotFound;
134 const sel_localtz = objc.sel_registerName("localTimeZone") orelse return error.SelectorNotFound;
135 const tz_obj = objc.msgSendId(NSTimeZone, sel_localtz);
136 const timezone = if (tz_obj != null) nsStringField(tz_obj, "name") catch "" else "";
137
138 var esc: [5][512]u8 = undefined;
91139
92140 return std.fmt.bufPrint(
93141 buf,
94 "",
95 .{ system_name, system_version, model, isSimulator() },
142 "",
158 .{
159 try bridge_error.escapeJsonString(&esc[0], model),
160 try bridge_error.escapeJsonString(&esc[1], device_name),
161 system_name,
162 system_version,
163 vendor_id,
164 isSimulator(),
165 bounds.size.width,
166 bounds.size.height,
167 scale,
168 battery_level,
169 battery_state,
170 try bridge_error.escapeJsonString(&esc[2], locale),
171 try bridge_error.escapeJsonString(&esc[3], timezone),
172 },
96173 );
97174}
98175
176/// `UIDevice.BatteryState` as the spec spells it (`getBatteryState`,
177/// `CraftApp.swift:3204`). The raw values are UIKit's: unknown 0, unplugged 1,
178/// charging 2, full 3. Anything else is "unknown", matching the spec's
179/// `default` rather than inventing a fifth name for a value UIKit does not
180/// currently produce.
181fn batteryStateName(raw: isize) []const u8 {
182 return switch (raw) {
183 1 => "unplugged",
184 2 => "charging",
185 3 => "full",
186 else => "unknown",
187 };
188}
189
190/// A zero-argument selector returning `CGRect`.
191///
192/// A plain `objc_msgSend` cast, deliberately — **not**
193/// `objc_runtime.msgSendStret`, which picks `objc_msgSend_stret` for anything
194/// over 16 bytes. `CGRect` is 32, and `objc_msgSend_stret` does not exist on
195/// arm64: the struct return travels in x8 through ordinary `objc_msgSend`.
196/// `bridge_mobile_motion.zig:124` records the same trap, and `macos.zig`'s
197/// `msgSendRect` is the shape this copies.
198fn msgSendRect(target: objc.id, comptime selector: [:0]const u8) !objc.CGRect {
199 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
200 const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGRect = @ptrCast(&objc.objc_msgSend);
201 return func(target, sel);
202}
203
204/// A zero-argument selector returning `CGFloat` (`double` on 64-bit).
205fn msgSendCGFloat(target: objc.id, comptime selector: [:0]const u8) !objc.CGFloat {
206 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
207 const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGFloat = @ptrCast(&objc.objc_msgSend);
208 return func(target, sel);
209}
210
211/// A zero-argument selector returning `float`. `batteryLevel` is a `float`,
212/// not a `CGFloat` — reading it as a double returns garbage, because the two
213/// come back in different halves of the same register.
214fn msgSendFloat(target: objc.id, comptime selector: [:0]const u8) !f32 {
215 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
216 const func: *const fn (objc.id, objc.SEL) callconv(.c) f32 = @ptrCast(&objc.objc_msgSend);
217 return func(target, sel);
218}
219
220/// A zero-argument selector returning `NSInteger`.
221fn msgSendInteger(target: objc.id, comptime selector: [:0]const u8) !isize {
222 const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound;
223 const func: *const fn (objc.id, objc.SEL) callconv(.c) isize = @ptrCast(&objc.objc_msgSend);
224 return func(target, sel);
225}
226
99227/// Send a zero-argument selector that returns an NSString, and borrow its UTF-8.
100228///
101229/// The returned slice points into the NSString's own buffer, which is
packages/zig/src/ios_dispatch.zigmodified+131-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -3,6 +3,7 @@ const builtin = ;
33const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
6const capabilities = ;
67pub const ios_async = ;
78pub const ios_events = ;
89pub const ios_delegate = ;
@@ -319,6 +320,83 @@ const mobile_bridges = .{
319320 bridge_mobile_speech.SpeechBridge,
320321};
321322
323/// The same modules again, as their capability manifests.
324///
325/// A second spelling of `mobile_bridges` is not something to want, but the two
326/// cannot be one: `mobile_bridges` holds bridge *types* and `capability_actions`
327/// is declared at module scope, so there is no way to reach a module's manifest
328/// from its struct. The comptime length check below is what keeps them in step
329/// — a module added to one list and not the other fails the build.
330const mobile_manifests = [_][]const capabilities.ActionDecl{
331 &bridge_mobile.capability_actions,
332 &bridge_mobile_clipboard.capability_actions,
333 &bridge_mobile_haptics.capability_actions,
334 &bridge_mobile_device.capability_actions,
335 &bridge_mobile_system.capability_actions,
336 &bridge_mobile_display.capability_actions,
337 &bridge_mobile_storage.capability_actions,
338 &bridge_mobile_misc.capability_actions,
339 &bridge_mobile_shortcuts.capability_actions,
340 &bridge_mobile_securestore.capability_actions,
341 &bridge_mobile_biometric.capability_actions,
342 &bridge_mobile_permissions.capability_actions,
343 &bridge_mobile_db.capability_actions,
344 &bridge_mobile_notifcancel.capability_actions,
345 &bridge_mobile_notifications.capability_actions,
346 &bridge_mobile_bgtasks.capability_actions,
347 &bridge_mobile_watch.capability_actions,
348 &bridge_mobile_location.capability_actions,
349 &bridge_mobile_locrecording.capability_actions,
350 &bridge_mobile_motion.capability_actions,
351 &bridge_mobile_imagepicker.capability_actions,
352 &bridge_mobile_filepicker.capability_actions,
353 &bridge_mobile_contactpicker.capability_actions,
354 &bridge_mobile_calendar.capability_actions,
355 &bridge_mobile_contacts.capability_actions,
356 &bridge_mobile_vision.capability_actions,
357 &bridge_mobile_auth.capability_actions,
358 &bridge_mobile_siri.capability_actions,
359 &bridge_mobile_pdf.capability_actions,
360 &bridge_mobile_bluetooth.capability_actions,
361 &bridge_mobile_audiorec.capability_actions,
362 &bridge_mobile_health.capability_actions,
363 &bridge_mobile_speech.capability_actions,
364};
365
366comptime {
367 if (mobile_manifests.len != mobile_bridges.len) {
368 @compileError("mobile_manifests and mobile_bridges must list the same modules");
369 }
370}
371
372/// Whether a host that offered this action has to serve it itself.
373///
374/// `.status = .unavailable` in a module's manifest means the handler is
375/// reachable and refuses, deliberately, because Zig cannot do the thing
376/// correctly — `haptic` cannot see `config.enableHaptics`, and the rest say
377/// why in their own `reason`.
378///
379/// In the Zig-hosted app that refusal is the honest end of the line: `route`
380/// tries `handOffToHost` next, and the shim is what actually serves it. The
381/// host-offered path has no such fallback — `craft_ios_handle_action`
382/// deliberately does not consult `handOffToHost`, because there the host *is*
383/// the caller. So claiming one of these would not be a refusal instead of an
384/// answer, it would be a refusal instead of the *host's working answer*: an app
385/// that has haptics today would stop buzzing the moment the runtime was linked,
386/// and the page would get a rejection where it used to get silence-and-a-buzz.
387///
388/// `test/ios_conformance_test.zig` already treats falling through as better
389/// than `.unavailable`. This is that rule, applied to the one path that had no
390/// way to fall through.
391fn hostServesItself(action: []const u8) bool {
392 for (mobile_manifests) |manifest| {
393 for (manifest) |decl| {
394 if (decl.status == .unavailable and std.mem.eql(u8, decl.name, action)) return true;
395 }
396 }
397 return false;
398}
399
322400/// Narrow an arbitrary handler error to one the page's error codes can express.
323401///
324402/// A handler's error set is wider than `BridgeError` — it picks up allocation
@@ -444,6 +522,15 @@ export fn craft_ios_handle_action(
444522 request_context.current(),
445523 });
446524
525 // Before the gate and the module chain, because this is not a question
526 // about what the config allows or what a handler would do — it is a
527 // question about which arm owns the action at all, and the answer is the
528 // caller's own.
529 if (hostServesItself(action)) {
530 std.log.info("ios: {s} is declared unavailable; leaving it to the host", .{action});
531 return false;
532 }
533
447534 const offer = offerToModules(std.heap.c_allocator, action, payload) catch {
448535 // A module ran and failed, and answered the page on its way out. That
449536 // is a served action with an unhappy result, not an unserved one.
@@ -761,3 +848,47 @@ test "setting the webview from a host is what makes replies reachable" {
761848 craft_ios_set_webview(null);
762849 try testing.expectError(error.NoWebView, evalJS("1"));
763850}
851
852test "an action declared unavailable is left to the host, not claimed" {
853 // The regression this guards. Each of these has a working Swift arm and a
854 // Zig handler that refuses on purpose; before the host-offered path knew
855 // the difference, linking the runtime replaced the working answer with a
856 // rejection. `false` here is what sends the call back to Swift's switch.
857 const declared_unavailable = [_][]const u8{
858 "haptic",
859 "getNetworkStatus",
860 "lockOrientation",
861 "unlockOrientation",
862 };
863 for (declared_unavailable) |action| {
864 try testing.expect(hostServesItself(action));
865 try testing.expect(!craft_ios_handle_action(action.ptr, action.len, "{}", 2, 11));
866 }
867}
868
869test "the fall-through is read from the manifests, not from a list kept beside them" {
870 // `hostServesItself` must answer for exactly the actions the modules
871 // declare `.unavailable` — no more, or a working Zig action is handed away;
872 // no fewer, or the regression comes back for whichever one was missed. So
873 // count the declarations rather than trusting the test above's literals.
874 var declared: usize = 0;
875 for (mobile_manifests) |manifest| {
876 for (manifest) |decl| {
877 if (decl.status == .unavailable) {
878 declared += 1;
879 try testing.expect(hostServesItself(decl.name));
880 }
881 }
882 }
883 try testing.expectEqual(@as(usize, 4), declared);
884}
885
886test "a live action is still claimed, unavailable is not a blanket hand-back" {
887 // The other half: `vibrate` sits in the same module as `haptic` and is
888 // `.live`, so the seam must keep claiming it. A fall-through that widened
889 // to whole modules would silently un-migrate everything beside a refusal.
890 try testing.expect(!hostServesItself("vibrate"));
891 try testing.expect(!hostServesItself("setKeepAwake"));
892 try testing.expect(!hostServesItself("getDeviceInfo"));
893 try testing.expect(!hostServesItself("noSuchAction"));
894}
packages/zig/test/ios_conformance_test.zigmodified+48-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -649,3 +649,51 @@ test "Zig requires exactly the keys the spec's decoder requires" {
649649 try testing.expect(required >= 40);
650650 try testing.expect(optional_seen >= 1);
651651}
652
653test "getDeviceInfo answers every field the spec answers" {
654 // The regression that made this test necessary. Zig's `getDeviceInfo` is
655 // `.live`, so the Swift-hosted seam prefers it over the spec's arm — and
656 // it answered four of the spec's fourteen fields. A page reading
657 // `screenWidth` or `locale` got `undefined` from an action that reported
658 // success, which is the exact failure `.unavailable` exists to prevent and
659 // could not catch, because the action does work; it just works less.
660 //
661 // Keys are read out of the spec rather than listed here, so a field added
662 // to `CraftApp.swift` fails this test until Zig answers it too.
663 // Anchored to the function, not to `let info:` — the spec has more than
664 // one dictionary spelled that way (`getMemoryUsage` has another), and the
665 // first match is not this one.
666 const fn_start = std.mem.indexOf(u8, swift_spec, "private func getDeviceInfo(callbackId:") orelse
667 return error.SpecShapeChanged;
668 const body = swift_spec[fn_start..];
669 const start = std.mem.indexOf(u8, body, "let info: [String: Any] = [") orelse
670 return error.SpecShapeChanged;
671 const after = body[start..];
672 const end = std.mem.indexOf(u8, after, "\n ]") orelse return error.SpecShapeChanged;
673 const block = after[0..end];
674
675 const zig_src = @embedFile("src/bridge_mobile.zig");
676
677 var it = std.mem.splitScalar(u8, block, '\n');
678 var checked: usize = 0;
679 while (it.next()) |line| {
680 const q1 = std.mem.indexOfScalar(u8, line, '"') orelse continue;
681 const rest = line[q1 + 1 ..];
682 const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse continue;
683 const key = rest[0..q2];
684 if (key.len == 0) continue;
685
686 // Zig builds the reply as a format string, so the key appears in the
687 // source as the escaped `\"key\":` it will emit.
688 var needle_buf: [64]u8 = undefined;
689 const needle = try std.fmt.bufPrint(&needle_buf, "\\\"{s}\\\":", .{key});
690 if (std.mem.indexOf(u8, zig_src, needle) == null) {
691 std.debug.print("getDeviceInfo: the spec answers `{s}` and Zig does not\n", .{key});
692 return error.FieldMissingFromZig;
693 }
694 checked += 1;
695 }
696
697 // A shape change that silently matched nothing would otherwise pass.
698 try testing.expectEqual(@as(usize, 14), checked);
699}