From 7772dd8770dd63379404de6cba0d1ee49ee1a62d Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Fri, 4 Sep 2026 23:56:02 +0800 Subject: [PATCH 1/2] feat(ios): AR moves to Zig, ratchet 18 -> 13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last group whose deferral reason was an effort judgement rather than a wall — 'SceneKit node-graph glue; miserable and low-value through objc_msgSend'. The node graph turned out not to be the hard part. What is hard is that this is the first module to pass and return structs by value in floating-point registers, and it does it three ways: CGRect for the view frame, SCNVector3 for a node's position, and simd_float3 for a plane anchor's centre and extent. The last one uses a different rule from the other two — sixteen bytes in one vector register, not a float aggregate in four — and getting it wrong yields plausible coordinates rather than a crash. So all three are exercised against real objects rather than read off the headers. SceneKit ships on macOS even though ARKit does not, which is what makes SCNNode, SCNBox and simdPosition reachable from a host test. The simd_float3 guard was mutation-tested: decoding it as four floats leaves y and z at zero, and the test says so. SCNVector3 is three CGFloats on macOS and three floats on iOS, so the alias follows the target and the host test verifies the mechanism at the wrong width. That is a real limit and it is written down in the module. Ports two details that are easy to miss: extent contributes x and z as width and height, so a plane's y extent never reaches the page; and position is a [String: Double] cast of the whole dictionary, so one non-number discards all three components rather than just its own. --- packages/zig/build.zig | 7 + packages/zig/src/bridge_mobile_ar.zig | 1450 ++++++++++++++++++++ packages/zig/src/ios_config.zig | 16 +- packages/zig/src/ios_dispatch.zig | 3 + packages/zig/test/ios_conformance_test.zig | 10 +- 5 files changed, 1476 insertions(+), 10 deletions(-) create mode 100644 packages/zig/src/bridge_mobile_ar.zig diff --git a/packages/zig/build.zig b/packages/zig/build.zig index 8cb65358..066ca9b2 100644 --- a/packages/zig/build.zig +++ b/packages/zig/build.zig @@ -374,6 +374,10 @@ pub fn build(b: *std.Build) void { ios_module_tests.root_module.linkFramework("CoreGraphics", .{}); ios_module_tests.root_module.linkFramework("CoreMIDI", .{}); ios_module_tests.root_module.linkFramework("Security", .{}); + // SceneKit is on macOS as well as iOS, which is the only reason + // `bridge_mobile_ar.zig`'s three struct-ABI tests can run on a host: + // ARKit is device-only, SCNNode and SCNBox are not. + ios_module_tests.root_module.linkFramework("SceneKit", .{}); ios_module_tests.root_module.addCSourceFile(.{ .file = b.path("vendor/sqlite/sqlite3.c"), .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" }, @@ -485,6 +489,9 @@ pub fn build(b: *std.Build) void { ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_locrecording.zig", .{ .root_source_file = b.path("src/bridge_mobile_locrecording.zig"), }); + ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_ar.zig", .{ + .root_source_file = b.path("src/bridge_mobile_ar.zig"), + }); ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_motion.zig", .{ .root_source_file = b.path("src/bridge_mobile_motion.zig"), }); diff --git a/packages/zig/src/bridge_mobile_ar.zig b/packages/zig/src/bridge_mobile_ar.zig new file mode 100644 index 00000000..f9666612 --- /dev/null +++ b/packages/zig/src/bridge_mobile_ar.zig @@ -0,0 +1,1450 @@ +//! The five `startAR` / `stopAR` / `placeARObject` / `removeARObject` / +//! `getARPlanes` actions of the `mobile` namespace. +//! +//! This is the last group whose deferral reason was an effort judgement rather +//! than a wall — "SceneKit node-graph glue; miserable and low-value through +//! `objc_msgSend`" — so it is worth being precise about what was actually hard, +//! because it was not the node graph. +//! +//! ## Three struct returns, and why they are the whole risk +//! +//! Every other migrated module passes and returns pointers and integers. This +//! one passes and returns *structs by value*, in floating-point registers, +//! three times: +//! +//! - `CGRect` (`UIScreen.main.bounds`, `-initWithFrame:`) — four `CGFloat`s, +//! a homogeneous float aggregate returned in `v0`-`v3`. +//! - `SCNVector3` (`SCNNode.position`) — three components in `v0`-`v2`. +//! - `simd_float3` (`ARPlaneAnchor.center` / `.extent`) — sixteen bytes in a +//! *single* 128-bit vector register, which is a different rule from the +//! other two and the one most likely to be got wrong. +//! +//! Getting any of them wrong does not crash. It yields plausible coordinates +//! that are not the ones ARKit measured — a wrong answer delivered as a right +//! one, which is the failure this migration exists to remove. So the three are +//! not asserted from the headers and hoped for; they are exercised on a host, +//! against real SceneKit objects, in the tests at the bottom of this file. +//! +//! **`SCNVector3` is not the same type on the two platforms.** macOS declares +//! it as three `CGFloat`s and iOS as three `float`s +//! (`SceneKitTypes.h` in each SDK, checked in both). The alias below follows +//! the target; a host test therefore verifies the *mechanism* — a +//! three-element homogeneous aggregate through `objc_msgSend` — at double +//! width, not the exact iOS layout. That is a real limit on what the tests +//! below prove, and it is why the alias is derived from the target rather than +//! written out once. +//! +//! ## What cannot be verified here at all +//! +//! ARKit is device-only: `ARWorldTrackingConfiguration.isSupported` is false in +//! the simulator, so `startAR` refuses there and every line past the guard is +//! unreachable without hardware. The tests below cover the argument parsing, +//! the reply shapes, the plane JSON and the three ABI mechanisms; they do not +//! cover a running AR session, and no test in this repository can. + +const std = @import("std"); +const builtin = @import("builtin"); +const capabilities = @import("capabilities.zig"); +const bridge_error = @import("bridge_error.zig"); +const objc_runtime = @import("objc_runtime.zig"); +const ios_events = @import("ios_events.zig"); + +const objc = objc_runtime.objc; +const is_darwin = builtin.target.os.tag.isDarwin(); +const is_ios = builtin.target.os.tag == .ios; + +/// The same type as `objc.id` — `?*anyopaque` — spelled locally, so a +/// `callconv(.c)` signature stays legal off Darwin. +const Id = ?*anyopaque; + +// ============================================================================= +// The three struct types that cross the ABI boundary. +// ============================================================================= + +/// `CGFloat`: 64-bit on every target this ships to (arm64 and x86_64 alike). +const CGFloat = f64; + +const CGPoint = extern struct { x: CGFloat = 0, y: CGFloat = 0 }; +const CGSize = extern struct { width: CGFloat = 0, height: CGFloat = 0 }; +const CGRect = extern struct { origin: CGPoint = .{}, size: CGSize = .{} }; + +/// `SCNVector3`'s component type, which the two SDKs disagree about. +/// +/// `SceneKitTypes.h` declares `CGFloat x, y, z` on macOS and `float x, y, z` +/// on iOS. Both are homogeneous three-element aggregates, so the calling +/// convention is the same shape at two widths — but a single hardcoded width +/// would silently misread every coordinate on one of the two. +const ScnFloat = if (is_ios) f32 else CGFloat; + +const SCNVector3 = extern struct { x: ScnFloat = 0, y: ScnFloat = 0, z: ScnFloat = 0 }; + +/// `simd_float3`: three floats in sixteen bytes, returned in one 128-bit +/// vector register rather than as a float aggregate. +/// +/// `@Vector(4, f32)` rather than a four-field struct on purpose. An +/// `extern struct { f32, f32, f32, f32 }` is a homogeneous aggregate and comes +/// back in `v0`-`v3`; `simd_float3` comes back in `v0` alone. The two disagree +/// about everything but `x`, which is exactly the kind of bug that reads as +/// "the y coordinate is always zero" rather than as a crash. +const SimdFloat3 = @Vector(4, f32); + +fn simdX(v: SimdFloat3) f32 { + return v[0]; +} +fn simdY(v: SimdFloat3) f32 { + return v[1]; +} +fn simdZ(v: SimdFloat3) f32 { + return v[2]; +} + +/// The typed `objc_msgSend` casts for the three struct shapes. +/// +/// `objc_msgSend` and not `objc_msgSend_stret` for every one of them: on arm64 +/// a homogeneous float aggregate of four or fewer members, and a 16-byte +/// vector, are all returned in registers, and `_stret` does not exist on that +/// architecture at all. This is arm64-only reasoning, which is what both the +/// device and the host run. +fn selector(name: [*:0]const u8) !Id { + if (!is_darwin) return error.UnsupportedPlatform; + return objc.sel_registerName(name) orelse error.SelectorNotFound; +} + +fn msgSendRect(target: Id, sel: Id) CGRect { + const Fn = *const fn (Id, Id) callconv(.c) CGRect; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel); +} + +fn msgSendIdWithRect(target: Id, sel: Id, rect: CGRect) Id { + const Fn = *const fn (Id, Id, CGRect) callconv(.c) Id; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel, rect); +} + +fn msgSendVector3(target: Id, sel: Id) SCNVector3 { + const Fn = *const fn (Id, Id) callconv(.c) SCNVector3; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel); +} + +fn msgSendSetVector3(target: Id, sel: Id, value: SCNVector3) void { + const Fn = *const fn (Id, Id, SCNVector3) callconv(.c) void; + const f: Fn = @ptrCast(&objc.objc_msgSend); + f(target, sel, value); +} + +fn msgSendSimd3(target: Id, sel: Id) SimdFloat3 { + const Fn = *const fn (Id, Id) callconv(.c) SimdFloat3; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel); +} + +fn msgSendCGFloat(target: Id, sel: Id) CGFloat { + const Fn = *const fn (Id, Id) callconv(.c) CGFloat; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel); +} + +// ============================================================================= +// The actions. +// ============================================================================= + +pub const A = struct { + pub const start_ar = "startAR"; + pub const stop_ar = "stopAR"; + pub const place_ar_object = "placeARObject"; + pub const remove_ar_object = "removeARObject"; + pub const get_ar_planes = "getARPlanes"; +}; + +/// `.result` throughout: each of the five Swift paths ends in exactly one +/// `resolveCallback` or one `rejectCallback`, and each is awaited by the page. +/// +/// `.live`, not `.unavailable`: these dispatch and do the thing. A device +/// without world tracking is refused at run time by +/// `ARWorldTrackingConfiguration.isSupported`, which is a fact about the +/// hardware rather than about this build. +pub const capability_actions = [_]capabilities.ActionDecl{ + .{ .name = A.start_ar, .reply = .result }, + .{ .name = A.stop_ar, .reply = .result }, + .{ .name = A.place_ar_object, .reply = .result }, + .{ .name = A.remove_ar_object, .reply = .result }, + .{ .name = A.get_ar_planes, .reply = .result }, +}; + +const Route = enum { start, stop, place, remove, planes }; + +fn routeFor(action: []const u8) ?Route { + if (std.mem.eql(u8, action, A.start_ar)) return .start; + if (std.mem.eql(u8, action, A.stop_ar)) return .stop; + if (std.mem.eql(u8, action, A.place_ar_object)) return .place; + if (std.mem.eql(u8, action, A.remove_ar_object)) return .remove; + if (std.mem.eql(u8, action, A.get_ar_planes)) return .planes; + return null; +} + +/// `[.horizontal, .vertical]`. `ARPlaneDetectionHorizontal` is `1 << 0` and +/// `ARPlaneDetectionVertical` is `1 << 1` (`ARPlaneDetectionTypes.h`), read out +/// of the iphoneos SDK rather than guessed. +const ar_plane_detection_horizontal_and_vertical: c_ulong = 3; + +/// `AREnvironmentTexturingAutomatic`: the third member of an +/// `NS_ENUM(NSInteger)` that begins at `None` (`ARConfiguration.h`). +const ar_environment_texturing_automatic: c_long = 2; + +/// `ARPlaneAnchorAlignmentHorizontal` is 0, `…Vertical` is 1 +/// (`ARPlaneAnchor.h`). Swift's ternary is `== .horizontal ? … : …`, so +/// everything that is not 0 reads as vertical — including a value neither +/// constant covers, which is why this is a comparison and not a lookup. +const ar_plane_alignment_horizontal: c_long = 0; + +/// `arView.tag = 9999`, which Swift sets under the comment "For removal later" +/// and then never reads — `stopAR` holds the view directly. Carried across +/// anyway: it is visible to anything else walking the view hierarchy, and +/// dropping it would be a silent change to what that sees. +const ar_view_tag: c_long = 9999; + +/// `SCNVector3(0, 0, -0.5)`, and the `?? -0.5` in `pos["z"]`. +const default_z: f64 = -0.5; + +// ============================================================================= +// The session, and the two collections that outlive a dispatch. +// +// Unguarded, as Swift's are. `ios_dispatch` delivers page messages from +// `craftDidReceiveScriptMessage`, a WebKit main-thread callback, and SceneKit +// delivers `renderer:didAdd:` on the main thread as well, so writer and reader +// are the same thread. +// ============================================================================= + +var ar_view: Id = null; +var ar_session: Id = null; + +/// `arObjects: [String: SCNNode]`. Keys owned by the map, values retained. +var ar_objects: std.StringHashMapUnmanaged(Id) = .empty; + +/// `detectedPlanes: [UUID: ARPlaneAnchor]`, keyed by `UUIDString`. +/// +/// The anchors are stored retained and read at `getARPlanes` time rather than +/// flattened to numbers when they arrive, because that is what Swift does: its +/// dictionary holds the anchor objects, and `getARPlanes` reads `.center` and +/// `.extent` off them at the moment it is asked. Flattening on arrival would +/// answer with a plane's first measurement forever. +var ar_planes: std.StringHashMapUnmanaged(Id) = .empty; + +/// Module-level collections outlive every dispatch frame, so they cannot borrow +/// a bridge's allocator. +const ar_allocator = std.heap.c_allocator; + +// ============================================================================= +// Small Objective-C helpers. +// ============================================================================= + +fn retain(object: Id) void { + if (!is_darwin) return; + const sel = objc.sel_registerName("retain") orelse return; + _ = objc.msgSendId(object, sel); +} + +fn release(object: Id) void { + if (!is_darwin) return; + const sel = objc.sel_registerName("release") orelse return; + objc.msgSend(object, sel); +} + +/// An autoreleased `NSString` for a Zig slice. +fn makeNSString(text: []const u8) ?Id { + if (!is_darwin) return null; + + var buf: [4096]u8 = undefined; + if (text.len >= buf.len) return null; + @memcpy(buf[0..text.len], text); + buf[text.len] = 0; + + const NSString = objc.objc_getClass("NSString") orelse return null; + const sel = objc.sel_registerName("stringWithUTF8String:") orelse return null; + return objc.msgSendId1(NSString, sel, @as([*:0]const u8, @ptrCast(&buf))); +} + +/// An owned copy of a zero-argument `NSString`-returning property. +fn copyNSStringProperty(allocator: std.mem.Allocator, object: Id, comptime name: [*:0]const u8) ![]u8 { + if (!is_darwin) return error.UnsupportedPlatform; + + const sel = objc.sel_registerName(name) orelse return error.SelectorNotFound; + const value = objc.msgSendId(object, sel) orelse return error.NotFound; + const utf8 = objc.getNSStringUTF8(value) orelse return error.NotFound; + return allocator.dupe(u8, std.mem.span(utf8)); +} + +/// `UUID().uuidString`, through `NSUUID`. +fn newObjectId(allocator: std.mem.Allocator) ![]u8 { + if (!is_darwin) return error.UnsupportedPlatform; + + const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound; + const sel_uuid = try selector("UUID"); + const uuid = objc.msgSendId(NSUUID, sel_uuid) orelse return error.NativeCallFailed; + return copyNSStringProperty(allocator, uuid, "UUIDString"); +} + +fn msgSendLong(target: Id, sel: Id) c_long { + const Fn = *const fn (Id, Id) callconv(.c) c_long; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(target, sel); +} + +fn msgSendVoidLong(target: Id, sel: Id, value: c_long) void { + const Fn = *const fn (Id, Id, c_long) callconv(.c) void; + const f: Fn = @ptrCast(&objc.objc_msgSend); + f(target, sel, value); +} + +fn msgSendVoidULong(target: Id, sel: Id, value: c_ulong) void { + const Fn = *const fn (Id, Id, c_ulong) callconv(.c) void; + const f: Fn = @ptrCast(&objc.objc_msgSend); + f(target, sel, value); +} + +fn msgSendVoidBool(target: Id, sel: Id, value: bool) void { + const Fn = *const fn (Id, Id, bool) callconv(.c) void; + const f: Fn = @ptrCast(&objc.objc_msgSend); + f(target, sel, value); +} + +/// A class-method `BOOL` with no arguments, e.g. +/// `+[ARWorldTrackingConfiguration isSupported]`. +fn classBool(class_name: [*:0]const u8, comptime sel_name: [*:0]const u8) bool { + if (!is_darwin) return false; + const cls = objc.objc_getClass(class_name) orelse return false; + const sel = objc.sel_registerName(sel_name) orelse return false; + return objc.msgSendBool(cls, sel); +} + +// ============================================================================= +// The plane shape, kept pure so the JSON the page reads is pinnable on a host. +// ============================================================================= + +/// One entry of `getARPlanes`, and the body of a `craftARPlane` event. +/// +/// The seven numbers Swift reads off an `ARPlaneAnchor`, flattened at the point +/// of use. `extent` contributes **x and z** — Swift maps them to `width` and +/// `height`, so a plane's `y` extent never reaches the page. +const Plane = struct { + id: []const u8, + horizontal: bool, + center_x: f32, + center_y: f32, + center_z: f32, + extent_x: f32, + extent_z: f32, +}; + +fn appendPlaneObject(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), plane: Plane) !void { + try out.append(allocator, '{'); + try appendPlaneFields(allocator, out, plane); + try out.append(allocator, '}'); +} + +/// The four keys, without the braces, so `getARPlanes` and the `craftARPlane` +/// event cannot drift apart: the event is the same object with a `type` in +/// front of it. +fn appendPlaneFields(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), plane: Plane) !void { + try out.appendSlice(allocator, "\"id\":\""); + try bridge_error.appendJsonEscaped(allocator, out, plane.id); + try out.appendSlice(allocator, "\",\"alignment\":\""); + try out.appendSlice(allocator, if (plane.horizontal) "horizontal" else "vertical"); + try out.appendSlice(allocator, "\",\"center\":{\"x\":"); + try appendFloat(allocator, out, plane.center_x); + try out.appendSlice(allocator, ",\"y\":"); + try appendFloat(allocator, out, plane.center_y); + try out.appendSlice(allocator, ",\"z\":"); + try appendFloat(allocator, out, plane.center_z); + try out.appendSlice(allocator, "},\"extent\":{\"width\":"); + try appendFloat(allocator, out, plane.extent_x); + try out.appendSlice(allocator, ",\"height\":"); + try appendFloat(allocator, out, plane.extent_z); + try out.append(allocator, '}'); +} + +/// `allocPrint`, not a stack buffer: an `f32` rendered with `{d}` has no small +/// bound, and a `NoSpaceLeft` here would turn a measured plane into a refusal. +fn appendFloat(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: f32) !void { + const rendered = try std.fmt.allocPrint(allocator, "{d}", .{value}); + defer allocator.free(rendered); + try out.appendSlice(allocator, rendered); +} + +/// The bare JSON array `getARPlanes` resolves. +/// +/// `resolveCallback(callbackId, result: planes)` through `.fragmentsAllowed` +/// puts the array on the wire unwrapped, so `[]` — not `{"planes":[]}` — is the +/// answer when nothing has been detected. +fn shapePlanes(allocator: std.mem.Allocator, planes: []const Plane) ![]u8 { + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(allocator); + + try out.append(allocator, '['); + for (planes, 0..) |plane, i| { + if (i != 0) try out.append(allocator, ','); + try appendPlaneObject(allocator, &out, plane); + } + try out.append(allocator, ']'); + + return out.toOwnedSlice(allocator); +} + +// ============================================================================= +// The bridge. +// ============================================================================= + +pub const ARBridge = struct { + allocator: std.mem.Allocator, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ .allocator = allocator }; + } + + pub fn deinit(_: *Self) void {} + + pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { + const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; + // Exhaustive, so a `Route` without a handler is a compile error. + return switch (route) { + .start => self.startAR(data), + .stop => self.stopAR(data), + .place => self.placeARObject(data), + .remove => self.removeARObject(data), + .planes => self.getARPlanes(data), + }; + } + + /// Put an `ARSCNView` on the app's window and run world tracking on it. + /// + /// `data` is accepted and ignored, which is not laziness: the Swift + /// dispatcher parses `body["options"]` into a dictionary and hands it to + /// `startAR(options:callbackId:)`, whose body never reads it. Parsing it + /// here would invent a rejection for a malformed field the shim accepts. + /// + /// Swift wraps the whole body in `DispatchQueue.main.async`. This does not, + /// because dispatch already arrives on the main thread — the hop would only + /// defer the reply by a runloop turn. See the ordering note on + /// `getARPlanes`, which is the one place that is observable. + fn startAR(self: *Self, data: []const u8) !void { + _ = data; + if (!is_darwin) return error.UnsupportedPlatform; + + // `guard ARWorldTrackingConfiguration.isSupported else { reject }`. + // Also the answer on a host and in the simulator, where the class is + // absent or reports false — `classBool` gives the same `false` either + // way, which is the honest answer for both. + if (!classBool("ARWorldTrackingConfiguration", "isSupported")) { + std.log.warn("startAR: ARWorldTrackingConfiguration reports unsupported; refusing, as the shim does", .{}); + return bridge_error.BridgeError.PlatformNotSupported; + } + + const ARSCNView = objc.objc_getClass("ARSCNView") orelse return error.ClassNotFound; + const ARWorldTrackingConfiguration = objc.objc_getClass("ARWorldTrackingConfiguration") orelse + return error.ClassNotFound; + + const bounds = try screenBounds(); + + const allocated = objc.msgSendId(ARSCNView, try selector("alloc")) orelse + return error.NativeCallFailed; + const view = msgSendIdWithRect(allocated, try selector("initWithFrame:"), bounds) orelse + return error.NativeCallFailed; + + msgSendVoidBool(view, try selector("setAutoenablesDefaultLighting:"), true); + msgSendVoidLong(view, try selector("setTag:"), ar_view_tag); + objc.msgSendVoid1(view, try selector("setDelegate:"), try ensureDelegate()); + + const configuration = (try objc.allocInit(ARWorldTrackingConfiguration)) orelse + return error.NativeCallFailed; + msgSendVoidULong( + configuration, + try selector("setPlaneDetection:"), + ar_plane_detection_horizontal_and_vertical, + ); + msgSendVoidLong( + configuration, + try selector("setEnvironmentTexturing:"), + ar_environment_texturing_automatic, + ); + + const session = objc.msgSendId(view, try selector("session")) orelse + return error.NativeCallFailed; + + // Swift's `if let windowScene = …, let window = … { window.addSubview }` + // is a soft failure: the session still runs, the view is just not + // visible. Kept soft here, and logged, because refusing would be a + // different answer from the shim's. + addViewToKeyWindow(view); + + objc.msgSendVoid1(session, try selector("runWithConfiguration:"), configuration); + + ar_view = view; + ar_session = session; + + bridge_error.sendResultToJS(self.allocator, A.start_ar, "{\"started\":true}"); + } + + /// Pause the session, take the view off the window, and forget everything. + /// + /// No guard: `stopAR` has none in Swift, so stopping a session that never + /// started answers `{"stopped":true}` rather than an error. "There is no + /// session" and "the session is stopped" are the same state to a caller. + fn stopAR(self: *Self, data: []const u8) !void { + _ = data; + if (!is_darwin) return error.UnsupportedPlatform; + + if (ar_session) |session| { + objc.msgSend(session, try selector("pause")); + } + if (ar_view) |view| { + objc.msgSend(view, try selector("removeFromSuperview")); + release(view); + } + ar_view = null; + ar_session = null; + + clearObjects(); + clearPlanes(); + + bridge_error.sendResultToJS(self.allocator, A.stop_ar, "{\"stopped\":true}"); + } + + /// Add a node to the running session's scene. + fn placeARObject(self: *Self, data: []const u8) !void { + if (!is_darwin) return error.UnsupportedPlatform; + + var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch + return bridge_error.BridgeError.InvalidJSON; + defer parsed.deinit(); + + const object = switch (parsed.value) { + .object => |o| o, + else => return invalidArgument("placeARObject"), + }; + + // `if let model = body["model"] as? String` — a missing or non-string + // `model` is INVALID_ARGUMENT, and the position is read only after. + const model = switch (object.get("model") orelse return invalidArgument("placeARObject")) { + .string => |s| s, + else => return invalidArgument("placeARObject"), + }; + + const view = ar_view orelse { + std.log.warn("placeARObject: no AR session is running", .{}); + return bridge_error.BridgeError.InvalidParameter; + }; + + const position = readPosition(object.get("position")); + + const node = try buildNode(model); + + const object_id = try newObjectId(ar_allocator); + errdefer ar_allocator.free(object_id); + + try setNodePosition(node, position); + + const ns_id = makeNSString(object_id) orelse return error.NativeCallFailed; + objc.msgSendVoid1(node, try selector("setName:"), ns_id); + + const scene = objc.msgSendId(view, try selector("scene")) orelse return error.NativeCallFailed; + const root = objc.msgSendId(scene, try selector("rootNode")) orelse return error.NativeCallFailed; + objc.msgSendVoid1(root, try selector("addChildNode:"), node); + + retain(node); + try ar_objects.put(ar_allocator, object_id, node); + + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(self.allocator); + try out.appendSlice(self.allocator, "{\"objectId\":\""); + try bridge_error.appendJsonEscaped(self.allocator, &out, object_id); + try out.appendSlice(self.allocator, "\",\"placed\":true}"); + + bridge_error.sendResultToJS(self.allocator, A.place_ar_object, out.items); + } + + /// Take a node back out of the scene. + fn removeARObject(self: *Self, data: []const u8) !void { + if (!is_darwin) return error.UnsupportedPlatform; + + var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch + return bridge_error.BridgeError.InvalidJSON; + defer parsed.deinit(); + + const object = switch (parsed.value) { + .object => |o| o, + else => return invalidArgument("removeARObject"), + }; + const object_id = switch (object.get("objectId") orelse return invalidArgument("removeARObject")) { + .string => |s| s, + else => return invalidArgument("removeARObject"), + }; + + // `if let node = self.arObjects[objectId] … else { reject("Object not found") }`. + const entry = ar_objects.fetchRemove(object_id) orelse { + std.log.warn("removeARObject: no object with that id is placed", .{}); + return bridge_error.BridgeError.InvalidParameter; + }; + defer ar_allocator.free(entry.key); + + objc.msgSend(entry.value, try selector("removeFromParentNode")); + release(entry.value); + + bridge_error.sendResultToJS(self.allocator, A.remove_ar_object, "{\"removed\":true}"); + } + + /// Every plane the session has detected, as a bare JSON array. + /// + /// One ordering divergence, and it is a fix rather than a loss: Swift's + /// `getARPlanes` is the only one of the five *not* wrapped in + /// `DispatchQueue.main.async`, so a `startAR` immediately followed by a + /// `getARPlanes` has the planes reply first. Both are synchronous here, so + /// replies keep the order the page sent them in. + fn getARPlanes(self: *Self, data: []const u8) !void { + _ = data; + if (!is_darwin) return error.UnsupportedPlatform; + + var planes: std.ArrayListUnmanaged(Plane) = .empty; + defer { + for (planes.items) |plane| self.allocator.free(plane.id); + planes.deinit(self.allocator); + } + + var it = ar_planes.iterator(); + while (it.next()) |entry| { + const plane = readPlane(self.allocator, entry.key_ptr.*, entry.value_ptr.*) catch |err| { + // One unreadable anchor must not cost the page the others. + std.log.warn("getARPlanes: could not read a plane anchor ({}); it is left out", .{err}); + continue; + }; + try planes.append(self.allocator, plane); + } + + const json = try shapePlanes(self.allocator, planes.items); + defer self.allocator.free(json); + + bridge_error.sendResultToJS(self.allocator, A.get_ar_planes, json); + } +}; + +/// `rejectCallback(callbackId, error: "… was called without the values it needs", code: "INVALID_ARGUMENT")`. +/// +/// `BridgeError` carries an enum rather than free text, so the page sees +/// `INVALID_PARAMETER` where Swift sent `INVALID_ARGUMENT` — the same trade +/// `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document. The +/// rejection itself, which is what the promise needs, is not lost. +fn invalidArgument(comptime action: []const u8) bridge_error.BridgeError { + std.log.warn(action ++ ": called without the values it needs", .{}); + return bridge_error.BridgeError.InvalidParameter; +} + +// ============================================================================= +// Building a node, and reading a plane. +// ============================================================================= + +/// `SCNVector3(Float(pos["x"] ?? 0), Float(pos["y"] ?? 0), Float(pos["z"] ?? -0.5))`. +const Position = struct { x: f64 = 0, y: f64 = 0, z: f64 = default_z }; + +/// Swift's cast is `body["position"] as? [String: Double]`, and a +/// **dictionary** cast fails as a whole when any one value is not a `Double`. +/// So `{"x": 1, "y": "up"}` is not "x with a bad y", it is no position at all, +/// and the node lands at the default. Ported exactly: one non-number anywhere +/// discards the whole dictionary. +fn readPosition(value: ?std.json.Value) Position { + const object = switch (value orelse return .{}) { + .object => |o| o, + else => return .{}, + }; + + var position: Position = .{}; + var it = object.iterator(); + while (it.next()) |entry| { + const number: f64 = switch (entry.value_ptr.*) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + // The whole cast fails, so nothing survives from this dictionary. + else => return .{}, + }; + const key = entry.key_ptr.*; + if (std.mem.eql(u8, key, "x")) position.x = number; + if (std.mem.eql(u8, key, "y")) position.y = number; + if (std.mem.eql(u8, key, "z")) position.z = number; + } + return position; +} + +fn setNodePosition(node: Id, position: Position) !void { + msgSendSetVector3(node, try selector("setPosition:"), .{ + .x = @floatCast(position.x), + .y = @floatCast(position.y), + .z = @floatCast(position.z), + }); +} + +/// `model.hasSuffix(".usdz") || model.hasSuffix(".scn")` picks the loader; +/// everything else is a named primitive, with an unrecognised name falling to +/// the same box as `"box"` — Swift's `default:`, not an error. +fn buildNode(model: []const u8) !Id { + if (std.mem.endsWith(u8, model, ".usdz") or std.mem.endsWith(u8, model, ".scn")) { + return loadModelNode(model); + } + return primitiveNode(model); +} + +fn loadModelNode(model: []const u8) !Id { + const NSURL = objc.objc_getClass("NSURL") orelse return error.ClassNotFound; + const ns_model = makeNSString(model) orelse return error.NativeCallFailed; + + // `URL(string: model)`, which answers nil for a string that is not a URL — + // Swift's "Invalid model URL" rejection. + const url = objc.msgSendId1(NSURL, try selector("URLWithString:"), ns_model) orelse { + std.log.warn("placeARObject: the model string is not a URL", .{}); + return bridge_error.BridgeError.InvalidParameter; + }; + + const SCNScene = objc.objc_getClass("SCNScene") orelse return error.ClassNotFound; + + // `SCNScene(url:options:)` throws; the Objective-C spelling takes an + // `NSError **` and answers nil. Both are "Failed to load model". + var err_object: Id = null; + const SceneFn = *const fn (Id, Id, Id, Id, *Id) callconv(.c) Id; + const scene_with: SceneFn = @ptrCast(&objc.objc_msgSend); + const scene = scene_with( + SCNScene, + try selector("sceneWithURL:options:error:"), + url, + null, + &err_object, + ) orelse { + std.log.warn("placeARObject: SCNScene could not load the model", .{}); + return bridge_error.BridgeError.NativeCallFailed; + }; + + // `let node = SCNNode(); for child in scene.rootNode.childNodes { node.addChildNode(child) }` + // + // A wrapper node, not the scene's root: `addChildNode` re-parents, so + // handing the page the loaded root would move the scene's own graph. + const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound; + const node = objc.msgSendId(SCNNode, try selector("node")) orelse return error.NativeCallFailed; + + const root = objc.msgSendId(scene, try selector("rootNode")) orelse return error.NativeCallFailed; + const children = objc.msgSendId(root, try selector("childNodes")) orelse return error.NativeCallFailed; + + const count: c_ulong = @intCast(msgSendLong(children, try selector("count"))); + const sel_at = try selector("objectAtIndex:"); + const sel_add = try selector("addChildNode:"); + const AtFn = *const fn (Id, Id, c_ulong) callconv(.c) Id; + const object_at: AtFn = @ptrCast(&objc.objc_msgSend); + + var i: c_ulong = 0; + while (i < count) : (i += 1) { + const child = object_at(children, sel_at, i) orelse continue; + objc.msgSendVoid1(node, sel_add, child); + } + + return node; +} + +fn primitiveNode(model: []const u8) !Id { + const geometry = try namedGeometry(model); + + // `geometry.firstMaterial?.diffuse.contents = UIColor.systemBlue`. Optional + // the whole way in Swift, so a geometry with no material is not an error. + if (objc.msgSendId(geometry, try selector("firstMaterial"))) |material| { + if (objc.msgSendId(material, try selector("diffuse"))) |property| { + if (systemBlue()) |color| { + objc.msgSendVoid1(property, try selector("setContents:"), color); + } + } + } + + const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound; + return objc.msgSendId1(SCNNode, try selector("nodeWithGeometry:"), geometry) orelse + error.NativeCallFailed; +} + +/// The four named primitives, at Swift's dimensions. +fn namedGeometry(model: []const u8) !Id { + const Two = *const fn (Id, Id, CGFloat, CGFloat) callconv(.c) Id; + const Three = *const fn (Id, Id, CGFloat, CGFloat, CGFloat) callconv(.c) Id; + const Four = *const fn (Id, Id, CGFloat, CGFloat, CGFloat, CGFloat) callconv(.c) Id; + + if (std.mem.eql(u8, model, "sphere")) { + const SCNSphere = objc.objc_getClass("SCNSphere") orelse return error.ClassNotFound; + const f: *const fn (Id, Id, CGFloat) callconv(.c) Id = @ptrCast(&objc.objc_msgSend); + return f(SCNSphere, try selector("sphereWithRadius:"), 0.05) orelse error.NativeCallFailed; + } + if (std.mem.eql(u8, model, "cylinder")) { + const SCNCylinder = objc.objc_getClass("SCNCylinder") orelse return error.ClassNotFound; + const f: Two = @ptrCast(&objc.objc_msgSend); + return f(SCNCylinder, try selector("cylinderWithRadius:height:"), 0.05, 0.1) orelse + error.NativeCallFailed; + } + if (std.mem.eql(u8, model, "cone")) { + const SCNCone = objc.objc_getClass("SCNCone") orelse return error.ClassNotFound; + const f: Three = @ptrCast(&objc.objc_msgSend); + return f(SCNCone, try selector("coneWithTopRadius:bottomRadius:height:"), 0, 0.05, 0.1) orelse + error.NativeCallFailed; + } + + // `case "box"` and `default:` are the same geometry in Swift. + const SCNBox = objc.objc_getClass("SCNBox") orelse return error.ClassNotFound; + const f: Four = @ptrCast(&objc.objc_msgSend); + return f(SCNBox, try selector("boxWithWidth:height:length:chamferRadius:"), 0.1, 0.1, 0.1, 0.01) orelse + error.NativeCallFailed; +} + +fn systemBlue() ?Id { + const UIColor = objc.objc_getClass("UIColor") orelse return null; + const sel = objc.sel_registerName("systemBlueColor") orelse return null; + return objc.msgSendId(UIColor, sel); +} + +/// `UIScreen.main.bounds`. +fn screenBounds() !CGRect { + const UIScreen = objc.objc_getClass("UIScreen") orelse return error.ClassNotFound; + const screen = objc.msgSendId(UIScreen, try selector("mainScreen")) orelse + return error.NativeCallFailed; + return msgSendRect(screen, try selector("bounds")); +} + +/// `UIApplication.shared.connectedScenes.first as? UIWindowScene` → +/// `.windows.first` → `addSubview`. +/// +/// Every step is optional in Swift and the whole thing is an `if let`, so a +/// failure anywhere means the view is simply not added — the session still +/// runs. Logged rather than refused, because refusing would be a different +/// answer from the shim's. +fn addViewToKeyWindow(view: Id) void { + const missing = "startAR: could not reach a window to attach the AR view to; the session runs without a visible view"; + + const UIApplication = objc.objc_getClass("UIApplication") orelse return; + const sel_shared = objc.sel_registerName("sharedApplication") orelse return; + const app = objc.msgSendId(UIApplication, sel_shared) orelse return; + + const sel_scenes = objc.sel_registerName("connectedScenes") orelse return; + const scenes = objc.msgSendId(app, sel_scenes) orelse { + std.log.warn(missing, .{}); + return; + }; + + // `Set.first` in Swift is an arbitrary member; `-anyObject` is the same + // choice made by the same collection. + const sel_any = objc.sel_registerName("anyObject") orelse return; + const scene = objc.msgSendId(scenes, sel_any) orelse { + std.log.warn(missing, .{}); + return; + }; + + const sel_windows = objc.sel_registerName("windows") orelse return; + const sel_responds = objc.sel_registerName("respondsToSelector:") orelse return; + const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool; + const responds: RespondsFn = @ptrCast(&objc.objc_msgSend); + + // `as? UIWindowScene` in one question: a scene that is not a window scene + // has no `windows`, and asking beats sending an unrecognised selector. + if (!responds(scene, sel_responds, sel_windows)) { + std.log.warn(missing, .{}); + return; + } + + const windows = objc.msgSendId(scene, sel_windows) orelse return; + const sel_first = objc.sel_registerName("firstObject") orelse return; + const window = objc.msgSendId(windows, sel_first) orelse { + std.log.warn(missing, .{}); + return; + }; + + const sel_add = objc.sel_registerName("addSubview:") orelse return; + objc.msgSendVoid1(window, sel_add, view); + retain(view); +} + +/// The seven numbers `getARPlanes` reads off one anchor. +fn readPlane(allocator: std.mem.Allocator, id: []const u8, anchor: Id) !Plane { + const center = msgSendSimd3(anchor, try selector("center")); + const extent = msgSendSimd3(anchor, try selector("extent")); + const alignment = msgSendLong(anchor, try selector("alignment")); + + return .{ + .id = try allocator.dupe(u8, id), + .horizontal = alignment == ar_plane_alignment_horizontal, + .center_x = simdX(center), + .center_y = simdY(center), + .center_z = simdZ(center), + .extent_x = simdX(extent), + .extent_z = simdZ(extent), + }; +} + +fn clearObjects() void { + var it = ar_objects.iterator(); + while (it.next()) |entry| { + release(entry.value_ptr.*); + ar_allocator.free(entry.key_ptr.*); + } + ar_objects.clearAndFree(ar_allocator); +} + +fn clearPlanes() void { + var it = ar_planes.iterator(); + while (it.next()) |entry| { + release(entry.value_ptr.*); + ar_allocator.free(entry.key_ptr.*); + } + ar_planes.clearAndFree(ar_allocator); +} + +// ============================================================================= +// The ARSCNViewDelegate, which is where planes come from. +// ============================================================================= + +const delegate_class_name = "CraftARDelegate"; + +var delegate_instance: Id = null; + +/// Register the delegate class once, and keep one instance alive. +/// +/// `ARSCNView` holds its delegate **weakly**, so a released instance is a +/// session that silently stops reporting planes. Module-level, for the same +/// reason `bridge_mobile_location.zig` keeps its own. +fn ensureDelegate() !Id { + if (!is_darwin) return error.UnsupportedPlatform; + if (delegate_instance) |existing| return existing; + + var cls = objc.objc_getClass(delegate_class_name); + if (cls == null) { + // All three selectors resolved before the class pair is allocated: a + // failure between `objc_allocateClassPair` and `objc_registerClassPair` + // leaves an unregistered class behind that nothing can dispose of. + const sel_add = try selector("renderer:didAddNode:forAnchor:"); + const sel_update = try selector("renderer:didUpdateNode:forAnchor:"); + const sel_remove = try selector("renderer:didRemoveNode:forAnchor:"); + + const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound; + cls = objc.objc_allocateClassPair(NSObject, delegate_class_name, 0) orelse + return error.ClassAllocationFailed; + + // v@:@@@ — void, self, _cmd, the renderer, the node, the anchor. + if (!objc.class_addMethod(cls, sel_add, @ptrCast(&rendererDidAddNode), "v@:@@@")) { + return error.MethodNotAdded; + } + if (!objc.class_addMethod(cls, sel_update, @ptrCast(&rendererDidUpdateNode), "v@:@@@")) { + return error.MethodNotAdded; + } + if (!objc.class_addMethod(cls, sel_remove, @ptrCast(&rendererDidRemoveNode), "v@:@@@")) { + return error.MethodNotAdded; + } + objc.objc_registerClassPair(cls); + } + + const instance = (try objc.allocInit(cls)) orelse return error.NativeCallFailed; + delegate_instance = instance; + return instance; +} + +fn isKindOf(object: Id, class_name: [*:0]const u8) bool { + if (!is_darwin) return false; + const cls = objc.objc_getClass(class_name) orelse return false; + const sel = objc.sel_registerName("isKindOfClass:") orelse return false; + const Fn = *const fn (Id, Id, Id) callconv(.c) bool; + const f: Fn = @ptrCast(&objc.objc_msgSend); + return f(object, sel, cls); +} + +/// The anchor's `identifier.uuidString`, owned by the caller. +fn anchorId(allocator: std.mem.Allocator, anchor: Id) ![]u8 { + const identifier = objc.msgSendId(anchor, try selector("identifier")) orelse + return error.NativeCallFailed; + return copyNSStringProperty(allocator, identifier, "UUIDString"); +} + +/// Store or replace the anchor under its id, keeping one retain per entry. +fn rememberPlane(id: []const u8, anchor: Id) !void { + if (ar_planes.getEntry(id)) |entry| { + // `detectedPlanes[anchor.identifier] = planeAnchor` on an existing key + // replaces the value; the key is already ours and stays. + release(entry.value_ptr.*); + retain(anchor); + entry.value_ptr.* = anchor; + return; + } + + const owned = try ar_allocator.dupe(u8, id); + errdefer ar_allocator.free(owned); + retain(anchor); + try ar_planes.put(ar_allocator, owned, anchor); +} + +fn rendererDidAddNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void { + if (!is_darwin) return; + + // `guard let planeAnchor = anchor as? ARPlaneAnchor else { return }`. + if (!isKindOf(anchor, "ARPlaneAnchor")) return; + + const allocator = std.heap.c_allocator; + + const id = anchorId(allocator, anchor) catch |err| { + std.log.warn("ar: a plane anchor arrived with no readable identifier ({}); it is not recorded", .{err}); + return; + }; + defer allocator.free(id); + + rememberPlane(id, anchor) catch |err| { + std.log.warn("ar: could not record the plane anchor ({}); getARPlanes will not report it", .{err}); + return; + }; + + // The visualisation is decoration: a failure here costs the page a grey + // rectangle, not a plane, so it is logged and the event still goes out. + addPlaneVisualisation(node, anchor) catch |err| { + std.log.warn("ar: could not build the plane visualisation ({}); the plane is still reported", .{err}); + }; + + const plane = readPlane(allocator, id, anchor) catch |err| { + std.log.warn("ar: could not read the new plane's geometry ({}); no event is emitted", .{err}); + return; + }; + defer allocator.free(plane.id); + + const json = shapePlaneEvent(allocator, "added", plane) catch return; + defer allocator.free(json); + + ios_events.emit(.ar_plane, json); +} + +fn rendererDidUpdateNode(_: Id, _: Id, _: Id, node: Id, anchor: Id) callconv(.c) void { + if (!is_darwin) return; + + if (!isKindOf(anchor, "ARPlaneAnchor")) return; + + const allocator = std.heap.c_allocator; + + const id = anchorId(allocator, anchor) catch return; + defer allocator.free(id); + + rememberPlane(id, anchor) catch |err| { + std.log.warn("ar: could not update the stored plane anchor ({})", .{err}); + }; + + // `if let planeNode = node.childNodes.first, let plane = planeNode.geometry as? SCNPlane` + // — both optional, so nothing here is an error. + updatePlaneVisualisation(node, anchor) catch {}; + + // Swift emits no event on update. Neither does this: a page that wants + // live extents polls `getARPlanes`, which reads the anchor this just + // replaced. +} + +fn rendererDidRemoveNode(_: Id, _: Id, _: Id, _: Id, anchor: Id) callconv(.c) void { + if (!is_darwin) return; + + if (!isKindOf(anchor, "ARPlaneAnchor")) return; + + const allocator = std.heap.c_allocator; + + const id = anchorId(allocator, anchor) catch return; + defer allocator.free(id); + + if (ar_planes.fetchRemove(id)) |entry| { + release(entry.value); + ar_allocator.free(entry.key); + } + + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(allocator); + + out.appendSlice(allocator, "{\"type\":\"removed\",\"id\":\"") catch return; + bridge_error.appendJsonEscaped(allocator, &out, id) catch return; + out.appendSlice(allocator, "\"}") catch return; + + ios_events.emit(.ar_plane, out.items); +} + +/// `{"type":"added", …the same fields `getARPlanes` reports…}`. +fn shapePlaneEvent(allocator: std.mem.Allocator, comptime kind: []const u8, plane: Plane) ![]u8 { + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(allocator); + + try out.appendSlice(allocator, "{\"type\":\"" ++ kind ++ "\","); + try appendPlaneFields(allocator, &out, plane); + try out.append(allocator, '}'); + + return out.toOwnedSlice(allocator); +} + +/// The grey rectangle Swift draws over a detected plane. +fn addPlaneVisualisation(node: Id, anchor: Id) !void { + const extent = msgSendSimd3(anchor, try selector("extent")); + const center = msgSendSimd3(anchor, try selector("center")); + + const SCNPlane = objc.objc_getClass("SCNPlane") orelse return error.ClassNotFound; + const PlaneFn = *const fn (Id, Id, CGFloat, CGFloat) callconv(.c) Id; + const plane_with: PlaneFn = @ptrCast(&objc.objc_msgSend); + const plane = plane_with( + SCNPlane, + try selector("planeWithWidth:height:"), + @floatCast(simdX(extent)), + @floatCast(simdZ(extent)), + ) orelse return error.NativeCallFailed; + + // `UIColor.systemBlue.withAlphaComponent(0.3)`. + if (objc.msgSendId(plane, try selector("firstMaterial"))) |material| { + if (objc.msgSendId(material, try selector("diffuse"))) |property| { + if (systemBlue()) |color| { + const AlphaFn = *const fn (Id, Id, CGFloat) callconv(.c) Id; + const with_alpha: AlphaFn = @ptrCast(&objc.objc_msgSend); + if (with_alpha(color, try selector("colorWithAlphaComponent:"), 0.3)) |faded| { + objc.msgSendVoid1(property, try selector("setContents:"), faded); + } + } + } + } + + const SCNNode = objc.objc_getClass("SCNNode") orelse return error.ClassNotFound; + const plane_node = objc.msgSendId1(SCNNode, try selector("nodeWithGeometry:"), plane) orelse + return error.NativeCallFailed; + + // `SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)` — y is + // dropped on purpose: the rectangle sits in the anchor's own plane. + msgSendSetVector3(plane_node, try selector("setPosition:"), .{ + .x = @floatCast(simdX(center)), + .y = 0, + .z = @floatCast(simdZ(center)), + }); + + // `planeNode.eulerAngles.x = -.pi / 2`, which in Objective-C is a whole + // vector read, one component changed, and the vector written back — + // Swift's property-of-a-property assignment does exactly that too. + const sel_euler = try selector("eulerAngles"); + var euler = msgSendVector3(plane_node, sel_euler); + euler.x = -std.math.pi / 2.0; + msgSendSetVector3(plane_node, try selector("setEulerAngles:"), euler); + + objc.msgSendVoid1(node, try selector("addChildNode:"), plane_node); +} + +/// Resize and re-centre the rectangle a previous `didAdd` built. +fn updatePlaneVisualisation(node: Id, anchor: Id) !void { + const children = objc.msgSendId(node, try selector("childNodes")) orelse return; + const plane_node = objc.msgSendId(children, try selector("firstObject")) orelse return; + const geometry = objc.msgSendId(plane_node, try selector("geometry")) orelse return; + + // `as? SCNPlane`: a child that is not one is left alone rather than sent a + // selector it does not have. + if (!isKindOf(geometry, "SCNPlane")) return; + + const extent = msgSendSimd3(anchor, try selector("extent")); + const center = msgSendSimd3(anchor, try selector("center")); + + const SetFn = *const fn (Id, Id, CGFloat) callconv(.c) void; + const set_dimension: SetFn = @ptrCast(&objc.objc_msgSend); + set_dimension(geometry, try selector("setWidth:"), @floatCast(simdX(extent))); + set_dimension(geometry, try selector("setHeight:"), @floatCast(simdZ(extent))); + + msgSendSetVector3(plane_node, try selector("setPosition:"), .{ + .x = @floatCast(simdX(center)), + .y = 0, + .z = @floatCast(simdZ(center)), + }); +} + +// ============================================================================= +// Tests. +// +// The three ABI mechanisms, exercised against real objects rather than read off +// the headers. SceneKit ships on macOS, so `SCNNode` and `SCNBox` are reachable +// from a host test even though ARKit is not; `NSView` supplies the `CGRect` +// round trip. What these cannot reach is a running AR session — see the module +// comment. +// ============================================================================= + +const testing = std.testing; + +test "a CGRect survives the trip out and back" { + if (!is_darwin) return error.SkipZigTest; + + // `-initWithFrame:` takes it in v0-v3 and `-frame` returns it the same way. + // A mismatch here would misplace the AR view rather than fail. + const NSView = objc.objc_getClass("NSView") orelse return error.SkipZigTest; + const sel_alloc = try selector("alloc"); + const sel_init_frame = try selector("initWithFrame:"); + const sel_frame = try selector("frame"); + + const allocated = objc.msgSendId(NSView, sel_alloc) orelse return error.SkipZigTest; + const sent: CGRect = .{ + .origin = .{ .x = 12.5, .y = -3.25 }, + .size = .{ .width = 640, .height = 480.75 }, + }; + const view = msgSendIdWithRect(allocated, sel_init_frame, sent) orelse return error.SkipZigTest; + + const got = msgSendRect(view, sel_frame); + try testing.expectEqual(sent.origin.x, got.origin.x); + try testing.expectEqual(sent.origin.y, got.origin.y); + try testing.expectEqual(sent.size.width, got.size.width); + try testing.expectEqual(sent.size.height, got.size.height); +} + +test "an SCNVector3 survives being set and read back" { + if (!is_darwin) return error.SkipZigTest; + + // The mechanism `placeARObject`'s `node.position = …` depends on. At host + // width, because macOS declares the members as CGFloat and iOS as float — + // the shape is what is being checked, not the width. + const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest; + const sel_node = try selector("node"); + const sel_set_position = try selector("setPosition:"); + const sel_position = try selector("position"); + + const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest; + + const sent: SCNVector3 = .{ .x = 1.5, .y = -2.25, .z = -0.5 }; + msgSendSetVector3(node, sel_set_position, sent); + + const got = msgSendVector3(node, sel_position); + try testing.expectEqual(sent.x, got.x); + try testing.expectEqual(sent.y, got.y); + try testing.expectEqual(sent.z, got.z); +} + +test "a simd_float3 comes back in one vector register, not as four floats" { + if (!is_darwin) return error.SkipZigTest; + + // `ARPlaneAnchor.center` and `.extent` are `simd_float3`, and this is the + // return rule they use. `SCNNode.simdPosition` is the same type and is + // reachable without ARKit, so the rule is checked here rather than assumed + // on a device nobody can run in CI. + // + // Read against `position`, which carries the same three numbers through the + // *other* convention: if this were being decoded as a float aggregate, x + // would still agree and y and z would not. + const SCNNode = objc.objc_getClass("SCNNode") orelse return error.SkipZigTest; + const sel_node = try selector("node"); + const sel_set_position = try selector("setPosition:"); + const sel_simd_position = try selector("simdPosition"); + + const node = objc.msgSendId(SCNNode, sel_node) orelse return error.SkipZigTest; + msgSendSetVector3(node, sel_set_position, .{ .x = 1.5, .y = -2.25, .z = -0.5 }); + + const got = msgSendSimd3(node, sel_simd_position); + try testing.expectApproxEqAbs(@as(f32, 1.5), simdX(got), 0.0001); + try testing.expectApproxEqAbs(@as(f32, -2.25), simdY(got), 0.0001); + try testing.expectApproxEqAbs(@as(f32, -0.5), simdZ(got), 0.0001); +} + +test "a CGFloat-returning geometry property reads back what it was built with" { + if (!is_darwin) return error.SkipZigTest; + + // The four-CGFloat constructor `placeARObject` uses for "box". + const SCNBox = objc.objc_getClass("SCNBox") orelse return error.SkipZigTest; + const sel_box = try selector("boxWithWidth:height:length:chamferRadius:"); + const Fn = *const fn (Id, Id, CGFloat, CGFloat, CGFloat, CGFloat) callconv(.c) Id; + const box_with: Fn = @ptrCast(&objc.objc_msgSend); + + const box = box_with(SCNBox, sel_box, 0.1, 0.2, 0.3, 0.01) orelse return error.SkipZigTest; + + try testing.expectApproxEqAbs(@as(CGFloat, 0.1), msgSendCGFloat(box, try selector("width")), 0.0001); + try testing.expectApproxEqAbs(@as(CGFloat, 0.2), msgSendCGFloat(box, try selector("height")), 0.0001); + try testing.expectApproxEqAbs(@as(CGFloat, 0.3), msgSendCGFloat(box, try selector("length")), 0.0001); +} + +test "the declared actions are the ones the handler serves" { + try testing.expectEqual(@as(usize, 5), capability_actions.len); + try testing.expectEqualStrings(A.start_ar, capability_actions[0].name); + try testing.expectEqualStrings(A.stop_ar, capability_actions[1].name); + try testing.expectEqualStrings(A.place_ar_object, capability_actions[2].name); + try testing.expectEqualStrings(A.remove_ar_object, capability_actions[3].name); + try testing.expectEqualStrings(A.get_ar_planes, capability_actions[4].name); + + for (capability_actions) |decl| { + try testing.expectEqual(capabilities.Reply.result, decl.reply); + try testing.expectEqual(capabilities.ActionStatus.live, decl.status); + try testing.expect(decl.reason == null); + try testing.expect(routeFor(decl.name) != null); + } +} + +test "an action the namespace does not serve is reported, not ignored" { + var bridge = ARBridge.init(testing.allocator); + defer bridge.deinit(); + + try testing.expectError( + bridge_error.BridgeError.UnknownAction, + bridge.handleMessage("noSuchAction", "{}"), + ); + // Casing is how a real typo arrives, and `startAr` is a plausible one. + try testing.expectError( + bridge_error.BridgeError.UnknownAction, + bridge.handleMessage("startAr", "{}"), + ); +} + +test "no planes is the bare array, not null and not a wrapper" { + // `resolveCallback(callbackId, result: planes)` through `.fragmentsAllowed` + // puts the array on the wire unwrapped, so a page doing `planes.length` + // keeps working when nothing has been detected. + const json = try shapePlanes(testing.allocator, &.{}); + defer testing.allocator.free(json); + try testing.expectEqualStrings("[]", json); +} + +test "a plane carries extent's x and z as width and height" { + // The mapping is easy to get wrong and impossible to see: Swift reads + // `extent.x` into `width` and **`extent.z`** into `height`, so a plane's y + // extent never reaches the page at all. + const json = try shapePlanes(testing.allocator, &.{ + .{ + .id = "AAA", + .horizontal = true, + .center_x = 1, + .center_y = 2, + .center_z = 3, + .extent_x = 4, + .extent_z = 5, + }, + }); + defer testing.allocator.free(json); + + try testing.expectEqualStrings( + \\[{"id":"AAA","alignment":"horizontal","center":{"x":1,"y":2,"z":3},"extent":{"width":4,"height":5}}] + , json); +} + +test "alignment is horizontal or vertical, and nothing else" { + const json = try shapePlanes(testing.allocator, &.{ + .{ .id = "A", .horizontal = true, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 }, + .{ .id = "B", .horizontal = false, .center_x = 0, .center_y = 0, .center_z = 0, .extent_x = 0, .extent_z = 0 }, + }); + defer testing.allocator.free(json); + + try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"horizontal\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"alignment\":\"vertical\"") != null); + // Two objects, one separator. + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "},{")); +} + +test "the craftARPlane event is the same object with a type in front" { + // `getARPlanes` and the event have to agree about a plane, so they share + // the field writer. This is what pins that they still do. + const plane: Plane = .{ + .id = "AAA", + .horizontal = false, + .center_x = 1, + .center_y = 2, + .center_z = 3, + .extent_x = 4, + .extent_z = 5, + }; + + const event = try shapePlaneEvent(testing.allocator, "added", plane); + defer testing.allocator.free(event); + + const array = try shapePlanes(testing.allocator, &.{plane}); + defer testing.allocator.free(array); + + // The array element's fields, unwrapped from its brackets and braces. + const fields = array[2 .. array.len - 2]; + + const expected = try std.fmt.allocPrint(testing.allocator, "{{\"type\":\"added\",{s}}}", .{fields}); + defer testing.allocator.free(expected); + + try testing.expectEqualStrings(expected, event); +} + +fn positionOf(json: []const u8) !Position { + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + return readPosition(parsed.value.object.get("position")); +} + +test "a missing position is the same as an empty one: (0, 0, -0.5)" { + // Both arms of Swift's `if let pos = position` land on z = -0.5, one + // through `SCNVector3(0, 0, -0.5)` and the other through `pos["z"] ?? -0.5`. + const absent = try positionOf("{}"); + try testing.expectEqual(@as(f64, 0), absent.x); + try testing.expectEqual(@as(f64, 0), absent.y); + try testing.expectEqual(default_z, absent.z); + + const empty = try positionOf("{\"position\":{}}"); + try testing.expectEqual(default_z, empty.z); +} + +test "a position is read component by component, integers included" { + const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":-2,\"z\":0}}"); + try testing.expectEqual(@as(f64, 1.5), p.x); + try testing.expectEqual(@as(f64, -2), p.y); + try testing.expectEqual(@as(f64, 0), p.z); +} + +test "one non-number discards the whole position, as a dictionary cast does" { + // `body["position"] as? [String: Double]` is a cast of the *dictionary*. + // It fails whole when any value is not a Double, so `{"x":1,"y":"up"}` is + // not "x with a bad y" — it is no position, and the node lands at the + // default. A reader that kept x would put the object somewhere Swift + // never would. + const p = try positionOf("{\"position\":{\"x\":1.5,\"y\":\"up\"}}"); + try testing.expectEqual(@as(f64, 0), p.x); + try testing.expectEqual(@as(f64, 0), p.y); + try testing.expectEqual(default_z, p.z); + + const nested = try positionOf("{\"position\":{\"x\":1.5,\"z\":{\"deep\":1}}}"); + try testing.expectEqual(@as(f64, 0), nested.x); + try testing.expectEqual(default_z, nested.z); +} + +test "a position that is not an object at all is no position" { + try testing.expectEqual(default_z, (try positionOf("{\"position\":[1,2,3]}")).z); + try testing.expectEqual(default_z, (try positionOf("{\"position\":\"1,2,3\"}")).z); + try testing.expectEqual(default_z, (try positionOf("{\"position\":null}")).z); +} + +test "startAR refuses where world tracking is unavailable rather than reaching for it" { + // On a host — and in the simulator — `ARWorldTrackingConfiguration` is + // absent or answers false, and `classBool` gives `false` for both. That is + // Swift's `guard … isSupported else { reject }`, and it is the only AR + // path any test in this repository can reach. + var bridge = ARBridge.init(testing.allocator); + defer bridge.deinit(); + + try testing.expectError( + bridge_error.BridgeError.PlatformNotSupported, + bridge.handleMessage(A.start_ar, "{}"), + ); +} + +test "placeARObject needs a model before it needs a session" { + var bridge = ARBridge.init(testing.allocator); + defer bridge.deinit(); + + // Swift checks `body["model"] as? String` in the dispatcher, before + // `placeARObject` ever looks at `self.arView` — so a call with no model is + // INVALID_ARGUMENT whether or not a session is running. + try testing.expectError( + bridge_error.BridgeError.InvalidParameter, + bridge.handleMessage(A.place_ar_object, "{}"), + ); + try testing.expectError( + bridge_error.BridgeError.InvalidParameter, + bridge.handleMessage(A.place_ar_object, "{\"model\":42}"), + ); +} + +test "removeARObject with an id nothing was placed under is a rejection" { + var bridge = ARBridge.init(testing.allocator); + defer bridge.deinit(); + + // Swift's `else { rejectCallback(… "Object not found") }`. Reachable on a + // host because the map is empty, which is the same state a real device is + // in before anything is placed. + try testing.expectError( + bridge_error.BridgeError.InvalidParameter, + bridge.handleMessage(A.remove_ar_object, "{\"objectId\":\"nope\"}"), + ); + try testing.expectError( + bridge_error.BridgeError.InvalidParameter, + bridge.handleMessage(A.remove_ar_object, "{}"), + ); +} diff --git a/packages/zig/src/ios_config.zig b/packages/zig/src/ios_config.zig index ed96cca2..32cba1dd 100644 --- a/packages/zig/src/ios_config.zig +++ b/packages/zig/src/ios_config.zig @@ -454,6 +454,14 @@ pub fn gateFor(action: []const u8) ?Feature { // and adding one here would strand a track on disk with no action able // to reach it. .{ "startLocationRecording", .geolocation }, + // All five AR actions carry the same `config.enableAR` guard in the + // spec (`CraftApp.swift:1210-1249`), unlike the recorder where only + // the start is gated. + .{ "startAR", .ar }, + .{ "stopAR", .ar }, + .{ "placeARObject", .ar }, + .{ "removeARObject", .ar }, + .{ "getARPlanes", .ar }, }; inline for (table) |entry| { 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" { try testing.expectEqual(Feature.camera, gateFor("pickImage").?); try testing.expectEqual(Feature.secure_storage, gateFor("secureSet").?); - // `openURL` is served and ungated in the spec; `startAR` is gated but not - // served here, so the shim's own gate applies and this must not answer. + // `openURL` is served and ungated in the spec, so this must not answer for + // it. `startAR` used to be here for the opposite reason — gated but still on + // the shim — and now that Zig serves it, the gate has to come from here. try testing.expect(gateFor("openURL") == null); - try testing.expect(gateFor("startAR") == null); + try testing.expectEqual(Feature.ar, gateFor("startAR").?); + try testing.expectEqual(Feature.ar, gateFor("getARPlanes").?); try testing.expect(gateFor("") == null); } diff --git a/packages/zig/src/ios_dispatch.zig b/packages/zig/src/ios_dispatch.zig index 0693d5ab..82f71f7f 100644 --- a/packages/zig/src/ios_dispatch.zig +++ b/packages/zig/src/ios_dispatch.zig @@ -27,6 +27,7 @@ const bridge_mobile_bgtasks = @import("bridge_mobile_bgtasks.zig"); const bridge_mobile_watch = @import("bridge_mobile_watch.zig"); const bridge_mobile_location = @import("bridge_mobile_location.zig"); const bridge_mobile_locrecording = @import("bridge_mobile_locrecording.zig"); +const bridge_mobile_ar = @import("bridge_mobile_ar.zig"); const bridge_mobile_motion = @import("bridge_mobile_motion.zig"); const bridge_mobile_imagepicker = @import("bridge_mobile_imagepicker.zig"); const bridge_mobile_filepicker = @import("bridge_mobile_filepicker.zig"); @@ -306,6 +307,7 @@ const mobile_bridges = .{ bridge_mobile_watch.WatchBridge, bridge_mobile_location.LocationBridge, bridge_mobile_locrecording.LocationRecordingBridge, + bridge_mobile_ar.ARBridge, bridge_mobile_motion.MotionBridge, bridge_mobile_imagepicker.ImagePickerBridge, bridge_mobile_filepicker.FilePickerBridge, @@ -351,6 +353,7 @@ const mobile_manifests = [_][]const capabilities.ActionDecl{ &bridge_mobile_watch.capability_actions, &bridge_mobile_location.capability_actions, &bridge_mobile_locrecording.capability_actions, + &bridge_mobile_ar.capability_actions, &bridge_mobile_motion.capability_actions, &bridge_mobile_imagepicker.capability_actions, &bridge_mobile_filepicker.capability_actions, diff --git a/packages/zig/test/ios_conformance_test.zig b/packages/zig/test/ios_conformance_test.zig index 9d7e8051..359ae0a0 100644 --- a/packages/zig/test/ios_conformance_test.zig +++ b/packages/zig/test/ios_conformance_test.zig @@ -43,6 +43,7 @@ const zig_sources = [_][]const u8{ @embedFile("src/bridge_mobile_watch.zig"), @embedFile("src/bridge_mobile_location.zig"), @embedFile("src/bridge_mobile_locrecording.zig"), + @embedFile("src/bridge_mobile_ar.zig"), @embedFile("src/bridge_mobile_motion.zig"), @embedFile("src/bridge_mobile_imagepicker.zig"), @embedFile("src/bridge_mobile_filepicker.zig"), @@ -121,7 +122,7 @@ const dispatch_end = "func webView("; /// and `deliverErrorCode` landed the day after that was written. Worth /// re-reading the other deferrals for the same reason before assuming they /// still hold. -const max_not_yet_migrated: usize = 18; +const max_not_yet_migrated: usize = 13; fn dispatcherRegion() []const u8 { const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; @@ -270,11 +271,6 @@ const deliberate_deferrals = [_]Deferral{ // 51 namespaces, no `mobile`, and neither `ios.zig` nor `ios_dispatch.zig` // reads it — so the boundary is recorded here instead, where the tooling // that counts unmigrated actions can see it. - .{ .action = "startAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, - .{ .action = "stopAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, - .{ .action = "getARPlanes", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, - .{ .action = "placeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, - .{ .action = "removeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, // StoreKit 2. Narrower than "no ObjC surface": StoreKit *1* is ObjC and // `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" { } // Non-vacuity: the loop above is satisfied by an empty table. - try testing.expect(deliberate_deferrals.len >= 18); + try testing.expect(deliberate_deferrals.len >= 13); // As of this commit the table happens to cover every unmigrated action, // but that is not asserted. Pinning the exact count would make migrating a From c33a9d35941289ba6c4f37837aabb0d6a5915e89 Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Fri, 4 Sep 2026 23:58:16 +0800 Subject: [PATCH 2/2] fix(ios): the AR view was retained three times and released twice alloc/initWithFrame: already gives this frame the reference ar_view owns and stopAR releases, and addSubview: gives the window a second one that removeFromSuperview drops. The extra retain outlived both, leaking the view and its whole SceneKit graph on every start/stop cycle. --- packages/zig/src/bridge_mobile_ar.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/zig/src/bridge_mobile_ar.zig b/packages/zig/src/bridge_mobile_ar.zig index f9666612..909b9663 100644 --- a/packages/zig/src/bridge_mobile_ar.zig +++ b/packages/zig/src/bridge_mobile_ar.zig @@ -858,9 +858,13 @@ fn addViewToKeyWindow(view: Id) void { return; }; + // No retain here. `alloc`/`initWithFrame:` already gave this frame the one + // reference `ar_view` owns and `stopAR` releases; `addSubview:` gives the + // window a second one that `removeFromSuperview` drops. A third would + // outlive both, and it would take the whole SceneKit graph with it on every + // start/stop cycle. const sel_add = objc.sel_registerName("addSubview:") orelse return; objc.msgSendVoid1(window, sel_add, view); - retain(view); } /// The seven numbers `getARPlanes` reads off one anchor.