also looking at this
fix(ios): answer the shapes the spec answers, now that a bare false survives
#105
7 files
+423
-82
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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"; |
| @@ -119,7 +125,7 @@ pub const DisplayBridge = struct { | ||
| 119 | 125 | /// so inventing one here would be a second divergence rather than a fix. |
| 120 | 126 | fn setBadge(self: *Self, data: []const u8) !void { |
| 121 | 127 | const requested = try badgeCountFrom(self.allocator, data); |
| 122 | try self.replyBadge(A.set_badge, try applyBadge(requested)); | |
| 128 | try requestBadgeAuthorization(self.allocator, A.set_badge, requested); | |
| 123 | 129 | } |
| 124 | 130 | |
| 125 | 131 | /// The same path as `setBadge` with a fixed 0, so the two cannot drift. |
| @@ -127,12 +133,7 @@ pub const DisplayBridge = struct { | ||
| 127 | 133 | /// `clearBadge` sends no payload at all, so there is nothing to parse: |
| 128 | 134 | /// `payloadOf` hands this `{}` and the count is not the caller's to choose. |
| 129 | 135 | fn clearBadge(self: *Self) !void { |
| 130 | try self.replyBadge(A.clear_badge, try applyBadge(0)); | |
| 131 | } | |
| 132 | ||
| 133 | fn replyBadge(self: *Self, action: []const u8, observed: i64) !void { | |
| 134 | std.log.info("{s}: badge is now {d}", .{ action, observed }); | |
| 135 | bridge_error.sendResultToJS(self.allocator, action, badge_applied); | |
| 136 | try requestBadgeAuthorization(self.allocator, A.clear_badge, 0); | |
| 136 | 137 | } |
| 137 | 138 | |
| 138 | 139 | /// `{"enabled":<bool>}` in, the bare observed boolean out. |
| @@ -197,6 +198,212 @@ pub const DisplayBridge = struct { | ||
| 197 | 198 | /// What `setBadge` and `clearBadge` resolve. A bare `true`, matching the spec. |
| 198 | 199 | const badge_applied = "true"; |
| 199 | 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)); | |
| 259 | } | |
| 260 | ||
| 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); | |
| 405 | } | |
| 406 | ||
| 200 | 407 | // ============================================================================= |
| 201 | 408 | // Payload parsing |
| 202 | 409 | // ============================================================================= |
| @@ -660,3 +867,62 @@ test "UIKit work is refused off Darwin rather than attempted" { | ||
| 660 | 867 | try testing.expectError(error.UnsupportedPlatform, applyBadge(1)); |
| 661 | 868 | try testing.expectError(error.UnsupportedPlatform, applyKeepAwake(true)); |
| 662 | 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 | } | |