| @@ -3,8 +3,12 @@ const builtin = ; |
| 3 | 3 | const capabilities = ; |
| 4 | 4 | const bridge_error = ; |
| 5 | 5 | const objc_runtime = ; |
| 6 | +const ios_async = ; |
| 7 | +const compat_mutex = ; |
| 6 | 8 | |
| 7 | 9 | const objc = objc_runtime.objc; |
| 10 | +const Id = objc.id; |
| 11 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 8 | 12 | |
| 9 | 13 | /// The `mobile` display group: the app icon's badge, the idle timer, and the |
| 10 | 14 | /// two orientation actions. |
| @@ -44,14 +48,16 @@ const objc = objc_runtime.objc; |
| 44 | 48 | /// which WebKit delivers on the main thread. The hop is redundant, and there is |
| 45 | 49 | /// no dispatch-block machinery in this repo to reproduce it with. |
| 46 | 50 | /// |
| 47 | | -/// **No badge authorization.** Swift calls |
| 48 | | -/// `requestAuthorizationWithOptions:` and rejects with "Badge permission |
| 49 | | -/// denied". Reporting that from Zig needs a *capturing* block — every block in |
| 50 | | -/// this repo is static and non-capturing — so it is not attempted here. The |
| 51 | | -/// consequence is stated rather than hidden: Zig sets the number and proves |
| 52 | | -/// UIKit stored it, which is **not** proof that the springboard draws it. An |
| 53 | | -/// unauthorized app gets `{"count":N}` and an invisible badge, and will never |
| 54 | | -/// see "Badge permission denied" or the first-run prompt Swift triggers. |
| 51 | +/// **Badge authorization, as the spec does it.** `setBadge` and `clearBadge` |
| 52 | +/// call `requestAuthorizationWithOptions:` first and reject with |
| 53 | +/// `PERMISSION_DENIED` when iOS says no — the spec's "Badge permission denied". |
| 54 | +/// This used to be skipped with the note that it needed a *capturing* block, |
| 55 | +/// which no block in the repo was. That stopped being true when |
| 56 | +/// `bridge_mobile_auth.zig` shipped per-slot global blocks whose slot index is |
| 57 | +/// baked in at comptime; the same shape is used here. Without it Zig set the |
| 58 | +/// number, proved UIKit stored it, and answered `true` for a badge an |
| 59 | +/// unauthorised app never draws — a fabricated success, and the one this file |
| 60 | +/// was still producing after every other module had stopped. |
| 55 | 61 | pub const A = struct { |
| 56 | 62 | pub const set_badge = "setBadge"; |
| 57 | 63 | pub const clear_badge = "clearBadge"; |
| @@ -105,15 +111,21 @@ pub const DisplayBridge = struct { |
| 105 | 111 | } |
| 106 | 112 | } |
| 107 | 113 | |
| 108 | | - /// `{"count":N}` in, `{"count":<what UIKit now reports>}` out. |
| 114 | + /// `{"count":N}` in, bare `true` out — what the spec resolves. |
| 109 | 115 | /// |
| 110 | | - /// The reply is an object, not the bare `true` Swift resolves: |
| 111 | | - /// `craft-bridge.js` settles with `payload || {}`, so any falsy scalar |
| 112 | | - /// arrives at the page as `{}` and carries nothing. An object also lets the |
| 113 | | - /// value be checked, which a `true` never could. |
| 116 | + /// This answered `{"count":<observed>}` until now, to work around |
| 117 | + /// `craft-bridge.js` settling with `payload || {}`: a falsy scalar reached |
| 118 | + /// the page as `{}` and carried nothing. That is fixed at its source |
| 119 | + /// (`_orEmpty`), so the wrapper is gone and pages reading `=== true` work |
| 120 | + /// again. |
| 121 | + /// |
| 122 | + /// The observed count goes to the log rather than the page. It was the one |
| 123 | + /// thing the object shape bought, and the spec never offered it — a page |
| 124 | + /// that wants the badge back has no API for it in either implementation, |
| 125 | + /// so inventing one here would be a second divergence rather than a fix. |
| 114 | 126 | fn setBadge(self: *Self, data: []const u8) !void { |
| 115 | 127 | const requested = try badgeCountFrom(self.allocator, data); |
| 116 | | - try self.replyBadge(A.set_badge, try applyBadge(requested)); |
| 128 | + try requestBadgeAuthorization(self.allocator, A.set_badge, requested); |
| 117 | 129 | } |
| 118 | 130 | |
| 119 | 131 | /// The same path as `setBadge` with a fixed 0, so the two cannot drift. |
| @@ -121,15 +133,14 @@ pub const DisplayBridge = struct { |
| 121 | 133 | /// `clearBadge` sends no payload at all, so there is nothing to parse: |
| 122 | 134 | /// `payloadOf` hands this `{}` and the count is not the caller's to choose. |
| 123 | 135 | fn clearBadge(self: *Self) !void { |
| 124 | | - try self.replyBadge(A.clear_badge, try applyBadge(0)); |
| 125 | | - } |
| 126 | | - |
| 127 | | - fn replyBadge(self: *Self, action: []const u8, observed: i64) !void { |
| 128 | | - var buf: [48]u8 = undefined; |
| 129 | | - bridge_error.sendResultToJS(self.allocator, action, try renderBadgeReply(&buf, observed)); |
| 136 | + try requestBadgeAuthorization(self.allocator, A.clear_badge, 0); |
| 130 | 137 | } |
| 131 | 138 | |
| 132 | | - /// `{"enabled":<bool>}` in, `{"enabled":<what UIKit now reports>}` out. |
| 139 | + /// `{"enabled":<bool>}` in, the bare observed boolean out. |
| 140 | + /// |
| 141 | + /// The spec resolves the `enabled` the page sent; this resolves what UIKit |
| 142 | + /// reports, which is the same value whenever the write took. Bare rather |
| 143 | + /// than `{"enabled":…}` for the reason recorded on `replyBadge`. |
| 133 | 144 | /// |
| 134 | 145 | /// Swift also stores `isKeepingAwake`, which nothing in the template ever |
| 135 | 146 | /// reads. It is not ported: a variable with no reader cannot be wrong, and |
| @@ -140,11 +151,10 @@ pub const DisplayBridge = struct { |
| 140 | 151 | const requested = try keepAwakeFrom(self.allocator, data); |
| 141 | 152 | const observed = try applyKeepAwake(requested); |
| 142 | 153 | |
| 143 | | - var buf: [32]u8 = undefined; |
| 144 | 154 | bridge_error.sendResultToJS( |
| 145 | 155 | self.allocator, |
| 146 | 156 | A.set_keep_awake, |
| 147 | | - try renderKeepAwakeReply(&buf, observed), |
| 157 | + if (observed) "true" else "false", |
| 148 | 158 | ); |
| 149 | 159 | } |
| 150 | 160 | |
| @@ -185,12 +195,213 @@ pub const DisplayBridge = struct { |
| 185 | 195 | /// Split out because a test that re-typed the format string would pass no |
| 186 | 196 | /// matter what the handler sent — it would be asserting that `std.fmt` works. |
| 187 | 197 | /// These are the strings the page actually receives. |
| 188 | | -fn renderBadgeReply(buf: []u8, observed: i64) ![]const u8 { |
| 189 | | - return std.fmt.bufPrint(buf, "", .{observed}); |
| 198 | +/// What `setBadge` and `clearBadge` resolve. A bare `true`, matching the spec. |
| 199 | +const badge_applied = "true"; |
| 200 | + |
| 201 | +// ============================================================================= |
| 202 | +// Badge authorization: ask, then set, then answer — on the threads the spec |
| 203 | +// uses for each. |
| 204 | +// ============================================================================= |
| 205 | + |
| 206 | +/// `UNAuthorizationOptionBadge`. `UNUserNotificationCenter.h:23` in the |
| 207 | +/// iPhoneSimulator 26.5 SDK: `UNAuthorizationOptionBadge = (1 << 0)`. Pinned |
| 208 | +/// by a test that cites the same line, after a neighbouring module once |
| 209 | +/// guessed a `1 << 2` that named a different option entirely. |
| 210 | +const un_authorization_option_badge: c_ulong = 1 << 0; |
| 211 | + |
| 212 | +/// Ask iOS for badge authorization, and set the badge only once it says yes. |
| 213 | +/// |
| 214 | +/// The spec's order exactly: `requestAuthorization(options: .badge)`, then |
| 215 | +/// `applicationIconBadgeNumber = count` inside `DispatchQueue.main.async` on |
| 216 | +/// grant, or reject on refusal. The count is validated *before* anything is |
| 217 | +/// asked, so a malformed call fails as it always did without ever triggering |
| 218 | +/// the first-run prompt. |
| 219 | +/// |
| 220 | +/// When UserNotifications is not in the process — the `zig-slice` fixture |
| 221 | +/// links only UIKit, WebKit and Foundation — there is nothing to ask, and the |
| 222 | +/// badge is set directly as before, with a warning saying so. That path cannot |
| 223 | +/// be reached by a generated app: `project.yml` links UserNotifications |
| 224 | +/// unconditionally. |
| 225 | +fn requestBadgeAuthorization(allocator: std.mem.Allocator, action: []const u8, count: i64) !void { |
| 226 | + if (!is_darwin) return error.UnsupportedPlatform; |
| 227 | + _ = std.math.cast(c_long, count) orelse return bridge_error.BridgeError.InvalidParameter; |
| 228 | + |
| 229 | + const UNCenterClass = objc.objc_getClass("UNUserNotificationCenter") orelse { |
| 230 | + std.log.warn( |
| 231 | + "{s}: UserNotifications is not in this process, so authorization cannot be " ++ |
| 232 | + "requested; setting the badge directly, which an unauthorised app will not draw", |
| 233 | + .{action}, |
| 234 | + ); |
| 235 | + const observed = try applyBadge(count); |
| 236 | + std.log.info("{s}: badge is now {d}", .{ action, observed }); |
| 237 | + bridge_error.sendResultToJS(allocator, action, badge_applied); |
| 238 | + return; |
| 239 | + }; |
| 240 | + |
| 241 | + // `currentNotificationCenter` RAISES — not nil — in a process with no |
| 242 | + // bundle identifier, which is every test runner. Same guard as |
| 243 | + // `bridge_mobile_permissions.zig` and `bridge_mobile_notifcancel.zig`. |
| 244 | + try requireBundleIdentifier(); |
| 245 | + |
| 246 | + const sel_current = objc.sel_registerName("currentNotificationCenter") orelse |
| 247 | + return error.SelectorNotFound; |
| 248 | + const center = objc.msgSendId(UNCenterClass, sel_current) orelse return error.NoNotificationCenter; |
| 249 | + const sel = objc.sel_registerName("requestAuthorizationWithOptions:completionHandler:") orelse |
| 250 | + return error.SelectorNotFound; |
| 251 | + |
| 252 | + const ticket = ios_async.acquire(action) orelse return poolFull(action); |
| 253 | + errdefer ios_async.abandon(ticket); |
| 254 | + publishPendingCall(ticket, action, count); |
| 255 | + |
| 256 | + const Fn = *const fn (Id, objc.SEL, c_ulong, *anyopaque) callconv(.c) void; |
| 257 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 258 | + func(center, sel, un_authorization_option_badge, replyBlock(ticket)); |
| 190 | 259 | } |
| 191 | 260 | |
| 192 | | -fn renderKeepAwakeReply(buf: []u8, observed: bool) ![]const u8 { |
| 193 | | - return std.fmt.bufPrint(buf, "", .{observed}); |
| 261 | +fn requireBundleIdentifier() !void { |
| 262 | + const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; |
| 263 | + const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; |
| 264 | + const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoBundleIdentifier; |
| 265 | + const sel_ident = objc.sel_registerName("bundleIdentifier") orelse return error.SelectorNotFound; |
| 266 | + if (objc.msgSendId(bundle, sel_ident) == null) return error.NoBundleIdentifier; |
| 267 | +} |
| 268 | + |
| 269 | +fn poolFull(action: []const u8) bridge_error.BridgeError { |
| 270 | + std.log.warn("{s} refused: all {d} async slots in flight", .{ action, ios_async.max_in_flight }); |
| 271 | + return bridge_error.BridgeError.InvalidParameter; |
| 272 | +} |
| 273 | + |
| 274 | +/// What a slot's block will answer. The ticket is stored, not rebuilt from the |
| 275 | +/// index: a ticket carries a generation, and inventing one at reply time would |
| 276 | +/// defeat the check that stops a late completion answering the slot's next |
| 277 | +/// occupant. `action` is one of the two static names in `A`, never allocated. |
| 278 | +const PendingBadge = struct { |
| 279 | + ticket: ios_async.Ticket, |
| 280 | + action: []const u8, |
| 281 | + count: i64, |
| 282 | +}; |
| 283 | + |
| 284 | +var pending_badges: [ios_async.max_in_flight]?PendingBadge = @splat(null); |
| 285 | +var pending_mutex: compat_mutex.Mutex = .{}; |
| 286 | + |
| 287 | +fn publishPendingCall(ticket: ios_async.Ticket, action: []const u8, count: i64) void { |
| 288 | + pending_mutex.lock(); |
| 289 | + defer pending_mutex.unlock(); |
| 290 | + pending_badges[ticket.index] = .{ .ticket = ticket, .action = action, .count = count }; |
| 291 | +} |
| 292 | + |
| 293 | +/// Read and clear. Clearing is what makes a second fire a no-op instead of a |
| 294 | +/// second reply. |
| 295 | +fn takePendingCall(index: u5) ?PendingBadge { |
| 296 | + pending_mutex.lock(); |
| 297 | + defer pending_mutex.unlock(); |
| 298 | + const call = pending_badges[index]; |
| 299 | + pending_badges[index] = null; |
| 300 | + return call; |
| 301 | +} |
| 302 | + |
| 303 | +const BlockDescriptor = extern struct { |
| 304 | + reserved: c_ulong = 0, |
| 305 | + size: c_ulong, |
| 306 | +}; |
| 307 | + |
| 308 | +/// `void (^)(BOOL, NSError *)` — `requestAuthorizationWithOptions:`'s |
| 309 | +/// completion. Not `ios_async.boolErrorBlock`: that one answers the page |
| 310 | +/// `"granted"`/`"denied"`, and this one has a badge to set in between. |
| 311 | +const ReplyBlock = extern struct { |
| 312 | + isa: ?*anyopaque, |
| 313 | + flags: c_int, |
| 314 | + reserved: c_int = 0, |
| 315 | + invoke: *const anyopaque, |
| 316 | + descriptor: *const BlockDescriptor, |
| 317 | +}; |
| 318 | + |
| 319 | +/// 1 << 28. A global block is never copied, so it can be handed to an API that |
| 320 | +/// escapes it with no heap copy and no descriptor lifetime. |
| 321 | +const BLOCK_IS_GLOBAL: c_int = 1 << 28; |
| 322 | +const reply_block_descriptor = BlockDescriptor{ .size = @sizeOf(ReplyBlock) }; |
| 323 | + |
| 324 | +extern var _NSConcreteGlobalBlock: anyopaque; |
| 325 | + |
| 326 | +fn makeReplyInvoke(comptime index: u5) *const anyopaque { |
| 327 | + const S = struct { |
| 328 | + fn invoke(_: *const ReplyBlock, granted: bool, err: Id) callconv(.c) void { |
| 329 | + authorizationFired(index, granted, err); |
| 330 | + } |
| 331 | + }; |
| 332 | + return @ptrCast(&S.invoke); |
| 333 | +} |
| 334 | + |
| 335 | +fn makeReplyBlocks() [ios_async.max_in_flight]ReplyBlock { |
| 336 | + var out: [ios_async.max_in_flight]ReplyBlock = undefined; |
| 337 | + for (&out, 0..) |*b, i| { |
| 338 | + b.* = .{ |
| 339 | + .isa = &_NSConcreteGlobalBlock, |
| 340 | + .flags = BLOCK_IS_GLOBAL, |
| 341 | + .invoke = makeReplyInvoke(@intCast(i)), |
| 342 | + .descriptor = &reply_block_descriptor, |
| 343 | + }; |
| 344 | + } |
| 345 | + return out; |
| 346 | +} |
| 347 | + |
| 348 | +var reply_blocks: [ios_async.max_in_flight]ReplyBlock = |
| 349 | + if (is_darwin) makeReplyBlocks() else undefined; |
| 350 | + |
| 351 | +fn replyBlock(ticket: ios_async.Ticket) *anyopaque { |
| 352 | + return @ptrCast(&reply_blocks[ticket.index]); |
| 353 | +} |
| 354 | + |
| 355 | +extern "c" fn dispatch_async_f( |
| 356 | + queue: *anyopaque, |
| 357 | + context: ?*anyopaque, |
| 358 | + work: *const fn (?*anyopaque) callconv(.c) void, |
| 359 | +) void; |
| 360 | +extern var _dispatch_main_q: anyopaque; |
| 361 | + |
| 362 | +/// Runs on whatever queue UserNotifications chose. |
| 363 | +/// |
| 364 | +/// A refusal is answered from here — `ios_async` hops to the main queue |
| 365 | +/// itself. A grant is not: the badge has to be *set* on the main thread |
| 366 | +/// before the answer is true, which is what Swift's `DispatchQueue.main.async` |
| 367 | +/// is for in this position. So the grant path hops first and answers after. |
| 368 | +/// The pending entry survives the hop and is taken on the far side, which is |
| 369 | +/// what makes a double fire harmless: the second hop finds nothing. |
| 370 | +fn authorizationFired(index: u5, granted: bool, err: Id) void { |
| 371 | + if (!is_darwin) return; |
| 372 | + |
| 373 | + if (!granted) { |
| 374 | + const call = takePendingCall(index) orelse { |
| 375 | + std.log.warn("badge authorization fired for slot {d} with no call recorded; ignored", .{index}); |
| 376 | + return; |
| 377 | + }; |
| 378 | + _ = err; |
| 379 | + std.log.info("{s}: badge authorization denied", .{call.action}); |
| 380 | + ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.PermissionDenied); |
| 381 | + return; |
| 382 | + } |
| 383 | + |
| 384 | + // `index + 1`, never a null context: `ios_async` passes its slot the same |
| 385 | + // way and for the same reason. |
| 386 | + dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), applyBadgeOnMain); |
| 387 | +} |
| 388 | + |
| 389 | +/// Main thread. Authorized, so set the badge and say so. |
| 390 | +fn applyBadgeOnMain(context: ?*anyopaque) callconv(.c) void { |
| 391 | + if (!is_darwin) return; |
| 392 | + const index: u5 = @intCast(@intFromPtr(context) - 1); |
| 393 | + const call = takePendingCall(index) orelse { |
| 394 | + std.log.warn("badge grant reached the main queue for slot {d} with no call recorded; ignored", .{index}); |
| 395 | + return; |
| 396 | + }; |
| 397 | + |
| 398 | + const observed = applyBadge(call.count) catch |e| { |
| 399 | + std.log.warn("{s}: authorised, but the badge could not be set: {s}", .{ call.action, @errorName(e) }); |
| 400 | + ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed); |
| 401 | + return; |
| 402 | + }; |
| 403 | + std.log.info("{s}: badge is now {d}", .{ call.action, observed }); |
| 404 | + ios_async.deliverJson(call.ticket, badge_applied); |
| 194 | 405 | } |
| 195 | 406 | |
| 196 | 407 | // ============================================================================= |
| @@ -630,27 +841,25 @@ test "the orientation actions refuse, and say which kind of refusal it is" { |
| 630 | 841 | ); |
| 631 | 842 | } |
| 632 | 843 | |
| 633 | | -test "replies are objects carrying a value, not bare scalars" { |
| 634 | | - // `craft-bridge.js` settles with `payload || {}`. A bare `false` — which |
| 635 | | - // `setKeepAwake(false)` would resolve, following Swift — arrives at the |
| 636 | | - // page as `{}` and loses the answer entirely. |
| 844 | +test "replies are the bare scalars the spec resolves" { |
| 845 | + // These were objects — `{"count":N}` and `{"enabled":<bool>}` — to work |
| 846 | + // around `craft-bridge.js` settling with `payload || {}`, which turned a |
| 847 | + // bare `false` into a truthy `{}`. That is fixed at its source, so the |
| 848 | + // wrapper is gone and the shapes match the spec again: a page doing |
| 849 | + // `(await craft.setKeepAwake(false)) === false` gets `false`, and one doing |
| 850 | + // `(await craft.clearBadge()) === true` gets `true`. |
| 637 | 851 | // |
| 638 | | - // These call the same renderers the handlers do, so changing a key name or |
| 639 | | - // dropping the object wrapper fails here instead of reading as coverage. |
| 640 | | - var buf: [48]u8 = undefined; |
| 641 | | - |
| 642 | | - try testing.expectEqualStrings("{\"count\":7}", try renderBadgeReply(&buf, 7)); |
| 643 | | - try testing.expectEqualStrings("{\"count\":0}", try renderBadgeReply(&buf, 0)); |
| 644 | | - try testing.expectEqualStrings("{\"enabled\":false}", try renderKeepAwakeReply(&buf, false)); |
| 645 | | - try testing.expectEqualStrings("{\"enabled\":true}", try renderKeepAwakeReply(&buf, true)); |
| 646 | | - |
| 647 | | - // The stack buffers the handlers hand these are 48 and 32 bytes; the widest |
| 648 | | - // reply either can produce has to fit, or a legitimate count turns into a |
| 649 | | - // `NoSpaceLeft` the page reads as a native failure. |
| 650 | | - var badge_buf: [48]u8 = undefined; |
| 651 | | - try testing.expect((try renderBadgeReply(&badge_buf, std.math.minInt(i64))).len <= badge_buf.len); |
| 652 | | - var keep_buf: [32]u8 = undefined; |
| 653 | | - try testing.expect((try renderKeepAwakeReply(&keep_buf, false)).len <= keep_buf.len); |
| 852 | + // Pinned as the literal bytes the handlers send, so re-wrapping either |
| 853 | + // reply fails here rather than reading as coverage. |
| 854 | + try testing.expectEqualStrings("true", badge_applied); |
| 855 | + |
| 856 | + // Parsed, not just compared, so a literal that is not valid JSON — `True`, |
| 857 | + // or a stray quote — cannot pass. |
| 858 | + for ([_][]const u8{ badge_applied, "true", "false" }) |json| { |
| 859 | + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 860 | + defer parsed.deinit(); |
| 861 | + try testing.expect(parsed.value == .bool); |
| 862 | + } |
| 654 | 863 | } |
| 655 | 864 | |
| 656 | 865 | test "UIKit work is refused off Darwin rather than attempted" { |
| @@ -658,3 +867,62 @@ test "UIKit work is refused off Darwin rather than attempted" { |
| 658 | 867 | try testing.expectError(error.UnsupportedPlatform, applyBadge(1)); |
| 659 | 868 | try testing.expectError(error.UnsupportedPlatform, applyKeepAwake(true)); |
| 660 | 869 | } |
| 870 | + |
| 871 | +test "the badge authorization option is the header's bit, not a guess" { |
| 872 | + // `UNUserNotificationCenter.h:23`: `UNAuthorizationOptionBadge = (1 << 0)`. |
| 873 | + // A neighbouring module once pinned a guessed `1 << 2` for a HealthKit |
| 874 | + // option and shipped a crash; this cites the line instead. |
| 875 | + try testing.expectEqual(@as(c_ulong, 1), un_authorization_option_badge); |
| 876 | + try testing.expect(un_authorization_option_badge != 1 << 1); // Sound |
| 877 | + try testing.expect(un_authorization_option_badge != 1 << 2); // Alert |
| 878 | +} |
| 879 | + |
| 880 | +test "each badge slot's reply block is global and has its own invoke" { |
| 881 | + if (!is_darwin) return error.SkipZigTest; |
| 882 | + for (&reply_blocks) |*b| { |
| 883 | + try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); |
| 884 | + try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); |
| 885 | + try testing.expectEqual(@sizeOf(ReplyBlock), @as(usize, @intCast(b.descriptor.size))); |
| 886 | + } |
| 887 | + try testing.expect(reply_blocks[0].invoke != reply_blocks[1].invoke); |
| 888 | +} |
| 889 | + |
| 890 | +test "a pending badge call is taken exactly once" { |
| 891 | + // The property a double-fired completion relies on: the second `take` |
| 892 | + // finds nothing, so it replies to nobody instead of answering twice. |
| 893 | + const ticket = ios_async.Ticket{ .index = 3, .generation = 42 }; |
| 894 | + publishPendingCall(ticket, A.set_badge, 7); |
| 895 | + const first = takePendingCall(3) orelse return error.TestUnexpectedResult; |
| 896 | + try testing.expectEqual(@as(i64, 7), first.count); |
| 897 | + try testing.expectEqualStrings(A.set_badge, first.action); |
| 898 | + try testing.expectEqual(@as(u32, 42), first.ticket.generation); |
| 899 | + try testing.expect(takePendingCall(3) == null); |
| 900 | +} |
| 901 | + |
| 902 | +test "a bad count is refused before any authorization is requested" { |
| 903 | + // Ordering, not just parsing: the parse failure has to come first, on |
| 904 | + // every platform, so a malformed call never triggers the first-run prompt. |
| 905 | + var bridge = DisplayBridge.init(testing.allocator); |
| 906 | + defer bridge.deinit(); |
| 907 | + try testing.expectError( |
| 908 | + bridge_error.BridgeError.InvalidParameter, |
| 909 | + bridge.handleMessage(A.set_badge, "{\"count\":\"three\"}"), |
| 910 | + ); |
| 911 | +} |
| 912 | + |
| 913 | +test "setBadge is refused on the host rather than resolved without asking" { |
| 914 | + // There is no UIKit and no bundle identifier in a test binary, so the |
| 915 | + // only correct outcome is a refusal. Which refusal depends on what the |
| 916 | + // host has loaded — UserNotifications absent takes one path, present |
| 917 | + // takes another — so this pins the shape (an error, never a reply) and |
| 918 | + // not the name. A resolve here would be the exact fabricated success this |
| 919 | + // change exists to remove. |
| 920 | + var bridge = DisplayBridge.init(testing.allocator); |
| 921 | + defer bridge.deinit(); |
| 922 | + if (bridge.handleMessage(A.set_badge, "{\"count\":3}")) |_| { |
| 923 | + return error.TestUnexpectedResult; |
| 924 | + } else |_| {} |
| 925 | + if (bridge.handleMessage(A.clear_badge, "{}")) |_| { |
| 926 | + return error.TestUnexpectedResult; |
| 927 | + } else |_| {} |
| 928 | +} |