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
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+1454-0
Changes to packages/zig/src/bridge_mobile_ar.zig
@@ -0,0 +1,1454 @@
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 // No retain here. `alloc`/`initWithFrame:` already gave this frame the one
862 // reference `ar_view` owns and `stopAR` releases; `addSubview:` gives the
863 // window a second one that `removeFromSuperview` drops. A third would
864 // outlive both, and it would take the whole SceneKit graph with it on every
865 // start/stop cycle.
866 const sel_add = objc.sel_registerName("addSubview:") orelse return;
867 objc.msgSendVoid1(window, sel_add, view);
868}
869
870/// The seven numbers `getARPlanes` reads off one anchor.
871fn readPlane(allocator: std.mem.Allocator, id: []const u8, anchor: Id) !Plane {
872 const center = msgSendSimd3(anchor, try selector("center"));
873 const extent = msgSendSimd3(anchor, try selector("extent"));
874 const alignment = msgSendLong(anchor, try selector("alignment"));
875
876 return .{
877 .id = try allocator.dupe(u8, id),
878 .horizontal = alignment == ar_plane_alignment_horizontal,
879 .center_x = simdX(center),
880 .center_y = simdY(center),
881 .center_z = simdZ(center),
882 .extent_x = simdX(extent),
883 .extent_z = simdZ(extent),
884 };
885}
886
887fn clearObjects() void {
888 var it = ar_objects.iterator();
889 while (it.next()) |entry| {
890 release(entry.value_ptr.*);
891 ar_allocator.free(entry.key_ptr.*);
892 }
893 ar_objects.clearAndFree(ar_allocator);
894}
895
896fn clearPlanes() void {
897 var it = ar_planes.iterator();
898 while (it.next()) |entry| {
899 release(entry.value_ptr.*);
900 ar_allocator.free(entry.key_ptr.*);
901 }
902 ar_planes.clearAndFree(ar_allocator);
903}
904
905// =============================================================================
906// The ARSCNViewDelegate, which is where planes come from.
907// =============================================================================
908
909const delegate_class_name = "CraftARDelegate";
910
911var delegate_instance: Id = null;
912
913/// Register the delegate class once, and keep one instance alive.
914///
915/// `ARSCNView` holds its delegate **weakly**, so a released instance is a
916/// session that silently stops reporting planes. Module-level, for the same
917/// reason `bridge_mobile_location.zig` keeps its own.
918fn ensureDelegate() !Id {
919 if (!is_darwin) return error.UnsupportedPlatform;
920 if (delegate_instance) |existing| return existing;
921
922 var cls = objc.objc_getClass(delegate_class_name);
923 if (cls == null) {
924 // All three selectors resolved before the class pair is allocated: a
925 // failure between `objc_allocateClassPair` and `objc_registerClassPair`
926 // leaves an unregistered class behind that nothing can dispose of.
927 const sel_add = try selector("renderer:didAddNode:forAnchor:");
928 const sel_update = try selector("renderer:didUpdateNode:forAnchor:");
929 const sel_remove = try selector("renderer:didRemoveNode:forAnchor:");
930
931 const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound;
932 cls = objc.objc_allocateClassPair(NSObject, delegate_class_name, 0) orelse
933 return error.ClassAllocationFailed;
934
935 // v@:@@@ — void, self, _cmd, the renderer, the node, the anchor.
936 if (!objc.class_addMethod(cls, sel_add, @ptrCast(&rendererDidAddNode), "v@:@@@")) {
937 return error.MethodNotAdded;
938 }
939 if (!objc.class_addMethod(cls, sel_update, @ptrCast(&rendererDidUpdateNode), "v@:@@@")) {
940 return error.MethodNotAdded;
941 }
942 if (!objc.class_addMethod(cls, sel_remove, @ptrCast(&rendererDidRemoveNode), "v@:@@@")) {
943 return error.MethodNotAdded;
944 }
945 objc.objc_registerClassPair(cls);
946 }
947
948 const instance = (try objc.allocInit(cls)) orelse return error.NativeCallFailed;
949 delegate_instance = instance;
950 return instance;
951}
952
953fn isKindOf(object: Id, class_name: [*:0]const u8) bool {
954 if (!is_darwin) return false;
955 const cls = objc.objc_getClass(class_name) orelse return false;
956 const sel = objc.sel_registerName("isKindOfClass:") orelse return false;
957 const Fn = *const fn (Id, Id, Id) callconv(.c) bool;
958 const f: Fn = @ptrCast(&objc.objc_msgSend);
959 return f(object, sel, cls);
960}
961
962/// The anchor's `identifier.uuidString`, owned by the caller.
963fn anchorId(allocator: std.mem.Allocator, anchor: Id) ![]u8 {
964 const identifier = objc.msgSendId(anchor, try selector("identifier")) orelse
965 return error.NativeCallFailed;
966 return copyNSStringProperty(allocator, identifier, "UUIDString");
967}
968
969/// Store or replace the anchor under its id, keeping one retain per entry.
970fn rememberPlane(id: []const u8, anchor: Id) !void {
971 if (ar_planes.getEntry(id)) |entry| {
972 // `detectedPlanes[anchor.identifier] = planeAnchor` on an existing key
973 // replaces the value; the key is already ours and stays.
974 release(entry.value_ptr.*);
975 retain(anchor);
976 entry.value_ptr.* = anchor;
977 return;
978 }
979
980 const owned = try ar_allocator.dupe(u8, id);
981 errdefer ar_allocator.free(owned);
982 retain(anchor);
983 try ar_planes.put(ar_allocator, owned, anchor);
984}
985
986fn rendererDidAddNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void {
987 if (!is_darwin) return;
988
989 // `guard let planeAnchor = anchor as? ARPlaneAnchor else { return }`.
990 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
991
992 const allocator = std.heap.c_allocator;
993
994 const id = anchorId(allocator, anchor) catch |err| {
995 std.log.warn("ar: a plane anchor arrived with no readable identifier ({}); it is not recorded", .{err});
996 return;
997 };
998 defer allocator.free(id);
999
1000 rememberPlane(id, anchor) catch |err| {
1001 std.log.warn("ar: could not record the plane anchor ({}); getARPlanes will not report it", .{err});
1002 return;
1003 };
1004
1005 // The visualisation is decoration: a failure here costs the page a grey
1006 // rectangle, not a plane, so it is logged and the event still goes out.
1007 addPlaneVisualisation(node, anchor) catch |err| {
1008 std.log.warn("ar: could not build the plane visualisation ({}); the plane is still reported", .{err});
1009 };
1010
1011 const plane = readPlane(allocator, id, anchor) catch |err| {
1012 std.log.warn("ar: could not read the new plane's geometry ({}); no event is emitted", .{err});
1013 return;
1014 };
1015 defer allocator.free(plane.id);
1016
1017 const json = shapePlaneEvent(allocator, "added", plane) catch return;
1018 defer allocator.free(json);
1019
1020 ios_events.emit(.ar_plane, json);
1021}
1022
1023fn rendererDidUpdateNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void {
1024 if (!is_darwin) return;
1025
1026 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
1027
1028 const allocator = std.heap.c_allocator;
1029
1030 const id = anchorId(allocator, anchor) catch return;
1031 defer allocator.free(id);
1032
1033 rememberPlane(id, anchor) catch |err| {
1034 std.log.warn("ar: could not update the stored plane anchor ({})", .{err});
1035 };
1036
1037 // `if let planeNode = node.childNodes.first, let plane = planeNode.geometry as? SCNPlane`
1038 // — both optional, so nothing here is an error.
1039 updatePlaneVisualisation(node, anchor) catch {};
1040
1041 // Swift emits no event on update. Neither does this: a page that wants
1042 // live extents polls `getARPlanes`, which reads the anchor this just
1043 // replaced.
1044}
1045
1046fn rendererDidRemoveNode(_: Id, _: Id, _: Id, _: Id, anchor: Id) callconv(.c) void {
1047 if (!is_darwin) return;
1048
1049 if (!isKindOf(anchor, "ARPlaneAnchor")) return;
1050
1051 const allocator = std.heap.c_allocator;
1052
1053 const id = anchorId(allocator, anchor) catch return;
1054 defer allocator.free(id);
1055
1056 if (ar_planes.fetchRemove(id)) |entry| {
1057 release(entry.value);
1058 ar_allocator.free(entry.key);
1059 }
1060
1061 var out: std.ArrayListUnmanaged(u8) = .empty;
1062 defer out.deinit(allocator);
1063
1064 out.appendSlice(allocator, "{\"type\":\"removed\",\"id\":\"") catch return;
1065 bridge_error.appendJsonEscaped(allocator, &out, id) catch return;
1066 out.appendSlice(allocator, "\"}") catch return;
1067
1068 ios_events.emit(.ar_plane, out.items);
1069}
1070
1071/// `{"type":"added", …the same fields `getARPlanes` reports…}`.
1072fn shapePlaneEvent(allocator: std.mem.Allocator, comptime kind: []const u8, plane: Plane) ![]u8 {
1073 var out: std.ArrayListUnmanaged(u8) = .empty;
1074 defer out.deinit(allocator);
1075
1076 try out.appendSlice(allocator, "{\"type\":\"" ++ kind ++ "\",");
1077 try appendPlaneFields(allocator, &out, plane);
1078 try out.append(allocator, '}');
1079
1080 return out.toOwnedSlice(allocator);
1081}
1082
1083/// The grey rectangle Swift draws over a detected plane.
1084fn addPlaneVisualisation(node: Id, anchor: Id) !void {
1085 const extent = msgSendSimd3(anchor, try selector("extent"));
1086 const center = msgSendSimd3(anchor, try selector("center"));
1087
1088 const SCNPlane = objc.objc_getClass("SCNPlane") orelse return error.ClassNotFound;
1089 const PlaneFn = *const fn (Id, Id, CGFloat, CGFloat) callconv(.c) Id;
1090 const plane_with: PlaneFn = @ptrCast(&objc.objc_msgSend);
1091 const plane = plane_with(
1092 SCNPlane,
1093 try selector("planeWithWidth:height:"),
1094 @floatCast(simdX(extent)),
1095 @floatCast(simdZ(extent)),
1096 ) orelse return error.NativeCallFailed;
1097
1098 // `UIColor.systemBlue.withAlphaComponent(0.3)`.
1099 if (objc.msgSendId(plane, try selector("firstMaterial"))) |material| {
1100 if (objc.msgSendId(material, try selector("diffuse"))) |property| {
1101 if (systemBlue()) |color| {
1102 const AlphaFn = *const fn (Id, Id, CGFloat) callconv(.c) Id;
1103 const with_alpha: AlphaFn = @ptrCast(&objc.objc_msgSend);
1104 if (with_alpha(color, try selector("colorWithAlphaComponent:"), 0.3)) |faded| {
1105 objc.msgSendVoid1(property, try selector("setContents:"), faded);
1106 }
1107 }
1108 }
1109 }
1110
1111 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound;
1112 const plane_node = objc.msgSendId1(SCNNode, try selector("nodeWithGeometry:"), plane) orelse
1113 return error.NativeCallFailed;
1114
1115 // `SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)` — y is
1116 // dropped on purpose: the rectangle sits in the anchor's own plane.
1117 msgSendSetVector3(plane_node, try selector("setPosition:"), .{
1118 .x = @floatCast(simdX(center)),
1119 .y = 0,
1120 .z = @floatCast(simdZ(center)),
1121 });
1122
1123 // `planeNode.eulerAngles.x = -.pi / 2`, which in Objective-C is a whole
1124 // vector read, one component changed, and the vector written back —
1125 // Swift's property-of-a-property assignment does exactly that too.
1126 const sel_euler = try selector("eulerAngles");
1127 var euler = msgSendVector3(plane_node, sel_euler);
1128 euler.x = -std.math.pi / 2.0;
1129 msgSendSetVector3(plane_node, try selector("setEulerAngles:"), euler);
1130
1131 objc.msgSendVoid1(node, try selector("addChildNode:"), plane_node);
1132}
1133
1134/// Resize and re-centre the rectangle a previous `didAdd` built.
1135fn updatePlaneVisualisation(node: Id, anchor: Id) !void {
1136 const children = objc.msgSendId(node, try selector("childNodes")) orelse return;
1137 const plane_node = objc.msgSendId(children, try selector("firstObject")) orelse return;
1138 const geometry = objc.msgSendId(plane_node, try selector("geometry")) orelse return;
1139
1140 // `as? SCNPlane`: a child that is not one is left alone rather than sent a
1141 // selector it does not have.
1142 if (!isKindOf(geometry, "SCNPlane")) return;
1143
1144 const extent = msgSendSimd3(anchor, try selector("extent"));
1145 const center = msgSendSimd3(anchor, try selector("center"));
1146
1147 const SetFn = *const fn (Id, Id, CGFloat) callconv(.c) void;
1148 const set_dimension: SetFn = @ptrCast(&objc.objc_msgSend);
1149 set_dimension(geometry, try selector("setWidth:"), @floatCast(simdX(extent)));
1150 set_dimension(geometry, try selector("setHeight:"), @floatCast(simdZ(extent)));
1151
1152 msgSendSetVector3(plane_node, try selector("setPosition:"), .{
1153 .x = @floatCast(simdX(center)),
1154 .y = 0,
1155 .z = @floatCast(simdZ(center)),
1156 });
1157}
1158
1159// =============================================================================
1160// Tests.
1161//
1162// The three ABI mechanisms, exercised against real objects rather than read off
1163// the headers. SceneKit ships on macOS, so `SCNNode` and `SCNBox` are reachable
1164// from a host test even though ARKit is not; `NSView` supplies the `CGRect`
1165// round trip. What these cannot reach is a running AR session — see the module
1166// comment.
1167// =============================================================================
1168
1169const testing = std.testing;
1170
1171test "a CGRect survives the trip out and back" {
1172 if (!is_darwin) return error.SkipZigTest;
1173
1174 // `-initWithFrame:` takes it in v0-v3 and `-frame` returns it the same way.
1175 // A mismatch here would misplace the AR view rather than fail.
1176 const NSView = objc.objc_getClass("NSView") orelse return error.SkipZigTest;
1177 const sel_alloc = try selector("alloc");
1178 const sel_init_frame = try selector("initWithFrame:");
1179 const sel_frame = try selector("frame");
1180
1181 const allocated = objc.msgSendId(NSView, sel_alloc) orelse return error.SkipZigTest;
1182 const sent: CGRect = .{
1183 .origin = .{ .x = 12.5, .y = -3.25 },
1184 .size = .{ .width = 640, .height = 480.75 },
1185 };
1186 const view = msgSendIdWithRect(allocated, sel_init_frame, sent) orelse return error.SkipZigTest;
1187
1188 const got = msgSendRect(view, sel_frame);
1189 try testing.expectEqual(sent.origin.x, got.origin.x);
1190 try testing.expectEqual(sent.origin.y, got.origin.y);
1191 try testing.expectEqual(sent.size.width, got.size.width);
1192 try testing.expectEqual(sent.size.height, got.size.height);
1193}
1194
1195test "an SCNVector3 survives being set and read back" {
1196 if (!is_darwin) return error.SkipZigTest;
1197
1198 // The mechanism `placeARObject`'s `node.position = …` depends on. At host
1199 // width, because macOS declares the members as CGFloat and iOS as float —
1200 // the shape is what is being checked, not the width.
1201 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest;
1202 const sel_node = try selector("node");
1203 const sel_set_position = try selector("setPosition:");
1204 const sel_position = try selector("position");
1205
1206 const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest;
1207
1208 const sent: SCNVector3 = .{ .x = 1.5, .y = -2.25, .z = -0.5 };
1209 msgSendSetVector3(node, sel_set_position, sent);
1210
1211 const got = msgSendVector3(node, sel_position);
1212 try testing.expectEqual(sent.x, got.x);
1213 try testing.expectEqual(sent.y, got.y);
1214 try testing.expectEqual(sent.z, got.z);
1215}
1216
1217test "a simd_float3 comes back in one vector register, not as four floats" {
1218 if (!is_darwin) return error.SkipZigTest;
1219
1220 // `ARPlaneAnchor.center` and `.extent` are `simd_float3`, and this is the
1221 // return rule they use. `SCNNode.simdPosition` is the same type and is
1222 // reachable without ARKit, so the rule is checked here rather than assumed
1223 // on a device nobody can run in CI.
1224 //
1225 // Read against `position`, which carries the same three numbers through the
1226 // *other* convention: if this were being decoded as a float aggregate, x
1227 // would still agree and y and z would not.
1228 const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest;
1229 const sel_node = try selector("node");
1230 const sel_set_position = try selector("setPosition:");
1231 const sel_simd_position = try selector("simdPosition");
1232
1233 const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest;
1234 msgSendSetVector3(node, sel_set_position, .{ .x = 1.5, .y = -2.25, .z = -0.5 });
1235
1236 const got = msgSendSimd3(node, sel_simd_position);
1237 try testing.expectApproxEqAbs(@as(f32, 1.5), simdX(got), 0.0001);
1238 try testing.expectApproxEqAbs(@as(f32, -2.25), simdY(got), 0.0001);
1239 try testing.expectApproxEqAbs(@as(f32, -0.5), simdZ(got), 0.0001);
1240}
1241
1242test "a CGFloat-returning geometry property reads back what it was built with" {
1243 if (!is_darwin) return error.SkipZigTest;
1244
1245 // The four-CGFloat constructor `placeARObject` uses for "box".
1246 const SCNBox = objc.objc_getClass("SCNBox") orelse return error.SkipZigTest;
1247 const sel_box = try selector("boxWithWidth:height:length:chamferRadius:");
1248 const Fn = *const fn (Id, Id, CGFloat, CGFloat, CGFloat, CGFloat) callconv(.c) Id;
1249 const box_with: Fn = @ptrCast(&objc.objc_msgSend);
1250
1251 const box = box_with(SCNBox, sel_box, 0.1, 0.2, 0.3, 0.01) orelse return error.SkipZigTest;
1252
1253 try testing.expectApproxEqAbs(@as(CGFloat, 0.1), msgSendCGFloat(box, try selector("width")), 0.0001);
1254 try testing.expectApproxEqAbs(@as(CGFloat, 0.2), msgSendCGFloat(box, try selector("height")), 0.0001);
1255 try testing.expectApproxEqAbs(@as(CGFloat, 0.3), msgSendCGFloat(box, try selector("length")), 0.0001);
1256}
1257
1258test "the declared actions are the ones the handler serves" {
1259 try testing.expectEqual(@as(usize, 5), capability_actions.len);
1260 try testing.expectEqualStrings(A.start_ar, capability_actions[0].name);
1261 try testing.expectEqualStrings(A.stop_ar, capability_actions[1].name);
1262 try testing.expectEqualStrings(A.place_ar_object, capability_actions[2].name);
1263 try testing.expectEqualStrings(A.remove_ar_object, capability_actions[3].name);
1264 try testing.expectEqualStrings(A.get_ar_planes, capability_actions[4].name);
1265
1266 for (capability_actions) |decl| {
1267 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1268 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1269 try testing.expect(decl.reason == null);
1270 try testing.expect(routeFor(decl.name) != null);
1271 }
1272}
1273
1274test "an action the namespace does not serve is reported, not ignored" {
1275 var bridge = ARBridge.init(testing.allocator);
1276 defer bridge.deinit();
1277
1278 try testing.expectError(
1279 bridge_error.BridgeError.UnknownAction,
1280 bridge.handleMessage("noSuchAction", "{}"),
1281 );
1282 // Casing is how a real typo arrives, and `startAr` is a plausible one.
1283 try testing.expectError(
1284 bridge_error.BridgeError.UnknownAction,
1285 bridge.handleMessage("startAr", "{}"),
1286 );
1287}
1288
1289test "no planes is the bare array, not null and not a wrapper" {
1290 // `resolveCallback(callbackId, result: planes)` through `.fragmentsAllowed`
1291 // puts the array on the wire unwrapped, so a page doing `planes.length`
1292 // keeps working when nothing has been detected.
1293 const json = try shapePlanes(testing.allocator, &.{});
1294 defer testing.allocator.free(json);
1295 try testing.expectEqualStrings("[]", json);
1296}
1297
1298test "a plane carries extent's x and z as width and height" {
1299 // The mapping is easy to get wrong and impossible to see: Swift reads
1300 // `extent.x` into `width` and **`extent.z`** into `height`, so a plane's y
1301 // extent never reaches the page at all.
1302 const json = try shapePlanes(testing.allocator, &.{
1303 .{
1304 .id = "AAA",
1305 .horizontal = true,
1306 .center_x = 1,
1307 .center_y = 2,
1308 .center_z = 3,
1309 .extent_x = 4,
1310 .extent_z = 5,
1311 },
1312 });
1313 defer testing.allocator.free(json);
1314
1315 try testing.expectEqualStrings(
1316 \\[{"id":"AAA","alignment":"horizontal","center":{"x":1,"y":2,"z":3},"extent":{"width":4,"height":5}}]
1317 , json);
1318}
1319
1320test "alignment is horizontal or vertical, and nothing else" {
1321 const json = try shapePlanes(testing.allocator, &.{
1322 .{ .id = "A", .horizontal = true, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 },
1323 .{ .id = "B", .horizontal = false, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 },
1324 });
1325 defer testing.allocator.free(json);
1326
1327 try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"horizontal\"") != null);
1328 try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"vertical\"") != null);
1329 // Two objects, one separator.
1330 try testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "},{"));
1331}
1332
1333test "the craftARPlane event is the same object with a type in front" {
1334 // `getARPlanes` and the event have to agree about a plane, so they share
1335 // the field writer. This is what pins that they still do.
1336 const plane: Plane = .{
1337 .id = "AAA",
1338 .horizontal = false,
1339 .center_x = 1,
1340 .center_y = 2,
1341 .center_z = 3,
1342 .extent_x = 4,
1343 .extent_z = 5,
1344 };
1345
1346 const event = try shapePlaneEvent(testing.allocator, "added", plane);
1347 defer testing.allocator.free(event);
1348
1349 const array = try shapePlanes(testing.allocator, &.{plane});
1350 defer testing.allocator.free(array);
1351
1352 // The array element's fields, unwrapped from its brackets and braces.
1353 const fields = array[2 .. array.len - 2];
1354
1355 const expected = try std.fmt.allocPrint(testing.allocator, "", .{fields});
1356 defer testing.allocator.free(expected);
1357
1358 try testing.expectEqualStrings(expected, event);
1359}
1360
1361fn positionOf(json: []const u8) !Position {
1362 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1363 defer parsed.deinit();
1364 return readPosition(parsed.value.object.get("position"));
1365}
1366
1367test "a missing position is the same as an empty one: (0, 0, -0.5)" {
1368 // Both arms of Swift's `if let pos = position` land on z = -0.5, one
1369 // through `SCNVector3(0, 0, -0.5)` and the other through `pos["z"] ?? -0.5`.
1370 const absent = try positionOf("{}");
1371 try testing.expectEqual(@as(f64, 0), absent.x);
1372 try testing.expectEqual(@as(f64, 0), absent.y);
1373 try testing.expectEqual(default_z, absent.z);
1374
1375 const empty = try positionOf("{\"position\":{}}");
1376 try testing.expectEqual(default_z, empty.z);
1377}
1378
1379test "a position is read component by component, integers included" {
1380 const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":-2,\"z\":0}}");
1381 try testing.expectEqual(@as(f64, 1.5), p.x);
1382 try testing.expectEqual(@as(f64, -2), p.y);
1383 try testing.expectEqual(@as(f64, 0), p.z);
1384}
1385
1386test "one non-number discards the whole position, as a dictionary cast does" {
1387 // `body["position"] as? [String: Double]` is a cast of the *dictionary*.
1388 // It fails whole when any value is not a Double, so `{"x":1,"y":"up"}` is
1389 // not "x with a bad y" — it is no position, and the node lands at the
1390 // default. A reader that kept x would put the object somewhere Swift
1391 // never would.
1392 const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":\"up\"}}");
1393 try testing.expectEqual(@as(f64, 0), p.x);
1394 try testing.expectEqual(@as(f64, 0), p.y);
1395 try testing.expectEqual(default_z, p.z);
1396
1397 const nested = try positionOf("{\"position\":{\"x\":1.5,\"z\":{\"deep\":1}}}");
1398 try testing.expectEqual(@as(f64, 0), nested.x);
1399 try testing.expectEqual(default_z, nested.z);
1400}
1401
1402test "a position that is not an object at all is no position" {
1403 try testing.expectEqual(default_z, (try positionOf("{\"position\":[1,2,3]}")).z);
1404 try testing.expectEqual(default_z, (try positionOf("{\"position\":\"1,2,3\"}")).z);
1405 try testing.expectEqual(default_z, (try positionOf("{\"position\":null}")).z);
1406}
1407
1408test "startAR refuses where world tracking is unavailable rather than reaching for it" {
1409 // On a host — and in the simulator — `ARWorldTrackingConfiguration` is
1410 // absent or answers false, and `classBool` gives `false` for both. That is
1411 // Swift's `guard … isSupported else { reject }`, and it is the only AR
1412 // path any test in this repository can reach.
1413 var bridge = ARBridge.init(testing.allocator);
1414 defer bridge.deinit();
1415
1416 try testing.expectError(
1417 bridge_error.BridgeError.PlatformNotSupported,
1418 bridge.handleMessage(A.start_ar, "{}"),
1419 );
1420}
1421
1422test "placeARObject needs a model before it needs a session" {
1423 var bridge = ARBridge.init(testing.allocator);
1424 defer bridge.deinit();
1425
1426 // Swift checks `body["model"] as? String` in the dispatcher, before
1427 // `placeARObject` ever looks at `self.arView` — so a call with no model is
1428 // INVALID_ARGUMENT whether or not a session is running.
1429 try testing.expectError(
1430 bridge_error.BridgeError.InvalidParameter,
1431 bridge.handleMessage(A.place_ar_object, "{}"),
1432 );
1433 try testing.expectError(
1434 bridge_error.BridgeError.InvalidParameter,
1435 bridge.handleMessage(A.place_ar_object, "{\"model\":42}"),
1436 );
1437}
1438
1439test "removeARObject with an id nothing was placed under is a rejection" {
1440 var bridge = ARBridge.init(testing.allocator);
1441 defer bridge.deinit();
1442
1443 // Swift's `else { rejectCallback(… "Object not found") }`. Reachable on a
1444 // host because the map is empty, which is the same state a real device is
1445 // in before anything is placed.
1446 try testing.expectError(
1447 bridge_error.BridgeError.InvalidParameter,
1448 bridge.handleMessage(A.remove_ar_object, "{\"objectId\":\"nope\"}"),
1449 );
1450 try testing.expectError(
1451 bridge_error.BridgeError.InvalidParameter,
1452 bridge.handleMessage(A.remove_ar_object, "{}"),
1453 );
1454}
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