ReviewOS

also looking at this

craft-native/craft

fix(ios): answer the shapes the spec answers, now that a bare false survives

#105
Merged chris wants to merge fix/ios-reply-shape-parity into main
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.

packages/zig/src/bridge_mobile_display.zigmodified+281-15
Changes to packages/zig/src/bridge_mobile_display.zig
@@ -3,8 +3,12 @@ const builtin = ;
33const capabilities = ;
44const bridge_error = ;
55const objc_runtime = ;
6const ios_async = ;
7const compat_mutex = ;
68
79const objc = objc_runtime.objc;
10const Id = objc.id;
11const is_darwin = builtin.target.os.tag.isDarwin();
812
913/// The `mobile` display group: the app icon's badge, the idle timer, and the
1014/// two orientation actions.
@@ -44,14 +48,16 @@ const objc = objc_runtime.objc;
4448/// which WebKit delivers on the main thread. The hop is redundant, and there is
4549/// no dispatch-block machinery in this repo to reproduce it with.
4650///
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.
5561pub const A = struct {
5662 pub const set_badge = "setBadge";
5763 pub const clear_badge = "clearBadge";
@@ -119,7 +125,7 @@ pub const DisplayBridge = struct {
119125 /// so inventing one here would be a second divergence rather than a fix.
120126 fn setBadge(self: *Self, data: []const u8) !void {
121127 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);
123129 }
124130
125131 /// The same path as `setBadge` with a fixed 0, so the two cannot drift.
@@ -127,12 +133,7 @@ pub const DisplayBridge = struct {
127133 /// `clearBadge` sends no payload at all, so there is nothing to parse:
128134 /// `payloadOf` hands this `{}` and the count is not the caller's to choose.
129135 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);
136137 }
137138
138139 /// `{"enabled":<bool>}` in, the bare observed boolean out.
@@ -197,6 +198,212 @@ pub const DisplayBridge = struct {
197198/// What `setBadge` and `clearBadge` resolve. A bare `true`, matching the spec.
198199const badge_applied = "true";
199200
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.
210const 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.
225fn 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
261fn 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
269fn 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.
278const PendingBadge = struct {
279 ticket: ios_async.Ticket,
280 action: []const u8,
281 count: i64,
282};
283
284var pending_badges: [ios_async.max_in_flight]?PendingBadge = @splat(null);
285var pending_mutex: compat_mutex.Mutex = .{};
286
287fn 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.
295fn 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
303const 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.
311const 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.
321const BLOCK_IS_GLOBAL: c_int = 1 << 28;
322const reply_block_descriptor = BlockDescriptor{ .size = @sizeOf(ReplyBlock) };
323
324extern var _NSConcreteGlobalBlock: anyopaque;
325
326fn 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
335fn 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
348var reply_blocks: [ios_async.max_in_flight]ReplyBlock =
349 if (is_darwin) makeReplyBlocks() else undefined;
350
351fn replyBlock(ticket: ios_async.Ticket) *anyopaque {
352 return @ptrCast(&reply_blocks[ticket.index]);
353}
354
355extern "c" fn dispatch_async_f(
356 queue: *anyopaque,
357 context: ?*anyopaque,
358 work: *const fn (?*anyopaque) callconv(.c) void,
359) void;
360extern 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.
370fn 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.
390fn 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
200407// =============================================================================
201408// Payload parsing
202409// =============================================================================
@@ -660,3 +867,62 @@ test "UIKit work is refused off Darwin rather than attempted" {
660867 try testing.expectError(error.UnsupportedPlatform, applyBadge(1));
661868 try testing.expectError(error.UnsupportedPlatform, applyKeepAwake(true));
662869}
870
871test "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
880test "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
890test "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
902test "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
913test "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}