also looking at this
iOS: retire three stale refusals — ratchet 38 → 35
#96
8 files
+1036
-100
| @@ -64,6 +64,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \ | ||
| 64 | 64 | "$ROOT/packages/zig/zig-out/lib/$LIB" \ |
| 65 | 65 | -framework UIKit -framework WebKit -framework Foundation -framework Security \ |
| 66 | 66 | -framework Vision -framework LocalAuthentication -framework PDFKit \ |
| 67 | -framework WatchConnectivity \ | |
| 67 | 68 | -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \ |
| 68 | 69 | -o "$APP/CraftSlice" |
| 69 | 70 | |
| @@ -298,4 +299,25 @@ C44="$(count 44)" | ||
| 298 | 299 | [ "$C44" -ge 1 ] || { echo "FAIL: closePDF did not dismiss the viewer"; exit 1; } |
| 299 | 300 | echo "ok: the same module that presented the viewer dismissed it (i=44 seen ${C44}x)" |
| 300 | 301 | |
| 302 | C48="$(count 48)" | |
| 303 | [ "$C48" -ge 1 ] || { echo "FAIL: downloadFile did not land a file in Documents"; exit 1; } | |
| 304 | echo "ok: URL fetched and moved into the app container (i=48 seen ${C48}x)" | |
| 305 | ||
| 306 | C52="$(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; } | |
| 312 | echo "ok: watch refusal delivered through the async error path (i=52 seen ${C52}x)" | |
| 313 | ||
| 314 | C54="$(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; } | |
| 321 | echo "ok: openSettings replied a real boolean, not \"granted\"/\"denied\" (i=54 seen ${C54}x)" | |
| 322 | ||
| 301 | 323 | echo "PASS" |
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | "enableDeepLinks": true, |
| 22 | 22 | "enableQRScanner": false, |
| 23 | 23 | "enableFilePicker": false, |
| 24 | "enableFileDownload": false, | |
| 24 | "enableFileDownload": true, | |
| 25 | 25 | "enableSocialAuth": false, |
| 26 | 26 | "enableAudioRecording": false, |
| 27 | 27 | "enableVideoRecording": false, |
| @@ -50,6 +50,7 @@ const char craft_slice_page[] = | ||
| 50 | 50 | "var sentOcr = false, sentClassify = false, sentDetect = false;\n" |
| 51 | 51 | "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n" |
| 52 | 52 | "var sentPdfOpen = false, sentPdfClose = false;\n" |
| 53 | "var sentDownload = false, sentWatch = false, sentSettings = false;\n" | |
| 53 | 54 | "window.__craftBridgeError = function (ctx) {\n" |
| 54 | 55 | " // The message crossed Zig's escaping with a backslash, quote, and\n" |
| 55 | 56 | " // newline intact; anything mangled and this equality fails, i=7 never\n" |
| @@ -73,6 +74,14 @@ const char craft_slice_page[] = | ||
| 73 | 74 | " // and not a missing-class refusal: PERMISSION_DENIED means\n" |
| 74 | 75 | " // canEvaluatePolicy said no. Before this phase the action was gated in\n" |
| 75 | 76 | " // 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" | |
| 76 | 85 | " if (!sentAuth && ctx && ctx.error === true && ctx.id === 95\n" |
| 77 | 86 | " && ctx.code === 'PERMISSION_DENIED') {\n" |
| 78 | 87 | " sentAuth = true;\n" |
| @@ -162,6 +171,18 @@ const char craft_slice_page[] = | ||
| 162 | 171 | " // come back over the background-queue hop with its ticket intact.\n" |
| 163 | 172 | " // The reply echoes both fields the page sent, so it proves the payload\n" |
| 164 | 173 | " // 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" | |
| 165 | 186 | " if (!sentPdfOpen && action === 'openPDF' && payload\n" |
| 166 | 187 | " && payload.opened === true && payload.pageCount === 1) {\n" |
| 167 | 188 | " sentPdfOpen = true;\n" |
| @@ -285,10 +306,21 @@ const char craft_slice_page[] = | ||
| 285 | 306 | " PDF += 'biAKMDAwMDAwMDA1NCAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwv';\n" |
| 286 | 307 | " PDF += 'U2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTcwCiUlRU9GCg==';\n" |
| 287 | 308 | " 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" | |
| 288 | 317 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 289 | 318 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 290 | 319 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| 291 | 320 | " 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" | |
| 292 | 324 | "} else {\n" |
| 293 | 325 | " document.getElementById('s').textContent = 'NO BRIDGE';\n" |
| 294 | 326 | "}\n" |
| @@ -266,18 +266,16 @@ const Id = ?*anyopaque; | ||
| 266 | 266 | pub const A = struct { |
| 267 | 267 | pub const pick_file = "pickFile"; |
| 268 | 268 | pub const save_file = "saveFile"; |
| 269 | pub const download_file = "downloadFile"; | |
| 269 | 270 | }; |
| 270 | 271 | |
| 271 | /// The third action of this family, and on purpose not served. | |
| 272 | /// Nothing in this family is unserved any more. | |
| 272 | 273 | /// |
| 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. | |
| 278 | pub 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. | |
| 278 | pub const deliberately_unserved = [_][]const u8{}; | |
| 281 | 279 | |
| 282 | 280 | /// Both `.result`: each Swift path terminates in exactly one `resolveCallback` |
| 283 | 281 | /// or one `rejectCallback`, and the page awaits both. `.none` on either would |
| @@ -290,15 +288,17 @@ pub const deliberately_unserved = [_][]const u8{ | ||
| 290 | 288 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 291 | 289 | .{ .name = A.pick_file, .reply = .result }, |
| 292 | 290 | .{ .name = A.save_file, .reply = .result }, |
| 291 | .{ .name = A.download_file, .reply = .result }, | |
| 293 | 292 | }; |
| 294 | 293 | |
| 295 | 294 | /// Which handler an action selects, split out of `handleMessage` so the |
| 296 | 295 | /// table-versus-dispatch agreement is assertable on a host without UIKit. |
| 297 | const Route = enum { pick_file, save_file }; | |
| 296 | const Route = enum { pick_file, save_file, download_file }; | |
| 298 | 297 | |
| 299 | 298 | fn routeFor(action: []const u8) ?Route { |
| 300 | 299 | if (std.mem.eql(u8, action, A.pick_file)) return .pick_file; |
| 301 | 300 | 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; | |
| 302 | 302 | return null; |
| 303 | 303 | } |
| 304 | 304 | |
| @@ -319,6 +319,7 @@ pub const FilePickerBridge = struct { | ||
| 319 | 319 | return switch (route) { |
| 320 | 320 | .pick_file => self.pickFile(data), |
| 321 | 321 | .save_file => self.saveFile(data), |
| 322 | .download_file => self.downloadFile(data), | |
| 322 | 323 | }; |
| 323 | 324 | } |
| 324 | 325 | |
| @@ -485,6 +486,79 @@ pub const FilePickerBridge = struct { | ||
| 485 | 486 | |
| 486 | 487 | bridge_error.sendResultToJS(self.allocator, A.save_file, json); |
| 487 | 488 | } |
| 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 | } | |
| 488 | 562 | }; |
| 489 | 563 | |
| 490 | 564 | /// 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 { | ||
| 1447 | 1521 | // that a simulator fixture can drive end to end. |
| 1448 | 1522 | // ============================================================================= |
| 1449 | 1523 | |
| 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. | |
| 1529 | fn 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. | |
| 1562 | const PendingDownload = struct { | |
| 1563 | ticket: ios_async.Ticket, | |
| 1564 | /// Owned, `c_allocator`. Freed when the completion fires. | |
| 1565 | filename: []u8, | |
| 1566 | }; | |
| 1567 | ||
| 1568 | var pending_downloads: [ios_async.max_in_flight]?PendingDownload = @splat(null); | |
| 1569 | var download_mutex: compat_mutex.Mutex = .{}; | |
| 1570 | ||
| 1571 | fn 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. | |
| 1579 | fn 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 | ||
| 1587 | const DownloadBlockDescriptor = extern struct { | |
| 1588 | reserved: c_ulong = 0, | |
| 1589 | size: c_ulong, | |
| 1590 | }; | |
| 1591 | ||
| 1592 | const 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. | |
| 1601 | const DOWNLOAD_BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 1602 | ||
| 1603 | const download_block_descriptor = DownloadBlockDescriptor{ .size = @sizeOf(DownloadBlock) }; | |
| 1604 | ||
| 1605 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 1606 | ||
| 1607 | fn 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 | ||
| 1616 | fn 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 | ||
| 1629 | var download_blocks: [ios_async.max_in_flight]DownloadBlock = | |
| 1630 | if (is_darwin) makeDownloadBlocks() else undefined; | |
| 1631 | ||
| 1632 | fn 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. | |
| 1684 | fn 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 | ||
| 1727 | fn 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 | ||
| 1450 | 1735 | const testing = std.testing; |
| 1451 | 1736 | |
| 1452 | 1737 | test "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); | |
| 1454 | 1739 | try testing.expectEqualStrings(A.pick_file, capability_actions[0].name); |
| 1455 | 1740 | try testing.expectEqualStrings(A.save_file, capability_actions[1].name); |
| 1741 | try testing.expectEqualStrings(A.download_file, capability_actions[2].name); | |
| 1456 | 1742 | |
| 1457 | 1743 | for (capability_actions) |decl| { |
| 1458 | 1744 | // 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" { | ||
| 1948 | 2234 | settlePick(.picked, "{}"); |
| 1949 | 2235 | try testing.expect(takePendingPick() == null); |
| 1950 | 2236 | } |
| 2237 | ||
| 2238 | test "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 | ||
| 2248 | test "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 | ||
| 2272 | test "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 | ||
| 2283 | test "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 | ||
| 2295 | test "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 | } | |
| @@ -37,18 +37,23 @@ | ||
| 37 | 37 | //! special case a BOOL cannot carry) and contacts/calendar/reminders |
| 38 | 38 | //! (need a store instance kept alive past the dispatch frame, plus an |
| 39 | 39 | //! 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 | |
| 45 | 45 | //! `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. | |
| 52 | 57 | //! |
| 53 | 58 | //! Returning `UnknownAction` for a payload value is what keeps this honest |
| 54 | 59 | //! without regressing anything: `ios_dispatch.route` treats it as "not mine, |
| @@ -102,6 +107,7 @@ const capabilities = ; | ||
| 102 | 107 | const bridge_error = ; |
| 103 | 108 | const objc_runtime = ; |
| 104 | 109 | const ios_async = ; |
| 110 | const compat_mutex = ; | |
| 105 | 111 | |
| 106 | 112 | const objc = objc_runtime.objc; |
| 107 | 113 | const is_darwin = builtin.target.os.tag.isDarwin(); |
| @@ -124,6 +130,7 @@ const Id = ?*anyopaque; | ||
| 124 | 130 | pub const A = struct { |
| 125 | 131 | pub const check_permission = "checkPermission"; |
| 126 | 132 | pub const request_permission = "requestPermission"; |
| 133 | pub const open_settings = "openSettings"; | |
| 127 | 134 | }; |
| 128 | 135 | |
| 129 | 136 | /// Both `.result`: every Swift path for these two actions terminates in |
| @@ -137,16 +144,18 @@ pub const A = struct { | ||
| 137 | 144 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 138 | 145 | .{ .name = A.check_permission, .reply = .result }, |
| 139 | 146 | .{ .name = A.request_permission, .reply = .result }, |
| 147 | .{ .name = A.open_settings, .reply = .result }, | |
| 140 | 148 | }; |
| 141 | 149 | |
| 142 | 150 | /// Which handler an action selects, or null for one this namespace does not |
| 143 | 151 | /// serve. Split out from `handleMessage` so the table-versus-dispatch |
| 144 | 152 | /// agreement is assertable on a host that has none of the frameworks. |
| 145 | const Route = enum { check, request }; | |
| 153 | const Route = enum { check, request, open_settings }; | |
| 146 | 154 | |
| 147 | 155 | fn routeFor(action: []const u8) ?Route { |
| 148 | 156 | if (std.mem.eql(u8, action, A.check_permission)) return .check; |
| 149 | 157 | if (std.mem.eql(u8, action, A.request_permission)) return .request; |
| 158 | if (std.mem.eql(u8, action, A.open_settings)) return .open_settings; | |
| 150 | 159 | return null; |
| 151 | 160 | } |
| 152 | 161 | |
| @@ -322,6 +331,7 @@ pub const PermissionsBridge = struct { | ||
| 322 | 331 | return switch (route) { |
| 323 | 332 | .check => self.checkPermission(data), |
| 324 | 333 | .request => self.requestPermission(data), |
| 334 | .open_settings => self.openSettings(), | |
| 325 | 335 | }; |
| 326 | 336 | } |
| 327 | 337 | |
| @@ -478,6 +488,62 @@ pub const PermissionsBridge = struct { | ||
| 478 | 488 | const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 479 | 489 | func(center, sel, un_options_alert_badge_sound, ios_async.boolErrorBlock(ticket)); |
| 480 | 490 | } |
| 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 | } | |
| 481 | 547 | }; |
| 482 | 548 | |
| 483 | 549 | /// The answer for a full block pool. `BridgeError` has no "Busy"; |
| @@ -672,12 +738,144 @@ fn mediaTypeVideo(allocator: std.mem.Allocator) !Id { | ||
| 672 | 738 | // someone's screen). |
| 673 | 739 | // ============================================================================= |
| 674 | 740 | |
| 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. | |
| 770 | fn 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. | |
| 786 | const opened_reply = "true"; | |
| 787 | const not_opened_reply = "false"; | |
| 788 | ||
| 789 | const SettingsBlockDescriptor = extern struct { | |
| 790 | reserved: c_ulong = 0, | |
| 791 | size: c_ulong, | |
| 792 | }; | |
| 793 | ||
| 794 | /// `void (^)(BOOL)`. | |
| 795 | const 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. | |
| 805 | const SETTINGS_BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 806 | ||
| 807 | const settings_block_descriptor = SettingsBlockDescriptor{ .size = @sizeOf(SettingsBlock) }; | |
| 808 | ||
| 809 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 810 | ||
| 811 | fn 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 | ||
| 820 | fn 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 | ||
| 833 | var 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. | |
| 837 | var settings_calls: [ios_async.max_in_flight]?ios_async.Ticket = @splat(null); | |
| 838 | var settings_mutex: compat_mutex.Mutex = .{}; | |
| 839 | ||
| 840 | fn 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. | |
| 847 | fn 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. | |
| 858 | fn 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 | ||
| 675 | 872 | const testing = std.testing; |
| 676 | 873 | |
| 677 | 874 | test "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); | |
| 679 | 876 | try testing.expectEqualStrings(A.check_permission, capability_actions[0].name); |
| 680 | 877 | try testing.expectEqualStrings(A.request_permission, capability_actions[1].name); |
| 878 | try testing.expectEqualStrings(A.open_settings, capability_actions[2].name); | |
| 681 | 879 | |
| 682 | 880 | for (capability_actions) |decl| { |
| 683 | 881 | try testing.expectEqual(capabilities.Reply.result, decl.reply); |
| @@ -690,6 +888,7 @@ test "the action names are the ones the Swift dispatcher answers" { | ||
| 690 | 888 | // spec lacks; it cannot catch both being renamed in step, which this holds. |
| 691 | 889 | try testing.expectEqualStrings("checkPermission", A.check_permission); |
| 692 | 890 | try testing.expectEqualStrings("requestPermission", A.request_permission); |
| 891 | try testing.expectEqualStrings("openSettings", A.open_settings); | |
| 693 | 892 | } |
| 694 | 893 | |
| 695 | 894 | test "every declared action is one the dispatcher routes" { |
| @@ -717,22 +916,45 @@ test "every route the dispatcher has is a declared action" { | ||
| 717 | 916 | } |
| 718 | 917 | } |
| 719 | 918 | |
| 720 | test "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(); | |
| 919 | test "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 | } | |
| 728 | 932 | |
| 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")); | |
| 933 | test "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))); | |
| 735 | 945 | } |
| 946 | try testing.expect(settings_blocks[0].invoke != settings_blocks[1].invoke); | |
| 947 | } | |
| 948 | ||
| 949 | test "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); | |
| 736 | 958 | } |
| 737 | 959 | |
| 738 | 960 | test "an action the namespace does not serve is reported, not ignored" { |
| @@ -768,10 +990,14 @@ test "every declared action dispatches to something" { | ||
| 768 | 990 | defer bridge.deinit(); |
| 769 | 991 | |
| 770 | 992 | 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 | }; | |
| 775 | 1001 | } |
| 776 | 1002 | } |
| 777 | 1003 | |
| @@ -133,57 +133,50 @@ | ||
| 133 | 133 | //! hypothetical Zig-only app it would not, and `isWatchReachable` would report |
| 134 | 134 | //! `false` forever — honest, and worth knowing. |
| 135 | 135 | //! |
| 136 | //! ## Why `sendToWatch` is absent rather than `.unavailable` | |
| 136 | //! ## `sendToWatch`, and the condition that unblocked it | |
| 137 | 137 | //! |
| 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. | |
| 148 | 141 | //! |
| 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. | |
| 155 | 151 | //! |
| 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 | |
| 159 | 155 | //! `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. | |
| 162 | 167 | //! |
| 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. | |
| 170 | 171 | //! |
| 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. | |
| 181 | 172 | |
| 182 | 173 | const std = ; |
| 183 | 174 | const builtin = ; |
| 184 | 175 | const capabilities = ; |
| 185 | 176 | const bridge_error = ; |
| 186 | 177 | const objc_runtime = ; |
| 178 | const ios_async = ; | |
| 179 | const compat_mutex = ; | |
| 187 | 180 | |
| 188 | 181 | const objc = objc_runtime.objc; |
| 189 | 182 | const is_darwin = builtin.target.os.tag.isDarwin(); |
| @@ -208,6 +201,7 @@ const Id = ?*anyopaque; | ||
| 208 | 201 | pub const A = struct { |
| 209 | 202 | pub const update_watch_context = "updateWatchContext"; |
| 210 | 203 | pub const is_watch_reachable = "isWatchReachable"; |
| 204 | pub const send_to_watch = "sendToWatch"; | |
| 211 | 205 | }; |
| 212 | 206 | |
| 213 | 207 | /// Both `.result`: each Swift path terminates in exactly one |
| @@ -222,6 +216,7 @@ pub const A = struct { | ||
| 222 | 216 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 223 | 217 | .{ .name = A.update_watch_context, .reply = .result }, |
| 224 | 218 | .{ .name = A.is_watch_reachable, .reply = .result }, |
| 219 | .{ .name = A.send_to_watch, .reply = .result }, | |
| 225 | 220 | }; |
| 226 | 221 | |
| 227 | 222 | // ----------------------------------------------------------------------------- |
| @@ -247,11 +242,12 @@ fn reachableFragment(reachable: bool) []const u8 { | ||
| 247 | 242 | /// Which handler an action selects, split out from `handleMessage` so the |
| 248 | 243 | /// table-versus-dispatch agreement is assertable on a host, where |
| 249 | 244 | /// `WCSession` does not exist. |
| 250 | const Route = enum { update_context, is_reachable }; | |
| 245 | const Route = enum { update_context, is_reachable, send_message }; | |
| 251 | 246 | |
| 252 | 247 | fn routeFor(action: []const u8) ?Route { |
| 253 | 248 | if (std.mem.eql(u8, action, A.update_watch_context)) return .update_context; |
| 254 | 249 | 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; | |
| 255 | 251 | return null; |
| 256 | 252 | } |
| 257 | 253 | |
| @@ -272,7 +268,69 @@ pub const WatchBridge = struct { | ||
| 272 | 268 | return switch (route) { |
| 273 | 269 | .update_context => self.updateWatchContext(data), |
| 274 | 270 | .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; | |
| 275 | 319 | }; |
| 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 | ); | |
| 276 | 334 | } |
| 277 | 335 | |
| 278 | 336 | /// Push a new application context to the paired watch. |
| @@ -704,12 +762,212 @@ fn readNSInteger(object: Id, comptime selector: [*:0]const u8) c_long { | ||
| 704 | 762 | // session, that would push a context to somebody's actual watch. |
| 705 | 763 | // ============================================================================= |
| 706 | 764 | |
| 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. | |
| 772 | fn 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. | |
| 796 | var watch_calls: [ios_async.max_in_flight]?ios_async.Ticket = @splat(null); | |
| 797 | var watch_mutex: compat_mutex.Mutex = .{}; | |
| 798 | ||
| 799 | fn 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. | |
| 808 | fn 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 | ||
| 816 | const 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. | |
| 822 | const 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. | |
| 831 | const WATCH_BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 832 | ||
| 833 | const watch_block_descriptor = WatchBlockDescriptor{ .size = @sizeOf(WatchBlock) }; | |
| 834 | ||
| 835 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 836 | ||
| 837 | fn 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 | ||
| 846 | fn 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 | ||
| 855 | fn 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 | ||
| 868 | var watch_reply_blocks: [ios_async.max_in_flight]WatchBlock = | |
| 869 | if (is_darwin) makeWatchBlocks(makeWatchReplyInvoke) else undefined; | |
| 870 | var 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. | |
| 874 | fn 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. | |
| 894 | fn 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. | |
| 927 | fn 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 | ||
| 707 | 964 | const testing = std.testing; |
| 708 | 965 | |
| 709 | 966 | test "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); | |
| 711 | 968 | try testing.expectEqualStrings(A.update_watch_context, capability_actions[0].name); |
| 712 | 969 | try testing.expectEqualStrings(A.is_watch_reachable, capability_actions[1].name); |
| 970 | try testing.expectEqualStrings(A.send_to_watch, capability_actions[2].name); | |
| 713 | 971 | |
| 714 | 972 | for (capability_actions) |decl| { |
| 715 | 973 | try testing.expectEqual(capabilities.Reply.result, decl.reply); |
| @@ -763,27 +1021,76 @@ test "every route the dispatcher has is a declared action" { | ||
| 763 | 1021 | } |
| 764 | 1022 | } |
| 765 | 1023 | |
| 766 | test "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. | |
| 1024 | test "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 | ||
| 775 | 1034 | var bridge = WatchBridge.init(testing.allocator); |
| 776 | 1035 | defer bridge.deinit(); |
| 1036 | try testing.expect(routeFor("sendToWatch") != null); | |
| 1037 | } | |
| 777 | 1038 | |
| 778 | try testing.expect(routeFor("sendToWatch") == null); | |
| 779 | try testing.expectError( | |
| 780 | bridge_error.BridgeError.UnknownAction, | |
| 781 | bridge.handleMessage("sendToWatch", "{\"message\":{\"action\":\"ping\"}}"), | |
| 1039 | test "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 | .{}, | |
| 782 | 1048 | ); |
| 1049 | defer object.deinit(); | |
| 1050 | _ = try parseMessage(object.value); | |
| 783 | 1051 | |
| 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`. | |
| 1059 | fn 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 | ||
| 1065 | test "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); | |
| 786 | 1077 | } |
| 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 | ||
| 1082 | test "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); | |
| 787 | 1094 | } |
| 788 | 1095 | |
| 789 | 1096 | test "an action the namespace does not serve is reported, not ignored" { |
| @@ -412,6 +412,7 @@ pub fn gateFor(action: []const u8) ?Feature { | ||
| 412 | 412 | .{ "dbQuery", .local_database }, |
| 413 | 413 | .{ "deleteCalendarEvent", .calendar }, |
| 414 | 414 | .{ "detectObjects", .ml_kit }, |
| 415 | .{ "downloadFile", .file_download }, | |
| 415 | 416 | .{ "getCalendarEvents", .calendar }, |
| 416 | 417 | .{ "getContacts", .contacts }, |
| 417 | 418 | .{ "getCurrentPosition", .geolocation }, |
| @@ -97,7 +97,7 @@ const dispatch_end = "func webView("; | ||
| 97 | 97 | /// 48 after calendar and contacts. Speech was researched in full and left |
| 98 | 98 | /// with the shim: neither action replies, so unlike the earlier deferrals |
| 99 | 99 | /// there is no promise to strand and falling through costs the page nothing. |
| 100 | const max_not_yet_migrated: usize = 38; | |
| 100 | const max_not_yet_migrated: usize = 35; | |
| 101 | 101 | |
| 102 | 102 | fn dispatcherRegion() []const u8 { |
| 103 | 103 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |