No description
Find a file
2026-07-02 01:37:49 -05:00
example docs: point flake input at the real remote (git.lyte.dev/lytedev/nixos-deckmode) 2026-07-01 23:20:42 -05:00
.gitignore
flake.lock
flake.nix
LICENSE
module.nix Revert "feat: sentinel + root-watcher start (spawn-proof launcher)" 2026-07-02 01:37:49 -05:00
README.md docs: point flake input at the real remote (git.lyte.dev/lytedev/nixos-deckmode) 2026-07-01 23:20:42 -05:00

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 with PAMName=login fails with bwrap: Unexpected capabilities but not setuid.
  • The session command (default gamescope --backend drm -e -- steam -gamepadui) is wrapped in systemd-inhibit so the machine doesn't idle-suspend mid-game. Session output lands in ~/.deckmode.log.
  • The deckmode launcher starts the service (greetd switches the VT itself). If the session is already running it instead switches VTs via logind's Seat.SwitchTosystemctl start on 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 NoNewPrivileges blocks a privileged chvt (and setuid helpers like pkexec). But the sandbox can write a file. So deckmode-stop just touches a sentinel; a root-owned systemd .path unit watches it (systemd's own inotify — no polling) and does the privileged part: chvt back to the desktop VT and systemctl stop deckmode.service.
  • Lifecycle: the service has Restart=no and ExecStopPost=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 why Restart=no is load-bearing.
  • polkit rule lets the configured user (and only them) start/stop exactly deckmode.service without 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:

  1. In Steam (desktop mode is easiest): Add a Game → Add a Non-Steam Game → Browse/run/current-system/sw/bin/deckmode-stop.
  2. 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=no lets it die (your desktop and its programs are untouched; relaunch to game again). Do not "fix" this with Restart=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 clean systemctl stop suppresses 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 with bwrap: Unexpected capabilities but not setuid.
  • VT numbering vs NAutoVTs. logind autospawns gettys on VTs up to NAutoVTs (default 6). The gaming vt must 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 raised NAutoVTs, raise vt too.
  • The lid-inhibitor tradeoff. inhibitLidSwitch = true sets logind's LidSwitchIgnoreInhibited=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 = true requires nixpkgs.config.allowUnfree (or an allowUnfreePredicate covering 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.