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/typescript/src/types.tsmodified+39-8
Changes to packages/typescript/src/types.ts
@@ -814,12 +814,51 @@ export interface AppInfo {
814814/**
815815 * Notification options
816816 */
817/**
818 * A button on a notification banner.
819 *
820 * macOS shows two directly on a banner and puts any others behind an
821 * "Options" disclosure, so craft accepts at most four — past that they are
822 * a menu the user has to go looking for rather than a choice they can see.
823 *
824 * Two spellings are accepted. `{ id, label }` is the one to use. `{ action,
825 * title }` is what this type has said since before anything implemented it,
826 * and it keeps working rather than being deleted out from under whoever wrote
827 * against it — the field was published, it just never did anything.
828 */
829export type NotificationAction =
830 | {
831 /** Comes back as `actionId` in `craft.notifications.onAction`. */
832 id: string
833 /** The text on the button. */
834 label: string
835 }
836 | {
837 /** @deprecated Use `id`. */
838 action: string
839 /** @deprecated Use `label`. */
840 title: string
841 }
842
817843export interface NotificationOptions {
818844 /**
819845 * Notification title (required)
820846 */
821847 title: string
822848
849 /**
850 * Buttons on the banner.
851 *
852 * Pressing one brings the app forward and fires
853 * `craft.notifications.onAction` with `{ notificationId, actionId }` —
854 * which is what lets a prompt be answered without switching to the app
855 * first.
856 *
857 * Two buttons cannot share an `id`: the response names the button by id,
858 * and for an Approve/Deny prompt that name is the entire answer.
859 */
860 actions?: NotificationAction[]
861
823862 /**
824863 * Notification body text
825864 */
@@ -839,14 +878,6 @@ export interface NotificationOptions {
839878 */
840879 sound?: string
841880
842 /**
843 * Action buttons (platform dependent)
844 */
845 actions?: Array<{
846 action: string
847 title: string
848 }>
849
850881 /**
851882 * Notification tag (for grouping/replacing)
852883 */
packages/zig/build.zigmodified+13-0
Changes to packages/zig/build.zig
@@ -695,6 +695,17 @@ pub fn build(b: *std.Build) void {
695695 });
696696 cli_unit_tests.root_module.link_libc = true;
697697
698 // Notification action buttons: which category a set of buttons forms, and
699 // what a response means. Pure, so the whole thing is provable without
700 // posting a banner or clicking one.
701 const notification_actions_tests = b.addTest(.{
702 .root_module = b.createModule(.{
703 .root_source_file = b.path("src/notification_actions.zig"),
704 .target = target,
705 .optimize = optimize,
706 }),
707 });
708
698709 // The conformance test: what craft declares, against what it dispatches.
699710 const capability_conformance_tests = b.addTest(.{
700711 .root_module = b.createModule(.{
@@ -1069,6 +1080,7 @@ pub fn build(b: *std.Build) void {
10691080 const run_external_link_tests = b.addRunArtifact(external_link_tests);
10701081 const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests);
10711082 const run_request_context_tests = b.addRunArtifact(request_context_tests);
1083 const run_notification_actions_tests = b.addRunArtifact(notification_actions_tests);
10721084 const run_cli_unit_tests = b.addRunArtifact(cli_unit_tests);
10731085 const run_lifecycle_policy_tests = b.addRunArtifact(lifecycle_policy_tests);
10741086 const run_prefs_tests = b.addRunArtifact(prefs_tests);
@@ -1187,6 +1199,7 @@ pub fn build(b: *std.Build) void {
11871199 test_step.dependOn(&run_external_link_tests.step);
11881200 test_step.dependOn(&run_webview_recovery_tests.step);
11891201 test_step.dependOn(&run_request_context_tests.step);
1202 test_step.dependOn(&run_notification_actions_tests.step);
11901203 test_step.dependOn(&run_cli_unit_tests.step);
11911204 test_step.dependOn(&run_lifecycle_policy_tests.step);
11921205 // macOS only: the registry describes the dispatch chain in macos.zig, and
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}
packages/zig/src/capabilities.zigmodified+4-0
Changes to packages/zig/src/capabilities.zig
@@ -118,6 +118,8 @@ pub const Channel = enum {
118118 menubar_statechange,
119119 midi_message,
120120 networkchange,
121 notification_action,
122 notification_click,
121123 powersleep,
122124 powerwake,
123125 screen_change,
@@ -165,6 +167,8 @@ pub const Channel = enum {
165167 .menubar_statechange => "craft:menubar:stateChange",
166168 .midi_message => "craft:midi:message",
167169 .networkchange => "craft:networkChange",
170 .notification_action => "craft:notification:action",
171 .notification_click => "craft:notification:click",
168172 .powersleep => "craft:powerSleep",
169173 .powerwake => "craft:powerWake",
170174 .screen_change => "craft:screen:change",
packages/zig/src/js/craft-bridge.jsmodified+12-1
Changes to packages/zig/src/js/craft-bridge.js
@@ -577,11 +577,14 @@
577577 // -------------------------------------------------------------------------
578578 window.craft.notifications = {
579579 show: function (opts) {
580 // Convenience wrapper around schedule with no triggerAt.
581580 const o = Object.assign({}, opts || {})
582581 if (typeof o.title !== 'string' || o.title.length === 0) {
583582 return Promise.reject(new Error('notification title is required'))
584583 }
584 // `show` means now. This routes through `schedule`, whose delay defaults
585 // to sixty seconds — so every `show()` used to arrive a minute later,
586 // which from here is indistinguishable from one that never arrived.
587 if (typeof o.delay !== 'number') o.delay = 0
585588 return _send('notification', 'schedule', _stringify(o))
586589 },
587590 schedule: function (opts) { return _send('notification', 'schedule', _stringify(opts || {})) },
@@ -590,6 +593,14 @@
590593 setBadge: function (n) { return _send('notification', 'setBadge', _stringify({ count: Number(n) || 0 })) },
591594 clearBadge: function () { return _send('notification', 'clearBadge') },
592595 requestPermission: function () { return _req('notification', 'requestPermission').then(function (r) { return (r && r.granted) === true }) },
596 // The banner was a dead end: craft posted real notifications and
597 // registered no delegate, so a click reached nothing.
598 // onClick -> { notificationId }
599 // onAction -> { notificationId, actionId }
600 // A swipe-away arrives as an action with actionId 'dismiss', so an app
601 // waiting on an answer hears that there will not be one.
602 onClick: _evt('craft:notification:click'),
603 onAction: _evt('craft:notification:action'),
593604 }
594605
595606 // -------------------------------------------------------------------------
packages/zig/src/macos.zigmodified+6-0
Changes to packages/zig/src/macos.zig
@@ -6561,6 +6561,12 @@ pub fn installAppDelegate() void {
65616561 msgSendVoid1(app, "setDelegate:", delegate);
65626562 app_delegate_installed = true;
65636563
6564 // The notification centre's delegate has the same deadline — Apple wants
6565 // it set before the app finishes launching, so a notification the user
6566 // clicked to launch the app is not delivered to nobody. This is the point
6567 // #63 created for it. It is a no-op for an unbundled process.
6568 .installResponseDelegate();
6569
65646570 if (comptime builtin.mode == .debug)
65656571 std.debug.print("[App] Installed NSApplicationDelegate\n", .{});
65666572}
packages/zig/src/notification_actions.zigadded+282-0
Changes to packages/zig/src/notification_actions.zig
@@ -0,0 +1,282 @@
1//! Notification action buttons, and what comes back when one is pressed.
2//!
3//! A `UNNotificationCategory` is what puts buttons on a banner: the content
4//! names a category, the category holds the actions, and the response names
5//! the action that was pressed. Categories are registered against the
6//! notification centre as a **set** — `setNotificationCategories:` replaces
7//! whatever was there — so they cannot be created per notification and thrown
8//! away. They have to be identified by their shape and reused.
9//!
10//! That identity is the interesting part, and it is pure, so it lives here
11//! with tests rather than inside the Objective-C. Same buttons in the same
12//! order means the same category; a different label, a different id, or the
13//! same two buttons the other way round means a different one, because the
14//! order is what the user sees.
15
16const std = ;
17
18/// One button on a banner.
19pub const Action = struct {
20 /// What comes back in `craft:notification:action`. The app's own name for
21 /// the button.
22 id: []const u8,
23 /// What the button says.
24 label: []const u8,
25};
26
27/// How many buttons a notification may declare.
28///
29/// macOS shows two on a banner and puts the rest behind an "Options"
30/// disclosure, so past four the extra ones are effectively a menu the user has
31/// to go looking for. Refusing beyond that is more honest than silently
32/// registering buttons nobody will find.
33pub const max_actions = 4;
34
35pub const Error = error{
36 TooManyActions,
37 EmptyActionId,
38 EmptyActionLabel,
39 DuplicateActionId,
40 ActionIdTooLong,
41};
42
43/// Longest action id accepted. Ids travel into a category identifier and back
44/// out through a JSON event; a bound here keeps both sides off the heap.
45pub const max_id_len = 64;
46
47pub fn validate(actions: []const Action) Error!void {
48 if (actions.len > max_actions) return Error.TooManyActions;
49 for (actions, 0..) |action, i| {
50 if (action.id.len == 0) return Error.EmptyActionId;
51 if (action.id.len > max_id_len) return Error.ActionIdTooLong;
52 // A button with no text is a button the user cannot read. Rendering it
53 // blank and letting them guess is worse than saying no.
54 if (action.label.len == 0) return Error.EmptyActionLabel;
55 // Two buttons with one id means the response cannot say which was
56 // pressed — for an Approve/Deny prompt that is the whole answer.
57 for (actions[0..i]) |earlier| {
58 if (std.mem.eql(u8, earlier.id, action.id)) return Error.DuplicateActionId;
59 }
60 }
61}
62
63/// A stable identifier for the category these actions form.
64///
65/// Derived from the actions themselves so an app that posts the same
66/// Approve/Deny prompt a thousand times registers one category, not a
67/// thousand. Written into `buf`, which must hold `category_id_len` bytes.
68///
69/// FNV-1a over the ids and labels, each length-prefixed. The prefixes are what
70/// stop `{"ab","c"}` and `{"a","bc"}` hashing the same, which a plain
71/// concatenation would allow — and that collision would put one notification's
72/// buttons on another's banner.
73pub const category_id_len = "craft.actions.".len + 16;
74
75pub fn categoryId(actions: []const Action, buf: []u8) []const u8 {
76 std.debug.assert(buf.len >= category_id_len);
77
78 var hash: u64 = 0xcbf29ce484222325;
79 const prime: u64 = 0x100000001b3;
80
81 const mix = struct {
82 fn byte(h: *u64, p: u64, b: u8) void {
83 h.* = (h.* ^ b) *% p;
84 }
85 fn slice(h: *u64, p: u64, s: []const u8) void {
86 // Length first, so the boundary between fields is part of what is
87 // hashed rather than something the reader has to infer.
88 var len: u64 = s.len;
89 var i: usize = 0;
90 while (i < 8) : (i += 1) {
91 byte(h, p, @truncate(len));
92 len >>= 8;
93 }
94 for (s) |c| byte(h, p, c);
95 }
96 };
97
98 mix.slice(&hash, prime, std.mem.asBytes(&actions.len));
99 for (actions) |action| {
100 mix.slice(&hash, prime, action.id);
101 mix.slice(&hash, prime, action.label);
102 }
103
104 _ = std.fmt.bufPrint(buf, "craft.actions.{x:0>16}", .{hash}) catch unreachable;
105 return buf[0..category_id_len];
106}
107
108// =============================================================================
109// What came back
110// =============================================================================
111
112/// Apple's two built-in response identifiers. A response always names an
113/// action; for a plain tap or a swipe-away it names one of these.
114pub const default_action_id = "com.apple.UNNotificationDefaultActionIdentifier";
115pub const dismiss_action_id = "com.apple.UNNotificationDismissActionIdentifier";
116
117pub const Response = union(enum) {
118 /// The banner itself was clicked. `craft:notification:click`.
119 opened,
120 /// Swiped away or cleared. Reported as an action so an app waiting on an
121 /// answer hears that there will not be one, rather than waiting forever.
122 dismissed,
123 /// One of the app's own buttons. `craft:notification:action`.
124 action: []const u8,
125};
126
127/// Classify the `actionIdentifier` of a `UNNotificationResponse`.
128pub fn classify(action_identifier: []const u8) Response {
129 if (std.mem.eql(u8, action_identifier, default_action_id)) return .opened;
130 if (std.mem.eql(u8, action_identifier, dismiss_action_id)) return .dismissed;
131 return .{ .action = action_identifier };
132}
133
134/// The `actionId` the page sees for a dismissal. Deliberately not Apple's
135/// reverse-DNS constant: the page's vocabulary is the ids the app chose, and
136/// `"dismiss"` belongs in it.
137pub const dismiss_public_id = "dismiss";
138
139const testing = std.testing;
140
141test "the same buttons produce the same category" {
142 var a: [category_id_len]u8 = undefined;
143 var b: [category_id_len]u8 = undefined;
144 const actions = [_]Action{
145 .{ .id = "approve", .label = "Approve" },
146 .{ .id = "deny", .label = "Deny" },
147 };
148 try testing.expectEqualStrings(categoryId(&actions, &a), categoryId(&actions, &b));
149}
150
151test "different buttons produce different categories" {
152 var a: [category_id_len]u8 = undefined;
153 var b: [category_id_len]u8 = undefined;
154 const approve_deny = [_]Action{
155 .{ .id = "approve", .label = "Approve" },
156 .{ .id = "deny", .label = "Deny" },
157 };
158 const yes_no = [_]Action{
159 .{ .id = "yes", .label = "Yes" },
160 .{ .id = "no", .label = "No" },
161 };
162 try testing.expect(!std.mem.eql(u8, categoryId(&approve_deny, &a), categoryId(&yes_no, &b)));
163}
164
165test "the order of the buttons is part of the category" {
166 // It is what the user sees, and a category reused across both orderings
167 // would put Deny where the app drew Approve.
168 var a: [category_id_len]u8 = undefined;
169 var b: [category_id_len]u8 = undefined;
170 const forward = [_]Action{
171 .{ .id = "approve", .label = "Approve" },
172 .{ .id = "deny", .label = "Deny" },
173 };
174 const reversed = [_]Action{
175 .{ .id = "deny", .label = "Deny" },
176 .{ .id = "approve", .label = "Approve" },
177 };
178 try testing.expect(!std.mem.eql(u8, categoryId(&forward, &a), categoryId(&reversed, &b)));
179}
180
181test "relabelling a button is a different category" {
182 // Same ids, different text. Reusing the category would leave the old words
183 // on the banner.
184 var a: [category_id_len]u8 = undefined;
185 var b: [category_id_len]u8 = undefined;
186 const before = [_]Action{.{ .id = "ok", .label = "OK" }};
187 const after = [_]Action{.{ .id = "ok", .label = "Got it" }};
188 try testing.expect(!std.mem.eql(u8, categoryId(&before, &a), categoryId(&after, &b)));
189}
190
191test "a field boundary cannot be moved without changing the category" {
192 // The classic concatenation collision: "ab"+"c" and "a"+"bc" are the same
193 // bytes in a row. Length-prefixing is what separates them, and getting
194 // this wrong would put one notification's buttons on another's banner.
195 var a: [category_id_len]u8 = undefined;
196 var b: [category_id_len]u8 = undefined;
197 const one = [_]Action{.{ .id = "ab", .label = "c" }};
198 const two = [_]Action{.{ .id = "a", .label = "bc" }};
199 try testing.expect(!std.mem.eql(u8, categoryId(&one, &a), categoryId(&two, &b)));
200}
201
202test "no buttons still has a category, and it is its own" {
203 var a: [category_id_len]u8 = undefined;
204 var b: [category_id_len]u8 = undefined;
205 const none = [_]Action{};
206 const one = [_]Action{.{ .id = "x", .label = "X" }};
207 try testing.expectEqual(category_id_len, categoryId(&none, &a).len);
208 try testing.expect(!std.mem.eql(u8, categoryId(&none, &a), categoryId(&one, &b)));
209}
210
211test "a category identifier is always the same length" {
212 // It is written into a fixed buffer on the Objective-C side, and a short
213 // hash printed without padding would silently truncate the comparison.
214 var buf: [category_id_len]u8 = undefined;
215 for ([_][]const u8{ "a", "bb", "ccc", "dddd" }) |id| {
216 const actions = [_]Action{.{ .id = id, .label = id }};
217 try testing.expectEqual(category_id_len, categoryId(&actions, &buf).len);
218 try testing.expect(std.mem.startsWith(u8, &buf, "craft.actions."));
219 }
220}
221
222test "more buttons than macOS will show is refused" {
223 const five = [_]Action{
224 .{ .id = "a", .label = "A" }, .{ .id = "b", .label = "B" },
225 .{ .id = "c", .label = "C" }, .{ .id = "d", .label = "D" },
226 .{ .id = "e", .label = "E" },
227 };
228 try testing.expectError(Error.TooManyActions, validate(&five));
229
230 const four = five[0..4];
231 try validate(four);
232}
233
234test "a button with no id or no label is refused" {
235 try testing.expectError(Error.EmptyActionId, validate(&[_]Action{.{ .id = "", .label = "X" }}));
236 try testing.expectError(Error.EmptyActionLabel, validate(&[_]Action{.{ .id = "x", .label = "" }}));
237}
238
239test "two buttons cannot share an id" {
240 // The response would be unable to say which was pressed, which for
241 // Approve/Deny is the entire answer.
242 try testing.expectError(Error.DuplicateActionId, validate(&[_]Action{
243 .{ .id = "same", .label = "First" },
244 .{ .id = "same", .label = "Second" },
245 }));
246}
247
248test "an id longer than the bound is refused" {
249 var long: [max_id_len + 1]u8 = undefined;
250 @memset(&long, 'x');
251 try testing.expectError(Error.ActionIdTooLong, validate(&[_]Action{.{ .id = &long, .label = "X" }}));
252
253 var at_limit: [max_id_len]u8 = undefined;
254 @memset(&at_limit, 'x');
255 try validate(&[_]Action{.{ .id = &at_limit, .label = "X" }});
256}
257
258test "no buttons at all is fine" {
259 try validate(&[_]Action{});
260}
261
262test "a plain click is told apart from a button" {
263 try testing.expectEqual(Response.opened, classify(default_action_id));
264 try testing.expectEqual(Response.dismissed, classify(dismiss_action_id));
265 switch (classify("approve")) {
266 .action => |id| try testing.expectEqualStrings("approve", id),
267 else => return error.TestUnexpectedResult,
268 }
269}
270
271test "an app may name a button after Apple's constants without being mistaken for one" {
272 // Only the exact reverse-DNS strings are Apple's. An app with a button
273 // literally called "default" gets that id back, not an open event.
274 switch (classify("default")) {
275 .action => |id| try testing.expectEqualStrings("default", id),
276 else => return error.TestUnexpectedResult,
277 }
278 switch (classify("dismiss")) {
279 .action => |id| try testing.expectEqualStrings("dismiss", id),
280 else => return error.TestUnexpectedResult,
281 }
282}
packages/zig/test/injected_js_test.zigmodified+105-0
Changes to packages/zig/test/injected_js_test.zig
@@ -1126,3 +1126,108 @@ test "the menubar state reply no longer needs a queue of its own" {
11261126 // And nothing is left queued under the key it used to invent.
11271127 try testing.expectEqualStrings("undefined", try fx.text("String(window.__craftBridgePending['menubarCollapse:getState'])"));
11281128}
1129
1130// =============================================================================
1131// Notification responses (#65)
1132// =============================================================================
1133
1134test "show means now, not in a minute" {
1135 // `show` routes through the `schedule` action, whose native `delay`
1136 // defaults to sixty seconds. Every `show()` therefore arrived a minute
1137 // later, which from the page is indistinguishable from one that never
1138 // arrived at all.
1139 var fx = try Fixture.init();
1140 defer fx.deinit();
1141 _ = try fx.ctx.evaluate(WEBVIEW_HOST);
1142 _ = try fx.ctx.evaluate(BRIDGE);
1143
1144 _ = try fx.ctx.evaluate("window.craft.notifications.show({ title: 'Hi' });");
1145 try testing.expectEqualStrings("notification", try fx.text("posted[0].t"));
1146 try testing.expectEqualStrings("schedule", try fx.text("posted[0].a"));
1147 try testing.expectEqualStrings("0", try fx.text("String(JSON.parse(posted[0].d).delay)"));
1148}
1149
1150test "an explicit delay is still honoured by show" {
1151 var fx = try Fixture.init();
1152 defer fx.deinit();
1153 _ = try fx.ctx.evaluate(WEBVIEW_HOST);
1154 _ = try fx.ctx.evaluate(BRIDGE);
1155
1156 _ = try fx.ctx.evaluate("window.craft.notifications.show({ title: 'Hi', delay: 30 });");
1157 try testing.expectEqualStrings("30", try fx.text("String(JSON.parse(posted[0].d).delay)"));
1158}
1159
1160test "action buttons reach native in the shape it parses" {
1161 var fx = try Fixture.init();
1162 defer fx.deinit();
1163 _ = try fx.ctx.evaluate(WEBVIEW_HOST);
1164 _ = try fx.ctx.evaluate(BRIDGE);
1165
1166 _ = try fx.ctx.evaluate(
1167 \\window.craft.notifications.show({
1168 \\ title: 'Tool approval',
1169 \\ body: 'Claude wants to run rm -rf build/',
1170 \\ actions: [{ id: 'approve', label: 'Approve' }, { id: 'deny', label: 'Deny' }],
1171 \\});
1172 );
1173
1174 const payload = try fx.text("posted[0].d");
1175 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
1176 defer parsed.deinit();
1177
1178 // Decoded with the very struct the bridge decodes it with, so a rename on
1179 // either side fails here rather than producing a silent buttonless banner.
1180 const Spec = struct { id: []const u8 = "", label: []const u8 = "" };
1181 const Body = struct { title: []const u8 = "", actions: []const Spec = &.{} };
1182 const decoded = try std.json.parseFromSlice(Body, testing.allocator, payload, .{
1183 .ignore_unknown_fields = true,
1184 .allocate = .alloc_always,
1185 });
1186 defer decoded.deinit();
1187
1188 try testing.expectEqual(@as(usize, 2), decoded.value.actions.len);
1189 try testing.expectEqualStrings("approve", decoded.value.actions[0].id);
1190 try testing.expectEqualStrings("Approve", decoded.value.actions[0].label);
1191 try testing.expectEqualStrings("deny", decoded.value.actions[1].id);
1192}
1193
1194test "a click and a button press reach the page as their own events" {
1195 var fx = try Fixture.init();
1196 defer fx.deinit();
1197 _ = try fx.ctx.evaluate(WEBVIEW_HOST);
1198 _ = try fx.ctx.evaluate(BRIDGE);
1199
1200 _ = try fx.ctx.evaluate(
1201 \\var clicked = 'none';
1202 \\var acted = 'none';
1203 \\window.craft.notifications.onClick(function (d) { clicked = d.notificationId });
1204 \\window.craft.notifications.onAction(function (d) { acted = d.notificationId + ':' + d.actionId });
1205 );
1206
1207 // Exactly what `emitNotificationEvent` evaluates in the page.
1208 _ = try fx.ctx.evaluate(
1209 \\window.dispatchEvent(new CustomEvent('craft:notification:click',{detail:{"notificationId":"n1"}}));
1210 );
1211 _ = try fx.ctx.evaluate(
1212 \\window.dispatchEvent(new CustomEvent('craft:notification:action',{detail:{"notificationId":"n1","actionId":"approve"}}));
1213 );
1214
1215 try testing.expectEqualStrings("n1", try fx.text("clicked"));
1216 try testing.expectEqualStrings("n1:approve", try fx.text("acted"));
1217}
1218
1219test "the event names the page listens for are the ones the channels declare" {
1220 // The page subscribes with `_evt('craft:notification:click')`; native emits
1221 // through `capabilities.Channel`. If those two strings ever drift, the
1222 // handler is simply never called and nothing reports it.
1223 try testing.expectEqualStrings(
1224 "craft:notification:click",
1225 capabilities.Channel.notification_click.eventName(),
1226 );
1227 try testing.expectEqualStrings(
1228 "craft:notification:action",
1229 capabilities.Channel.notification_action.eventName(),
1230 );
1231 try testing.expect(std.mem.indexOf(u8, BRIDGE, "_evt('craft:notification:click')") != null);
1232 try testing.expect(std.mem.indexOf(u8, BRIDGE, "_evt('craft:notification:action')") != null);
1233}