| @@ -118,6 +118,7 @@ const bridge_error = ; |
| 118 | 118 | const objc_runtime = ; |
| 119 | 119 | const ios_async = ; |
| 120 | 120 | const compat_mutex = ; |
| 121 | +const memory = ; |
| 121 | 122 | |
| 122 | 123 | const objc = objc_runtime.objc; |
| 123 | 124 | const is_darwin = builtin.target.os.tag.isDarwin(); |
| @@ -147,6 +148,7 @@ const Id = ?*anyopaque; |
| 147 | 148 | /// scheduleNotification is left to the shim rather than claimed and refused. |
| 148 | 149 | pub const A = struct { |
| 149 | 150 | pub const get_pending_notifications = "getPendingNotifications"; |
| 151 | + pub const schedule_notification = "scheduleNotification"; |
| 150 | 152 | }; |
| 151 | 153 | |
| 152 | 154 | /// `.result`: Swift resolves the callback with the array, and the JS promise is |
| @@ -158,15 +160,20 @@ pub const A = struct { |
| 158 | 160 | /// `.unavailable`. |
| 159 | 161 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 160 | 162 | .{ .name = A.get_pending_notifications, .reply = .result }, |
| 163 | + // `.result`: the spec resolves the identifier string, and the page's |
| 164 | + // promise is the untimed legacy kind. `.live`, because the reason this was |
| 165 | + // absent is gone — see the module comment. |
| 166 | + .{ .name = A.schedule_notification, .reply = .result }, |
| 161 | 167 | }; |
| 162 | 168 | |
| 163 | 169 | /// Which handler an action selects, split out from `handleMessage` so the |
| 164 | 170 | /// table-versus-dispatch agreement is assertable on a host without issuing the |
| 165 | 171 | /// Objective-C call the handler exists to make. |
| 166 | | -const Route = enum { get_pending }; |
| 172 | +const Route = enum { get_pending, schedule }; |
| 167 | 173 | |
| 168 | 174 | fn routeFor(action: []const u8) ?Route { |
| 169 | 175 | if (std.mem.eql(u8, action, A.get_pending_notifications)) return .get_pending; |
| 176 | + if (std.mem.eql(u8, action, A.schedule_notification)) return .schedule; |
| 170 | 177 | return null; |
| 171 | 178 | } |
| 172 | 179 | |
| @@ -186,6 +193,7 @@ pub const NotificationsBridge = struct { |
| 186 | 193 | // Exhaustive, so a `Route` without a handler is a compile error. |
| 187 | 194 | return switch (route) { |
| 188 | 195 | .get_pending => self.getPendingNotifications(data), |
| 196 | + .schedule => self.scheduleNotification(data), |
| 189 | 197 | }; |
| 190 | 198 | } |
| 191 | 199 | |
| @@ -227,6 +235,41 @@ pub const NotificationsBridge = struct { |
| 227 | 235 | const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 228 | 236 | func(center, sel_get, arrayBlock(ticket)); |
| 229 | 237 | } |
| 238 | + |
| 239 | + /// Schedule one local notification, and answer with its identifier. |
| 240 | + /// |
| 241 | + /// Two stages, exactly as the spec has them: ask for authorization, and |
| 242 | + /// only on a grant build the request and add it. The content and the |
| 243 | + /// request are built *inside* the authorization completion, which is where |
| 244 | + /// the spec builds them — so the autoreleased request never has to survive |
| 245 | + /// a completion boundary, and nothing needs retaining. What does survive is |
| 246 | + /// the identifier, which the second stage answers with; it is owned heap |
| 247 | + /// memory parked on the slot. |
| 248 | + /// |
| 249 | + /// Every fallible step runs before `ios_async.acquire`, per this file's |
| 250 | + /// existing rule: no error path may sit between leasing a slot and handing |
| 251 | + /// a block to the framework, because a failure there would have to release |
| 252 | + /// the slot by hand and a missed release narrows the pool permanently. |
| 253 | + fn scheduleNotification(self: *Self, data: []const u8) !void { |
| 254 | + if (!is_darwin) return error.UnsupportedPlatform; |
| 255 | + |
| 256 | + try requireBundleIdentifier(); |
| 257 | + const center = try notificationCenter(); |
| 258 | + const sels = try ScheduleSels.resolve(); |
| 259 | + const sel_request = objc.sel_registerName("requestAuthorizationWithOptions:completionHandler:") orelse |
| 260 | + return error.SelectorNotFound; |
| 261 | + |
| 262 | + var schedule = try parseSchedule(self.allocator, data); |
| 263 | + errdefer schedule.deinit(self.allocator); |
| 264 | + |
| 265 | + const ticket = ios_async.acquire(A.schedule_notification) orelse return poolFull(); |
| 266 | + |
| 267 | + publishScheduleCall(ticket, sels, schedule); |
| 268 | + |
| 269 | + const Fn = *const fn (Id, Id, c_ulong, *anyopaque) callconv(.c) void; |
| 270 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 271 | + func(center, sel_request, un_options_alert_sound_badge, boolErrorBlock(ticket)); |
| 272 | + } |
| 230 | 273 | }; |
| 231 | 274 | |
| 232 | 275 | /// The answer for a full block pool, copied from `bridge_mobile_permissions`: |
| @@ -539,12 +582,10 @@ fn arrayBlock(ticket: ios_async.Ticket) *anyopaque { |
| 539 | 582 | /// answers under the request id captured back at dispatch. |
| 540 | 583 | /// |
| 541 | 584 | /// The one path that cannot answer is a reply that will not shape: out of |
| 542 | | -/// memory, a nil array, or a NUL-truncated string. `ios_async` can resolve and |
| 543 | | -/// nothing else, so the honest options are a fabricated result or silence, and |
| 544 | | -/// silence is the lesser wrong — the slot is released, the failure is logged |
| 545 | | -/// with its cause, and the caller's untimed promise is left hanging rather than |
| 546 | | -/// resolved with a lie. This is the second reason the module comment asks for |
| 547 | | -/// `ios_async.deliverError`. |
| 585 | +/// memory, a nil array, or a NUL-truncated string. That is answered with |
| 586 | +/// `ios_async.deliverError`, which reports NATIVE_CALL_FAILED through this same |
| 587 | +/// slot and hop — so the caller learns the call failed instead of waiting out a |
| 588 | +/// promise nothing will settle. |
| 548 | 589 | fn pendingCompletionFired(index: u5, requests: Id) void { |
| 549 | 590 | if (!is_darwin) return; |
| 550 | 591 | |
| @@ -558,11 +599,19 @@ fn pendingCompletionFired(index: u5, requests: Id) void { |
| 558 | 599 | |
| 559 | 600 | const allocator = std.heap.c_allocator; |
| 560 | 601 | const json = shapePendingReply(allocator, requests, call.sels) catch |err| { |
| 602 | + // Rejects rather than going silent. This abandoned the slot and left |
| 603 | + // the caller's untimed promise hanging, on the reasoning that |
| 604 | + // `ios_async` could resolve and nothing else, so the choice was |
| 605 | + // between a fabricated result and silence. That stopped being true one |
| 606 | + // day after it was written: `deliverError` reports NATIVE_CALL_FAILED |
| 607 | + // through the same slot, the same `dispatch_async_f` hop to the main |
| 608 | + // queue, and the same restored request id. Silence was the lesser |
| 609 | + // wrong only while it was the only alternative to a lie. |
| 561 | 610 | std.log.err( |
| 562 | | - "getPendingNotifications could not shape its reply ({}); slot released, caller unanswered", |
| 611 | + "getPendingNotifications could not shape its reply ({}); rejecting", |
| 563 | 612 | .{err}, |
| 564 | 613 | ); |
| 565 | | - ios_async.abandon(call.ticket); |
| 614 | + ios_async.deliverError(call.ticket); |
| 566 | 615 | return; |
| 567 | 616 | }; |
| 568 | 617 | defer allocator.free(json); |
| @@ -570,6 +619,492 @@ fn pendingCompletionFired(index: u5, requests: Id) void { |
| 570 | 619 | ios_async.deliverJson(call.ticket, json); |
| 571 | 620 | } |
| 572 | 621 | |
| 622 | +// ============================================================================= |
| 623 | +// scheduleNotification: parsing, selectors, and the two-stage completion chain. |
| 624 | +// ============================================================================= |
| 625 | + |
| 626 | +/// `UNAuthorizationOptionAlert | Sound | Badge` = `(1<<2)|(1<<1)|(1<<0)`. |
| 627 | +/// `UNUserNotificationCenter.h:23-25`. Same value |
| 628 | +/// `bridge_mobile_permissions.zig` resolved independently, which is the |
| 629 | +/// cross-check that it is not a guess. |
| 630 | +const un_options_alert_sound_badge: c_ulong = 7; |
| 631 | + |
| 632 | +/// `NSCalendarUnitYear|Month|Day|Hour|Minute|Second`, the set the spec asks |
| 633 | +/// `Calendar.current.dateComponents` for. `NSCalendar.h:61-66` defines each as |
| 634 | +/// the matching `kCFCalendarUnit*`, and `CFCalendar.h:68-73` gives the bits: |
| 635 | +/// year `1<<2` through second `1<<7`, so the union is 252. |
| 636 | +const calendar_units_ymdhms: c_ulong = (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7); |
| 637 | + |
| 638 | +/// When the notification fires. |
| 639 | +/// |
| 640 | +/// `.now` is the spec's nil trigger — UserNotifications reads that as "deliver |
| 641 | +/// immediately". It is also where a non-positive `delay` lands, and that is a |
| 642 | +/// deliberate divergence: the spec passes `delay / 1000` to |
| 643 | +/// `UNTimeIntervalNotificationTrigger` unguarded, and an interval of zero or |
| 644 | +/// less raises an uncatchable Objective-C exception, so `{delay: 0}` SIGABRTs |
| 645 | +/// the app today. `bridge_notification.zig` documents the same trap and answers |
| 646 | +/// it the same way. Crashing the process is not a behaviour worth reproducing. |
| 647 | +const Trigger = union(enum) { |
| 648 | + now, |
| 649 | + /// Seconds, always > 0. |
| 650 | + interval: f64, |
| 651 | + /// Seconds since the epoch. |
| 652 | + calendar: f64, |
| 653 | +}; |
| 654 | + |
| 655 | +/// The parsed `notification` object, owning its strings. |
| 656 | +const Schedule = struct { |
| 657 | + /// `null` means the page sent none and the spec's `UUID().uuidString` |
| 658 | + /// applies. Generated in the Objective-C path so this stays pure. |
| 659 | + id: ?[]u8, |
| 660 | + title: []u8, |
| 661 | + body: []u8, |
| 662 | + subtitle: ?[]u8, |
| 663 | + badge: ?i64, |
| 664 | + trigger: Trigger, |
| 665 | + |
| 666 | + fn deinit(self: *Schedule, allocator: std.mem.Allocator) void { |
| 667 | + if (self.id) |v| allocator.free(v); |
| 668 | + allocator.free(self.title); |
| 669 | + allocator.free(self.body); |
| 670 | + if (self.subtitle) |v| allocator.free(v); |
| 671 | + self.* = undefined; |
| 672 | + } |
| 673 | +}; |
| 674 | + |
| 675 | +/// Read the page's `notification` object. |
| 676 | +/// |
| 677 | +/// Follows the spec's coercions rather than tightening them: a non-string |
| 678 | +/// `title` or `body` becomes `""` because `as? String ?? ""` does, and a |
| 679 | +/// non-numeric `badge` is dropped because `as? Int` yields nil. The one place |
| 680 | +/// this refuses where the spec does not is a missing `notification` object — |
| 681 | +/// the spec's `case` has no `else`, so that call never reaches a |
| 682 | +/// `resolveCallback` and the page's promise hangs forever. A rejection is the |
| 683 | +/// only honest answer available. |
| 684 | +fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule { |
| 685 | + var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |
| 686 | + return bridge_error.BridgeError.InvalidJSON; |
| 687 | + defer parsed.deinit(); |
| 688 | + |
| 689 | + const root = switch (parsed.value) { |
| 690 | + .object => |o| o, |
| 691 | + else => return bridge_error.BridgeError.InvalidJSON, |
| 692 | + }; |
| 693 | + const notification = switch (root.get("notification") orelse |
| 694 | + return bridge_error.BridgeError.MissingData) { |
| 695 | + .object => |o| o, |
| 696 | + else => return bridge_error.BridgeError.MissingData, |
| 697 | + }; |
| 698 | + |
| 699 | + // `as? String ?? ""`, field by field. |
| 700 | + const title = try allocator.dupe(u8, stringOr(notification.get("title"), "")); |
| 701 | + errdefer allocator.free(title); |
| 702 | + const body = try allocator.dupe(u8, stringOr(notification.get("body"), "")); |
| 703 | + errdefer allocator.free(body); |
| 704 | + |
| 705 | + const subtitle: ?[]u8 = if (notification.get("subtitle")) |v| switch (v) { |
| 706 | + .string => |str| try allocator.dupe(u8, str), |
| 707 | + else => null, |
| 708 | + } else null; |
| 709 | + errdefer if (subtitle) |v| allocator.free(v); |
| 710 | + |
| 711 | + const id: ?[]u8 = if (notification.get("id")) |v| switch (v) { |
| 712 | + .string => |str| try allocator.dupe(u8, str), |
| 713 | + else => null, |
| 714 | + } else null; |
| 715 | + errdefer if (id) |v| allocator.free(v); |
| 716 | + |
| 717 | + return .{ |
| 718 | + .id = id, |
| 719 | + .title = title, |
| 720 | + .body = body, |
| 721 | + .subtitle = subtitle, |
| 722 | + .badge = numberOr(notification.get("badge")), |
| 723 | + .trigger = triggerFrom(notification), |
| 724 | + }; |
| 725 | +} |
| 726 | + |
| 727 | +fn stringOr(value: ?std.json.Value, fallback: []const u8) []const u8 { |
| 728 | + const v = value orelse return fallback; |
| 729 | + return switch (v) { |
| 730 | + .string => |s| s, |
| 731 | + else => fallback, |
| 732 | + }; |
| 733 | +} |
| 734 | + |
| 735 | +/// `as? Int`: a JSON integer, or a float that is exactly one. Anything else is |
| 736 | +/// nil, and the badge is left unset. |
| 737 | +fn numberOr(value: ?std.json.Value) ?i64 { |
| 738 | + const v = value orelse return null; |
| 739 | + return switch (v) { |
| 740 | + .integer => |i| i, |
| 741 | + .float => |f| if (f == @trunc(f) and f >= -9.2e18 and f <= 9.2e18) @intFromFloat(f) else null, |
| 742 | + else => null, |
| 743 | + }; |
| 744 | +} |
| 745 | + |
| 746 | +/// `timestamp` wins over `delay`, as it does in the spec's if/else-if. |
| 747 | +fn triggerFrom(notification: std.json.ObjectMap) Trigger { |
| 748 | + if (millisFrom(notification.get("timestamp"))) |ms| return .{ .calendar = ms / 1000.0 }; |
| 749 | + if (millisFrom(notification.get("delay"))) |ms| { |
| 750 | + const seconds = ms / 1000.0; |
| 751 | + // See `Trigger`: the spec crashes here, this delivers now. |
| 752 | + return if (seconds > 0) .{ .interval = seconds } else .now; |
| 753 | + } |
| 754 | + return .now; |
| 755 | +} |
| 756 | + |
| 757 | +fn millisFrom(value: ?std.json.Value) ?f64 { |
| 758 | + const v = value orelse return null; |
| 759 | + return switch (v) { |
| 760 | + .float => |f| f, |
| 761 | + .integer => |i| @floatFromInt(i), |
| 762 | + else => null, |
| 763 | + }; |
| 764 | +} |
| 765 | + |
| 766 | +/// Selectors for the schedule path, all resolved before a slot is leased. |
| 767 | +const ScheduleSels = struct { |
| 768 | + ns_string: Id, |
| 769 | + number_with_ll: Id, |
| 770 | + content_alloc_init: Id, |
| 771 | + set_title: Id, |
| 772 | + set_body: Id, |
| 773 | + set_subtitle: Id, |
| 774 | + set_badge: Id, |
| 775 | + set_sound: Id, |
| 776 | + default_sound: Id, |
| 777 | + trigger_interval: Id, |
| 778 | + trigger_calendar: Id, |
| 779 | + current_calendar: Id, |
| 780 | + components_from: Id, |
| 781 | + date_with_since1970: Id, |
| 782 | + request_with: Id, |
| 783 | + add_request: Id, |
| 784 | + uuid_string: Id, |
| 785 | + |
| 786 | + fn resolve() !ScheduleSels { |
| 787 | + if (!is_darwin) return error.UnsupportedPlatform; |
| 788 | + return .{ |
| 789 | + .ns_string = try selector("stringWithUTF8String:"), |
| 790 | + .number_with_ll = try selector("numberWithLongLong:"), |
| 791 | + .content_alloc_init = try selector("init"), |
| 792 | + .set_title = try selector("setTitle:"), |
| 793 | + .set_body = try selector("setBody:"), |
| 794 | + .set_subtitle = try selector("setSubtitle:"), |
| 795 | + .set_badge = try selector("setBadge:"), |
| 796 | + .set_sound = try selector("setSound:"), |
| 797 | + .default_sound = try selector("defaultSound"), |
| 798 | + .trigger_interval = try selector("triggerWithTimeInterval:repeats:"), |
| 799 | + .trigger_calendar = try selector("triggerWithDateMatchingComponents:repeats:"), |
| 800 | + .current_calendar = try selector("currentCalendar"), |
| 801 | + .components_from = try selector("components:fromDate:"), |
| 802 | + .date_with_since1970 = try selector("dateWithTimeIntervalSince1970:"), |
| 803 | + .request_with = try selector("requestWithIdentifier:content:trigger:"), |
| 804 | + .add_request = try selector("addNotificationRequest:withCompletionHandler:"), |
| 805 | + .uuid_string = try selector("UUIDString"), |
| 806 | + }; |
| 807 | + } |
| 808 | +}; |
| 809 | + |
| 810 | +const ScheduleCall = struct { |
| 811 | + ticket: ios_async.Ticket, |
| 812 | + sels: ScheduleSels, |
| 813 | + schedule: Schedule, |
| 814 | +}; |
| 815 | + |
| 816 | +var schedule_calls: [ios_async.max_in_flight]?ScheduleCall = @splat(null); |
| 817 | + |
| 818 | +fn publishScheduleCall(ticket: ios_async.Ticket, sels: ScheduleSels, schedule: Schedule) void { |
| 819 | + pending_mutex.lock(); |
| 820 | + defer pending_mutex.unlock(); |
| 821 | + schedule_calls[ticket.index] = .{ .ticket = ticket, .sels = sels, .schedule = schedule }; |
| 822 | +} |
| 823 | + |
| 824 | +fn takeScheduleCall(index: u5) ?ScheduleCall { |
| 825 | + pending_mutex.lock(); |
| 826 | + defer pending_mutex.unlock(); |
| 827 | + const call = schedule_calls[index]; |
| 828 | + schedule_calls[index] = null; |
| 829 | + return call; |
| 830 | +} |
| 831 | + |
| 832 | +/// `void (^)(BOOL, NSError *)` — the authorization completion. |
| 833 | +const BoolErrorBlock = extern struct { |
| 834 | + isa: ?*anyopaque, |
| 835 | + flags: c_int, |
| 836 | + reserved: c_int = 0, |
| 837 | + invoke: *const anyopaque, |
| 838 | + descriptor: *const BlockDescriptor, |
| 839 | +}; |
| 840 | + |
| 841 | +/// `void (^)(NSError *)` — `addNotificationRequest:`'s completion. |
| 842 | +const ErrorBlock = extern struct { |
| 843 | + isa: ?*anyopaque, |
| 844 | + flags: c_int, |
| 845 | + reserved: c_int = 0, |
| 846 | + invoke: *const anyopaque, |
| 847 | + descriptor: *const BlockDescriptor, |
| 848 | +}; |
| 849 | + |
| 850 | +const bool_error_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolErrorBlock) }; |
| 851 | +const error_block_descriptor = BlockDescriptor{ .size = @sizeOf(ErrorBlock) }; |
| 852 | + |
| 853 | +fn makeBoolErrorInvoke(comptime index: u5) *const anyopaque { |
| 854 | + const S = struct { |
| 855 | + fn invoke(_: *const BoolErrorBlock, granted: bool, _: Id) callconv(.c) void { |
| 856 | + authorizationFired(index, granted); |
| 857 | + } |
| 858 | + }; |
| 859 | + return @ptrCast(&S.invoke); |
| 860 | +} |
| 861 | + |
| 862 | +fn makeBoolErrorBlocks() [ios_async.max_in_flight]BoolErrorBlock { |
| 863 | + var out: [ios_async.max_in_flight]BoolErrorBlock = undefined; |
| 864 | + for (&out, 0..) |*b, i| { |
| 865 | + b.* = .{ |
| 866 | + .isa = &_NSConcreteGlobalBlock, |
| 867 | + .flags = BLOCK_IS_GLOBAL, |
| 868 | + .invoke = makeBoolErrorInvoke(@intCast(i)), |
| 869 | + .descriptor = &bool_error_block_descriptor, |
| 870 | + }; |
| 871 | + } |
| 872 | + return out; |
| 873 | +} |
| 874 | + |
| 875 | +fn makeErrorInvoke(comptime index: u5) *const anyopaque { |
| 876 | + const S = struct { |
| 877 | + fn invoke(_: *const ErrorBlock, err: Id) callconv(.c) void { |
| 878 | + addFired(index, err); |
| 879 | + } |
| 880 | + }; |
| 881 | + return @ptrCast(&S.invoke); |
| 882 | +} |
| 883 | + |
| 884 | +fn makeErrorBlocks() [ios_async.max_in_flight]ErrorBlock { |
| 885 | + var out: [ios_async.max_in_flight]ErrorBlock = undefined; |
| 886 | + for (&out, 0..) |*b, i| { |
| 887 | + b.* = .{ |
| 888 | + .isa = &_NSConcreteGlobalBlock, |
| 889 | + .flags = BLOCK_IS_GLOBAL, |
| 890 | + .invoke = makeErrorInvoke(@intCast(i)), |
| 891 | + .descriptor = &error_block_descriptor, |
| 892 | + }; |
| 893 | + } |
| 894 | + return out; |
| 895 | +} |
| 896 | + |
| 897 | +var bool_error_blocks: [ios_async.max_in_flight]BoolErrorBlock = |
| 898 | + if (is_darwin) makeBoolErrorBlocks() else undefined; |
| 899 | +var error_blocks: [ios_async.max_in_flight]ErrorBlock = |
| 900 | + if (is_darwin) makeErrorBlocks() else undefined; |
| 901 | + |
| 902 | +fn boolErrorBlock(ticket: ios_async.Ticket) *anyopaque { |
| 903 | + return @ptrCast(&bool_error_blocks[ticket.index]); |
| 904 | +} |
| 905 | + |
| 906 | +fn errorBlock(ticket: ios_async.Ticket) *anyopaque { |
| 907 | + return @ptrCast(&error_blocks[ticket.index]); |
| 908 | +} |
| 909 | + |
| 910 | +/// Stage one, on whatever queue UserNotifications chose. |
| 911 | +/// |
| 912 | +/// A refusal answers `PERMISSION_DENIED`, where the spec rejects with the |
| 913 | +/// string "Permission denied" — the typed code every other migrated module |
| 914 | +/// uses, carrying the same meaning in a form a page can branch on. |
| 915 | +fn authorizationFired(index: u5, granted: bool) void { |
| 916 | + if (!is_darwin) return; |
| 917 | + |
| 918 | + var call = takeScheduleCall(index) orelse { |
| 919 | + std.log.warn( |
| 920 | + "scheduleNotification authorization fired for slot {d} with no call recorded; ignored", |
| 921 | + .{index}, |
| 922 | + ); |
| 923 | + return; |
| 924 | + }; |
| 925 | + const allocator = std.heap.c_allocator; |
| 926 | + |
| 927 | + if (!granted) { |
| 928 | + call.schedule.deinit(allocator); |
| 929 | + ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.PermissionDenied); |
| 930 | + return; |
| 931 | + } |
| 932 | + |
| 933 | + const request = buildRequest(&call.schedule, call.sels) catch |err| { |
| 934 | + std.log.err("scheduleNotification could not build its request ({}); rejecting", .{err}); |
| 935 | + call.schedule.deinit(allocator); |
| 936 | + ios_async.deliverError(call.ticket); |
| 937 | + return; |
| 938 | + }; |
| 939 | + |
| 940 | + const center = notificationCenter() catch { |
| 941 | + call.schedule.deinit(allocator); |
| 942 | + ios_async.deliverError(call.ticket); |
| 943 | + return; |
| 944 | + }; |
| 945 | + |
| 946 | + // Republished before the add, for the reason the first publish exists: the |
| 947 | + // completion can fire before `msgSend` returns. |
| 948 | + publishScheduleCall(call.ticket, call.sels, call.schedule); |
| 949 | + |
| 950 | + const Fn = *const fn (Id, Id, Id, *anyopaque) callconv(.c) void; |
| 951 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 952 | + func(center, call.sels.add_request, request, errorBlock(call.ticket)); |
| 953 | +} |
| 954 | + |
| 955 | +/// Stage two. Answers the identifier the request was filed under, which is what |
| 956 | +/// the page needs to cancel it later. |
| 957 | +fn addFired(index: u5, err: Id) void { |
| 958 | + if (!is_darwin) return; |
| 959 | + |
| 960 | + var call = takeScheduleCall(index) orelse { |
| 961 | + std.log.warn( |
| 962 | + "scheduleNotification add fired for slot {d} with no call recorded; ignored", |
| 963 | + .{index}, |
| 964 | + ); |
| 965 | + return; |
| 966 | + }; |
| 967 | + const allocator = std.heap.c_allocator; |
| 968 | + defer call.schedule.deinit(allocator); |
| 969 | + |
| 970 | + if (err != null) { |
| 971 | + std.log.warn("scheduleNotification: addNotificationRequest reported an error", .{}); |
| 972 | + ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed); |
| 973 | + return; |
| 974 | + } |
| 975 | + |
| 976 | + const id = call.schedule.id orelse { |
| 977 | + // `buildRequest` fills this in from `NSUUID` before the add, so a null |
| 978 | + // here means the request was filed under an identifier nothing kept. |
| 979 | + // Answering anyway would hand the page a value it cannot cancel with. |
| 980 | + std.log.err("scheduleNotification: the request was added with no identifier recorded", .{}); |
| 981 | + ios_async.deliverError(call.ticket); |
| 982 | + return; |
| 983 | + }; |
| 984 | + |
| 985 | + // A bare JSON string, which is what the spec resolves. Escaped, because a |
| 986 | + // page-supplied identifier can contain a quote. |
| 987 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 988 | + defer out.deinit(allocator); |
| 989 | + out.append(allocator, '"') catch return ios_async.deliverError(call.ticket); |
| 990 | + bridge_error.appendJsonEscaped(allocator, &out, id) catch |
| 991 | + return ios_async.deliverError(call.ticket); |
| 992 | + out.append(allocator, '"') catch return ios_async.deliverError(call.ticket); |
| 993 | + |
| 994 | + ios_async.deliverJson(call.ticket, out.items); |
| 995 | +} |
| 996 | + |
| 997 | +/// Build `UNNotificationRequest` from the parsed schedule. |
| 998 | +/// |
| 999 | +/// Runs inside the authorization completion, where the spec builds it, so the |
| 1000 | +/// autoreleased result is handed straight to `addNotificationRequest:` without |
| 1001 | +/// crossing a pool boundary. |
| 1002 | +/// |
| 1003 | +/// Fills `schedule.id` when the page sent none, using `NSUUID` exactly as the |
| 1004 | +/// spec's `UUID().uuidString` does — so the identifier answered to the page is |
| 1005 | +/// the identifier the request was actually filed under. |
| 1006 | +fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id { |
| 1007 | + const allocator = std.heap.c_allocator; |
| 1008 | + |
| 1009 | + const UNMutableNotificationContent = objc.objc_getClass("UNMutableNotificationContent") orelse |
| 1010 | + return error.ClassNotFound; |
| 1011 | + const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound; |
| 1012 | + const content = objc.msgSendId(objc.msgSendId(UNMutableNotificationContent, sel_alloc), sels.content_alloc_init); |
| 1013 | + if (content == null) return error.NoContent; |
| 1014 | + |
| 1015 | + try setStringField(content, sels.set_title, schedule.title, sels); |
| 1016 | + try setStringField(content, sels.set_body, schedule.body, sels); |
| 1017 | + if (schedule.subtitle) |sub| try setStringField(content, sels.set_subtitle, sub, sels); |
| 1018 | + |
| 1019 | + if (schedule.badge) |badge| { |
| 1020 | + const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound; |
| 1021 | + const NumFn = *const fn (Id, Id, c_longlong) callconv(.c) Id; |
| 1022 | + const num: NumFn = @ptrCast(&objc.objc_msgSend); |
| 1023 | + const boxed = num(NSNumber, sels.number_with_ll, @intCast(badge)); |
| 1024 | + objc.msgSendVoid1(content, sels.set_badge, boxed); |
| 1025 | + } |
| 1026 | + |
| 1027 | + // `content.sound = .default`, unconditional in the spec. |
| 1028 | + const UNNotificationSound = objc.objc_getClass("UNNotificationSound") orelse |
| 1029 | + return error.ClassNotFound; |
| 1030 | + objc.msgSendVoid1(content, sels.set_sound, objc.msgSendId(UNNotificationSound, sels.default_sound)); |
| 1031 | + |
| 1032 | + const trigger = try buildTrigger(schedule.trigger, sels); |
| 1033 | + |
| 1034 | + if (schedule.id == null) schedule.id = try uuidString(allocator, sels); |
| 1035 | + const ns_id = try nsString(schedule.id.?, sels); |
| 1036 | + |
| 1037 | + const UNNotificationRequest = objc.objc_getClass("UNNotificationRequest") orelse |
| 1038 | + return error.ClassNotFound; |
| 1039 | + const ReqFn = *const fn (Id, Id, Id, Id, Id) callconv(.c) Id; |
| 1040 | + const req: ReqFn = @ptrCast(&objc.objc_msgSend); |
| 1041 | + const request = req(UNNotificationRequest, sels.request_with, ns_id, content, trigger); |
| 1042 | + if (request == null) return error.NoRequest; |
| 1043 | + return request; |
| 1044 | +} |
| 1045 | + |
| 1046 | +/// A nil trigger is legal and means "deliver now" — see `Trigger`. |
| 1047 | +fn buildTrigger(trigger: Trigger, sels: ScheduleSels) !Id { |
| 1048 | + switch (trigger) { |
| 1049 | + .now => return null, |
| 1050 | + .interval => |seconds| { |
| 1051 | + const UNTimeIntervalNotificationTrigger = |
| 1052 | + objc.objc_getClass("UNTimeIntervalNotificationTrigger") orelse return error.ClassNotFound; |
| 1053 | + const Fn = *const fn (Id, Id, f64, bool) callconv(.c) Id; |
| 1054 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 1055 | + return func(UNTimeIntervalNotificationTrigger, sels.trigger_interval, seconds, false); |
| 1056 | + }, |
| 1057 | + .calendar => |epoch_seconds| { |
| 1058 | + const NSDate = objc.objc_getClass("NSDate") orelse return error.ClassNotFound; |
| 1059 | + const DateFn = *const fn (Id, Id, f64) callconv(.c) Id; |
| 1060 | + const date_fn: DateFn = @ptrCast(&objc.objc_msgSend); |
| 1061 | + const date = date_fn(NSDate, sels.date_with_since1970, epoch_seconds); |
| 1062 | + |
| 1063 | + const NSCalendar = objc.objc_getClass("NSCalendar") orelse return error.ClassNotFound; |
| 1064 | + const calendar = objc.msgSendId(NSCalendar, sels.current_calendar); |
| 1065 | + const CompFn = *const fn (Id, Id, c_ulong, Id) callconv(.c) Id; |
| 1066 | + const comp_fn: CompFn = @ptrCast(&objc.objc_msgSend); |
| 1067 | + const components = comp_fn(calendar, sels.components_from, calendar_units_ymdhms, date); |
| 1068 | + |
| 1069 | + const UNCalendarNotificationTrigger = |
| 1070 | + objc.objc_getClass("UNCalendarNotificationTrigger") orelse return error.ClassNotFound; |
| 1071 | + const TrigFn = *const fn (Id, Id, Id, bool) callconv(.c) Id; |
| 1072 | + const trig_fn: TrigFn = @ptrCast(&objc.objc_msgSend); |
| 1073 | + return trig_fn(UNCalendarNotificationTrigger, sels.trigger_calendar, components, false); |
| 1074 | + }, |
| 1075 | + } |
| 1076 | +} |
| 1077 | + |
| 1078 | +fn nsString(text: []const u8, sels: ScheduleSels) !Id { |
| 1079 | + const allocator = std.heap.c_allocator; |
| 1080 | + // `memory.dupeZ`, not `allocator.dupeZ`: 0.17 dropped the method in some |
| 1081 | + // dev builds, which is the churn that helper exists to absorb. |
| 1082 | + const c_text = try memory.dupeZ(allocator, u8, text); |
| 1083 | + defer allocator.free(c_text); |
| 1084 | + |
| 1085 | + const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound; |
| 1086 | + const Fn = *const fn (Id, Id, [*:0]const u8) callconv(.c) Id; |
| 1087 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 1088 | + const ns = func(NSString, sels.ns_string, c_text.ptr); |
| 1089 | + if (ns == null) return error.NoString; |
| 1090 | + return ns; |
| 1091 | +} |
| 1092 | + |
| 1093 | +fn setStringField(content: Id, sel: Id, text: []const u8, sels: ScheduleSels) !void { |
| 1094 | + objc.msgSendVoid1(content, sel, try nsString(text, sels)); |
| 1095 | +} |
| 1096 | + |
| 1097 | +fn uuidString(allocator: std.mem.Allocator, sels: ScheduleSels) ![]u8 { |
| 1098 | + const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound; |
| 1099 | + const sel_uuid = objc.sel_registerName("UUID") orelse return error.SelectorNotFound; |
| 1100 | + const uuid = objc.msgSendId(NSUUID, sel_uuid); |
| 1101 | + if (uuid == null) return error.NoUUID; |
| 1102 | + const ns = objc.msgSendId(uuid, sels.uuid_string); |
| 1103 | + if (ns == null) return error.NoUUID; |
| 1104 | + const utf8 = objc.getNSStringUTF8(ns) orelse return error.NoUUID; |
| 1105 | + return allocator.dupe(u8, std.mem.span(utf8)); |
| 1106 | +} |
| 1107 | + |
| 573 | 1108 | // ============================================================================= |
| 574 | 1109 | // Tests — host-only. |
| 575 | 1110 | // |
| @@ -585,8 +1120,11 @@ fn pendingCompletionFired(index: u5, requests: Id) void { |
| 585 | 1120 | const testing = std.testing; |
| 586 | 1121 | |
| 587 | 1122 | test "the declared actions are the ones the handler serves" { |
| 588 | | - try testing.expectEqual(@as(usize, 1), capability_actions.len); |
| 1123 | + try testing.expectEqual(@as(usize, 2), capability_actions.len); |
| 589 | 1124 | try testing.expectEqualStrings(A.get_pending_notifications, capability_actions[0].name); |
| 1125 | + try testing.expectEqualStrings(A.schedule_notification, capability_actions[1].name); |
| 1126 | + try testing.expectEqual(capabilities.Reply.result, capability_actions[1].reply); |
| 1127 | + try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[1].status); |
| 590 | 1128 | |
| 591 | 1129 | // A `.result` whose handler never replies parks the caller on an untimed |
| 592 | 1130 | // promise; a `.none` that is awaited resolves immediately and means |
| @@ -680,27 +1218,106 @@ test "an action the namespace does not serve is reported, not ignored" { |
| 680 | 1218 | ); |
| 681 | 1219 | } |
| 682 | 1220 | |
| 683 | | -test "scheduleNotification is left to the Swift shim, not claimed and refused" { |
| 684 | | - // This is the deliberate omission, asserted rather than left implicit. |
| 685 | | - // `UnknownAction` is what makes `ios_dispatch` fall through to the host |
| 686 | | - // shim, which schedules notifications correctly today. Declaring the action |
| 687 | | - // `.unavailable` instead would take it away from the shim in order to |
| 688 | | - // reject it, which is strictly worse for the page — see the module comment |
| 689 | | - // for what `ios_async` still owes before this can be served honestly. |
| 1221 | +test "scheduleNotification is claimed now, and routes" { |
| 1222 | + // This asserted the opposite until the blocker went away. The module |
| 1223 | + // comment said `ios_async` could only resolve, so an action whose two |
| 1224 | + // failure paths are both rejections could not be served honestly and was |
| 1225 | + // left to the shim. `deliverErrorCode` landed the day after that was |
| 1226 | + // written; both refusals have a home now, and the action is served. |
| 690 | 1227 | var bridge = NotificationsBridge.init(testing.allocator); |
| 691 | 1228 | defer bridge.deinit(); |
| 692 | 1229 | |
| 1230 | + try testing.expect(routeFor(A.schedule_notification) == .schedule); |
| 1231 | + |
| 1232 | + var declared = false; |
| 1233 | + for (capability_actions) |decl| { |
| 1234 | + if (std.mem.eql(u8, decl.name, A.schedule_notification)) declared = true; |
| 1235 | + } |
| 1236 | + try testing.expect(declared); |
| 1237 | + |
| 1238 | + // On the host there is no bundle identifier, so it refuses before touching |
| 1239 | + // the notification center — which is the guard, not a missing migration. |
| 693 | 1240 | try testing.expectError( |
| 694 | | - bridge_error.BridgeError.UnknownAction, |
| 695 | | - bridge.handleMessage("scheduleNotification", "{\"notification\":{\"title\":\"t\"}}"), |
| 1241 | + error.NoBundleIdentifier, |
| 1242 | + bridge.handleMessage(A.schedule_notification, "{\"notification\":{\"title\":\"t\"}}"), |
| 696 | 1243 | ); |
| 697 | | - try testing.expect(routeFor("scheduleNotification") == null); |
| 1244 | +} |
| 698 | 1245 | |
| 699 | | - // And it must not be declared, or `capabilities` would tell an app craft |
| 700 | | - // serves it. |
| 701 | | - for (capability_actions) |decl| { |
| 702 | | - try testing.expect(!std.mem.eql(u8, decl.name, "scheduleNotification")); |
| 1246 | +test "the payload is read the way the spec reads it" { |
| 1247 | + const allocator = testing.allocator; |
| 1248 | + |
| 1249 | + // `as? String ?? ""` for title and body; a non-string is not an error. |
| 1250 | + var plain = try parseSchedule(allocator, "{\"notification\":{\"title\":7,\"body\":\"b\"}}"); |
| 1251 | + defer plain.deinit(allocator); |
| 1252 | + try testing.expectEqualStrings("", plain.title); |
| 1253 | + try testing.expectEqualStrings("b", plain.body); |
| 1254 | + try testing.expect(plain.subtitle == null); |
| 1255 | + try testing.expect(plain.badge == null); |
| 1256 | + try testing.expect(plain.id == null); |
| 1257 | + try testing.expect(plain.trigger == .now); |
| 1258 | + |
| 1259 | + // `timestamp` wins over `delay`, as the spec's if/else-if does. |
| 1260 | + var both = try parseSchedule(allocator, "{\"notification\":{\"timestamp\":2000,\"delay\":9000}}"); |
| 1261 | + defer both.deinit(allocator); |
| 1262 | + try testing.expectEqual(@as(f64, 2.0), both.trigger.calendar); |
| 1263 | + |
| 1264 | + var delayed = try parseSchedule(allocator, "{\"notification\":{\"delay\":1500}}"); |
| 1265 | + defer delayed.deinit(allocator); |
| 1266 | + try testing.expectEqual(@as(f64, 1.5), delayed.trigger.interval); |
| 1267 | + |
| 1268 | + // A missing `notification` hangs the page in the spec — its `case` has no |
| 1269 | + // `else` and never reaches a callback. Rejecting is the only honest answer. |
| 1270 | + try testing.expectError( |
| 1271 | + bridge_error.BridgeError.MissingData, |
| 1272 | + parseSchedule(allocator, "{}"), |
| 1273 | + ); |
| 1274 | +} |
| 1275 | + |
| 1276 | +test "a non-positive delay delivers now instead of raising" { |
| 1277 | + // The spec passes `delay / 1000` to `UNTimeIntervalNotificationTrigger` |
| 1278 | + // unguarded, and an interval of zero or less raises an uncatchable ObjC |
| 1279 | + // exception — so `{delay: 0}` SIGABRTs the app today. A nil trigger is how |
| 1280 | + // UserNotifications spells "now"; `bridge_notification.zig` answers the |
| 1281 | + // same trap the same way. |
| 1282 | + const allocator = testing.allocator; |
| 1283 | + for ([_][]const u8{ |
| 1284 | + "{\"notification\":{\"delay\":0}}", |
| 1285 | + "{\"notification\":{\"delay\":-1}}", |
| 1286 | + "{\"notification\":{\"delay\":-0.5}}", |
| 1287 | + }) |payload| { |
| 1288 | + var s2 = try parseSchedule(allocator, payload); |
| 1289 | + defer s2.deinit(allocator); |
| 1290 | + try testing.expect(s2.trigger == .now); |
| 1291 | + } |
| 1292 | +} |
| 1293 | + |
| 1294 | +test "the calendar unit mask is the header's, not a guess" { |
| 1295 | + // `NSCalendar.h:61-66` defines each unit as the matching `kCFCalendarUnit*`, |
| 1296 | + // and `CFCalendar.h:68-73` gives year `1<<2` through second `1<<7`. A |
| 1297 | + // sibling module once shipped a guessed HealthKit bit that named a |
| 1298 | + // different option and crashed, so this cites its source. |
| 1299 | + try testing.expectEqual(@as(c_ulong, 252), calendar_units_ymdhms); |
| 1300 | + try testing.expect(calendar_units_ymdhms & (1 << 1) == 0); // era, not asked for |
| 1301 | + try testing.expectEqual(@as(c_ulong, 7), un_options_alert_sound_badge); |
| 1302 | +} |
| 1303 | + |
| 1304 | +test "each schedule slot has its own block, and a call is taken once" { |
| 1305 | + if (!is_darwin) return error.SkipZigTest; |
| 1306 | + for (&bool_error_blocks) |*b| { |
| 1307 | + try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); |
| 1308 | + try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); |
| 703 | 1309 | } |
| 1310 | + try testing.expect(bool_error_blocks[0].invoke != bool_error_blocks[1].invoke); |
| 1311 | + try testing.expect(error_blocks[0].invoke != error_blocks[1].invoke); |
| 1312 | + |
| 1313 | + // Take-and-clear is what makes a double-fired completion a no-op rather |
| 1314 | + // than a second reply. |
| 1315 | + const allocator = testing.allocator; |
| 1316 | + var schedule = try parseSchedule(allocator, "{\"notification\":{\"title\":\"t\"}}"); |
| 1317 | + defer schedule.deinit(allocator); |
| 1318 | + publishScheduleCall(.{ .index = 2, .generation = 9 }, undefined, schedule); |
| 1319 | + try testing.expect(takeScheduleCall(2) != null); |
| 1320 | + try testing.expect(takeScheduleCall(2) == null); |
| 704 | 1321 | } |
| 705 | 1322 | |
| 706 | 1323 | test "the payload is ignored, not parsed" { |