- Nix 100%
|
|
||
|---|---|---|
| example | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| module.nix | ||
| README.md | ||
deckmode
A jump-in/jump-out, controller-driven, gamescope-based "gaming mode" for NixOS — the Steam Deck's gamescope session experience, but as a mode you flip into and out of on a normal PC. Your regular desktop (niri, GNOME, KDE, whatever) keeps running with all its programs on its own virtual terminal; the gaming session lives on a second VT and you switch between them at will.
- Enter: launch "Gaming Mode" from your app launcher (or run
deckmode). A gamescope + Steam Big Picture session starts on its own VT and the screen switches to it. Because gamescope owns the seat there, Steam Input drives the cursor natively — full controller-as-mouse, the Deck model. - Peek back: Ctrl+Alt+F1 (or your desktop VT) — everything is still running. Ctrl+Alt+F7 returns to the game... usually (see CAVEATS).
- Exit from the couch: a "Exit Gaming Mode" shortcut added to Steam's library (controller-navigable) stops the session and returns you to the desktop, optionally with a desktop notification.
Quick start
{
inputs.deckmode.url = "git+https://git.lyte.dev/lytedev/nixos-deckmode.git";
outputs = { nixpkgs, deckmode, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
deckmode.nixosModules.default
{
services.deckmode = {
enable = true;
user = "alice";
};
nixpkgs.config.allowUnfree = true; # Steam
}
];
};
};
}
Rebuild, then launch Gaming Mode from your desktop's app launcher, or run
deckmode in a terminal. See example/configuration.nix
for a fuller config.
How it works
- A second greetd instance (
deckmode.service, not started at boot) runs on a dedicated VT (default 7) and autologins the configured user into the gaming session. greetd — not a hand-rolled systemd unit — because a greetd session is a proper PAM login, which Steam's bwrap sandbox requires; a unit withPAMName=loginfails withbwrap: Unexpected capabilities but not setuid. - The session command (default
gamescope --backend drm -e -- steam -gamepadui) is wrapped insystemd-inhibitso the machine doesn't idle-suspend mid-game. Session output lands in~/.deckmode.log. - The
deckmodelauncher starts the service (greetd switches the VT itself). If the session is already running it instead switches VTs via logind'sSeat.SwitchTo—systemctl starton an active unit is a no-op and would not flip the VT. - Stopping from inside the session: anything launched from the Steam
library runs inside Steam's own bwrap/pressure-vessel container — this is
system Steam, not flatpak, and it still applies — where
NoNewPrivilegesblocks a privilegedchvt(and setuid helpers likepkexec). But the sandbox can write a file. Sodeckmode-stopjust touches a sentinel; a root-owned systemd.pathunit watches it (systemd's own inotify — no polling) and does the privileged part:chvtback to the desktop VT andsystemctl stop deckmode.service. - Lifecycle: the service has
Restart=noandExecStopPost=chvt <desktopVt>, so however the session ends — clean stop, Steam quit, or gamescope crash — you land back on your desktop, never on a dead compositor-less VT. See CAVEATS for whyRestart=nois load-bearing. - polkit rule lets the configured user (and only them) start/stop exactly
deckmode.servicewithout a password prompt.
Options
All under services.deckmode:
| Option | Default | Description |
|---|---|---|
enable |
false |
Enable deckmode. |
user |
— (required) | User the gaming session autologins as. |
vt |
7 |
VT for the gaming session. Must be free (see CAVEATS). |
desktopVt |
1 |
VT your desktop lives on; deckmode returns here when the session ends. |
command |
"gamescope --backend drm -e -- steam -gamepadui" |
Compositor + client command. Fully overridable for non-Steam setups. |
extraEnvironment |
{ } |
Env vars exported in the session before the command. |
extraSessionCommands |
"" |
Shell snippet run before the command (helper daemons etc.). |
inhibitLidSwitch |
false |
Hold a lid-switch inhibitor while gaming; also flips LidSwitchIgnoreInhibited to no (see CAVEATS). |
stopSentinel |
~<user>/.deckmode-stop |
Sentinel file path; must be writable by user and visible inside the session's sandbox. |
notifyOnStop |
true |
Desktop notification when the stop watcher runs. |
launcher.enable |
true |
Install start/stop desktop entries. |
launcher.name / launcher.icon |
"Gaming Mode" / "input-gaming" |
Start entry. |
launcher.stopName / launcher.stopIcon |
"Exit Gaming Mode" / "application-exit" |
Stop entry. |
steam.enable |
true |
Enable programs.steam + programs.gamescope. Disable if you manage them yourself or run a non-Steam command — the default command expects gamescope and steam on PATH. |
Installed commands: deckmode (enter / switch to gaming mode) and
deckmode-stop (leave it, from either side).
Exiting with only a controller: add the Steam shortcut manually
Steam's built-in "Switch to Desktop" does not work here (verified: it
never shells out to steamos-session-select on a non-SteamOS system). To
exit from inside Big Picture with a controller, add deckmode-stop as a
non-Steam game once, manually:
- In Steam (desktop mode is easiest): Add a Game → Add a Non-Steam Game →
Browse →
/run/current-system/sw/bin/deckmode-stop. - Optionally rename the shortcut to "Switch to Desktop".
This cannot be automated by the module: shortcuts live in Steam's binary
shortcuts.vdf, which Steam owns and rewrites — a module writing into it
would race Steam and corrupt user data. One manual add persists across
rebuilds.
CAVEATS
- Switching away can crash gamescope — accepted by design. Running
gamescope on a separate TTY and VT-switching away is a documented gamescope
caveat: it may crash when it loses the VT. deckmode treats the session as
disposable —
Restart=nolets it die (your desktop and its programs are untouched; relaunch to game again). Do not "fix" this withRestart=always: the crash is an autonomous exit, systemd would respawn it seconds later, greetd grabs the VT back, and you can never leave the gaming VT. A cleansystemctl stopsuppresses restart, a crash does not — this was learned the hard way. - The greetd requirement is real, not a style choice. Steam's bwrap
needs a proper PAM login session. If you replace the second greetd with a
plain systemd service (even with
PAMName=login), Steam dies withbwrap: Unexpected capabilities but not setuid. - VT numbering vs
NAutoVTs. logind autospawns gettys on VTs up toNAutoVTs(default 6). The gamingvtmust be above that, and not owned by your display manager or a static getty. The default of 7 is safe on a stock config; if you raisedNAutoVTs, raisevttoo. - The lid-inhibitor tradeoff.
inhibitLidSwitch = truesets logind'sLidSwitchIgnoreInhibited=no, without which lid-switch inhibitors are ignored. But that flag is system-wide: afterwards any application holding a lid inhibitor prevents lid-close suspend, not just deckmode. A plain lid close with no inhibitor held still suspends as usual. - Steam is unfree — the default
steam.enable = truerequiresnixpkgs.config.allowUnfree(or anallowUnfreePredicatecovering steam). - Recent nixpkgs required. The module uses
services.logind.settings(nixpkgs ≥ 25.11) and expects the flake-era module system. Pin the flake input accordingly. - polkit is force-enabled (
mkDefault) so the passwordless start/stop rule applies; virtually every desktop already enables it.
License
MIT — see LICENSE.