ReviewOS

also looking at this

craft-native/craft

feat(ios): signInWithApple, and scanQRCode's real reason — ratchet 25 → 23

#122
Merged glennmichael123 wants to merge feat/ios-sign-in-with-apple into main
6 files +591 -5
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -407,6 +407,9 @@ pub fn build(b: *std.Build) void {
407407 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_nfc.zig", .{
408408 .root_source_file = b.path("src/bridge_mobile_nfc.zig"),
409409 });
410 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_auth_apple.zig", .{
411 .root_source_file = b.path("src/bridge_mobile_auth_apple.zig"),
412 });
410413 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_auth.zig", .{
411414 .root_source_file = b.path("src/bridge_mobile_auth.zig"),
412415 });
packages/zig/src/bridge_mobile_auth_apple.zigadded+556-0
Changes to packages/zig/src/bridge_mobile_auth_apple.zig
@@ -0,0 +1,556 @@
1//! The `mobile` namespace's one Sign in with Apple action: `signInWithApple`.
2//!
3//! Separate from `bridge_mobile_auth.zig`, which owns `authenticate` — that is
4//! LocalAuthentication and a biometric prompt, this is AuthenticationServices
5//! and an account. They share a word and nothing else.
6//!
7//! ## Why this one is reachable and `scanQRCode` is not
8//!
9//! Both were unmigrated with no recorded reason. AuthenticationServices has
10//! Objective-C headers — `ASAuthorizationController.h:38` declares
11//! `presentationAnchorForAuthorizationController:` — so every class and
12//! selector here is reachable from the runtime. VisionKit's `DataScanner` is
13//! not: its ObjC headers carry DocumentCamera and nothing else, and its
14//! delegate takes a Swift enum with associated values. That one is recorded in
15//! the deferral table instead.
16//!
17//! ## The delegate returns an object, which is new here
18//!
19//! Every other delegate in this migration is `void`. This one must answer
20//! `presentationAnchorForAuthorizationController:` with the `UIWindow` to
21//! present over, so `ios_delegate.enc.object_one_object` (`"@@:@"`) was added
22//! for it. The leading character of an encoding is the return type; declaring
23//! it `v@:@` would have the runtime read a window pointer out of a function
24//! that returns nothing.
25//!
26//! ## The reply
27//!
28//! `{"userId":…}` always, plus `email`, `name` and `identityToken` only when
29//! present — the spec builds the dictionary conditionally and this reproduces
30//! that, because a page distinguishing "Apple withheld the email" from
31//! `"email": ""` is reading a real difference. `name` is `givenName` and
32//! `familyName` joined by a space, omitting whichever is absent, and omitted
33//! entirely when both are.
34//!
35//! Apple sends `email` and `fullName` **only on the first authorization** for
36//! an app. Every later sign-in omits them, which is the API's design and not a
37//! failure — another reason the fields are omitted rather than emptied.
38
39const std = ;
40const builtin = ;
41const capabilities = ;
42const bridge_error = ;
43const objc_runtime = ;
44const ios_async = ;
45const ios_delegate = ;
46const compat_mutex = ;
47
48const objc = objc_runtime.objc;
49const is_darwin = builtin.target.os.tag.isDarwin();
50const BridgeError = bridge_error.BridgeError;
51
52/// `objc.id` spelled locally — see the notifcancel/securestore precedent.
53const Id = ?*anyopaque;
54
55pub const A = struct {
56 pub const sign_in_with_apple = "signInWithApple";
57};
58
59/// `.result`: the spec resolves an object and the page's promise is the
60/// untimed legacy kind, so `.none` would strand the caller.
61pub const capability_actions = [_]capabilities.ActionDecl{
62 .{ .name = A.sign_in_with_apple, .reply = .result },
63};
64
65const Route = enum { sign_in };
66
67fn routeFor(action: []const u8) ?Route {
68 if (std.mem.eql(u8, action, A.sign_in_with_apple)) return .sign_in;
69 return null;
70}
71
72/// `ASAuthorizationScopeFullName` and `ASAuthorizationScopeEmail`.
73///
74/// These are `NSString *` constants, not an option set, so they are read with
75/// `dlsym` rather than spelled — the route `bridge_mobile_imagepicker.zig`
76/// documents for framework string constants. A wrong literal here would be a
77/// scope the request silently does not ask for.
78const scope_full_name = "ASAuthorizationScopeFullName";
79const scope_email = "ASAuthorizationScopeEmail";
80
81pub const AppleAuthBridge = struct {
82 allocator: std.mem.Allocator,
83
84 const Self = @This();
85
86 pub fn init(allocator: std.mem.Allocator) Self {
87 return .{ .allocator = allocator };
88 }
89
90 pub fn deinit(_: *Self) void {}
91
92 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
93 _ = data;
94 const route = routeFor(action) orelse return BridgeError.UnknownAction;
95 switch (route) {
96 .sign_in => try self.signInWithApple(),
97 }
98 }
99
100 /// Build the request and perform it.
101 ///
102 /// Every fallible step runs before `ios_async.acquire`, so no error path
103 /// sits between leasing a slot and handing the controller its delegate.
104 fn signInWithApple(self: *Self) !void {
105 _ = self;
106 if (!is_darwin) return error.UnsupportedPlatform;
107
108 const ProviderClass = objc.objc_getClass("ASAuthorizationAppleIDProvider") orelse
109 return BridgeError.PlatformNotSupported;
110 const ControllerClass = objc.objc_getClass("ASAuthorizationController") orelse
111 return BridgeError.PlatformNotSupported;
112
113 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
114 const sel_init = objc.sel_registerName("init") orelse return error.SelectorNotFound;
115 const sel_create = objc.sel_registerName("createRequest") orelse return error.SelectorNotFound;
116 const sel_scopes = objc.sel_registerName("setRequestedScopes:") orelse return error.SelectorNotFound;
117 const sel_init_reqs = objc.sel_registerName("initWithAuthorizationRequests:") orelse
118 return error.SelectorNotFound;
119 const sel_set_delegate = objc.sel_registerName("setDelegate:") orelse return error.SelectorNotFound;
120 const sel_set_ctx = objc.sel_registerName("setPresentationContextProvider:") orelse
121 return error.SelectorNotFound;
122 const sel_perform = objc.sel_registerName("performRequests") orelse return error.SelectorNotFound;
123
124 const handler = try delegateInstance();
125
126 const provider = objc.msgSendId(objc.msgSendId(ProviderClass, sel_alloc), sel_init);
127 if (provider == null) return BridgeError.NativeCallFailed;
128 const request = objc.msgSendId(provider, sel_create);
129 if (request == null) return BridgeError.NativeCallFailed;
130
131 if (try requestedScopes()) |scopes| objc.msgSendVoid1(request, sel_scopes, scopes);
132
133 const requests = try arrayOf(request);
134 const controller = objc.msgSendId1(objc.msgSendId(ControllerClass, sel_alloc), sel_init_reqs, requests);
135 if (controller == null) return BridgeError.NativeCallFailed;
136
137 const ticket = ios_async.acquire(A.sign_in_with_apple) orelse return poolFull();
138 errdefer ios_async.abandon(ticket);
139
140 objc.msgSendVoid1(controller, sel_set_delegate, handler);
141 objc.msgSendVoid1(controller, sel_set_ctx, handler);
142
143 // Published before `performRequests`: the delegate can fire before it
144 // returns, and a callback at an empty slot has no ticket to answer.
145 publishPending(ticket);
146 objc.msgSend(controller, sel_perform);
147 }
148};
149
150fn poolFull() BridgeError {
151 std.log.warn("signInWithApple refused: all {d} async slots in flight", .{ios_async.max_in_flight});
152 return BridgeError.InvalidParameter;
153}
154
155/// `@[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]`, or null when
156/// neither constant is in the process — in which case the request simply asks
157/// for no scopes, which is what an unscoped `createRequest` already does.
158fn requestedScopes() !?Id {
159 const full = dlsymString(scope_full_name);
160 const email = dlsymString(scope_email);
161 if (full == null and email == null) return null;
162
163 const NSArray = objc.objc_getClass("NSArray") orelse return null;
164 const sel = objc.sel_registerName("arrayWithObjects:count:") orelse return null;
165 var items: [2]Id = undefined;
166 var n: usize = 0;
167 if (full) |f| {
168 items[n] = f;
169 n += 1;
170 }
171 if (email) |e| {
172 items[n] = e;
173 n += 1;
174 }
175 const Fn = *const fn (Id, objc.SEL, [*]const Id, usize) callconv(.c) Id;
176 const func: Fn = @ptrCast(&objc.objc_msgSend);
177 return func(NSArray, sel, &items, n);
178}
179
180fn arrayOf(object: Id) !Id {
181 const NSArray = objc.objc_getClass("NSArray") orelse return BridgeError.NativeCallFailed;
182 const sel = objc.sel_registerName("arrayWithObject:") orelse return error.SelectorNotFound;
183 const result = objc.msgSendId1(NSArray, sel, object);
184 if (result == null) return BridgeError.NativeCallFailed;
185 return result;
186}
187
188extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
189const rtld_default: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
190
191/// Read an `NSString *` framework constant. `dlsym` gives the address of the
192/// variable, so the pointer has to be dereferenced to get the object.
193fn dlsymString(name: [:0]const u8) ?Id {
194 if (!is_darwin) return null;
195 const addr = dlsym(rtld_default, name.ptr) orelse return null;
196 const slot: *const Id = @ptrCast(@alignCast(addr));
197 return slot.*;
198}
199// =============================================================================
200// The pending sign-in, and the delegate that answers it.
201// =============================================================================
202
203var pending: ?ios_async.Ticket = null;
204var pending_mutex: compat_mutex.Mutex = .{};
205
206fn publishPending(ticket: ios_async.Ticket) void {
207 pending_mutex.lock();
208 defer pending_mutex.unlock();
209 pending = ticket;
210}
211
212/// Read and clear, so a second callback answers nobody rather than twice.
213fn takePending() ?ios_async.Ticket {
214 pending_mutex.lock();
215 defer pending_mutex.unlock();
216 const t = pending;
217 pending = null;
218 return t;
219}
220
221var delegate: ?Id = null;
222const delegate_class_name = "CraftAppleAuthDelegate";
223
224/// One instance, retained for the life of the process.
225///
226/// `ASAuthorizationController` holds both its `delegate` and its
227/// `presentationContextProvider` weakly, so an instance built per call would
228/// be gone before the sheet came up.
229fn delegateInstance() !Id {
230 if (delegate) |existing| return existing;
231
232 const class = ios_delegate.defineClass(delegate_class_name, "NSObject", &.{
233 .{
234 .selector = "authorizationController:didCompleteWithAuthorization:",
235 .imp = @ptrCast(&craftAppleDidComplete),
236 .types = ios_delegate.enc.void_two_objects,
237 },
238 .{
239 .selector = "authorizationController:didCompleteWithError:",
240 .imp = @ptrCast(&craftAppleDidFail),
241 .types = ios_delegate.enc.void_two_objects,
242 },
243 .{
244 .selector = "presentationAnchorForAuthorizationController:",
245 .imp = @ptrCast(&craftApplePresentationAnchor),
246 .types = ios_delegate.enc.object_one_object,
247 },
248 }) catch |err| {
249 std.log.warn("signInWithApple: could not build the delegate class: {}", .{err});
250 return BridgeError.NativeCallFailed;
251 };
252
253 delegate = ios_delegate.instantiate(class) catch |err| {
254 std.log.warn("signInWithApple: could not instantiate the delegate: {}", .{err});
255 return BridgeError.NativeCallFailed;
256 };
257 return delegate.?;
258}
259
260fn craftAppleDidComplete(_: Id, _: objc.SEL, _: Id, authorization: Id) callconv(.c) void {
261 if (!is_darwin) return;
262 const ticket = takePending() orelse {
263 std.log.warn("signInWithApple: an authorization arrived with no call recorded; ignored", .{});
264 return;
265 };
266
267 const allocator = std.heap.c_allocator;
268 const json = shapeCredential(allocator, authorization) catch |err| {
269 std.log.err("signInWithApple could not shape its reply ({}); rejecting", .{err});
270 ios_async.deliverError(ticket);
271 return;
272 };
273 defer allocator.free(json);
274 ios_async.deliverJson(ticket, json);
275}
276
277fn craftAppleDidFail(_: Id, _: objc.SEL, _: Id, err: Id) callconv(.c) void {
278 if (!is_darwin) return;
279 const ticket = takePending() orelse return;
280
281 // 1001 is `ASAuthorizationError.canceled`. The spec rejects every failure
282 // with the same localized string, so a page cannot tell a cancel from a
283 // real error; a typed code can, and cancel is not a failure to report.
284 const code = errorCode(err);
285 if (code == as_authorization_error_canceled) {
286 ios_async.deliverErrorCode(ticket, BridgeError.Cancelled);
287 return;
288 }
289 std.log.warn("signInWithApple: the authorization failed with code {d}", .{code});
290 ios_async.deliverErrorCode(ticket, BridgeError.NativeCallFailed);
291}
292
293/// `ASAuthorizationError.canceled`. `ASAuthorizationError.h` — the user
294/// dismissed the sheet.
295const as_authorization_error_canceled: i64 = 1001;
296
297/// The window the sheet is presented over.
298///
299/// The spec walks `connectedScenes.first` → `windows.first` and returns a bare
300/// `UIWindow()` when that misses. A fresh detached window is not presentable,
301/// so that fallback shows nothing; returning nil is the same outcome and does
302/// not allocate a window to throw away.
303fn craftApplePresentationAnchor(_: Id, _: objc.SEL, _: Id) callconv(.c) Id {
304 if (!is_darwin) return null;
305 return keyWindow();
306}
307
308fn keyWindow() Id {
309 const UIApplication = objc.objc_getClass("UIApplication") orelse return null;
310 const sel_shared = objc.sel_registerName("sharedApplication") orelse return null;
311 const app = objc.msgSendId(UIApplication, sel_shared);
312 if (app == null) return null;
313
314 const sel_scenes = objc.sel_registerName("connectedScenes") orelse return null;
315 const scenes = objc.msgSendId(app, sel_scenes);
316 if (scenes == null) return null;
317
318 const sel_all = objc.sel_registerName("allObjects") orelse return null;
319 const list = objc.msgSendId(scenes, sel_all);
320 if (list == null) return null;
321
322 const sel_count = objc.sel_registerName("count") orelse return null;
323 const CountFn = *const fn (Id, objc.SEL) callconv(.c) usize;
324 const count_fn: CountFn = @ptrCast(&objc.objc_msgSend);
325 if (count_fn(list, sel_count) == 0) return null;
326
327 const sel_at = objc.sel_registerName("objectAtIndex:") orelse return null;
328 const AtFn = *const fn (Id, objc.SEL, usize) callconv(.c) Id;
329 const at_fn: AtFn = @ptrCast(&objc.objc_msgSend);
330 const scene = at_fn(list, sel_at, 0);
331
332 const sel_windows = objc.sel_registerName("windows") orelse return null;
333 const windows = objc.msgSendId(scene, sel_windows);
334 if (windows == null) return null;
335 if (count_fn(windows, sel_count) == 0) return null;
336 return at_fn(windows, sel_at, 0);
337}
338
339fn errorCode(err: Id) i64 {
340 const e = err orelse return 0;
341 const sel = objc.sel_registerName("code") orelse return 0;
342 const Fn = *const fn (Id, objc.SEL) callconv(.c) isize;
343 const func: Fn = @ptrCast(&objc.objc_msgSend);
344 return @intCast(func(e, sel));
345}
346
347// =============================================================================
348// Shaping the reply. Split out so the half that decides what the page sees is
349// testable on a host with no AuthenticationServices in the process.
350// =============================================================================
351
352/// The credential as the page receives it. Absent fields are absent, not empty.
353const Credential = struct {
354 user_id: []const u8,
355 email: ?[]const u8 = null,
356 given_name: ?[]const u8 = null,
357 family_name: ?[]const u8 = null,
358 identity_token: ?[]const u8 = null,
359};
360
361fn appendJsonString(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), s: []const u8) !void {
362 try out.append(allocator, '"');
363 try bridge_error.appendJsonEscaped(allocator, out, s);
364 try out.append(allocator, '"');
365}
366
367/// `{"userId":…}` plus whatever else Apple sent.
368///
369/// `name` is the spec's join: given, then family, separated by a space, with
370/// whichever is absent skipped, and the key omitted when the result is empty.
371fn appendCredential(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), c: Credential) !void {
372 try out.appendSlice(allocator, "{\"userId\":");
373 try appendJsonString(allocator, out, c.user_id);
374
375 if (c.email) |e| {
376 try out.appendSlice(allocator, ",\"email\":");
377 try appendJsonString(allocator, out, e);
378 }
379
380 const given = c.given_name orelse "";
381 const family = c.family_name orelse "";
382 if (given.len != 0 or family.len != 0) {
383 try out.appendSlice(allocator, ",\"name\":\"");
384 try bridge_error.appendJsonEscaped(allocator, out, given);
385 if (given.len != 0 and family.len != 0) try out.append(allocator, ' ');
386 try bridge_error.appendJsonEscaped(allocator, out, family);
387 try out.append(allocator, '"');
388 }
389
390 if (c.identity_token) |t| {
391 try out.appendSlice(allocator, ",\"identityToken\":");
392 try appendJsonString(allocator, out, t);
393 }
394 try out.append(allocator, '}');
395}
396
397fn shapeCredential(allocator: std.mem.Allocator, authorization: Id) ![]u8 {
398 var out: std.ArrayListUnmanaged(u8) = .empty;
399 errdefer out.deinit(allocator);
400
401 if (!is_darwin or authorization == null) return BridgeError.NativeCallFailed;
402
403 const sel_credential = objc.sel_registerName("credential") orelse return error.SelectorNotFound;
404 const credential = objc.msgSendId(authorization, sel_credential);
405 if (credential == null) return BridgeError.NativeCallFailed;
406
407 // `as? ASAuthorizationAppleIDCredential` in the spec: a password
408 // credential reaches the same delegate and has no `user`.
409 const AppleIDCredential = objc.objc_getClass("ASAuthorizationAppleIDCredential") orelse
410 return BridgeError.NativeCallFailed;
411 const sel_is_kind = objc.sel_registerName("isKindOfClass:") orelse return error.SelectorNotFound;
412 const KindFn = *const fn (Id, objc.SEL, Id) callconv(.c) bool;
413 const kind_fn: KindFn = @ptrCast(&objc.objc_msgSend);
414 if (!kind_fn(credential, sel_is_kind, AppleIDCredential)) return BridgeError.NativeCallFailed;
415
416 const user_id = nsStringValue(credential, "user") orelse return BridgeError.NativeCallFailed;
417 const email = nsStringValue(credential, "email");
418
419 const sel_full_name = objc.sel_registerName("fullName") orelse return error.SelectorNotFound;
420 const full_name = objc.msgSendId(credential, sel_full_name);
421 const given = if (full_name != null) nsStringValue(full_name, "givenName") else null;
422 const family = if (full_name != null) nsStringValue(full_name, "familyName") else null;
423
424 const sel_token = objc.sel_registerName("identityToken") orelse return error.SelectorNotFound;
425 const token_data = objc.msgSendId(credential, sel_token);
426 const token = dataUtf8(token_data);
427
428 try appendCredential(allocator, &out, .{
429 .user_id = user_id,
430 .email = email,
431 .given_name = given,
432 .family_name = family,
433 .identity_token = token,
434 });
435 return out.toOwnedSlice(allocator);
436}
437
438fn nsStringValue(target: Id, comptime selector: [:0]const u8) ?[]const u8 {
439 if (!is_darwin) return null;
440 const t = target orelse return null;
441 const sel = objc.sel_registerName(selector) orelse return null;
442 const ns = objc.msgSendId(t, sel);
443 if (ns == null) return null;
444 const utf8 = objc.getNSStringUTF8(ns) orelse return null;
445 return std.mem.span(utf8);
446}
447
448/// The identity token is an `NSData` of JWT bytes; the spec decodes it as
449/// UTF-8 and omits the key when that fails.
450fn dataUtf8(data: Id) ?[]const u8 {
451 if (!is_darwin) return null;
452 const d = data orelse return null;
453 const sel_bytes = objc.sel_registerName("bytes") orelse return null;
454 const sel_length = objc.sel_registerName("length") orelse return null;
455 const BytesFn = *const fn (Id, objc.SEL) callconv(.c) ?[*]const u8;
456 const bytes_fn: BytesFn = @ptrCast(&objc.objc_msgSend);
457 const LenFn = *const fn (Id, objc.SEL) callconv(.c) usize;
458 const len_fn: LenFn = @ptrCast(&objc.objc_msgSend);
459
460 const len = len_fn(d, sel_length);
461 if (len == 0) return null;
462 const ptr = bytes_fn(d, sel_bytes) orelse return null;
463 const slice = ptr[0..len];
464 return if (std.unicode.utf8ValidateSlice(slice)) slice else null;
465}
466
467// =============================================================================
468// Tests — host-only. AuthenticationServices is not in this process, so what is
469// pinned is everything that decides what the page sees.
470// =============================================================================
471
472const testing = std.testing;
473
474test "the declared action is the one the handler serves" {
475 try testing.expectEqual(@as(usize, 1), capability_actions.len);
476 try testing.expectEqualStrings(A.sign_in_with_apple, capability_actions[0].name);
477 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
478 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status);
479 try testing.expect(routeFor(A.sign_in_with_apple) == .sign_in);
480 try testing.expect(routeFor("authenticate") == null);
481}
482
483test "an action this namespace does not serve is reported, not ignored" {
484 var bridge = AppleAuthBridge.init(testing.allocator);
485 defer bridge.deinit();
486 // `authenticate` belongs to bridge_mobile_auth.zig; claiming it here would
487 // make dispatch order decide which implementation a page gets.
488 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("authenticate", "{}"));
489}
490
491test "a first authorization carries everything; a later one carries only the id" {
492 const allocator = testing.allocator;
493
494 var first: std.ArrayListUnmanaged(u8) = .empty;
495 defer first.deinit(allocator);
496 try appendCredential(allocator, &first, .{
497 .user_id = "001234.abc",
498 .email = "a@b.c",
499 .given_name = "Ada",
500 .family_name = "Lovelace",
501 .identity_token = "ey.J.token",
502 });
503 try testing.expectEqualStrings(
504 "{\"userId\":\"001234.abc\",\"email\":\"a@b.c\",\"name\":\"Ada Lovelace\",\"identityToken\":\"ey.J.token\"}",
505 first.items,
506 );
507
508 // Apple sends email and fullName only on the *first* authorization for an
509 // app; every later one omits them. Absent keys, not empty strings — a page
510 // telling "Apple withheld it" from `""` is reading a real difference.
511 var later: std.ArrayListUnmanaged(u8) = .empty;
512 defer later.deinit(allocator);
513 try appendCredential(allocator, &later, .{ .user_id = "001234.abc" });
514 try testing.expectEqualStrings("{\"userId\":\"001234.abc\"}", later.items);
515}
516
517test "a half-present name joins without a stray space" {
518 const allocator = testing.allocator;
519 for ([_]struct { g: ?[]const u8, f: ?[]const u8, want: []const u8 }{
520 .{ .g = "Ada", .f = null, .want = "{\"userId\":\"u\",\"name\":\"Ada\"}" },
521 .{ .g = null, .f = "Lovelace", .want = "{\"userId\":\"u\",\"name\":\"Lovelace\"}" },
522 .{ .g = null, .f = null, .want = "{\"userId\":\"u\"}" },
523 }) |c| {
524 var out: std.ArrayListUnmanaged(u8) = .empty;
525 defer out.deinit(allocator);
526 try appendCredential(allocator, &out, .{ .user_id = "u", .given_name = c.g, .family_name = c.f });
527 try testing.expectEqualStrings(c.want, out.items);
528 }
529}
530
531test "a name carrying a quote stays valid JSON" {
532 const allocator = testing.allocator;
533 var out: std.ArrayListUnmanaged(u8) = .empty;
534 defer out.deinit(allocator);
535 try appendCredential(allocator, &out, .{ .user_id = "u", .given_name = "A\"B", .family_name = "C\\D" });
536
537 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, out.items, .{});
538 defer parsed.deinit();
539 try testing.expectEqualStrings("A\"B C\\D", parsed.value.object.get("name").?.string);
540}
541
542test "the cancel code is the framework's" {
543 // `ASAuthorizationError.canceled` = 1001. The spec rejects every failure
544 // with the same localized string, so a page cannot tell a dismissal from a
545 // real error; this answers CANCELLED for that one.
546 try testing.expectEqual(@as(i64, 1001), as_authorization_error_canceled);
547}
548
549test "the presentation anchor is object-returning, not void" {
550 // The encoding's first character is the return type. Declaring this
551 // delegate method `v@:@` would have the runtime read a window pointer out
552 // of a function that returns nothing.
553 try testing.expectEqualStrings("@@:@", ios_delegate.enc.object_one_object);
554 try testing.expect(ios_delegate.enc.object_one_object[0] == '@');
555 try testing.expect(ios_delegate.enc.void_one_object[0] == 'v');
556}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -419,6 +419,7 @@ pub fn gateFor(action: []const u8) ?Feature {
419419 .{ "getHealthData", .health_kit },
420420 .{ "getPendingNotifications", .local_notifications },
421421 .{ "scanNFC", .nfc },
422 .{ "signInWithApple", .social_auth },
422423 .{ "scheduleNotification", .local_notifications },
423424 .{ "haptic", .haptics },
424425 .{ "lockOrientation", .orientation_lock },
packages/zig/src/ios_delegate.zigmodified+10-0
Changes to packages/zig/src/ios_delegate.zig
@@ -62,6 +62,16 @@ pub const enc = struct {
6262 /// `B` is C99 `_Bool`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is
6363 /// defined, so `BOOL` is `bool` rather than `signed char`.
6464 pub const bool_two_objects: [:0]const u8 = "B@:@@";
65 /// `- (id)m:(id)a` — one object argument, **returning an object**.
66 /// `presentationAnchorForAuthorizationController:` is this shape: it hands
67 /// back the `UIWindow` the sheet is presented over.
68 ///
69 /// The first character is the return type, so this differs from
70 /// `void_one_object` only in that leading `@`. Getting it wrong is not a
71 /// compile error — the runtime would read a window pointer out of a
72 /// function declared to return nothing, and the sheet would come up over
73 /// garbage or not at all.
74 pub const object_one_object: [:0]const u8 = "@@:@";
6575};
6676
6777/// One method to add to a class.
packages/zig/src/ios_dispatch.zigmodified+3-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -42,6 +42,7 @@ const bridge_mobile_audiorec = ;
4242const bridge_mobile_health = ;
4343const bridge_mobile_speech = ;
4444const bridge_mobile_nfc = ;
45const bridge_mobile_auth_apple = ;
4546
4647const objc = objc_runtime.objc;
4748
@@ -320,6 +321,7 @@ const mobile_bridges = .{
320321 bridge_mobile_health.HealthBridge,
321322 bridge_mobile_speech.SpeechBridge,
322323 bridge_mobile_nfc.NfcBridge,
324 bridge_mobile_auth_apple.AppleAuthBridge,
323325};
324326
325327/// The same modules again, as their capability manifests.
@@ -364,6 +366,7 @@ const mobile_manifests = [_][]const capabilities.ActionDecl{
364366 &bridge_mobile_health.capability_actions,
365367 &bridge_mobile_speech.capability_actions,
366368 &bridge_mobile_nfc.capability_actions,
369 &bridge_mobile_auth_apple.capability_actions,
367370};
368371
369372comptime {
packages/zig/test/ios_conformance_test.zigmodified+18-5
Changes to packages/zig/test/ios_conformance_test.zig
@@ -58,6 +58,7 @@ const zig_sources = [_][]const u8{
5858 @embedFile("src/bridge_mobile_health.zig"),
5959 @embedFile("src/bridge_mobile_speech.zig"),
6060 @embedFile("src/bridge_mobile_nfc.zig"),
61 @embedFile("src/bridge_mobile_auth_apple.zig"),
6162};
6263
6364/// The action list lives in the `switch action` block, and nowhere else.
@@ -106,9 +107,13 @@ const dispatch_end = "func webView(";
106107/// audio tap, which is the first callback in this migration that runs on a
107108/// thread where a lock is a bug rather than a slowdown.
108109/// 24 with scanNFC, which had no recorded reason at all — not a deferral that
109/// expired, just an action nobody had reached. One framework, one guard, one
110/// small delegate; the four remaining like it are registerPush, scanQRCode and
111/// signInWithApple.
110/// expired, just an action nobody had reached.
111/// 23 with signInWithApple, which needed the first object-returning delegate
112/// method in the migration (`presentationAnchorForAuthorizationController:`).
113/// Of the four that had no recorded reason, scanQRCode turned out to have a
114/// real one and is in the table below; `registerPush` is the last, and its
115/// device token arrives through a notification the Swift app delegate posts,
116/// which is a coupling to weigh rather than a wall.
112117/// 25 with scheduleNotification. Its deferral was the first to expire on its
113118/// own: the blocker recorded in `bridge_mobile_notifications.zig` was that
114119/// `ios_async` could only resolve, so an action whose two failure paths are
@@ -116,7 +121,7 @@ const dispatch_end = "func webView(";
116121/// and `deliverErrorCode` landed the day after that was written. Worth
117122/// re-reading the other deferrals for the same reason before assuming they
118123/// still hold.
119const max_not_yet_migrated: usize = 24;
124const max_not_yet_migrated: usize = 23;
120125
121126fn dispatcherRegion() []const u8 {
122127 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
@@ -298,6 +303,14 @@ const deliberate_deferrals = [_]Deferral{
298303 .{ .action = "resumeLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
299304 .{ .action = "getLocationRecordingState", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
300305
306 // VisionKit's `DataScannerViewController` is Swift-only: the framework's
307 // Objective-C headers carry DocumentCamera and nothing else, and the
308 // delegate callback takes a `RecognizedItem`, a Swift enum with associated
309 // values that `objc_msgSend` cannot destructure. Same class of wall as
310 // ActivityKit, and checked against the iphonesimulator SDK rather than
311 // assumed.
312 .{ .action = "scanQRCode", .reason = "VisionKit's DataScanner is Swift-only; the delegate takes a Swift enum with associated values" },
313
301314 // Not a capability gap: the spec's own success path has never executed, so
302315 // there is no working contract to port. See the tracking issue.
303316 .{ .action = "startVideoRecording", .reason = "the spec reads info[.originalImage] for a movie pick, so its success path never runs" },
@@ -332,7 +345,7 @@ test "every recorded deferral is real, and still a deferral" {
332345 }
333346
334347 // Non-vacuity: the loop above is satisfied by an empty table.
335 try testing.expect(deliberate_deferrals.len >= 20);
348 try testing.expect(deliberate_deferrals.len >= 22);
336349
337350 // And the table cannot claim more than remain unmigrated.
338351 try testing.expect(deliberate_deferrals.len <= max_not_yet_migrated);