Neru runs on macOS, Linux, and Windows from one shared Go core. This document covers both sides of that:
- Part 1 — Feature Parity Reference: what actually works on each platform, and how it is implemented.
- Part 2 — Contributor Guide: where platform code lives, and how to add to it.
Every claim in Part 1 is derived from code under internal/adapter/,
internal/app/. If this document and the code
disagree, the code wins — and the disagreement is a bug worth fixing here.
Related: Architecture · Linux setup · Linux desktops · Development Guide
Part 1 — Feature Parity Reference
- Platform Status
- Capability Matrix
- Input Injection
- Keyboard Capture And Hotkeys
- Accessibility And Hints
- Overlay Rendering
- Mode Coverage
- Platform Exclusives
- Known Gaps
Part 2 — Contributor Guide
- First Stops
- The Three Tiers
- File Layout Rules
- Backend Packages
- Where To Implement What
- Build And Test Commands
- Linux Backend Model
- Windows Model
- CGO Guidance
- Hotkeys And Modifiers
- Adding A New Capability
- Errors And Capability Reporting
- Testing Checklist
- Documentation Checklist
- Contributing Safely
Three words carry the whole promise, so they are worth pinning down:
Stable — fully featured. Everything Neru does works here. This is the reference implementation, and a gap on this platform is a bug.
Beta — good for daily driving. Every navigation mode works and behaves the same as it does on a stable platform; what is missing sits around the edges (notifications, alerts, a few animations) rather than in your way.
Alpha — worth trying, not yet worth switching to. Core navigation works, but hint coverage is incomplete and per-app config does not re-apply on focus change. You will notice the difference in ordinary use.
Every claim behind these labels is enumerated in the Capability Matrix and Known Gaps. If a label and the matrix disagree, the matrix is right.
| Aspect | macOS (Darwin) | Linux | Windows |
|---|---|---|---|
| Status | Stable | Beta | Alpha |
| Build tag | darwin |
linux |
windows |
| CGO | Required (Objective-C) | Per-backend; most Linux backends need it | Not used (pure Go Win32 / COM) |
| Primary modifier | Cmd |
Ctrl |
Ctrl |
| Display stack | Cocoa / Quartz | X11, or Wayland (wlroots / KWin) | Win32 / DWM |
| Accessibility | AXUIElement | AT-SPI over D-Bus | UI Automation over COM |
| Native product | Yes (Neru.app, codesigned) |
Binary + install script | Binary |
Linux is not one target. The live backend is detected once at startup from
XDG_CURRENT_DESKTOP, WAYLAND_DISPLAY, and DISPLAY
(backend_linux.go):
| Backend | Detected when | Status |
|---|---|---|
x11 |
DISPLAY set, no WAYLAND_DISPLAY |
Supported |
wayland-wlroots |
Sway, Hyprland, niri, River, Wayfire, or unset XDG_CURRENT_DESKTOP |
Supported |
wayland-kde |
XDG_CURRENT_DESKTOP contains KDE |
Supported |
wayland-gnome |
XDG_CURRENT_DESKTOP contains GNOME |
Not supported |
wayland-other |
Any other Wayland compositor | Not supported |
unknown |
Neither WAYLAND_DISPLAY nor DISPLAY |
Not supported |
GNOME Wayland does not run at all.
platform.NewSystemPortreturnsCodeNotSupportedforwayland-gnome,wayland-other, andunknown, and that is the first step of daemon startup — the daemon exits instead of starting in a degraded state. Mutter implements neitherwlr-layer-shell(overlays) norwlr-foreign-toplevel-management(focused app), and exposes no input-injection path Neru can use. Use an X11 session under GNOME. The tables below therefore have no GNOME column: nothing runs there.
Status of each ports.SystemPort-level capability, with the mechanism that
implements it. The KDE and wlroots columns differ only where noted; both are
Wayland with wlr-layer-shell overlays.
Legend: ✅ supported · CodeNotSupported
or no-op) · ❌ no code path
| Capability | macOS | Linux X11 | Linux Wayland (wlroots) | Linux Wayland (KDE) | Windows |
|---|---|---|---|---|---|
| Screen bounds / enumeration | ✅ Cocoa | ✅ XRandR | ✅ xdg-output | ✅ xdg-output | ✅ EnumDisplayMonitors |
| Display hotplug events | ✅ screen-params notif. | ✅ RandR event fd | ✅ wl_output events |
✅ wl_output events |
🟡 |
| Focused app identity | ✅ NSWorkspace + AX | ✅ _NET_ACTIVE_WINDOW / WM_CLASS |
✅ GetForegroundWindow |
||
| App watcher (focus change) | ✅ NSWorkspace observer | ✅ event-driven | ✅ event-driven | ✅ event-driven | 🟡 |
| Keymap learns the focused app | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | |
| Cursor position | ✅ CGEventGetLocation |
✅ XQueryPointer |
✅ sync-surface trick | ✅ sync-surface trick | ✅ GetCursorPos |
| Cursor move | ✅ CGWarpMouseCursorPosition |
✅ XTest | ✅ zwlr_virtual_pointer |
✅ libei | ✅ SetCursorPos |
| Mouse buttons / drag | ✅ CGEventPost |
✅ XTest | ✅ zwlr_virtual_pointer |
✅ libei | ✅ SendInput |
| Scroll injection | ✅ both axes | ✅ both axes | ✅ both axes (uinput + virtual pointer) | ✅ libei | |
| Smooth cursor animation | ✅ (incl. relative, opt-in) | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ❌ |
| Smooth scroll animation | ✅ | ❌ | ❌ | ❌ | ❌ |
| Element discovery (hints) | ✅ AXUIElement | ||||
| Overlay | ✅ NSPanel + CoreAnimation | ✅ X11 + Cairo | ✅ layer-shell + Cairo | ✅ layer-shell + Cairo | ✅ layered HWND + GDI |
| Global hotkeys | ✅ per-key CGEventTap | ✅ XGrabKey |
✅ RegisterHotKey |
||
| Keyboard capture | ✅ CGEventTap | ✅ XGrabKeyboard |
✅ evdev grab (wl-keyboard fallback) | ✅ evdev grab | ✅ WH_KEYBOARD_LL |
| Modifier passthrough | ✅ | ❌ | ✅ evdev backend only | ✅ evdev backend only | ❌ |
| Dark mode detection | ✅ Cocoa appearance | ✅ xdg appearance portal | ✅ xdg appearance portal | ✅ kdeglobals + portal | ✅ registry |
| Font resolution | ✅ NSFont | ✅ fontconfig | ✅ fontconfig | ✅ fontconfig | |
| System tray | ✅ NSStatusItem | ✅ D-Bus StatusNotifierItem | ✅ StatusNotifierItem | ✅ StatusNotifierItem | ✅ Win32 notification area |
| Native alerts | ✅ NSAlert | 🟡 | 🟡 | 🟡 | ✅ MessageBoxW |
| Native notifications | ✅ UNNotification | 🟡 | 🟡 | 🟡 | 🟡 |
| Secure input detection | ✅ | 🟡 always false | 🟡 always false | 🟡 always false | 🟡 always false |
| System cursor hide | ✅ CGDisplayHideCursor |
❌ | ❌ | ❌ | ❌ |
monitor_select mode |
✅ native panels | ✅ Cairo panels | ✅ Cairo panels | ✅ Cairo panels | 🟡 CodeNotSupported |
| Native hint-search field | ✅ NSTextField overlay | 🟡 key-stream fallback | 🟡 key-stream fallback | 🟡 key-stream fallback | 🟡 key-stream fallback |
| Vision / OCR detection | ✅ Vision framework | ❌ | ❌ | ❌ | ❌ |
Key feed (neru key) |
✅ CGEventPost |
✅ uinput | ✅ uinput / virtual-keyboard | ✅ uinput | 🟡 CodeNotSupported |
¹ Per-app hotkey overrides need to know which application is focused. Where the app watcher fires, it publishes that identity to the mode handler and the keymap is re-settled from it, so switching applications mid-mode changes what the next key does; the platform is asked at most until the watcher first fires. Windows has no watcher, so the keymap asks each time it settles — which means overrides there settle when the mode opens rather than the instant you switch apps. The same applies to a Linux session whose compositor exposes no focused-app source (GNOME/Mutter). No platform is asked on a keystroke; ADR 0005 has the reasoning.
² macOS and Linux resolve font families through the OS (NSFont, fontconfig).
Windows only maps the generic aliases sans / serif / mono to Segoe UI /
Cambria / Consolas and passes every other name straight to GDI without checking
it; an unavailable family falls back to whatever GDI substitutes.
A family somebody named resolves to that name: the answer is the family the
config asked for, not the family the platform would render in its place. The one
exception is Linux with fontconfig, the only backend that can tell an installed
family from a missing one — a missing one falls back to DejaVu Sans rather than
to fontconfig's own substitution for the name, so font_family = "Arial"
without Arial installed reports DejaVu Sans and not the Liberation Sans
fc-match Arial names. It is the sans baseline whatever face the name asked
for: the serif and mono baselines are what the serif and mono aliases
resolve to, so a missing Times New Roman also lands on DejaVu Sans. Where the
baseline is itself missing, fontconfig chooses that machine's generic, so the
fallback is always a family it has. macOS, Windows, the non-CGO Linux build, and a CGO build whose
fontconfig cannot be consulted at all check nothing: they hand the written name
to NSFont / GDI / Cairo, which substitute when the text is drawn — as Cairo does
on Linux too, for whichever name it is given.
Which names count as generic is the same on all three: sans, sans serif,
serif, mono, monospace and the empty string, matched ignoring case,
surrounding whitespace and the separator between words — sans-serif,
sans_serif and sansserif are one name. Every other family name is passed to
the platform trimmed. One parser answers that for all three
(internal/adapter/platform/fontgeneric, ADR 0007); what each generic resolves
to is the platform's own — Helvetica Neue / Times New Roman / Menlo on macOS,
DejaVu Sans / DejaVu Serif / DejaVu Sans Mono on Linux, the Windows families
above.
Where an answer is remembered — macOS, Windows and the fontconfig-backed Linux
resolver — it is remembered under the family name exactly as written
(internal/adapter/platform/fontcache); the non-CGO Linux build re-derives it
each time. Either way what a name resolves to depends on that name alone and
never on what was resolved before it.
Focused app on Wayland. wlroots and KWin resolve the focused window through
wlr-foreign-toplevel-management, which exposes the window's app_id — used
as the bundle identifier for per-app config — but not its PID, because a Wayland
client cannot read another client's process credentials.
SystemPort.FocusedApplicationPID best-effort matches the app_id against
/proc; with no match it returns CodeNotSupported carrying the app_id rather
than a fabricated number.
App watcher. macOS gets focus changes pushed from an NSWorkspace observer.
Linux has no equivalent single API, so appwatcher/platform_linux.go subscribes
to a backend focus-change fd (linux.SubscribeFocusedApp: X11 event fd, or the
wlroots toplevel manager) and re-samples on each wake — near-instant per-app
hotkey re-registration — with a 3s safety re-sample against coalesced events.
When no fd is available it degrades to polling FocusedAppID every 400ms. The
identity is the WM_CLASS (X11) or app_id (Wayland). A sibling goroutine
watches a display-configuration fd and dispatches screen-parameter changes, so
monitor hotplug regenerates overlays like it does on macOS. Only
activate/deactivate/screen-params are emitted; launch, terminate, and Mission
Control events remain macOS-only.
Global hotkeys on Wayland. No Wayland protocol lets an ordinary client
register a global hotkey, so Neru reads /dev/input/event* directly with a
passive evdev listener — it does not grab devices or inject anything, so the
focused app still receives every key
(global_hotkey_cgo.go).
Two conditions apply: the process needs read access to /dev/input (add your
user to the input group), and it requires CGO — a CGO_ENABLED=0 build gets a
no-op stub. When the listener cannot start, Neru logs a warning pointing at both
the input group and the fallback: bind neru <mode> as a compositor
keybinding. While a mode is active the in-mode event tap grabs the same devices,
so the listener naturally goes quiet until the mode exits.
Smooth cursor animation on Linux. Off by default; opt in with
smooth_cursor.move_mouse_enabled (the same cross-platform SmoothCursorConfig
macOS uses). When enabled, SystemAdapter.MoveCursorToPoint routes through
smoothCursorAnimator (mouse_animator.go):
one worker goroutine samples the current position, then steps the per-backend
warp (XTest / zwlr_virtual_pointer / libei) toward the target by linear
interpolation, and WaitForCursorIdle blocks until it settles. This mirrors the
darwin animator (coalescing, latest-target-wins) but drives discrete warps
rather than a Quartz event stream, so there is no drag-event distinction. It
covers the same flows macOS animates — grid/recursive-grid cursor-follow,
move_mouse, selection moves; clicks stay instant. On Wayland the interpolation
start point comes from the client-side cursor cache, so a stale read only skews
the glide path, never the landing point.
Relative (hjkl) moves animate too, with the fixed per-move duration
smooth_cursor.relative_movement_duration, matching macOS. X11 and KDE extend
the absolute animator's pending endpoint; wlroots instead drains the delta in
integer chunks through native relative motion
(relative_animator.go) —
the animation never reads the client position cache, preserving the exactness
that made wlroots apply deltas natively in the first place. Position-dependent
actions (clicks, scrolls) settle the in-flight animation before acting, so an
action fired mid-glide lands where the user aimed.
Every action type in
action.go — left/right/middle click,
per-button down/up/toggle, absolute and relative moves, drag-while-held, and
scroll — is dispatched through the shared InfraAXClient.PerformAction. The
dispatch, the action set, and the mode logic that drives it are platform-neutral
Go; only the final injection primitive differs:
| Platform | Primitive |
|---|---|
| macOS | CGEventPost (+ CGWarpMouseCursorPosition for moves) |
| Linux X11 | XTest (XWarpPointer, buttons 1/2/3, scroll buttons 4/5) |
| Linux Wayland wlroots | zwlr_virtual_pointer (+ /dev/uinput for scroll) |
| Linux Wayland KDE | libei via org.freedesktop.portal.RemoteDesktop |
| Windows | SendInput / SetCursorPos |
The one behavioral difference: Windows ScrollAtCursor ignores deltaX, so
horizontal scrolling is a no-op there. Everything else behaves the same on all
three platforms.
Held mouse buttons. Press and release are separate actions, so every backend
must remember what it pressed — and that bookkeeping is shared, not
per-platform. Each adapter keeps a
mousestate.Tracker
recording which buttons are down, where, and with which modifiers. It drives
three behaviors identically everywhere: toggle actions resolve against it (held
→ release, free → press), EnsureMouseUp releases every held button when Neru
returns to idle, and on macOS it selects the drag event type for cursor moves.
macOS is the only backend needing that last distinction — Quartz requires
kCGEventLeftMouseDragged / RightMouseDragged / OtherMouseDragged with a
matching button number instead of kCGEventMouseMoved, while X11, Wayland, and
Windows simply warp the pointer and let the compositor or OS infer the drag.
When several buttons are held at once a macOS move is attributed to the
left-most held button, since one event cannot describe more.
| Aspect | macOS | Linux X11 | Linux Wayland | Windows |
|---|---|---|---|---|
| In-mode capture | CGEventTapCreate |
XGrabKeyboard |
evdev EVIOCGRAB, wl-keyboard fallback |
WH_KEYBOARD_LL |
| Global hotkeys | Per-key CGEventTap | XGrabKey |
Passive evdev read | RegisterHotKey |
| CGO needed | Yes | Yes | Yes | No |
| Press/release | ✅ separate callbacks | ✅ KeyPress/KeyRelease | ✅ WM_HOTKEY flags |
|
| Modifier passthrough | ✅ | ❌ grab is all-or-nothing | ✅ evdev only | ❌ no-op |
PostModifierEvent |
✅ | ✅ | ✅ (zwp_virtual_keyboard_v1) |
❌ no-op |
| Sticky modifiers | ✅ | ✅ | ✅ | ✅ |
| Capture files | eventtap/darwin/ |
eventtap/linux/x11_cgo.go |
eventtap/linux/wayland_cgo.go, evdev_cgo.go |
eventtap/windows/ |
| Hotkey files | hotkeys/darwin/ |
hotkeys/linux/x11_cgo.go |
hotkeys/linux/manager.go + eventtap/linux/global_hotkey_cgo.go ³ |
hotkeys/windows/ |
³ There is no separate Wayland hotkey file — the Wayland path lives in the
common hotkeys/linux/manager.go, which delegates to the evdev listener in the
eventtap package.
Modifier passthrough (Wayland evdev only). While a mode is active Neru
captures the keyboard exclusively, so shortcuts it does not bind (Ctrl+C,
Ctrl+Tab) are normally swallowed. With general.passthrough_unbounded_keys,
unbound Ctrl/Alt/Cmd chords are re-injected to the focused app instead. This
works on the Wayland evdev backend because Neru holds EVIOCGRAB on the
physical device but injects through a separate zwp_virtual_keyboard_v1,
which bypasses that grab and reaches the app with no feedback loop (see
handleWaylandEvdevEvent → passthroughEvdevChord). It is not available on
X11 — an XGrabKeyboard routes Neru's own synthetic XTest events back to
itself, and XSendEvent is ignored by most apps — nor on the rare wl-keyboard
fallback, which has no injection path. Classification (blacklist,
mode-intercepted keys, the mode's own hotkeys) and the post-passthrough hint
refresh are shared in
passthrough.go; only the final
re-injection is backend-specific. The blacklist keeps chosen chords consumed,
and general.should_exit_after_passthrough exits the mode after a passthrough.
Both lists are re-derived whenever a mode opens, the configuration is replaced,
hints refresh after a passthrough, or — where a per-app override could move
them — the focused application changes under an open mode. That last trigger is
what keeps per-app overrides meaningful after passing Cmd+Tab through and
carrying on in the application you landed in; it runs as soon as the mode
handler is free rather than in step with the focus change, so a chord pressed
in the same instant can still be routed by the lists the application you left
put in force.
| Aspect | macOS | Linux | Windows |
|---|---|---|---|
| Backend | AXUIElement (CGO ObjC bridge) | AT-SPI over D-Bus (pure Go) | UI Automation over COM (pure Go) |
| Client | InfraAXClient → ObjC bridge |
ATSPIClient → org.a11y.atspi |
UIAClient → raw COM vtables |
| Files | element_darwin.go, tree.go |
element_linux.go, atspi_linux.go |
element_windows.go, uia_windows.go, tree_windows.go |
| Traversal | Full recursive walk of the AXUIElement hierarchy | Recursive walk of the active frame's subtree, depth/node capped | Shallow walk of root-level nodes |
| Sources collected | Frontmost + all windows, popovers, menubar, dock, notification center, Stage Manager, PIP | Active frame's subtree only | Root element's children only |
| Filtering | Role matching, size/position heuristics, excluded apps, dedup | Native AT-SPI roles, SHOWING state, on-screen extents |
IsControlElement + IsContentElement, non-zero bounds |
| Strategies | axtree (default) and vision, incl. per-app overrides |
axtree only |
axtree only |
| Popovers / menus | ✅ dedicated detection | 🟡 |
macOS builds the richest tree by a wide margin: it walks multiple window and system sources, applies per-app strategy overrides, can fall back to the Vision framework for OCR-discovered targets, and deduplicates overlapping elements. Linux and Windows each walk a single tree with no OCR fallback.
Linux is ATSPIClient enables
assistive-tech mode, finds the active frame, and walks it (ClickableNodes)
emitting native AT-SPI role names. Configured roles are resolved into that same
vocabulary at config load (element.ResolveRoles), so both sides of the filter
speak AT-SPI. This is the path the Linux adapter actually uses
(platform_client_linux.go → Adapter.ClickableElements → client.ClickableNodes);
the TreeNode / BuildTree stub in tree_linux.go is the macOS-style tree API
and is not on the Linux hints path. The
Chromium and Electron apps on Linux. Chromium-based apps (Chrome, Electron,
forks such as Helium) do not expose their web-content tree over AT-SPI by
default — they gate it behind their own runtime detection, and unlike macOS
there is no per-app attribute Neru can toggle to force it (the macOS
AXManualAccessibility nudge in electron.EnsureAccessibility is a no-op on
Linux). The result is an AT-SPI frame with a single empty child, so hints find
nothing inside such windows. Launch the app with
--force-renderer-accessibility to force the full tree. Native GTK/Qt apps and
Firefox need no flag. This is Chromium behavior, not a Neru limitation.
Picking the active frame on Wayland. The AT-SPI ACTIVE state is unreliable
on wlroots compositors (niri, Sway, Hyprland) — the focused window can report
ACTIVE=false while background frames report ACTIVE=true. Neru therefore
matches the AT-SPI frame against the compositor's focused app_id (from
wlr-foreign-toplevel-management, the same source as the app watcher), falling
back to the ACTIVE/SHOWING heuristic only on X11 or when no app_id is
available. See findActiveFrame in atspi_linux.go.
Window-origin offset on Wayland. A Wayland client cannot know its own
on-screen position, so AT-SPI reports element coordinates relative to the
window. Neru offsets them by the focused window's screen origin, supplied by a
compositor-specific windowOriginSource
(window_origin_linux.go):
| Compositor | Source | Limits |
|---|---|---|
| KDE / KWin | KWin script pushing focused-window geometry over D-Bus | — |
| niri | niri msg -j focused-window / focused-output |
Floating and fullscreen windows only. Tiled windows — including a maximized column (Mod+F) — expose no on-screen position (niri#2381), so hints are misaligned there. |
| Sway | swaymsg -t get_tree, focused node rect + window_rect |
— |
| Hyprland | hyprctl -j activewindow at / size |
— |
Each source verifies the reported window size matches the AT-SPI frame (a focus change can race the query) and is best-effort: an unavailable origin degrades to unoffset window-relative coordinates rather than misplacing hints.
The three platforms split responsibility differently, which is the single most important thing to know before touching overlay code:
- macOS — each render component owns its own NSPanel. Files such as
adapter/overlay/render/hints/overlay_darwin.gocall the Objective-C bridge directly, and rendering is GPU-backed via CoreAnimation. - Linux and Windows — the render components hold the shared
Styleand a thin wrapper; all real rendering happens in the overlay manager (overlay/linux/x11_cgo.go,overlay/linux/wayland_cgo.go,overlay/windows/manager.go), drawing every element into one shared surface.
| Aspect | macOS | Linux X11 | Linux Wayland | Windows |
|---|---|---|---|---|
| Window type | NSPanel, borderless non-activating | override-redirect X11 window | wlr_layer_shell_v1 overlay surface |
layered WS_POPUP HWND |
| Rendering | CoreAnimation (CALayer, GPU) | Cairo on an Xlib surface (CPU) | Cairo into SHM buffers (CPU) | GDI + software SDF, BGRA (CPU) |
| Per-pixel alpha | clear color + non-opaque layer | CAIRO_OPERATOR_CLEAR |
CAIRO_OPERATOR_CLEAR |
AC_SRC_ALPHA via UpdateLayeredWindow |
| Click-through | setIgnoresMouseEvents:YES |
XFixes empty input region | empty wl_surface input region |
WS_EX_TRANSPARENT |
| Always on top | NSScreenSaverWindowLevel |
_NET_WM_STATE_ABOVE + MapRaised |
overlay layer | HWND_TOPMOST |
| Focus prevention | non-activating panel | override_redirect=YES |
controlled keyboard interactivity | WS_EX_NOACTIVATE |
| HiDPI | dynamic contentsScale + backing-change callback |
Xft.dpi, one global factor |
wl_output scale + wp_fractional_scale_v1 / wp_viewporter |
not explicit |
| Multi-monitor | per-display clamping, screen-change tracking | all monitors enumerated, per-monitor render, live RandR hotplug | one wl_surface per output (max 16), live hotplug |
cursor-screen tracking, separate indicator/sticky windows |
| Buffers | layer-backed, OS-managed | single Cairo surface | triple-buffered SHM pool | single pixel buffer |
| Rounded rects / borders | NSBezierPath | Cairo arc path + stroke | Cairo arc path + stroke | software SDF fill + multi-pass stroke |
| Text | NSFontManager | Cairo select_font_face / show_text |
Cairo select_font_face / show_text |
GDI CreateFontW + DrawTextW + alpha composite |
| Coordinate origin | bottom-left (Y-flipped in the adapter) | top-left | top-left | top-left (negative DIB height) |
| Thread model | main-thread dispatch | renderMu mutex |
displayMu mutex (shared with renderMu) |
dedicated UI thread (LockOSThread) |
| Animation | macOS | Linux X11 / Wayland | Windows |
|---|---|---|---|
| Grid transition | CoreAnimation, ease-in-out @120Hz | goroutine, smoothstep @120fps | ❌ |
| Mouse action indicator | CABasicAnimation (scale + opacity) |
goroutine, scale + opacity @120fps | goroutine, cubic easing @60fps |
| Smooth cursor | ✅ configurable easing | ✅ stepped warp, incl. relative (opt-in) | ❌ |
| Smooth scroll | ✅ ease-out cubic | ❌ | ❌ |
Mode logic — labelling, alphabets, matching, search filtering, grid subdivision,
recursion depth, scroll amounts, cell navigation — is pure domain Go under
internal/domain/ and behaves identically on all three platforms. Only
the rows below differ, and every difference traces to rendering or element
discovery rather than the mode itself.
| Mode | Feature | macOS | Linux | Windows |
|---|---|---|---|---|
| Hints | Element discovery | ✅ full AX tree | ||
| Hints | vision strategy + per-app overrides |
✅ | ❌ macOS-only | ❌ macOS-only |
| Hints | Menubar / dock elements | ✅ | 🟡 | 🟡 |
| Hints | Search input badge | ✅ | 🟡 CodeNotSupported |
✅ |
| Hints | Label arrow / tail | ✅ NSBezierPath | ✅ Cairo triangle | ✅ sampled triangle, see below |
| Hints | Label placement | ✅ top / center / bottom | ✅ top / center / bottom | ✅ top / center / bottom |
| Grid | Transition animation | ✅ | ✅ | ❌ |
| Grid | Virtual pointer indicator | ✅ | ❌ no-op | ❌ no-op |
| Recursive grid | Transition animation | ✅ | ✅ | ❌ |
| Recursive grid | Virtual pointer indicator | ✅ | ✅ | ✅ |
| Recursive grid | Sub-key preview | ✅ mini-grid of next keys | ✅ mini-grid of next keys | ✅ mini-grid of next keys |
| Scroll | Smooth scroll animation | ✅ | ❌ | ❌ |
| Monitor select | Whole mode | ✅ native panels | ✅ Cairo panels | 🟡 CodeNotSupported |
Everything else is shared: multi-letter labels, label direction, hide-unmatched, split-word, interactive search behavior (only the on-screen badge differs), boundary highlight, mode indicator, sticky-modifier indicator, all pending actions on grid cells, subgrid zoom, backtracking, and every scroll granularity.
The cursor-replacement virtual pointer — the pointer drawn when the real cursor is hidden — is separate from the recursive-grid indicator above and is macOS-only:
virtualpointer.Overlayis a no-op on every non-darwin build, and it is paired withCGDisplayHideCursor, which has no equivalent elsewhere.
hints.ui.placementmeans the same thing on all three platforms. Each backend offsets the badge from the target point at the element's centre, keeping it horizontally centred there:topputs the badge above that point with a connector arrow pointing down at it,centerover it with no arrow,bottombelow it with an arrow pointing up (the default).The rule is shared; the exact pixels are not. Linux and Windows take the offsets and the arrow from one implementation (
adapter/overlay/render/badge.PlaceHint), so a configured placement lands on the same pixel on both. macOS computes its own in Objective-C — the deliberate exception ADR 0007 records — with a shorter, wider arrow, so an offset badge sits a few pixels closer to its element there than it does on the other two.One detail of the arrow differs on Windows (#1303, which is also where that backend started reading the option at all). macOS and Linux build the badge and the arrow as a single outline, so the border runs around both, while the Win32 surface has no path primitive: it draws the arrow as a triangle over a slightly larger one in the border colour, which borders its two slanted edges but leaves the badge's own edge running across the arrow's base.
recursive_grid.ui.sub_key_previewis one drawing on all three platforms as of #1297. Each backend divides the cell by the next level's grid dimensions and draws the key that selects each sub-cell in its own place, so the preview shows where each key lands; the center sub-cell of an odd-by-odd division is left blank, because the cell's own label is drawn there. None of them previews anything at the deepest level, where there is no next level to show.Windows drew a single label along the bottom of the cell until then, and its
sub_key_preview_autohide_multipliermeasured the whole cell to match. All three now measure a sub-cell — the cell divided by the next level's dimensions — which must reachsub_key_preview_font_size × multiplierin both width and height, from one implementation (recursivegrid.Style.ShowSubKeyPreviewIn, with the macOS copy held to it byinternal/architecture/sub_key_preview_autohide_rule_test.go). A Windows user's configured multiplier therefore hides the preview in larger cells than it used to: with a 3×3 next level the preview now disappears at roughly three times the cell size it used to survive down to. Lower the multiplier to keep a preview in cells that small — it is the same number Linux and macOS have always read.
Features available on exactly one platform, with why they do not port:
| Feature | Platform | Location | Why it is exclusive |
|---|---|---|---|
| System cursor hide + virtual-pointer replacement | macOS | app/modes/cursor_darwin.go, adapter/overlay/render/virtualpointer/overlay_darwin.go |
CGDisplayHideCursor has no X11/Wayland/Win32 equivalent |
| Smooth scroll animation | macOS | platform/darwin/scroll_animator.go |
Needs a synthesizable continuous scroll event stream |
| Vision (OCR) hint strategy | macOS | ports/vision.go, platform/darwin/vision_darwin.m |
macOS-only VNRequest APIs |
| Screen-sharing hide | macOS | platform/darwin/overlay_darwin.m |
NSWindow sharing level is a Quartz concept |
| Secure input detection | macOS | platform/darwin/secureinput.go |
CGSessionCopyCurrentDictionary, a private API |
Linux and Windows have no exclusive user-facing features — their unique
elements (evdev, zwlr_virtual_pointer, libei, the Wayland sync-cursor surface,
WH_KEYBOARD_LL, RegisterHotKey, SDF rendering) are platform mechanisms
serving cross-platform features, and are listed in the
Capability Matrix.
Work that is genuinely missing, as opposed to deliberately platform-specific.
Linux
- Native notifications and alerts — stubs; target freedesktop D-Bus notifications
- Smooth scroll animation — not implemented
- GNOME/Mutter Wayland — unsupported; the daemon refuses to start
- Hints search input badge — not drawn; the overlay manager reports
CodeNotSupportedand the query goes on reaching hints through the event tap's key stream - Secure input detection — always false
- Wayland global hotkeys — need
input-group access and a CGO build
Windows
- App watcher — no foreground-window change notifications, so per-app config never re-applies
- Display hotplug — no screen-parameter change events
- Native notifications — no toast support
- UIA tree depth — shallow walk; complex apps under-report clickable elements
- Grid and recursive-grid transition animation — not implemented
- Smooth cursor and smooth scroll animation — not implemented
- Modifier passthrough and
PostModifierEvent— no-ops - Horizontal scroll —
ScrollAtCursorignoresdeltaX monitor_selectmode — returnsCodeNotSupported- Font resolution — alias mapping only, no system font enumeration
macOS
- Named keys without a Carbon keycode —
InsertandF21–F24validate but never fire, because Carbon declares no virtual key code for them. They stay in the shared key vocabulary so one config file works on every platform (ADR 0008), and the absence is pinned byinternal/architecture/named_key_tables_test.go— the day macOS grows a keycode, that test fails.
Otherwise none; macOS is the reference implementation.
Guiding principles:
- shared business logic stays in pure Go
- platform-specific code is easy to locate
- Linux backend differences are explicit
- contributors implement in existing slots instead of inventing new file layout
- unsupported features fail loudly with
CodeNotSupported
Read these before changing platform code:
- The Three Tiers — start here; it decides where your code goes
- platform/profile.go — per-subsystem backend family and CGO expectations
- ports/system.go — the main OS contract, plus the optional-extension pattern
- ports/capabilities.go and capability_presets.go — the capability registry
neru doctorreports - ports/font.go — FontResolver port
- architecture/platform_slots_test.go — the file-layout rules, as executable checks
- ARCHITECTURE.md and the root AGENTS.md conventions
Contributing Linux support? Also open the reserved backend files in the package
you plan to touch (*_linux_common.go, *_linux_x11.go, *_linux_wayland.go)
before writing anything.
Before choosing a file, choose a tier. Every platform-varying capability in Neru is expressed one of exactly three ways, and picking the wrong one is the most common way platform code becomes hard to navigate.
The deciding question is who needs the capability:
| Tier | Use when | Mechanism |
|---|---|---|
| 1 — Port | app, domain, or more than one adapter package needs it | interface in internal/ports, adapter in internal/adapter |
| 2 — In-package dispatch | exactly one adapter package needs it | build-tagged platform_<os>.go files, unexported functions |
| 3 — Optional port extension | only some platforms can offer it, and the caller has a real fallback | interface declared in ports, reached by type assertion |
The app and domain layers must never import an adapter package to reach an OS capability. If they need it, it is a port.
Requirements — all four, or it is not done:
- Interface in
internal/ports, documented with what each platform is expected to do and what a caller must do when it cannot. - Adapter in
internal/adapter/<subsystem>/, withvar _ ports.XPort = (*Adapter)(nil). - Mock in
internal/ports/mocks/. Hand-rolled fakes in_test.gofiles rot silently when the contract changes — the shared mock does not. - An entry in
ports.PlatformCapabilitiessoneru doctorreports it.
Current ports: SystemPort, AccessibilityPort, OverlayPort, EventTapPort,
HotkeyPort, IPCPort, VisionPort, TextInputPort, KeyFeedPort,
AppWatcherPort, SystrayPort, FontResolver.
Optional extensions, reached by type assertion (Tier 3): RelativeCursorMover
and CursorSynchronizer on SystemPort, HotkeyReleaseRegistrar and
HotkeyHealthReporter on HotkeyPort, OverlayKeyboardPassthroughReporter on
EventTapPort, and OverlayCapabilityReporter on OverlayPort.
keyfeed is the reference example: shared
normalization untagged in keyfeed.go, one unexported postKey per platform,
Adapter implementing the port, capability entry, mock, contract tests.
A capability only one adapter package uses does not become a port. Wrapping it in an interface buys no test seam and no substitutability — just indirection.
Use build-tagged files inside that package with unexported functions:
// platform_darwin.go
func platformActiveScreenBounds() image.Rectangle { /* Cocoa */ }
// platform_other.go
func platformActiveScreenBounds() image.Rectangle { return image.Rectangle{} }Keeping them unexported is the whole point: an exported one becomes another package's dependency, and the seam has quietly become a badly-specified port.
Examples: accessibility/priming_*.go, accessibility/supplementary_*.go,
appwatcher/platform_*.go, ipc/transport_unix.go.
Some platforms can do a job better than shared code can, but not all can do it
at all — so it cannot go on the base port without forcing every adapter to carry
a stub. Declare a small interface in ports, next to the port it extends,
and let the caller find it by type assertion:
// ports/system.go
type RelativeCursorMover interface {
MoveCursorBy(ctx context.Context, delta image.Point) (handled bool, err error)
}
// the caller always has a fallback
if mover, ok := s.system.(ports.RelativeCursorMover); ok { /* fast path */ }Two rules:
- Declare it in
ports. An interface defined in the consuming package is undiscoverable — a contributor on another platform has no way to learn the extension exists. This is whyrelativeCursorMoverandcursorSyncermoved out ofservicesandmodes. - The caller must have a working fallback. An optional extension is an optimization or a platform-native shortcut, never the only path.
Adapters opting in should assert it: var _ ports.RelativeCursorMover = (*SystemAdapter)(nil). Callers reach these by type assertion, so a signature
drift would otherwise silently downgrade the platform to the generic path
instead of failing to compile.
Do not lift these behind interfaces: platform/{darwin,linux,windows}
internals, wlr_protocol, overlay drawing in internal/adapter/overlay, logger,
and IPC transport. They are implementation, reached through a port that already
exists.
The tiers only mean something if the arrows point one way. Three rules, all enforced by layering_test.go:
| Rule | Why |
|---|---|
internal/domain imports no adapter, app, or UI |
domain is pure Go; a domain package that needs an OS cannot be unit-tested |
internal/{domain,ports,derrors,adapter} never import internal/app |
adapters implement ports; the hexagon has no upward edges |
| app code reaches adapters only through ports | only the composition root knows which adapter exists |
The third rule has three deliberate escapes, all narrow:
- Shared vocabulary —
adapter/ipc(the CLI/daemon wire protocol),adapter/logger, andadapter/platform(the SystemPort factory plus theProfilethatneru doctorprints). These are data and plumbing, not OS behavior. - Composition root —
wiring.go,startup_phases.go,cmd/neru/main.go. Wiring adapters to ports is their job.component_factory.gowas on this list until #1213 and came off it: with the overlay's render components no longer handed back to the app, it names no adapter at all. - Build-tagged dispatch — any
*_darwin.go/*_linux*.go/*_windows.go/*_other.gofile in the app layer is Tier 2.
Anything else is a violation. knownLayeringExceptions exists for edges that
cannot be fixed in the same change; it is currently empty, and a second test
fails if an entry stops being a real violation, so the list can only shrink.
internal/adapter/overlaywas the worked example of an escape, and is now the worked example of retiring one. It carried a shared-vocabulary entry until #1213, because the app named its render models and its manager interface directly. The entry went when the things above it moved: the port tookports.Framefor transitions, the adapter took over resolving Styles and building its own render components, and the per-modeContexttypes — which were mode state, not drawing — moved up intointernal/app/components/. Nothing about the render models moved down. The lesson is that an allowlist entry is retired by finding what does not belong on the other side of the line, not by relocating what does.
Once the tier is settled, the filename declares the slot. These rules are
enforced by
platform_slots_test.go, so a
violation fails just test rather than review:
| Suffix | Meaning |
|---|---|
*_darwin.go |
macOS |
*_windows.go |
Windows |
*_other.go |
non-target fallback for dispatch-style packages |
*_unix.go |
the !windows side of a split (established Go convention) |
*_linux_common.go |
Linux-shared wrapper, fallback, or backend routing |
*_linux_x11.go |
X11 |
*_linux_wayland.go |
Wayland |
*_linux_wayland_<compositor>.go |
one compositor family needing a distinct path |
*_cgo.go / *_nocgo.go |
CGO and pure-Go variants of the same slot |
Inside a package that is already one platform (adapter/*/darwin,
adapter/*/linux, adapter/platform/windows, …) the OS token is dropped —
the directory carries it. overlay/linux/wayland_cgo.go and
platform/linux/system_x11_cgo.go keep only the axes that still vary; a
system_linux_x11_cgo.go inside platform/linux/ would say linux twice.
What the guardrail test checks:
- A file constrained to exactly one GOOS must carry that OS as a name token.
Go's implicit suffix rule already prevents the forward mistake; this catches
the reverse — a
tree.gothat is secretly//go:build darwinis invisible to anyone scanning the directory. - A file whose constraint is a pure negation is a fallback and must be named
*_other.go._stub.go,_stubs.go,_default.go,_fallback.go,_noop.goand friends are rejected — one slot, one spelling. - A file gated on cgo must say so:
*_cgo.goor*_nocgo.go. Before this rule a plain name usually meant the cgo variant, but in the overlay package it meant the opposite, and reading the build tag was the only way to tell. - Every file in a single-platform package declares its OS tag. Such a package is exempt from the suffix rule — the directory carries the meaning — which only holds if nothing untagged leaks in. The set of exempt directories is derived from the tree, not listed: a directory earns the exemption when every file in it targets the same one OS.
- Every relative
#includeresolves (cgo_includes_test.go). This one exists because a broken include is invisible togo vetand tojust check-cross—CGO_ENABLED=0skips the file — and only surfaces when the target OS compiles with cgo on.
Two rules that save review cycles:
- Do not invent new ad hoc platform filenames when a slot already exists.
- Do not create empty
darwin/linux/windowsfiles for symmetry. Add a file only when there is a real implementation slot behind it.
Every OS capability is a contract plus one directory per operating system, and each backend directory is named for its GOOS:
adapter/accessibility/{ax, atspi, native/{darwin,linux,windows}}
adapter/eventtap/{tap, darwin, linux, windows}
adapter/hotkeys/{darwin, linux, windows}
adapter/systray/{darwin, linux, windows, icon}
adapter/overlay/{manager, darwin, linux, windows}
The directory names the platform, so the filenames inside do not have to, and
ls answers "what do I touch for Wayland?". Because the word darwin means the
same thing in every one of them, the guardrails that key on it need no
per-package list.
The parent package keeps the port adapter and a small build-tagged factory — usually ten lines — which is the only place that knows which implementation exists.
The test is whether every platform has something substantial to say. If one does and the others answer in eighty-line stubs, build-tagged files in a single package are clearer: three directories where two hold stubs is ceremony rather than navigation.
That is the case for
overlay/render/{grid,hints,recursivegrid,modeindicator,stickyindicator}. Each
is one real renderer plus small stubs, and overlay_linux_common.go is already
the obvious file to open.
A package that reads as "shared code plus platform files" is usually one generic shell specialised by build-tagged concrete types. It has no interface seam, so there is nothing for a backend package to implement, and creating one is three moves in order:
- Find the seam. List the methods the shell calls on the platform type.
For
eventtapthat is ten — small enough to write down in one sitting. - Extract the contract into a leaf package (
accessibility/ax,eventtap/tap). It has to be a leaf: the backends import it to satisfy it and the factory imports the backends, so anything else is an import cycle. - Move each platform into a package behind a build-tagged factory.
When the shell talks to package-level symbols rather than to methods on a
value, there is a cheaper route: alias instead of abstracting.
accessibility/native works this way. Its shell is generic over Element,
ElementInfo, TreeNode, TreeOptions and roughly forty package-level
functions; each platform's files live in their own package and a build-tagged
file aliases the four types and binds the functions, leaving the shell itself
platform-agnostic without an interface.
Two traps worth knowing before you start:
- Named function types do not interchange. A method taking
darwin.Callbackdoes not satisfy an interface wantingfunc(string), even though the underlying types are identical. Put callback types in the contract package and have the backends use them. - Typed nil. A factory returning a concrete
*Tas an interface hands back a non-nil interface holding a nil pointer, and every caller'sif x != nilsilently passes. Check before returning;staticcheckreports this as SA4023.
hints.Hint, grid.Style and the other render models sit under
adapter/overlay/render/ rather than in the domain, even though nothing about
them is platform-specific.
They stay there because hints.Hint, hints.StyleMode and hints.Overlay are
one concept, and every backend needs all three to draw. Splitting them by layer
produces two packages named hints — likewise grid and recursivegrid —
which every site touching both halves must then alias, and three of the six
render packages have no platform-neutral content at all.
Nothing above the overlay names them any more (#1213), so their home is now a
question about the adapter alone. What did move out was the per-mode Context
types, which sat in render/hints, render/grid and render/recursivegrid
without being render models at all: they are the state one mode session keeps,
they know no colour and no surface, and they live in
internal/app/components/{hints,grid,recursivegrid} beside the scroll context
that always did.
grid.Style, recursivegrid.Style and hints.StyleMode are each declared once
for every platform. Their fields hold the values the configuration writes — hex
color strings, integer sizes — and the packed-ARGB and float forms that Cairo
and GDI want are accessors that convert at the point of use.
Keeping representation out of the struct is what lets manager.Interface name
these types in a signature every platform shares. When a type looks
platform-specific, check whether it differs in meaning or only in
representation; the second kind belongs in an accessor.
| Capability | Primary location |
|---|---|
| screen bounds, cursor, dark mode, notifications, permissions | internal/adapter/platform/<os>/ |
| global hotkeys | internal/adapter/hotkeys/ |
| keyboard event capture | internal/adapter/eventtap/ |
| accessibility integration | internal/adapter/accessibility/ (ax/, atspi/, native/) |
| overlay window orchestration and all Linux/Windows drawing | internal/adapter/overlay/ |
| overlay rendering by mode (macOS only; stubs elsewhere) | internal/adapter/overlay/render/*/overlay_*.go |
| app watcher and other isolated platform hooks | dispatch-style platform_*.go in the relevant package |
Worked examples:
- X11 hotkeys → x11_cgo.go
- Wayland keyboard capture → wayland_cgo.go
- shared Linux system fallbacks → system_common.go
Every build and test recipe is catalogued in DEVELOPMENT.md. Two apply specifically to platform work:
just build && just test-foundation— the cross-platform-safe baseline to run before touching anything. Do that first, then find the slot you expect to change before writing code.just release-ci-linux <arch> <version>/just release-ci-windows <arch> <version>— the tagged release binaries CI produces.
Only the target OS can run just test meaningfully — integration tests are
tagged per-OS.
just build-windows cross-compiles from any host, because Windows is a CGO-off
build. just build-linux does not: Linux needs CGO for the X11 and Wayland
backends, which drags in Go's own cgo runtime (linux_syscall.c,
gcc_<arch>.S). A macOS clang compiles that against the macOS SDK and fails.
The recipe checks the compiler's target triple up front and refuses with the alternatives rather than failing inside Go's runtime. From a macOS host, use:
just lint-cross— compiles and lints the linux/amd64 build with CGO on, in Dockerjust check-cross— a fast CGO-off type-check of the Linux and Windows builds, no Docker neededCGO_ENABLED=0 GOOS=linux GOARCH=<arch> go build ./cmd/neru— a pure-Go Linux binary. The CGO-only backends compile out, so it is not the shipped product
just build-linux still runs on a Linux host, and on any host whose CC is a
Linux cross toolchain. The guard fails open — it only refuses when the compiler
positively reports a non-Linux target — so it never blocks a build that would
have worked. The tagged Linux release binaries are built by CI on a native Linux
runner (just release-ci-linux).
golangci-lint honours build tags, so a //go:build linux file is invisible to
just lint on macOS. A change can be locally clean and still fail the Linux or
Windows lint job. To reproduce one of those failures:
CGO_ENABLED=0 GOOS=linux golangci-lint run ./internal/...Read the output with care. Without cgo, the *_cgo.go files are excluded, so
anything they alone use is reported as unused and any helper they alone call
with a second value is reported by unparam. Those are artifacts of the
no-cgo build, not real findings — CI lints Linux with cgo enabled. Findings in
plain-linux files (funcorder, godoclint, revive, and similar) are real.
The cgo-only Linux paths need a real Linux toolchain, so the host cannot lint
them directly. just lint-cross runs them in the same container image the Linux
CI job uses; without Docker, CI is the check for those.
Linux is a backend family, not a single target. Keep two axes separate:
- Compile-time axis (OS + CGO) — expressed by build tags and file suffixes.
Build tags cannot distinguish compositors: KDE and GNOME are both
linux+ Wayland at compile time. A suffix therefore never encodes a single desktop environment on its own. - Runtime axis (which compositor is live) — expressed by the
LinuxBackendfamily in backend_linux.go, detected from environment variables and routed byfactory.goplus dispatch seams such assystem_wayland_input.go.
Within the compile-time axis, choose the slot by purpose:
| Slot | Use for |
|---|---|
common |
shared Linux types, shared fallbacks, backend detection/routing, helpers |
x11 |
X11 display enumeration, event capture, overlays, pointer queries and warps |
wayland |
compositor capture/overlay behavior, layer-shell, output enumeration |
Not every package must implement both backends immediately — but new code should land in the right slot from the start. Accessibility is the main exception: most Linux accessibility stays shared around AT-SPI even where other subsystems split.
Desktop environments share mechanisms, so the axis that actually varies is usually the mechanism:
- Input — KDE and GNOME both use libei (RemoteDesktop portal); wlroots and
COSMIC use
zwlr_virtual_pointer. One libei backend serves several DEs; do not duplicate it per DE. - Overlay — layer-shell works on KDE, wlroots, and COSMIC; only GNOME/Mutter lacks it.
- Genuinely DE-specific — active-window geometry (KWin D-Bus vs Mutter
D-Bus) and hotkey registration. These belong in DE-named files such as
kwin_geometry_linux.go.
Use a *_linux_wayland_<compositor>.go sub-slot only when a compositor family
needs a path no other family shares. Current sub-slots:
system_linux_wayland_wlroots_*.go (virtual-pointer input) and
system_linux_wayland_kde_*.go (libei input), with
system_wayland_input.go as the shared routing seam.
To add a compositor (COSMIC, say): add a LinuxBackend value and detection
in backend_linux.go, route it in the factory and the relevant dispatch seams,
and add a new *_linux_wayland_<compositor>.go slot only if it cannot reuse an
existing mechanism file.
Per-DE decisions, measured protocol support, and known issues live in LINUX_DESKTOPS.md; host setup lives in LINUX_SETUP.md.
Windows is one backend family with alpha-level support. Prefer:
*_windows.goas the implementation slot- pure Go Win32 / COM bindings (via
x/sys/windowsor syscall) over CGO
Do not introduce additional Windows backend naming until there is a real reason. See Known Gaps for the current Windows to-do list — several entries there are well-scoped starter tasks.
Do not decide CGO usage by OS alone. CGO is a per-backend decision, and profile.go is the source of truth.
Current intent:
- macOS — CGO required throughout (Objective-C bridge)
- Linux — backend-dependent; several backends already require it, and
*_nocgo.govariants must still compile and degrade honestly - Windows — pure Go first
Good default instincts:
- AT-SPI and freedesktop notifications should prefer pure Go / D-Bus paths
- X11 may be feasible in pure Go depending on library choice
- Wayland and compositor integrations often need CGO or native helpers
- Win32 hotkeys, hooks, monitor APIs, and UIA should prefer pure Go bindings
If you introduce a backend that changes the build story, update profile.go, the justfile, and this document — and state the build assumption explicitly in your PR description and the backend's package comments.
Shared code must not hard-code macOS conventions:
- use
Primarywhen you mean "the main accelerator modifier" Primarymaps toCmdon macOS andCtrlon Linux/Windows- keep backend-specific key translation inside
adapter/platformcode - never leak X11, Wayland, Carbon, or Win32 naming into shared app logic
Relevant files: config.go, modifiers.go, binder.go.
On macOS, per-hotkey CGEventTaps are re-registered on keyboard-layout change
(via NeruSetKeymapLayoutChangeCallback2) because NeruKeyNameToCode maps key
names to layout-aware keycodes.
Start from The Three Tiers — the tier decides everything below.
Tier 1, extending an existing port (a new OS operation the app needs, and a port already covers that subsystem — e.g. another screen query):
- Add the method to the port, documenting what each platform should do
- Implement it in the darwin adapter
- Add a Linux shared fallback in
system_common.go - Add a Windows implementation or explicit
CodeNotSupportedstub - Push backend-specific Linux behavior down into
system_x11_cgo.goorsystem_wayland.go - Add the method to the mock in
internal/ports/mocks/ - Update capability reporting if the support surface changed
Tier 1, a whole new port (a subsystem no port covers yet): everything above,
plus a new internal/ports/<name>.go, an adapter package under
internal/adapter/, a PlatformCapabilities field and its Entries()
registration, and wiring in startup_phases.go. Copy the shape of
keyfeed.
Tier 2, one adapter package only (isolated platform behavior):
- Keep the shared package code platform-agnostic
- Use
platform_darwin.go/platform_other.godispatch files, unexported - Add Linux backend files inside that package rather than pushing detection up into shared app or service code
Tier 3, an optional extension: declare the interface in ports beside the
port it extends, implement it on the adapters that can, assert compliance with
var _ ports.X = (*SystemAdapter)(nil), and give the caller a fallback.
PlatformCapabilities is a registry, not just a struct. Add the field, add a
CapabilityKey constant, and register the pair in Entries(). Every renderer
(neru doctor, the IPC info map) iterates Entries(), so that is the only
edit — and
capabilities_test.go fails if a
field is added without registering it. Then fill the entry in all three presets
in capability_presets.go.
Unimplemented platform behavior returns CodeNotSupported — never a silent
no-op, unless the behavior is explicitly documented as best-effort:
return derrors.New(derrors.CodeNotSupported, "ScreenBounds not yet implemented on linux")Name the missing operation and the platform in the message. Callers degrade
gracefully via derrors.IsNotSupported(err).
Capability reporting is part of the contract, not a user nicety — it is what
neru doctor prints. When you implement or partially implement a feature,
review capabilities.go,
capability_presets.go, and
info.go. A stub must report stub, not
supported — and a shipped feature must stop reporting stub. When a feature
becomes real: replace the CodeNotSupported return, update the capability
detail, and delete TODO wording that no longer applies.
- unit tests for shared parsing, normalization, routing, or config logic
(
*_test.go, using mocks frominternal/ports/mocks) - contract tests pinning
CodeNotSupportedbehavior and capability semantics - integration tests for real platform behavior, tagged per-OS
(
*_integration_linux_test.go,*_integration_darwin_test.go,*_integration_windows_test.go)
Questions your tests should answer:
- does the adapter return the right error when the feature is unsupported?
- does the capability matrix reflect the new state?
- does backend selection route to the intended Linux slot?
- does shared logic stay platform-neutral?
Land docs in the same PR as the platform work. Each fact has exactly one home — update the one that owns it rather than restating it elsewhere:
| What changed | Update |
|---|---|
| A capability's status or mechanism | this file — the parity tables in Part 1 |
| A gap closed or discovered | this file — Known Gaps |
| Desktop-specific setup, protocol support, or a DE workaround | LINUX_DESKTOPS.md |
| Host dependencies, permissions, or deployment | LINUX_SETUP.md — keep DE-agnostic |
| A layer boundary, port contract, or data flow | ARCHITECTURE.md |
| A build recipe or test tier | DEVELOPMENT.md |
| Go style, logging, or naming | AGENTS.md (Conventions) |
| What the project claims to support, at a glance | README.md |
ARCHITECTURE.md deliberately does not track per-platform support — it describes shape, not status. Do not add a capability table there.
Good starter tasks:
- improve capability detail text for an existing platform slice
- replace a Linux
CodeNotSupportedreturn with real X11 or AT-SPI behavior - add a contract test for a currently stubbed feature
- pick a numbered item from Known Gaps
- document missing backend assumptions in the package you are touching
Higher-risk — open or link an issue first:
- changing shared input semantics
- introducing CGO to a backend that was previously pure Go
- moving shared logic into platform packages
- mixing backend detection into app or service code
A good platform PR leaves the repo better in five ways: the implementation sits in the intended file slot, unsupported paths stay explicit and honest, capability reporting is updated, tests cover the new behavior or contract, and the docs tell the next contributor what changed. That is the bar even for small slices.