| @@ -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 | +} |