also looking at this
feat(ios): the Zig runtime, offered to a Swift-hosted app
#103
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.
| @@ -52,7 +52,7 @@ pub const MobileBridge = struct { | ||
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | fn getDeviceInfo(self: *Self) !void { |
| 55 | var buf: [512]u8 = undefined; | |
| 55 | var buf: [4096]u8 = undefined; | |
| 56 | 56 | const json = try describeDevice(&buf); |
| 57 | 57 | bridge_error.sendResultToJS(self.allocator, A.get_device_info, json); |
| 58 | 58 | } |
| @@ -88,14 +88,142 @@ fn describeDevice(buf: []u8) ![]const u8 { | ||
| 88 | 88 | const system_name = try nsStringField(device, "systemName"); |
| 89 | 89 | const system_version = try nsStringField(device, "systemVersion"); |
| 90 | 90 | 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; | |
| 91 | 139 | |
| 92 | 140 | return std.fmt.bufPrint( |
| 93 | 141 | 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 | }, | |
| 96 | 173 | ); |
| 97 | 174 | } |
| 98 | 175 | |
| 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. | |
| 181 | fn 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. | |
| 198 | fn 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). | |
| 205 | fn 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. | |
| 214 | fn 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`. | |
| 221 | fn 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 | ||
| 99 | 227 | /// Send a zero-argument selector that returns an NSString, and borrow its UTF-8. |
| 100 | 228 | /// |
| 101 | 229 | /// The returned slice points into the NSString's own buffer, which is |
| @@ -3,6 +3,7 @@ const builtin = ; | ||
| 3 | 3 | const objc_runtime = ; |
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | const capabilities = ; | |
| 6 | 7 | pub const ios_async = ; |
| 7 | 8 | pub const ios_events = ; |
| 8 | 9 | pub const ios_delegate = ; |
| @@ -319,6 +320,83 @@ const mobile_bridges = .{ | ||
| 319 | 320 | bridge_mobile_speech.SpeechBridge, |
| 320 | 321 | }; |
| 321 | 322 | |
| 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. | |
| 330 | const 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 | ||
| 366 | comptime { | |
| 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. | |
| 391 | fn 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 | ||
| 322 | 400 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| 323 | 401 | /// |
| 324 | 402 | /// A handler's error set is wider than `BridgeError` — it picks up allocation |
| @@ -444,6 +522,15 @@ export fn craft_ios_handle_action( | ||
| 444 | 522 | request_context.current(), |
| 445 | 523 | }); |
| 446 | 524 | |
| 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 | ||
| 447 | 534 | const offer = offerToModules(std.heap.c_allocator, action, payload) catch { |
| 448 | 535 | // A module ran and failed, and answered the page on its way out. That |
| 449 | 536 | // 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" { | ||
| 761 | 848 | craft_ios_set_webview(null); |
| 762 | 849 | try testing.expectError(error.NoWebView, evalJS("1")); |
| 763 | 850 | } |
| 851 | ||
| 852 | test "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 | ||
| 869 | test "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 | ||
| 886 | test "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 | } | |
| @@ -649,3 +649,51 @@ test "Zig requires exactly the keys the spec's decoder requires" { | ||
| 649 | 649 | try testing.expect(required >= 40); |
| 650 | 650 | try testing.expect(optional_seen >= 1); |
| 651 | 651 | } |
| 652 | ||
| 653 | test "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 | } | |