Compare commits

...
Author SHA1 Message Date
admin 738014ae54 updated docs 2026-07-11 17:49:19 -04:00
admin 4b7725849f added dms greeter auto session switch 2026-07-11 17:48:08 -04:00
28allday 835a939a81 Revert "v0.1.14 — change Gaming Mode launch keybind from Super+Shift+S to Super+Shift+G"
This reverts commit 0844f02a8c.
2026-06-08 21:21:41 +01:00
28alldayandClaude Opus 4.8 0844f02a8c v0.1.14 — change Gaming Mode launch keybind from Super+Shift+S to Super+Shift+G
Super+Shift+S collided with other uses; move the enter-Gaming-Mode bind to
Super+Shift+G. Updates the Hyprland bindd line, all comments/messages, and the
README usage references. Adds in-place migration so re-running the installer
rewrites an existing Super+Shift+S bind to Super+Shift+G (the idempotency check
previously skipped because the switch-to-gaming line already existed). Also
syncs the README version header (was 0.1.9) to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:17:26 +01:00
28allday d45f81be36 v0.1.13 — pacman hook keeps gamescope cap_sys_nice across upgrades
File capabilities live on the inode as a security.capability xattr, so
pacman drops cap_sys_nice every time it replaces /usr/bin/gamescope on
upgrade. Performance mode silently regresses (worse frame pacing / input
latency) with no error surfaced.

Installer now drops /usr/share/libalpm/hooks/deckshift-gamescope-cap.hook,
which re-applies cap_sys_nice=eip PostTransaction whenever gamescope is
installed or upgraded. Idempotent; prompted once at install time alongside
the existing cap_sys_nice grant prompt.
2026-05-19 18:03:32 +01:00
28alldayandClaude Opus 4.7 a3cb6f28e4 v0.1.12 — Patch gamescope-session-plus so refresh-rate selection actually reaches gamescope
The real bug behind every "60 Hz stuck in Gaming Mode" report:

  - DeckShift installs `gamescope` from Arch's `extra` repo (upstream
    Valve binary). That binary does NOT have `--custom-refresh-rates`.
    The flag is a ChimeraOS-fork (`gamescope-plus`) addition that
    never landed upstream, and the fork is not packaged in AUR for
    64-bit Arch.

  - The AUR `gamescope-session-git` script (OpenGamingCollective,
    ex-ChimeraOS) was written assuming gamescope-plus. It feature-
    detects via `gamescope_has_option "--custom-refresh-rates"` and
    silently drops the value when missing.

  - Net effect: CUSTOM_REFRESH_RATES from the env conf reaches the
    session script but never reaches the gamescope binary. Every
    refresh-rate selection in the DeckShift TUI since the project
    began has been a paper fix; gamescope just launched at the
    EDID-preferred mode (usually 60 Hz).

  - v0.1.8's comma-list "fix" was correct on paper but the value
    never reached the binary, so it didn't actually do anything on
    Omarchy. The Framework Desktop user wasn't reporting a hardware-
    specific bug — they were the first user to notice a bug that
    affected everyone, because their 170 Hz monitor made the regression
    obvious. On a 165 Hz Acer Nitro you'd just never notice 60 vs 165
    in Steam BPM.

Fix: ./deckshift.sh now patches the installed gamescope-session-plus
in place, adding an elif fallback that uses `--nested-refresh` (a flag
present in every gamescope version) with the highest value from the
CUSTOM_REFRESH_RATES list as the launch rate. Marked with a
DECKSHIFT-NESTED-REFRESH-FALLBACK sentinel comment for idempotency,
re-applied on every install so AUR upgrades that clobber the file
don't silently regress refresh-rate handling.

Implementation: new patch_gamescope_session_plus() function in
deckshift.sh, called from setup_session_switching() after the AUR
install completes. Uses Python regex with a function-based replacement
(re.sub with a string-form replacement was the first attempt and got
caught by Python's backslash processing turning `\n` in the `tr`
argument into a real newline — function callbacks bypass that, so the
literal `\n` reaches the shell as intended).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:38:33 +01:00
28alldayandClaude Opus 4.7 52c883b780 v0.1.11 — Multi-monitor handling: disable an auxiliary monitor before Gaming Mode
Reported on a Framework Desktop (AMD AI MAX 380) + Gigabyte M27Q +
LG DualUp setup: with both monitors attached, gamescope would either
land on the wrong screen or refuse to start, and writing
OUTPUT_CONNECTOR=DP-X alone wasn't enough to fix it. The workaround
shipped by the user was to manually patch /usr/share/gamescope-session-plus
to disable the other monitor before launching gamescope.

