detached 509f0eb

feat(session): start graphical-session.target through a systemd user unit (#21)

commit
509f0eb677592fba8de2ecb32925b564be063512
author
bnema · · 2026-09-27 14:15 UTC
committer
GitHub · · 2026-09-27 14:15 UTC
parent
3e6f9e9
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +92 -4
M README.md +1 -1
@@ -77,7 +77,7 @@ make pkg # builds dist/neferwl-*.pkg.tar.zst fr
7777 sudo pacman -U dist/neferwl-*.pkg.tar.zst
7878 ```
7979
80-The package installs `neferwl` and a **NeferWL** session for display managers such as Ly, GDM and SDDM. The session runs `neferwl --session`, which passes `WAYLAND_DISPLAY` and `DISPLAY` to D-Bus and systemd user services so that portals and notification daemons can connect.
80+The package installs `neferwl` and a **NeferWL** session for display managers such as Ly, GDM and SDDM. The session runs `neferwl-session`, which starts `neferwl.service` under systemd. This starts `graphical-session.target` and services bound to it (bars, notification daemons, portals, and XDG autostart) with NeferWL and stops them with it. Running `neferwl --session` alone still exports the display environment to D-Bus and systemd user services.
8181
8282 On other distributions, `make build` builds `bin/neferwl`.
8383
@@ -77,7 +77,7 @@ make pkg # builds dist/neferwl-*.pkg.tar.zst fr
77 sudo pacman -U dist/neferwl-*.pkg.tar.zst77 sudo pacman -U dist/neferwl-*.pkg.tar.zst
78 ```78 ```
79 79
80-The package installs `neferwl` and a **NeferWL** session for display managers such as Ly, GDM and SDDM. The session runs `neferwl --session`, which passes `WAYLAND_DISPLAY` and `DISPLAY` to D-Bus and systemd user services so that portals and notification daemons can connect.80+The package installs `neferwl` and a **NeferWL** session for display managers such as Ly, GDM and SDDM. The session runs `neferwl-session`, which starts `neferwl.service` under systemd. This starts `graphical-session.target` and services bound to it (bars, notification daemons, portals, and XDG autostart) with NeferWL and stops them with it. Running `neferwl --session` alone still exports the display environment to D-Bus and systemd user services.
81 81
82 On other distributions, `make build` builds `bin/neferwl`.82 On other distributions, `make build` builds `bin/neferwl`.
83 83
M cmd/neferwl/main.go +1 -1
@@ -122,7 +122,7 @@ func run() error {
122122 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
123123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
124124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
125- session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services")
125+ session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
126126 if err := flags.Parse(os.Args[1:]); err != nil {
127127 if errors.Is(err, flag.ErrHelp) {
128128 return err
@@ -122,7 +122,7 @@ func run() error {
122 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")122 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
125- session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services")125+ session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
126 if err := flags.Parse(os.Args[1:]); err != nil {126 if err := flags.Parse(os.Args[1:]); err != nil {
127 if errors.Is(err, flag.ErrHelp) {127 if errors.Is(err, flag.ErrHelp) {
128 return err128 return err
M internal/adapters/launcher/launcher_test.go +1 -1
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 func TestChildEnv(t *testing.T) {
17- got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24)
17+ got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "NOTIFY_SOCKET=/tmp/notify", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24)
1818 want := []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus", "HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=neferwl", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"}
1919 if !reflect.DeepEqual(got, want) {
2020 t.Fatalf("got %v want %v", got, want)
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 func TestChildEnv(t *testing.T) {16 func TestChildEnv(t *testing.T) {
17- got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24)17+ got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "XCURSOR_THEME=Adwaita", "XCURSOR_PATH=/icons", "SECRET=bad", "NOTIFY_SOCKET=/tmp/notify", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus"}, "wayland-7", "/run/me", 24)
18 want := []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus", "HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=neferwl", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"}18 want := []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/me/bus", "HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XCURSOR_PATH=/icons", "XCURSOR_SIZE=24", "XCURSOR_THEME=Adwaita", "XDG_CURRENT_DESKTOP=neferwl", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"}
19 if !reflect.DeepEqual(got, want) {19 if !reflect.DeepEqual(got, want) {
20 t.Fatalf("got %v want %v", got, want)20 t.Fatalf("got %v want %v", got, want)
M internal/adapters/launcher/session.go +19 -0
@@ -2,6 +2,7 @@ package launcher
22
33 import (
44 "context"
5+ "net"
56 "os/exec"
67 "time"
78
@@ -50,3 +51,21 @@ func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, a
5051 }
5152 log.Info().Strs("vars", sessionVars).Msg(done)
5253 }
54+
55+// NotifyReady tells the systemd user manager that the compositor is ready.
56+func NotifyReady(socket string, log zerowrap.Logger) {
57+ if socket == "" {
58+ return
59+ }
60+ conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: socket, Net: "unixgram"})
61+ if err != nil {
62+ log.Warn().Err(err).Msg("session readiness notification failed")
63+ return
64+ }
65+ defer conn.Close()
66+ if _, err := conn.Write([]byte("READY=1")); err != nil {
67+ log.Warn().Err(err).Msg("session readiness notification failed")
68+ return
69+ }
70+ log.Info().Msg("session ready")
71+}
@@ -2,6 +2,7 @@ package launcher
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net"
5 "os/exec"6 "os/exec"
6 "time"7 "time"
7 8
@@ -50,3 +51,21 @@ func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, a
50 }51 }
51 log.Info().Strs("vars", sessionVars).Msg(done)52 log.Info().Strs("vars", sessionVars).Msg(done)
52 }53 }
54+
55+// NotifyReady tells the systemd user manager that the compositor is ready.
56+func NotifyReady(socket string, log zerowrap.Logger) {
57+ if socket == "" {
58+ return
59+ }
60+ conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: socket, Net: "unixgram"})
61+ if err != nil {
62+ log.Warn().Err(err).Msg("session readiness notification failed")
63+ return
64+ }
65+ defer conn.Close()
66+ if _, err := conn.Write([]byte("READY=1")); err != nil {
67+ log.Warn().Err(err).Msg("session readiness notification failed")
68+ return
69+ }
70+ log.Info().Msg("session ready")
71+}
M internal/adapters/launcher/session_test.go +26 -0
@@ -2,10 +2,12 @@ package launcher
22
33 import (
44 "context"
5+ "net"
56 "os"
67 "path/filepath"
78 "strings"
89 "testing"
10+ "time"
911
1012 "github.com/bnema/neferwl/internal/logging"
1113 )
@@ -40,3 +42,27 @@ func TestExportSession(t *testing.T) {
4042 t.Fatal(string(data))
4143 }
4244 }
45+
46+func TestNotifyReady(t *testing.T) {
47+ ctx := context.Background()
48+ log := logging.For(ctx, "launcher")
49+ NotifyReady("", log)
50+ path := filepath.Join(t.TempDir(), "notify")
51+ listener, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ defer listener.Close()
56+ NotifyReady(path, log)
57+ if err := listener.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
58+ t.Fatal(err)
59+ }
60+ buf := make([]byte, 64)
61+ n, _, err := listener.ReadFromUnix(buf)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if string(buf[:n]) != "READY=1" {
66+ t.Fatalf("notification: %q", buf[:n])
67+ }
68+}
@@ -2,10 +2,12 @@ package launcher
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "strings"8 "strings"
8 "testing"9 "testing"
10+ "time"
9 11
10 "github.com/bnema/neferwl/internal/logging"12 "github.com/bnema/neferwl/internal/logging"
11 )13 )
@@ -40,3 +42,27 @@ func TestExportSession(t *testing.T) {
40 t.Fatal(string(data))42 t.Fatal(string(data))
41 }43 }
42 }44 }
45+
46+func TestNotifyReady(t *testing.T) {
47+ ctx := context.Background()
48+ log := logging.For(ctx, "launcher")
49+ NotifyReady("", log)
50+ path := filepath.Join(t.TempDir(), "notify")
51+ listener, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ defer listener.Close()
56+ NotifyReady(path, log)
57+ if err := listener.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
58+ t.Fatal(err)
59+ }
60+ buf := make([]byte, 64)
61+ n, _, err := listener.ReadFromUnix(buf)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if string(buf[:n]) != "READY=1" {
66+ t.Fatalf("notification: %q", buf[:n])
67+ }
68+}
M internal/app/app.go +1 -0
@@ -133,6 +133,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
133133 }
134134 if opts.Session {
135135 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
136+ launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
136137 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
137138 }
138139 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
@@ -133,6 +133,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
133 }133 }
134 if opts.Session {134 if opts.Session {
135 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))135 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
136+ launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
136 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))137 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
137 }138 }
138 child := launcher.New(childEnv, logging.For(ctx, "launcher"))139 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
M packaging/arch/PKGBUILD +3 -0
@@ -39,6 +39,9 @@ check() {
3939 package() {
4040 cd "$pkgname-$pkgver"
4141 install -Dm755 neferwl "$pkgdir/usr/bin/neferwl"
42+ install -Dm755 packaging/neferwl-session "$pkgdir/usr/bin/neferwl-session"
43+ install -Dm644 packaging/neferwl.service "$pkgdir/usr/lib/systemd/user/neferwl.service"
44+ install -Dm644 packaging/neferwl-shutdown.target "$pkgdir/usr/lib/systemd/user/neferwl-shutdown.target"
4245 install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop"
4346 install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf"
4447 install -Dm644 examples/config "$pkgdir/usr/share/doc/neferwl/config.example"
@@ -39,6 +39,9 @@ check() {
39 package() {39 package() {
40 cd "$pkgname-$pkgver"40 cd "$pkgname-$pkgver"
41 install -Dm755 neferwl "$pkgdir/usr/bin/neferwl"41 install -Dm755 neferwl "$pkgdir/usr/bin/neferwl"
42+ install -Dm755 packaging/neferwl-session "$pkgdir/usr/bin/neferwl-session"
43+ install -Dm644 packaging/neferwl.service "$pkgdir/usr/lib/systemd/user/neferwl.service"
44+ install -Dm644 packaging/neferwl-shutdown.target "$pkgdir/usr/lib/systemd/user/neferwl-shutdown.target"
42 install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop"45 install -Dm644 packaging/neferwl.desktop "$pkgdir/usr/share/wayland-sessions/neferwl.desktop"
43 install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf"46 install -Dm644 packaging/neferwl-portals.conf "$pkgdir/usr/share/xdg-desktop-portal/neferwl-portals.conf"
44 install -Dm644 examples/config "$pkgdir/usr/share/doc/neferwl/config.example"47 install -Dm644 examples/config "$pkgdir/usr/share/doc/neferwl/config.example"
A packaging/neferwl-session +19 -0
@@ -0,0 +1,19 @@
1+#!/bin/sh
2+if [ "${1:-}" != '-l' ] && [ -n "${SHELL:-}" ] && [ -x "$SHELL" ] && grep -Fxq "$SHELL" /etc/shells; then
3+ case "$SHELL" in
4+ */false|*/nologin) ;;
5+ *) exec "$SHELL" -l -c 'exec neferwl-session -l' ;;
6+ esac
7+fi
8+if systemctl --user -q is-active neferwl.service; then
9+ echo 'A NeferWL session is already running.' >&2
10+ exit 1
11+fi
12+systemctl --user reset-failed
13+systemctl --user import-environment
14+if command -v dbus-update-activation-environment >/dev/null 2>&1; then
15+ dbus-update-activation-environment --all
16+fi
17+systemctl --user --wait start neferwl.service
18+systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
19+systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
@@ -0,0 +1,19 @@
1+#!/bin/sh
2+if [ "${1:-}" != '-l' ] && [ -n "${SHELL:-}" ] && [ -x "$SHELL" ] && grep -Fxq "$SHELL" /etc/shells; then
3+ case "$SHELL" in
4+ */false|*/nologin) ;;
5+ *) exec "$SHELL" -l -c 'exec neferwl-session -l' ;;
6+ esac
7+fi
8+if systemctl --user -q is-active neferwl.service; then
9+ echo 'A NeferWL session is already running.' >&2
10+ exit 1
11+fi
12+systemctl --user reset-failed
13+systemctl --user import-environment
14+if command -v dbus-update-activation-environment >/dev/null 2>&1; then
15+ dbus-update-activation-environment --all
16+fi
17+systemctl --user --wait start neferwl.service
18+systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
19+systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
A packaging/neferwl-shutdown.target +6 -0
@@ -0,0 +1,6 @@
1+[Unit]
2+Description=Shutdown running NeferWL session
3+DefaultDependencies=no
4+StopWhenUnneeded=true
5+Conflicts=graphical-session.target graphical-session-pre.target
6+After=graphical-session.target graphical-session-pre.target
@@ -0,0 +1,6 @@
1+[Unit]
2+Description=Shutdown running NeferWL session
3+DefaultDependencies=no
4+StopWhenUnneeded=true
5+Conflicts=graphical-session.target graphical-session-pre.target
6+After=graphical-session.target graphical-session-pre.target
M packaging/neferwl.desktop +1 -1
@@ -1,6 +1,6 @@
11 [Desktop Entry]
22 Name=NeferWL
33 Comment=Minimal Wayland compositor with column tiling
4-Exec=neferwl --session
4+Exec=neferwl-session
55 Type=Application
66 DesktopNames=neferwl
@@ -1,6 +1,6 @@
1 [Desktop Entry]1 [Desktop Entry]
2 Name=NeferWL2 Name=NeferWL
3 Comment=Minimal Wayland compositor with column tiling3 Comment=Minimal Wayland compositor with column tiling
4-Exec=neferwl --session4+Exec=neferwl-session
5 Type=Application5 Type=Application
6 DesktopNames=neferwl6 DesktopNames=neferwl
A packaging/neferwl.service +14 -0
@@ -0,0 +1,14 @@
1+[Unit]
2+Description=NeferWL Wayland compositor
3+Documentation=https://github.com/bnema/neferwl
4+BindsTo=graphical-session.target
5+Before=graphical-session.target
6+Wants=graphical-session-pre.target
7+After=graphical-session-pre.target
8+Wants=xdg-desktop-autostart.target
9+Before=xdg-desktop-autostart.target
10+
11+[Service]
12+Slice=session.slice
13+Type=notify
14+ExecStart=/usr/bin/neferwl --session
@@ -0,0 +1,14 @@
1+[Unit]
2+Description=NeferWL Wayland compositor
3+Documentation=https://github.com/bnema/neferwl
4+BindsTo=graphical-session.target
5+Before=graphical-session.target
6+Wants=graphical-session-pre.target
7+After=graphical-session-pre.target
8+Wants=xdg-desktop-autostart.target
9+Before=xdg-desktop-autostart.target
10+
11+[Service]
12+Slice=session.slice
13+Type=notify
14+ExecStart=/usr/bin/neferwl --session

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close