ReviewOS

also looking at this

craft-native/craft

feat(ios): scanNFC — the first migration with no blocker to expire, ratchet 25 → 24

#121
Merged glennmichael123 wants to merge feat/ios-scan-nfc into main
5 files +539 -1
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -404,6 +404,9 @@ pub fn build(b: *std.Build) void {
404404 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{
405405 .root_source_file = b.path("src/bridge_mobile_vision.zig"),
406406 });
407 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_nfc.zig", .{
408 .root_source_file = b.path("src/bridge_mobile_nfc.zig"),
409 });
407410 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_auth.zig", .{
408411 .root_source_file = b.path("src/bridge_mobile_auth.zig"),
409412 });
packages/zig/src/bridge_mobile_nfc.zigadded+526-0
Changes to packages/zig/src/bridge_mobile_nfc.zig
@@ -0,0 +1,526 @@
1//! The `mobile` namespace's one NFC action: `scanNFC`.
2//!
3//! ## Shape
4//!
5//! `NFCNDEFReaderSession.readingAvailable` is asked first, exactly as the spec
6//! does — it is false on every simulator and on any device without the
7//! hardware, and starting a session anyway raises rather than failing.
8//!
9//! Before that, the class itself is looked up, and a null answer is a real and
10//! expected condition rather than a failure. A generated app has CoreNFC in
11//! the process because `CraftApp.swift:17` imports it and Swift autolinks what
12//! it imports; the `zig-slice` fixture links twelve frameworks by hand and
13//! CoreNFC is not among them, so there the lookup misses and this answers
14//! `PLATFORM_NOT_SUPPORTED`. Both are honest answers to "can this device
15//! scan a tag", which is the question the page asked.
16//!
17//! The session is `invalidateAfterFirstRead:YES`, so one scan answers one call
18//! and the session is done. That is what lets a single pending slot serve this
19//! action without a queue.
20//!
21//! ## The reply
22//!
23//! An array of NDEF records, flattened across messages, which is what the spec
24//! builds:
25//!
26//! ```json
27//! [{"typeNameFormat":1,"type":"T","identifier":"","payload":"hello"}]
28//! ```
29//!
30//! `type`, `identifier` and `payload` are `NSData`. The spec decodes each as
31//! UTF-8 and substitutes `""` on failure for the first two; `payload` falls
32//! back to base64 instead of an empty string, because a payload is the point
33//! of the record and losing it silently would be worse than an encoding a
34//! caller has to notice. That asymmetry is the spec's and is reproduced.
35//!
36//! ## One divergence, and it is a hang the spec has
37//!
38//! `readerSession:didInvalidateWithError:` in the spec rejects only when the
39//! error code is not 200, and 200 is "user cancelled". So a cancelled scan
40//! clears `pendingCallbackId` and settles nothing: the page's promise waits
41//! forever. This answers `CANCELLED`, which is what every other presented
42//! surface in this migration answers for the same gesture — the image picker,
43//! the document picker and the contact picker all do.
44
45const std = ;
46const builtin = ;
47const capabilities = ;
48const bridge_error = ;
49const objc_runtime = ;
50const ios_async = ;
51const ios_delegate = ;
52const compat_mutex = ;
53const memory = ;
54
55const objc = objc_runtime.objc;
56const is_darwin = builtin.target.os.tag.isDarwin();
57const BridgeError = bridge_error.BridgeError;
58
59/// `objc.id` spelled locally: `objc_runtime.objc` is an empty struct off
60/// Darwin and a signature is analysed even when a comptime guard prunes the
61/// body, so naming `objc.id` here would break the Linux build. The
62/// notifcancel/securestore precedent.
63const Id = ?*anyopaque;
64
65pub const A = struct {
66 pub const scan_nfc = "scanNFC";
67};
68
69/// `.result`: the spec resolves an array and the page's promise is the untimed
70/// legacy kind, so `.none` would strand the caller.
71///
72/// `.live`: the hardware guard is asked at dispatch and answers honestly on a
73/// device without NFC. Nothing here warrants `.unavailable`.
74pub const capability_actions = [_]capabilities.ActionDecl{
75 .{ .name = A.scan_nfc, .reply = .result },
76};
77
78const Route = enum { scan };
79
80fn routeFor(action: []const u8) ?Route {
81 if (std.mem.eql(u8, action, A.scan_nfc)) return .scan;
82 return null;
83}
84
85pub const NfcBridge = struct {
86 allocator: std.mem.Allocator,
87
88 const Self = @This();
89
90 pub fn init(allocator: std.mem.Allocator) Self {
91 return .{ .allocator = allocator };
92 }
93
94 pub fn deinit(_: *Self) void {}
95
96 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
97 _ = data;
98 const route = routeFor(action) orelse return BridgeError.UnknownAction;
99 switch (route) {
100 .scan => try self.scanNFC(),
101 }
102 }
103
104 /// Begin one NDEF read.
105 ///
106 /// Every fallible step runs before `ios_async.acquire`, so no error path
107 /// sits between leasing a slot and handing the session its delegate: a
108 /// failure there would have to release the slot by hand, and a missed
109 /// release narrows the pool permanently. The same ordering rule
110 /// `bridge_mobile_notifications.zig` documents.
111 fn scanNFC(self: *Self) !void {
112 _ = self;
113 if (!is_darwin) return error.UnsupportedPlatform;
114
115 const SessionClass = objc.objc_getClass("NFCNDEFReaderSession") orelse
116 return BridgeError.PlatformNotSupported;
117
118 // The spec's guard. False on every simulator, and beginning a session
119 // without it raises rather than returning an error.
120 const sel_available = objc.sel_registerName("readingAvailable") orelse
121 return error.SelectorNotFound;
122 const AvailFn = *const fn (Id, objc.SEL) callconv(.c) bool;
123 const avail: AvailFn = @ptrCast(&objc.objc_msgSend);
124 if (!avail(SessionClass, sel_available)) return BridgeError.PlatformNotSupported;
125
126 const handler = try delegateInstance();
127 const sel_init = objc.sel_registerName("initWithDelegate:queue:invalidateAfterFirstRead:") orelse
128 return error.SelectorNotFound;
129 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
130 const sel_alert = objc.sel_registerName("setAlertMessage:") orelse return error.SelectorNotFound;
131 const sel_begin = objc.sel_registerName("begin") orelse return error.SelectorNotFound;
132
133 const ticket = ios_async.acquire(A.scan_nfc) orelse return poolFull();
134 errdefer ios_async.abandon(ticket);
135
136 const raw = objc.msgSendId(SessionClass, sel_alloc);
137 const InitFn = *const fn (Id, objc.SEL, Id, Id, bool) callconv(.c) Id;
138 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
139 // `queue: nil` is what the spec passes; CoreNFC then picks its own.
140 const session = init_fn(raw, sel_init, handler, null, true);
141 if (session == null) return BridgeError.NativeCallFailed;
142
143 // Published before `begin`, never after: the delegate can fire before
144 // `begin` returns, and a callback arriving at an empty slot would have
145 // no ticket to answer with.
146 publishPending(ticket, session);
147
148 if (try nsString(alert_message)) |msg| objc.msgSendVoid1(session, sel_alert, msg);
149 objc.msgSend(session, sel_begin);
150 }
151};
152
153/// The spec's wording, kept so an app that has localised around it does not
154/// see the prompt change under it.
155const alert_message = "Hold your iPhone near the NFC tag";
156
157fn poolFull() BridgeError {
158 std.log.warn("scanNFC refused: all {d} async slots in flight", .{ios_async.max_in_flight});
159 return BridgeError.InvalidParameter;
160}
161
162// =============================================================================
163// The pending scan. One slot: the session is invalidate-after-first-read, so a
164// scan answers exactly once and there is never a second in flight for it.
165// =============================================================================
166
167const Pending = struct {
168 ticket: ios_async.Ticket,
169 session: Id,
170};
171
172var pending: ?Pending = null;
173var pending_mutex: compat_mutex.Mutex = .{};
174
175fn publishPending(ticket: ios_async.Ticket, session: Id) void {
176 pending_mutex.lock();
177 defer pending_mutex.unlock();
178 pending = .{ .ticket = ticket, .session = session };
179}
180
181/// Read and clear. Clearing is what makes a second delegate callback a no-op
182/// rather than a second reply — CoreNFC can invalidate a session it has
183/// already reported a read for.
184fn takePending() ?Pending {
185 pending_mutex.lock();
186 defer pending_mutex.unlock();
187 const p = pending;
188 pending = null;
189 return p;
190}
191
192var delegate: ?Id = null;
193const delegate_class_name = "CraftNfcDelegate";
194
195/// Built once and kept. `NFCNDEFReaderSession` holds its delegate weakly and
196/// one instance serves every scan for the life of the process, so this is
197/// retained and never released — the same shape `bridge_mobile_bluetooth.zig`
198/// uses for its central-manager delegate.
199fn delegateInstance() !Id {
200 if (delegate) |existing| return existing;
201
202 const class = ios_delegate.defineClass(delegate_class_name, "NSObject", &.{
203 .{
204 .selector = "readerSession:didDetectNDEFs:",
205 .imp = @ptrCast(&craftNfcDidDetect),
206 .types = ios_delegate.enc.void_two_objects,
207 },
208 .{
209 .selector = "readerSession:didInvalidateWithError:",
210 .imp = @ptrCast(&craftNfcDidInvalidate),
211 .types = ios_delegate.enc.void_two_objects,
212 },
213 }) catch |err| {
214 std.log.warn("scanNFC: could not build the delegate class: {}", .{err});
215 return BridgeError.NativeCallFailed;
216 };
217
218 delegate = ios_delegate.instantiate(class) catch |err| {
219 std.log.warn("scanNFC: could not instantiate the delegate: {}", .{err});
220 return BridgeError.NativeCallFailed;
221 };
222 return delegate.?;
223}
224
225fn craftNfcDidDetect(_: Id, _: objc.SEL, _: Id, messages: Id) callconv(.c) void {
226 if (!is_darwin) return;
227 const p = takePending() orelse {
228 std.log.warn("scanNFC: a read arrived with no scan recorded; ignored", .{});
229 return;
230 };
231
232 const allocator = std.heap.c_allocator;
233 const json = shapeRecords(allocator, messages) catch |err| {
234 std.log.err("scanNFC could not shape its reply ({}); rejecting", .{err});
235 ios_async.deliverError(p.ticket);
236 return;
237 };
238 defer allocator.free(json);
239
240 ios_async.deliverJson(p.ticket, json);
241}
242
243fn craftNfcDidInvalidate(_: Id, _: objc.SEL, _: Id, err: Id) callconv(.c) void {
244 if (!is_darwin) return;
245 // A read already answered clears the slot, so an invalidation after a
246 // successful scan finds nothing and correctly says nothing.
247 const p = takePending() orelse return;
248
249 // The spec rejects only when the code is not 200, and 200 is user-cancel —
250 // which leaves the promise unsettled. `CANCELLED` is what the other
251 // presented surfaces answer for the same gesture.
252 const code = errorCode(err);
253 if (code == nfc_reader_session_cancelled) {
254 ios_async.deliverErrorCode(p.ticket, BridgeError.Cancelled);
255 return;
256 }
257 std.log.warn("scanNFC: the session invalidated with code {d}", .{code});
258 ios_async.deliverErrorCode(p.ticket, BridgeError.NativeCallFailed);
259}
260
261/// `NFCReaderError.readerSessionInvalidationErrorUserCanceled` — 200. The spec
262/// spells it as the bare literal with the comment "200 is user cancelled".
263const nfc_reader_session_cancelled: i64 = 200;
264
265fn errorCode(err: Id) i64 {
266 const e = err orelse return 0;
267 const sel = objc.sel_registerName("code") orelse return 0;
268 const Fn = *const fn (Id, objc.SEL) callconv(.c) isize;
269 const func: Fn = @ptrCast(&objc.objc_msgSend);
270 return @intCast(func(e, sel));
271}
272
273fn nsString(text: []const u8) !?Id {
274 if (!is_darwin) return null;
275 const allocator = std.heap.c_allocator;
276 const c_text = try memory.dupeZ(allocator, u8, text);
277 defer allocator.free(c_text);
278
279 const NSString = objc.objc_getClass("NSString") orelse return null;
280 const sel = objc.sel_registerName("stringWithUTF8String:") orelse return null;
281 const Fn = *const fn (Id, objc.SEL, [*:0]const u8) callconv(.c) Id;
282 const func: Fn = @ptrCast(&objc.objc_msgSend);
283 return func(NSString, sel, c_text.ptr);
284}
285
286// =============================================================================
287// Shaping the reply.
288//
289// Split from the Objective-C walk so the JSON assembly — the half that decides
290// what the page sees — is testable on a host with no CoreNFC in the process.
291// =============================================================================
292
293/// `appendJsonEscaped` escapes the *contents* and adds no quotes, so every
294/// field here would emit a bare token without this. Wrapping it once means a
295/// call site cannot forget.
296fn appendJsonString(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), s: []const u8) !void {
297 try out.append(allocator, '"');
298 try bridge_error.appendJsonEscaped(allocator, out, s);
299 try out.append(allocator, '"');
300}
301
302/// One NDEF record as the page receives it.
303const Record = struct {
304 type_name_format: u8,
305 /// Already decoded; `""` where the spec substitutes it.
306 type_text: []const u8,
307 identifier: []const u8,
308 payload: []const u8,
309};
310
311/// `[{...},{...}]`, or `[]` for a read with no records.
312///
313/// An empty array is a real answer, not a failure: a tag can carry a message
314/// with no records, and the spec resolves the empty array for it.
315fn appendRecords(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), records: []const Record) !void {
316 try out.append(allocator, '[');
317 for (records, 0..) |r, i| {
318 if (i != 0) try out.append(allocator, ',');
319 try out.appendSlice(allocator, "{\"typeNameFormat\":");
320 var tnf_buf: [4]u8 = undefined;
321 try out.appendSlice(allocator, try std.fmt.bufPrint(&tnf_buf, "{d}", .{r.type_name_format}));
322 try out.appendSlice(allocator, ",\"type\":");
323 try appendJsonString(allocator, out, r.type_text);
324 try out.appendSlice(allocator, ",\"identifier\":");
325 try appendJsonString(allocator, out, r.identifier);
326 try out.appendSlice(allocator, ",\"payload\":");
327 try appendJsonString(allocator, out, r.payload);
328 try out.append(allocator, '}');
329 }
330 try out.append(allocator, ']');
331}
332
333/// Walk `NSArray<NFCNDEFMessage *>` into the reply, flattening records across
334/// messages exactly as the spec's nested loop does.
335fn shapeRecords(allocator: std.mem.Allocator, messages: Id) ![]u8 {
336 var out: std.ArrayListUnmanaged(u8) = .empty;
337 errdefer out.deinit(allocator);
338
339 if (!is_darwin or messages == null) {
340 try appendRecords(allocator, &out, &.{});
341 return out.toOwnedSlice(allocator);
342 }
343
344 const sel_count = objc.sel_registerName("count") orelse return error.SelectorNotFound;
345 const sel_at = objc.sel_registerName("objectAtIndex:") orelse return error.SelectorNotFound;
346 const sel_records = objc.sel_registerName("records") orelse return error.SelectorNotFound;
347 const sel_tnf = objc.sel_registerName("typeNameFormat") orelse return error.SelectorNotFound;
348 const sel_type = objc.sel_registerName("type") orelse return error.SelectorNotFound;
349 const sel_ident = objc.sel_registerName("identifier") orelse return error.SelectorNotFound;
350 const sel_payload = objc.sel_registerName("payload") orelse return error.SelectorNotFound;
351
352 const CountFn = *const fn (Id, objc.SEL) callconv(.c) usize;
353 const count_fn: CountFn = @ptrCast(&objc.objc_msgSend);
354 const AtFn = *const fn (Id, objc.SEL, usize) callconv(.c) Id;
355 const at_fn: AtFn = @ptrCast(&objc.objc_msgSend);
356 const TnfFn = *const fn (Id, objc.SEL) callconv(.c) u8;
357 const tnf_fn: TnfFn = @ptrCast(&objc.objc_msgSend);
358
359 try out.append(allocator, '[');
360 var wrote: usize = 0;
361
362 const message_count = count_fn(messages, sel_count);
363 var m: usize = 0;
364 while (m < message_count) : (m += 1) {
365 const message = at_fn(messages, sel_at, m);
366 const records = objc.msgSendId(message, sel_records);
367 if (records == null) continue;
368
369 const record_count = count_fn(records, sel_count);
370 var r: usize = 0;
371 while (r < record_count) : (r += 1) {
372 const record = at_fn(records, sel_at, r);
373 if (record == null) continue;
374
375 if (wrote != 0) try out.append(allocator, ',');
376 try out.appendSlice(allocator, "{\"typeNameFormat\":");
377 var tnf_buf: [4]u8 = undefined;
378 try out.appendSlice(allocator, try std.fmt.bufPrint(&tnf_buf, "{d}", .{tnf_fn(record, sel_tnf)}));
379
380 // `type` and `identifier` fall back to "" the way the spec's
381 // `String(data:encoding:) ?? ""` does.
382 try out.appendSlice(allocator, ",\"type\":");
383 try appendData(allocator, &out, objc.msgSendId(record, sel_type), .empty_on_failure);
384 try out.appendSlice(allocator, ",\"identifier\":");
385 try appendData(allocator, &out, objc.msgSendId(record, sel_ident), .empty_on_failure);
386
387 // `payload` falls back to base64 instead: the payload is the point
388 // of the record, and dropping it silently is worse than handing
389 // back an encoding the caller has to notice.
390 try out.appendSlice(allocator, ",\"payload\":");
391 try appendData(allocator, &out, objc.msgSendId(record, sel_payload), .base64_on_failure);
392
393 try out.append(allocator, '}');
394 wrote += 1;
395 }
396 }
397
398 try out.append(allocator, ']');
399 return out.toOwnedSlice(allocator);
400}
401
402const DataFallback = enum { empty_on_failure, base64_on_failure };
403
404/// An `NSData` as a JSON string: its UTF-8 if it is valid UTF-8, otherwise the
405/// fallback the field calls for.
406fn appendData(
407 allocator: std.mem.Allocator,
408 out: *std.ArrayListUnmanaged(u8),
409 data: Id,
410 fallback: DataFallback,
411) !void {
412 const bytes = dataBytes(data) orelse return appendJsonString(allocator, out, "");
413
414 if (std.unicode.utf8ValidateSlice(bytes)) {
415 return appendJsonString(allocator, out, bytes);
416 }
417 switch (fallback) {
418 .empty_on_failure => return appendJsonString(allocator, out, ""),
419 .base64_on_failure => {
420 const enc = std.base64.standard.Encoder;
421 const buf = try allocator.alloc(u8, enc.calcSize(bytes.len));
422 defer allocator.free(buf);
423 return appendJsonString(allocator, out, enc.encode(buf, bytes));
424 },
425 }
426}
427
428/// Borrow an `NSData`'s bytes. The slice points into the object, which the
429/// caller's autorelease pool outlives for the duration of this walk.
430fn dataBytes(data: Id) ?[]const u8 {
431 if (!is_darwin) return null;
432 const d = data orelse return null;
433 const sel_bytes = objc.sel_registerName("bytes") orelse return null;
434 const sel_length = objc.sel_registerName("length") orelse return null;
435
436 const BytesFn = *const fn (Id, objc.SEL) callconv(.c) ?[*]const u8;
437 const bytes_fn: BytesFn = @ptrCast(&objc.objc_msgSend);
438 const LenFn = *const fn (Id, objc.SEL) callconv(.c) usize;
439 const len_fn: LenFn = @ptrCast(&objc.objc_msgSend);
440
441 const len = len_fn(d, sel_length);
442 if (len == 0) return &.{};
443 const ptr = bytes_fn(d, sel_bytes) orelse return null;
444 return ptr[0..len];
445}
446
447// =============================================================================
448// Tests — host-only. The CoreNFC walk cannot run here (no framework, and
449// `readingAvailable` is false on every simulator), so what is pinned is
450// everything that decides what the page sees: routing, the action name, the
451// reply bytes, the escaping, and the empty case.
452// =============================================================================
453
454const testing = std.testing;
455
456test "the declared action is the one the handler serves" {
457 try testing.expectEqual(@as(usize, 1), capability_actions.len);
458 try testing.expectEqualStrings(A.scan_nfc, capability_actions[0].name);
459 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
460 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status);
461 try testing.expect(routeFor(A.scan_nfc) == .scan);
462 try testing.expect(routeFor("scanQRCode") == null);
463}
464
465test "an action this namespace does not serve is reported, not ignored" {
466 var bridge = NfcBridge.init(testing.allocator);
467 defer bridge.deinit();
468 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("scanQRCode", "{}"));
469}
470
471test "a read with no records is an empty array, not a failure" {
472 // A tag can carry a message with no records, and the spec resolves `[]`
473 // for it. Answering an error instead would turn a real answer into a bug
474 // report.
475 const allocator = testing.allocator;
476 var out: std.ArrayListUnmanaged(u8) = .empty;
477 defer out.deinit(allocator);
478 try appendRecords(allocator, &out, &.{});
479 try testing.expectEqualStrings("[]", out.items);
480}
481
482test "records are flattened across messages, in order" {
483 const allocator = testing.allocator;
484 var out: std.ArrayListUnmanaged(u8) = .empty;
485 defer out.deinit(allocator);
486 try appendRecords(allocator, &out, &.{
487 .{ .type_name_format = 1, .type_text = "T", .identifier = "", .payload = "hello" },
488 .{ .type_name_format = 2, .type_text = "U", .identifier = "id", .payload = "https://x" },
489 });
490 try testing.expectEqualStrings(
491 "[{\"typeNameFormat\":1,\"type\":\"T\",\"identifier\":\"\",\"payload\":\"hello\"}," ++
492 "{\"typeNameFormat\":2,\"type\":\"U\",\"identifier\":\"id\",\"payload\":\"https://x\"}]",
493 out.items,
494 );
495}
496
497test "a payload carrying a quote or a backslash stays valid JSON" {
498 // NDEF payloads are arbitrary bytes written by whoever made the tag, so
499 // this is the field most likely to carry something that breaks a naive
500 // reply. Parsed rather than compared, so a wrong escape cannot pass.
501 const allocator = testing.allocator;
502 var out: std.ArrayListUnmanaged(u8) = .empty;
503 defer out.deinit(allocator);
504 try appendRecords(allocator, &out, &.{
505 .{ .type_name_format = 1, .type_text = "T", .identifier = "", .payload = "he said \"hi\"\\ok\n" },
506 });
507
508 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, out.items, .{});
509 defer parsed.deinit();
510 try testing.expect(parsed.value == .array);
511 const first = parsed.value.array.items[0].object;
512 try testing.expectEqualStrings("he said \"hi\"\\ok\n", first.get("payload").?.string);
513 try testing.expectEqual(@as(i64, 1), first.get("typeNameFormat").?.integer);
514}
515
516test "the user-cancel code is the spec's, not a guess" {
517 // The spec writes the bare literal with the comment "200 is user
518 // cancelled"; this is that constant, named. It is also the one branch that
519 // diverges — the spec settles nothing there and this answers CANCELLED.
520 try testing.expectEqual(@as(i64, 200), nfc_reader_session_cancelled);
521}
522
523test "the prompt is the spec's wording" {
524 // An app that has localised around this string should not see it change.
525 try testing.expectEqualStrings("Hold your iPhone near the NFC tag", alert_message);
526}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -418,6 +418,7 @@ pub fn gateFor(action: []const u8) ?Feature {
418418 .{ "getCurrentPosition", .geolocation },
419419 .{ "getHealthData", .health_kit },
420420 .{ "getPendingNotifications", .local_notifications },
421 .{ "scanNFC", .nfc },
421422 .{ "scheduleNotification", .local_notifications },
422423 .{ "haptic", .haptics },
423424 .{ "lockOrientation", .orientation_lock },
packages/zig/src/ios_dispatch.zigmodified+3-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -41,6 +41,7 @@ const bridge_mobile_bluetooth = ;
4141const bridge_mobile_audiorec = ;
4242const bridge_mobile_health = ;
4343const bridge_mobile_speech = ;
44const bridge_mobile_nfc = ;
4445
4546const objc = objc_runtime.objc;
4647
@@ -318,6 +319,7 @@ const mobile_bridges = .{
318319 bridge_mobile_audiorec.AudioRecordingBridge,
319320 bridge_mobile_health.HealthBridge,
320321 bridge_mobile_speech.SpeechBridge,
322 bridge_mobile_nfc.NfcBridge,
321323};
322324
323325/// The same modules again, as their capability manifests.
@@ -361,6 +363,7 @@ const mobile_manifests = [_][]const capabilities.ActionDecl{
361363 &bridge_mobile_audiorec.capability_actions,
362364 &bridge_mobile_health.capability_actions,
363365 &bridge_mobile_speech.capability_actions,
366 &bridge_mobile_nfc.capability_actions,
364367};
365368
366369comptime {
packages/zig/test/ios_conformance_test.zigmodified+6-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -57,6 +57,7 @@ const zig_sources = [_][]const u8{
5757 @embedFile("src/bridge_mobile_audiorec.zig"),
5858 @embedFile("src/bridge_mobile_health.zig"),
5959 @embedFile("src/bridge_mobile_speech.zig"),
60 @embedFile("src/bridge_mobile_nfc.zig"),
6061};
6162
6263/// The action list lives in the `switch action` block, and nowhere else.
@@ -104,6 +105,10 @@ const dispatch_end = "func webView(";
104105/// preconditions and four blockers; the load-bearing one was the realtime
105106/// audio tap, which is the first callback in this migration that runs on a
106107/// thread where a lock is a bug rather than a slowdown.
108/// 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.
107112/// 25 with scheduleNotification. Its deferral was the first to expire on its
108113/// own: the blocker recorded in `bridge_mobile_notifications.zig` was that
109114/// `ios_async` could only resolve, so an action whose two failure paths are
@@ -111,7 +116,7 @@ const dispatch_end = "func webView(";
111116/// and `deliverErrorCode` landed the day after that was written. Worth
112117/// re-reading the other deferrals for the same reason before assuming they
113118/// still hold.
114const max_not_yet_migrated: usize = 25;
119const max_not_yet_migrated: usize = 24;
115120
116121fn dispatcherRegion() []const u8 {
117122 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";