DeckShift now handles this natively, without touching the
gamescope-session-plus script (which is ChimeraOS's, not ours):

- New env var OUTPUT_CONNECTOR_TO_DISABLE (single connector or
  comma list) written to ~/.config/environment.d/gamescope-session-plus.conf
  alongside the other display keys.

- switch-to-gaming reads it and runs `hyprctl keyword monitor X,disable`
  for each listed connector BEFORE the SDDM restart, while Hyprland is
  still alive (hyprctl needs a live IPC socket). The disable is
  runtime-only — Hyprland's static config isn't touched — so when the
  user returns from Gaming Mode the new Hyprland reads its config fresh
  and the monitor comes back automatically. No re-enable step needed.

- Settings TUI exposes this as a "Hide monitor" main-menu item. The
  picker lists every connected monitor EXCEPT the gaming one (so a
  user can't accidentally pick the same connector they just set as
  OUTPUT_CONNECTOR) and includes a "(clear)" entry.

Also fixes a latent bug in v0.1.10's config-path shortening:
${CONF/#$HOME/~} was supposed to render the conf path with ~ but
bash applies tilde-expansion to the replacement side, re-expanding ~
to $HOME and making the substitution a no-op. Escaped as \~ now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:12:42 +01:00
28alldayandClaude Opus 4.7 aaa2f3d768 v0.1.10 — Settings TUI layout polish
Banner, state panel, menu header, and menu items now share a single centred
panel column rather than each block centring itself independently. The TUI
feels visibly aligned in a Walker floating window of any width — no more
drifting elements off to the left while the menu floats to the right.

- Adaptive panel width: min(COLS − 6, 60), floored at 40.
- Terminal-width detection: `stty size </dev/tty` first (kernel-reported,
  always reflects the live window) with `tput cols`/80 fallback. Fixes
  off-centre rendering in freshly-spawned floating terminals whose terminfo
  hasn't caught up to the compositor's actual size yet.
- Config-file path renders as `~/...` instead of `/home/<user>/...` so it
  fits the panel.
- Unset resolution shows `<auto>` instead of `?x?` (matches the other unset
  placeholders).

Internally: `pad_block` replaces `center_block`'s widest-line-centres-block
behaviour with a single shared left margin; `cmenu` left-aligns the menu
at the panel edge so the `> ` cursor sits in the same column as the state
panel's section headers above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:01:07 +01:00
28alldayandClaude Opus 4.7 3131dcab8f v0.1.9 — auto-migrate legacy CUSTOM_REFRESH_RATES on installer re-run
Pre-v0.1.8 users have a scalar CUSTOM_REFRESH_RATES=<rate> in their
gamescope-session-plus.conf that triggers the 60 Hz launch bug. v0.1.8
only fixed the TUI write path, so existing users had to re-open the TUI
and re-pick the rate to migrate. Installer now detects the legacy
scalar format and rewrites to the comma list (60,<rate>) on re-run,
then imports the new value into the running systemd user environment.
Idempotent — already-migrated configs are left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:15:24 +01:00
28alldayandClaude Opus 4.7 4fa77a6238 v0.1.8 — fix Gaming Mode 60 Hz launch on NVIDIA + HDMI
Reported by clutchmuffin: TUI saved e.g. CUSTOM_REFRESH_RATES=165 but
Gaming Mode always launched at 60 Hz. Two stacked bugs:

1. Settings TUI wrote ~/.config/environment.d/gamescope-session-plus.conf
   but never reloaded systemd's user env, so the running user manager
   (and therefore gamescope-session-plus@.service) still had the old
   values until next login. Now flush_pending calls
   `systemctl --user import-environment` / `unset-environment` for the
   keys it just touched.

2. CUSTOM_REFRESH_RATES was written as a single scalar. Gamescope's
   --custom-refresh-rates is a list of *switchable* rates, not a
   launch-rate selector — and with no safe 60 Hz fallback in the list,
   some DRM/NVIDIA paths drop to the EDID-preferred 60 Hz on first
   launch. TUI now writes a comma list (e.g. 60,165). show_state and
   confirm_risky_save de-list to the highest member for display /
   validation.

Adds a troubleshooting README entry documenting the Steam BPM
client-side rate persistence as the canonical first-launch workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:11:19 +01:00
28alldayandClaude Opus 4.7 ba86b8c777 v0.1.7 — fix AMD Steam bootstrap, drop redundant deps
Steam was failing to bootstrap on AMD systems during install. The homegrown
`setsid gtk-launch steam` path silently no-op'd when `gtk-launch` wasn't
available (gtk3 isn't a hard dep of `steam` or any AMD-specific package, so
leaner AMD setups never had it; NVIDIA systems usually pulled it in via
nvidia-settings).

Now delegates to `omarchy-install-gaming-steam`, which is the canonical
install + bootstrap path on Omarchy and is exercised on AMD/Intel/NVIDIA
by the upstream Omarchy tests. It's idempotent — `omarchy-pkg-add steam`
no-ops if Steam is already present.

Cleanup pass on dep lists now that the bootstrap is delegated:

- core_deps: drop `steam` (installed by omarchy-install-gaming-steam) and
  `mesa-utils` (glxinfo/glxgears never called by the script).
- gpu_deps: drop `lib32-nvidia-utils`, `lib32-nvidia-580xx-utils`,
  `lib32-vulkan-radeon`, `lib32-vulkan-intel` — all now installed by
  `omarchy-install-gaming-gpu-lib32` (called by omarchy-install-gaming-steam).
- optional_deps: drop `xf86-video-amdgpu` — X11 DDX driver, useless under
  Hyprland (Wayland-only).
- setup_requirements: drop `steam` from required_packages (same reason
  as core_deps).

64-bit GPU drivers (`vulkan-radeon`, `vulkan-intel`, `nvidia-utils`, etc.)
kept because `omarchy-install-gaming-gpu-lib32` only handles the lib32 side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:57:34 +01:00
28alldayandClaude Opus 4.7 e54f45723f docs: README updates for v0.1.4–v0.1.6
- Bump banner version 0.1.3 → 0.1.6.
- Drop the "moving toward distro-portability" / "cross-distro is the
  next direction" notes — DeckShift is Omarchy-only.
- Add What's New entries for v0.1.4 (portal-recovery race fix),
  v0.1.5 (clipboard via Walker restart), v0.1.6 (Omarchy-only cleanup).
- Combine the screen-sharing and clipboard symptoms into a single
  troubleshooting block (same root cause) and replace the old racy
  manual-restart command with `touch /tmp/.deckshift-just-returned
  && /usr/local/bin/deckshift-portal-recovery`, with a note explaining
  why the marker is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:02:51 +01:00
4 changed files with 1226 additions and 125 deletions
+101 -20
View File
@@ -1,15 +1,78 @@
# DeckShift
**Version 0.1.3** — Steam Deck-style gaming mode for Linux + Hyprland. Press `Super+Shift+S` to enter Gaming Mode (Steam Big Picture in Gamescope), `Super+Shift+R` to return to your desktop.
**Version 0.1.9** — Steam Deck-style gaming mode for [Omarchy](https://omarchy.com). Press `Super+Shift+S` to enter Gaming Mode (Steam Big Picture in Gamescope), `Super+Shift+R` to return to your desktop.
Lineage: forked from [Super-Shift-S-Omarchy-Deck-Mode](https://git.no-signal.uk/nosignal/Super-Shift-S-Omarchy-Deck-Mode), briefly renamed Omarchy Deck, then renamed DeckShift as the project moves toward distro-portability.
Lineage: forked from [Super-Shift-S-Omarchy-Deck-Mode](https://git.no-signal.uk/nosignal/Super-Shift-S-Omarchy-Deck-Mode), briefly renamed Omarchy Deck, then renamed DeckShift.
> **Current status**: targets [Omarchy](https://omarchy.com) (Arch + Hyprland + SDDM + iwd). Works on other Arch + Hyprland setups with minor manual tweaks. Cross-distro support (Fedora / openSUSE / Cachy) is the next direction.
> **Target:** [Omarchy](https://omarchy.com) Arch + Hyprland + SDDM + Walker. Experimental DMS/greetd manual session switching is available through `DECKSHIFT_SESSION_BACKEND=dms-greeter-manual`. DeckShift still depends on Omarchy-specific helpers (`omarchy-pkg-add`, `omarchy-restart-walker`, etc.) and is not intended to be cross-distro.
[![DeckShift demo](https://img.youtube.com/vi/nj4pLh3spCs/maxresdefault.jpg)](https://youtu.be/nj4pLh3spCs)
## What's New
### Unreleased — Session backend abstraction
- DeckShift now writes `/etc/deckshift/session-backend.conf` and routes mode selection through `/usr/local/bin/deckshift-session-switch` plus `/usr/local/bin/deckshift-session-transition`.
- `sddm` preserves the existing behavior: update `/etc/sddm.conf.d/zz-gaming-session.conf`, then restart SDDM.
- `dms-greeter-manual` is experimental: it writes DMS greeter memory to select the target session, then terminates the current login session so the user authenticates through DMS. It does not implement one-shot auto-login yet.
### v0.1.13 — Pacman hook keeps gamescope's cap_sys_nice across upgrades
- Linux file capabilities live as an xattr (`security.capability`) on the inode, so every time pacman replaces `/usr/bin/gamescope` during an upgrade the previously-granted `cap_sys_nice=eip` is silently lost. Performance mode keeps "working" but the compositor thread loses its priority boost — worse frame pacing and input latency, with no error surfaced anywhere.
- DeckShift now installs `/usr/share/libalpm/hooks/deckshift-gamescope-cap.hook`, a pacman hook that re-applies `cap_sys_nice=eip` PostTransaction whenever `gamescope` is installed or upgraded. The installer prompts for it the same time it asks for the initial capability grant; if you already consented on a prior install, re-running `./deckshift.sh` adds the hook silently.
- The hook is treated as optional in the verification step, so users who declined performance mode (or declined the cap prompt) won't see a missing-file warning.
### v0.1.12 — Refresh-rate selection actually reaches gamescope now
- **The real bug:** Omarchy installs `gamescope` from Arch's `extra` repo (upstream Valve binary), but the AUR `gamescope-session-git` script (OpenGamingCollective / ex-ChimeraOS fork) was written assuming the ChimeraOS-fork `gamescope-plus` binary that ships `--custom-refresh-rates`. The fork isn't packaged for 64-bit Arch — we can't install it cleanly. The session script feature-detects via `gamescope_has_option "--custom-refresh-rates"`, finds it absent, and **silently drops the `CUSTOM_REFRESH_RATES` value before it reaches gamescope**. Net effect: every refresh-rate selection in the DeckShift TUI since the project began has been a no-op. Gaming Mode has been launching at the EDID-preferred mode (usually 60 Hz) regardless of what the user picked. v0.1.8's "60 Hz fix" was correct on paper but never actually reached the binary on Omarchy.
- **The fix:** `./deckshift.sh` now patches `/usr/share/gamescope-session-plus/gamescope-session-plus` in place, adding an `elif` branch that falls back to `--nested-refresh` (a flag present in every gamescope version) with the highest value from the `CUSTOM_REFRESH_RATES` list as the launch rate. The patch is marked with a `DECKSHIFT-NESTED-REFRESH-FALLBACK` sentinel comment for idempotency, and is re-applied on every install so AUR upgrades that clobber the file don't silently regress refresh-rate handling.
- **What you should do after upgrading:** re-run `./deckshift.sh` once. Future-you, if you ever see Gaming Mode stuck at 60 Hz after a `pacman -Syu` that touched `gamescope-session-git`, just re-run the installer — the patch reapplies cleanly.
### v0.1.11 — Multi-monitor handling: disable an auxiliary monitor before Gaming Mode
- New env var `OUTPUT_CONNECTOR_TO_DISABLE` (single connector or comma list). When set, `switch-to-gaming` runs `hyprctl keyword monitor <conn>,disable` for each listed connector *before* SDDM restart, while Hyprland is still alive. The disable is runtime-only — when the user returns from Gaming Mode, the new Hyprland reads its static config fresh and the monitor comes back automatically.
- Settings TUI exposes this as a **"Hide monitor"** option in the main menu and on the state panel. The picker lists every connected monitor *except* the gaming one, plus a "(clear)" entry to remove the override.
- Fixes a reported issue on multi-monitor setups (e.g. Framework Desktop + LG DualUp + Gigabyte M27Q) where gamescope would either land on the wrong screen or refuse to start when both monitors were attached. The previous workaround was to physically unplug the second monitor.
- Also fixes a latent bug from v0.1.10: the config-file path in the TUI was supposed to render with `~` instead of `/home/<user>` to fit the panel, but bash's tilde-expansion on the replacement side of `${var/#pat/~}` re-expanded `~` back to `$HOME`, making the substitution a no-op. The replacement is now escaped as `\~`.
### v0.1.10 — Settings TUI layout polish
- Banner, state panel, menu header, and menu items now share a single centred panel column rather than each block centring itself independently. The TUI feels visibly aligned in a Walker floating window of any width — no more drifting elements off to the left while the menu floats to the right.
- Panel width is adaptive (`min(terminal 6, 60)`, floored at 40) so the layout looks right from narrow ttys up to fullscreen.
- Terminal-width detection now reads `stty size </dev/tty` first (kernel-reported, always reflects the live window) and only falls back to `tput cols` / `80`. Fixes off-centre rendering in freshly-spawned floating terminals whose terminfo hasn't caught up yet.
- Config-file path now renders with `~` instead of `/home/<user>/…` so it fits the panel.
- Unset resolution shows `<auto>` (matching the other unset placeholders) instead of `?x?`.
### v0.1.9 — Auto-migrate legacy refresh-rate values
- Installer now detects pre-v0.1.8 scalar `CUSTOM_REFRESH_RATES` values (e.g. `165`) and rewrites them to the v0.1.8 comma format (`60,165`), then imports the new value into the running systemd user environment. Re-running `./deckshift.sh` is enough to fix Gaming Mode for users hit by the 60 Hz bug — no need to re-open the Settings TUI and re-pick the rate.
### v0.1.8 — Settings TUI now reaches gamescope without re-login
- The Settings TUI used to write `~/.config/environment.d/gamescope-session-plus.conf` and rely on the user logging out before the change reached `gamescope-session-plus@.service`. Saving the TUI now calls `systemctl --user import-environment` for the keys it just wrote, so the next Gaming Mode launch picks up the new values immediately.
- Refresh-rate writes are now a comma list with `60` as the floor (e.g. `60,165`) rather than a single value. Gamescope's `--custom-refresh-rates` is a list of switchable rates, not a launch-rate selector — keeping `60` in the list guarantees a safe fallback if the high-rate mode isn't enumerated on first launch.
- Fixes a reported regression where Gaming Mode always launched at 60 Hz on NVIDIA + HDMI even though the TUI showed the user's chosen rate.
### v0.1.7 — Foot terminal compatibility
- Internal: confirmed DeckShift Settings TUI works unchanged with Omarchy's new `foot` terminal (in addition to kitty / ghostty / alacritty). No code changes required — Omarchy's stock floating-window rule already lists foot's native class.
### v0.1.6 — Omarchy-only, simpler portal recovery
- Dropped the non-Omarchy fallback in `deckshift-portal-recovery` — DeckShift targets Omarchy only, so the helper now just calls `omarchy-restart-walker` directly.
- Header / docs cleaned up to drop the "cross-distro is the next direction" note.
### v0.1.5 — clipboard recovery after Gaming Mode
- After returning from Gaming Mode, Walker's clipboard listener (`elephant.service`) was still bound to the killed Hyprland's Wayland socket, so paste did nothing and clipboard history was empty.
- `deckshift-portal-recovery` now calls `omarchy-restart-walker` at the end, which restarts `elephant.service` + `app-walker@autostart.service` and reattaches the clipboard to the live compositor.
### v0.1.4 — portal recovery race fix
- The initial `deckshift-portal-recovery` helper restarted all five services (xdg-desktop-portal-hyprland, xdg-desktop-portal, pipewire, pipewire-pulse, wireplumber) simultaneously. That raced — the portals could come up before wireplumber had rebuilt the node graph, leaving the screencast portal bound to nothing.
- Rewritten as a serialised sequence: push live `WAYLAND_DISPLAY`/`XDG_*` env into systemd-user + D-Bus activation env → stop portals → SIGTERM/SIGKILL stragglers → restart pipewire stack → wait → start portals.
- Thanks to the user on the issue tracker who diagnosed the race and supplied the env-update + serialised-restart sequence.
### v0.1.3 — power-state save/restore + reliable exit
- **Saves your real pre-Gaming-Mode state** (CPU governor + power profile) on entry to `~/.cache/deckshift/saved-state` and restores those exact values on exit. No more guessing `powersave`/`balanced`.
@@ -60,11 +123,11 @@ This installer transforms your desktop into a dual-mode system:
- **Desktop Mode** — your normal Hyprland session.
- **Gaming Mode** — full-screen Steam Big Picture running inside Gamescope (the same compositor used by the Steam Deck), with automatic performance tuning, controller support, and external drive mounting.
Switching between modes is seamless — SDDM handles session transitions, and your network, audio, and peripherals carry over automatically.
Switching between modes is handled by a session backend. SDDM remains the default and preserves the seamless auto-login flow. The experimental DMS greeter backend preselects the next session and returns to the greeter for manual authentication.
## Requirements
- **OS**: [Omarchy](https://omarchy.com) (Arch Linux + Hyprland + SDDM)
- **OS**: [Omarchy](https://omarchy.com) (Arch Linux + Hyprland + SDDM), or an experimental DMS/greetd setup for manual session switching
- **GPU**: AMD (discrete or APU), NVIDIA (discrete), or Intel (Arc / Iris Xe), or any hybrid combo of the above
- Intel Arc (Alchemist, Battlemage): well-supported
- Tiger Lake / Alder Lake Iris Xe: playable for indies / older AAA
@@ -72,7 +135,7 @@ Switching between modes is seamless — SDDM handles session transitions, and yo
- Hybrid laptops (NVIDIA + iGPU, AMD dGPU + iGPU): use the corresponding `[hybrid-*]` GPU mode in the Settings TUI
- **AUR Helper**: yay or paru (for ChimeraOS session packages)
> **Note**: This script targets Omarchy and its stack (Hyprland, SDDM, iwd, UWSM, PipeWire). It works on other Arch + Hyprland setups with light tweaks, but isn't tested there.
> **Note**: This script targets Omarchy and its stack (Hyprland, SDDM, iwd, UWSM, PipeWire). The DMS/greetd backend is experimental and currently manual-only.
## Quick Start
@@ -177,9 +240,11 @@ Package installs use Omarchy's `omarchy-pkg-add` (idempotent, double-checks pacm
| Path | Purpose |
|---|---|
| `/usr/local/bin/switch-to-gaming` | Hyprland → Gaming Mode |
| `/usr/local/bin/switch-to-desktop` | Gaming Mode → Hyprland (synchronous power-state restore + atomic SDDM restart) |
| `/usr/local/bin/switch-to-desktop` | Gaming Mode → Hyprland (synchronous power-state restore + backend transition) |
| `/usr/local/bin/gamescope-session-nm-wrapper` | Main session wrapper (performance mode, NM, drive mounting, saves pre-Gaming-Mode state) |
| `/usr/local/bin/gaming-session-switch` | Helper that toggles SDDM autologin between Hyprland and Gamescope |
| `/usr/local/bin/deckshift-session-switch` | Backend-neutral helper that selects desktop or gaming session |
| `/usr/local/bin/deckshift-session-transition` | Backend-neutral helper that restarts SDDM or exits to DMS greeter |
| `/usr/local/bin/gaming-session-switch` | Compatibility wrapper for `deckshift-session-switch` |
| `/usr/local/bin/gaming-keybind-monitor` | Python evdev daemon catching `Super+Shift+R` inside Gamescope |
| `/usr/lib/os-session-select` | Handler for Steam's "Exit to Desktop" button |
| `/usr/local/lib/gamescope-nvidia/gamescope` | NVIDIA wrapper that adds `--force-composition` |
@@ -200,8 +265,10 @@ Package installs use Omarchy's `omarchy-pkg-add` (idempotent, double-checks pacm
#### Session & Display Manager
| Path | Purpose |
|---|---|
| `/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop` | SDDM session entry for Gaming Mode |
| `/etc/sddm.conf.d/zz-gaming-session.conf` | SDDM autologin session switching config |
| `/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop` | Wayland session entry for Gaming Mode |
| `/etc/deckshift/session-backend.conf` | Active session backend and session IDs |
| `/etc/sddm.conf.d/zz-gaming-session.conf` | SDDM autologin session switching config when using the SDDM backend |
| `/var/cache/dms-greeter/.local/state/memory.json` | DMS greeter memory updated when using the DMS manual backend |
#### Permissions & Security
| Path | Purpose |
@@ -243,8 +310,8 @@ Desktop Mode (Hyprland)
├─ Super+Shift+S pressed
│ └─ switch-to-gaming runs:
│ ├─ Masks suspend targets (prevents sleep during switch)
│ ├─ Updates SDDM config to gaming session
│ └─ Restarts SDDM → boots into Gaming Mode
│ ├─ Selects the gaming session through deckshift-session-switch
│ └─ Runs deckshift-session-transition
Gaming Mode (Gamescope + Steam Big Picture)
@@ -263,8 +330,8 @@ Gaming Mode (Gamescope + Steam Big Picture)
│ ├─ Restores Bluetooth
│ ├─ Shuts down Steam gracefully
│ ├─ Kills gamescope
│ ├─ Updates SDDM config to Hyprland session
│ └─ Atomic systemctl restart sddm → boots into Desktop Mode
│ ├─ Selects the desktop session through deckshift-session-switch
│ └─ Runs deckshift-session-transition
└─ On session cleanup (trap handler — backup path):
├─ Kills steam-library-mount and keybind-monitor
@@ -431,17 +498,17 @@ See [Recovery from a Black Screen](#recovery-from-a-black-screen) for how to get
- Check PipeWire config exists: `cat /etc/pipewire/pipewire.conf.d/10-gaming-latency.conf`
- Try lower quantum: edit the config and set `default.clock.min-quantum = 128`
**Screen sharing in Chromium / Firefox is broken after returning from Gaming Mode (only "Share a tab" works)**
**Screen sharing in Chromium / Firefox is broken after returning from Gaming Mode (only "Share a tab" works) — and/or clipboard is dead**
`xdg-desktop-portal-hyprland` is bound to the killed Hyprland instance — tab capture works because it bypasses the portal, but desktop/window capture goes through it and fails silently.
Both symptoms have the same root cause: `xdg-desktop-portal-hyprland` and Walker's `elephant.service` (the clipboard listener) are still bound to the killed Hyprland instance after the SDDM restart. Tab capture in Chromium works because it bypasses the portal entirely. Clipboard listening, screen capture, and window capture all go through services that need to be reattached to the live compositor.
DeckShift handles this automatically via `/usr/local/bin/deckshift-portal-recovery` (autostarted from `~/.config/hypr/autostart.conf`). If you installed before this fix or the autostart hook didn't get added, run:
DeckShift handles this automatically via `/usr/local/bin/deckshift-portal-recovery` (autostarted from `~/.config/hypr/autostart.conf`). If you installed before this fix, re-run `./deckshift.sh` to install the helper, or run the recovery manually:
```bash
systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal pipewire pipewire-pulse wireplumber
touch /tmp/.deckshift-just-returned && /usr/local/bin/deckshift-portal-recovery
```
then re-open the browser tab.
then re-open the browser tab. (The `touch` is needed because the helper is a no-op without the marker file — that's deliberate, so it doesn't bounce portals on every normal login.)
**Suspend fails with "Access denied" after returning from Gaming Mode**
@@ -461,6 +528,18 @@ If you're on AC and using Omarchy, this is expected — see the *Performance Mod
- Older Gen8/9 Intel iGPUs (Skylake, Kaby Lake) struggle with Vulkan workloads. Lower the launch resolution via the Settings TUI (`deckshift-settings`) — 720p / 1080p makes a big difference.
- If you have a discrete GPU that should take over, check its driver is loaded: `lspci -k | grep -A2 VGA`
**Gaming Mode launches at 60 Hz even though I picked a higher rate in the TUI**
`--custom-refresh-rates` is gamescope's list of *switchable* rates, not a launch-rate selector. On embedded/DRM output (especially NVIDIA + HDMI) gamescope picks the connector's EDID-preferred mode at first launch, which is usually 60 Hz even when higher modes are enumerated. Two-step fix:
1. Confirm the env var actually reached the session:
```bash
systemctl --user show-environment | grep REFRESH
journalctl --user -u "gamescope-session-plus@*" -b --no-pager | grep -m1 -- '--custom-refresh-rates'
```
In v0.1.8+ this should work without re-login — the Settings TUI now calls `systemctl --user import-environment` on save. If you're on an older release, log out and back in once after saving in the TUI.
2. Once Steam Big Picture is up, set the rate explicitly: Settings → Display → Refresh Rate → your rate. Steam persists this client-side, so every subsequent Gaming Mode launch will go straight to that rate.
### Log Locations
| Component | Command |
@@ -484,7 +563,8 @@ sudo pkill -f steam-library-mount
# Remove scripts
sudo rm -f /usr/local/bin/{switch-to-gaming,switch-to-desktop,gamescope-session-nm-wrapper,\
gaming-session-switch,gaming-keybind-monitor,gamescope-nm-start,gamescope-nm-stop,\
deckshift-session-switch,deckshift-session-transition,gaming-session-switch,\
gaming-keybind-monitor,gamescope-nm-start,gamescope-nm-stop,\
steam-library-mount,deckshift-settings}
sudo rm -f /usr/lib/os-session-select
sudo rm -rf /usr/local/lib/gamescope-nvidia
@@ -503,6 +583,7 @@ sudo rm -f /etc/udev/rules.d/99-gaming-performance.rules
sudo rm -f /etc/security/limits.d/99-gaming-memlock.conf
# Remove configs
sudo rm -rf /etc/deckshift
sudo rm -f /etc/sddm.conf.d/zz-gaming-session.conf
sudo rm -f /etc/environment.d/99-shader-cache.conf
sudo rm -f /etc/environment.d/90-nvidia-gamescope.conf
+198 -24
View File
@@ -48,6 +48,80 @@ command -v lspci >/dev/null || die "lspci is required"
command -v jq >/dev/null || die "jq is required (install with: omarchy-pkg-add jq)"
command -v hyprctl >/dev/null || die "hyprctl is required (this TUI is for Hyprland sessions)"
# ------------------------------------------------------------------------------
# Layout — everything renders inside a single centred "panel" column so the
# banner, state, menu, and toasts all share a left edge regardless of terminal
# width. PANEL_WIDTH is adaptive: target 60 cols, capped at the terminal width
# minus margin, floored so it stays usable on narrow windows.
# ------------------------------------------------------------------------------
# Live terminal width — refreshed each main loop iteration so a window resize
# is picked up between menus. Reads via `stty size` first (kernel-reported,
# always reflects the live window) because `tput cols` in a freshly-spawned
# floating terminal sometimes returns the terminfo default (80) before the
# compositor has applied its size.
#
# Every `$(...)` here ends with `|| true` because under `set -eo pipefail` a
# failing pipeline inside a command substitution propagates out and trips -e
# on the enclosing assignment.
COLS=80
PANEL_WIDTH=60
LEFT_MARGIN=0
LEFT_PAD=""
refresh_cols() {
local raw
raw=$(stty size 2>/dev/null </dev/tty || true)
if [[ "$raw" =~ ^[0-9]+[[:space:]]+([0-9]+) ]] && (( BASH_REMATCH[1] > 0 )); then
COLS=${BASH_REMATCH[1]}
else
raw=$(tput cols 2>/dev/null || true)
if [[ "$raw" =~ ^[0-9]+$ ]] && (( raw > 0 )); then
COLS=$raw
else
COLS=80
fi
fi
PANEL_WIDTH=60
(( PANEL_WIDTH > COLS - 6 )) && PANEL_WIDTH=$(( COLS - 6 ))
(( PANEL_WIDTH < 40 )) && PANEL_WIDTH=40
LEFT_MARGIN=$(( (COLS - PANEL_WIDTH) / 2 ))
(( LEFT_MARGIN < 0 )) && LEFT_MARGIN=0
LEFT_PAD=""
(( LEFT_MARGIN > 0 )) && printf -v LEFT_PAD '%*s' "$LEFT_MARGIN" ''
}
# Prepend N spaces to every line of stdin. Used to position blocks at the
# panel's left edge so banner, state, menu, and toasts share one column.
pad_block() {
local n="${1:-$LEFT_MARGIN}"
local pad=""
(( n > 0 )) && printf -v pad '%*s' "$n" ''
local line
while IFS= read -r line; do
printf '%s%s\n' "$pad" "$line"
done
}
# Centred wrapper around gum choose: positions the menu horizontally so it
# sits inside the panel column. gum draws at column 0 with no alignment flag,
# but it reserves the cursor string's width as the gutter for unselected rows,
# so baking padding into the cursor prefix shifts the whole rendered block.
# Header is padded separately so it sits above the items at the same offset.
#
# Usage: cmenu "Header text" "Item 1" "Item 2" ...
# Cancellation (Esc/Ctrl-C) returns empty just like gchoose.
cmenu() {
refresh_cols
local header="$1"; shift
# Left-align the menu at the panel's left edge so the "> " cursor and item
# labels sit in the same column as the state panel's section headers and
# rows above. Centering items within the panel would put them in the middle
# of the panel — visually disconnected from the state panel column.
local pad="$LEFT_PAD"
gchoose --cursor "${pad}> " --header "${pad}${header}" "$@"
}
# ------------------------------------------------------------------------------
# Conf helpers — read straight from disk; writes go through pending_* below so
# nothing hits the file until the user explicitly saves.
@@ -109,6 +183,19 @@ flush_pending() {
echo "${key}=${value}" >> "$CONF"
fi
done
# environment.d/*.conf is parsed by `systemd --user` at user-manager startup,
# so edits made here don't reach gamescope-session-plus@.service until the
# next login unless we nudge systemd. import-environment pulls the keys we
# just wrote into the running user manager scope so the next Gaming Mode
# launch sees them without a re-login.
if [[ ${#PENDING_SET[@]} -gt 0 ]]; then
# shellcheck disable=SC2046
systemctl --user import-environment $(printf '%s ' "${!PENDING_SET[@]}") 2>/dev/null || true
fi
if [[ ${#PENDING_UNSET[@]} -gt 0 ]]; then
systemctl --user unset-environment "${!PENDING_UNSET[@]}" 2>/dev/null || true
fi
}
# ------------------------------------------------------------------------------
@@ -220,15 +307,22 @@ list_gpus() {
# ------------------------------------------------------------------------------
show_state() {
local connector width height refresh vk_adapter dri_prime prime_offload mesa_vk_select
local connector width height refresh vk_adapter dri_prime prime_offload mesa_vk_select disable_connector
connector=$(effective OUTPUT_CONNECTOR)
width=$(effective SCREEN_WIDTH)
height=$(effective SCREEN_HEIGHT)
refresh=$(effective CUSTOM_REFRESH_RATES)
# Stored as a comma list (e.g. "60,165") so gamescope has a safe 60 Hz
# fallback. The user only cares about the rate they picked — the highest
# member of the list.
if [[ "$refresh" == *,* ]]; then
refresh=$(tr ',' '\n' <<<"$refresh" | sort -nr | head -1)
fi
vk_adapter=$(effective VULKAN_ADAPTER)
dri_prime=$(effective DRI_PRIME)
prime_offload=$(effective __NV_PRIME_RENDER_OFFLOAD)
mesa_vk_select=$(effective MESA_VK_DEVICE_SELECT)
disable_connector=$(effective OUTPUT_CONNECTOR_TO_DISABLE)
# Single GPU-mode line — shows the active mode rather than half-empty rows,
# since the modes are mutually exclusive. AMD hybrid is identified by both
@@ -253,15 +347,26 @@ show_state() {
monitor_label="${monitor_label} (max ${HYPR_NATIVE} @ ${HYPR_MAX_REFRESH:-?}Hz)"
fi
# Build resolution label so an unset value renders as "<auto>" (matching the
# other unset placeholders) rather than "?x?".
local resolution_label="<auto>"
if [[ -n "$width" && -n "$height" ]]; then
resolution_label="${width}x${height}"
fi
# Replace $HOME with ~ so the config path fits the panel column.
local conf_display="${CONF/#$HOME/\~}"
cat <<EOF
Gaming Mode display settings${pending_label}:
Gaming Mode display settings${pending_label}
Monitor : ${monitor_label}
Resolution : ${width:-?}x${height:-?}
Resolution : ${resolution_label}
Refresh rate : ${refresh:-<auto>} Hz
GPU mode : ${gpu_mode}
Hide monitor : ${disable_connector:-<none>}
Config file : ${CONF}
Config file : ${conf_display}
EOF
}
@@ -284,7 +389,7 @@ choose_monitor() {
pending_set OUTPUT_CONNECTOR "$connector"
return 0
fi
choice=$(printf '%s\n' "${labels[@]}" | gchoose --header "Select monitor for Gaming Mode")
choice=$(cmenu "Select monitor for Gaming Mode" "${labels[@]}")
[[ -z "$choice" ]] && return 0
if [[ "$choice" == "(clear"* ]]; then
pending_unset OUTPUT_CONNECTOR
@@ -293,6 +398,49 @@ choose_monitor() {
fi
}
# choose_monitor_to_disable — picks a connector to physically disable (via
# `hyprctl keyword monitor X,disable`) before Gaming Mode launches.
#
# Why this exists: with multiple monitors connected, gamescope-session-plus
# can't reliably target a single output via OUTPUT_CONNECTOR alone — on some
# setups it picks the wrong screen, on others it fails to start at all
# (reported on a Framework Desktop + LG DualUp + Gigabyte M27Q setup).
# Disabling the auxiliary monitor right before SDDM restart guarantees
# gamescope only sees the gaming display.
#
# The disable is runtime-only (hyprctl keyword, not a config edit), so when
# the user returns from Gaming Mode the fresh Hyprland reads its static
# config and the monitor comes back automatically — no re-enable step needed.
choose_monitor_to_disable() {
local choice gaming_monitor
gaming_monitor=$(effective OUTPUT_CONNECTOR)
mapfile -t connected < <(list_connected_monitors)
local -a labels=()
local entry conn
for entry in "${connected[@]}"; do
conn="${entry%%|*}"
# Exclude the gaming monitor — disabling it would be self-defeating.
[[ "$conn" == "$gaming_monitor" ]] && continue
labels+=("$entry")
done
labels+=("(clear / don't hide any monitor)")
if (( ${#labels[@]} == 1 )); then
gum confirm "Only the gaming monitor is connected. Set a connector to disable manually?" || return 0
local connector
connector=$(ginput --prompt "Connector to disable (e.g. HDMI-A-1): ")
[[ -z "$connector" ]] && return 0
pending_set OUTPUT_CONNECTOR_TO_DISABLE "$connector"
return 0
fi
choice=$(cmenu "Select monitor to disable while gaming" "${labels[@]}")
[[ -z "$choice" ]] && return 0
if [[ "$choice" == "(clear"* ]]; then
pending_unset OUTPUT_CONNECTOR_TO_DISABLE
else
pending_set OUTPUT_CONNECTOR_TO_DISABLE "${choice%%|*}"
fi
}
choose_resolution() {
refresh_monitor_data
local choice w h
@@ -322,7 +470,7 @@ choose_resolution() {
options+=("Custom…")
choice=$(printf '%s\n' "${options[@]}" | gchoose --header "Select launch resolution (max: ${HYPR_NATIVE:-unknown})")
choice=$(cmenu "Select launch resolution (max: ${HYPR_NATIVE:-unknown})" "${options[@]}")
[[ -z "$choice" ]] && return 0
if [[ "$choice" == "Custom…" ]]; then
w=$(ginput --prompt "Width: " --placeholder "2560")
@@ -377,7 +525,7 @@ choose_refresh_rate() {
options+=("Custom…")
choice=$(printf '%s\n' "${options[@]}" | gchoose --header "Select refresh rate (Hz, max: ${HYPR_MAX_REFRESH:-unknown})")
choice=$(cmenu "Select refresh rate (Hz, max: ${HYPR_MAX_REFRESH:-unknown})" "${options[@]}")
[[ -z "$choice" ]] && return 0
if [[ "$choice" == "Custom…" ]]; then
rate=$(ginput --prompt "Rate (Hz): " --placeholder "144")
@@ -385,7 +533,17 @@ choose_refresh_rate() {
rate=${choice%% *}
fi
[[ -z "$rate" ]] && return 0
pending_set CUSTOM_REFRESH_RATES "$rate"
# gamescope-session-plus passes this env value to gamescope as
# --custom-refresh-rates, which is a list of *switchable* rates the user can
# cycle between in Steam Big Picture. Writing a single value (e.g. "165") can
# leave gamescope without a safe 60 Hz fallback if the monitor's EDID
# preferred mode is 60 Hz and the high-rate mode isn't enumerated on first
# launch — most visibly on NVIDIA HDMI outputs. Always include 60 as the
# floor so Steam can fall back and the user can switch up to their pick.
local rate_list="$rate"
[[ "$rate" != "60" ]] && rate_list="60,${rate}"
pending_set CUSTOM_REFRESH_RATES "$rate_list"
}
# Keys this menu owns. Cleared at the start of every selection so switching
@@ -444,7 +602,7 @@ choose_gpu() {
done
labels+=("(clear GPU override — let system decide)")
choice=$(printf '%s\n' "${labels[@]}" | gchoose --header "Select GPU for Gaming Mode")
choice=$(cmenu "Select GPU for Gaming Mode" "${labels[@]}")
[[ -z "$choice" ]] && return 0
# Wipe every GPU-mode key first; each branch sets only what it needs.
@@ -471,7 +629,7 @@ choose_gpu() {
esac
done
local t_choice t_entry=""
t_choice=$(printf '%s\n' "${t_labels[@]}" | gchoose --header "Pick dGPU (render target) — usually the discrete one")
t_choice=$(cmenu "Pick dGPU (render target) — usually the discrete one" "${t_labels[@]}")
[[ -z "$t_choice" ]] && return 0
local i
for ((i=0; i<${#t_labels[@]}; i++)); do
@@ -526,6 +684,11 @@ confirm_risky_save() {
w=$(effective SCREEN_WIDTH)
h=$(effective SCREEN_HEIGHT)
rate=$(effective CUSTOM_REFRESH_RATES)
# Check the user's actual pick (highest of the comma list) against monitor
# support — not the bare list, which won't match a single mode line.
if [[ "$rate" == *,* ]]; then
rate=$(tr ',' '\n' <<<"$rate" | sort -nr | head -1)
fi
local -a warnings=()
if [[ -n "$w" && -n "$h" ]] && ! resolution_supported "$w" "$h"; then
@@ -537,12 +700,14 @@ confirm_risky_save() {
(( ${#warnings[@]} == 0 )) && return 0
refresh_cols
clear
gum style --foreground 196 --bold "Warning — selected values may not work:"
echo ""
printf ' %s\n' "${warnings[@]}"
gum style --foreground 196 --bold "Warning — selected values may not work:" | pad_block
echo ""
gum style --foreground 244 "If Gaming Mode shows a black screen, press Super+Shift+R to return to desktop."
printf ' %s\n' "${warnings[@]}" | pad_block
echo ""
gum style --foreground 244 "If Gaming Mode shows a black screen, press Super+Shift+R to return to desktop." | pad_block
echo ""
gum confirm "Save anyway?" --default=false
}
@@ -553,12 +718,17 @@ confirm_risky_save() {
main() {
while true; do
refresh_cols
refresh_monitor_data
clear
echo ""
gum style \
--border double --margin "1" --padding "1 4" --border-foreground 212 \
"DECKSHIFT — Gaming Mode Settings"
show_state
--border double --padding "1 0" --border-foreground 212 \
--width "$PANEL_WIDTH" --align center \
"DECKSHIFT — Gaming Mode Settings" \
| pad_block
echo ""
show_state | pad_block
echo ""
local save_label="Save and exit"
@@ -569,26 +739,29 @@ main() {
fi
local action
action=$(gchoose --header "What do you want to change?" \
action=$(cmenu "What do you want to change?" \
"Monitor" \
"Resolution" \
"Refresh rate" \
"GPU" \
"Hide monitor" \
"$save_label" \
"$cancel_label")
case "$action" in
"Monitor") choose_monitor ;;
"Resolution") choose_resolution ;;
"Refresh rate") choose_refresh_rate ;;
"GPU") choose_gpu ;;
"Monitor") choose_monitor ;;
"Resolution") choose_resolution ;;
"Refresh rate") choose_refresh_rate ;;
"GPU") choose_gpu ;;
"Hide monitor") choose_monitor_to_disable ;;
"Save and exit"*)
if ! confirm_risky_save; then
continue
fi
flush_pending
clear
gum style --foreground 212 "Settings saved to $CONF"
gum style --foreground 244 "Changes apply next time you enter Gaming Mode (Super+Shift+S)."
echo ""
gum style --foreground 212 "Settings saved to ${CONF/#$HOME/\~}" | pad_block
gum style --foreground 244 "Changes apply next time you enter Gaming Mode (Super+Shift+S)." | pad_block
sleep 1
return 0
;;
@@ -597,7 +770,8 @@ main() {
gum confirm "Discard unsaved changes?" --default=true || continue
fi
clear
gum style --foreground 244 "No changes saved."
echo ""
gum style --foreground 244 "No changes saved." | pad_block
sleep 1
return 0
;;
+504 -81
View File
@@ -34,7 +34,7 @@ set -Euo pipefail
# -u: Treat unset variables as errors (catches typos in variable names)
# -o pipefail: A pipeline fails if ANY command in it fails, not just the last one
DECKSHIFT_VERSION="0.1.6"
DECKSHIFT_VERSION="0.1.13"
# Resolve the directory this script lives in so we can find sibling files like
# bin/deckshift-settings and applications/deckshift-settings.desktop when
@@ -48,6 +48,9 @@ CONFIG_FILE="/etc/gaming-mode.conf"
[[ -f "$HOME/.gaming-mode.conf" ]] && CONFIG_FILE="$HOME/.gaming-mode.conf"
source "$CONFIG_FILE" 2>/dev/null || true
: "${PERFORMANCE_MODE:=enabled}"
: "${DECKSHIFT_SESSION_BACKEND:=auto}"
: "${DECKSHIFT_DESKTOP_SESSION_ID:=hyprland-uwsm.desktop}"
: "${DECKSHIFT_GAMING_SESSION_ID:=gamescope-session-steam-nm.desktop}"
# Flags that track whether the user needs to reboot or re-login after setup.
# Various steps set these to 1 when they make changes that only take effect
@@ -493,12 +496,10 @@ check_steam_dependencies() {
local -a optional_deps=()
local -a core_deps=(
"steam"
"lib32-vulkan-icd-loader"
"vulkan-icd-loader"
"lib32-mesa"
"mesa"
"mesa-utils"
"lib32-glibc"
"lib32-gcc-libs"
"lib32-libx11"
@@ -547,7 +548,6 @@ check_steam_dependencies() {
info "NVIDIA driver branch: modern (nvidia-utils)"
gpu_deps+=(
"nvidia-utils"
"lib32-nvidia-utils"
"nvidia-settings"
"libva-nvidia-driver"
)
@@ -559,7 +559,6 @@ check_steam_dependencies() {
info "NVIDIA driver branch: legacy 580xx (Maxwell/Pascal/Volta)"
gpu_deps+=(
"nvidia-580xx-utils"
"lib32-nvidia-580xx-utils"
"nvidia-settings"
"libva-nvidia-driver"
)
@@ -571,7 +570,6 @@ check_steam_dependencies() {
info "NVIDIA detected but driver branch unrecognised; defaulting to modern (nvidia-utils)"
gpu_deps+=(
"nvidia-utils"
"lib32-nvidia-utils"
"nvidia-settings"
"libva-nvidia-driver"
)
@@ -581,17 +579,14 @@ check_steam_dependencies() {
if $has_amd; then
gpu_deps+=(
"vulkan-radeon"
"lib32-vulkan-radeon"
"libvdpau"
"lib32-libvdpau"
)
! check_package "xf86-video-amdgpu" && optional_deps+=("xf86-video-amdgpu")
fi
if $has_intel; then
gpu_deps+=(
"vulkan-intel"
"lib32-vulkan-intel"
"intel-media-driver"
)
fi
@@ -778,14 +773,15 @@ check_steam_dependencies() {
check_steam_config
# Bootstrap Steam — launches it in the background so its first-run client
# update happens in parallel with the rest of the install. Same pattern as
# omarchy-install-gaming-steam. Skipped silently if Steam isn't installed
# (e.g. user declined to install missing required deps).
if check_package steam && command -v gtk-launch >/dev/null 2>&1; then
info "Launching Steam to complete its first-run download..."
setsid gtk-launch steam >/dev/null 2>&1 < /dev/null &
disown 2>/dev/null || true
# Bootstrap Steam via Omarchy's installer — idempotent and the canonical
# path on AMD/Intel/NVIDIA. Handles lib32 GPU drivers + setsid gtk-launch
# in one shot; previous homegrown gtk-launch call silently no-op'd on
# AMD systems that lacked gtk3.
if command -v omarchy-install-gaming-steam >/dev/null 2>&1; then
info "Bootstrapping Steam via omarchy-install-gaming-steam..."
omarchy-install-gaming-steam || warn "omarchy-install-gaming-steam returned non-zero"
else
warn "omarchy-install-gaming-steam not found — skipping Steam bootstrap"
fi
}
@@ -1144,7 +1140,7 @@ restart_elephant_walker() {
# After installing packages, it also runs the sub-setup functions for
# performance permissions, shader cache, and gamescope capabilities.
setup_requirements() {
local -a required_packages=("steam" "gamescope" "mangohud" "python" "python-evdev" "libcap" "gamemode" "curl" "pciutils" "ntfs-3g" "xcb-util-cursor")
local -a required_packages=("gamescope" "mangohud" "python" "python-evdev" "libcap" "gamemode" "curl" "pciutils" "ntfs-3g" "xcb-util-cursor")
local -a packages_to_install=()
for pkg in "${required_packages[@]}"; do
check_package "$pkg" || packages_to_install+=("$pkg")
@@ -1169,24 +1165,64 @@ setup_requirements() {
configure_elephant_launcher
if [[ "${PERFORMANCE_MODE,,}" == "enabled" ]] && command -v gamescope >/dev/null 2>&1; then
if ! getcap "$(command -v gamescope)" 2>/dev/null | grep -q 'cap_sys_nice'; then
local hook_path="/usr/share/libalpm/hooks/deckshift-gamescope-cap.hook"
local needs_cap=false
getcap "$(command -v gamescope)" 2>/dev/null | grep -q 'cap_sys_nice' || needs_cap=true
if $needs_cap || ! sudo test -f "$hook_path"; then
echo ""
echo "================================================================"
echo " GAMESCOPE CAPABILITY REQUEST"
echo "================================================================"
echo ""
echo " Performance mode requires granting cap_sys_nice to gamescope."
echo " Performance mode needs cap_sys_nice on gamescope (better frame"
echo " pacing + lower input latency). Pacman strips file capabilities"
echo " on every gamescope upgrade, so DeckShift also installs a pacman"
echo " hook that re-applies the cap automatically post-upgrade."
echo ""
read -p "Grant cap_sys_nice to gamescope? [Y/n]: " -n 1 -r
read -p "Grant cap_sys_nice + install pacman hook? [Y/n]: " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
sudo setcap 'cap_sys_nice=eip' "$(command -v gamescope)" || warn "Failed to set capability"
info "Capability granted to gamescope"
if $needs_cap; then
sudo setcap 'cap_sys_nice=eip' "$(command -v gamescope)" || warn "Failed to set capability"
info "Capability granted to gamescope"
fi
install_gamescope_cap_hook
fi
else
install_gamescope_cap_hook
fi
fi
}
# Pacman strips file capabilities (security.capability xattr) every time it
# replaces the gamescope binary on upgrade. Without cap_sys_nice the
# compositor thread loses its priority boost and performance mode silently
# regresses (no error surfaced). This hook re-applies the cap PostTransaction
# whenever gamescope is installed or upgraded. Idempotent — safe to re-run.
install_gamescope_cap_hook() {
local hook_path="/usr/share/libalpm/hooks/deckshift-gamescope-cap.hook"
sudo install -d -m 755 /usr/share/libalpm/hooks
sudo tee "$hook_path" > /dev/null << 'HOOK'
# Managed by DeckShift — DO NOT EDIT.
# Re-applies cap_sys_nice to gamescope after every pacman upgrade. File
# capabilities live on the inode as a security.capability xattr and are lost
# when pacman replaces the binary.
[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Target = usr/bin/gamescope
[Action]
Description = DeckShift: re-applying cap_sys_nice to gamescope
When = PostTransaction
Exec = /usr/bin/setcap cap_sys_nice=eip /usr/bin/gamescope
HOOK
sudo chmod 644 "$hook_path"
info "Installed pacman hook: $hook_path"
}
# Optional: install Bluetooth Xbox controller support (xpadneo).
# Wired Xbox controllers work without this via the kernel's xpad driver.
# xpadneo-dkms gives proper button mapping and rumble for wireless controllers
@@ -1288,10 +1324,10 @@ setup_settings_tui() {
# to seamlessly switch between Desktop Mode (Hyprland) and Gaming Mode
# (Gamescope + Steam Big Picture).
#
# The switching mechanism works through SDDM (the display/login manager):
# The switching mechanism works through a session backend:
# 1. User presses Super+Shift+S in Hyprland
# 2. switch-to-gaming script updates SDDM config to point to Gaming session
# 3. SDDM restarts and auto-logs into the Gaming Mode session
# 2. switch-to-gaming selects the Gaming Mode session
# 3. The backend transitions through SDDM or the configured greeter
# 4. gamescope-session-nm-wrapper starts performance tuning, NetworkManager,
# drive mounting, keybind monitor, then launches Gamescope + Steam
# 5. When done (Super+Shift+R or Steam > Exit to Desktop), the reverse happens
@@ -1302,13 +1338,146 @@ setup_settings_tui() {
# - Keybind monitor (Python daemon using evdev for Super+Shift+R)
# - NetworkManager start/stop scripts (iwd <-> NM handoff)
# - Steam library auto-mount daemon
# - SDDM session entry and config
# - Session entry, backend config, and display-manager helpers
# - Polkit and sudoers rules for passwordless operation
# - Hyprland keybind for Super+Shift+S
#
# It also installs ChimeraOS's gamescope-session packages from AUR, which
# provide the base session framework that the Steam Deck uses.
# ==============================================================================
# Patch the installed gamescope-session-plus script to add a --nested-refresh
# fallback for CUSTOM_REFRESH_RATES.
#
# Why this exists:
# - DeckShift installs `gamescope` from Arch's `extra` repo (upstream Valve
# binary). That binary does NOT have `--custom-refresh-rates` — the flag is
# a ChimeraOS-fork (gamescope-plus) addition that never landed upstream.
# - The OpenGamingCollective (ex-ChimeraOS) `gamescope-session-plus` script
# we install from AUR was written assuming gamescope-plus. It feature-
# detects via `gamescope_has_option "--custom-refresh-rates"` and silently
# drops the value when the flag is missing. Result: CUSTOM_REFRESH_RATES
# reaches the script but never reaches gamescope, and Gaming Mode launches
# at the EDID-preferred rate (usually 60 Hz) regardless of TUI selection.
# - This patch adds an `elif` branch that falls back to `--nested-refresh`
# (the older flag that exists in every gamescope version) with the highest
# rate from the comma list as the launch rate.
#
# Idempotent: the patched line carries a `DECKSHIFT-NESTED-REFRESH-FALLBACK`
# marker so re-runs detect "already patched" and skip. Re-applied on every
# ./deckshift.sh run so pacman/AUR upgrades that clobber the script don't
# silently regress refresh-rate handling.
patch_gamescope_session_plus() {
local gsp="/usr/share/gamescope-session-plus/gamescope-session-plus"
if [[ ! -f "$gsp" ]]; then
warn "$gsp not found — skipping refresh-rate fallback patch"
return 0
fi
if grep -q "DECKSHIFT-NESTED-REFRESH-FALLBACK" "$gsp" 2>/dev/null; then
info "gamescope-session-plus already has DeckShift refresh-rate fallback"
return 0
fi
info "Patching gamescope-session-plus to add --nested-refresh fallback..."
local tmp
tmp=$(mktemp)
if ! python3 - "$gsp" "$tmp" <<'PY'
import re, sys
src, dst = sys.argv[1], sys.argv[2]
with open(src) as f:
content = f.read()
# Match the 4-line block exactly as it ships in gamescope-session-git r339.
# Group 1 = leading 3 lines (kept), group 2 = closing `fi` (kept). The new
# elif+body lines are inserted between them.
pattern = re.compile(
r'(\tCUSTOM_REFRESH_RATES_OPTION=""\n'
r'\tif \[ -n "\$CUSTOM_REFRESH_RATES" \] && gamescope_has_option "--custom-refresh-rates"; then\n'
r'\t\tCUSTOM_REFRESH_RATES_OPTION="--custom-refresh-rates \$CUSTOM_REFRESH_RATES"\n'
r')(\tfi\n)'
)
# Use a function as the replacement so re.sub doesn't process backslash escapes
# in our string — we need literal `\n` in the `tr "," "\n"` argument to reach
# the shell, and a string-form replacement would turn it into a real newline.
def _patch(m):
return m.group(1) + (
'\telif [ -n "$CUSTOM_REFRESH_RATES" ] && gamescope_has_option "--nested-refresh"; then # DECKSHIFT-NESTED-REFRESH-FALLBACK\n'
'\t\t_deckshift_rate=$(echo "$CUSTOM_REFRESH_RATES" | tr "," "\\n" | sort -nr | head -1)\n'
'\t\tCUSTOM_REFRESH_RATES_OPTION="--nested-refresh $_deckshift_rate"\n'
) + m.group(2)
new = pattern.sub(_patch, content, count=1)
if new == content:
sys.stderr.write("could not locate CUSTOM_REFRESH_RATES_OPTION block in expected shape\n")
sys.exit(1)
with open(dst, "w") as f:
f.write(new)
PY
then
warn "Could not patch $gsp — upstream may have changed shape"
warn "Refresh-rate selection in the TUI will continue to be a no-op until this is resolved"
rm -f "$tmp"
return 1
fi
if ! grep -q "DECKSHIFT-NESTED-REFRESH-FALLBACK" "$tmp"; then
warn "Patch produced output but marker is missing — aborting install"
rm -f "$tmp"
return 1
fi
sudo install -m 0755 "$tmp" "$gsp"
rm -f "$tmp"
info "Patched $gsp — CUSTOM_REFRESH_RATES now reaches gamescope via --nested-refresh"
}
detect_session_backend() {
case "${DECKSHIFT_SESSION_BACKEND}" in
sddm|dms-greeter-manual)
printf '%s\n' "$DECKSHIFT_SESSION_BACKEND"
return 0
;;
auto)
;;
*)
die "Unsupported DECKSHIFT_SESSION_BACKEND: $DECKSHIFT_SESSION_BACKEND"
;;
esac
local display_manager=""
if [[ -L /etc/systemd/system/display-manager.service ]]; then
display_manager=$(basename "$(readlink -f /etc/systemd/system/display-manager.service)" 2>/dev/null || true)
fi
if [[ "$display_manager" == "greetd.service" ]] &&
[[ -r /etc/greetd/config.toml ]] &&
grep -q 'dms-greeter' /etc/greetd/config.toml; then
printf '%s\n' "dms-greeter-manual"
return 0
fi
printf '%s\n' "sddm"
}
read_installed_session_backend() {
local conf="/etc/deckshift/session-backend.conf"
if [[ -r "$conf" ]]; then
local installed_backend
installed_backend=$(sed -n 's/^DECKSHIFT_SESSION_BACKEND=//p' "$conf" 2>/dev/null | head -1)
case "$installed_backend" in
sddm|dms-greeter-manual)
printf '%s\n' "$installed_backend"
return 0
;;
esac
fi
detect_session_backend
}
setup_session_switching() {
echo ""
echo "================================================================"
@@ -1349,6 +1518,10 @@ setup_session_switching() {
local current_user="${SUDO_USER:-$USER}"
local user_home
user_home=$(eval echo "~$current_user")
local session_backend
session_backend=$(detect_session_backend)
local gaming_session_path="/usr/share/wayland-sessions/${DECKSHIFT_GAMING_SESSION_ID}"
info "Using session backend: $session_backend"
# GPU detection only — the installer no longer chooses a monitor, resolution
# or refresh rate. Those are user choices, made later via Walker → "DeckShift
@@ -1533,6 +1706,11 @@ setup_session_switching() {
info "ChimeraOS gamescope-session packages already installed (correct -git versions)"
fi
# Patch the installed gamescope-session-plus to add --nested-refresh
# fallback. Runs on every install so AUR upgrades that overwrite the file
# get re-patched the next time the user re-runs ./deckshift.sh.
patch_gamescope_session_plus
# NetworkManager Integration
#
# Omarchy uses iwd (Intel Wireless Daemon) for WiFi, but Steam requires
@@ -1873,6 +2051,24 @@ UDISKS_POLKIT
mkdir -p "$env_dir"
touch "$gamescope_conf"
# Legacy CUSTOM_REFRESH_RATES migration (added in v0.1.9).
#
# Pre-v0.1.8 the Settings TUI wrote CUSTOM_REFRESH_RATES as a single rate
# (e.g. "165"). Gamescope's --custom-refresh-rates is a list of *switchable*
# rates, not a launch-rate selector — and with no safe 60 Hz fallback in the
# list, some DRM/NVIDIA paths drop to the EDID-preferred 60 Hz on first
# launch. v0.1.8+ writes a comma list (e.g. "60,165") from the TUI, but
# existing users still have the scalar in their conf. Rewrite it here so
# re-running the installer is enough to fix Gaming Mode for them — no need
# to re-open the TUI and re-pick the rate.
local legacy_rate
legacy_rate=$(grep -E '^CUSTOM_REFRESH_RATES=[0-9]+$' "$gamescope_conf" | head -1 | cut -d= -f2)
if [[ -n "$legacy_rate" && "$legacy_rate" != "60" ]]; then
info "Migrating CUSTOM_REFRESH_RATES=${legacy_rate} → 60,${legacy_rate} (v0.1.9 format)"
sed -i "s|^CUSTOM_REFRESH_RATES=.*|CUSTOM_REFRESH_RATES=60,${legacy_rate}|" "$gamescope_conf"
systemctl --user import-environment CUSTOM_REFRESH_RATES 2>/dev/null || true
fi
# Per-key updater — replaces in place if present, appends if missing.
# Same shape as the TUI's flush_pending so the two never fight.
set_conf_key() {
@@ -2140,14 +2336,13 @@ NM_WRAPPER
sudo chmod +x "$nm_wrapper"
info "Created $nm_wrapper"
# SDDM Session Entry
# Gaming Session Entry
#
# SDDM (the login/display manager) needs a .desktop file to know about
# Gaming Mode as a session option. This is what tells SDDM "when you
# auto-login to the gaming session, run this script." It's placed in
# /usr/share/wayland-sessions/ alongside the normal Hyprland session.
info "Creating SDDM session entry..."
local session_desktop="/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop"
# Display managers and greeters need a .desktop file to know about Gaming
# Mode as a session option. The active session backend selects this entry
# when switching into Gaming Mode.
info "Creating Gaming Mode session entry..."
local session_desktop="$gaming_session_path"
sudo tee "$session_desktop" > /dev/null << 'SESSION_DESKTOP'
[Desktop Entry]
@@ -2165,19 +2360,19 @@ SESSION_DESKTOP
# When you click Steam > Power > "Exit to Desktop" inside Gaming Mode,
# Steam calls /usr/lib/os-session-select. On a real Steam Deck this
# switches to Desktop Mode. Our version does the same thing — it updates
# SDDM to boot back into Hyprland and restarts the display manager.
# selects the desktop session and asks the active backend to transition.
info "Creating session-select script..."
local os_session_select="/usr/lib/os-session-select"
sudo tee "$os_session_select" > /dev/null << 'OS_SESSION_SELECT'
#!/bin/bash
rm -f /tmp/.gaming-session-active
sudo -n /usr/local/bin/gaming-session-switch desktop 2>/dev/null || {
sudo -n /usr/local/bin/deckshift-session-switch desktop 2>/dev/null || {
echo "Warning: Failed to update session config"
}
timeout 5 steam -shutdown 2>/dev/null || true
sleep 1
nohup sudo -n systemctl restart sddm &>/dev/null &
nohup /usr/local/bin/deckshift-session-transition desktop &>/dev/null &
disown
exit 0
OS_SESSION_SELECT
@@ -2190,10 +2385,10 @@ OS_SESSION_SELECT
# This script handles the transition from Desktop to Gaming Mode:
# 1. Masks suspend targets — prevents the system from sleeping when the
# monitor briefly disconnects during the display manager restart
# 2. Updates SDDM config to auto-login to the gaming session
# 2. Selects the gaming session through DeckShift's active backend
# 3. Kills any leftover gamescope processes from a previous session
# 4. Switches to VT2 (virtual terminal) to avoid display conflicts
# 5. Restarts SDDM, which auto-logs into Gaming Mode
# 5. Transitions through SDDM or the configured greeter
info "Creating switch-to-gaming script..."
local switch_script="/usr/local/bin/switch-to-gaming"
@@ -2201,16 +2396,38 @@ OS_SESSION_SELECT
#!/bin/bash
# Inhibit suspend FIRST - prevents suspend when monitor detaches during switch
sudo -n systemctl mask --runtime sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null
sudo -n /usr/local/bin/gaming-session-switch gaming 2>/dev/null || {
sudo -n /usr/local/bin/deckshift-session-switch gaming 2>/dev/null || {
notify-send -u critical -t 3000 "Gaming Mode" "Failed to update session config" 2>/dev/null || true
}
notify-send -u normal -t 2000 "Gaming Mode" "Switching to Gaming Mode..." 2>/dev/null || true
pkill -9 gamescope 2>/dev/null || true
pkill -9 -f gamescope-session 2>/dev/null || true
sleep 1
# Multi-monitor handling — gamescope-session-plus picks an output by env, but
# with two monitors connected it sometimes lands on the wrong one (or refuses
# to start). If OUTPUT_CONNECTOR_TO_DISABLE is set in the user's env conf,
# disable those connectors via hyprctl while Hyprland is still alive so
# gamescope only sees the gaming display. The disable is runtime-only (no
# config edit) so when the user returns from Gaming Mode the new Hyprland
# reads its static config fresh and the monitor comes back automatically.
ENV_CONF="$HOME/.config/environment.d/gamescope-session-plus.conf"
if [[ -f "$ENV_CONF" ]]; then
TO_DISABLE=$(awk -F= '$1=="OUTPUT_CONNECTOR_TO_DISABLE" { sub(/^[^=]*=/,""); v=$0 } END { print v }' "$ENV_CONF")
if [[ -n "$TO_DISABLE" ]]; then
IFS=',' read -ra DISABLE_LIST <<< "$TO_DISABLE"
for conn in "${DISABLE_LIST[@]}"; do
conn="${conn// /}"
[[ -z "$conn" ]] && continue
hyprctl keyword monitor "${conn},disable" 2>/dev/null || true
done
sleep 0.5
fi
fi
sudo -n chvt 2 2>/dev/null || true
sleep 0.3
sudo -n systemctl restart sddm
/usr/local/bin/deckshift-session-transition gaming
SWITCH_SCRIPT
sudo chmod +x "$switch_script"
@@ -2223,8 +2440,8 @@ SWITCH_SCRIPT
# 2. Restores Bluetooth (disabled during gaming to reduce interference)
# 3. Gracefully shuts down Steam (timeout 5s, then force kill)
# 4. Kills gamescope with SIGTERM first, then SIGKILL if it won't die
# 5. Updates SDDM config back to Hyprland session
# 6. Restarts SDDM, which auto-logs into Desktop Mode
# 5. Selects the desktop session through DeckShift's active backend
# 6. Transitions through SDDM or the configured greeter
info "Creating switch-to-desktop script..."
local switch_desktop_script="/usr/local/bin/switch-to-desktop"
@@ -2262,7 +2479,7 @@ fi
sudo -n systemctl unmask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null
sudo -n systemctl unmask --runtime sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null
sudo -n systemctl daemon-reload 2>/dev/null
sudo -n /usr/local/bin/gaming-session-switch desktop 2>/dev/null || true
sudo -n /usr/local/bin/deckshift-session-switch desktop 2>/dev/null || true
# Re-enable Bluetooth
sudo -n /usr/bin/rfkill unblock bluetooth 2>/dev/null || true
@@ -2298,9 +2515,10 @@ sudo -n chvt 2 2>/dev/null || true
sleep 0.5
# Atomic restart — stop+start (with stop and start as separate sudo calls)
# was unreliable: stop/start aren't NOPASSWD-allowed individually (only
# `restart` is), and the disowned `start` could be killed by session teardown
# before SDDM actually came back up, leaving the user on a black screen.
sudo -n systemctl restart sddm
# `restart` is for SDDM), and the disowned `start` could be killed by session
# teardown before the display manager actually came back up, leaving the user
# on a black screen.
/usr/local/bin/deckshift-session-transition desktop
exit 0
SWITCH_DESKTOP
@@ -2494,21 +2712,14 @@ HYPR_PORTAL
warn "Add manually: exec-once = /usr/local/bin/deckshift-portal-recovery"
fi
# SDDM Session Switching Config
# Session Backend Config
#
# SDDM supports auto-login — it can automatically log in a user to a
# specific session without showing the login screen. This config file
# controls WHICH session SDDM auto-logs into.
#
# The switching mechanism works by editing this file:
# - "Session=hyprland-uwsm" → boots into Desktop Mode
# - "Session=gamescope-session-steam-nm" → boots into Gaming Mode
#
# The gaming-session-switch helper script toggles this value, then SDDM
# is restarted to pick up the change. The "zz-" prefix ensures this
# config loads LAST and overrides any other SDDM autologin settings.
info "Creating SDDM session switching config..."
local sddm_gaming_conf="/etc/sddm.conf.d/zz-gaming-session.conf"
# Keep display-manager-specific behavior behind small helpers so DeckShift's
# Gamescope, Steam, GPU, and performance logic can stay backend-agnostic.
info "Creating session backend config..."
local deckshift_config_dir="/etc/deckshift"
local deckshift_backend_conf="${deckshift_config_dir}/session-backend.conf"
sudo install -d -m 0755 "$deckshift_config_dir"
local autologin_user="$current_user"
if [[ -f /etc/sddm.conf.d/autologin.conf ]]; then
@@ -2516,49 +2727,198 @@ HYPR_PORTAL
[[ -z "$autologin_user" ]] && autologin_user="$current_user"
fi
sudo tee "$sddm_gaming_conf" > /dev/null << SDDM_GAMING
sudo tee "$deckshift_backend_conf" > /dev/null << BACKEND_CONF
DECKSHIFT_SESSION_BACKEND=${session_backend}
DECKSHIFT_DESKTOP_SESSION_ID=${DECKSHIFT_DESKTOP_SESSION_ID}
DECKSHIFT_GAMING_SESSION_ID=${DECKSHIFT_GAMING_SESSION_ID}
DECKSHIFT_GAMING_SESSION_PATH=${gaming_session_path}
DECKSHIFT_SDDM_CONF=/etc/sddm.conf.d/zz-gaming-session.conf
DECKSHIFT_SDDM_USER=${autologin_user}
DECKSHIFT_DMS_MEMORY_FILE=/var/cache/dms-greeter/.local/state/memory.json
DECKSHIFT_DMS_GREETER_GROUP=greeter
BACKEND_CONF
sudo chmod 0644 "$deckshift_backend_conf"
info "Created $deckshift_backend_conf"
if [[ "$session_backend" == "sddm" ]]; then
info "Creating SDDM session switching config..."
local sddm_gaming_conf="/etc/sddm.conf.d/zz-gaming-session.conf"
sudo install -d -m 0755 /etc/sddm.conf.d
sudo tee "$sddm_gaming_conf" > /dev/null << SDDM_GAMING
[Autologin]
User=${autologin_user}
Session=hyprland-uwsm
Session=${DECKSHIFT_DESKTOP_SESSION_ID%.desktop}
Relogin=true
SDDM_GAMING
info "Created $sddm_gaming_conf"
else
info "DMS greeter backend selected; SDDM autologin config will not be created"
fi
info "Created $sddm_gaming_conf"
info "Creating session switching helper script..."
local session_helper="/usr/local/bin/gaming-session-switch"
info "Creating session switching helper scripts..."
local session_helper="/usr/local/bin/deckshift-session-switch"
sudo tee "$session_helper" > /dev/null << 'SESSION_HELPER'
#!/bin/bash
CONF="/etc/sddm.conf.d/zz-gaming-session.conf"
set -euo pipefail
CONF="/etc/deckshift/session-backend.conf"
if [[ ! -f "$CONF" ]]; then
echo "Error: Config file not found: $CONF" >&2
exit 1
fi
case "$1" in
gaming)
sed -i 's/^Session=.*/Session=gamescope-session-steam-nm/' "$CONF"
echo "Session set to: gaming mode"
;;
desktop)
sed -i 's/^Session=.*/Session=hyprland-uwsm/' "$CONF"
echo "Session set to: desktop mode"
;;
# shellcheck disable=SC1090
source "$CONF"
target="${1:-}"
case "$target" in
gaming|desktop) ;;
*)
echo "Usage: $0 {gaming|desktop}" >&2
exit 1
;;
esac
session_id_for_target() {
case "$1" in
gaming) printf '%s\n' "$DECKSHIFT_GAMING_SESSION_ID" ;;
desktop) printf '%s\n' "$DECKSHIFT_DESKTOP_SESSION_ID" ;;
esac
}
resolve_session_path() {
local session_id="$1"
local explicit=""
if [[ "$session_id" == "$DECKSHIFT_GAMING_SESSION_ID" ]]; then
explicit="${DECKSHIFT_GAMING_SESSION_PATH:-}"
fi
if [[ -n "$explicit" && -f "$explicit" ]]; then
printf '%s\n' "$explicit"
return 0
fi
local dir candidate
for dir in /usr/local/share/wayland-sessions /usr/share/wayland-sessions; do
candidate="${dir}/${session_id}"
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
echo "Error: session file not found for ${session_id}" >&2
exit 1
}
write_dms_memory() {
local session_id="$1"
local session_path="$2"
local memory_file="${DECKSHIFT_DMS_MEMORY_FILE:-/var/cache/dms-greeter/.local/state/memory.json}"
local tmp
tmp=$(mktemp)
python3 - "$memory_file" "$session_path" "$session_id" > "$tmp" <<'PY'
import json
import os
import sys
path, session_path, session_id = sys.argv[1:4]
data = {}
try:
if os.path.exists(path):
with open(path) as fh:
loaded = json.load(fh)
if isinstance(loaded, dict):
data = loaded
except Exception:
data = {}
data["lastSessionId"] = session_path
data["lastSessionDesktopId"] = session_id
json.dump(data, sys.stdout, indent=2)
sys.stdout.write("\n")
PY
install -d -m 0775 "$(dirname "$memory_file")"
install -m 0664 "$tmp" "$memory_file"
rm -f "$tmp"
if getent group "${DECKSHIFT_DMS_GREETER_GROUP:-greeter}" >/dev/null 2>&1; then
chgrp "${DECKSHIFT_DMS_GREETER_GROUP:-greeter}" "$(dirname "$memory_file")" "$memory_file" || true
fi
}
session_id="$(session_id_for_target "$target")"
case "${DECKSHIFT_SESSION_BACKEND:-sddm}" in
sddm)
sddm_conf="${DECKSHIFT_SDDM_CONF:-/etc/sddm.conf.d/zz-gaming-session.conf}"
if [[ ! -f "$sddm_conf" ]]; then
echo "Error: SDDM config file not found: $sddm_conf" >&2
exit 1
fi
sed -i "s/^Session=.*/Session=${session_id%.desktop}/" "$sddm_conf"
;;
dms-greeter-manual)
session_path="$(resolve_session_path "$session_id")"
write_dms_memory "$session_id" "$session_path"
;;
*)
echo "Error: unsupported DeckShift session backend: ${DECKSHIFT_SESSION_BACKEND:-}" >&2
exit 1
;;
esac
echo "Session set to: ${target} mode"
SESSION_HELPER
sudo chmod +x "$session_helper"
info "Created $session_helper"
local legacy_session_helper="/usr/local/bin/gaming-session-switch"
sudo tee "$legacy_session_helper" > /dev/null << 'LEGACY_SESSION_HELPER'
#!/bin/bash
exec /usr/local/bin/deckshift-session-switch "$@"
LEGACY_SESSION_HELPER
sudo chmod +x "$legacy_session_helper"
info "Created compatibility wrapper $legacy_session_helper"
local transition_helper="/usr/local/bin/deckshift-session-transition"
sudo tee "$transition_helper" > /dev/null << 'TRANSITION_HELPER'
#!/bin/bash
set -euo pipefail
CONF="/etc/deckshift/session-backend.conf"
[[ -f "$CONF" ]] || { echo "Error: Config file not found: $CONF" >&2; exit 1; }
# shellcheck disable=SC1090
source "$CONF"
case "${DECKSHIFT_SESSION_BACKEND:-sddm}" in
sddm)
exec sudo -n systemctl restart sddm
;;
dms-greeter-manual)
if [[ -n "${XDG_SESSION_ID:-}" ]]; then
exec loginctl terminate-session "$XDG_SESSION_ID"
fi
exec sudo -n systemctl restart greetd.service
;;
*)
echo "Error: unsupported DeckShift session backend: ${DECKSHIFT_SESSION_BACKEND:-}" >&2
exit 1
;;
esac
TRANSITION_HELPER
sudo chmod +x "$transition_helper"
info "Created $transition_helper"
# Sudoers Rules for Session Switching
#
# The session switching scripts need to run several commands as root
# (restart SDDM, start/stop NetworkManager, control Bluetooth, etc.).
# (restart display managers, start/stop NetworkManager, control Bluetooth, etc.).
# These sudoers rules allow members of the "video" and "wheel" groups
# to run ONLY these specific commands without a password.
#
@@ -2577,7 +2937,10 @@ SESSION_HELPER
local switch_output
switch_output=$(sudo tee "$sudoers_session" << 'SUDOERS_SWITCH' 2>&1
%video ALL=(ALL) NOPASSWD: /usr/local/bin/gaming-session-switch
%video ALL=(ALL) NOPASSWD: /usr/local/bin/deckshift-session-switch
%video ALL=(ALL) NOPASSWD: /usr/local/bin/deckshift-session-transition
%video ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart sddm
%video ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart greetd.service
%video ALL=(ALL) NOPASSWD: /usr/bin/chvt
%video ALL=(ALL) NOPASSWD: /usr/bin/systemctl mask --runtime sleep.target suspend.target hibernate.target hybrid-sleep.target
%video ALL=(ALL) NOPASSWD: /usr/bin/systemctl unmask sleep.target suspend.target hibernate.target hybrid-sleep.target
@@ -2679,10 +3042,13 @@ HYPR_GAMING
echo " Files created/modified:"
echo " - ~/.config/environment.d/gamescope-session-plus.conf"
echo " - /usr/local/bin/gamescope-session-nm-wrapper"
echo " - /usr/share/wayland-sessions/gamescope-session-steam-nm.desktop"
echo " - ${gaming_session_path}"
echo " - /usr/lib/os-session-select"
echo " - /usr/local/bin/switch-to-gaming"
echo " - /usr/local/bin/switch-to-desktop"
echo " - /usr/local/bin/deckshift-session-switch"
echo " - /usr/local/bin/deckshift-session-transition"
echo " - /etc/deckshift/session-backend.conf (${session_backend})"
echo " - /usr/local/bin/gaming-keybind-monitor (Super+Shift+R)"
echo " - ~/.config/hypr/bindings.conf (keybind added)"
echo ""
@@ -2743,10 +3109,14 @@ verify_installation() {
local all_ok=true
local missing_files=()
local permission_issues=()
local verify_session_backend
verify_session_backend=$(read_installed_session_backend)
declare -A expected_files=(
["/usr/local/bin/gamescope-session-nm-wrapper"]="755:ChimeraOS session with NM wrapper"
["/usr/local/lib/gamescope-nvidia/gamescope"]="755:NVIDIA gamescope wrapper (--force-composition)"
["/usr/local/bin/deckshift-session-switch"]="755:Backend-neutral session selector"
["/usr/local/bin/deckshift-session-transition"]="755:Backend-neutral display-manager transition helper"
["/usr/local/bin/gaming-session-switch"]="755:Session switching helper (gaming/desktop)"
["/usr/lib/os-session-select"]="755:Steam Exit to Desktop handler"
["/usr/local/bin/switch-to-gaming"]="755:Hyprland to Gaming Mode switcher"
@@ -2760,8 +3130,9 @@ verify_installation() {
["/usr/bin/steamos-update"]="755:Steam compatibility (from AUR package)"
["/usr/bin/jupiter-biosupdate"]="755:Steam compatibility (from AUR package)"
["/usr/bin/steamos-select-branch"]="755:Steam compatibility (from AUR package)"
["/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop"]="644:SDDM session entry"
["/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop"]="644:Gaming Mode Wayland session entry"
["/usr/share/gamescope-session-plus/gamescope-session-plus"]="755:ChimeraOS session launcher (from AUR)"
["/etc/deckshift/session-backend.conf"]="644:Session backend config"
["/etc/sddm.conf.d/zz-gaming-session.conf"]="644:SDDM session switching config"
["/etc/polkit-1/rules.d/50-gamescope-networkmanager.rules"]="644:Polkit NM rules"
["/etc/polkit-1/rules.d/50-udisks-gaming.rules"]="644:Polkit udisks2 rules (external drive mount)"
@@ -2775,6 +3146,7 @@ verify_installation() {
["/etc/environment.d/99-shader-cache.conf"]="644:Shader cache config"
["/usr/local/bin/deckshift-settings"]="755:Gaming Mode settings TUI"
["/usr/share/applications/deckshift-settings.desktop"]="644:Walker launcher for settings TUI"
["/usr/share/libalpm/hooks/deckshift-gamescope-cap.hook"]="644:Pacman hook re-applies cap_sys_nice on gamescope upgrade (optional)"
)
echo " FILE STATUS:"
echo " ------------"
@@ -2785,7 +3157,16 @@ verify_installation() {
local description="${expected_files[$file]#*:}"
local is_optional=false
[[ "$description" == *"(optional)"* ]] && is_optional=true
case "$file" in
/etc/sddm.conf.d/zz-gaming-session.conf)
[[ "$verify_session_backend" == "dms-greeter-manual" ]] && is_optional=true
;;
/etc/NetworkManager/conf.d/10-iwd-backend.conf|\
/etc/NetworkManager/conf.d/20-unmanaged-systemd.conf|\
/usr/share/libalpm/hooks/deckshift-gamescope-cap.hook)
is_optional=true
;;
esac
if sudo test -f "$file" 2>/dev/null; then
local actual_perm
@@ -2944,6 +3325,48 @@ verify_installation() {
echo " systemd-networkd: $(systemctl is-active systemd-networkd.service 2>/dev/null || echo 'inactive')"
echo " polkit: $(systemctl is-active polkit.service 2>/dev/null || echo 'inactive')"
if [[ "$verify_session_backend" == "dms-greeter-manual" ]]; then
echo ""
echo " DMS GREETER BACKEND:"
echo " --------------------"
local dms_memory_file="/var/cache/dms-greeter/.local/state/memory.json"
if [[ -r /etc/deckshift/session-backend.conf ]]; then
local configured_memory_file
configured_memory_file=$(sed -n 's/^DECKSHIFT_DMS_MEMORY_FILE=//p' /etc/deckshift/session-backend.conf 2>/dev/null | head -1)
[[ -n "$configured_memory_file" ]] && dms_memory_file="$configured_memory_file"
fi
echo " Backend: $verify_session_backend"
echo " Memory file: $dms_memory_file"
if [[ -r "$dms_memory_file" ]]; then
local memory_stat
memory_stat=$(stat -c "%U:%G %a" "$dms_memory_file" 2>/dev/null || echo "unknown")
echo " ✓ DMS memory readable ($memory_stat)"
if python3 - "$dms_memory_file" <<'PY'
import json
import sys
with open(sys.argv[1]) as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise SystemExit(1)
for key in ("lastSessionId", "lastSessionDesktopId"):
value = data.get(key)
if not isinstance(value, str) or not value:
raise SystemExit(1)
PY
then
echo " ✓ DMS memory has lastSessionId and lastSessionDesktopId"
else
echo " ✗ DMS memory JSON is missing expected session keys"
all_ok=false
fi
else
echo " ✗ DMS memory file is not readable"
all_ok=false
fi
fi
echo ""
echo " SUDO PERMISSIONS TEST:"
echo " ----------------------"
+423
View File
@@ -0,0 +1,423 @@
# DeckShift Session Backend Handoff
Last updated: 2026-07-11.
## Goal
Extend DeckShift so its Steam Deck-style gaming mode is not hard-wired to SDDM.
The first target is to preserve the existing SDDM behavior for upstream/Omarchy
users while adding a DMS/greetd manual backend for our Hyprland/DMS dotfiles.
The long-term goal is:
- `sddm`: existing seamless auto-login flow for Omarchy users.
- `dms-greeter-manual`: select the next session through DMS greeter memory and
require normal greeter authentication.
- Later: a host-gated one-shot auto-login backend for living-room machines, if
it can be made safe and recoverable.
## Repository State
Local checkout:
```text
/home/anodyine/Projects/deckshift
```
Current work branch:
```text
feature/session-backends
```
Remotes:
```text
origin ssh://git@tea.kyle-merritt.com:2222/anodyine/deckshift.git
github https://github.com/Anodyine/deckshift.git
upstream https://github.com/28allday/deckshift.git
```
`origin` is the Gitea working remote. `github` is the public fork. `upstream`
is fetch-only for the original DeckShift project.
## What Changed So Far
All code changes so far are in:
```text
deckshift.sh
README.md
```
### Installer Configuration
`deckshift.sh` now accepts these variables:
```sh
DECKSHIFT_SESSION_BACKEND=auto
DECKSHIFT_DESKTOP_SESSION_ID=hyprland-uwsm.desktop
DECKSHIFT_GAMING_SESSION_ID=gamescope-session-steam-nm.desktop
```
Supported backend values right now:
```text
auto
sddm
dms-greeter-manual
```
`auto` detects DMS/greetd when:
- `/etc/systemd/system/display-manager.service` resolves to `greetd.service`.
- `/etc/greetd/config.toml` contains `dms-greeter`.
Otherwise it falls back to `sddm`.
### Generated Backend Config
The installer writes:
```text
/etc/deckshift/session-backend.conf
```
It records the active backend, desktop/gaming session IDs, the Gamescope session
file path, SDDM config path, and DMS greeter memory path.
### Generated Helpers
The old SDDM-specific calls are now routed through backend-neutral helpers:
```text
/usr/local/bin/deckshift-session-switch
/usr/local/bin/deckshift-session-transition
```
`/usr/local/bin/gaming-session-switch` is still generated as a compatibility
wrapper that execs `deckshift-session-switch`.
Existing generated scripts now call the backend-neutral helpers:
```text
/usr/lib/os-session-select
/usr/local/bin/switch-to-gaming
/usr/local/bin/switch-to-desktop
```
### SDDM Backend Behavior
The SDDM backend preserves the previous model:
1. Write or update `/etc/sddm.conf.d/zz-gaming-session.conf`.
2. `deckshift-session-switch gaming|desktop` changes `Session=...`.
3. `deckshift-session-transition` runs `sudo -n systemctl restart sddm`.
This should be behaviorally equivalent to the original DeckShift flow.
### DMS Manual Backend Behavior
The DMS manual backend does not create SDDM config.
`deckshift-session-switch gaming|desktop`:
1. Resolves the target session file from an allowlist:
- desktop: `DECKSHIFT_DESKTOP_SESSION_ID`
- gaming: `DECKSHIFT_GAMING_SESSION_ID`
2. Looks in:
- `/usr/local/share/wayland-sessions`
- `/usr/share/wayland-sessions`
3. Writes DMS greeter memory:
```text
/var/cache/dms-greeter/.local/state/memory.json
```
4. Preserves any existing JSON keys and updates:
- `lastSessionId`
- `lastSessionDesktopId`
`deckshift-session-transition`:
1. If `XDG_SESSION_ID` exists, runs:
```sh
loginctl terminate-session "$XDG_SESSION_ID"
```
2. If there is no current session ID, falls back to:
```sh
sudo -n systemctl restart greetd.service
```
This intentionally returns the user to the DMS greeter for normal
authentication. It is not a one-shot auto-login implementation.
### Sudoers
The generated sudoers file now includes NOPASSWD entries for:
```text
/usr/local/bin/deckshift-session-switch
/usr/local/bin/deckshift-session-transition
/usr/bin/systemctl restart sddm
/usr/bin/systemctl restart greetd.service
```
The existing compatibility and NetworkManager/Bluetooth rules remain.
### README
The README now documents:
- The experimental DMS/greetd manual backend.
- The new backend config and helper files.
- The backend-neutral session switching flow.
- Updated uninstall cleanup for `/etc/deckshift` and the new helper scripts.
## Validation Already Run
These checks passed:
```sh
bash -n deckshift.sh
git diff --check
```
The generated heredoc scripts were also extracted and syntax-checked with
`bash -n`:
- `deckshift-session-switch`
- `deckshift-session-transition`
- `switch-to-gaming`
- `switch-to-desktop`
- `os-session-select`
- `gaming-session-switch` compatibility wrapper
`shellcheck` was not installed on this machine, so it has not been run yet.
No installer run has been performed. No live `/etc`, `/usr/local/bin`, greeter,
or display-manager state was changed by this work.
### DMS Greeter Selection Probe
After the first code pass, a manual DMS greeter selection test was run outside
this checkout. The greeter selected the expected `Deckshift dms test` session.
That confirms the core DMS manual backend assumption still holds: writing DMS
greeter memory can preselect a non-SDDM session for the next login. This does
not yet prove the full DeckShift-generated helper flow or Gamescope session
entry, but it is the right green light for implementing and testing the manual
DMS backend path before any auto-login work.
### DMS Generated Helper Probe
The generated helper path was then tested manually:
```sh
sudo -n /usr/local/bin/deckshift-session-switch gaming
/usr/local/bin/deckshift-session-transition gaming
```
It behaved as expected: the current session exited, DMS greeter appeared, and
the target gaming session was preselected.
This proves the first real DMS manual backend flow at the DeckShift helper
layer. Remaining DMS validation should focus on the full `switch-to-gaming`
script, Gamescope/Steam session startup, and the return path back to the
Hyprland UWSM desktop session.
### DMS Manual Round Trip
The DMS manual backend was also tested in both directions. It successfully
switched the greeter selection away from Hyprland UWSM and then back to the
Hyprland UWSM session. The user was able to log back into the UWSM desktop.
This proves the non-SDDM manual session-selection loop:
```text
Hyprland UWSM -> selected gaming/test session -> DMS greeter
DMS greeter/session -> selected Hyprland UWSM -> DMS greeter -> Hyprland UWSM
```
The next layer to test is no longer DMS memory/session preselection. It is the
real DeckShift gaming session: `switch-to-gaming`, Gamescope/Steam startup, and
`switch-to-desktop` return behavior.
## Important Constraints
Do not implement one-shot DMS auto-login yet. Manual DMS switching is the first
safe milestone.
Do not make this dotfiles-specific inside DeckShift. The DeckShift fork should
grow generic backend seams; the dotfiles repo can decide whether and how to
install/use the DMS backend.
Keep the SDDM behavior stable for existing users. The backend abstraction should
be boring and low-risk for the default Omarchy path.
## Known Gaps And Risks
### Not Tested Live
The backend helpers have only been syntax-checked. They still need live testing
on:
- An SDDM/Omarchy machine or VM.
- A DMS/greetd machine or VM.
- A real target host with Gamescope/Steam.
### DMS Memory Contract
The DMS greeter memory write is based on a local proof from the dotfiles repo:
writing `lastSessionId` and `lastSessionDesktopId` successfully preselected a
test session in DMS greeter.
Still verify:
- Ownership and permissions after DeckShift writes the file.
- Whether DMS ever overwrites the file during greeter startup.
- Whether preserving extra JSON keys is sufficient across DMS versions.
### Transition Semantics
For DMS manual mode, `loginctl terminate-session "$XDG_SESSION_ID"` is preferred.
The fallback `systemctl restart greetd.service` is more disruptive and should be
treated as a fallback only.
Verify whether `switch-to-desktop` from inside Gamescope has a valid
`XDG_SESSION_ID`. If not, returning from gaming may restart greetd.
### Session File Location
The installer currently writes the gaming session to:
```text
/usr/share/wayland-sessions/gamescope-session-steam-nm.desktop
```
For our dotfiles, `/usr/local/share/wayland-sessions` may be a better long-term
owner boundary. Decide whether DeckShift should make this configurable or leave
the current upstream-compatible path alone.
### Verification Mode
`verify_installation` still treats `/etc/sddm.conf.d/zz-gaming-session.conf` as
optional by description only. The verification logic marks missing optional
files by checking whether the description contains `(optional)`, so this may
need adjustment if verifying a DMS backend install.
### Documentation Still Has Historical SDDM Mentions
Some README changelog and troubleshooting sections still mention SDDM because
they describe older releases or SDDM-specific recovery. That is probably fine,
but before publishing this branch upstream, review the README for places where
current behavior should say "session backend" instead.
## Recommended Next Steps
1. Install `shellcheck` on the dev machine or run it in a container/VM:
```sh
shellcheck deckshift.sh
shellcheck bin/deckshift-settings
```
2. Test SDDM backend generation without changing behavior:
```sh
DECKSHIFT_SESSION_BACKEND=sddm ./deckshift.sh
```
Verify:
- `/etc/deckshift/session-backend.conf`
- `/etc/sddm.conf.d/zz-gaming-session.conf`
- `/usr/local/bin/deckshift-session-switch`
- `/usr/local/bin/deckshift-session-transition`
- Existing Super+Shift+S / Super+Shift+R behavior.
3. Test DMS backend in the CachyOS/DMS VM or sacrificial host:
```sh
DECKSHIFT_SESSION_BACKEND=dms-greeter-manual ./deckshift.sh
```
Verify before switching:
- Gaming session appears in the DMS greeter session list.
- `deckshift-session-switch gaming` writes the expected DMS memory.
- `deckshift-session-switch desktop` restores the Hyprland UWSM session.
- File ownership/group/mode allow DMS greeter to read the memory file.
4. Test DMS manual transition without Steam first:
```sh
sudo -n /usr/local/bin/deckshift-session-switch gaming
/usr/local/bin/deckshift-session-transition gaming
```
Expected result:
- Current session exits.
- DMS greeter appears.
- Gaming session is preselected.
- User must authenticate manually.
5. Test return path:
```sh
sudo -n /usr/local/bin/deckshift-session-switch desktop
/usr/local/bin/deckshift-session-transition desktop
```
Expected result:
- Current session exits.
- DMS greeter appears.
- Hyprland UWSM is preselected.
6. Only after manual DMS mode works, test full Gamescope/Steam entry and return.
7. After both SDDM and DMS manual mode are proven, split large generated helper
bodies into clearer functions or files if desired. Avoid refactoring before
the behavior is proven.
8. Add Gitea issues for follow-up work:
- Prove SDDM backend parity.
- Prove DMS manual backend in VM.
- Fix verification mode for backend-specific expected files.
- Decide session file owner path.
- Prototype one-shot DMS/greetd auto-login separately.
## Recovery Notes For DMS Testing
If a DMS switch leaves the host at the wrong selected session, switch to a TTY
and write DMS memory back to Hyprland UWSM:
```sh
sudo python3 - <<'PY'
import json
path = "/var/cache/dms-greeter/.local/state/memory.json"
data = {}
try:
with open(path) as fh:
data = json.load(fh)
except Exception:
pass
data["lastSessionId"] = "/usr/local/share/wayland-sessions/hyprland-uwsm.desktop"
data["lastSessionDesktopId"] = "hyprland-uwsm.desktop"
with open(path, "w") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
PY
sudo chgrp greeter /var/cache/dms-greeter/.local/state /var/cache/dms-greeter/.local/state/memory.json 2>/dev/null || true
sudo chmod 0664 /var/cache/dms-greeter/.local/state/memory.json
sudo systemctl restart greetd.service
```
Do not rely on `chezmoi apply` as the only recovery path. The failure mode may
be that the graphical desktop is unavailable.