Skip to content

Commit f1b6640

Browse files
authored
fix(linux): report when a no-cgo build cannot run Wayland global hotkeys (#1432)
1 parent dbd189b commit f1b6640

20 files changed

Lines changed: 642 additions & 72 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Configuration is hot-reloadable TOML; adding an option touches four links every
7575
Formatting and lint mechanics are fully enforced by `just fmt` + `just lint` — run them rather than memorizing rules. What tooling cannot enforce:
7676

7777
- Logging: named zap loggers per subsystem; constructors accept nil (`zap.NewNop()` fallback). `info` is for lifecycle/config/mode-activation only; per-keypress internals go to `debug`. **Never log UI text, element titles/values, hint search terms, keystreams, exec output, or raw config subtrees** — log counts, durations, IDs, booleans instead.
78-
- Tests: unit tests use port mocks from `internal/ports/mocks`; real-OS tests are `*_integration_<os>_test.go` tagged `//go:build integration && <os>`. Table-driven, `TestType_Method_EdgeCase` naming. Every platform stub gets a contract test pinning its `CodeNotSupported` behavior. Full user journeys (hotkey → overlay draw → cursor/click) run as plain unit tests through the simulation harness in `internal/app/simulation_harness_test.go` — extend those journeys when changing user-visible mode behavior.
78+
- Tests: unit tests use port mocks from `internal/ports/mocks`; real-OS tests are `*_integration_<os>_test.go` tagged `//go:build integration && <os>`. Table-driven, `TestType_Method_EdgeCase` naming. Platform stubs return `derrors.CodeNotSupported`; contract tests pin that per subsystem rather than for every stub in the tree — `internal/adapter/platform/AGENTS.md` names the ones that exist. Full user journeys (hotkey → overlay draw → cursor/click) run as plain unit tests through the simulation harness in `internal/app/simulation_harness_test.go` — extend those journeys when changing user-visible mode behavior.
7979
- Commits are conventional commits; Release Please ships the subject verbatim in the changelog, so write it for users (the `create-pr` skill covers this).
8080
- Each documented fact has exactly one home — ownership table in `docs/CROSS_PLATFORM.md`. Capability *status* goes there, never in `docs/ARCHITECTURE.md` (shape, not status). Update docs in the same change as platform work.
8181

docs/CROSS_PLATFORM.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,14 @@ focused app still receives every key
225225
([global_hotkey_cgo.go](../internal/adapter/eventtap/linux/global_hotkey_cgo.go)).
226226
Two conditions apply: the process needs read access to `/dev/input` (add your
227227
user to the `input` group), and it requires CGO — a `CGO_ENABLED=0` build gets a
228-
no-op stub. When the listener cannot start, Neru logs a warning pointing at both
229-
the `input` group and the fallback: bind `neru <mode>` as a compositor
230-
keybinding. While a mode is active the in-mode event tap grabs the same devices,
231-
so the listener naturally goes quiet until the mode exits.
228+
stub whose `Start` reports `CodeNotSupported`
229+
([global_hotkey_nocgo.go](../internal/adapter/eventtap/linux/global_hotkey_nocgo.go)).
230+
Either way the listener cannot start, and Neru warns with the remediation that
231+
fits. An unreadable `/dev/input` points at the `input` group; a no-cgo build
232+
points at the build, and is warned about once, since no retry changes how the
233+
binary was compiled. Both name the same fallback — bind `neru <mode>` as a
234+
compositor keybinding. While a mode is active the in-mode event tap grabs the
235+
same devices, so the listener naturally goes quiet until the mode exits.
232236

233237
**Smooth cursor animation on Linux.** Off by default; opt in with
234238
`smooth_cursor.move_mouse_enabled` (the same cross-platform `SmoothCursorConfig`

internal/adapter/eventtap/linux/global_hotkey_nocgo.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import (
66
"time"
77

88
"go.uber.org/zap"
9+
10+
"github.com/y3owk1n/neru/internal/derrors"
911
)
1012

11-
// GlobalHotkeyListener is a no-op stub when cgo is disabled.
12-
//
13-
// No-op GlobalHotkeyListener for builds without cgo (evdev needs cgo).
14-
// Does nothing; exists only so the hotkey manager compiles without cgo.
13+
// GlobalHotkeyListener is the stub for builds without cgo, which evdev needs.
14+
// It watches nothing; it exists so the hotkey manager compiles, and Start says
15+
// so rather than letting the manager believe a reader is running.
1516
type GlobalHotkeyListener struct{}
1617

1718
// NewGlobalHotkeyListener returns a stub listener.
@@ -25,8 +26,15 @@ func (l *GlobalHotkeyListener) SetBinding(_ string, _ func()) {}
2526
// ClearBindings is a no-op without cgo.
2627
func (l *GlobalHotkeyListener) ClearBindings() {}
2728

28-
// Start is a no-op without cgo.
29-
func (l *GlobalHotkeyListener) Start() error { return nil }
29+
// Start reports CodeNotSupported without cgo: evdev needs it, so there is no
30+
// reader to start. Answering nil would tell the hotkey manager the user's
31+
// config keybindings are live while nothing is watching the keyboard.
32+
func (l *GlobalHotkeyListener) Start() error {
33+
return derrors.New(
34+
derrors.CodeNotSupported,
35+
"Wayland global hotkeys require CGO-enabled Linux builds",
36+
)
37+
}
3038

3139
// Stop is a no-op without cgo.
3240
func (l *GlobalHotkeyListener) Stop() {}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//go:build linux && !cgo
2+
3+
package linux
4+
5+
import (
6+
"testing"
7+
8+
"github.com/y3owk1n/neru/internal/derrors"
9+
)
10+
11+
// The evdev global-hotkey listener is the Wayland substitute for OS-level
12+
// global hotkeys, and it needs cgo. In a CGO_ENABLED=0 Linux build the whole
13+
// type is a stub, so Start has to say so.
14+
//
15+
// Returning nil is the failure this pins. The only caller is
16+
// `hotkeys/linux.Manager.ensureWaylandStarted`, which reads a nil error as
17+
// "the listener is reading the keyboard" and sets waylandStarted, logging the
18+
// info line that tells the user their config keybindings are active. On a
19+
// no-cgo build nothing is reading anything: the user is told their hotkeys
20+
// work, presses one, and nothing happens — with no warning anywhere pointing
21+
// at the build. CodeNotSupported routes the same call into the warn branch
22+
// that already exists for an unreadable /dev/input.
23+
//
24+
// The rule is `internal/adapter/platform/AGENTS.md`, "Stubs are loud".
25+
//
26+
// Note where this runs: the CI Linux leg builds with CGO on, so nothing on the
27+
// gate compiles this file. `just test-linux-nocgo` does, as does any
28+
// CGO_ENABLED=0 Linux build — run it after touching the no-cgo twins.
29+
func TestGlobalHotkeyListener_StartReportsNotSupportedWithoutCgo(t *testing.T) {
30+
listener := NewGlobalHotkeyListener(nil)
31+
32+
err := listener.Start()
33+
if err == nil {
34+
t.Fatal(
35+
"Start returned nil without cgo; the hotkey manager would report the " +
36+
"user's config keybindings active while nothing reads the keyboard",
37+
)
38+
}
39+
40+
if !derrors.IsNotSupported(err) {
41+
t.Errorf("Start returned %v (code %q), want CodeNotSupported",
42+
err, derrors.GetCode(err))
43+
}
44+
}
45+
46+
// TestGlobalHotkeyListener_StartIsStableAcrossCalls guards against a stub that
47+
// refuses once and then changes its answer. The manager retries Start on every
48+
// registration once waylandStarted has been cleared, so a second call that
49+
// returned nil would put it back in the state above.
50+
func TestGlobalHotkeyListener_StartIsStableAcrossCalls(t *testing.T) {
51+
listener := NewGlobalHotkeyListener(nil)
52+
53+
for i := range 3 {
54+
err := listener.Start()
55+
if !derrors.IsNotSupported(err) {
56+
t.Fatalf("Start call %d returned %v, want CodeNotSupported every time", i+1, err)
57+
}
58+
}
59+
}
60+
61+
// TestGlobalHotkeyListener_IsRunningStaysFalseAfterStart pins the other half of
62+
// the refusal: IsRunning and DeviceCount are what
63+
// `hotkeys/linux.Manager.HealthCheck` polls, and a stub that claimed to be
64+
// running would make the health loop stop trying to recover a listener that
65+
// does not exist.
66+
func TestGlobalHotkeyListener_IsRunningStaysFalseAfterStart(t *testing.T) {
67+
listener := NewGlobalHotkeyListener(nil)
68+
69+
_ = listener.Start()
70+
71+
if listener.IsRunning() {
72+
t.Error("IsRunning reported true without cgo; no evdev reader exists")
73+
}
74+
75+
if got := listener.DeviceCount(); got != 0 {
76+
t.Errorf("DeviceCount() = %d without cgo, want 0", got)
77+
}
78+
}

internal/adapter/hotkeys/linux/manager.go

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
eventtaplinux "github.com/y3owk1n/neru/internal/adapter/eventtap/linux"
1212
"github.com/y3owk1n/neru/internal/adapter/platform"
13+
"github.com/y3owk1n/neru/internal/derrors"
1314
"github.com/y3owk1n/neru/internal/ports"
1415
)
1516

@@ -29,6 +30,12 @@ type Manager struct {
2930
// reads, since compositors do not expose global hotkeys to clients.
3031
waylandHotkeys *eventtaplinux.GlobalHotkeyListener
3132
waylandStarted bool
33+
// waylandUnsupportedLogged latches the CodeNotSupported refusal so the
34+
// build-is-missing-evdev warning is said once. Registration retries Start
35+
// per hotkey, and the sleep/reload recovery loop retries registration up to
36+
// ten times, so an unlatched warning would repeat dozens of times for an
37+
// answer fixed at compile time.
38+
waylandUnsupportedLogged bool
3239
}
3340

3441
// NewManager creates and creates a new hotkey manager instance.
@@ -214,11 +221,7 @@ func (m *Manager) ensureWaylandStarted() {
214221

215222
err := m.waylandHotkeys.Start()
216223
if err != nil {
217-
m.logger.Warn(
218-
"Wayland global hotkeys unavailable; grant read access to /dev/input "+
219-
"(add your user to the `input` group) or bind `neru <mode>` in your compositor instead",
220-
zap.Error(err),
221-
)
224+
m.logWaylandStartFailure(err)
222225

223226
return
224227
}
@@ -228,6 +231,41 @@ func (m *Manager) ensureWaylandStarted() {
228231
m.logger.Info("Wayland global hotkeys enabled via evdev; config keybindings are active")
229232
}
230233

234+
// logWaylandStartFailure explains a failed Start in terms the user can act on.
235+
// Callers must hold m.mu.
236+
//
237+
// The two failures need different advice. A CodeNotSupported refusal means this
238+
// binary was built without cgo and carries no evdev reader at all, so telling
239+
// the user to join the `input` group sends them after a fix that cannot work —
240+
// and nothing about a compile-time answer changes on a later attempt, which is
241+
// why it is said once. Everything else is a live listener that could not read
242+
// `/dev/input`, which permissions can still fix, so that one keeps warning per
243+
// attempt.
244+
func (m *Manager) logWaylandStartFailure(err error) {
245+
if derrors.IsNotSupported(err) {
246+
if m.waylandUnsupportedLogged {
247+
return
248+
}
249+
250+
m.waylandUnsupportedLogged = true
251+
252+
m.logger.Warn(
253+
"Wayland global hotkeys unavailable: this build has no evdev support "+
254+
"(built without cgo). Bind `neru <mode>` in your compositor instead, "+
255+
"or use a cgo-enabled build",
256+
zap.Error(err),
257+
)
258+
259+
return
260+
}
261+
262+
m.logger.Warn(
263+
"Wayland global hotkeys unavailable; grant read access to /dev/input "+
264+
"(add your user to the `input` group) or bind `neru <mode>` in your compositor instead",
265+
zap.Error(err),
266+
)
267+
}
268+
231269
func (m *Manager) stopWayland() {
232270
if m.waylandHotkeys == nil || !m.waylandStarted {
233271
return
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//go:build linux && !cgo
2+
3+
package linux
4+
5+
import (
6+
"strings"
7+
"testing"
8+
9+
"go.uber.org/zap"
10+
"go.uber.org/zap/zapcore"
11+
"go.uber.org/zap/zaptest/observer"
12+
13+
eventtaplinux "github.com/y3owk1n/neru/internal/adapter/eventtap/linux"
14+
)
15+
16+
// This is the wiring the no-cgo stub's refusal is for, driven end to end:
17+
// `eventtap/linux/global_hotkey_stub_contract_nocgo_test.go` pins that Start
18+
// returns CodeNotSupported, and this pins what the manager then does with it.
19+
//
20+
// Both halves used to be wrong on a CGO_ENABLED=0 Wayland build. Start returned
21+
// nil, so the manager logged "config keybindings are active" at info while
22+
// nothing read the keyboard; making it loud without this fix swapped that for
23+
// the /dev/input warning, which sends the user after an `input` group
24+
// membership that changes nothing in a build with no evdev in it.
25+
//
26+
// Note where this runs: the CI Linux leg builds with cgo on, so nothing on the
27+
// gate compiles this file. `just test-linux-nocgo` does.
28+
func TestManager_EnsureWaylandStarted_ReportsTheBuildWithoutCgo(t *testing.T) {
29+
core, logs := observer.New(zapcore.DebugLevel)
30+
31+
mgr := NewManager(zap.New(core))
32+
// Set directly rather than relying on backend detection: the listener is
33+
// only constructed on a detected Wayland session, and the test host is not
34+
// required to be one.
35+
mgr.waylandHotkeys = eventtaplinux.NewGlobalHotkeyListener(nil)
36+
37+
// Three attempts stands in for the reload and sleep recovery loops, which
38+
// re-register on every retry.
39+
for range 3 {
40+
mgr.ensureWaylandStarted()
41+
}
42+
43+
if mgr.waylandStarted {
44+
t.Error(
45+
"waylandStarted is true without cgo; HealthCheck would stop trying to " +
46+
"recover a listener that was never started",
47+
)
48+
}
49+
50+
var warns []string
51+
52+
for _, entry := range logs.All() {
53+
if entry.Level == zapcore.InfoLevel &&
54+
strings.Contains(entry.Message, "config keybindings are active") {
55+
t.Error("the manager announced active keybindings while nothing reads the keyboard")
56+
}
57+
58+
if entry.Level == zapcore.WarnLevel {
59+
warns = append(warns, entry.Message)
60+
}
61+
}
62+
63+
if len(warns) != 1 {
64+
t.Fatalf("got %d warnings across 3 attempts, want 1: %v", len(warns), warns)
65+
}
66+
67+
if strings.Contains(warns[0], "/dev/input") {
68+
t.Errorf("the no-cgo warning points the user at /dev/input permissions: %q", warns[0])
69+
}
70+
}

0 commit comments

Comments
 (0)