This repository has no description
9.5 kB
205 lines
1{
2 config,
3 pkgs,
4 ...
5}:
6let
7 mainUser = config.users.extraUsers.mainUser;
8
9 # Run the launcher + games from the on-disk strom checkout at
10 # /home/strom/strom when it exists, else the public github flake (see the
11 # STROM_FLAKE selection in strom-session below).
12 #
13 # Compositor: sway (wlroots) via greetd autologin - NOT cage, and NOT an outer
14 # gamescope session. Each game still brings its own gamescope, nested ONCE
15 # inside sway.
16 #
17 # Why sway and not cage: a game's nested gamescope prefers its WAYLAND backend,
18 # which needs wlroots protocols that cage (a minimal kiosk compositor) does not
19 # expose. Under cage gamescope's wayland backend fails ("Couldn't create
20 # Wayland objects") and silently falls back to its SDL backend, whose Vulkan
21 # swapchain remake loops and aborts on this RADV RENOIR iGPU
22 # (rendervulkan.cpp vulkan_remake_swapchain) - that is what crashed Baba Is You
23 # a couple seconds in. sway exposes those protocols, so gamescope inits its
24 # wayland backend and never touches the crashing SDL path. (An outer gamescope
25 # session instead double-nests gamescope-in-gamescope, which is worse.)
26 # Keeping per-game gamescope preserves render resolution/scaling, the in-game
27 # exit chord, and bare-tty portability - hence no STROM_NO_GAMESCOPE.
28 strom-session = pkgs.writeShellScript "strom-session" ''
29 set -u
30 # xpadneo owns the Xbox pad's hidraw node, so force SDL (pygame launcher and
31 # the games) to read the pad through evdev instead of its HIDAPI backend -
32 # same fix as configs/steam.nix, without it SDL finds no controller.
33 export SDL_JOYSTICK_HIDAPI_XBOX=0
34 # SDL's udev-monitor hotplug does not deliver in this kiosk session, so a pad
35 # powered on after the launcher starts is never seen. Force SDL to poll
36 # /dev/input instead, which reliably picks up pads as they connect/disconnect.
37 export SDL_JOYSTICK_DISABLE_UDEV=1
38 # In the kiosk the launcher window may not report input focus, and SDL
39 # suppresses joystick (not keyboard) events while unfocused - so allow
40 # joystick events regardless of focus.
41 export SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1
42 # Resolve the strom flake once: prefer the on-disk checkout at
43 # /home/strom/strom (edit + test there before pushing to github), and fall
44 # back to the public flake when it is absent. The launcher runs from this
45 # ref, and pkgs/launcher resolves each game's ref from STROM_FLAKE too (it
46 # sets the default with := so this export wins).
47 if [ -d /home/strom/strom ]; then
48 STROM_FLAKE=/home/strom/strom
49 else
50 STROM_FLAKE=github:kraftwerk-gaming/strom
51 fi
52 export STROM_FLAKE
53 # strom-run (the per-game supervisor) needs a delegated cgroup v2 so it can
54 # mkdir a child cgroup and cgroup.kill the game tree atomically. The greetd
55 # PAM session drops us into a root-owned logind session scope
56 # (user-1011.slice/session-cN.scope) that is NOT delegated, so strom-run dies
57 # with "could not create cgroup (Permission denied)" and the game never
58 # launches. Re-parent into a delegated transient scope under the user manager
59 # (user@UID.service, which logind DOES delegate). Wait for the user bus first:
60 # on boot the PAM session opens slightly before the user manager's bus socket.
61 rd="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
62 for _ in $(seq 1 50); do
63 [ -S "$rd/bus" ] && break
64 sleep 0.2
65 done
66 # Relaunch whenever the couch launcher exits (gamepad B / Esc) so the kiosk
67 # never drops to a blank screen. The loop runs inside the delegated scope so
68 # every game inherits a writable cgroup; sway stays up (the launcher is just
69 # one of its clients) across relaunches.
70 #
71 # setpriv --ambient-caps=-all: the login session may carry an ambient
72 # capability (e.g. cap_wake_alarm) that every child inherits. bwrap refuses
73 # to start when it sees capabilities in its permitted set without being
74 # setuid ("Unexpected capabilities but not setuid, old file caps config?"),
75 # which instant-crashes every sandboxed game. Drop the ambient set for the
76 # whole launcher tree so bwrap (and thus each game) starts clean.
77 exec ${pkgs.systemd}/bin/systemd-run --user --scope -p Delegate=yes \
78 --quiet --collect -- \
79 ${pkgs.util-linux}/bin/setpriv --ambient-caps=-all \
80 ${pkgs.bash}/bin/bash -c 'while true; do
81 ${config.nix.package}/bin/nix run "$STROM_FLAKE#scripts.launcher"
82 sleep 2
83 done'
84 '';
85
86 # Bare wlroots kiosk: no window chrome, every toplevel fullscreen, no idle
87 # blanking. Its whole reason to exist over cage is that it exposes the wlroots
88 # protocols each game's nested gamescope needs for its wayland backend (see
89 # the top-of-file note).
90 sway-kiosk = pkgs.writeText "sway-kiosk.conf" ''
91 default_border none
92 default_floating_border none
93 gaps inner 0
94 gaps outer 0
95 # Fullscreen every toplevel: the launcher grid and each game's gamescope.
96 for_window [app_id=".*"] fullscreen enable
97 for_window [class=".*"] fullscreen enable
98 # No swaybar / no swayidle -> no bar, no DPMS blank (mains-powered kiosk).
99 exec ${strom-session}
100 '';
101in
102{
103 # Dedicated, unprivileged kiosk user. Gamepad input comes through evdev
104 # (input group), audio through the system-wide pipewire socket (pipewire
105 # group), and the games need video/audio like any local player.
106 users.users.strom = {
107 isNormalUser = true;
108 extraGroups = [
109 "video"
110 "audio"
111 "input"
112 "pipewire"
113 ];
114 };
115
116 # Let the main user become `strom` without a password. Everything that makes
117 # this kiosk a kiosk belongs to that account and is unreachable from outside
118 # it: sway owns seat0, XDG_RUNTIME_DIR is 0700, the pad's evdev nodes are
119 # ACL'd to strom (js0 alone is world-readable, and SDL2 does not use it), and
120 # /home/strom holds both the game state and the /home/strom/strom checkout the
121 # launcher resolves STROM_FLAKE from. So testing a game the way a player
122 # actually meets it - on the TV, with the pad - means being strom.
123 security.sudo.extraConfig = ''
124 ${mainUser.name} ALL=(strom) NOPASSWD: ALL
125 '';
126
127 # Kiosk autostart: greetd autologins the strom user on tty1 into sway, which
128 # runs the couch launcher as its sole fullscreen client. default_session
129 # re-runs the same session if sway ever exits.
130 services.greetd = {
131 enable = true;
132 settings =
133 let
134 session = {
135 command = "${pkgs.sway}/bin/sway --config ${sway-kiosk}";
136 user = "strom";
137 };
138 in
139 {
140 initial_session = session;
141 default_session = session;
142 };
143 };
144
145 # The panel comes up at ~1% backlight, unreadable for a couch setup. Ship
146 # brightnessctl + its udev rules (chgrp backlight to the video group, g+w) so
147 # any video-group user (strom, lass) can adjust it without root, e.g.
148 # `brightnessctl set 100%` / `brightnessctl set 50%`.
149 environment.systemPackages = [
150 pkgs.brightnessctl
151 ];
152 services.udev.packages = [ pkgs.brightnessctl ];
153
154 # Force the backlight to maximum on every boot. Ordered after
155 # systemd-backlight, which would otherwise restore the last (dim) saved level.
156 systemd.services.max-brightness = {
157 wantedBy = [ "multi-user.target" ];
158 after = [ "systemd-backlight@backlight:amdgpu_bl1.service" ];
159 serviceConfig = {
160 Type = "oneshot";
161 ExecStart = "${pkgs.brightnessctl}/bin/brightnessctl set 100%";
162 };
163 };
164
165 # Xbox Wireless Controllers (Bluetooth). xpadneo for correct rumble/axis
166 # mapping; the two bluez tweaks + disable_ertm are what make Xbox pads
167 # actually bond (without disable_ertm they fail with AuthenticationFailed).
168 # Bluetooth itself (adapter + powerOnBoot) already comes from
169 # configs/pipewire.nix. Pair pads with bluetuith (also from pipewire.nix).
170 hardware.xpadneo.enable = true;
171 # Xbox One/Series controllers pair over Bluetooth only with L2CAP ERTM off.
172 boot.extraModprobeConfig = "options bluetooth disable_ertm=1";
173 hardware.bluetooth.settings.General = {
174 # JustWorksRepairing lets bluez accept fresh pairings without a manual
175 # remove; Privacy = device pins a static host address so the controller's
176 # bond key survives RPA rotation.
177 JustWorksRepairing = "always";
178 Privacy = "device";
179 # Keep page scan always on so a bonded pad reconnects promptly when powered
180 # back on (otherwise bonded pads bond but never re-link). Fine on a
181 # mains-powered kiosk.
182 FastConnectable = true;
183 # In this busy-RF LAN environment the cache fills with hundreds of stray
184 # BLE devices; evict non-bonded ones ~30s after they leave range so they
185 # do not pile up and saturate the adapter.
186 TemporaryTimeout = 30;
187 };
188
189 # Persistent JustWorks pairing agent. Xbox pads pair via JustWorks, and on
190 # reconnect a pad whose bond went stale re-initiates pairing. bluez needs a
191 # registered agent to auto-confirm that; bluetuith only provides one while you
192 # are actively pairing in it, so an autonomous re-pair hits "No agent
193 # available for request type 2" and the link drops. Keep a NoInputNoOutput
194 # agent registered at all times so pairing/re-pairing just works.
195 systemd.services.bt-agent = {
196 wantedBy = [ "multi-user.target" ];
197 after = [ "bluetooth.service" ];
198 requires = [ "bluetooth.service" ];
199 serviceConfig = {
200 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput";
201 Restart = "always";
202 RestartSec = 2;
203 };
204 };
205}