| @@ -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 | + |
| 45 | +const std = ; |
| 46 | +const builtin = ; |
| 47 | +const capabilities = ; |
| 48 | +const bridge_error = ; |
| 49 | +const objc_runtime = ; |
| 50 | +const ios_async = ; |
| 51 | +const ios_delegate = ; |
| 52 | +const compat_mutex = ; |
| 53 | +const memory = ; |
| 54 | + |
| 55 | +const objc = objc_runtime.objc; |
| 56 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 57 | +const 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. |
| 63 | +const Id = ?*anyopaque; |
| 64 | + |
| 65 | +pub 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`. |
| 74 | +pub const capability_actions = [_]capabilities.ActionDecl{ |
| 75 | + .{ .name = A.scan_nfc, .reply = .result }, |
| 76 | +}; |
| 77 | + |
| 78 | +const Route = enum { scan }; |
| 79 | + |
| 80 | +fn routeFor(action: []const u8) ?Route { |
| 81 | + if (std.mem.eql(u8, action, A.scan_nfc)) return .scan; |
| 82 | + return null; |
| 83 | +} |
| 84 | + |
| 85 | +pub 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. |
| 155 | +const alert_message = "Hold your iPhone near the NFC tag"; |
| 156 | + |
| 157 | +fn 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 | + |
| 167 | +const Pending = struct { |
| 168 | + ticket: ios_async.Ticket, |
| 169 | + session: Id, |
| 170 | +}; |
| 171 | + |
| 172 | +var pending: ?Pending = null; |
| 173 | +var pending_mutex: compat_mutex.Mutex = .{}; |
| 174 | + |
| 175 | +fn 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. |
| 184 | +fn takePending() ?Pending { |
| 185 | + pending_mutex.lock(); |
| 186 | + defer pending_mutex.unlock(); |
| 187 | + const p = pending; |
| 188 | + pending = null; |
| 189 | + return p; |
| 190 | +} |
| 191 | + |
| 192 | +var delegate: ?Id = null; |
| 193 | +const 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. |
| 199 | +fn 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 | + |
| 225 | +fn 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 | + |
| 243 | +fn 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". |
| 263 | +const nfc_reader_session_cancelled: i64 = 200; |
| 264 | + |
| 265 | +fn 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 | + |
| 273 | +fn 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. |
| 296 | +fn 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. |
| 303 | +const 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. |
| 315 | +fn 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. |
| 335 | +fn 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 | + |
| 402 | +const 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. |
| 406 | +fn 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. |
| 430 | +fn 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 | + |
| 454 | +const testing = std.testing; |
| 455 | + |
| 456 | +test "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 | + |
| 465 | +test "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 | + |
| 471 | +test "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 | + |
| 482 | +test "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 | + |
| 497 | +test "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 | + |
| 516 | +test "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 | + |
| 523 | +test "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 | +} |