ReviewOS

also looking at this

craft-native/craft

feat(notifications): make the banner answerable — clicks, buttons, and a crash

#74
Merged glennmichael123 wants to merge feat/notification-actions into main
8 files +942 -17
packages/zig/src/bridge_notification.zigmodified+481-8
Changes to packages/zig/src/bridge_notification.zig
@@ -48,8 +48,16 @@ pub const NotificationBridge = struct {
4848 return;
4949 }
5050
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
5361 self.notification_center = macos.msgSend0(UNUserNotificationCenter, "currentNotificationCenter");
5462
5563 if (self.notification_center) |center| {
@@ -100,8 +108,14 @@ pub const NotificationBridge = struct {
100108 bridge_error.sendErrorToJS(self.allocator, action, bridge_err);
101109 }
102110
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.
105119 fn showNotification(self: *Self, data: []const u8) !void {
106120 if (builtin.os.tag == .linux) {
107121 try self.linuxShowNotification(data);
@@ -122,6 +136,7 @@ pub const NotificationBridge = struct {
122136 body: []const u8 = "",
123137 subtitle: []const u8 = "",
124138 sound: bool = true,
139 actions: []const ActionSpec = &.{},
125140 };
126141
127142 const parsed = std.json.parseFromSlice(ShowParams, self.allocator, data, .{
@@ -180,6 +195,8 @@ pub const NotificationBridge = struct {
180195 _ = macos.msgSend1(content, "setSound:", default_sound);
181196 }
182197
198 applyActions(content, params.actions);
199
183200 // Create trigger (nil for immediate)
184201 const trigger: ?*anyopaque = null;
185202
@@ -222,6 +239,8 @@ pub const NotificationBridge = struct {
222239 title: []const u8 = "",
223240 body: []const u8 = "",
224241 delay: f64 = 60.0,
242 /// Buttons on the banner. Empty means a plain notification.
243 actions: []const ActionSpec = &.{},
225244 };
226245
227246 const parsed = std.json.parseFromSlice(ScheduleParams, self.allocator, data, .{
@@ -263,10 +282,21 @@ pub const NotificationBridge = struct {
263282 const default_sound = macos.msgSend0(UNNotificationSound, "defaultSound");
264283 _ = macos.msgSend1(content, "setSound:", default_sound);
265284
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;
270300
271301 // Create request
272302 const id_cstr = try .dupeZ(self.allocator, u8, id);
@@ -604,3 +634,446 @@ pub const NotificationBridge = struct {
604634 self.pending_callbacks.deinit();
605635 }
606636};
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
657const notification_actions = ;
658const capabilities = ;
659const 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.
664fn 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.
674const 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.
690fn 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
699var response_delegate_installed = false;
700var click_emitter: ?capabilities.Emitter = null;
701var 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.
710pub 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.
767fn 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.
781fn 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.
823fn 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
833fn 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.
881const max_categories = 32;
882
883const CategoryEntry = struct {
884 id: [notification_actions.category_id_len]u8 = undefined,
885 used: bool = false,
886};
887
888var categories: [max_categories]CategoryEntry = @splat(.{});
889var category_count: usize = 0;
890
891fn 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.
904fn 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.
993fn 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.
1015var registered_categories: objc.id = null;
1016
1017fn 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.
1035pub 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.
1055fn 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}