diff --git a/docs/usage.md b/docs/usage.md index 02bbb5af..de9ffc1d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -107,8 +107,11 @@ show `ack pending` and retry on reconnect. Desktop alerts show the session title or project and why it needs you, within your capture policy. macOS notifications require the installed **Driga.app** (`brew install --cask labstack/tap/driga`); they use Driga's name and icon through the native -UserNotifications API. Allow alerts and sounds for Driga when macOS asks, or in -System Settings → Notifications → Driga. Unbundled builds (`go run`, checkout +UserNotifications API. The collector asks for permission when it starts, so +answer the macOS prompt after `driga service install`. macOS asks only once: +if the prompt was declined, or withdrawn because the collector restarted before +you answered, turn on Allow notifications in System Settings → Notifications → +Driga (`driga doctor` prints the command that opens that page). Unbundled builds (`go run`, checkout binaries) cannot send macOS notifications; `driga doctor` explains how to install the app. No notification helpers are needed. `driga doctor` warns when notifications are turned off for Driga; turning them back on takes effect from @@ -123,7 +126,8 @@ window with `inbox.jump_terminal = true`, or print a resume command. Machine warnings open Terminal on `driga top --tab machines`. Batch summaries and remote alerts have no click action. The collector keeps the native notification delegate and main run loop alive while it runs. Re-sending an item replaces its existing -notification. Linux uses notify-send; Windows uses native WinRT toasts. +notification. Linux uses notify-send; Windows uses native WinRT toasts. Click +to jump works on macOS only; Linux and Windows alerts have no click action. Health warnings notify once per condition, until it clears. Concurrent health warnings from one machine form one summary. Details and cleanup commands remain diff --git a/internal/attention/notifications.go b/internal/attention/notifications.go index 4f08e9a4..b009be45 100644 --- a/internal/attention/notifications.go +++ b/internal/attention/notifications.go @@ -14,6 +14,11 @@ import ( // ErrNotificationsDenied means macOS has Driga's notifications turned off. var ErrNotificationsDenied = errors.New("Driga is not allowed to send notifications") +// MacNotificationSettings opens Driga's page in System Settings > Notifications. +// macOS asks only once, so after a refusal or a prompt withdrawn by a restart +// this is the only way to turn notifications on. +const MacNotificationSettings = `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga"` + // NativeNotificationRequest is the value-only boundary to UserNotifications. // No Objective-C objects escape the Darwin implementation. type NativeNotificationRequest struct { @@ -46,6 +51,36 @@ func (m *MacNotifier) Notify(ctx context.Context, n Notification) error { if err := ctx.Err(); err != nil { return err } + ch := m.authorize() + select { + case <-ch: + return m.post(ctx, n) + default: + } + // While the user considers permission, keep only the latest alert per + // destination and post them once answered; collection never waits. + m.mu.Lock() + if m.pending == nil { + m.pending = map[string]Notification{} + go m.flush(ctx, ch) + } + m.pending[nativeNotificationRequest(n).Identifier] = n + m.mu.Unlock() + return nil +} + +// RequestPermission asks macOS once at startup, while the user who just +// installed or restarted the service is likely present. macOS shows its prompt +// only while undecided and answers silently from the saved setting otherwise. +func (m *MacNotifier) RequestPermission() { + if m.Center != nil { + m.authorize() + } +} + +// authorize starts at most one permission request and returns the channel +// closed when macOS answers. +func (m *MacNotifier) authorize() chan struct{} { m.mu.Lock() ch, start := m.authorized, m.authorized == nil if start { @@ -67,21 +102,7 @@ func (m *MacNotifier) Notify(ctx context.Context, n Notification) error { close(ch) }) } - select { - case <-ch: - return m.post(ctx, n) - default: - } - // While the user considers permission, keep only the latest alert per - // destination and post them once answered; collection never waits. - m.mu.Lock() - if m.pending == nil { - m.pending = map[string]Notification{} - go m.flush(ctx, ch) - } - m.pending[nativeNotificationRequest(n).Identifier] = n - m.mu.Unlock() - return nil + return ch } func (m *MacNotifier) flush(ctx context.Context, ch chan struct{}) { diff --git a/internal/attention/notifications_darwin.go b/internal/attention/notifications_darwin.go index 637a7f7e..b1962257 100644 --- a/internal/attention/notifications_darwin.go +++ b/internal/attention/notifications_darwin.go @@ -27,6 +27,10 @@ func MacDesktopNotifier() Notifier { return macNotifier } // a viewer without the service, must leave alerts for the collector. func DesktopNotificationsReady() bool { return macNotifier.(*MacNotifier).Center != nil } +// RequestNotificationPermission asks macOS for permission when this process +// runs the native center (the bundled collector); elsewhere it does nothing. +func RequestNotificationPermission() { macNotifier.(*MacNotifier).RequestPermission() } + func nsClass(name string) objc.ID { return objc.ID(objc.GetClass(name)) } func nsString(s string) objc.ID { return nsClass("NSString").Send(objc.RegisterName("stringWithUTF8String:"), s) @@ -60,7 +64,7 @@ func MacNotificationStatus() (string, error) { case 1: return "", ErrNotificationsDenied case 0: - return "native, not asked yet: macOS asks when Driga first notifies", nil + return "native, not asked yet: macOS asks when the collector starts", nil case -1: return "native UserNotifications (Driga); permission unknown", nil } diff --git a/internal/attention/notifications_other.go b/internal/attention/notifications_other.go index d341450b..abb9698c 100644 --- a/internal/attention/notifications_other.go +++ b/internal/attention/notifications_other.go @@ -11,3 +11,5 @@ func MacNotificationStatus() (string, error) { func MacDesktopNotifier() Notifier { return &MacNotifier{} } func DesktopNotificationsReady() bool { return true } + +func RequestNotificationPermission() {} diff --git a/internal/attention/notifications_test.go b/internal/attention/notifications_test.go index ed7cdeaf..89b7f493 100644 --- a/internal/attention/notifications_test.go +++ b/internal/attention/notifications_test.go @@ -223,3 +223,29 @@ func TestMacPendingAuthorizationQueuesFirstAlert(t *testing.T) { t.Fatal(f.requests) } } + +func TestMacRequestPermissionAsksOnceBeforeAnyAlert(t *testing.T) { + (&MacNotifier{}).RequestPermission() // no native center: nothing to ask + f := &fakeNotificationCenter{} + m := &MacNotifier{Center: f} + m.RequestPermission() + m.RequestPermission() + if f.authorizations != 1 { + t.Fatalf("authorizations = %d, want 1", f.authorizations) + } + if err := m.Notify(t.Context(), Notification{Kind: "waiting", Title: "Needs you", ItemID: "a", MachineID: "local"}); err != nil { + t.Fatal(err) + } + if f.authorizations != 1 || f.count() != 1 { + t.Fatalf("authorizations = %d, posts = %d; the startup answer must cover the first alert", f.authorizations, f.count()) + } + f.authErr = ErrNotificationsDenied + m = &MacNotifier{Center: f} + m.RequestPermission() + if err := m.Notify(t.Context(), Notification{Kind: "waiting", Title: "Needs you", ItemID: "b", MachineID: "local"}); !errors.Is(err, ErrNotificationsDenied) { + t.Fatalf("Notify after refusal = %v", err) + } + if f.authorizations != 3 { + t.Fatalf("authorizations = %d; a refusal must be re-checked on the next alert", f.authorizations) + } +} diff --git a/internal/cli/collect.go b/internal/cli/collect.go index 28920b39..739bd0cb 100644 --- a/internal/cli/collect.go +++ b/internal/cli/collect.go @@ -78,6 +78,7 @@ func (c CollectCmd) Run(g *Globals) error { } else { slog.Info("macOS notifications", "event", "notifications_status", "status", status) } + attention.RequestNotificationPermission() } ctx, stop := notifyContext(context.Background()) defer stop() diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index e530feb7..35a6164e 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -434,7 +434,7 @@ func Doctor(ctx context.Context, d DoctorDeps) []Check { detail, err := status() if err != nil { if errors.Is(err, attention.ErrNotificationsDenied) { - add("notifications", "warn", err.Error(), "System Settings > Notifications > Driga > Allow notifications") + add("notifications", "warn", err.Error(), attention.MacNotificationSettings) } else { add("notifications", "info", err.Error(), "brew install --cask labstack/tap/driga") } diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 8dc89b63..aa945da6 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -103,7 +103,7 @@ func TestDoctorMacNotificationsDenied(t *testing.T) { d, _ := doctorFixture(t) d.OS = "darwin" d.NotificationStatus = func() (string, error) { return "", attention.ErrNotificationsDenied } - c := requireCheck(t, Doctor(context.Background(), d), "notifications", "warn", "System Settings > Notifications > Driga > Allow notifications") + c := requireCheck(t, Doctor(context.Background(), d), "notifications", "warn", `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga"`) if c.Detail != attention.ErrNotificationsDenied.Error() { t.Fatal(c) }