Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 36 additions & 15 deletions internal/attention/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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{}) {
Expand Down
6 changes: 5 additions & 1 deletion internal/attention/notifications_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/attention/notifications_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ func MacNotificationStatus() (string, error) {
func MacDesktopNotifier() Notifier { return &MacNotifier{} }

func DesktopNotificationsReady() bool { return true }

func RequestNotificationPermission() {}
26 changes: 26 additions & 0 deletions internal/attention/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions internal/cli/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading