Skip to content

Commit 134ad2d

Browse files
authored
feat(linux): show notifications and alerts through freedesktop D-Bus (#1471)
1 parent c943b1b commit 134ad2d

20 files changed

Lines changed: 1455 additions & 57 deletions

docs/CROSS_PLATFORM.md

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ reference implementation, and a gap on this platform is a bug.
6565

6666
**Beta** — good for daily driving. Every navigation mode works and behaves the
6767
same as it does on a stable platform; what is missing sits around the edges
68-
(notifications, alerts, a few animations) rather than in your way.
68+
(a few animations, the OCR hint strategy) rather than in your way.
6969

7070
**Alpha** — worth trying, not yet worth switching to. Core navigation works, but
7171
hint coverage is incomplete and per-app config does not re-apply on focus
@@ -157,8 +157,8 @@ that is what [Known Gaps](#known-gaps) tracks, per
157157
| **Dark mode detection** | ✅ Cocoa appearance | ✅ xdg appearance portal | ✅ xdg appearance portal | ✅ kdeglobals + portal | ✅ registry |
158158
| **Font resolution** | ✅ NSFont | ✅ fontconfig | ✅ fontconfig | ✅ fontconfig | ⚠️ generic-alias map only ² |
159159
| **System tray** | ✅ NSStatusItem | ✅ D-Bus StatusNotifierItem | ✅ StatusNotifierItem | ✅ StatusNotifierItem | ✅ Win32 notification area |
160-
| **Native alerts** | ✅ NSAlert | 🟡 | 🟡 | 🟡 |`MessageBoxW` |
161-
| **Native notifications** | ✅ UNNotification | 🟡 | 🟡 | 🟡 | 🟡 |
160+
| **Native alerts** | ✅ NSAlert | ⚠️ D-Bus, not modal | ⚠️ D-Bus, not modal | ⚠️ D-Bus, not modal |`MessageBoxW` |
161+
| **Native notifications** | ✅ UNNotification | `org.freedesktop.Notifications` |`org.freedesktop.Notifications` |`org.freedesktop.Notifications` | 🟡 |
162162
| **Secure input detection** || ➖ always false | ➖ always false | ➖ always false | ➖ always false |
163163
| **System cursor hide** |`CGDisplayHideCursor` |||||
164164
| **`monitor_select` mode** | ✅ native panels | ✅ Cairo panels | ✅ Cairo panels | ✅ Cairo panels | 🟡 `CodeNotSupported` |
@@ -250,6 +250,24 @@ binary was compiled. Both name the same fallback — bind `neru <mode>` as a
250250
compositor keybinding. While a mode is active the in-mode event tap grabs the
251251
same devices, so the listener naturally goes quiet until the mode exits.
252252

253+
**Native alerts on Linux.** Notifications and alerts both go to the session's
254+
freedesktop notification daemon over D-Bus — the same session bus the tray's
255+
StatusNotifierItem uses, in pure Go, so a `CGO_ENABLED=0` build shows them too.
256+
An alert differs from a notification only in insistence: critical urgency and no
257+
expiry, which the specification requires a daemon to leave on screen until it is
258+
dismissed. What it is *not* is modal. macOS's `NSAlert` stops the world and
259+
returns which button was pressed; no ordinary Wayland or X11 client can do that,
260+
so a Linux alert informs rather than asks, and callers that would have branched
261+
on the answer take the safe default. The two startup alerts are where that shows
262+
up: a missing config file starts Neru on built-in defaults and says so, instead
263+
of offering create / defaults / quit. Delivery depends on the session having a
264+
notification daemon (mako, dunst, or the desktop's own) — either running, or
265+
registered with the bus to be started on demand, which is how most desktops ship
266+
theirs and which `neru doctor` counts as present. With none, `ShowNotification`
267+
and `ShowAlert` report `CodeNotSupported` naming what is absent, `neru doctor`
268+
probes the session and downgrades the notifications row with a line saying what
269+
to install, and the two startup alerts fall back to stderr.
270+
253271
**Smooth cursor animation on Linux.** Off by default; opt in with
254272
`smooth_cursor.move_mouse_enabled` (the same cross-platform `SmoothCursorConfig`
255273
macOS uses). When enabled, `SystemAdapter.MoveCursorToPoint` routes through
@@ -751,49 +769,45 @@ command — that means less here than it does on macOS, whether or not the
751769

752770
**Linux**
753771

754-
1. Native notifications and alerts — both stubs; target freedesktop D-Bus
755-
notifications. `ShowNotification` has an empty body and no error to carry,
756-
`ShowAlert` returns `CodeNotSupported`, and the config-onboarding and
757-
validation-error alerts silently return defaults instead of prompting
758-
2. `neru services` — no systemd user unit, so install/uninstall/start/stop/
772+
1. `neru services` — no systemd user unit, so install/uninstall/start/stop/
759773
restart/status all return `CodeNotSupported` where macOS writes a launchd
760774
plist. Scope is systemd; other init systems stay `CodeNotSupported`
761-
3. `neru docs` — returns `CodeNotSupported` although the tray already opens
775+
2. `neru docs` — returns `CodeNotSupported` although the tray already opens
762776
URLs through `xdg-open` in the same repo
763-
4. Smooth scroll animation — not implemented, and `smooth_scroll.*` is parsed,
777+
3. Smooth scroll animation — not implemented, and `smooth_scroll.*` is parsed,
764778
validated and then silently ignored. Spike `REL_WHEEL_HI_RES` (uinput),
765779
continuous `wl_pointer` axis values and libei scroll deltas before
766780
committing
767-
5. Hints search input badge — not drawn; the overlay manager reports
781+
4. Hints search input badge — not drawn; the overlay manager reports
768782
`CodeNotSupported` and the query goes on reaching hints through the event
769783
tap's key stream
770-
6. Screen capture — no code path anywhere in the tree. Prerequisite for the OCR
784+
5. Screen capture — no code path anywhere in the tree. Prerequisite for the OCR
771785
strategy below and the missing half of `ports.Vision`. Take it per backend:
772786
`wlr-screencopy` on wlroots, `XGetImage` on X11, the portal only for KDE
773-
7. `vision` hint strategy — no engine. Met by linking one through
787+
6. `vision` hint strategy — no engine. Met by linking one through
774788
`#cgo pkg-config`, as every other native dependency here is, with the engine
775789
added to the required Linux library list and its language data checked at
776790
use so a missing `tessdata` reports `CodeNotSupported` naming what is
777791
absent. Note the strategy is wider than OCR: macOS also runs rectangle
778792
detection and saliency, which no OCR engine answers, so
779793
`hints.vision.detect_rectangles` and the four `rectangle_*` options are
780-
declared macOS-only and Linux `vision` is text-only. Needs 6
781-
8. X11 unmodified scroll — a scroll with no `--modifier` presses nothing, so the
794+
declared macOS-only and Linux `vision` is text-only. Needs 5
795+
7. X11 unmodified scroll — a scroll with no `--modifier` presses nothing, so the
782796
`XTestFakeButtonEvent` still carries whatever the X server records the user as
783797
physically holding. Binding `Ctrl+J` to a plain `scroll_down` therefore sends
784798
ctrl+scroll for as long as ctrl is down. macOS forces the empty set onto the
785799
event instead; a real-key backend has no per-event field to zero, so closing
786800
this means reading the live key state through `XQueryKeymap` in the C bridge
787-
9. KDE RemoteDesktop portal grant — does not survive a daemon restart, so the
801+
8. KDE RemoteDesktop portal grant — does not survive a daemon restart, so the
788802
consent prompt returns on every start
789-
10. Grid virtual-pointer indicator — a no-op on Linux, while recursive grid
790-
draws it on all three platforms
791-
11. `FocusedWindowBounds` — returns not-found on KWin, so callers silently fall
803+
9. Grid virtual-pointer indicator — a no-op on Linux, while recursive grid
804+
draws it on all three platforms
805+
10. `FocusedWindowBounds` — returns not-found on KWin, so callers silently fall
792806
back to the active screen
793-
12. Wayland global hotkeys — a setup requirement rather than missing code: they
807+
11. Wayland global hotkeys — a setup requirement rather than missing code: they
794808
need `input`-group membership and a CGO build. Failing loudly with the
795809
remedy, and documenting it as a first-class setup step, is the work
796-
13. Tail — the tray tooltip is a no-op (dbusmenu carries no such property), the
810+
12. Tail — the tray tooltip is a no-op (dbusmenu carries no such property), the
797811
tray has one icon for both running and paused states where macOS has two,
798812
and the `CGO_ENABLED=0` build should announce its boundary once at startup
799813
rather than failing feature by feature

docs/LINUX_SETUP.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,12 @@ systemctl --user enable --now neru
311311
by app. DE-specific coordinate details: [LINUX_DESKTOPS.md](./LINUX_DESKTOPS.md).
312312
3. **Dark mode** — Via `org.freedesktop.appearance` portal, with session-specific
313313
fallbacks where the portal is unavailable.
314-
4. **Notifications** — May log instead of using `org.freedesktop.Notifications`.
314+
4. **Notifications** — Delivered over `org.freedesktop.Notifications` on the
315+
session bus, so a notification daemon (mako, dunst, or your desktop's own)
316+
has to be running — or installed as a D-Bus service the bus starts on
317+
demand, as most desktops ship theirs. What Neru does when neither, and why
318+
an alert is not modal here: "Native alerts on Linux" under the
319+
[Capability Matrix](./CROSS_PLATFORM.md#capability-matrix).
315320
5. **Wayland modified clicks** — Need `evdev` access (see [keyboard permissions](#wayland-keyboard-capture-permissions)).
316321
6. **Monitor hotplug** — Adding/removing a monitor is tracked live (RandR on X11,
317322
`wl_output` on Wayland) and the overlay follows; a relaunch is only needed for a

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ wlroots Wayland with a CGO build, with a closed set of macOS-only exemptions.
4040

4141
The two largest open areas:
4242

43-
- **Linux**freedesktop notifications and alerts across all backends, and
43+
- **Linux**screen capture and the OCR hint strategy it unblocks, and
4444
persisting the KDE RemoteDesktop portal grant across daemon restarts.
4545
- **Windows** — foreground-window and display-hotplug events, which currently
4646
block per-app config re-application and monitor tracking.

internal/adapter/platform/darwin/system.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,14 @@ func (s *SystemAdapter) ShowAlert(ctx context.Context, title, message string) er
188188
}
189189

190190
// ShowNotification displays a lightweight toast/banner notification on macOS.
191-
func (s *SystemAdapter) ShowNotification(title, message string) {
191+
//
192+
// The UserNotifications path is fire-and-forget, so this returns as soon as
193+
// the request is handed to the notification center; there is no failure to
194+
// report at this point and it always reports success.
195+
func (s *SystemAdapter) ShowNotification(_ context.Context, title, message string) error {
192196
ShowNotification(title, message)
197+
198+
return nil
193199
}
194200

195201
// CheckScreenCapturePermission reports whether macOS screen recording is

internal/adapter/platform/factory_linux.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
package platform
44

55
import (
6+
"context"
7+
"fmt"
8+
"os"
9+
610
"github.com/y3owk1n/neru/internal/adapter/platform/linux"
711
"github.com/y3owk1n/neru/internal/ports"
812
)
@@ -25,13 +29,49 @@ func NewFontResolver() ports.FontResolver {
2529
return linux.NewFontResolver()
2630
}
2731

28-
// ShowConfigOnboardingAlert is a stub on Linux.
29-
func ShowConfigOnboardingAlert(_ string) int {
32+
// ShowConfigOnboardingAlert tells a first-time user that Neru started on
33+
// built-in defaults, and how to get a config file, then answers with that
34+
// choice.
35+
//
36+
// macOS asks the question in a modal NSAlert and waits — create, use defaults,
37+
// or quit. Linux answers it instead of asking, deliberately. A modal dialog
38+
// here needs a toolkit Neru does not link or a helper (zenity, kdialog) a
39+
// minimal wlroots session need not have, so the honest choices were a prompt
40+
// that may never appear or a message plus a safe default. Blocking startup on
41+
// a dialog nobody can see is worse than starting; a keyboard-driven tool whose
42+
// first act is to seize the session for a question is worse still. So the
43+
// question is answered the way the user could only have answered it after
44+
// being asked — run, on defaults, changing nothing on disk — and they are told
45+
// what happened and what to type.
46+
func ShowConfigOnboardingAlert(configPath string) int {
47+
title := "Neru is running on built-in defaults"
48+
message := "No configuration file at " + configPath +
49+
". Run `neru config init` to create one."
50+
51+
// Bounded by the adapter's own notify deadline; these run before the daemon
52+
// is up, so a wedged session bus costs a moment rather than the launch.
53+
err := linux.ShowAlert(context.Background(), title, message)
54+
if err != nil {
55+
// Onboarding has no other channel: nothing upstream prints this, so a
56+
// session with no notification daemon would otherwise learn nothing.
57+
fmt.Fprintf(os.Stderr, "⚠️ %s.\n%s\n\n", title, message)
58+
}
59+
3060
return ConfigOnboardingDefaults
3161
}
3262

33-
// ShowConfigValidationErrorAlert is a stub on Linux.
34-
func ShowConfigValidationErrorAlert(_, _ string) int {
63+
// ShowConfigValidationErrorAlert puts a rejected configuration in front of the
64+
// user before Neru exits, then answers as the dismissed macOS dialog does.
65+
//
66+
// It is a notification rather than a dialog for the reasons above; the alert
67+
// shape it uses stays on screen until dismissed, so a message that arrives
68+
// while the user is looking elsewhere is still there when they look back.
69+
func ShowConfigValidationErrorAlert(errorMessage, configPath string) int {
70+
// The launcher has already written the same failure to stderr, so a
71+
// missing notification daemon costs the desktop copy rather than the
72+
// message — nothing to fall back to here.
73+
_ = linux.ShowAlert(context.Background(), "Neru could not load "+configPath, errorMessage)
74+
3575
return ConfigValidationOK
3676
}
3777

internal/adapter/platform/linux/system_common.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package linux
66
import (
77
"bufio"
88
"context"
9+
"errors"
910
"image"
1011
"io"
1112
"os"
@@ -84,6 +85,12 @@ func (s *SystemAdapter) Capabilities() ports.PlatformCapabilities {
8485
value, source, ok := darkModePreference()
8586
capabilities.DarkModeDetection = darkModeCapability(value, source, ok)
8687

88+
// Notifications are live-probed for the same reason dark mode is: the code
89+
// path exists on every backend, but whether the user will see anything
90+
// depends on a notification daemon being installed, which is a session fact
91+
// rather than a build one.
92+
capabilities.Notifications = s.notificationCapability(capabilities.Notifications)
93+
8794
// Screen, cursor and process support is what this binary and session can
8895
// actually reach, not what the build target intends — the static preset is
8996
// wrong for CGO_ENABLED=0, for compositors without the wlroots stack, and
@@ -491,15 +498,20 @@ func (s *SystemAdapter) IsSecureInputEnabled() bool {
491498
// ShowSecureInputNotification is a no-op on Linux — secure input is a macOS-only concept.
492499
func (s *SystemAdapter) ShowSecureInputNotification() {}
493500

494-
// ShowAlert displays a native system alert on Linux.
495-
// TODO(linux): implement using libnotify, zenity, or kdialog.
501+
// ShowAlert displays a message the user has to dismiss, through the session's
502+
// freedesktop notification daemon. Delivery does not depend on the display
503+
// server, so every Linux backend takes the same path; see alertNotification
504+
// for why this is a critical-urgency notification rather than a modal dialog.
496505
func (s *SystemAdapter) ShowAlert(ctx context.Context, title, message string) error {
497-
return derrors.New(derrors.CodeNotSupported, "ShowAlert not yet implemented on linux")
506+
return ShowAlert(ctx, title, message)
498507
}
499508

500-
// ShowNotification displays a lightweight notification on Linux.
501-
// TODO(linux): implement using org.freedesktop.Notifications D-Bus interface.
502-
func (s *SystemAdapter) ShowNotification(title, message string) {}
509+
// ShowNotification displays a lightweight notification through the session's
510+
// freedesktop notification daemon, reporting CodeNotSupported when the session
511+
// has no bus or no daemon to show it rather than dropping the message.
512+
func (s *SystemAdapter) ShowNotification(ctx context.Context, title, message string) error {
513+
return ShowNotification(ctx, title, message)
514+
}
503515

504516
// CheckScreenCapturePermission reports true: Linux does not gate screen capture
505517
// behind a permission.
@@ -553,6 +565,67 @@ func (s *SystemAdapter) probedCapability(
553565
}
554566
}
555567

568+
// notificationFeature names the notification probe's slot in capabilityProbes,
569+
// and opens the detail it reports.
570+
const notificationFeature = "desktop notifications"
571+
572+
// notificationCapability live-probes whether a notification daemon is
573+
// reachable right now. The question a user runs `neru doctor` to answer is
574+
// "will I see notifications?", and on Linux that is answered by the session
575+
// rather than by this code: every backend can send one, and a session with no
576+
// daemon shows none. Reporting the static "supported" there would be the same
577+
// lie the empty ShowNotification body used to tell.
578+
//
579+
// It shares the probe slots and the budget with probedCapability but not its
580+
// body: that one explains a failure in terms of the display-server backend,
581+
// which decides nothing here — notifications are a session-bus service every
582+
// backend reaches the same way. The budget has to be the full one because the
583+
// first probe of a daemon's life pays for the session-bus connect as well as
584+
// the question, and reporting "could not be confirmed" on a session where
585+
// notifications work is the same dishonesty in the other direction.
586+
func (s *SystemAdapter) notificationCapability(
587+
declared ports.FeatureCapability,
588+
) ports.FeatureCapability {
589+
completed, err := s.probes.run(
590+
notificationFeature,
591+
capabilityProbeTimeout,
592+
func() error {
593+
ctx, cancel := context.WithTimeout(context.Background(), capabilityProbeTimeout)
594+
defer cancel()
595+
596+
return sessionNotifier.daemonReachable(ctx)
597+
},
598+
)
599+
600+
switch {
601+
case !completed:
602+
return ports.FeatureCapability{
603+
Status: ports.FeatureStatusStub,
604+
Detail: notificationFeature + " could not be confirmed: the session bus did not " +
605+
"answer within " + capabilityProbeTimeout.String(),
606+
}
607+
case err != nil:
608+
return ports.FeatureCapability{
609+
Status: ports.FeatureStatusStub,
610+
Detail: notificationFeature + " are unavailable: " + userFacingReason(err),
611+
}
612+
default:
613+
return declared
614+
}
615+
}
616+
617+
// userFacingReason unwraps a domain error to the sentence written for the
618+
// user, leaving the "[CODE] …" prefix out of a capability detail that `neru
619+
// doctor` prints verbatim.
620+
func userFacingReason(err error) string {
621+
var domainErr *derrors.Error
622+
if errors.As(err, &domainErr) {
623+
return domainErr.Message()
624+
}
625+
626+
return err.Error()
627+
}
628+
556629
// unavailableDetail explains why a probed capability is unavailable, in terms
557630
// the user can act on.
558631
func (s *SystemAdapter) unavailableDetail(feature string, cause error) string {

0 commit comments

Comments
 (0)