ReviewOS

also looking at this

craft-native/craft

iOS: retire three stale refusals — ratchet 38 → 35

#96
Merged glennmichael123 wants to merge feat/ios-stale-blockers into main
8 files +1036 -100
packages/ios/fixtures/zig-slice/build-and-run.shmodified+22-0
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -64,6 +64,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6464 "$ROOT/packages/zig/zig-out/lib/$LIB" \
6565 -framework UIKit -framework WebKit -framework Foundation -framework Security \
6666 -framework Vision -framework LocalAuthentication -framework PDFKit \
67 -framework WatchConnectivity \
6768 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6869 -o "$APP/CraftSlice"
6970
@@ -298,4 +299,25 @@ C44="$(count 44)"
298299[ "$C44" -ge 1 ] || { echo "FAIL: closePDF did not dismiss the viewer"; exit 1; }
299300echo "ok: the same module that presented the viewer dismissed it (i=44 seen ${C44}x)"
300301
302C48="$(count 48)"
303[ "$C48" -ge 1 ] || { echo "FAIL: downloadFile did not land a file in Documents"; exit 1; }
304echo "ok: URL fetched and moved into the app container (i=48 seen ${C48}x)"
305
306C52="$(count 52)"
307# NOT_FOUND rather than PLATFORM_NOT_SUPPORTED: WatchConnectivity is linked, so
308# WCSession itself reported no reachable watch. Before this phase the action
309# reached the shim instead, because ios_async had no way to deliver an error —
310# which is the common branch here, not the exotic one.
311[ "$C52" -ge 1 ] || { echo "FAIL: sendToWatch did not refuse through the error path"; exit 1; }
312echo "ok: watch refusal delivered through the async error path (i=52 seen ${C52}x)"
313
314C54="$(count 54)"
315# The exact trap this action was held back for: ios_async's pooled block would
316# have replied the STRING "denied", and "denied" is truthy — so a page's
317# `if (await openSettings())` would take the success branch on failure. The
318# page asserts `typeof payload === 'boolean'`, which only the module's own
319# block can satisfy. Posted last, because it navigates to Settings.
320[ "$C54" -ge 1 ] || { echo "FAIL: openSettings did not reply with a boolean"; exit 1; }
321echo "ok: openSettings replied a real boolean, not \"granted\"/\"denied\" (i=54 seen ${C54}x)"
322
301323echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -21,7 +21,7 @@
2121 "enableDeepLinks": true,
2222 "enableQRScanner": false,
2323 "enableFilePicker": false,
24 "enableFileDownload": false,
24 "enableFileDownload": true,
2525 "enableSocialAuth": false,
2626 "enableAudioRecording": false,
2727 "enableVideoRecording": false,
packages/ios/fixtures/zig-slice/page.mmodified+32-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -50,6 +50,7 @@ const char craft_slice_page[] =
5050 "var sentOcr = false, sentClassify = false, sentDetect = false;\n"
5151 "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n"
5252 "var sentPdfOpen = false, sentPdfClose = false;\n"
53 "var sentDownload = false, sentWatch = false, sentSettings = false;\n"
5354 "window.__craftBridgeError = function (ctx) {\n"
5455 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5556 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -73,6 +74,14 @@ const char craft_slice_page[] =
7374 " // and not a missing-class refusal: PERMISSION_DENIED means\n"
7475 " // canEvaluatePolicy said no. Before this phase the action was gated in\n"
7576 " // Swift with no else and the promise never settled at all.\n"
77 " // NOT_FOUND, not PLATFORM_NOT_SUPPORTED: WatchConnectivity is linked,\n"
78 " // so this is WCSession itself reporting no reachable watch rather than\n"
79 " // a missing class. Before this phase the action was not served at all.\n"
80 " if (!sentWatch && ctx && ctx.error === true && ctx.id === 101\n"
81 " && ctx.code === 'NOT_FOUND') {\n"
82 " sentWatch = true;\n"
83 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:52});\n"
84 " }\n"
7685 " if (!sentAuth && ctx && ctx.error === true && ctx.id === 95\n"
7786 " && ctx.code === 'PERMISSION_DENIED') {\n"
7887 " sentAuth = true;\n"
@@ -162,6 +171,18 @@ const char craft_slice_page[] =
162171 " // come back over the background-queue hop with its ticket intact.\n"
163172 " // The reply echoes both fields the page sent, so it proves the payload\n"
164173 " // was parsed and routed — and the donation ran without throwing.\n"
174 " // A bare string path, landed inside the app container's Documents.\n"
175 " // Nothing but a real download-and-move produces it.\n"
176 " if (!sentSettings && action === 'openSettings' && typeof payload === 'boolean') {\n"
177 " sentSettings = true;\n"
178 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:54});\n"
179 " }\n"
180 " if (!sentDownload && action === 'downloadFile' && typeof payload === 'string'\n"
181 " && payload.indexOf('/Documents/') !== -1\n"
182 " && payload.slice(-12) === 'slice-dl.txt') {\n"
183 " sentDownload = true;\n"
184 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:48});\n"
185 " }\n"
165186 " if (!sentPdfOpen && action === 'openPDF' && payload\n"
166187 " && payload.opened === true && payload.pageCount === 1) {\n"
167188 " sentPdfOpen = true;\n"
@@ -285,10 +306,21 @@ const char craft_slice_page[] =
285306 " PDF += 'biAKMDAwMDAwMDA1NCAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwv';\n"
286307 " PDF += 'U2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTcwCiUlRU9GCg==';\n"
287308 " bridge.postMessage({t:'mobile', a:'openPDF', d: JSON.stringify({source:PDF}), i:98});\n"
309 " // A data: URL, so the download needs no network. NSURLSession handles\n"
310 " // data: for download tasks, and the reply is the destination path.\n"
311 " bridge.postMessage({t:'mobile', a:'downloadFile', d: JSON.stringify({\n"
312 " url:'data:text/plain;base64,aGVsbG8gY3JhZnQ=', filename:'slice-dl.txt'}), i:100});\n"
313 " // No paired watch on a simulator, so this must refuse — the same and\n"
314 " // only branch the Swift shim reaches there.\n"
315 " bridge.postMessage({t:'mobile', a:'sendToWatch',\n"
316 " d: JSON.stringify({message:{action:'ping'}}), i:101});\n"
288317 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
289318 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
290319 " // And one the shim answers by rejecting, to prove the error route.\n"
291320 " bridge.postMessage({t:'mobile', a:'hostOnlyFail', i:6});\n"
321 " // Last, deliberately: this navigates to the Settings app, so anything\n"
322 " // posted after it would race the webview being backgrounded.\n"
323 " bridge.postMessage({t:'mobile', a:'openSettings', i:102});\n"
292324 "} else {\n"
293325 " document.getElementById('s').textContent = 'NO BRIDGE';\n"
294326 "}\n"
packages/zig/src/bridge_mobile_filepicker.zigmodified+359-11
Changes to packages/zig/src/bridge_mobile_filepicker.zig
@@ -266,18 +266,16 @@ const Id = ?*anyopaque;
266266pub const A = struct {
267267 pub const pick_file = "pickFile";
268268 pub const save_file = "saveFile";
269 pub const download_file = "downloadFile";
269270};
270271
271/// The third action of this family, and on purpose not served.
272/// Nothing in this family is unserved any more.
272273///
273/// `downloadFile` is out of scope for this round. Recorded as data rather than
274/// prose so a test can hold the two properties the decision rests on: it is not
275/// declared, and it still falls out of `handleMessage` as `UnknownAction` —
276/// the one return `ios_dispatch.route` converts into a Swift-shim hand-off.
277/// Whoever migrates it moves the name into `A` and updates the module comment.
278pub const deliberately_unserved = [_][]const u8{
279 "downloadFile",
280};
274/// `downloadFile` was the last entry, recorded as data so a test could hold
275/// that it was neither declared nor routed. It is declared now, and the empty
276/// list is kept rather than deleted so the test that walks it keeps compiling
277/// and the next deferral has somewhere to go.
278pub const deliberately_unserved = [_][]const u8{};
281279
282280/// Both `.result`: each Swift path terminates in exactly one `resolveCallback`
283281/// or one `rejectCallback`, and the page awaits both. `.none` on either would
@@ -290,15 +288,17 @@ pub const deliberately_unserved = [_][]const u8{
290288pub const capability_actions = [_]capabilities.ActionDecl{
291289 .{ .name = A.pick_file, .reply = .result },
292290 .{ .name = A.save_file, .reply = .result },
291 .{ .name = A.download_file, .reply = .result },
293292};
294293
295294/// Which handler an action selects, split out of `handleMessage` so the
296295/// table-versus-dispatch agreement is assertable on a host without UIKit.
297const Route = enum { pick_file, save_file };
296const Route = enum { pick_file, save_file, download_file };
298297
299298fn routeFor(action: []const u8) ?Route {
300299 if (std.mem.eql(u8, action, A.pick_file)) return .pick_file;
301300 if (std.mem.eql(u8, action, A.save_file)) return .save_file;
301 if (std.mem.eql(u8, action, A.download_file)) return .download_file;
302302 return null;
303303}
304304
@@ -319,6 +319,7 @@ pub const FilePickerBridge = struct {
319319 return switch (route) {
320320 .pick_file => self.pickFile(data),
321321 .save_file => self.saveFile(data),
322 .download_file => self.downloadFile(data),
322323 };
323324 }
324325
@@ -485,6 +486,79 @@ pub const FilePickerBridge = struct {
485486
486487 bridge_error.sendResultToJS(self.allocator, A.save_file, json);
487488 }
489
490 /// Fetch a URL and land it in Documents under `filename`.
491 ///
492 /// The third action of this family, and the last one it was missing. The
493 /// module recorded it as "out of scope for this round"; nothing structural
494 /// stood in the way, only a completion shape — `void (^)(NSURL *,
495 /// NSURLResponse *, NSError *)` — that no pooled block covers.
496 ///
497 /// Two divergences from Swift, both deliberate:
498 ///
499 /// - **The filename is validated.** `saveFile` already refuses a name
500 /// carrying `/` or a NUL, because `appendingPathComponent` on
501 /// `"../Library/Preferences/x.plist"` escapes the container. Swift's
502 /// `downloadFile` runs the same `appendingPathComponent` with no such
503 /// check, so the same escape is available through the download path
504 /// today. Guarding one entrance and not the other is not a guard.
505 /// - **The error text is a code.** Swift rejects with
506 /// `error.localizedDescription`; the protocol carries no message field,
507 /// so the description goes to the log and the page gets
508 /// `NATIVE_CALL_FAILED`.
509 fn downloadFile(self: *Self, data: []const u8) !void {
510 if (!is_darwin) return error.UnsupportedPlatform;
511
512 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
513 return BridgeError.InvalidJSON;
514 defer parsed.deinit();
515
516 const root = switch (parsed.value) {
517 .object => |obj| obj,
518 else => return BridgeError.InvalidJSON,
519 };
520
521 const url_text = try downloadField(root, "url");
522 const filename = try downloadField(root, "filename");
523 try validateFilename(filename);
524
525 const url = objc.createNSURL(url_text, self.allocator) catch return BridgeError.AllocationFailed;
526 if (url == null) {
527 // Swift's `guard let downloadURL = URL(string: url)`.
528 std.log.warn("downloadFile refused: the url did not parse", .{});
529 return BridgeError.InvalidParameter;
530 }
531
532 const NSURLSession = objc.objc_getClass("NSURLSession") orelse return error.ClassNotFound;
533 const sel_shared = try selector("sharedSession");
534 const session = objc.msgSendId(NSURLSession, sel_shared) orelse return error.NativeCallFailed;
535
536 const owned_filename = std.heap.c_allocator.dupe(u8, filename) catch
537 return BridgeError.AllocationFailed;
538 errdefer std.heap.c_allocator.free(owned_filename);
539
540 const ticket = ios_async.acquire(A.download_file) orelse {
541 std.log.warn(
542 "downloadFile: no free reply slot; {d} native calls are already awaiting one",
543 .{ios_async.max_in_flight},
544 );
545 return BridgeError.NativeCallFailed;
546 };
547 errdefer ios_async.abandon(ticket);
548
549 publishDownload(ticket, owned_filename);
550
551 const sel_task = try selector("downloadTaskWithURL:completionHandler:");
552 const TaskFn = *const fn (Id, Id, Id, *anyopaque) callconv(.c) Id;
553 const taskFn: TaskFn = @ptrCast(&objc.objc_msgSend);
554 const task = taskFn(session, sel_task, url, @ptrCast(&download_blocks[ticket.index])) orelse {
555 _ = takeDownload(ticket.index);
556 return error.NativeCallFailed;
557 };
558
559 const sel_resume = try selector("resume");
560 objc.msgSend(task, sel_resume);
561 }
488562};
489563
490564/// The answer for a full block pool, in the shape `bridge_mobile_location.zig`
@@ -1447,12 +1521,224 @@ fn readNSString(object: Id, comptime name: [*:0]const u8) ?[]const u8 {
14471521// that a simulator fixture can drive end to end.
14481522// =============================================================================
14491523
1524/// One required string field of `downloadFile`'s payload.
1525///
1526/// Swift's arm is a two-clause `if let … as? String` chain with no `else`, so
1527/// a missing or mistyped field settles nothing and the page waits out its
1528/// timeout. Every path here ends in a value or an error.
1529fn downloadField(root: std.json.ObjectMap, comptime key: []const u8) ![]const u8 {
1530 const value = root.get(key) orelse {
1531 std.log.warn("downloadFile refused: no " ++ key ++ " field", .{});
1532 return BridgeError.MissingData;
1533 };
1534 return switch (value) {
1535 .string => |text| if (text.len == 0) {
1536 std.log.warn("downloadFile refused: " ++ key ++ " is empty", .{});
1537 return BridgeError.InvalidParameter;
1538 } else text,
1539 else => {
1540 std.log.warn("downloadFile refused: " ++ key ++ " is not a string", .{});
1541 return BridgeError.InvalidParameter;
1542 },
1543 };
1544}
1545
1546// ===========================================================================
1547// The download completion
1548//
1549// `void (^)(NSURL *location, NSURLResponse *response, NSError *error)` — three
1550// object arguments, which no `ios_async` pooled block covers. Global, one
1551// comptime invoke per slot, in the shape `bridge_mobile_notifications.zig`
1552// established.
1553//
1554// The move happens inside the block, on whatever queue NSURLSession chose,
1555// because that is the only window in which `location` is valid: the temporary
1556// file is deleted the moment the handler returns. `NSFileManager` is safe to
1557// use from any thread for this, and the reply goes out through
1558// `ios_async.deliverJson`, which does its own hop to the main queue.
1559// ===========================================================================
1560
1561/// The filename a slot's completion will move its download to.
1562const PendingDownload = struct {
1563 ticket: ios_async.Ticket,
1564 /// Owned, `c_allocator`. Freed when the completion fires.
1565 filename: []u8,
1566};
1567
1568var pending_downloads: [ios_async.max_in_flight]?PendingDownload = @splat(null);
1569var download_mutex: compat_mutex.Mutex = .{};
1570
1571fn publishDownload(ticket: ios_async.Ticket, filename: []u8) void {
1572 download_mutex.lock();
1573 defer download_mutex.unlock();
1574 pending_downloads[ticket.index] = .{ .ticket = ticket, .filename = filename };
1575}
1576
1577/// Read and clear, so a second fire is a no-op rather than a second reply and
1578/// a double free.
1579fn takeDownload(index: u5) ?PendingDownload {
1580 download_mutex.lock();
1581 defer download_mutex.unlock();
1582 const call = pending_downloads[index];
1583 pending_downloads[index] = null;
1584 return call;
1585}
1586
1587const DownloadBlockDescriptor = extern struct {
1588 reserved: c_ulong = 0,
1589 size: c_ulong,
1590};
1591
1592const DownloadBlock = extern struct {
1593 isa: ?*anyopaque,
1594 flags: c_int,
1595 reserved: c_int = 0,
1596 invoke: *const anyopaque,
1597 descriptor: *const DownloadBlockDescriptor,
1598};
1599
1600/// 1 << 28 — a global block is never copied.
1601const DOWNLOAD_BLOCK_IS_GLOBAL: c_int = 1 << 28;
1602
1603const download_block_descriptor = DownloadBlockDescriptor{ .size = @sizeOf(DownloadBlock) };
1604
1605extern var _NSConcreteGlobalBlock: anyopaque;
1606
1607fn makeDownloadInvoke(comptime index: u5) *const anyopaque {
1608 const S = struct {
1609 fn invoke(_: *const DownloadBlock, location: Id, _: Id, err: Id) callconv(.c) void {
1610 downloadFinished(index, location, err);
1611 }
1612 };
1613 return @ptrCast(&S.invoke);
1614}
1615
1616fn makeDownloadBlocks() [ios_async.max_in_flight]DownloadBlock {
1617 var out: [ios_async.max_in_flight]DownloadBlock = undefined;
1618 for (&out, 0..) |*b, i| {
1619 b.* = .{
1620 .isa = &_NSConcreteGlobalBlock,
1621 .flags = DOWNLOAD_BLOCK_IS_GLOBAL,
1622 .invoke = makeDownloadInvoke(@intCast(i)),
1623 .descriptor = &download_block_descriptor,
1624 };
1625 }
1626 return out;
1627}
1628
1629var download_blocks: [ios_async.max_in_flight]DownloadBlock =
1630 if (is_darwin) makeDownloadBlocks() else undefined;
1631
1632fn downloadFinished(index: u5, location: Id, err: Id) void {
1633 if (!is_darwin) return;
1634
1635 const call = takeDownload(index) orelse {
1636 std.log.warn(
1637 "downloadFile completion fired for slot {d} with no call recorded; ignored",
1638 .{index},
1639 );
1640 return;
1641 };
1642 const allocator = std.heap.c_allocator;
1643 defer allocator.free(call.filename);
1644
1645 if (err != null) {
1646 logNSError("downloadFile", err);
1647 ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed);
1648 return;
1649 }
1650 if (location == null) {
1651 // Swift's `guard let localURL else { reject("Download failed") }`.
1652 std.log.warn("downloadFile: the task reported neither a file nor an error", .{});
1653 ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed);
1654 return;
1655 }
1656
1657 const path = moveIntoDocuments(allocator, location, call.filename) catch |move_err| {
1658 std.log.warn("downloadFile: could not move the download into place: {}", .{move_err});
1659 ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed);
1660 return;
1661 };
1662 defer allocator.free(path);
1663
1664 var out: std.ArrayListUnmanaged(u8) = .empty;
1665 defer out.deinit(allocator);
1666 out.append(allocator, '"') catch {
1667 ios_async.deliverError(call.ticket);
1668 return;
1669 };
1670 bridge_error.appendJsonEscaped(allocator, &out, path) catch {
1671 ios_async.deliverError(call.ticket);
1672 return;
1673 };
1674 out.append(allocator, '"') catch {
1675 ios_async.deliverError(call.ticket);
1676 return;
1677 };
1678
1679 // Swift resolves `destinationURL.path` — a bare JSON string, not an object.
1680 ios_async.deliverJson(call.ticket, out.items);
1681}
1682
1683/// `Documents/<filename>`, replacing anything already there, and the path.
1684fn moveIntoDocuments(allocator: std.mem.Allocator, location: Id, filename: []const u8) ![]u8 {
1685 const documents = try documentsPath(allocator);
1686 defer allocator.free(documents);
1687
1688 const destination = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ documents, filename });
1689 errdefer allocator.free(destination);
1690
1691 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound;
1692 const sel_default = try selector("defaultManager");
1693 const manager = objc.msgSendId(NSFileManager, sel_default) orelse return error.NativeCallFailed;
1694
1695 const ns_destination = try objc.createNSString(destination, allocator);
1696
1697 // Swift removes an existing file first, because `moveItemAtURL:` fails
1698 // rather than overwriting. A failure here is ignored exactly as Swift's
1699 // `if fileExists` guard ignores the race between the two calls.
1700 const sel_exists = try selector("fileExistsAtPath:");
1701 const ExistsFn = *const fn (Id, Id, Id) callconv(.c) bool;
1702 const exists: ExistsFn = @ptrCast(&objc.objc_msgSend);
1703 if (exists(manager, sel_exists, ns_destination)) {
1704 const sel_remove = try selector("removeItemAtPath:error:");
1705 const RemoveFn = *const fn (Id, Id, Id, ?*Id) callconv(.c) bool;
1706 const remove: RemoveFn = @ptrCast(&objc.objc_msgSend);
1707 _ = remove(manager, sel_remove, ns_destination, null);
1708 }
1709
1710 const NSURL = objc.objc_getClass("NSURL") orelse return error.ClassNotFound;
1711 const sel_file_url = try selector("fileURLWithPath:");
1712 const destination_url = objc.msgSendId1(NSURL, sel_file_url, ns_destination) orelse
1713 return error.NativeCallFailed;
1714
1715 const sel_move = try selector("moveItemAtURL:toURL:error:");
1716 const MoveFn = *const fn (Id, Id, Id, Id, ?*Id) callconv(.c) bool;
1717 const move: MoveFn = @ptrCast(&objc.objc_msgSend);
1718 var move_error: Id = null;
1719 if (!move(manager, sel_move, location, destination_url, &move_error)) {
1720 logNSError("downloadFile", move_error);
1721 return error.NativeCallFailed;
1722 }
1723
1724 return destination;
1725}
1726
1727fn logNSError(action: []const u8, err: Id) void {
1728 const ns_error = err orelse return;
1729 const sel = objc.sel_registerName("localizedDescription") orelse return;
1730 const ns_description = objc.msgSendId(ns_error, sel) orelse return;
1731 const utf8 = objc.getNSStringUTF8(ns_description) orelse return;
1732 std.log.warn("{s}: {s}", .{ action, std.mem.span(utf8) });
1733}
1734
14501735const testing = std.testing;
14511736
14521737test "the declared actions are the ones the handler serves" {
1453 try testing.expectEqual(@as(usize, 2), capability_actions.len);
1738 try testing.expectEqual(@as(usize, 3), capability_actions.len);
14541739 try testing.expectEqualStrings(A.pick_file, capability_actions[0].name);
14551740 try testing.expectEqualStrings(A.save_file, capability_actions[1].name);
1741 try testing.expectEqualStrings(A.download_file, capability_actions[2].name);
14561742
14571743 for (capability_actions) |decl| {
14581744 // A `.result` whose handler never replies parks the caller until its
@@ -1948,3 +2234,65 @@ test "a callback with nothing pending is ignored rather than replying twice" {
19482234 settlePick(.picked, "{}");
19492235 try testing.expect(takePendingPick() == null);
19502236}
2237
2238test "downloadFile guards the container the same way saveFile does" {
2239 // Swift's downloadFile runs the same `appendingPathComponent` as saveFile
2240 // and does *not* validate, so "../Library/Preferences/x.plist" escapes the
2241 // container through the download path today. Guarding one entrance and not
2242 // the other is not a guard.
2243 try testing.expectError(BridgeError.InvalidParameter, validateFilename("../escape.plist"));
2244 try testing.expectError(BridgeError.InvalidParameter, validateFilename("a/b.txt"));
2245 try validateFilename("fine.txt");
2246}
2247
2248test "downloadFile requires both fields and refuses empties" {
2249 const parsed = try std.json.parseFromSlice(
2250 std.json.Value,
2251 testing.allocator,
2252 "{\"url\":\"https://a/b\",\"filename\":\"b.txt\"}",
2253 .{},
2254 );
2255 defer parsed.deinit();
2256 const root = parsed.value.object;
2257 try testing.expectEqualStrings("https://a/b", try downloadField(root, "url"));
2258 try testing.expectEqualStrings("b.txt", try downloadField(root, "filename"));
2259
2260 const empty = try std.json.parseFromSlice(
2261 std.json.Value,
2262 testing.allocator,
2263 "{\"url\":\"\",\"filename\":7}",
2264 .{},
2265 );
2266 defer empty.deinit();
2267 try testing.expectError(BridgeError.InvalidParameter, downloadField(empty.value.object, "url"));
2268 try testing.expectError(BridgeError.InvalidParameter, downloadField(empty.value.object, "filename"));
2269 try testing.expectError(BridgeError.MissingData, downloadField(empty.value.object, "nope"));
2270}
2271
2272test "each download block is global and has its own invoke" {
2273 if (!is_darwin) return error.SkipZigTest;
2274
2275 for (&download_blocks) |*b| {
2276 try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa);
2277 try testing.expectEqual(DOWNLOAD_BLOCK_IS_GLOBAL, b.flags);
2278 try testing.expectEqual(@sizeOf(DownloadBlock), @as(usize, @intCast(b.descriptor.size)));
2279 }
2280 try testing.expect(download_blocks[0].invoke != download_blocks[1].invoke);
2281}
2282
2283test "a download completion for a slot with no recorded call is ignored" {
2284 // Otherwise a second fire double-frees the parked filename.
2285 if (!is_darwin) return error.SkipZigTest;
2286
2287 download_mutex.lock();
2288 for (&pending_downloads) |*entry| entry.* = null;
2289 download_mutex.unlock();
2290
2291 downloadFinished(0, null, null);
2292 downloadFinished(0, null, null);
2293}
2294
2295test "nothing in this family is unserved any more" {
2296 try testing.expectEqual(@as(usize, 0), deliberately_unserved.len);
2297 try testing.expect(routeFor("downloadFile") != null);
2298}
packages/zig/src/bridge_mobile_permissions.zigmodified+257-31
Changes to packages/zig/src/bridge_mobile_permissions.zig
@@ -37,18 +37,23 @@
3737//! special case a BOOL cannot carry) and contacts/calendar/reminders
3838//! (need a store instance kept alive past the dispatch frame, plus an
3939//! iOS 17 availability fork) return `UnknownAction`.
40//! - `openSettings` is not claimed at all — not in `A`, not in the table.
41//! Its reply is a bare JSON *boolean* (`UIApplication.open`'s completion
42//! BOOL, `true`/`false` on the wire), and `ios_async.boolBlock` replies
43//! the strings `"granted"`/`"denied"`. That mismatch is not cosmetic:
44//! `false` is falsy and `"denied"` is truthy, so
40//! - `openSettings` **is** claimed now, and the reason it was not is worth
41//! keeping. Its reply is a bare JSON *boolean* (`UIApplication.open`'s
42//! completion BOOL, `true`/`false` on the wire) and `ios_async.boolBlock`
43//! replies the strings `"granted"`/`"denied"`. That mismatch is not
44//! cosmetic: `false` is falsy and `"denied"` is truthy, so
4545//! `if (await craft.permissions.openSettings())` would take its success
46//! branch on failure. Hand-building a one-off block here is off the table
47//! — the repo's last hand-rolled completion block was stack-allocated
48//! with a doc comment asserting a lifetime async does not honour — so
49//! claiming this action means first extending `ios_async` with a
50//! bool-literal reply variant. Until then the Swift shim's answer is
51//! correct and keeps flowing.
46//! branch on failure. The old note concluded that claiming the action
47//! meant first extending `ios_async` with a bool-literal reply variant.
48//! It did not — a module-owned global block delivering through
49//! `deliverJson` carries any literal, which is the shape
50//! `bridge_mobile_auth.zig` and `bridge_mobile_siri.zig` established
51//! afterwards. What the note was really objecting to remains true and is
52//! still avoided: the repo's one hand-rolled *stack* block
53//! (`bridge_mobile_system.zig:391`) is safe only because `openURL:`
54//! chooses to copy an escaping handler, which is the callee's choice and
55//! not a guarantee. Every block in this file is global, so `Block_copy` is
56//! identity and the question does not arise.
5257//!
5358//! Returning `UnknownAction` for a payload value is what keeps this honest
5459//! without regressing anything: `ios_dispatch.route` treats it as "not mine,
@@ -102,6 +107,7 @@ const capabilities = ;
102107const bridge_error = ;
103108const objc_runtime = ;
104109const ios_async = ;
110const compat_mutex = ;
105111
106112const objc = objc_runtime.objc;
107113const is_darwin = builtin.target.os.tag.isDarwin();
@@ -124,6 +130,7 @@ const Id = ?*anyopaque;
124130pub const A = struct {
125131 pub const check_permission = "checkPermission";
126132 pub const request_permission = "requestPermission";
133 pub const open_settings = "openSettings";
127134};
128135
129136/// Both `.result`: every Swift path for these two actions terminates in
@@ -137,16 +144,18 @@ pub const A = struct {
137144pub const capability_actions = [_]capabilities.ActionDecl{
138145 .{ .name = A.check_permission, .reply = .result },
139146 .{ .name = A.request_permission, .reply = .result },
147 .{ .name = A.open_settings, .reply = .result },
140148};
141149
142150/// Which handler an action selects, or null for one this namespace does not
143151/// serve. Split out from `handleMessage` so the table-versus-dispatch
144152/// agreement is assertable on a host that has none of the frameworks.
145const Route = enum { check, request };
153const Route = enum { check, request, open_settings };
146154
147155fn routeFor(action: []const u8) ?Route {
148156 if (std.mem.eql(u8, action, A.check_permission)) return .check;
149157 if (std.mem.eql(u8, action, A.request_permission)) return .request;
158 if (std.mem.eql(u8, action, A.open_settings)) return .open_settings;
150159 return null;
151160}
152161
@@ -322,6 +331,7 @@ pub const PermissionsBridge = struct {
322331 return switch (route) {
323332 .check => self.checkPermission(data),
324333 .request => self.requestPermission(data),
334 .open_settings => self.openSettings(),
325335 };
326336 }
327337
@@ -478,6 +488,62 @@ pub const PermissionsBridge = struct {
478488 const func: Fn = @ptrCast(&objc.objc_msgSend);
479489 func(center, sel, un_options_alert_badge_sound, ios_async.boolErrorBlock(ticket));
480490 }
491
492 /// Open the app's page in Settings and say whether iOS did.
493 ///
494 /// Ungated in the Swift dispatcher, so nothing above this refuses it, and
495 /// it is the one action in this file that takes no payload at all.
496 fn openSettings(self: *Self) !void {
497 if (!is_darwin) return bridge_error.BridgeError.PlatformNotSupported;
498
499 const url_string = settingsUrlString() orelse
500 return bridge_error.BridgeError.PlatformNotSupported;
501
502 const NSURL = objc.objc_getClass("NSURL") orelse
503 return bridge_error.BridgeError.NativeCallFailed;
504 const sel_with_string = objc.sel_registerName("URLWithString:") orelse
505 return bridge_error.BridgeError.NativeCallFailed;
506 const url = objc.msgSendId1(NSURL, sel_with_string, url_string) orelse {
507 // Swift's `guard let url = URL(string:)` else branch, which rejects
508 // with "Settings URL is unavailable".
509 std.log.warn("openSettings: the settings URL did not parse", .{});
510 return bridge_error.BridgeError.NativeCallFailed;
511 };
512
513 const UIApplication = objc.objc_getClass("UIApplication") orelse
514 return bridge_error.BridgeError.PlatformNotSupported;
515 const sel_shared = objc.sel_registerName("sharedApplication") orelse
516 return bridge_error.BridgeError.NativeCallFailed;
517 const app = objc.msgSendId(UIApplication, sel_shared) orelse
518 return bridge_error.BridgeError.NativeCallFailed;
519
520 const NSDictionary = objc.objc_getClass("NSDictionary") orelse
521 return bridge_error.BridgeError.NativeCallFailed;
522 const sel_dictionary = objc.sel_registerName("dictionary") orelse
523 return bridge_error.BridgeError.NativeCallFailed;
524 // `options:` is declared nonnull; an empty dictionary is the documented
525 // way to pass none.
526 const options = objc.msgSendId(NSDictionary, sel_dictionary) orelse
527 return bridge_error.BridgeError.NativeCallFailed;
528
529 const ticket = ios_async.acquire(A.open_settings) orelse {
530 std.log.warn(
531 "openSettings: no free reply slot; {d} native calls are already awaiting one",
532 .{ios_async.max_in_flight},
533 );
534 return bridge_error.BridgeError.NativeCallFailed;
535 };
536 errdefer ios_async.abandon(ticket);
537 publishSettingsCall(ticket);
538
539 const sel_open = objc.sel_registerName("openURL:options:completionHandler:") orelse
540 return bridge_error.BridgeError.NativeCallFailed;
541 const OpenFn = *const fn (objc.id, objc.SEL, objc.id, objc.id, *anyopaque) callconv(.c) void;
542 const openFn: OpenFn = @ptrCast(&objc.objc_msgSend);
543 openFn(app, sel_open, url, options, @ptrCast(&settings_blocks[ticket.index]));
544
545 _ = self;
546 }
481547};
482548
483549/// The answer for a full block pool. `BridgeError` has no "Busy";
@@ -672,12 +738,144 @@ fn mediaTypeVideo(allocator: std.mem.Allocator) !Id {
672738// someone's screen).
673739// =============================================================================
674740
741// ===========================================================================
742// openSettings
743//
744// Held back until now for a reason recorded at the top of this file: its reply
745// is a bare JSON *boolean*, and `ios_async.boolBlock` answers the strings
746// `"granted"`/`"denied"`. That mismatch is not cosmetic — `false` is falsy and
747// `"denied"` is truthy, so `if (await craft.permissions.openSettings())` would
748// take its success branch on failure.
749//
750// The note said claiming it meant first extending `ios_async` with a
751// bool-literal reply. It did not: the module-owned global block that
752// `bridge_mobile_auth.zig` and `bridge_mobile_siri.zig` now use answers through
753// `deliverJson`, which can carry any literal. What the note was really
754// objecting to is still true and still avoided here — "the repo's last
755// hand-rolled completion block was stack-allocated with a doc comment asserting
756// a lifetime async does not honour" (`bridge_mobile_system.zig:391`). That one
757// survives only because `openURL:` copies an escaping block, which is the
758// callee's choice to make and not a guarantee this file wants to depend on.
759// Every block below is global, so `Block_copy` is identity and the question
760// does not arise.
761// ===========================================================================
762
763/// `UIApplication.openSettingsURLString`.
764///
765/// An `extern NSString * const` in UIKit, read through its symbol rather than
766/// rebuilt from the literal `"app-settings:"`. The constant's *value* is not
767/// API — Apple has changed it before — and a hardcoded string that stops
768/// matching would open nothing while still reporting whatever `openURL:`
769/// said about a URL iOS does not recognise.
770fn settingsUrlString() ?objc.id {
771 const symbol = dlsym(RTLD_DEFAULT, "UIApplicationOpenSettingsURLString") orelse {
772 std.log.warn(
773 "openSettings: UIApplicationOpenSettingsURLString is not in this process",
774 .{},
775 );
776 return null;
777 };
778 const cell: *const objc.id = @ptrCast(@alignCast(symbol));
779 return cell.*;
780}
781
782/// The two literals the completion can put on the wire.
783///
784/// Swift's `resolveCallback(callbackId, result: opened)` under
785/// `.fragmentsAllowed`, so the page receives a bare boolean and not an object.
786const opened_reply = "true";
787const not_opened_reply = "false";
788
789const SettingsBlockDescriptor = extern struct {
790 reserved: c_ulong = 0,
791 size: c_ulong,
792};
793
794/// `void (^)(BOOL)`.
795const SettingsBlock = extern struct {
796 isa: ?*anyopaque,
797 flags: c_int,
798 reserved: c_int = 0,
799 invoke: *const anyopaque,
800 descriptor: *const SettingsBlockDescriptor,
801};
802
803/// 1 << 28. A global block is never copied, so it can be handed to an API that
804/// escapes it with no heap copy and no descriptor lifetime.
805const SETTINGS_BLOCK_IS_GLOBAL: c_int = 1 << 28;
806
807const settings_block_descriptor = SettingsBlockDescriptor{ .size = @sizeOf(SettingsBlock) };
808
809extern var _NSConcreteGlobalBlock: anyopaque;
810
811fn makeSettingsInvoke(comptime index: u5) *const anyopaque {
812 const S = struct {
813 fn invoke(_: *const SettingsBlock, opened: bool) callconv(.c) void {
814 settingsOpened(index, opened);
815 }
816 };
817 return @ptrCast(&S.invoke);
818}
819
820fn makeSettingsBlocks() [ios_async.max_in_flight]SettingsBlock {
821 var out: [ios_async.max_in_flight]SettingsBlock = undefined;
822 for (&out, 0..) |*b, i| {
823 b.* = .{
824 .isa = &_NSConcreteGlobalBlock,
825 .flags = SETTINGS_BLOCK_IS_GLOBAL,
826 .invoke = makeSettingsInvoke(@intCast(i)),
827 .descriptor = &settings_block_descriptor,
828 };
829 }
830 return out;
831}
832
833var settings_blocks: [ios_async.max_in_flight]SettingsBlock =
834 if (is_darwin) makeSettingsBlocks() else undefined;
835
836/// The ticket each slot's block will answer.
837var settings_calls: [ios_async.max_in_flight]?ios_async.Ticket = @splat(null);
838var settings_mutex: compat_mutex.Mutex = .{};
839
840fn publishSettingsCall(ticket: ios_async.Ticket) void {
841 settings_mutex.lock();
842 defer settings_mutex.unlock();
843 settings_calls[ticket.index] = ticket;
844}
845
846/// Read and clear, so a second fire is a no-op rather than a second reply.
847fn takeSettingsCall(index: u5) ?ios_async.Ticket {
848 settings_mutex.lock();
849 defer settings_mutex.unlock();
850 const ticket = settings_calls[index];
851 settings_calls[index] = null;
852 return ticket;
853}
854
855/// UIKit calls this on the main queue, but `ios_async` hops anyway — the reply
856/// path is the same one every other module uses and does not special-case its
857/// caller's queue.
858fn settingsOpened(index: u5, opened: bool) void {
859 if (!is_darwin) return;
860
861 const ticket = takeSettingsCall(index) orelse {
862 std.log.warn(
863 "openSettings completion fired for slot {d} with no call recorded; ignored",
864 .{index},
865 );
866 return;
867 };
868 // A bare literal, not "granted"/"denied": `false` has to stay falsy.
869 ios_async.deliverJson(ticket, if (opened) opened_reply else not_opened_reply);
870}
871
675872const testing = std.testing;
676873
677874test "the declared actions are the ones the handler serves" {
678 try testing.expectEqual(@as(usize, 2), capability_actions.len);
875 try testing.expectEqual(@as(usize, 3), capability_actions.len);
679876 try testing.expectEqualStrings(A.check_permission, capability_actions[0].name);
680877 try testing.expectEqualStrings(A.request_permission, capability_actions[1].name);
878 try testing.expectEqualStrings(A.open_settings, capability_actions[2].name);
681879
682880 for (capability_actions) |decl| {
683881 try testing.expectEqual(capabilities.Reply.result, decl.reply);
@@ -690,6 +888,7 @@ test "the action names are the ones the Swift dispatcher answers" {
690888 // spec lacks; it cannot catch both being renamed in step, which this holds.
691889 try testing.expectEqualStrings("checkPermission", A.check_permission);
692890 try testing.expectEqualStrings("requestPermission", A.request_permission);
891 try testing.expectEqualStrings("openSettings", A.open_settings);
693892}
694893
695894test "every declared action is one the dispatcher routes" {
@@ -717,22 +916,45 @@ test "every route the dispatcher has is a declared action" {
717916 }
718917}
719918
720test "openSettings is left to the Swift shim on purpose" {
721 // The pin for the module comment's biggest decision. Its reply is a bare
722 // boolean, `ios_async.boolBlock` replies "granted"/"denied", and "denied"
723 // is truthy — a page's `if (await openSettings())` would take the success
724 // branch on failure. Whoever claims this action deletes this test
725 // *after* giving ios_async a boolean-literal reply, not before.
726 var bridge = PermissionsBridge.init(testing.allocator);
727 defer bridge.deinit();
919test "openSettings answers with a bare boolean, never granted/denied" {
920 // The pin for the decision this module used to record the other way. Its
921 // reply is a bare boolean, `ios_async.boolBlock` replies "granted"/"denied",
922 // and "denied" is truthy — a page's `if (await openSettings())` would take
923 // the success branch on failure. That is why the completion below is the
924 // module's own block delivering a literal, and why these two strings are
925 // pinned rather than left to whoever edits the handler next.
926 try testing.expectEqualStrings("true", opened_reply);
927 try testing.expectEqualStrings("false", not_opened_reply);
928
929 // And it is served here now, rather than handed to the shim.
930 try testing.expect(routeFor("openSettings") != null);
931}
728932
729 try testing.expectError(
730 bridge_error.BridgeError.UnknownAction,
731 bridge.handleMessage("openSettings", "{}"),
732 );
733 for (capability_actions) |decl| {
734 try testing.expect(!std.mem.eql(u8, decl.name, "openSettings"));
933test "each openSettings block is global and has its own invoke" {
934 // Global, not the stack-allocated shape `bridge_mobile_system.zig:391`
935 // uses: a stack block is only safe because `openURL:` chooses to copy an
936 // escaping handler, and that is the callee's choice rather than a
937 // guarantee. `Block_copy` on a global block is identity, so the question
938 // does not arise.
939 if (!is_darwin) return error.SkipZigTest;
940
941 for (&settings_blocks) |*b| {
942 try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa);
943 try testing.expectEqual(SETTINGS_BLOCK_IS_GLOBAL, b.flags);
944 try testing.expectEqual(@sizeOf(SettingsBlock), @as(usize, @intCast(b.descriptor.size)));
735945 }
946 try testing.expect(settings_blocks[0].invoke != settings_blocks[1].invoke);
947}
948
949test "an openSettings completion for a slot with no recorded call is ignored" {
950 if (!is_darwin) return error.SkipZigTest;
951
952 settings_mutex.lock();
953 for (&settings_calls) |*entry| entry.* = null;
954 settings_mutex.unlock();
955
956 settingsOpened(0, true);
957 settingsOpened(0, false);
736958}
737959
738960test "an action the namespace does not serve is reported, not ignored" {
@@ -768,10 +990,14 @@ test "every declared action dispatches to something" {
768990 defer bridge.deinit();
769991
770992 for (capability_actions) |decl| {
771 try testing.expectError(
772 bridge_error.BridgeError.MissingData,
773 bridge.handleMessage(decl.name, "{}"),
774 );
993 // `openSettings` takes no payload, so it gets past validation and
994 // refuses on the host's missing UIKit instead. `UnknownAction` is the
995 // only forbidden outcome — that would mean a declared name the
996 // handler never compares against.
997 bridge.handleMessage(decl.name, "{}") catch |err| {
998 try testing.expect(err != bridge_error.BridgeError.UnknownAction);
999 continue;
1000 };
7751001 }
7761002}
7771003
packages/zig/src/bridge_mobile_watch.zigmodified+363-56
Changes to packages/zig/src/bridge_mobile_watch.zig
@@ -133,57 +133,50 @@
133133//! hypothetical Zig-only app it would not, and `isWatchReachable` would report
134134//! `false` forever — honest, and worth knowing.
135135//!
136//! ## Why `sendToWatch` is absent rather than `.unavailable`
136//! ## `sendToWatch`, and the condition that unblocked it
137137//!
138//! `-[WCSession sendMessage:replyHandler:errorHandler:]` takes two
139//! one-argument blocks, and `WCSession.h` guarantees that **exactly one of them
140//! is invoked** and that both fire on "a non-main serial queue". Zig can build
141//! the reply-handler half: a global block plus `ios_async.deliverJson` would
142//! resolve the watch's dictionary correctly.
143//!
144//! It cannot build the error half. `ios_async` has no error-delivery path —
145//! `deliverOnMain` ends unconditionally in `bridge_error.sendResultToJS`, and
146//! `sendErrorToJS` appears nowhere in that file. Every available move is a rule
147//! violation:
138//! This section used to explain why the action was absent. The explanation
139//! ended with a precise unblocking condition, and that condition has been met,
140//! so the reasoning is kept and the conclusion inverted.
148141//!
149//! - resolve something on the error path → fabricated success;
150//! - `ios_async.abandon` the ticket → silence on an untimed promise, the exact
151//! bug `ios_async` exists to prevent;
152//! - call `craft_ios_deliver_error` from the block → that export does not hop
153//! to the main queue, so it would reach `evaluateJavaScript` from
154//! WatchConnectivity's background delegate queue.
142//! `-[WCSession sendMessage:replyHandler:errorHandler:]` takes two
143//! one-argument blocks and `WCSession.h` guarantees **exactly one of them is
144//! invoked**, both on a non-main serial queue. The reply half was always
145//! expressible — a global block plus `ios_async.deliverJson`. The error half
146//! was not: `ios_async.deliverOnMain` ended unconditionally in
147//! `sendResultToJS`, so every available move broke a rule. Resolving on the
148//! error path is fabricated success; `abandon` is silence on an untimed
149//! promise; `craft_ios_deliver_error` does not hop to the main queue and would
150//! reach `evaluateJavaScript` from WatchConnectivity's background queue.
155151//!
156//! And the error path is the *common* path here, not the exotic one: unpaired
157//! simulator, watch app not installed, not reachable, reply timed out. The
158//! bundled `CraftWatchApp.swift.template` implements only
152//! That mattered more than it sounds, because **the error path is the common
153//! one here**: unpaired simulator, watch app not installed, not reachable,
154//! reply timed out. The bundled `CraftWatchApp.swift.template` implements only
159155//! `session(_:didReceiveMessage:)` — the no-reply variant — so against the
160//! shipped companion the reply handler never fires and the error handler always
161//! does.
156//! shipped companion the reply handler never fires and the error handler
157//! always does.
158//!
159//! `ios_async.deliverError` and `deliverErrorCode` now exist, which is exactly
160//! the condition the old note named. What it also predicted is implemented
161//! here: the reply goes through `+[NSJSONSerialization isValidJSONObject:]`
162//! before serialising. Swift does not — `resolveCallback` hands the reply
163//! straight to `dataWithJSONObject:`, which *raises*
164//! `NSInvalidArgumentException` for the `NSDate`/`NSData` values `WCSession`
165//! permits, and Swift's `catch` cannot catch a raise. A watch replying
166//! `["at": Date()]` terminates the app today; this refuses instead.
162167//!
163//! The Swift shim serves all of this correctly today: its reply handler routes
164//! through `deliverResultIfHandOff` and its error handler through
165//! `deliverErrorIfHandOff`, rejecting the page's promise with the real
166//! `localizedDescription`. Declaring the action `.unavailable` would *steal* it
167//! from that shim and turn every call into a refusal, which is strictly worse
168//! than what ships now. So the action is omitted from `A` entirely and falls
169//! through.
168//! The remaining divergence is the one every migrated action carries: Swift
169//! rejects with the error's `localizedDescription`, and the protocol has no
170//! message field, so the text goes to the log and the page gets a code.
170171//!
171//! Unblocking condition, precisely: `sendToWatch` becomes migratable the moment
172//! `ios_async` gains a `deliverError(ticket, BridgeError)` — a slot field plus
173//! one branch in `deliverOnMain` — at which point it still costs the
174//! `localizedDescription` text, and must additionally call
175//! `+[NSJSONSerialization isValidJSONObject:]` before serialising the watch's
176//! reply. Swift does not: `resolveCallback` hands the reply straight to
177//! `dataWithJSONObject:`, which *raises* `NSInvalidArgumentException` for the
178//! `NSDate`/`NSData` values `WCSession` permits, and Swift's `catch` cannot
179//! catch that. A watch replying `["at": Date()]` aborts the app today; Zig must
180//! not reproduce it.
181172
182173const std = ;
183174const builtin = ;
184175const capabilities = ;
185176const bridge_error = ;
186177const objc_runtime = ;
178const ios_async = ;
179const compat_mutex = ;
187180
188181const objc = objc_runtime.objc;
189182const is_darwin = builtin.target.os.tag.isDarwin();
@@ -208,6 +201,7 @@ const Id = ?*anyopaque;
208201pub const A = struct {
209202 pub const update_watch_context = "updateWatchContext";
210203 pub const is_watch_reachable = "isWatchReachable";
204 pub const send_to_watch = "sendToWatch";
211205};
212206
213207/// Both `.result`: each Swift path terminates in exactly one
@@ -222,6 +216,7 @@ pub const A = struct {
222216pub const capability_actions = [_]capabilities.ActionDecl{
223217 .{ .name = A.update_watch_context, .reply = .result },
224218 .{ .name = A.is_watch_reachable, .reply = .result },
219 .{ .name = A.send_to_watch, .reply = .result },
225220};
226221
227222// -----------------------------------------------------------------------------
@@ -247,11 +242,12 @@ fn reachableFragment(reachable: bool) []const u8 {
247242/// Which handler an action selects, split out from `handleMessage` so the
248243/// table-versus-dispatch agreement is assertable on a host, where
249244/// `WCSession` does not exist.
250const Route = enum { update_context, is_reachable };
245const Route = enum { update_context, is_reachable, send_message };
251246
252247fn routeFor(action: []const u8) ?Route {
253248 if (std.mem.eql(u8, action, A.update_watch_context)) return .update_context;
254249 if (std.mem.eql(u8, action, A.is_watch_reachable)) return .is_reachable;
250 if (std.mem.eql(u8, action, A.send_to_watch)) return .send_message;
255251 return null;
256252}
257253
@@ -272,7 +268,69 @@ pub const WatchBridge = struct {
272268 return switch (route) {
273269 .update_context => self.updateWatchContext(data),
274270 .is_reachable => self.isWatchReachable(),
271 .send_message => self.sendToWatch(data),
272 };
273 }
274
275 /// Send a message to the paired watch and answer with its reply.
276 ///
277 /// The action this module recorded as unservable, with the unblocking
278 /// condition stated exactly: *"becomes migratable the moment `ios_async`
279 /// gains a `deliverError(ticket, BridgeError)`"*. It has one
280 /// (`ios_async.zig:337`, plus `deliverErrorCode` at `:318`), so the error
281 /// half of the pair is now expressible and the action moves.
282 ///
283 /// `WCSession.h` guarantees exactly one of the two handlers is invoked,
284 /// and that both fire on a non-main serial queue. Neither replies from
285 /// where it runs: `ios_async` does the hop.
286 fn sendToWatch(self: *Self, data: []const u8) !void {
287 var parsed = try parsePayload(self.allocator, data);
288 defer parsed.deinit();
289
290 const message = try parseMessage(parsed.value);
291
292 const session = switch (try resolveSession()) {
293 .active => |s| s,
294 .absent => |why| {
295 std.log.warn(
296 "sendToWatch: no usable WCSession ({s}); refusing rather than reporting a message that was never sent",
297 .{@tagName(why)},
298 );
299 return bridge_error.BridgeError.NotFound;
300 },
301 };
302
303 // Swift's `guard let session = wcSession, session.isReachable`. On a
304 // simulator with no paired watch this is the only branch reachable,
305 // which is also the only branch Swift can reach there.
306 if (!readIsReachable(session)) {
307 std.log.warn("sendToWatch: the watch is not reachable", .{});
308 return bridge_error.BridgeError.NotFound;
309 }
310
311 const dictionary = try toFoundationDictionary(self.allocator, message);
312
313 const ticket = ios_async.acquire(A.send_to_watch) orelse {
314 std.log.warn(
315 "sendToWatch: no free reply slot; {d} native calls are already awaiting one",
316 .{ios_async.max_in_flight},
317 );
318 return bridge_error.BridgeError.NativeCallFailed;
275319 };
320 errdefer ios_async.abandon(ticket);
321 publishWatchCall(ticket);
322
323 const sel = objc.sel_registerName("sendMessage:replyHandler:errorHandler:") orelse
324 return error.SelectorNotFound;
325 const SendFn = *const fn (*anyopaque, objc.SEL, *anyopaque, *anyopaque, *anyopaque) callconv(.c) void;
326 const send: SendFn = @ptrCast(&objc.objc_msgSend);
327 send(
328 session,
329 sel,
330 dictionary,
331 @ptrCast(&watch_reply_blocks[ticket.index]),
332 @ptrCast(&watch_error_blocks[ticket.index]),
333 );
276334 }
277335
278336 /// Push a new application context to the paired watch.
@@ -704,12 +762,212 @@ fn readNSInteger(object: Id, comptime selector: [*:0]const u8) c_long {
704762// session, that would push a context to somebody's actual watch.
705763// =============================================================================
706764
765/// The `message` field of `sendToWatch`'s payload.
766///
767/// Swift's `body["message"] as? [String: Any]` with no `else` in the
768/// dispatcher: a missing or non-object message replies nothing and the page
769/// waits out its timeout. Refused here instead, and not coerced —
770/// `sendMessage:` takes a dictionary, and wrapping a string in one would send
771/// the watch a message the page never wrote.
772fn parseMessage(payload: std.json.Value) !std.json.Value {
773 const object = switch (payload) {
774 .object => |o| o,
775 else => return bridge_error.BridgeError.InvalidJSON,
776 };
777 const field = object.get("message") orelse return bridge_error.BridgeError.MissingData;
778 return switch (field) {
779 .object => field,
780 else => bridge_error.BridgeError.InvalidParameter,
781 };
782}
783
784// ===========================================================================
785// The two completion handlers
786//
787// `-[WCSession sendMessage:replyHandler:errorHandler:]` takes two
788// one-argument blocks and guarantees exactly one is invoked. Both are global
789// with one comptime invoke per slot, and both answer through `ios_async` —
790// which is what makes this action expressible at all. The module's own
791// unblocking note named the missing piece precisely, and `deliverErrorCode`
792// is it.
793// ===========================================================================
794
795/// The ticket each slot's pair of blocks will answer.
796var watch_calls: [ios_async.max_in_flight]?ios_async.Ticket = @splat(null);
797var watch_mutex: compat_mutex.Mutex = .{};
798
799fn publishWatchCall(ticket: ios_async.Ticket) void {
800 watch_mutex.lock();
801 defer watch_mutex.unlock();
802 watch_calls[ticket.index] = ticket;
803}
804
805/// Read and clear. Clearing is what makes the *other* half of the pair a
806/// no-op if WatchConnectivity ever broke its exactly-one guarantee — two
807/// replies for one call is worse than none.
808fn takeWatchCall(index: u5) ?ios_async.Ticket {
809 watch_mutex.lock();
810 defer watch_mutex.unlock();
811 const ticket = watch_calls[index];
812 watch_calls[index] = null;
813 return ticket;
814}
815
816const WatchBlockDescriptor = extern struct {
817 reserved: c_ulong = 0,
818 size: c_ulong,
819};
820
821/// `void (^)(id)` — a reply dictionary or an NSError, depending which half.
822const WatchBlock = extern struct {
823 isa: ?*anyopaque,
824 flags: c_int,
825 reserved: c_int = 0,
826 invoke: *const anyopaque,
827 descriptor: *const WatchBlockDescriptor,
828};
829
830/// 1 << 28 — a global block is never copied.
831const WATCH_BLOCK_IS_GLOBAL: c_int = 1 << 28;
832
833const watch_block_descriptor = WatchBlockDescriptor{ .size = @sizeOf(WatchBlock) };
834
835extern var _NSConcreteGlobalBlock: anyopaque;
836
837fn makeWatchReplyInvoke(comptime index: u5) *const anyopaque {
838 const S = struct {
839 fn invoke(_: *const WatchBlock, reply: ?*anyopaque) callconv(.c) void {
840 watchReplied(index, reply);
841 }
842 };
843 return @ptrCast(&S.invoke);
844}
845
846fn makeWatchErrorInvoke(comptime index: u5) *const anyopaque {
847 const S = struct {
848 fn invoke(_: *const WatchBlock, err: ?*anyopaque) callconv(.c) void {
849 watchFailed(index, err);
850 }
851 };
852 return @ptrCast(&S.invoke);
853}
854
855fn makeWatchBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_in_flight]WatchBlock {
856 var out: [ios_async.max_in_flight]WatchBlock = undefined;
857 for (&out, 0..) |*b, i| {
858 b.* = .{
859 .isa = &_NSConcreteGlobalBlock,
860 .flags = WATCH_BLOCK_IS_GLOBAL,
861 .invoke = maker(@intCast(i)),
862 .descriptor = &watch_block_descriptor,
863 };
864 }
865 return out;
866}
867
868var watch_reply_blocks: [ios_async.max_in_flight]WatchBlock =
869 if (is_darwin) makeWatchBlocks(makeWatchReplyInvoke) else undefined;
870var watch_error_blocks: [ios_async.max_in_flight]WatchBlock =
871 if (is_darwin) makeWatchBlocks(makeWatchErrorInvoke) else undefined;
872
873/// The watch answered. Serialise its dictionary and resolve.
874fn watchReplied(index: u5, reply: ?*anyopaque) void {
875 if (!is_darwin) return;
876
877 const ticket = takeWatchCall(index) orelse {
878 std.log.warn("sendToWatch reply fired for slot {d} with no call recorded; ignored", .{index});
879 return;
880 };
881
882 const allocator = std.heap.c_allocator;
883 const json = serialiseWatchReply(allocator, reply) catch |err| {
884 std.log.warn("sendToWatch: the watch's reply could not be serialised: {}", .{err});
885 ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed);
886 return;
887 };
888 defer allocator.free(json);
889
890 ios_async.deliverJson(ticket, json);
891}
892
893/// The watch, or WatchConnectivity, refused.
894fn watchFailed(index: u5, err: ?*anyopaque) void {
895 if (!is_darwin) return;
896
897 const ticket = takeWatchCall(index) orelse {
898 std.log.warn("sendToWatch error fired for slot {d} with no call recorded; ignored", .{index});
899 return;
900 };
901
902 // Swift rejects with `error.localizedDescription`. The protocol has no
903 // message field, so the description goes to the log — where "watch app not
904 // installed" and "reply timed out" are different enough to act on.
905 if (err) |ns_error| {
906 const sel = objc.sel_registerName("localizedDescription");
907 if (sel) |description_sel| {
908 if (objc.msgSendId(ns_error, description_sel)) |ns_description| {
909 if (objc.getNSStringUTF8(ns_description)) |utf8| {
910 std.log.warn("sendToWatch: {s}", .{std.mem.span(utf8)});
911 }
912 }
913 }
914 }
915 ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed);
916}
917
918/// The watch's reply dictionary as JSON bytes.
919///
920/// `+[NSJSONSerialization isValidJSONObject:]` first, which Swift does not do.
921/// `resolveCallback` hands the reply straight to `dataWithJSONObject:`, and
922/// that *raises* `NSInvalidArgumentException` for the `NSDate` and `NSData`
923/// values `WCSession` explicitly permits in a message. An ObjC exception is an
924/// uncatchable abort from Zig and Swift's `catch` cannot catch it either, so a
925/// watch replying `["at": Date()]` terminates the app today. Asking first turns
926/// that into a refusal.
927fn serialiseWatchReply(allocator: std.mem.Allocator, reply: ?*anyopaque) ![]u8 {
928 const dictionary = reply orelse return error.NoReply;
929
930 const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse
931 return error.ClassNotFound;
932
933 const sel_valid = objc.sel_registerName("isValidJSONObject:") orelse return error.SelectorNotFound;
934 const ValidFn = *const fn (objc.Class, objc.SEL, *anyopaque) callconv(.c) bool;
935 const isValid: ValidFn = @ptrCast(&objc.objc_msgSend);
936 if (!isValid(NSJSONSerialization, sel_valid, dictionary)) {
937 std.log.warn(
938 "sendToWatch: the watch replied a dictionary JSON cannot represent " ++
939 "(WCSession permits NSDate and NSData, which dataWithJSONObject: raises on)",
940 .{},
941 );
942 return error.UnrepresentableReply;
943 }
944
945 const sel_data = objc.sel_registerName("dataWithJSONObject:options:error:") orelse
946 return error.SelectorNotFound;
947 const DataFn = *const fn (objc.Class, objc.SEL, *anyopaque, c_ulong, ?*?*anyopaque) callconv(.c) ?*anyopaque;
948 const dataWith: DataFn = @ptrCast(&objc.objc_msgSend);
949 const ns_data = dataWith(NSJSONSerialization, sel_data, dictionary, 0, null) orelse
950 return error.NativeCallFailed;
951
952 const sel_bytes = objc.sel_registerName("bytes") orelse return error.SelectorNotFound;
953 const sel_length = objc.sel_registerName("length") orelse return error.SelectorNotFound;
954 const BytesFn = *const fn (*anyopaque, objc.SEL) callconv(.c) ?[*]const u8;
955 const LengthFn = *const fn (*anyopaque, objc.SEL) callconv(.c) c_ulong;
956 const bytesFn: BytesFn = @ptrCast(&objc.objc_msgSend);
957 const lengthFn: LengthFn = @ptrCast(&objc.objc_msgSend);
958
959 const length: usize = @intCast(lengthFn(ns_data, sel_length));
960 const bytes = bytesFn(ns_data, sel_bytes) orelse return error.NativeCallFailed;
961 return allocator.dupe(u8, bytes[0..length]);
962}
963
707964const testing = std.testing;
708965
709966test "the declared actions are the ones the handler serves" {
710 try testing.expectEqual(@as(usize, 2), capability_actions.len);
967 try testing.expectEqual(@as(usize, 3), capability_actions.len);
711968 try testing.expectEqualStrings(A.update_watch_context, capability_actions[0].name);
712969 try testing.expectEqualStrings(A.is_watch_reachable, capability_actions[1].name);
970 try testing.expectEqualStrings(A.send_to_watch, capability_actions[2].name);
713971
714972 for (capability_actions) |decl| {
715973 try testing.expectEqual(capabilities.Reply.result, decl.reply);
@@ -763,27 +1021,76 @@ test "every route the dispatcher has is a declared action" {
7631021 }
7641022}
7651023
766test "sendToWatch is left to the Swift shim, not claimed and not refused" {
767 // The deliberate omission, asserted so it cannot be "fixed" by accident.
768 //
769 // `ios_dispatch`'s chain treats UnknownAction as "not mine, ask the next"
770 // and any other error as final. So the *only* spelling of "let the shim
771 // keep serving this" is: absent from `A`, absent from `routeFor`, and
772 // UnknownAction out of `handleMessage`. An `.unavailable` declaration
773 // would dispatch and refuse, stealing an action the shim answers correctly
774 // today — including the error path, which `ios_async` cannot yet deliver.
1024test "sendToWatch is served now, and its unblocking condition really was met" {
1025 // This test used to assert the opposite, and the module comment named the
1026 // exact condition that would flip it: "becomes migratable the moment
1027 // `ios_async` gains a `deliverError(ticket, BridgeError)`". Both halves of
1028 // that now exist, so the claim is pinned here rather than trusted — a
1029 // module that lost the error path again would go back to fabricating
1030 // success on the error branch, which is the common branch for this action.
1031 try testing.expect(@hasDecl(ios_async, "deliverError"));
1032 try testing.expect(@hasDecl(ios_async, "deliverErrorCode"));
1033
7751034 var bridge = WatchBridge.init(testing.allocator);
7761035 defer bridge.deinit();
1036 try testing.expect(routeFor("sendToWatch") != null);
1037}
7771038
778 try testing.expect(routeFor("sendToWatch") == null);
779 try testing.expectError(
780 bridge_error.BridgeError.UnknownAction,
781 bridge.handleMessage("sendToWatch", "{\"message\":{\"action\":\"ping\"}}"),
1039test "a message is required, and is never coerced into one" {
1040 // Swift's `body["message"] as? [String: Any]` with no else replies nothing
1041 // at all. Wrapping a non-object would send the watch a message the page
1042 // never wrote.
1043 const object = try std.json.parseFromSlice(
1044 std.json.Value,
1045 testing.allocator,
1046 "{\"message\":{\"action\":\"ping\"}}",
1047 .{},
7821048 );
1049 defer object.deinit();
1050 _ = try parseMessage(object.value);
7831051
784 for (capability_actions) |decl| {
785 try testing.expect(!std.mem.eql(u8, decl.name, "sendToWatch"));
1052 try expectMessageRefused("{}", bridge_error.BridgeError.MissingData);
1053 try expectMessageRefused("{\"message\":\"ping\"}", bridge_error.BridgeError.InvalidParameter);
1054 try expectMessageRefused("{\"message\":[1]}", bridge_error.BridgeError.InvalidParameter);
1055 try expectMessageRefused("{\"message\":null}", bridge_error.BridgeError.InvalidParameter);
1056}
1057
1058/// Parse `json` and assert `parseMessage` refuses it with exactly `expected`.
1059fn expectMessageRefused(comptime json: []const u8, expected: anyerror) !void {
1060 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1061 defer parsed.deinit();
1062 try testing.expectError(expected, parseMessage(parsed.value));
1063}
1064
1065test "both watch blocks are global, distinct per slot, and distinct from each other" {
1066 if (!is_darwin) return error.SkipZigTest;
1067
1068 for (&watch_reply_blocks, &watch_error_blocks) |*reply, *fail| {
1069 try testing.expectEqual(&_NSConcreteGlobalBlock, reply.isa);
1070 try testing.expectEqual(&_NSConcreteGlobalBlock, fail.isa);
1071 try testing.expectEqual(WATCH_BLOCK_IS_GLOBAL, reply.flags);
1072 try testing.expectEqual(WATCH_BLOCK_IS_GLOBAL, fail.flags);
1073 // The pair must not share an invoke: `sendMessage:` hands them to two
1074 // different parameters, and one shared implementation would resolve
1075 // the page's promise on the error path.
1076 try testing.expect(reply.invoke != fail.invoke);
7861077 }
1078 try testing.expect(watch_reply_blocks[0].invoke != watch_reply_blocks[1].invoke);
1079 try testing.expect(watch_error_blocks[0].invoke != watch_error_blocks[1].invoke);
1080}
1081
1082test "only one of the pair can answer a call" {
1083 // `WCSession.h` guarantees exactly one handler is invoked. Clearing the
1084 // slot on take is what holds the line if that guarantee ever slipped: two
1085 // replies for one page call is worse than none.
1086 if (!is_darwin) return error.SkipZigTest;
1087
1088 watch_mutex.lock();
1089 for (&watch_calls) |*entry| entry.* = null;
1090 watch_mutex.unlock();
1091
1092 watchReplied(0, null);
1093 watchFailed(0, null);
7871094}
7881095
7891096test "an action the namespace does not serve is reported, not ignored" {
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -412,6 +412,7 @@ pub fn gateFor(action: []const u8) ?Feature {
412412 .{ "dbQuery", .local_database },
413413 .{ "deleteCalendarEvent", .calendar },
414414 .{ "detectObjects", .ml_kit },
415 .{ "downloadFile", .file_download },
415416 .{ "getCalendarEvents", .calendar },
416417 .{ "getContacts", .contacts },
417418 .{ "getCurrentPosition", .geolocation },
packages/zig/test/ios_conformance_test.zigmodified+1-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -97,7 +97,7 @@ const dispatch_end = "func webView(";
9797/// 48 after calendar and contacts. Speech was researched in full and left
9898/// with the shim: neither action replies, so unlike the earlier deferrals
9999/// there is no promise to strand and falling through costs the page nothing.
100const max_not_yet_migrated: usize = 38;
100const max_not_yet_migrated: usize = 35;
101101
102102fn dispatcherRegion() []const u8 {
103103 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";