also looking at this
feat(notifications): make the banner answerable — clicks, buttons, and a crash
#74
8 files
+942
-17
| @@ -48,8 +48,16 @@ pub const NotificationBridge = struct { | ||
| 48 | 48 | return; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | // Try to get the notification center - this can crash if called too early | |
| 52 | // so we defer this until actually needed | |
| 51 | // `currentNotificationCenter` raises NSInternalInconsistencyException | |
| 52 | // for a process with no bundle identifier — a bare `craft` binary run | |
| 53 | // from a shell — and an uncaught Objective-C exception aborts the | |
| 54 | // process. The comment that used to sit here warned this "can crash if | |
| 55 | // called too early"; the condition is the bundle, not the timing. | |
| 56 | if (!hasBundleIdentifier()) { | |
| 57 | log.warn("no bundle identifier; notifications need a packaged app", .{}); | |
| 58 | return; | |
| 59 | } | |
| 60 | ||
| 53 | 61 | self.notification_center = macos.msgSend0(UNUserNotificationCenter, "currentNotificationCenter"); |
| 54 | 62 | |
| 55 | 63 | if (self.notification_center) |center| { |
| @@ -100,8 +108,14 @@ pub const NotificationBridge = struct { | ||
| 100 | 108 | bridge_error.sendErrorToJS(self.allocator, action, bridge_err); |
| 101 | 109 | } |
| 102 | 110 | |
| 103 | /// Show an immediate notification | |
| 104 | /// JSON: {"id": "notif1", "title": "Hello", "body": "World", "sound": true, "actionId": "open"} | |
| 111 | /// Show an immediate notification. | |
| 112 | /// | |
| 113 | /// JSON: {"id", "title", "body", "subtitle", "sound", "actions":[{"id","label"}]} | |
| 114 | /// | |
| 115 | /// The doc comment here used to advertise an `"actionId"` field. Nothing | |
| 116 | /// has ever parsed it, and with real action buttons there is nothing for | |
| 117 | /// it to mean — a response names the button the user pressed, which the | |
| 118 | /// app already chose the id of. Removed rather than implemented. | |
| 105 | 119 | fn showNotification(self: *Self, data: []const u8) !void { |
| 106 | 120 | if (builtin.os.tag == .linux) { |
| 107 | 121 | try self.linuxShowNotification(data); |
| @@ -122,6 +136,7 @@ pub const NotificationBridge = struct { | ||
| 122 | 136 | body: []const u8 = "", |
| 123 | 137 | subtitle: []const u8 = "", |
| 124 | 138 | sound: bool = true, |
| 139 | actions: []const ActionSpec = &.{}, | |
| 125 | 140 | }; |
| 126 | 141 | |
| 127 | 142 | const parsed = std.json.parseFromSlice(ShowParams, self.allocator, data, .{ |
| @@ -180,6 +195,8 @@ pub const NotificationBridge = struct { | ||
| 180 | 195 | _ = macos.msgSend1(content, "setSound:", default_sound); |
| 181 | 196 | } |
| 182 | 197 | |
| 198 | applyActions(content, params.actions); | |
| 199 | ||
| 183 | 200 | // Create trigger (nil for immediate) |
| 184 | 201 | const trigger: ?*anyopaque = null; |
| 185 | 202 | |
| @@ -222,6 +239,8 @@ pub const NotificationBridge = struct { | ||
| 222 | 239 | title: []const u8 = "", |
| 223 | 240 | body: []const u8 = "", |
| 224 | 241 | delay: f64 = 60.0, |
| 242 | /// Buttons on the banner. Empty means a plain notification. | |
| 243 | actions: []const ActionSpec = &.{}, | |
| 225 | 244 | }; |
| 226 | 245 | |
| 227 | 246 | const parsed = std.json.parseFromSlice(ScheduleParams, self.allocator, data, .{ |
| @@ -263,10 +282,21 @@ pub const NotificationBridge = struct { | ||
| 263 | 282 | const default_sound = macos.msgSend0(UNNotificationSound, "defaultSound"); |
| 264 | 283 | _ = macos.msgSend1(content, "setSound:", default_sound); |
| 265 | 284 | |
| 266 | // Create time interval trigger | |
| 267 | const UNTimeIntervalNotificationTrigger = macos.getClass("UNTimeIntervalNotificationTrigger"); | |
| 268 | const msg_trigger = @as(*const fn (@TypeOf(UNTimeIntervalNotificationTrigger), macos_mod.objc.SEL, f64, bool) callconv(.c) *anyopaque, @ptrCast(&macos_mod.objc.objc_msgSend)); | |
| 269 | const trigger = msg_trigger(UNTimeIntervalNotificationTrigger, macos.sel("triggerWithTimeInterval:repeats:"), delay, false); | |
| 285 | applyActions(content, params.actions); | |
| 286 | ||
| 287 | // A trigger, unless the caller asked for now. | |
| 288 | // | |
| 289 | // `UNTimeIntervalNotificationTrigger` rejects an interval of zero, and | |
| 290 | // a nil trigger is how UserNotifications spells "immediately". This is | |
| 291 | // what `craft.notifications.show()` uses: it routes here, and with the | |
| 292 | // 60-second default it put every "show" a minute into the future — | |
| 293 | // which from the page looks exactly like a notification that never | |
| 294 | // arrived. | |
| 295 | const trigger: ?*anyopaque = if (delay > 0) blk: { | |
| 296 | const UNTimeIntervalNotificationTrigger = macos.getClass("UNTimeIntervalNotificationTrigger"); | |
| 297 | const msg_trigger = @as(*const fn (@TypeOf(UNTimeIntervalNotificationTrigger), macos_mod.objc.SEL, f64, bool) callconv(.c) *anyopaque, @ptrCast(&macos_mod.objc.objc_msgSend)); | |
| 298 | break :blk msg_trigger(UNTimeIntervalNotificationTrigger, macos.sel("triggerWithTimeInterval:repeats:"), delay, false); | |
| 299 | } else null; | |
| 270 | 300 | |
| 271 | 301 | // Create request |
| 272 | 302 | const id_cstr = try .dupeZ(self.allocator, u8, id); |
| @@ -604,3 +634,446 @@ pub const NotificationBridge = struct { | ||
| 604 | 634 | self.pending_callbacks.deinit(); |
| 605 | 635 | } |
| 606 | 636 | }; |
| 637 | ||
| 638 | // ============================================================================= | |
| 639 | // Responses: clicks and action buttons (#65) | |
| 640 | // ============================================================================= | |
| 641 | // | |
| 642 | // A banner with nothing behind it is a dead end. Craft posted real | |
| 643 | // `UNUserNotificationCenter` notifications but registered no delegate, so a | |
| 644 | // click on one reached nothing — the page was never told the user had | |
| 645 | // answered. Action buttons did not exist at all, and `showNotification`'s doc | |
| 646 | // comment advertised an `"actionId"` field that no code has ever parsed. | |
| 647 | // | |
| 648 | // Two things are needed and they are not the same thing: | |
| 649 | // | |
| 650 | // - `didReceiveNotificationResponse:` is how a click or a button press comes | |
| 651 | // back. Without it the banner is decoration. | |
| 652 | // - `willPresentNotification:` is how a notification appears *at all* while | |
| 653 | // the app is frontmost. Without it macOS suppresses the banner, which is | |
| 654 | // indistinguishable from a broken notification and is the first thing | |
| 655 | // anyone testing this hits. | |
| 656 | ||
| 657 | const notification_actions = ; | |
| 658 | const capabilities = ; | |
| 659 | const objc = macos_mod.objc; | |
| 660 | ||
| 661 | /// Enough of the block ABI to call a completion handler that was handed to us. | |
| 662 | /// `{isa, flags, reserved, invoke, descriptor}` is fixed layout; `invoke` | |
| 663 | /// takes the block itself first. | |
| 664 | fn CompletionBlock(comptime Arg: type) type { | |
| 665 | return extern struct { | |
| 666 | isa: ?*anyopaque, | |
| 667 | flags: c_int, | |
| 668 | reserved: c_int, | |
| 669 | invoke: *const fn (*anyopaque, Arg) callconv(.c) void, | |
| 670 | }; | |
| 671 | } | |
| 672 | ||
| 673 | /// A completion handler taking nothing at all. | |
| 674 | const VoidBlock = extern struct { | |
| 675 | isa: ?*anyopaque, | |
| 676 | flags: c_int, | |
| 677 | reserved: c_int, | |
| 678 | invoke: *const fn (*anyopaque) callconv(.c) void, | |
| 679 | }; | |
| 680 | ||
| 681 | /// Whether this process has a bundle identifier. | |
| 682 | /// | |
| 683 | /// `UNUserNotificationCenter currentNotificationCenter` raises | |
| 684 | /// `NSInternalInconsistencyException` for a process without one — a bare | |
| 685 | /// `craft` binary run from a shell rather than an `.app`. An uncaught | |
| 686 | /// Objective-C exception is a hard abort, so every path that touches the | |
| 687 | /// notification centre has to ask first. The existing code carried a comment | |
| 688 | /// warning that it "can crash if called too early"; this is the actual | |
| 689 | /// condition. | |
| 690 | fn hasBundleIdentifier() bool { | |
| 691 | if (comptime builtin.os.tag != .macos) return false; | |
| 692 | const NSBundle = macos_mod.getClass("NSBundle"); | |
| 693 | if (@intFromPtr(NSBundle) == 0) return false; | |
| 694 | const bundle = macos_mod.msgSend0(NSBundle, "mainBundle"); | |
| 695 | if (@intFromPtr(bundle) == 0) return false; | |
| 696 | return @intFromPtr(macos_mod.msgSend0(bundle, "bundleIdentifier")) != 0; | |
| 697 | } | |
| 698 | ||
| 699 | var response_delegate_installed = false; | |
| 700 | var click_emitter: ?capabilities.Emitter = null; | |
| 701 | var action_emitter: ?capabilities.Emitter = null; | |
| 702 | ||
| 703 | /// Attach the delegate that turns a notification into an event. | |
| 704 | /// | |
| 705 | /// Called during startup rather than lazily on first `show`, because Apple | |
| 706 | /// requires the delegate to be set before the app finishes launching: a | |
| 707 | /// notification the *user clicked to launch the app* is delivered during | |
| 708 | /// launch, and a delegate installed afterwards has already missed it. That is | |
| 709 | /// what the app-delegate install point (#63) exists for. | |
| 710 | pub fn installResponseDelegate() void { | |
| 711 | if (comptime builtin.os.tag != .macos) return; | |
| 712 | if (response_delegate_installed) return; | |
| 713 | // No bundle, no notification centre — and asking would abort the process. | |
| 714 | if (!hasBundleIdentifier()) return; | |
| 715 | ||
| 716 | const UNUserNotificationCenter = macos_mod.getClass("UNUserNotificationCenter"); | |
| 717 | if (@intFromPtr(UNUserNotificationCenter) == 0) return; | |
| 718 | ||
| 719 | const class_name = "CraftNotificationDelegate"; | |
| 720 | var cls = objc.objc_getClass(class_name); | |
| 721 | if (cls == null) { | |
| 722 | cls = objc.objc_allocateClassPair(macos_mod.getClass("NSObject"), class_name, 0); | |
| 723 | if (cls == null) return; | |
| 724 | ||
| 725 | // void; self, _cmd, center, response, completionHandler (block) | |
| 726 | _ = objc.class_addMethod( | |
| 727 | cls, | |
| 728 | macos_mod.sel("userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"), | |
| 729 | @as(objc.IMP, @ptrCast(@constCast(&handleNotificationResponse))), | |
| 730 | "v@:@@@?", | |
| 731 | ); | |
| 732 | // void; self, _cmd, center, notification, completionHandler (block) | |
| 733 | _ = objc.class_addMethod( | |
| 734 | cls, | |
| 735 | macos_mod.sel("userNotificationCenter:willPresentNotification:withCompletionHandler:"), | |
| 736 | @as(objc.IMP, @ptrCast(@constCast(&handleWillPresent))), | |
| 737 | "v@:@@@?", | |
| 738 | ); | |
| 739 | ||
| 740 | objc.objc_registerClassPair(cls); | |
| 741 | } | |
| 742 | ||
| 743 | const center = macos_mod.msgSend0(UNUserNotificationCenter, "currentNotificationCenter"); | |
| 744 | if (@intFromPtr(center) == 0) return; | |
| 745 | ||
| 746 | // `delegate` is a weak reference; the +1 from alloc/init is what keeps this | |
| 747 | // alive for the life of the process. | |
| 748 | const delegate = macos_mod.msgSend0(macos_mod.msgSend0(@as(objc.id, @ptrCast(@alignCast(cls))), "alloc"), "init"); | |
| 749 | macos_mod.msgSendVoid1(center, "setDelegate:", delegate); | |
| 750 | response_delegate_installed = true; | |
| 751 | ||
| 752 | // The permits to emit on these channels, taken out at the point they | |
| 753 | // become deliverable. `craft.capabilities()` reports them from these lines | |
| 754 | // running, not from a table asserting they should have. | |
| 755 | click_emitter = capabilities.registerEmitter(.notification_click); | |
| 756 | action_emitter = capabilities.registerEmitter(.notification_action); | |
| 757 | ||
| 758 | log.debug("notification response delegate installed", .{}); | |
| 759 | } | |
| 760 | ||
| 761 | /// `userNotificationCenter:willPresentNotification:withCompletionHandler:` | |
| 762 | /// | |
| 763 | /// Answering with the presentation options is what lets a notification appear | |
| 764 | /// while the app is frontmost. macOS suppresses it otherwise, and craft had no | |
| 765 | /// delegate to answer at all — so an app that notified while focused showed | |
| 766 | /// nothing, which looks exactly like a broken notification. | |
| 767 | fn handleWillPresent(_: objc.id, _: objc.SEL, _: objc.id, _: objc.id, completion: objc.id) callconv(.c) void { | |
| 768 | // UNNotificationPresentationOptionBanner (1<<4) | ...List (1<<3) | ...Sound (1<<1). | |
| 769 | // Banner and List replace the deprecated Alert (1<<2) on macOS 11+. | |
| 770 | const options: c_ulong = (1 << 4) | (1 << 3) | (1 << 1); | |
| 771 | const handler = completion orelse return; | |
| 772 | const block: *CompletionBlock(c_ulong) = @ptrCast(@alignCast(handler)); | |
| 773 | block.invoke(handler, options); | |
| 774 | } | |
| 775 | ||
| 776 | /// `userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:` | |
| 777 | /// | |
| 778 | /// The completion handler must be called on every path out. Dropping it does | |
| 779 | /// not fail anything visibly — it leaks the response and, repeated, gets the | |
| 780 | /// app's notification delivery throttled. | |
| 781 | fn handleNotificationResponse(_: objc.id, _: objc.SEL, _: objc.id, response: objc.id, completion: objc.id) callconv(.c) void { | |
| 782 | defer if (completion) |handler| { | |
| 783 | const block: *VoidBlock = @ptrCast(@alignCast(handler)); | |
| 784 | block.invoke(handler); | |
| 785 | }; | |
| 786 | ||
| 787 | if (@intFromPtr(response) == 0) return; | |
| 788 | ||
| 789 | var id_buf: [256]u8 = undefined; | |
| 790 | var action_buf: [256]u8 = undefined; | |
| 791 | ||
| 792 | const notification = macos_mod.msgSend0(response, "notification"); | |
| 793 | const request = macos_mod.msgSend0(notification, "request"); | |
| 794 | const notification_id = copyNSString(macos_mod.msgSend0(request, "identifier"), &id_buf) orelse return; | |
| 795 | const action_id = copyNSString(macos_mod.msgSend0(response, "actionIdentifier"), &action_buf) orelse return; | |
| 796 | ||
| 797 | switch (notification_actions.classify(action_id)) { | |
| 798 | .opened => { | |
| 799 | // Named through the emitter rather than spelled again here, so the | |
| 800 | // string the page listens for and the string `craft.capabilities()` | |
| 801 | // reports are one string. | |
| 802 | const emitter = click_emitter orelse return; | |
| 803 | emitNotificationEvent(emitter.eventName(), notification_id, null); | |
| 804 | }, | |
| 805 | .dismissed => { | |
| 806 | const emitter = action_emitter orelse return; | |
| 807 | emitNotificationEvent( | |
| 808 | emitter.eventName(), | |
| 809 | notification_id, | |
| 810 | notification_actions.dismiss_public_id, | |
| 811 | ); | |
| 812 | }, | |
| 813 | .action => |id| { | |
| 814 | const emitter = action_emitter orelse return; | |
| 815 | emitNotificationEvent(emitter.eventName(), notification_id, id); | |
| 816 | }, | |
| 817 | } | |
| 818 | } | |
| 819 | ||
| 820 | /// Read an `NSString` into `buf`. Null if it does not fit — better a dropped | |
| 821 | /// event than a truncated identifier the page would match against the wrong | |
| 822 | /// notification. | |
| 823 | fn copyNSString(str: objc.id, buf: []u8) ?[]const u8 { | |
| 824 | if (@intFromPtr(str) == 0) return null; | |
| 825 | const raw = macos_mod.msgSend0(str, "UTF8String") orelse return null; | |
| 826 | const cstr: [*:0]const u8 = @ptrCast(@alignCast(raw)); | |
| 827 | const slice = std.mem.span(cstr); | |
| 828 | if (slice.len > buf.len) return null; | |
| 829 | @memcpy(buf[0..slice.len], slice); | |
| 830 | return buf[0..slice.len]; | |
| 831 | } | |
| 832 | ||
| 833 | fn emitNotificationEvent(event: []const u8, notification_id: []const u8, action_id: ?[]const u8) void { | |
| 834 | const allocator = std.heap.c_allocator; | |
| 835 | ||
| 836 | var id_escaped: [1024]u8 = undefined; | |
| 837 | const id_json = bridge_error.escapeJsonString(&id_escaped, notification_id) catch return; | |
| 838 | ||
| 839 | var detail: std.ArrayListUnmanaged(u8) = .empty; | |
| 840 | defer detail.deinit(allocator); | |
| 841 | detail.appendSlice(allocator, "{\"notificationId\":\"") catch return; | |
| 842 | detail.appendSlice(allocator, id_json) catch return; | |
| 843 | detail.append(allocator, '"') catch return; | |
| 844 | if (action_id) |action| { | |
| 845 | var action_escaped: [1024]u8 = undefined; | |
| 846 | const action_json = bridge_error.escapeJsonString(&action_escaped, action) catch return; | |
| 847 | detail.appendSlice(allocator, ",\"actionId\":\"") catch return; | |
| 848 | detail.appendSlice(allocator, action_json) catch return; | |
| 849 | detail.append(allocator, '"') catch return; | |
| 850 | } | |
| 851 | detail.append(allocator, '}') catch return; | |
| 852 | ||
| 853 | const script = std.fmt.allocPrint( | |
| 854 | allocator, | |
| 855 | "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('{s}',));", | |
| 856 | .{ event, detail.items }, | |
| 857 | ) catch return; | |
| 858 | defer allocator.free(script); | |
| 859 | ||
| 860 | .evalJS(script) catch |err| { | |
| 861 | log.debug("could not deliver {s}: {}", .{ event, err }); | |
| 862 | }; | |
| 863 | } | |
| 864 | ||
| 865 | // ============================================================================= | |
| 866 | // Action buttons | |
| 867 | // ============================================================================= | |
| 868 | // | |
| 869 | // Buttons live on a `UNNotificationCategory`, not on the notification: the | |
| 870 | // content names a category, the category holds the actions, and the response | |
| 871 | // names the action pressed. Categories are registered as a **set** — | |
| 872 | // `setNotificationCategories:` replaces whatever was there — so they cannot be | |
| 873 | // made per notification and discarded. They are keyed by their shape (see | |
| 874 | // `notification_actions.zig`) and accumulated here. | |
| 875 | ||
| 876 | /// How many distinct button shapes one process may register. | |
| 877 | /// | |
| 878 | /// An app has a handful: an approve/deny prompt, a "view"/"dismiss" pair. | |
| 879 | /// Anything generating shapes without bound is building category identifiers | |
| 880 | /// from data, which is a bug this limit surfaces instead of hiding. | |
| 881 | const max_categories = 32; | |
| 882 | ||
| 883 | const CategoryEntry = struct { | |
| 884 | id: [notification_actions.category_id_len]u8 = undefined, | |
| 885 | used: bool = false, | |
| 886 | }; | |
| 887 | ||
| 888 | var categories: [max_categories]CategoryEntry = @splat(.{}); | |
| 889 | var category_count: usize = 0; | |
| 890 | ||
| 891 | fn findCategory(id: []const u8) bool { | |
| 892 | for (categories[0..category_count]) |entry| { | |
| 893 | if (entry.used and std.mem.eql(u8, &entry.id, id)) return true; | |
| 894 | } | |
| 895 | return false; | |
| 896 | } | |
| 897 | ||
| 898 | /// Register the category these actions form, if it is not already registered, | |
| 899 | /// and return its identifier. | |
| 900 | /// | |
| 901 | /// Null when the actions cannot be served — no bundle, no notification centre, | |
| 902 | /// or the table is full. The caller posts the notification without buttons | |
| 903 | /// rather than not at all: a banner with no buttons still says the thing. | |
| 904 | fn ensureCategory(actions: []const notification_actions.Action, id_buf: []u8) ?[]const u8 { | |
| 905 | if (comptime builtin.os.tag != .macos) return null; | |
| 906 | if (actions.len == 0) return null; | |
| 907 | if (!hasBundleIdentifier()) return null; | |
| 908 | ||
| 909 | const category_id = notification_actions.categoryId(actions, id_buf); | |
| 910 | if (findCategory(category_id)) return category_id; | |
| 911 | ||
| 912 | if (category_count >= max_categories) { | |
| 913 | log.warn("too many distinct notification action shapes; posting without buttons", .{}); | |
| 914 | return null; | |
| 915 | } | |
| 916 | ||
| 917 | const UNNotificationAction = macos_mod.getClass("UNNotificationAction"); | |
| 918 | const UNNotificationCategory = macos_mod.getClass("UNNotificationCategory"); | |
| 919 | const NSArray = macos_mod.getClass("NSArray"); | |
| 920 | const NSMutableArray = macos_mod.getClass("NSMutableArray"); | |
| 921 | if (@intFromPtr(UNNotificationAction) == 0 or @intFromPtr(UNNotificationCategory) == 0) return null; | |
| 922 | ||
| 923 | const action_array = macos_mod.msgSend0(macos_mod.msgSend0(NSMutableArray, "alloc"), "init"); | |
| 924 | for (actions) |action| { | |
| 925 | const ns_id = nsStringFrom(action.id) orelse return null; | |
| 926 | const ns_label = nsStringFrom(action.label) orelse return null; | |
| 927 | // UNNotificationActionOptionForeground (1<<2): pressing the button | |
| 928 | // brings the app forward. Without it the app is woken in the | |
| 929 | // background and the page — which is where the handler lives — may not | |
| 930 | // be running to hear about it. | |
| 931 | const options: c_ulong = 1 << 2; | |
| 932 | const makeAction = @as( | |
| 933 | *const fn (objc.id, objc.SEL, objc.id, objc.id, c_ulong) callconv(.c) objc.id, | |
| 934 | @ptrCast(&objc.objc_msgSend), | |
| 935 | ); | |
| 936 | const un_action = makeAction( | |
| 937 | UNNotificationAction, | |
| 938 | macos_mod.sel("actionWithIdentifier:title:options:"), | |
| 939 | ns_id, | |
| 940 | ns_label, | |
| 941 | options, | |
| 942 | ); | |
| 943 | if (@intFromPtr(un_action) == 0) return null; | |
| 944 | _ = macos_mod.msgSend1(action_array, "addObject:", un_action); | |
| 945 | } | |
| 946 | ||
| 947 | const ns_category_id = nsStringFrom(category_id) orelse return null; | |
| 948 | const empty = macos_mod.msgSend0(NSArray, "array"); | |
| 949 | const makeCategory = @as( | |
| 950 | *const fn (objc.id, objc.SEL, objc.id, objc.id, objc.id, c_ulong) callconv(.c) objc.id, | |
| 951 | @ptrCast(&objc.objc_msgSend), | |
| 952 | ); | |
| 953 | // UNNotificationCategoryOptionCustomDismissAction. | |
| 954 | // | |
| 955 | // Without it macOS handles a swipe-away itself and never delivers | |
| 956 | // `UNNotificationDismissActionIdentifier` to the delegate, which makes the | |
| 957 | // `.dismissed` arm of `handleNotificationResponse` unreachable — the | |
| 958 | // notification would simply vanish and the page would hear nothing. An app | |
| 959 | // waiting on Approve/Deny has to be told the banner was dismissed, or it | |
| 960 | // waits for an answer that is never coming. | |
| 961 | const options: c_ulong = 1 << 0; | |
| 962 | const category = makeCategory( | |
| 963 | UNNotificationCategory, | |
| 964 | macos_mod.sel("categoryWithIdentifier:actions:intentIdentifiers:options:"), | |
| 965 | ns_category_id, | |
| 966 | action_array, | |
| 967 | empty, | |
| 968 | options, | |
| 969 | ); | |
| 970 | if (@intFromPtr(category) == 0) return null; | |
| 971 | ||
| 972 | categories[category_count] = .{ .used = true, .id = undefined }; | |
| 973 | @memcpy(&categories[category_count].id, category_id); | |
| 974 | category_count += 1; | |
| 975 | ||
| 976 | // Re-register the whole set. `setNotificationCategories:` replaces rather | |
| 977 | // than adds, so posting only the new one would silently unregister the | |
| 978 | // buttons of every notification already on screen. | |
| 979 | if (!reregisterCategories(category)) { | |
| 980 | category_count -= 1; | |
| 981 | categories[category_count] = .{}; | |
| 982 | return null; | |
| 983 | } | |
| 984 | ||
| 985 | return category_id; | |
| 986 | } | |
| 987 | ||
| 988 | /// Hand the notification centre every category registered so far. | |
| 989 | /// | |
| 990 | /// `new_category` is included because the Objective-C objects are not retained | |
| 991 | /// between calls — the set held by the centre is the only copy, so the rebuild | |
| 992 | /// reads it back rather than keeping a parallel list of ids to reconstruct. | |
| 993 | fn reregisterCategories(new_category: objc.id) bool { | |
| 994 | const UNUserNotificationCenter = macos_mod.getClass("UNUserNotificationCenter"); | |
| 995 | if (@intFromPtr(UNUserNotificationCenter) == 0) return false; | |
| 996 | const center = macos_mod.msgSend0(UNUserNotificationCenter, "currentNotificationCenter"); | |
| 997 | if (@intFromPtr(center) == 0) return false; | |
| 998 | ||
| 999 | const NSMutableSet = macos_mod.getClass("NSMutableSet"); | |
| 1000 | const set = macos_mod.msgSend0(macos_mod.msgSend0(NSMutableSet, "alloc"), "init"); | |
| 1001 | if (@intFromPtr(set) == 0) return false; | |
| 1002 | ||
| 1003 | if (registered_categories) |existing| { | |
| 1004 | _ = macos_mod.msgSend1(set, "unionSet:", existing); | |
| 1005 | } | |
| 1006 | _ = macos_mod.msgSend1(set, "addObject:", new_category); | |
| 1007 | ||
| 1008 | macos_mod.msgSendVoid1(center, "setNotificationCategories:", set); | |
| 1009 | registered_categories = set; | |
| 1010 | return true; | |
| 1011 | } | |
| 1012 | ||
| 1013 | /// The set handed to the notification centre, kept so the next registration | |
| 1014 | /// can add to it instead of replacing it. | |
| 1015 | var registered_categories: objc.id = null; | |
| 1016 | ||
| 1017 | fn nsStringFrom(text: []const u8) ?objc.id { | |
| 1018 | var buf: [512]u8 = undefined; | |
| 1019 | if (text.len >= buf.len) return null; | |
| 1020 | @memcpy(buf[0..text.len], text); | |
| 1021 | buf[text.len] = 0; | |
| 1022 | const NSString = macos_mod.getClass("NSString"); | |
| 1023 | const str = macos_mod.msgSend1(NSString, "stringWithUTF8String:", @as([*:0]const u8, @ptrCast(&buf))); | |
| 1024 | return if (@intFromPtr(str) == 0) null else str; | |
| 1025 | } | |
| 1026 | ||
| 1027 | /// The `actions` array as the page writes it. | |
| 1028 | /// | |
| 1029 | /// Two spellings, because the TypeScript SDK has published | |
| 1030 | /// `actions?: [{action, title}]` on `NotificationOptions` since before | |
| 1031 | /// anything implemented it — the same documented-but-dead shape as the | |
| 1032 | /// `actionId` this change removes. Deleting it would break code that compiled | |
| 1033 | /// against a promise craft made; accepting it makes the promise true. | |
| 1034 | /// `{id, label}` is canonical and wins when both are given. | |
| 1035 | pub const ActionSpec = struct { | |
| 1036 | id: []const u8 = "", | |
| 1037 | label: []const u8 = "", | |
| 1038 | action: []const u8 = "", | |
| 1039 | title: []const u8 = "", | |
| 1040 | ||
| 1041 | fn actionId(self: ActionSpec) []const u8 { | |
| 1042 | return if (self.id.len > 0) self.id else self.action; | |
| 1043 | } | |
| 1044 | ||
| 1045 | fn actionLabel(self: ActionSpec) []const u8 { | |
| 1046 | return if (self.label.len > 0) self.label else self.title; | |
| 1047 | } | |
| 1048 | }; | |
| 1049 | ||
| 1050 | /// Put the buttons on `content`, if there are any and they are usable. | |
| 1051 | /// | |
| 1052 | /// Invalid buttons are refused loudly and the notification still goes out | |
| 1053 | /// without them. The alternative — dropping the notification because a label | |
| 1054 | /// was empty — loses the message the app was actually trying to send. | |
| 1055 | fn applyActions(content: objc.id, specs: []const ActionSpec) void { | |
| 1056 | if (specs.len == 0) return; | |
| 1057 | ||
| 1058 | var actions: [notification_actions.max_actions]notification_actions.Action = undefined; | |
| 1059 | if (specs.len > actions.len) { | |
| 1060 | log.warn( | |
| 1061 | "notification declared {d} actions; macOS shows {d}, so the rest are ignored", | |
| 1062 | .{ specs.len, notification_actions.max_actions }, | |
| 1063 | ); | |
| 1064 | } | |
| 1065 | const count = @min(specs.len, actions.len); | |
| 1066 | for (specs[0..count], 0..) |spec, i| { | |
| 1067 | actions[i] = .{ .id = spec.actionId(), .label = spec.actionLabel() }; | |
| 1068 | } | |
| 1069 | ||
| 1070 | notification_actions.validate(actions[0..count]) catch |err| { | |
| 1071 | log.warn("notification actions refused ({s}); posting without buttons", .{@errorName(err)}); | |
| 1072 | return; | |
| 1073 | }; | |
| 1074 | ||
| 1075 | var id_buf: [notification_actions.category_id_len]u8 = undefined; | |
| 1076 | const category_id = ensureCategory(actions[0..count], &id_buf) orelse return; | |
| 1077 | const ns_category = nsStringFrom(category_id) orelse return; | |
| 1078 | _ = macos_mod.msgSend1(content, "setCategoryIdentifier:", ns_category); | |
| 1079 | } | |