ReviewOS

also looking at this

craft-native/craft

feat(ios): AR moves to Zig — ratchet 18 -> 13

#126
Merged glennmichael123 wants to merge feat/ios-ar into main
5 files +1480 -10

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/build.zigmodified+7-0
Changes to packages/zig/build.zig
@@ -374,6 +374,10 @@ pub fn build(b: *std.Build) void {
374374 ios_module_tests.root_module.linkFramework("CoreGraphics", .{});
375375 ios_module_tests.root_module.linkFramework("CoreMIDI", .{});
376376 ios_module_tests.root_module.linkFramework("Security", .{});
377 // SceneKit is on macOS as well as iOS, which is the only reason
378 // `bridge_mobile_ar.zig`'s three struct-ABI tests can run on a host:
379 // ARKit is device-only, SCNNode and SCNBox are not.
380 ios_module_tests.root_module.linkFramework("SceneKit", .{});
377381 ios_module_tests.root_module.addCSourceFile(.{
378382 .file = b.path("vendor/sqlite/sqlite3.c"),
379383 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
@@ -485,6 +489,9 @@ pub fn build(b: *std.Build) void {
485489 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_locrecording.zig", .{
486490 .root_source_file = b.path("src/bridge_mobile_locrecording.zig"),
487491 });
492 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_ar.zig", .{
493 .root_source_file = b.path("src/bridge_mobile_ar.zig"),
494 });
488495 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_motion.zig", .{
489496 .root_source_file = b.path("src/bridge_mobile_motion.zig"),
490497 });
packages/zig/src/bridge_mobile_ar.zigadded+1450-0
Changes to packages/zig/src/bridge_mobile_ar.zig
@@ -0,0 +1,1450 @@
1//! The five `startAR` / `stopAR` / `placeARObject` / `removeARObject` /
2//! `getARPlanes` actions of the `mobile` namespace.
3//!
4//! This is the last group whose deferral reason was an effort judgement rather
5//! than a wall — "SceneKit node-graph glue; miserable and low-value through
6//! `objc_msgSend`" — so it is worth being precise about what was actually hard,
7//! because it was not the node graph.
8//!
9//! ## Three struct returns, and why they are the whole risk
10//!
11//! Every other migrated module passes and returns pointers and integers. This
12//! one passes and returns *structs by value*, in floating-point registers,
13//! three times:
14//!
15//! - `CGRect` (`UIScreen.main.bounds`, `-initWithFrame:`) — four `CGFloat`s,
16//! a homogeneous float aggregate returned in `v0`-`v3`.
17//! - `SCNVector3` (`SCNNode.position`) — three components in `v0`-`v2`.
18//! - `simd_float3` (`ARPlaneAnchor.center` / `.extent`) — sixteen bytes in a
19//! *single* 128-bit vector register, which is a different rule from the
20//! other two and the one most likely to be got wrong.
21//!
22//! Getting any of them wrong does not crash. It yields plausible coordinates
23//! that are not the ones ARKit measured — a wrong answer delivered as a right
24//! one, which is the failure this migration exists to remove. So the three are
25//! not asserted from the headers and hoped for; they are exercised on a host,
26//! against real SceneKit objects, in the tests at the bottom of this file.
27//!
28//! **`SCNVector3` is not the same type on the two platforms.** macOS declares
29//! it as three `CGFloat`s and iOS as three `float`s
30//! (`SceneKitTypes.h` in each SDK, checked in both). The alias below follows
31//! the target; a host test therefore verifies the *mechanism* — a
32//! three-element homogeneous aggregate through `objc_msgSend` — at double
33//! width, not the exact iOS layout. That is a real limit on what the tests
34//! below prove, and it is why the alias is derived from the target rather than
35//! written out once.
36//!
37//! ## What cannot be verified here at all
38//!
39//! ARKit is device-only: `ARWorldTrackingConfiguration.isSupported` is false in
40//! the simulator, so `startAR` refuses there and every line past the guard is
41//! unreachable without hardware. The tests below cover the argument parsing,
42//! the reply shapes, the plane JSON and the three ABI mechanisms; they do not
43//! cover a running AR session, and no test in this repository can.
44
45const std = ;
46const builtin = ;
47const capabilities = ;
48const bridge_error = ;
49const objc_runtime = ;
50const ios_events = ;
51
52const objc = objc_runtime.objc;
53const is_darwin = builtin.target.os.tag.isDarwin();
54const is_ios = builtin.target.os.tag == .ios;
55
56/// The same type as `objc.id` — `?*anyopaque` — spelled locally, so a
57/// `callconv(.c)` signature stays legal off Darwin.
58const Id = ?*anyopaque;
59
60// =============================================================================
61// The three struct types that cross the ABI boundary.
62// =============================================================================
63
64/// `CGFloat`: 64-bit on every target this ships to (arm64 and x86_64 alike).
65const CGFloat = f64;
66
67const CGPoint = extern struct { x: CGFloat = 0, y: CGFloat = 0 };
68const CGSize = extern struct { width: CGFloat = 0, height: CGFloat = 0 };
69const CGRect = extern struct { origin: CGPoint = .{}, size: CGSize = .{} };
70
71/// `SCNVector3`'s component type, which the two SDKs disagree about.
72///
73/// `SceneKitTypes.h` declares `CGFloat x, y, z` on macOS and `float x, y, z`
74/// on iOS. Both are homogeneous three-element aggregates, so the calling
75/// convention is the same shape at two widths — but a single hardcoded width
76/// would silently misread every coordinate on one of the two.
77const ScnFloat = if (is_ios) f32 else CGFloat;
78
79const SCNVector3 = extern struct { x: ScnFloat = 0, y: ScnFloat = 0, z: ScnFloat = 0 };
80
81/// `simd_float3`: three floats in sixteen bytes, returned in one 128-bit
82/// vector register rather than as a float aggregate.
83///
84/// `@Vector(4, f32)` rather than a four-field struct on purpose. An
85/// `extern struct { f32, f32, f32, f32 }` is a homogeneous aggregate and comes
86/// back in `v0`-`v3`; `simd_float3` comes back in `v0` alone. The two disagree
87/// about everything but `x`, which is exactly the kind of bug that reads as
88/// "the y coordinate is always zero" rather than as a crash.
89const SimdFloat3 = @Vector(4, f32);
90
91fn simdX(v: SimdFloat3) f32 {
92 return v[0];
93}
94fn simdY(v: SimdFloat3) f32 {
95 return v[1];
96}
97fn simdZ(v: SimdFloat3) f32 {
98 return v[2];
99}
100
101/// The typed `objc_msgSend` casts for the three struct shapes.
102///
103/// `objc_msgSend` and not `objc_msgSend_stret` for every one of them: on arm64
104/// a homogeneous float aggregate of four or fewer members, and a 16-byte
105/// vector, are all returned in registers, and `_stret` does not exist on that
106/// architecture at all. This is arm64-only reasoning, which is what both the
107/// device and the host run.
108fn selector(name: [*:0]const u8) !Id {
109 if (!is_darwin) return error.UnsupportedPlatform;
110 return objc.sel_registerName(name) orelse error.SelectorNotFound;
111}
112
113fn msgSendRect(target: Id, sel: Id) CGRect {
114 const Fn = *const fn (Id, Id) callconv(.c) CGRect;
115 const f: Fn = @ptrCast(&objc.objc_msgSend);
116 return f(target, sel);
117}
118
119fn msgSendIdWithRect(target: Id, sel: Id, rect: CGRect) Id {
120 const Fn = *const fn (Id, Id, CGRect) callconv(.c) Id;
121 const f: Fn = @ptrCast(&objc.objc_msgSend);
122 return f(target, sel, rect);
123}
124
125fn msgSendVector3(target: Id, sel: Id) SCNVector3 {
126 const Fn = *const fn (Id, Id) callconv(.c) SCNVector3;
127 const f: Fn = @ptrCast(&objc.objc_msgSend);
128 return f(target, sel);
129}
130
131fn msgSendSetVector3(target: Id, sel: Id, value: SCNVector3) void {
132 const Fn = *const fn (Id, Id, SCNVector3) callconv(.c) void;
133 const f: Fn = @ptrCast(&objc.objc_msgSend);
134 f(target, sel, value);
135}
136
137fn msgSendSimd3(target: Id, sel: Id) SimdFloat3 {
138 const Fn = *const fn (Id, Id) callconv(.c) SimdFloat3;
139 const f: Fn = @ptrCast(&objc.objc_msgSend);
140 return f(target, sel);
141}
142
143fn msgSendCGFloat(target: Id, sel: Id) CGFloat {
144 const Fn = *const fn (Id, Id) callconv(.c) CGFloat;
145 const f: Fn = @ptrCast(&objc.objc_msgSend);
146 return f(target, sel);
147}
148
149// =============================================================================
150// The actions.
151// =============================================================================
152
153pub const A = struct {
154 pub const start_ar = "startAR";
155 pub const stop_ar = "stopAR";
156 pub const place_ar_object = "placeARObject";
157 pub const remove_ar_object = "removeARObject";
158 pub const get_ar_planes = "getARPlanes";
159};
160
161/// `.result` throughout: each of the five Swift paths ends in exactly one
162/// `resolveCallback` or one `rejectCallback`, and each is awaited by the page.
163///
164/// `.live`, not `.unavailable`: these dispatch and do the thing. A device
165/// without world tracking is refused at run time by
166/// `ARWorldTrackingConfiguration.isSupported`, which is a fact about the
167/// hardware rather than about this build.
168pub const capability_actions = [_]capabilities.ActionDecl{
169 .{ .name = A.start_ar, .reply = .result },
170 .{ .name = A.stop_ar, .reply = .result },
171 .{ .name = A.place_ar_object, .reply = .result },
172 .{ .name = A.remove_ar_object, .reply = .result },
173 .{ .name = A.get_ar_planes, .reply = .result },
174};
175
176const Route = enum { start, stop, place, remove, planes };
177
178fn routeFor(action: []const u8) ?Route {
179 if (std.mem.eql(u8, action, A.start_ar)) return .start;
180 if (std.mem.eql(u8, action, A.stop_ar)) return .stop;
181 if (std.mem.eql(u8, action, A.place_ar_object)) return .place;
182 if (std.mem.eql(u8, action, A.remove_ar_object)) return .remove;
183 if (std.mem.eql(u8, action, A.get_ar_planes)) return .planes;
184 return null;
185}
186
187/// `[.horizontal, .vertical]`. `ARPlaneDetectionHorizontal` is `1 << 0` and
188/// `ARPlaneDetectionVertical` is `1 << 1` (`ARPlaneDetectionTypes.h`), read out
189/// of the iphoneos SDK rather than guessed.
190const ar_plane_detection_horizontal_and_vertical: c_ulong = 3;
191
192/// `AREnvironmentTexturingAutomatic`: the third member of an
193/// `NS_ENUM(NSInteger)` that begins at `None` (`ARConfiguration.h`).
194const ar_environment_texturing_automatic: c_long = 2;
195
196/// `ARPlaneAnchorAlignmentHorizontal` is 0, `…Vertical` is 1
197/// (`ARPlaneAnchor.h`). Swift's ternary is `== .horizontal ? … : …`, so
198/// everything that is not 0 reads as vertical — including a value neither
199/// constant covers, which is why this is a comparison and not a lookup.
200const ar_plane_alignment_horizontal: c_long = 0;
201
202/// `arView.tag = 9999`, which Swift sets under the comment "For removal later"
203/// and then never reads — `stopAR` holds the view directly. Carried across
204/// anyway: it is visible to anything else walking the view hierarchy, and
205/// dropping it would be a silent change to what that sees.
206const ar_view_tag: c_long = 9999;
207
208/// `SCNVector3(0, 0, -0.5)`, and the `?? -0.5` in `pos["z"]`.
209const default_z: f64 = -0.5;
210
211// =============================================================================
212// The session, and the two collections that outlive a dispatch.
213//
214// Unguarded, as Swift's are. `ios_dispatch` delivers page messages from
215// `craftDidReceiveScriptMessage`, a WebKit main-thread callback, and SceneKit
216// delivers `renderer:didAdd:` on the main thread as well, so writer and reader
217// are the same thread.
218// =============================================================================
219
220var ar_view: Id = null;
221var ar_session: Id = null;
222
223/// `arObjects: [String: SCNNode]`. Keys owned by the map, values retained.
224var ar_objects: std.StringHashMapUnmanaged(Id) = .empty;
225
226/// `detectedPlanes: [UUID: ARPlaneAnchor]`, keyed by `UUIDString`.
227///
228/// The anchors are stored retained and read at `getARPlanes` time rather than
229/// flattened to numbers when they arrive, because that is what Swift does: its
230/// dictionary holds the anchor objects, and `getARPlanes` reads `.center` and
231/// `.extent` off them at the moment it is asked. Flattening on arrival would
232/// answer with a plane's first measurement forever.
233var ar_planes: std.StringHashMapUnmanaged(Id) = .empty;
234
235/// Module-level collections outlive every dispatch frame, so they cannot borrow
236/// a bridge's allocator.
237const ar_allocator = std.heap.c_allocator;
238
239// =============================================================================
240// Small Objective-C helpers.
241// =============================================================================
242
243fn retain(object: Id) void {
244 if (!is_darwin) return;
245 const sel = objc.sel_registerName("retain") orelse return;
246 _ = objc.msgSendId(object, sel);
247}
248
249fn release(object: Id) void {
250 if (!is_darwin) return;
251 const sel = objc.sel_registerName("release") orelse return;
252 objc.msgSend(object, sel);
253}
254
255/// An autoreleased `NSString` for a Zig slice.
256fn makeNSString(text: []const u8) ?Id {
257 if (!is_darwin) return null;
258
259 var buf: [4096]u8 = undefined;
260 if (text.len >= buf.len) return null;
261 @memcpy(buf[0..text.len], text);
262 buf[text.len] = 0;
263
264 const NSString = objc.objc_getClass("NSString") orelse return null;
265 const sel = objc.sel_registerName("stringWithUTF8String:") orelse return null;
266 return objc.msgSendId1(NSString, sel, @as([*:0]const u8, @ptrCast(&buf)));
267}
268
269/// An owned copy of a zero-argument `NSString`-returning property.
270fn copyNSStringProperty(allocator: std.mem.Allocator, object: Id, comptime name: [*:0]const u8) ![]u8 {
271 if (!is_darwin) return error.UnsupportedPlatform;
272
273 const sel = objc.sel_registerName(name) orelse return error.SelectorNotFound;
274 const value = objc.msgSendId(object, sel) orelse return error.NotFound;
275 const utf8 = objc.getNSStringUTF8(value) orelse return error.NotFound;
276 return allocator.dupe(u8, std.mem.span(utf8));
277}
278
279/// `UUID().uuidString`, through `NSUUID`.
280fn newObjectId(allocator: std.mem.Allocator) ![]u8 {
281 if (!is_darwin) return error.UnsupportedPlatform;
282
283 const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound;
284 const sel_uuid = try selector("UUID");
285 const uuid = objc.msgSendId(NSUUID, sel_uuid) orelse return error.NativeCallFailed;
286 return copyNSStringProperty(allocator, uuid, "UUIDString");
287}
288
289fn msgSendLong(target: Id, sel: Id) c_long {
290 const Fn = *const fn (Id, Id) callconv(.c) c_long;
291 const f: Fn = @ptrCast(&objc.objc_msgSend);
292 return f(target, sel);
293}
294
295fn msgSendVoidLong(target: Id, sel: Id, value: c_long) void {
296 const Fn = *const fn (Id, Id, c_long) callconv(.c) void;
297 const f: Fn = @ptrCast(&objc.objc_msgSend);
298 f(target, sel, value);
299}
300
301fn msgSendVoidULong(target: Id, sel: Id, value: c_ulong) void {
302 const Fn = *const fn (Id, Id, c_ulong) callconv(.c) void;
303 const f: Fn = @ptrCast(&objc.objc_msgSend);
304 f(target, sel, value);
305}
306
307fn msgSendVoidBool(target: Id, sel: Id, value: bool) void {
308 const Fn = *const fn (Id, Id, bool) callconv(.c) void;
309 const f: Fn = @ptrCast(&objc.objc_msgSend);
310 f(target, sel, value);
311}
312
313/// A class-method `BOOL` with no arguments, e.g.
314/// `+[ARWorldTrackingConfiguration isSupported]`.
315fn classBool(class_name: [*:0]const u8, comptime sel_name: [*:0]const u8) bool {
316 if (!is_darwin) return false;
317 const cls = objc.objc_getClass(class_name) orelse return false;
318 const sel = objc.sel_registerName(sel_name) orelse return false;
319 return objc.msgSendBool(cls, sel);
320}
321
322// =============================================================================
323// The plane shape, kept pure so the JSON the page reads is pinnable on a host.
324// =============================================================================
325
326/// One entry of `getARPlanes`, and the body of a `craftARPlane` event.
327///
328/// The seven numbers Swift reads off an `ARPlaneAnchor`, flattened at the point
329/// of use. `extent` contributes **x and z** — Swift maps them to `width` and
330/// `height`, so a plane's `y` extent never reaches the page.
331const Plane = struct {
332 id: []const u8,
333 horizontal: bool,
334 center_x: f32,
335 center_y: f32,
336 center_z: f32,
337 extent_x: f32,
338 extent_z: f32,
339};
340
341fn appendPlaneObject(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), plane: Plane) !void {
342 try out.append(allocator, '{');
343 try appendPlaneFields(allocator, out, plane);
344 try out.append(allocator, '}');
345}
346
347/// The four keys, without the braces, so `getARPlanes` and the `craftARPlane`
348/// event cannot drift apart: the event is the same object with a `type` in
349/// front of it.
350fn appendPlaneFields(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), plane: Plane) !void {
351 try out.appendSlice(allocator, "\"id\":\"");
352 try bridge_error.appendJsonEscaped(allocator, out, plane.id);
353 try out.appendSlice(allocator, "\",\"alignment\":\"");
354 try out.appendSlice(allocator, if (plane.horizontal) "horizontal" else "vertical");
355 try out.appendSlice(allocator, "\",\"center\":{\"x\":");
356 try appendFloat(allocator, out, plane.center_x);
357 try out.appendSlice(allocator, ",\"y\":");
358 try appendFloat(allocator, out, plane.center_y);
359 try out.appendSlice(allocator, ",\"z\":");
360 try appendFloat(allocator, out, plane.center_z);
361 try out.appendSlice(allocator, "},\"extent\":{\"width\":");
362 try appendFloat(allocator, out, plane.extent_x);
363 try out.appendSlice(allocator, ",\"height\":");
364 try appendFloat(allocator, out, plane.extent_z);
365 try out.append(allocator, '}');
366}
367
368/// `allocPrint`, not a stack buffer: an `f32` rendered with `{d}` has no small
369/// bound, and a `NoSpaceLeft` here would turn a measured plane into a refusal.
370fn appendFloat(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: f32) !void {
371 const rendered = try std.fmt.allocPrint(allocator, "{d}", .{value});
372 defer allocator.free(rendered);
373 try out.appendSlice(allocator, rendered);
374}
375
376/// The bare JSON array `getARPlanes` resolves.
377///
378/// `resolveCallback(callbackId, result: planes)` through `.fragmentsAllowed`
379/// puts the array on the wire unwrapped, so `[]` — not `{"planes":[]}` — is the
380/// answer when nothing has been detected.
381fn shapePlanes(allocator: std.mem.Allocator, planes: []const Plane) ![]u8 {
382 var out: std.ArrayListUnmanaged(u8) = .empty;
383 defer out.deinit(allocator);
384
385 try out.append(allocator, '[');
386 for (planes, 0..) |plane, i| {
387 if (i != 0) try out.append(allocator, ',');
388 try appendPlaneObject(allocator, &out, plane);
389 }
390 try out.append(allocator, ']');
391
392 return out.toOwnedSlice(allocator);
393}
394
395// =============================================================================
396// The bridge.
397// =============================================================================
398
399pub const ARBridge = struct {
400 allocator: std.mem.Allocator,
401
402 const Self = @This();
403
404 pub fn init(allocator: std.mem.Allocator) Self {
405 return .{ .allocator = allocator };
406 }
407
408 pub fn deinit(_: *Self) void {}
409
410 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
411 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
412 // Exhaustive, so a `Route` without a handler is a compile error.
413 return switch (route) {
414 .start => self.startAR(data),
415 .stop => self.stopAR(data),
416 .place => self.placeARObject(data),
417 .remove => self.removeARObject(data),
418 .planes => self.getARPlanes(data),
419 };
420 }
421
422 /// Put an `ARSCNView` on the app's window and run world tracking on it.
423 ///
424 /// `data` is accepted and ignored, which is not laziness: the Swift
425 /// dispatcher parses `body["options"]` into a dictionary and hands it to
426 /// `startAR(options:callbackId:)`, whose body never reads it. Parsing it
427 /// here would invent a rejection for a malformed field the shim accepts.
428 ///
429 /// Swift wraps the whole body in `DispatchQueue.main.async`. This does not,
430 /// because dispatch already arrives on the main thread — the hop would only
431 /// defer the reply by a runloop turn. See the ordering note on
432 /// `getARPlanes`, which is the one place that is observable.
433 fn startAR(self: *Self, data: []const u8) !void {
434 _ = data;
435 if (!is_darwin) return error.UnsupportedPlatform;
436
437 // `guard ARWorldTrackingConfiguration.isSupported else { reject }`.
438 // Also the answer on a host and in the simulator, where the class is
439 // absent or reports false — `classBool` gives the same `false` either
440 // way, which is the honest answer for both.
441 if (!classBool("ARWorldTrackingConfiguration", "isSupported")) {
442 std.log.warn("startAR: ARWorldTrackingConfiguration reports unsupported; refusing, as the shim does", .{});
443 return bridge_error.BridgeError.PlatformNotSupported;
444 }
445
446 const ARSCNView = objc.objc_getClass("ARSCNView") orelse return error.ClassNotFound;
447 const ARWorldTrackingConfiguration = objc.objc_getClass("ARWorldTrackingConfiguration") orelse
448 return error.ClassNotFound;
449
450 const bounds = try screenBounds();
451
452 const allocated = objc.msgSendId(ARSCNView, try selector("alloc")) orelse
453 return error.NativeCallFailed;
454 const view = msgSendIdWithRect(allocated, try selector("initWithFrame:"), bounds) orelse
455 return error.NativeCallFailed;
456
457 msgSendVoidBool(view, try selector("setAutoenablesDefaultLighting:"), true);
458 msgSendVoidLong(view, try selector("setTag:"), ar_view_tag);
459 objc.msgSendVoid1(view, try selector("setDelegate:"), try ensureDelegate());
460
461 const configuration = (try objc.allocInit(ARWorldTrackingConfiguration)) orelse
462 return error.NativeCallFailed;
463 msgSendVoidULong(
464 configuration,
465 try selector("setPlaneDetection:"),
466 ar_plane_detection_horizontal_and_vertical,
467 );
468 msgSendVoidLong(
469 configuration,
470 try selector("setEnvironmentTexturing:"),
471 ar_environment_texturing_automatic,
472 );
473
474 const session = objc.msgSendId(view, try selector("session")) orelse
475 return error.NativeCallFailed;
476
477 // Swift's `if let windowScene = …, let window = … { window.addSubview }`
478 // is a soft failure: the session still runs, the view is just not
479 // visible. Kept soft here, and logged, because refusing would be a
480 // different answer from the shim's.
481 addViewToKeyWindow(view);
482
483 objc.msgSendVoid1(session, try selector("runWithConfiguration:"), configuration);
484
485 ar_view = view;
486 ar_session = session;
487
488 bridge_error.sendResultToJS(self.allocator, A.start_ar, "{\"started\":true}");
489 }
490
491 /// Pause the session, take the view off the window, and forget everything.
492 ///
493 /// No guard: `stopAR` has none in Swift, so stopping a session that never
494 /// started answers `{"stopped":true}` rather than an error. "There is no
495 /// session" and "the session is stopped" are the same state to a caller.
496 fn stopAR(self: *Self, data: []const u8) !void {
497 _ = data;
498 if (!is_darwin) return error.UnsupportedPlatform;
499
500 if (ar_session) |session| {
501 objc.msgSend(session, try selector("pause"));
502 }
503 if (ar_view) |view| {
504 objc.msgSend(view, try selector("removeFromSuperview"));
505 release(view);
506 }
507 ar_view = null;
508 ar_session = null;
509
510 clearObjects();
511 clearPlanes();
512
513 bridge_error.sendResultToJS(self.allocator, A.stop_ar, "{\"stopped\":true}");
514 }
515
516 /// Add a node to the running session's scene.
517 fn placeARObject(self: *Self, data: []const u8) !void {
518 if (!is_darwin) return error.UnsupportedPlatform;
519
520 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
521 return bridge_error.BridgeError.InvalidJSON;
522 defer parsed.deinit();
523
524 const object = switch (parsed.value) {
525 .object => |o| o,
526 else => return invalidArgument("placeARObject"),
527 };
528
529 // `if let model = body["model"] as? String` — a missing or non-string
530 // `model` is INVALID_ARGUMENT, and the position is read only after.
531 const model = switch (object.get("model") orelse return invalidArgument("placeARObject")) {
532 .string => |s| s,
533 else => return invalidArgument("placeARObject"),
534 };
535
536 const view = ar_view orelse {
537 std.log.warn("placeARObject: no AR session is running", .{});
538 return bridge_error.BridgeError.InvalidParameter;
539 };
540
541 const position = readPosition(object.get("position"));
542
543 const node = try buildNode(model);
544
545 const object_id = try newObjectId(ar_allocator);
546 errdefer ar_allocator.free(object_id);
547
548 try setNodePosition(node, position);
549
550 const ns_id = makeNSString(object_id) orelse return error.NativeCallFailed;
551 objc.msgSendVoid1(node, try selector("setName:"), ns_id);
552
553 const scene = objc.msgSendId(view, try selector("scene")) orelse return error.NativeCallFailed;
554 const root = objc.msgSendId(scene, try selector("rootNode")) orelse return error.NativeCallFailed;
555 objc.msgSendVoid1(root, try selector("addChildNode:"), node);
556
557 retain(node);
558 try ar_objects.put(ar_allocator, object_id, node);
559
560 var out: std.ArrayListUnmanaged(u8) = .empty;
561 defer out.deinit(self.allocator);
562 try out.appendSlice(self.allocator, "{\"objectId\":\"");
563 try bridge_error.appendJsonEscaped(self.allocator, &out, object_id);
564 try out.appendSlice(self.allocator, "\",\"placed\":true}");
565
566 bridge_error.sendResultToJS(self.allocator, A.place_ar_object, out.items);
567 }
568
569 /// Take a node back out of the scene.
570 fn removeARObject(self: *Self, data: []const u8) !void {
571 if (!is_darwin) return error.UnsupportedPlatform;
572
573 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
574 return bridge_error.BridgeError.InvalidJSON;
575 defer parsed.deinit();
576
577 const object = switch (parsed.value) {
578 .object => |o| o,
579 else => return invalidArgument("removeARObject"),
580 };
581 const object_id = switch (object.get("objectId") orelse return invalidArgument("removeARObject")) {
582 .string => |s| s,
583 else => return invalidArgument("removeARObject"),
584 };
585
586 // `if let node = self.arObjects[objectId] … else { reject("Object not found") }`.
587 const entry = ar_objects.fetchRemove(object_id) orelse {
588 std.log.warn("removeARObject: no object with that id is placed", .{});
589 return bridge_error.BridgeError.InvalidParameter;
590 };
591 defer ar_allocator.free(entry.key);
592
593 objc.msgSend(entry.value, try selector("removeFromParentNode"));
594 release(entry.value);
595
596 bridge_error.sendResultToJS(self.allocator, A.remove_ar_object, "{\"removed\":true}");
597 }
598
599 /// Every plane the session has detected, as a bare JSON array.
600 ///
601 /// One ordering divergence, and it is a fix rather than a loss: Swift's
602 /// `getARPlanes` is the only one of the five *not* wrapped in
603 /// `DispatchQueue.main.async`, so a `startAR` immediately followed by a
604 /// `getARPlanes` has the planes reply first. Both are synchronous here, so
605 /// replies keep the order the page sent them in.
606 fn getARPlanes(self: *Self, data: []const u8) !void {
607 _ = data;
608 if (!is_darwin) return error.UnsupportedPlatform;
609
610 var planes: std.ArrayListUnmanaged(Plane) = .empty;
611 defer {
612 for (planes.items) |plane| self.allocator.free(plane.id);
613 planes.deinit(self.allocator);
614 }
615
616 var it = ar_planes.iterator();
617 while (it.next()) |entry| {
618 const plane = readPlane(self.allocator, entry.key_ptr.*, entry.value_ptr.*) catch |err| {
619 // One unreadable anchor must not cost the page the others.
620 std.log.warn("getARPlanes: could not read a plane anchor ({}); it is left out", .{err});
621 continue;
622 };
623 try planes.append(self.allocator, plane);
624 }
625
626 const json = try shapePlanes(self.allocator, planes.items);
627 defer self.allocator.free(json);
628
629 bridge_error.sendResultToJS(self.allocator, A.get_ar_planes, json);
630 }
631};
632
633/// `rejectCallback(callbackId, error: "… was called without the values it needs", code: "INVALID_ARGUMENT")`.
634///
635/// `BridgeError` carries an enum rather than free text, so the page sees
636/// `INVALID_PARAMETER` where Swift sent `INVALID_ARGUMENT` — the same trade
637/// `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document. The
638/// rejection itself, which is what the promise needs, is not lost.
639fn invalidArgument(comptime action: []const u8) bridge_error.BridgeError {
640 std.log.warn(action ++ ": called without the values it needs", .{});
641 return bridge_error.BridgeError.InvalidParameter;
642}
643
644// =============================================================================
645// Building a node, and reading a plane.
646// =============================================================================
647
648/// `SCNVector3(Float(pos["x"] ?? 0), Float(pos["y"] ?? 0), Float(pos["z"] ?? -0.5))`.
649const Position = struct { x: f64 = 0, y: f64 = 0, z: f64 = default_z };
650
651/// Swift's cast is `body["position"] as? [String: Double]`, and a
652/// **dictionary** cast fails as a whole when any one value is not a `Double`.
653/// So `{"x": 1, "y": "up"}` is not "x with a bad y", it is no position at all,
654/// and the node lands at the default. Ported exactly: one non-number anywhere
655/// discards the whole dictionary.
656fn readPosition(value: ?std.json.Value) Position {
657 const object = switch (value orelse return .{}) {
658 .object => |o| o,
659 else => return .{},
660 };
661
662 var position: Position = .{};
663 var it = object.iterator();
664 while (it.next()) |entry| {
665 const number: f64 = switch (entry.value_ptr.*) {
666 .float => |f| f,
667 .integer => |i| @floatFromInt(i),
668 // The whole cast fails, so nothing survives from this dictionary.
669 else => return .{},
670 };
671 const key = entry.key_ptr.*;
672 if (std.mem.eql(u8, key, "x")) position.x = number;
673 if (std.mem.eql(u8, key, "y")) position.y = number;
674 if (std.mem.eql(u8, key, "z")) position.z = number;
675 }
676 return position;
677}
678
679fn setNodePosition(node: Id, position: Position) !void {
680 msgSendSetVector3(node, try selector("setPosition:"), .{
681 .x = @floatCast(position.x),
682 .y = @floatCast(position.y),
683 .z = @floatCast(position.z),
684 });
685}
686
687/// `model.hasSuffix(".usdz") || model.hasSuffix(".scn")` picks the loader;
688/// everything else is a named primitive, with an unrecognised name falling to
689/// the same box as `"box"` — Swift's `default:`, not an error.
690fn buildNode(model: []const u8) !Id {
691 if (std.mem.endsWith(u8, model, ".usdz") or std.mem.endsWith(u8, model, ".scn")) {
692 return loadModelNode(model);
693 }
694 return primitiveNode(model);
695}
696
697fn loadModelNode(model: []const u8) !Id {
698 const NSURL = objc.objc_getClass("NSURL") orelse return error.ClassNotFound;
699 const ns_model = makeNSString(model) orelse return error.NativeCallFailed;
700
701 // `URL(string: model)`, which answers nil for a string that is not a URL —
702 // Swift's "Invalid model URL" rejection.
703 const url = objc.msgSendId1(NSURL, try selector("URLWithString:"), ns_model) orelse {
704 std.log.warn("placeARObject: the model string is not a URL", .{});
705 return bridge_error.BridgeError.InvalidParameter;
706 };
707
708 const SCNScene = objc.objc_getClass("SCNScene") orelse return error.ClassNotFound;
709
710 // `SCNScene(url:options:)` throws; the Objective-C spelling takes an
711 // `NSError **` and answers nil. Both are "Failed to load model".
712 var err_object: Id = null;
713 const SceneFn = *const fn (Id, Id, Id, Id, *Id) callconv(.c) Id;
714 const scene_with: SceneFn = @ptrCast(&objc.objc_msgSend);
715 const scene = scene_with(
716 SCNScene,
717 try selector("sceneWithURL:options:error:"),
718 url,
719 null,
720 &err_object,
721 ) orelse {
722 std.log.warn("placeARObject: SCNScene could not load the model", .{});
723 return bridge_error.BridgeError.NativeCallFailed;
724 };
725
726 // `let node = SCNNode(); for child in scene.rootNode.childNodes { node.addChildNode(child) }`
727 //
728 // A wrapper node, not the scene's root: `addChildNode` re-parents, so
729 // handing the page the loaded root would move the scene's own graph.
730 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound;
731 const node = objc.msgSendId(SCNNode, try selector("node")) orelse return error.NativeCallFailed;
732
733 const root = objc.msgSendId(scene, try selector("rootNode")) orelse return error.NativeCallFailed;
734 const children = objc.msgSendId(root, try selector("childNodes")) orelse return error.NativeCallFailed;
735
736 const count: c_ulong = @intCast(msgSendLong(children, try selector("count")));
737 const sel_at = try selector("objectAtIndex:");
738 const sel_add = try selector("addChildNode:");
739 const AtFn = *const fn (Id, Id, c_ulong) callconv(.c) Id;
740 const object_at: AtFn = @ptrCast(&objc.objc_msgSend);
741
742 var i: c_ulong = 0;
743 while (i < count) : (i += 1) {
744 const child = object_at(children, sel_at, i) orelse continue;
745 objc.msgSendVoid1(node, sel_add, child);
746 }
747
748 return node;
749}
750
751fn primitiveNode(model: []const u8) !Id {
752 const geometry = try namedGeometry(model);
753
754 // `geometry.firstMaterial?.diffuse.contents = UIColor.systemBlue`. Optional
755 // the whole way in Swift, so a geometry with no material is not an error.
756 if (objc.msgSendId(geometry, try selector("firstMaterial"))) |material| {
757 if (objc.msgSendId(material, try selector("diffuse"))) |property| {
758 if (systemBlue()) |color| {
759 objc.msgSendVoid1(property, try selector("setContents:"), color);
760 }
761 }
762 }
763
764 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound;
765 return objc.msgSendId1(SCNNode, try selector("nodeWithGeometry:"), geometry) orelse
766 error.NativeCallFailed;
767}
768
769/// The four named primitives, at Swift's dimensions.
770fn namedGeometry(model: []const u8) !Id {
771 const Two = *const fn (Id, Id, CGFloat, CGFloat) callconv(.c) Id;
772 const Three = *const fn (Id, Id, CGFloat, CGFloat, CGFloat) callconv(.c) Id;
773 const Four = *const fn (Id, Id, CGFloat, CGFloat, CGFloat, CGFloat) callconv(.c) Id;
774
775 if (std.mem.eql(u8, model, "sphere")) {
776 const SCNSphere = objc.objc_getClass("SCNSphere") orelse return error.ClassNotFound;
777 const f: *const fn (Id, Id, CGFloat) callconv(.c) Id = @ptrCast(&objc.objc_msgSend);
778 return f(SCNSphere, try selector("sphereWithRadius:"), 0.05) orelse error.NativeCallFailed;
779 }
780 if (std.mem.eql(u8, model, "cylinder")) {
781 const SCNCylinder = objc.objc_getClass("SCNCylinder") orelse return error.ClassNotFound;
782 const f: Two = @ptrCast(&objc.objc_msgSend);
783 return f(SCNCylinder, try selector("cylinderWithRadius:height:"), 0.05, 0.1) orelse
784 error.NativeCallFailed;
785 }
786 if (std.mem.eql(u8, model, "cone")) {
787 const SCNCone = objc.objc_getClass("SCNCone") orelse return error.ClassNotFound;
788 const f: Three = @ptrCast(&objc.objc_msgSend);
789 return f(SCNCone, try selector("coneWithTopRadius:bottomRadius:height:"), 0, 0.05, 0.1) orelse
790 error.NativeCallFailed;
791 }
792
793 // `case "box"` and `default:` are the same geometry in Swift.
794 const SCNBox = objc.objc_getClass("SCNBox") orelse return error.ClassNotFound;
795 const f: Four = @ptrCast(&objc.objc_msgSend);
796 return f(SCNBox, try selector("boxWithWidth:height:length:chamferRadius:"), 0.1, 0.1, 0.1, 0.01) orelse
797 error.NativeCallFailed;
798}
799
800fn systemBlue() ?Id {
801 const UIColor = objc.objc_getClass("UIColor") orelse return null;
802 const sel = objc.sel_registerName("systemBlueColor") orelse return null;
803 return objc.msgSendId(UIColor, sel);
804}
805
806/// `UIScreen.main.bounds`.
807fn screenBounds() !CGRect {
808 const UIScreen = objc.objc_getClass("UIScreen") orelse return error.ClassNotFound;
809 const screen = objc.msgSendId(UIScreen, try selector("mainScreen")) orelse
810 return error.NativeCallFailed;
811 return msgSendRect(screen, try selector("bounds"));
812}
813
814/// `UIApplication.shared.connectedScenes.first as? UIWindowScene` →
815/// `.windows.first` → `addSubview`.
816///
817/// Every step is optional in Swift and the whole thing is an `if let`, so a
818/// failure anywhere means the view is simply not added — the session still
819/// runs. Logged rather than refused, because refusing would be a different
820/// answer from the shim's.
821fn addViewToKeyWindow(view: Id) void {
822 const missing = "startAR: could not reach a window to attach the AR view to; the session runs without a visible view";
823
824 const UIApplication = objc.objc_getClass("UIApplication") orelse return;
825 const sel_shared = objc.sel_registerName("sharedApplication") orelse return;
826 const app = objc.msgSendId(UIApplication, sel_shared) orelse return;
827
828 const sel_scenes = objc.sel_registerName("connectedScenes") orelse return;
829 const scenes = objc.msgSendId(app, sel_scenes) orelse {
830 std.log.warn(missing, .{});
831 return;
832 };
833
834 // `Set.first` in Swift is an arbitrary member; `-anyObject` is the same
835 // choice made by the same collection.
836 const sel_any = objc.sel_registerName("anyObject") orelse return;
837 const scene = objc.msgSendId(scenes, sel_any) orelse {
838 std.log.warn(missing, .{});
839 return;
840 };
841
842 const sel_windows = objc.sel_registerName("windows") orelse return;
843 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse return;
844 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
845 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
846
847 // `as? UIWindowScene` in one question: a scene that is not a window scene
848 // has no `windows`, and asking beats sending an unrecognised selector.
849 if (!responds(scene, sel_responds, sel_windows)) {
850 std.log.warn(missing, .{});
851 return;
852 }
853
854 const windows = objc.msgSendId(scene, sel_windows) orelse return;
855 const sel_first = objc.sel_registerName("firstObject") orelse return;
856 const window = objc.msgSendId(windows, sel_first) orelse {
857 std.log.warn(missing, .{});
858 return;
859 };
860
861 const sel_add = objc.sel_registerName("addSubview:") orelse return;
862 objc.msgSendVoid1(window, sel_add, view);
863 retain(view);
864}
865
866/// The seven numbers `getARPlanes` reads off one anchor.
867fn readPlane(allocator: std.mem.Allocator, id: []const u8, anchor: Id) !Plane {
868 const center = msgSendSimd3(anchor, try selector("center"));
869 const extent = msgSendSimd3(anchor, try selector("extent"));
870 const alignment = msgSendLong(anchor, try selector("alignment"));
871
872 return .{
873 .id = try allocator.dupe(u8, id),
874 .horizontal = alignment == ar_plane_alignment_horizontal,
875 .center_x = simdX(center),
876 .center_y = simdY(center),
877 .center_z = simdZ(center),
878 .extent_x = simdX(extent),
879 .extent_z = simdZ(extent),
880 };
881}
882
883fn clearObjects() void {
884 var it = ar_objects.iterator();
885 while (it.next()) |entry| {
886 release(entry.value_ptr.*);
887 ar_allocator.free(entry.key_ptr.*);
888 }
889 ar_objects.clearAndFree(ar_allocator);
890}
891
892fn clearPlanes() void {
893 var it = ar_planes.iterator();
894 while (it.next()) |entry| {
895 release(entry.value_ptr.*);
896 ar_allocator.free(entry.key_ptr.*);
897 }
898 ar_planes.clearAndFree(ar_allocator);
899}
900
901// =============================================================================
902// The ARSCNViewDelegate, which is where planes come from.
903// =============================================================================
904
905const delegate_class_name = "CraftARDelegate";
906
907var delegate_instance: Id = null;
908
909/// Register the delegate class once, and keep one instance alive.
910///
911/// `ARSCNView` holds its delegate **weakly**, so a released instance is a
912/// session that silently stops reporting planes. Module-level, for the same
913/// reason `bridge_mobile_location.zig` keeps its own.
914fn ensureDelegate() !Id {
915 if (!is_darwin) return error.UnsupportedPlatform;
916 if (delegate_instance) |existing| return existing;
917
918 var cls = objc.objc_getClass(delegate_class_name);
919 if (cls == null) {
920 // All three selectors resolved before the class pair is allocated: a
921 // failure between `objc_allocateClassPair` and `objc_registerClassPair`
922 // leaves an unregistered class behind that nothing can dispose of.
923 const sel_add = try selector("renderer:didAddNode:forAnchor:");
924 const sel_update = try selector("renderer:didUpdateNode:forAnchor:");
925 const sel_remove = try selector("renderer:didRemoveNode:forAnchor:");
926
927 const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound;
928 cls = objc.objc_allocateClassPair(NSObject, delegate_class_name, 0) orelse
929 return error.ClassAllocationFailed;
930
931 // v@:@@@ — void, self, _cmd, the renderer, the node, the anchor.
932 if (!objc.class_addMethod(cls, sel_add, @ptrCast(&rendererDidAddNode), "v@:@@@")) {
933 return error.MethodNotAdded;
934 }
935 if (!objc.class_addMethod(cls, sel_update, @ptrCast(&rendererDidUpdateNode), "v@:@@@")) {
936 return error.MethodNotAdded;
937 }
938 if (!objc.class_addMethod(cls, sel_remove, @ptrCast(&rendererDidRemoveNode), "v@:@@@")) {
939 return error.MethodNotAdded;
940 }
941 objc.objc_registerClassPair(cls);
942 }
943
944 const instance = (try objc.allocInit(cls)) orelse return error.NativeCallFailed;
945 delegate_instance = instance;
946 return instance;
947}
948
949fn isKindOf(object: Id, class_name: [*:0]const u8) bool {
950 if (!is_darwin) return false;
951 const cls = objc.objc_getClass(class_name) orelse return false;
952 const sel = objc.sel_registerName("isKindOfClass:") orelse return false;
953 const Fn = *const fn (Id, Id, Id) callconv(.c) bool;
954 const f: Fn = @ptrCast(&objc.objc_msgSend);
955 return f(object, sel, cls);
956}
957
958/// The anchor's `identifier.uuidString`, owned by the caller.
959fn anchorId(allocator: std.mem.Allocator, anchor: Id) ![]u8 {
960 const identifier = objc.msgSendId(anchor, try selector("identifier")) orelse
961 return error.NativeCallFailed;
962 return copyNSStringProperty(allocator, identifier, "UUIDString");
963}
964
965/// Store or replace the anchor under its id, keeping one retain per entry.
966fn rememberPlane(id: []const u8, anchor: Id) !void {
967 if (ar_planes.getEntry(id)) |entry| {
968 // `detectedPlanes[anchor.identifier] = planeAnchor` on an existing key
969 // replaces the value; the key is already ours and stays.
970 release(entry.value_ptr.*);
971 retain(anchor);
972 entry.value_ptr.* = anchor;
973 return;
974 }
975
976 const owned = try ar_allocator.dupe(u8, id);
977 errdefer ar_allocator.free(owned);
978 retain(anchor);
979 try ar_planes.put(ar_allocator, owned, anchor);
980}
981
982fn rendererDidAddNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void {
983 if (!is_darwin) return;
984
985 // `guard let planeAnchor = anchor as? ARPlaneAnchor else { return }`.
986 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
987
988 const allocator = std.heap.c_allocator;
989
990 const id = anchorId(allocator, anchor) catch |err| {
991 std.log.warn("ar: a plane anchor arrived with no readable identifier ({}); it is not recorded", .{err});
992 return;
993 };
994 defer allocator.free(id);
995
996 rememberPlane(id, anchor) catch |err| {
997 std.log.warn("ar: could not record the plane anchor ({}); getARPlanes will not report it", .{err});
998 return;
999 };
1000
1001 // The visualisation is decoration: a failure here costs the page a grey
1002 // rectangle, not a plane, so it is logged and the event still goes out.
1003 addPlaneVisualisation(node, anchor) catch |err| {
1004 std.log.warn("ar: could not build the plane visualisation ({}); the plane is still reported", .{err});
1005 };
1006
1007 const plane = readPlane(allocator, id, anchor) catch |err| {
1008 std.log.warn("ar: could not read the new plane's geometry ({}); no event is emitted", .{err});
1009 return;
1010 };
1011 defer allocator.free(plane.id);
1012
1013 const json = shapePlaneEvent(allocator, "added", plane) catch return;
1014 defer allocator.free(json);
1015
1016 ios_events.emit(.ar_plane, json);
1017}
1018
1019fn rendererDidUpdateNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void {
1020 if (!is_darwin) return;
1021
1022 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
1023
1024 const allocator = std.heap.c_allocator;
1025
1026 const id = anchorId(allocator, anchor) catch return;
1027 defer allocator.free(id);
1028
1029 rememberPlane(id, anchor) catch |err| {
1030 std.log.warn("ar: could not update the stored plane anchor ({})", .{err});
1031 };
1032
1033 // `if let planeNode = node.childNodes.first, let plane = planeNode.geometry as? SCNPlane`
1034 // — both optional, so nothing here is an error.
1035 updatePlaneVisualisation(node, anchor) catch {};
1036
1037 // Swift emits no event on update. Neither does this: a page that wants
1038 // live extents polls `getARPlanes`, which reads the anchor this just
1039 // replaced.
1040}
1041
1042fn rendererDidRemoveNode(_: Id, _: Id, _: Id, _: Id, anchor: Id) callconv(.c) void {
1043 if (!is_darwin) return;
1044
1045 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
1046
1047 const allocator = std.heap.c_allocator;
1048
1049 const id = anchorId(allocator, anchor) catch return;
1050 defer allocator.free(id);
1051
1052 if (ar_planes.fetchRemove(id)) |entry| {
1053 release(entry.value);
1054 ar_allocator.free(entry.key);
1055 }
1056
1057 var out: std.ArrayListUnmanaged(u8) = .empty;
1058 defer out.deinit(allocator);
1059
1060 out.appendSlice(allocator, "{\"type\":\"removed\",\"id\":\"") catch return;
1061 bridge_error.appendJsonEscaped(allocator, &out, id) catch return;
1062 out.appendSlice(allocator, "\"}") catch return;
1063
1064 ios_events.emit(.ar_plane, out.items);
1065}
1066
1067/// `{"type":"added", …the same fields `getARPlanes` reports…}`.
1068fn shapePlaneEvent(allocator: std.mem.Allocator, comptime kind: []const u8, plane: Plane) ![]u8 {
1069 var out: std.ArrayListUnmanaged(u8) = .empty;
1070 defer out.deinit(allocator);
1071
1072 try out.appendSlice(allocator, "{\"type\":\"" ++ kind ++ "\",");
1073 try appendPlaneFields(allocator, &out, plane);
1074 try out.append(allocator, '}');
1075
1076 return out.toOwnedSlice(allocator);
1077}
1078
1079/// The grey rectangle Swift draws over a detected plane.
1080fn addPlaneVisualisation(node: Id, anchor: Id) !void {
1081 const extent = msgSendSimd3(anchor, try selector("extent"));
1082 const center = msgSendSimd3(anchor, try selector("center"));
1083
1084 const SCNPlane = objc.objc_getClass("SCNPlane") orelse return error.ClassNotFound;
1085 const PlaneFn = *const fn (Id, Id, CGFloat, CGFloat) callconv(.c) Id;
1086 const plane_with: PlaneFn = @ptrCast(&objc.objc_msgSend);
1087 const plane = plane_with(
1088 SCNPlane,
1089 try selector("planeWithWidth:height:"),
1090 @floatCast(simdX(extent)),
1091 @floatCast(simdZ(extent)),
1092 ) orelse return error.NativeCallFailed;
1093
1094 // `UIColor.systemBlue.withAlphaComponent(0.3)`.
1095 if (objc.msgSendId(plane, try selector("firstMaterial"))) |material| {
1096 if (objc.msgSendId(material, try selector("diffuse"))) |property| {
1097 if (systemBlue()) |color| {
1098 const AlphaFn = *const fn (Id, Id, CGFloat) callconv(.c) Id;
1099 const with_alpha: AlphaFn = @ptrCast(&objc.objc_msgSend);
1100 if (with_alpha(color, try selector("colorWithAlphaComponent:"), 0.3)) |faded| {
1101 objc.msgSendVoid1(property, try selector("setContents:"), faded);
1102 }
1103 }
1104 }
1105 }
1106
1107 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound;
1108 const plane_node = objc.msgSendId1(SCNNode, try selector("nodeWithGeometry:"), plane) orelse
1109 return error.NativeCallFailed;
1110
1111 // `SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)` — y is
1112 // dropped on purpose: the rectangle sits in the anchor's own plane.
1113 msgSendSetVector3(plane_node, try selector("setPosition:"), .{
1114 .x = @floatCast(simdX(center)),
1115 .y = 0,
1116 .z = @floatCast(simdZ(center)),
1117 });
1118
1119 // `planeNode.eulerAngles.x = -.pi / 2`, which in Objective-C is a whole
1120 // vector read, one component changed, and the vector written back —
1121 // Swift's property-of-a-property assignment does exactly that too.
1122 const sel_euler = try selector("eulerAngles");
1123 var euler = msgSendVector3(plane_node, sel_euler);
1124 euler.x = -std.math.pi / 2.0;
1125 msgSendSetVector3(plane_node, try selector("setEulerAngles:"), euler);
1126
1127 objc.msgSendVoid1(node, try selector("addChildNode:"), plane_node);
1128}
1129
1130/// Resize and re-centre the rectangle a previous `didAdd` built.
1131fn updatePlaneVisualisation(node: Id, anchor: Id) !void {
1132 const children = objc.msgSendId(node, try selector("childNodes")) orelse return;
1133 const plane_node = objc.msgSendId(children, try selector("firstObject")) orelse return;
1134 const geometry = objc.msgSendId(plane_node, try selector("geometry")) orelse return;
1135
1136 // `as? SCNPlane`: a child that is not one is left alone rather than sent a
1137 // selector it does not have.
1138 if (!isKindOf(geometry, "SCNPlane")) return;
1139
1140 const extent = msgSendSimd3(anchor, try selector("extent"));
1141 const center = msgSendSimd3(anchor, try selector("center"));
1142
1143 const SetFn = *const fn (Id, Id, CGFloat) callconv(.c) void;
1144 const set_dimension: SetFn = @ptrCast(&objc.objc_msgSend);
1145 set_dimension(geometry, try selector("setWidth:"), @floatCast(simdX(extent)));
1146 set_dimension(geometry, try selector("setHeight:"), @floatCast(simdZ(extent)));
1147
1148 msgSendSetVector3(plane_node, try selector("setPosition:"), .{
1149 .x = @floatCast(simdX(center)),
1150 .y = 0,
1151 .z = @floatCast(simdZ(center)),
1152 });
1153}
1154
1155// =============================================================================
1156// Tests.
1157//
1158// The three ABI mechanisms, exercised against real objects rather than read off
1159// the headers. SceneKit ships on macOS, so `SCNNode` and `SCNBox` are reachable
1160// from a host test even though ARKit is not; `NSView` supplies the `CGRect`
1161// round trip. What these cannot reach is a running AR session — see the module
1162// comment.
1163// =============================================================================
1164
1165const testing = std.testing;
1166
1167test "a CGRect survives the trip out and back" {
1168 if (!is_darwin) return error.SkipZigTest;
1169
1170 // `-initWithFrame:` takes it in v0-v3 and `-frame` returns it the same way.
1171 // A mismatch here would misplace the AR view rather than fail.
1172 const NSView = objc.objc_getClass("NSView") orelse return error.SkipZigTest;
1173 const sel_alloc = try selector("alloc");
1174 const sel_init_frame = try selector("initWithFrame:");
1175 const sel_frame = try selector("frame");
1176
1177 const allocated = objc.msgSendId(NSView, sel_alloc) orelse return error.SkipZigTest;
1178 const sent: CGRect = .{
1179 .origin = .{ .x = 12.5, .y = -3.25 },
1180 .size = .{ .width = 640, .height = 480.75 },
1181 };
1182 const view = msgSendIdWithRect(allocated, sel_init_frame, sent) orelse return error.SkipZigTest;
1183
1184 const got = msgSendRect(view, sel_frame);
1185 try testing.expectEqual(sent.origin.x, got.origin.x);
1186 try testing.expectEqual(sent.origin.y, got.origin.y);
1187 try testing.expectEqual(sent.size.width, got.size.width);
1188 try testing.expectEqual(sent.size.height, got.size.height);
1189}
1190
1191test "an SCNVector3 survives being set and read back" {
1192 if (!is_darwin) return error.SkipZigTest;
1193
1194 // The mechanism `placeARObject`'s `node.position = …` depends on. At host
1195 // width, because macOS declares the members as CGFloat and iOS as float —
1196 // the shape is what is being checked, not the width.
1197 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest;
1198 const sel_node = try selector("node");
1199 const sel_set_position = try selector("setPosition:");
1200 const sel_position = try selector("position");
1201
1202 const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest;
1203
1204 const sent: SCNVector3 = .{ .x = 1.5, .y = -2.25, .z = -0.5 };
1205 msgSendSetVector3(node, sel_set_position, sent);
1206
1207 const got = msgSendVector3(node, sel_position);
1208 try testing.expectEqual(sent.x, got.x);
1209 try testing.expectEqual(sent.y, got.y);
1210 try testing.expectEqual(sent.z, got.z);
1211}
1212
1213test "a simd_float3 comes back in one vector register, not as four floats" {
1214 if (!is_darwin) return error.SkipZigTest;
1215
1216 // `ARPlaneAnchor.center` and `.extent` are `simd_float3`, and this is the
1217 // return rule they use. `SCNNode.simdPosition` is the same type and is
1218 // reachable without ARKit, so the rule is checked here rather than assumed
1219 // on a device nobody can run in CI.
1220 //
1221 // Read against `position`, which carries the same three numbers through the
1222 // *other* convention: if this were being decoded as a float aggregate, x
1223 // would still agree and y and z would not.
1224 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest;
1225 const sel_node = try selector("node");
1226 const sel_set_position = try selector("setPosition:");
1227 const sel_simd_position = try selector("simdPosition");
1228
1229 const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest;
1230 msgSendSetVector3(node, sel_set_position, .{ .x = 1.5, .y = -2.25, .z = -0.5 });
1231
1232 const got = msgSendSimd3(node, sel_simd_position);
1233 try testing.expectApproxEqAbs(@as(f32, 1.5), simdX(got), 0.0001);
1234 try testing.expectApproxEqAbs(@as(f32, -2.25), simdY(got), 0.0001);
1235 try testing.expectApproxEqAbs(@as(f32, -0.5), simdZ(got), 0.0001);
1236}
1237
1238test "a CGFloat-returning geometry property reads back what it was built with" {
1239 if (!is_darwin) return error.SkipZigTest;
1240
1241 // The four-CGFloat constructor `placeARObject` uses for "box".
1242 const SCNBox = objc.objc_getClass("SCNBox") orelse return error.SkipZigTest;
1243 const sel_box = try selector("boxWithWidth:height:length:chamferRadius:");
1244 const Fn = *const fn (Id, Id, CGFloat, CGFloat, CGFloat, CGFloat) callconv(.c) Id;
1245 const box_with: Fn = @ptrCast(&objc.objc_msgSend);
1246
1247 const box = box_with(SCNBox, sel_box, 0.1, 0.2, 0.3, 0.01) orelse return error.SkipZigTest;
1248
1249 try testing.expectApproxEqAbs(@as(CGFloat, 0.1), msgSendCGFloat(box, try selector("width")), 0.0001);
1250 try testing.expectApproxEqAbs(@as(CGFloat, 0.2), msgSendCGFloat(box, try selector("height")), 0.0001);
1251 try testing.expectApproxEqAbs(@as(CGFloat, 0.3), msgSendCGFloat(box, try selector("length")), 0.0001);
1252}
1253
1254test "the declared actions are the ones the handler serves" {
1255 try testing.expectEqual(@as(usize, 5), capability_actions.len);
1256 try testing.expectEqualStrings(A.start_ar, capability_actions[0].name);
1257 try testing.expectEqualStrings(A.stop_ar, capability_actions[1].name);
1258 try testing.expectEqualStrings(A.place_ar_object, capability_actions[2].name);
1259 try testing.expectEqualStrings(A.remove_ar_object, capability_actions[3].name);
1260 try testing.expectEqualStrings(A.get_ar_planes, capability_actions[4].name);
1261
1262 for (capability_actions) |decl| {
1263 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1264 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1265 try testing.expect(decl.reason == null);
1266 try testing.expect(routeFor(decl.name) != null);
1267 }
1268}
1269
1270test "an action the namespace does not serve is reported, not ignored" {
1271 var bridge = ARBridge.init(testing.allocator);
1272 defer bridge.deinit();
1273
1274 try testing.expectError(
1275 bridge_error.BridgeError.UnknownAction,
1276 bridge.handleMessage("noSuchAction", "{}"),
1277 );
1278 // Casing is how a real typo arrives, and `startAr` is a plausible one.
1279 try testing.expectError(
1280 bridge_error.BridgeError.UnknownAction,
1281 bridge.handleMessage("startAr", "{}"),
1282 );
1283}
1284
1285test "no planes is the bare array, not null and not a wrapper" {
1286 // `resolveCallback(callbackId, result: planes)` through `.fragmentsAllowed`
1287 // puts the array on the wire unwrapped, so a page doing `planes.length`
1288 // keeps working when nothing has been detected.
1289 const json = try shapePlanes(testing.allocator, &.{});
1290 defer testing.allocator.free(json);
1291 try testing.expectEqualStrings("[]", json);
1292}
1293
1294test "a plane carries extent's x and z as width and height" {
1295 // The mapping is easy to get wrong and impossible to see: Swift reads
1296 // `extent.x` into `width` and **`extent.z`** into `height`, so a plane's y
1297 // extent never reaches the page at all.
1298 const json = try shapePlanes(testing.allocator, &.{
1299 .{
1300 .id = "AAA",
1301 .horizontal = true,
1302 .center_x = 1,
1303 .center_y = 2,
1304 .center_z = 3,
1305 .extent_x = 4,
1306 .extent_z = 5,
1307 },
1308 });
1309 defer testing.allocator.free(json);
1310
1311 try testing.expectEqualStrings(
1312 \\[{"id":"AAA","alignment":"horizontal","center":{"x":1,"y":2,"z":3},"extent":{"width":4,"height":5}}]
1313 , json);
1314}
1315
1316test "alignment is horizontal or vertical, and nothing else" {
1317 const json = try shapePlanes(testing.allocator, &.{
1318 .{ .id = "A", .horizontal = true, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 },
1319 .{ .id = "B", .horizontal = false, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 },
1320 });
1321 defer testing.allocator.free(json);
1322
1323 try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"horizontal\"") != null);
1324 try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"vertical\"") != null);
1325 // Two objects, one separator.
1326 try testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "},{"));
1327}
1328
1329test "the craftARPlane event is the same object with a type in front" {
1330 // `getARPlanes` and the event have to agree about a plane, so they share
1331 // the field writer. This is what pins that they still do.
1332 const plane: Plane = .{
1333 .id = "AAA",
1334 .horizontal = false,
1335 .center_x = 1,
1336 .center_y = 2,
1337 .center_z = 3,
1338 .extent_x = 4,
1339 .extent_z = 5,
1340 };
1341
1342 const event = try shapePlaneEvent(testing.allocator, "added", plane);
1343 defer testing.allocator.free(event);
1344
1345 const array = try shapePlanes(testing.allocator, &.{plane});
1346 defer testing.allocator.free(array);
1347
1348 // The array element's fields, unwrapped from its brackets and braces.
1349 const fields = array[2 .. array.len - 2];
1350
1351 const expected = try std.fmt.allocPrint(testing.allocator, "", .{fields});
1352 defer testing.allocator.free(expected);
1353
1354 try testing.expectEqualStrings(expected, event);
1355}
1356
1357fn positionOf(json: []const u8) !Position {
1358 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1359 defer parsed.deinit();
1360 return readPosition(parsed.value.object.get("position"));
1361}
1362
1363test "a missing position is the same as an empty one: (0, 0, -0.5)" {
1364 // Both arms of Swift's `if let pos = position` land on z = -0.5, one
1365 // through `SCNVector3(0, 0, -0.5)` and the other through `pos["z"] ?? -0.5`.
1366 const absent = try positionOf("{}");
1367 try testing.expectEqual(@as(f64, 0), absent.x);
1368 try testing.expectEqual(@as(f64, 0), absent.y);
1369 try testing.expectEqual(default_z, absent.z);
1370
1371 const empty = try positionOf("{\"position\":{}}");
1372 try testing.expectEqual(default_z, empty.z);
1373}
1374
1375test "a position is read component by component, integers included" {
1376 const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":-2,\"z\":0}}");
1377 try testing.expectEqual(@as(f64, 1.5), p.x);
1378 try testing.expectEqual(@as(f64, -2), p.y);
1379 try testing.expectEqual(@as(f64, 0), p.z);
1380}
1381
1382test "one non-number discards the whole position, as a dictionary cast does" {
1383 // `body["position"] as? [String: Double]` is a cast of the *dictionary*.
1384 // It fails whole when any value is not a Double, so `{"x":1,"y":"up"}` is
1385 // not "x with a bad y" — it is no position, and the node lands at the
1386 // default. A reader that kept x would put the object somewhere Swift
1387 // never would.
1388 const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":\"up\"}}");
1389 try testing.expectEqual(@as(f64, 0), p.x);
1390 try testing.expectEqual(@as(f64, 0), p.y);
1391 try testing.expectEqual(default_z, p.z);
1392
1393 const nested = try positionOf("{\"position\":{\"x\":1.5,\"z\":{\"deep\":1}}}");
1394 try testing.expectEqual(@as(f64, 0), nested.x);
1395 try testing.expectEqual(default_z, nested.z);
1396}
1397
1398test "a position that is not an object at all is no position" {
1399 try testing.expectEqual(default_z, (try positionOf("{\"position\":[1,2,3]}")).z);
1400 try testing.expectEqual(default_z, (try positionOf("{\"position\":\"1,2,3\"}")).z);
1401 try testing.expectEqual(default_z, (try positionOf("{\"position\":null}")).z);
1402}
1403
1404test "startAR refuses where world tracking is unavailable rather than reaching for it" {
1405 // On a host — and in the simulator — `ARWorldTrackingConfiguration` is
1406 // absent or answers false, and `classBool` gives `false` for both. That is
1407 // Swift's `guard … isSupported else { reject }`, and it is the only AR
1408 // path any test in this repository can reach.
1409 var bridge = ARBridge.init(testing.allocator);
1410 defer bridge.deinit();
1411
1412 try testing.expectError(
1413 bridge_error.BridgeError.PlatformNotSupported,
1414 bridge.handleMessage(A.start_ar, "{}"),
1415 );
1416}
1417
1418test "placeARObject needs a model before it needs a session" {
1419 var bridge = ARBridge.init(testing.allocator);
1420 defer bridge.deinit();
1421
1422 // Swift checks `body["model"] as? String` in the dispatcher, before
1423 // `placeARObject` ever looks at `self.arView` — so a call with no model is
1424 // INVALID_ARGUMENT whether or not a session is running.
1425 try testing.expectError(
1426 bridge_error.BridgeError.InvalidParameter,
1427 bridge.handleMessage(A.place_ar_object, "{}"),
1428 );
1429 try testing.expectError(
1430 bridge_error.BridgeError.InvalidParameter,
1431 bridge.handleMessage(A.place_ar_object, "{\"model\":42}"),
1432 );
1433}
1434
1435test "removeARObject with an id nothing was placed under is a rejection" {
1436 var bridge = ARBridge.init(testing.allocator);
1437 defer bridge.deinit();
1438
1439 // Swift's `else { rejectCallback(… "Object not found") }`. Reachable on a
1440 // host because the map is empty, which is the same state a real device is
1441 // in before anything is placed.
1442 try testing.expectError(
1443 bridge_error.BridgeError.InvalidParameter,
1444 bridge.handleMessage(A.remove_ar_object, "{\"objectId\":\"nope\"}"),
1445 );
1446 try testing.expectError(
1447 bridge_error.BridgeError.InvalidParameter,
1448 bridge.handleMessage(A.remove_ar_object, "{}"),
1449 );
1450}
packages/zig/src/ios_config.zigmodified+13-3
Changes to packages/zig/src/ios_config.zig
@@ -454,6 +454,14 @@ pub fn gateFor(action: []const u8) ?Feature {
454454 // and adding one here would strand a track on disk with no action able
455455 // to reach it.
456456 .{ "startLocationRecording", .geolocation },
457 // All five AR actions carry the same `config.enableAR` guard in the
458 // spec (`CraftApp.swift:1210-1249`), unlike the recorder where only
459 // the start is gated.
460 .{ "startAR", .ar },
461 .{ "stopAR", .ar },
462 .{ "placeARObject", .ar },
463 .{ "removeARObject", .ar },
464 .{ "getARPlanes", .ar },
457465 };
458466 inline for (table) |entry| {
459467 if (std.mem.eql(u8, action, entry[0])) return entry[1];
@@ -637,10 +645,12 @@ test "gateFor answers for a served action and stays quiet for the rest" {
637645 try testing.expectEqual(Feature.camera, gateFor("pickImage").?);
638646 try testing.expectEqual(Feature.secure_storage, gateFor("secureSet").?);
639647
640 // `openURL` is served and ungated in the spec; `startAR` is gated but not
641 // served here, so the shim's own gate applies and this must not answer.
648 // `openURL` is served and ungated in the spec, so this must not answer for
649 // it. `startAR` used to be here for the opposite reason — gated but still on
650 // the shim — and now that Zig serves it, the gate has to come from here.
642651 try testing.expect(gateFor("openURL") == null);
643 try testing.expect(gateFor("startAR") == null);
652 try testing.expectEqual(Feature.ar, gateFor("startAR").?);
653 try testing.expectEqual(Feature.ar, gateFor("getARPlanes").?);
644654 try testing.expect(gateFor("") == null);
645655}
646656
packages/zig/src/ios_dispatch.zigmodified+3-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -27,6 +27,7 @@ const bridge_mobile_bgtasks = ;
2727const bridge_mobile_watch = ;
2828const bridge_mobile_location = ;
2929const bridge_mobile_locrecording = ;
30const bridge_mobile_ar = ;
3031const bridge_mobile_motion = ;
3132const bridge_mobile_imagepicker = ;
3233const bridge_mobile_filepicker = ;
@@ -306,6 +307,7 @@ const mobile_bridges = .{
306307 bridge_mobile_watch.WatchBridge,
307308 bridge_mobile_location.LocationBridge,
308309 bridge_mobile_locrecording.LocationRecordingBridge,
310 bridge_mobile_ar.ARBridge,
309311 bridge_mobile_motion.MotionBridge,
310312 bridge_mobile_imagepicker.ImagePickerBridge,
311313 bridge_mobile_filepicker.FilePickerBridge,
@@ -351,6 +353,7 @@ const mobile_manifests = [_][]const capabilities.ActionDecl{
351353 &bridge_mobile_watch.capability_actions,
352354 &bridge_mobile_location.capability_actions,
353355 &bridge_mobile_locrecording.capability_actions,
356 &bridge_mobile_ar.capability_actions,
354357 &bridge_mobile_motion.capability_actions,
355358 &bridge_mobile_imagepicker.capability_actions,
356359 &bridge_mobile_filepicker.capability_actions,
packages/zig/test/ios_conformance_test.zigmodified+3-7
Changes to packages/zig/test/ios_conformance_test.zig
@@ -43,6 +43,7 @@ const zig_sources = [_][]const u8{
4343 @embedFile("src/bridge_mobile_watch.zig"),
4444 @embedFile("src/bridge_mobile_location.zig"),
4545 @embedFile("src/bridge_mobile_locrecording.zig"),
46 @embedFile("src/bridge_mobile_ar.zig"),
4647 @embedFile("src/bridge_mobile_motion.zig"),
4748 @embedFile("src/bridge_mobile_imagepicker.zig"),
4849 @embedFile("src/bridge_mobile_filepicker.zig"),
@@ -121,7 +122,7 @@ const dispatch_end = "func webView(";
121122/// and `deliverErrorCode` landed the day after that was written. Worth
122123/// re-reading the other deferrals for the same reason before assuming they
123124/// still hold.
124const max_not_yet_migrated: usize = 18;
125const max_not_yet_migrated: usize = 13;
125126
126127fn dispatcherRegion() []const u8 {
127128 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
@@ -270,11 +271,6 @@ const deliberate_deferrals = [_]Deferral{
270271 // 51 namespaces, no `mobile`, and neither `ios.zig` nor `ios_dispatch.zig`
271272 // reads it — so the boundary is recorded here instead, where the tooling
272273 // that counts unmigrated actions can see it.
273 .{ .action = "startAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" },
274 .{ .action = "stopAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" },
275 .{ .action = "getARPlanes", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" },
276 .{ .action = "placeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" },
277 .{ .action = "removeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" },
278274
279275 // StoreKit 2. Narrower than "no ObjC surface": StoreKit *1* is ObjC and
280276 // `bridge_iap.zig` already drives it from Zig on macOS, so this is a choice
@@ -347,7 +343,7 @@ test "every recorded deferral is real, and still a deferral" {
347343 }
348344
349345 // Non-vacuity: the loop above is satisfied by an empty table.
350 try testing.expect(deliberate_deferrals.len >= 18);
346 try testing.expect(deliberate_deferrals.len >= 13);
351347
352348 // As of this commit the table happens to cover every unmigrated action,
353349 // but that is not asserted. Pinning the exact count would make migrating a